first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
import {
|
||||
createMockLogger,
|
||||
createMockRequest,
|
||||
createMockRequestWithSessionId,
|
||||
createMockRequestWithHeaderSessionId,
|
||||
createMockResponse,
|
||||
createMockTool,
|
||||
createMockTransport,
|
||||
createValidToolCallMessage,
|
||||
createListToolsMessage,
|
||||
createMockServer,
|
||||
MCP_SESSION_ID_HEADER,
|
||||
} from './helpers';
|
||||
import { QueuedExecutionStrategy } from '../execution/QueuedExecutionStrategy';
|
||||
import { McpServer } from '../McpServer';
|
||||
import { InMemorySessionStore } from '../session/InMemorySessionStore';
|
||||
|
||||
describe('McpServer', () => {
|
||||
let mcpServer: McpServer;
|
||||
let mockLogger: ReturnType<typeof createMockLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset singleton for testing
|
||||
(McpServer as unknown as { instance_: McpServer | undefined }).instance_ = undefined;
|
||||
mockLogger = createMockLogger();
|
||||
mcpServer = McpServer.instance(mockLogger);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up singleton
|
||||
(McpServer as unknown as { instance_: McpServer | undefined }).instance_ = undefined;
|
||||
});
|
||||
|
||||
describe('singleton pattern', () => {
|
||||
it('should return same instance for subsequent calls', () => {
|
||||
const instance1 = McpServer.instance(mockLogger);
|
||||
const instance2 = McpServer.instance(mockLogger);
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
|
||||
it('should log debug message when creating singleton', () => {
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith('McpServer created');
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith('Created singleton McpServer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionId', () => {
|
||||
it('should extract sessionId from query parameters', () => {
|
||||
const req = createMockRequestWithSessionId('session-123', '{}');
|
||||
expect(mcpServer.getSessionId(req)).toBe('session-123');
|
||||
});
|
||||
|
||||
it('should extract sessionId from mcp-session-id header', () => {
|
||||
const req = createMockRequestWithHeaderSessionId('header-session-456');
|
||||
|
||||
expect(mcpServer.getSessionId(req)).toBe('header-session-456');
|
||||
});
|
||||
|
||||
it('should prefer query parameter over header', () => {
|
||||
const req = createMockRequest({
|
||||
query: { sessionId: 'query-session' },
|
||||
headers: { [MCP_SESSION_ID_HEADER]: 'header-session' },
|
||||
});
|
||||
|
||||
expect(mcpServer.getSessionId(req)).toBe('query-session');
|
||||
});
|
||||
|
||||
it('should return undefined when no sessionId present', () => {
|
||||
const req = createMockRequest({ query: {}, headers: {} });
|
||||
|
||||
expect(mcpServer.getSessionId(req)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMcpMetadata', () => {
|
||||
it('should extract sessionId and messageId from request', () => {
|
||||
const rawBody = '{"jsonrpc":"2.0","id":"msg-123","method":"test"}';
|
||||
const req = createMockRequestWithSessionId('session-1', rawBody);
|
||||
|
||||
const metadata = mcpServer.getMcpMetadata(req);
|
||||
|
||||
expect(metadata).toEqual({
|
||||
sessionId: 'session-1',
|
||||
messageId: 'msg-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty messageId when not present in body', () => {
|
||||
const rawBody = '{"jsonrpc":"2.0","method":"notification"}';
|
||||
const req = createMockRequestWithSessionId('session-1', rawBody);
|
||||
|
||||
const metadata = mcpServer.getMcpMetadata(req);
|
||||
|
||||
expect(metadata).toEqual({
|
||||
sessionId: 'session-1',
|
||||
messageId: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when no sessionId', () => {
|
||||
const req = createMockRequest({ query: {}, headers: {} });
|
||||
|
||||
expect(mcpServer.getMcpMetadata(req)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePostMessage', () => {
|
||||
it('should return 401 when no transport found for session', async () => {
|
||||
const response = createMockResponse();
|
||||
const request = createMockRequestWithSessionId('non-existent', '{}');
|
||||
|
||||
await mcpServer.handlePostMessage(request, response, []);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(401);
|
||||
expect(response.send).toHaveBeenCalledWith('No transport found for sessionId');
|
||||
});
|
||||
|
||||
it('should identify tool call messages', async () => {
|
||||
const response = createMockResponse();
|
||||
const toolCallBody = createValidToolCallMessage('get_weather', { city: 'London' });
|
||||
const request = createMockRequestWithSessionId('non-existent', toolCallBody);
|
||||
|
||||
const result = await mcpServer.handlePostMessage(request, response, []);
|
||||
|
||||
expect(result.wasToolCall).toBe(true);
|
||||
expect(result.toolCallInfo).toEqual({
|
||||
toolName: 'get_weather',
|
||||
arguments: { city: 'London' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should add sourceNodeName from tool metadata', async () => {
|
||||
const response = createMockResponse();
|
||||
const tools = [
|
||||
createMockTool('get_weather', {
|
||||
metadata: { sourceNodeName: 'Weather Node' },
|
||||
}),
|
||||
];
|
||||
const toolCallBody = createValidToolCallMessage('get_weather', { city: 'London' });
|
||||
const request = createMockRequestWithSessionId('non-existent', toolCallBody);
|
||||
|
||||
const result = await mcpServer.handlePostMessage(request, response, tools);
|
||||
|
||||
expect(result.toolCallInfo).toEqual({
|
||||
toolName: 'get_weather',
|
||||
arguments: { city: 'London' },
|
||||
sourceNodeName: 'Weather Node',
|
||||
});
|
||||
});
|
||||
|
||||
it('should identify non-tool-call messages', async () => {
|
||||
const response = createMockResponse();
|
||||
const listToolsBody = createListToolsMessage();
|
||||
const request = createMockRequestWithSessionId('non-existent', listToolsBody);
|
||||
|
||||
const result = await mcpServer.handlePostMessage(request, response, []);
|
||||
|
||||
expect(result.wasToolCall).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDeleteRequest', () => {
|
||||
it('should return 400 when no sessionId provided', async () => {
|
||||
const response = createMockResponse();
|
||||
const request = {
|
||||
query: {},
|
||||
headers: {},
|
||||
rawBody: Buffer.from('{}'),
|
||||
} as unknown as Parameters<typeof mcpServer.handleDeleteRequest>[0];
|
||||
|
||||
await mcpServer.handleDeleteRequest(request, response);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(400);
|
||||
expect(response.send).toHaveBeenCalledWith('No sessionId provided');
|
||||
});
|
||||
|
||||
it('should return 404 when session not found', async () => {
|
||||
const response = createMockResponse();
|
||||
const request = createMockRequestWithSessionId('non-existent', '{}');
|
||||
|
||||
await mcpServer.handleDeleteRequest(request, response);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(404);
|
||||
expect(response.send).toHaveBeenCalledWith('Session not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration', () => {
|
||||
it('should allow setting custom session store', () => {
|
||||
const customStore = new InMemorySessionStore();
|
||||
mcpServer.setSessionStore(customStore);
|
||||
|
||||
// Verify the store is used (indirect test)
|
||||
expect(mcpServer).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow setting execution strategy', () => {
|
||||
const queuedStrategy = new QueuedExecutionStrategy(mcpServer.getPendingCallsManager());
|
||||
mcpServer.setExecutionStrategy(queuedStrategy);
|
||||
|
||||
expect(mcpServer.isQueueMode()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be in queue mode by default', () => {
|
||||
expect(mcpServer.isQueueMode()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pending response management', () => {
|
||||
it('should track and check pending responses', async () => {
|
||||
// First register a session with transport
|
||||
const sessionId = 'test-session';
|
||||
const transport = createMockTransport(sessionId);
|
||||
const server = createMockServer();
|
||||
|
||||
// Access private sessionManager to register session
|
||||
const sessionManager = (
|
||||
mcpServer as unknown as {
|
||||
sessionManager: {
|
||||
registerSession: (s: string, srv: unknown, tr: unknown) => Promise<void>;
|
||||
};
|
||||
}
|
||||
).sessionManager;
|
||||
await sessionManager.registerSession(sessionId, server, transport);
|
||||
|
||||
mcpServer.storePendingResponse(sessionId, 'msg-1');
|
||||
|
||||
expect(mcpServer.hasPendingResponse(sessionId, 'msg-1')).toBe(true);
|
||||
expect(mcpServer.hasPendingResponse(sessionId, 'msg-2')).toBe(false);
|
||||
expect(mcpServer.pendingResponseCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should remove pending responses', async () => {
|
||||
const sessionId = 'test-session';
|
||||
const transport = createMockTransport(sessionId);
|
||||
const server = createMockServer();
|
||||
|
||||
const sessionManager = (
|
||||
mcpServer as unknown as {
|
||||
sessionManager: {
|
||||
registerSession: (s: string, srv: unknown, tr: unknown) => Promise<void>;
|
||||
};
|
||||
}
|
||||
).sessionManager;
|
||||
await sessionManager.registerSession(sessionId, server, transport);
|
||||
|
||||
mcpServer.storePendingResponse(sessionId, 'msg-1');
|
||||
mcpServer.removePendingResponse(sessionId, 'msg-1');
|
||||
|
||||
expect(mcpServer.hasPendingResponse(sessionId, 'msg-1')).toBe(false);
|
||||
expect(mcpServer.pendingResponseCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle pending response without messageId', async () => {
|
||||
const sessionId = 'test-session';
|
||||
const transport = createMockTransport(sessionId);
|
||||
const server = createMockServer();
|
||||
|
||||
const sessionManager = (
|
||||
mcpServer as unknown as {
|
||||
sessionManager: {
|
||||
registerSession: (s: string, srv: unknown, tr: unknown) => Promise<void>;
|
||||
};
|
||||
}
|
||||
).sessionManager;
|
||||
await sessionManager.registerSession(sessionId, server, transport);
|
||||
|
||||
mcpServer.storePendingResponse(sessionId, '');
|
||||
|
||||
expect(mcpServer.hasPendingResponse(sessionId, '')).toBe(true);
|
||||
});
|
||||
|
||||
it('should warn when storing pending response without transport', () => {
|
||||
mcpServer.storePendingResponse('no-transport-session', 'msg-1');
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Cannot store pending response'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTransport', () => {
|
||||
it('should return undefined for unregistered session', () => {
|
||||
expect(mcpServer.getTransport('non-existent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return transport for registered session', async () => {
|
||||
const sessionId = 'test-session';
|
||||
const transport = createMockTransport(sessionId);
|
||||
const server = createMockServer();
|
||||
|
||||
const sessionManager = (
|
||||
mcpServer as unknown as {
|
||||
sessionManager: {
|
||||
registerSession: (s: string, srv: unknown, tr: unknown) => Promise<void>;
|
||||
};
|
||||
}
|
||||
).sessionManager;
|
||||
await sessionManager.registerSession(sessionId, server, transport);
|
||||
|
||||
expect(mcpServer.getTransport(sessionId)).toBe(transport);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTools', () => {
|
||||
it('should return undefined for session without tools', () => {
|
||||
expect(mcpServer.getTools('non-existent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPendingCallsManager', () => {
|
||||
it('should return the pending calls manager', () => {
|
||||
const manager = mcpServer.getPendingCallsManager();
|
||||
expect(manager).toBeDefined();
|
||||
expect(typeof manager.waitForResult).toBe('function');
|
||||
expect(typeof manager.resolve).toBe('function');
|
||||
expect(typeof manager.reject).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleWorkerResponse', () => {
|
||||
it('should handle list tools request marker', async () => {
|
||||
const sessionId = 'test-session';
|
||||
const transport = createMockTransport(sessionId, 'sse');
|
||||
const server = createMockServer();
|
||||
|
||||
const sessionManager = (
|
||||
mcpServer as unknown as {
|
||||
sessionManager: {
|
||||
registerSession: (
|
||||
s: string,
|
||||
srv: unknown,
|
||||
tr: unknown,
|
||||
tools?: unknown[],
|
||||
) => Promise<void>;
|
||||
};
|
||||
}
|
||||
).sessionManager;
|
||||
await sessionManager.registerSession(sessionId, server, transport, [
|
||||
createMockTool('test-tool'),
|
||||
]);
|
||||
|
||||
mcpServer.handleWorkerResponse(sessionId, 'msg-1', { _listToolsRequest: true });
|
||||
|
||||
// Should have attempted to send tools list via transport
|
||||
expect(transport.send).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,424 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { INode, IWebhookFunctions, ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
|
||||
import { createMockLogger, createMockRequest, createMockResponse } from './helpers';
|
||||
import { McpTrigger } from '../McpTrigger.node';
|
||||
import { McpServer } from '../McpServer';
|
||||
|
||||
// Mock the McpServer
|
||||
jest.mock('../McpServer', () => ({
|
||||
McpServer: {
|
||||
instance: jest.fn(),
|
||||
},
|
||||
MCP_LIST_TOOLS_REQUEST_MARKER: 'mcp_list_tools_request',
|
||||
}));
|
||||
|
||||
// Mock webhook utils from nodes-base
|
||||
jest.mock('n8n-nodes-base/dist/nodes/Webhook/utils', () => ({
|
||||
validateWebhookAuthentication: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock getConnectedTools from utils
|
||||
jest.mock('@utils/helpers', () => ({
|
||||
getConnectedTools: jest.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
describe('McpTrigger', () => {
|
||||
let mcpTrigger: McpTrigger;
|
||||
let mockMcpServer: jest.Mocked<McpServer>;
|
||||
let mockContext: jest.Mocked<IWebhookFunctions>;
|
||||
let mockLogger: ReturnType<typeof createMockLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
mcpTrigger = new McpTrigger();
|
||||
mockLogger = createMockLogger();
|
||||
|
||||
mockMcpServer = {
|
||||
handleSetupRequest: jest.fn().mockResolvedValue(undefined),
|
||||
handlePostMessage: jest.fn().mockResolvedValue({
|
||||
wasToolCall: false,
|
||||
toolCallInfo: undefined,
|
||||
messageId: undefined,
|
||||
relaySessionId: undefined,
|
||||
needsListToolsRelay: false,
|
||||
}),
|
||||
handleDeleteRequest: jest.fn().mockResolvedValue(undefined),
|
||||
handleStreamableHttpSetup: jest.fn().mockResolvedValue(undefined),
|
||||
getSessionId: jest.fn().mockReturnValue(undefined),
|
||||
} as unknown as jest.Mocked<McpServer>;
|
||||
|
||||
(McpServer.instance as jest.Mock).mockReturnValue(mockMcpServer);
|
||||
|
||||
mockContext = mock<IWebhookFunctions>({
|
||||
getWebhookName: jest.fn().mockReturnValue('setup'),
|
||||
getRequestObject: jest.fn(),
|
||||
getResponseObject: jest.fn(),
|
||||
getNode: jest.fn(),
|
||||
logger: mockLogger,
|
||||
getCredentials: jest.fn().mockResolvedValue({} as ICredentialDataDecryptedObject),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('description', () => {
|
||||
it('should have the correct node metadata', () => {
|
||||
expect(mcpTrigger.description.name).toBe('mcpTrigger');
|
||||
expect(mcpTrigger.description.displayName).toBe('MCP Server Trigger');
|
||||
expect(mcpTrigger.description.group).toContain('trigger');
|
||||
});
|
||||
|
||||
it('should support multiple versions', () => {
|
||||
expect(mcpTrigger.description.version).toEqual([1, 1.1, 2]);
|
||||
});
|
||||
|
||||
it('should have authentication options', () => {
|
||||
const authParam = mcpTrigger.description.properties?.find((p) => p.name === 'authentication');
|
||||
expect(authParam).toBeDefined();
|
||||
expect(authParam?.type).toBe('options');
|
||||
expect(authParam?.options).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should define webhook endpoints', () => {
|
||||
const webhooks = mcpTrigger.description.webhooks;
|
||||
expect(webhooks).toHaveLength(3);
|
||||
|
||||
const setupWebhook = webhooks?.find((w) => w.name === 'setup');
|
||||
expect(setupWebhook?.httpMethod).toBe('GET');
|
||||
|
||||
const defaultWebhooks = webhooks?.filter((w) => w.name === 'default');
|
||||
expect(defaultWebhooks).toHaveLength(2);
|
||||
expect(defaultWebhooks?.map((w) => w.httpMethod)).toEqual(['POST', 'DELETE']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhook - setup (GET)', () => {
|
||||
it('should handle setup request for version 1', async () => {
|
||||
const req = createMockRequest({ path: '/webhook/sse' });
|
||||
const resp = createMockResponse();
|
||||
const node = mock<INode>({
|
||||
typeVersion: 1,
|
||||
name: 'MCP Server Trigger',
|
||||
});
|
||||
|
||||
mockContext.getWebhookName.mockReturnValue('setup');
|
||||
mockContext.getRequestObject.mockReturnValue(req as never);
|
||||
mockContext.getResponseObject.mockReturnValue(resp as never);
|
||||
mockContext.getNode.mockReturnValue(node);
|
||||
|
||||
const result = await mcpTrigger.webhook(mockContext);
|
||||
|
||||
expect(mockMcpServer.handleSetupRequest).toHaveBeenCalled();
|
||||
expect(result).toEqual({ noWebhookResponse: true });
|
||||
});
|
||||
|
||||
it('should use n8n-mcp-server name for version 1', async () => {
|
||||
const req = createMockRequest({ path: '/webhook/sse' });
|
||||
const resp = createMockResponse();
|
||||
const node = mock<INode>({
|
||||
typeVersion: 1,
|
||||
name: 'MCP Server Trigger',
|
||||
});
|
||||
|
||||
mockContext.getWebhookName.mockReturnValue('setup');
|
||||
mockContext.getRequestObject.mockReturnValue(req as never);
|
||||
mockContext.getResponseObject.mockReturnValue(resp as never);
|
||||
mockContext.getNode.mockReturnValue(node);
|
||||
|
||||
await mcpTrigger.webhook(mockContext);
|
||||
|
||||
expect(mockMcpServer.handleSetupRequest).toHaveBeenCalledWith(
|
||||
req,
|
||||
resp,
|
||||
'n8n-mcp-server',
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use sanitized node name for version > 1', async () => {
|
||||
const req = createMockRequest({ path: '/webhook' });
|
||||
const resp = createMockResponse();
|
||||
const node = mock<INode>({
|
||||
typeVersion: 2,
|
||||
name: 'My Custom MCP Server',
|
||||
});
|
||||
|
||||
mockContext.getWebhookName.mockReturnValue('setup');
|
||||
mockContext.getRequestObject.mockReturnValue(req as never);
|
||||
mockContext.getResponseObject.mockReturnValue(resp as never);
|
||||
mockContext.getNode.mockReturnValue(node);
|
||||
|
||||
await mcpTrigger.webhook(mockContext);
|
||||
|
||||
// nodeNameToToolName converts "My Custom MCP Server" to a sanitized name
|
||||
expect(mockMcpServer.handleSetupRequest).toHaveBeenCalledWith(
|
||||
req,
|
||||
resp,
|
||||
expect.stringMatching(/^[a-z0-9_-]+$/i),
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
|
||||
it('should compute correct POST URL for version 1', async () => {
|
||||
const req = createMockRequest({ path: '/webhook/sse' });
|
||||
const resp = createMockResponse();
|
||||
const node = mock<INode>({
|
||||
typeVersion: 1,
|
||||
name: 'MCP Server Trigger',
|
||||
});
|
||||
|
||||
mockContext.getWebhookName.mockReturnValue('setup');
|
||||
mockContext.getRequestObject.mockReturnValue(req as never);
|
||||
mockContext.getResponseObject.mockReturnValue(resp as never);
|
||||
mockContext.getNode.mockReturnValue(node);
|
||||
|
||||
await mcpTrigger.webhook(mockContext);
|
||||
|
||||
expect(mockMcpServer.handleSetupRequest).toHaveBeenCalledWith(
|
||||
req,
|
||||
resp,
|
||||
expect.any(String),
|
||||
'/webhook/messages',
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use same path as POST URL for version 2', async () => {
|
||||
const req = createMockRequest({ path: '/webhook' });
|
||||
const resp = createMockResponse();
|
||||
const node = mock<INode>({
|
||||
typeVersion: 2,
|
||||
name: 'MCP Server Trigger',
|
||||
});
|
||||
|
||||
mockContext.getWebhookName.mockReturnValue('setup');
|
||||
mockContext.getRequestObject.mockReturnValue(req as never);
|
||||
mockContext.getResponseObject.mockReturnValue(resp as never);
|
||||
mockContext.getNode.mockReturnValue(node);
|
||||
|
||||
await mcpTrigger.webhook(mockContext);
|
||||
|
||||
expect(mockMcpServer.handleSetupRequest).toHaveBeenCalledWith(
|
||||
req,
|
||||
resp,
|
||||
expect.any(String),
|
||||
'/webhook',
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhook - default POST', () => {
|
||||
it('should handle POST with existing session', async () => {
|
||||
const req = createMockRequest({ method: 'POST', query: { sessionId: 'test-session' } });
|
||||
const resp = createMockResponse();
|
||||
const node = mock<INode>({
|
||||
typeVersion: 2,
|
||||
name: 'MCP Server Trigger',
|
||||
});
|
||||
|
||||
mockMcpServer.getSessionId.mockReturnValue('test-session');
|
||||
|
||||
mockContext.getWebhookName.mockReturnValue('default');
|
||||
mockContext.getRequestObject.mockReturnValue(req as never);
|
||||
mockContext.getResponseObject.mockReturnValue(resp as never);
|
||||
mockContext.getNode.mockReturnValue(node);
|
||||
|
||||
const result = await mcpTrigger.webhook(mockContext);
|
||||
|
||||
expect(mockMcpServer.handlePostMessage).toHaveBeenCalled();
|
||||
expect(result).toEqual({ noWebhookResponse: true });
|
||||
});
|
||||
|
||||
it('should return workflow data when tool call is detected', async () => {
|
||||
const req = createMockRequest({ method: 'POST', query: { sessionId: 'test-session' } });
|
||||
const resp = createMockResponse();
|
||||
const node = mock<INode>({
|
||||
typeVersion: 2,
|
||||
name: 'MCP Server Trigger',
|
||||
});
|
||||
|
||||
mockMcpServer.getSessionId.mockReturnValue('test-session');
|
||||
mockMcpServer.handlePostMessage.mockResolvedValue({
|
||||
wasToolCall: true,
|
||||
toolCallInfo: { toolName: 'test-tool', arguments: { arg1: 'value1' } },
|
||||
messageId: 'msg-123',
|
||||
relaySessionId: undefined,
|
||||
needsListToolsRelay: false,
|
||||
});
|
||||
|
||||
mockContext.getWebhookName.mockReturnValue('default');
|
||||
mockContext.getRequestObject.mockReturnValue(req as never);
|
||||
mockContext.getResponseObject.mockReturnValue(resp as never);
|
||||
mockContext.getNode.mockReturnValue(node);
|
||||
|
||||
const result = await mcpTrigger.webhook(mockContext);
|
||||
|
||||
expect(result).toEqual({
|
||||
noWebhookResponse: true,
|
||||
workflowData: [
|
||||
[
|
||||
{
|
||||
json: {
|
||||
mcpToolCall: { toolName: 'test-tool', arguments: { arg1: 'value1' } },
|
||||
mcpMessageId: 'msg-123',
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle Streamable HTTP setup when no session exists', async () => {
|
||||
const req = createMockRequest({ method: 'POST' });
|
||||
const resp = createMockResponse();
|
||||
const node = mock<INode>({
|
||||
typeVersion: 2,
|
||||
name: 'MCP Server Trigger',
|
||||
});
|
||||
|
||||
mockMcpServer.getSessionId.mockReturnValue(undefined);
|
||||
|
||||
mockContext.getWebhookName.mockReturnValue('default');
|
||||
mockContext.getRequestObject.mockReturnValue(req as never);
|
||||
mockContext.getResponseObject.mockReturnValue(resp as never);
|
||||
mockContext.getNode.mockReturnValue(node);
|
||||
|
||||
const result = await mcpTrigger.webhook(mockContext);
|
||||
|
||||
expect(mockMcpServer.handleStreamableHttpSetup).toHaveBeenCalled();
|
||||
expect(result).toEqual({ noWebhookResponse: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhook - default DELETE', () => {
|
||||
it('should handle DELETE request', async () => {
|
||||
const req = createMockRequest({ method: 'DELETE' });
|
||||
const resp = createMockResponse();
|
||||
const node = mock<INode>({
|
||||
typeVersion: 2,
|
||||
name: 'MCP Server Trigger',
|
||||
});
|
||||
|
||||
mockContext.getWebhookName.mockReturnValue('default');
|
||||
mockContext.getRequestObject.mockReturnValue(req as never);
|
||||
mockContext.getResponseObject.mockReturnValue(resp as never);
|
||||
mockContext.getNode.mockReturnValue(node);
|
||||
|
||||
const result = await mcpTrigger.webhook(mockContext);
|
||||
|
||||
expect(mockMcpServer.handleDeleteRequest).toHaveBeenCalledWith(req, resp);
|
||||
expect(result).toEqual({ noWebhookResponse: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication', () => {
|
||||
it('should rethrow non-authorization errors', async () => {
|
||||
const { validateWebhookAuthentication } = jest.requireMock(
|
||||
'n8n-nodes-base/dist/nodes/Webhook/utils',
|
||||
);
|
||||
|
||||
const genericError = new Error('Something went wrong');
|
||||
validateWebhookAuthentication.mockRejectedValue(genericError);
|
||||
|
||||
const req = createMockRequest({ path: '/webhook' });
|
||||
const resp = createMockResponse();
|
||||
const node = mock<INode>({
|
||||
typeVersion: 2,
|
||||
name: 'MCP Server Trigger',
|
||||
});
|
||||
|
||||
mockContext.getWebhookName.mockReturnValue('setup');
|
||||
mockContext.getRequestObject.mockReturnValue(req as never);
|
||||
mockContext.getResponseObject.mockReturnValue(resp as never);
|
||||
mockContext.getNode.mockReturnValue(node);
|
||||
|
||||
await expect(mcpTrigger.webhook(mockContext)).rejects.toThrow('Something went wrong');
|
||||
});
|
||||
|
||||
it('should return 401 for authentication errors', async () => {
|
||||
const { WebhookAuthorizationError } = jest.requireActual(
|
||||
'n8n-nodes-base/dist/nodes/Webhook/error',
|
||||
);
|
||||
const { validateWebhookAuthentication } = jest.requireMock(
|
||||
'n8n-nodes-base/dist/nodes/Webhook/utils',
|
||||
);
|
||||
|
||||
validateWebhookAuthentication.mockRejectedValue(
|
||||
new WebhookAuthorizationError(401, 'Unauthorized'),
|
||||
);
|
||||
|
||||
const req = createMockRequest({ path: '/webhook' });
|
||||
const resp = createMockResponse();
|
||||
const node = mock<INode>({
|
||||
typeVersion: 2,
|
||||
name: 'MCP Server Trigger',
|
||||
});
|
||||
|
||||
mockContext.getWebhookName.mockReturnValue('setup');
|
||||
mockContext.getRequestObject.mockReturnValue(req as never);
|
||||
mockContext.getResponseObject.mockReturnValue(resp as never);
|
||||
mockContext.getNode.mockReturnValue(node);
|
||||
|
||||
const result = await mcpTrigger.webhook(mockContext);
|
||||
|
||||
expect(resp.writeHead).toHaveBeenCalledWith(401);
|
||||
expect(resp.end).toHaveBeenCalledWith('Unauthorized');
|
||||
expect(result).toEqual({ noWebhookResponse: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('list tools relay', () => {
|
||||
it('should return list tools relay data when needed', async () => {
|
||||
// Reset validateWebhookAuthentication to resolve (not reject)
|
||||
const { validateWebhookAuthentication } = jest.requireMock(
|
||||
'n8n-nodes-base/dist/nodes/Webhook/utils',
|
||||
);
|
||||
validateWebhookAuthentication.mockResolvedValue(undefined);
|
||||
|
||||
const req = createMockRequest({ method: 'POST', query: { sessionId: 'test-session' } });
|
||||
const resp = createMockResponse();
|
||||
const node = mock<INode>({
|
||||
typeVersion: 2,
|
||||
name: 'MCP Server Trigger',
|
||||
});
|
||||
|
||||
mockMcpServer.getSessionId.mockReturnValue('test-session');
|
||||
mockMcpServer.handlePostMessage.mockResolvedValue({
|
||||
wasToolCall: false,
|
||||
toolCallInfo: undefined,
|
||||
messageId: 'msg-456',
|
||||
relaySessionId: 'relay-session-789',
|
||||
needsListToolsRelay: true,
|
||||
});
|
||||
|
||||
mockContext.getWebhookName.mockReturnValue('default');
|
||||
mockContext.getRequestObject.mockReturnValue(req as never);
|
||||
mockContext.getResponseObject.mockReturnValue(resp as never);
|
||||
mockContext.getNode.mockReturnValue(node);
|
||||
|
||||
const result = await mcpTrigger.webhook(mockContext);
|
||||
|
||||
expect(result).toEqual({
|
||||
noWebhookResponse: true,
|
||||
workflowData: [
|
||||
[
|
||||
{
|
||||
json: {
|
||||
mcpListToolsRelay: {
|
||||
sessionId: 'relay-session-789',
|
||||
messageId: 'msg-456',
|
||||
marker: 'mcp_list_tools_request',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
export {
|
||||
createMockTool,
|
||||
createMockTools,
|
||||
} from './mock-langchain';
|
||||
export {
|
||||
createMockRequest,
|
||||
createMockRequestWithSessionId,
|
||||
createMockRequestWithHeaderSessionId,
|
||||
createMockResponse,
|
||||
createValidToolCallMessage,
|
||||
createListToolsMessage,
|
||||
MCP_SESSION_ID_HEADER,
|
||||
} from './mock-express';
|
||||
export {
|
||||
createMockServer,
|
||||
createMockTransport,
|
||||
} from './mock-mcp-sdk';
|
||||
export { createMockLogger } from './mock-logger';
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
import type { CompressionResponse } from '../../transport/Transport';
|
||||
|
||||
/** MCP session ID header name */
|
||||
export const MCP_SESSION_ID_HEADER = 'mcp-session-id';
|
||||
|
||||
/**
|
||||
* Creates a mock Express Response with compression support
|
||||
*/
|
||||
export function createMockResponse(): jest.Mocked<CompressionResponse> {
|
||||
const response = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
end: jest.fn().mockReturnThis(),
|
||||
write: jest.fn().mockReturnThis(),
|
||||
writeHead: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
getHeader: jest.fn(),
|
||||
flush: jest.fn(),
|
||||
on: jest.fn().mockReturnThis(),
|
||||
once: jest.fn().mockReturnThis(),
|
||||
removeListener: jest.fn().mockReturnThis(),
|
||||
emit: jest.fn().mockReturnValue(true),
|
||||
headersSent: false,
|
||||
} as unknown as jest.Mocked<CompressionResponse>;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock Express Request with specified properties
|
||||
*/
|
||||
export function createMockRequest(
|
||||
options: {
|
||||
sessionId?: string;
|
||||
body?: unknown;
|
||||
rawBody?: string;
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string>;
|
||||
method?: string;
|
||||
path?: string;
|
||||
} = {},
|
||||
): jest.Mocked<Request> & { rawBody: Buffer } {
|
||||
const {
|
||||
sessionId,
|
||||
body = {},
|
||||
rawBody = '{}',
|
||||
headers = {},
|
||||
query = {},
|
||||
method = 'POST',
|
||||
path = '/mcp',
|
||||
} = options;
|
||||
|
||||
const finalQuery: Record<string, string> = { ...query };
|
||||
const finalHeaders: Record<string, string> = { ...headers };
|
||||
|
||||
if (sessionId) {
|
||||
if (!query.sessionId && !headers[MCP_SESSION_ID_HEADER]) {
|
||||
finalQuery.sessionId = sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
body,
|
||||
rawBody: Buffer.from(rawBody),
|
||||
headers: finalHeaders,
|
||||
query: finalQuery,
|
||||
method,
|
||||
params: {},
|
||||
url: path,
|
||||
path,
|
||||
get: jest.fn((name: string) => finalHeaders[name.toLowerCase()]),
|
||||
} as unknown as jest.Mocked<Request> & { rawBody: Buffer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock request with a specific session ID in query params
|
||||
*/
|
||||
export function createMockRequestWithSessionId(
|
||||
sessionId: string,
|
||||
rawBody: string,
|
||||
): jest.Mocked<Request> & { rawBody: Buffer } {
|
||||
return createMockRequest({
|
||||
sessionId,
|
||||
rawBody,
|
||||
query: { sessionId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a valid JSONRPC tool call message body
|
||||
*/
|
||||
export function createValidToolCallMessage(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
id: string | number = 1,
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: toolName,
|
||||
arguments: args,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a valid JSONRPC list tools request message body
|
||||
*/
|
||||
export function createListToolsMessage(id: string | number = 1): string {
|
||||
return JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method: 'tools/list',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock request with session ID in the mcp-session-id header
|
||||
*/
|
||||
export function createMockRequestWithHeaderSessionId(
|
||||
sessionId: string,
|
||||
rawBody: string = '{}',
|
||||
): jest.Mocked<Request> & { rawBody: Buffer } {
|
||||
return createMockRequest({
|
||||
rawBody,
|
||||
headers: { [MCP_SESSION_ID_HEADER]: sessionId },
|
||||
query: {},
|
||||
});
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import type { Tool } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Creates a mock Tool for testing
|
||||
*/
|
||||
export function createMockTool(
|
||||
toolName: string,
|
||||
opts: {
|
||||
description?: string;
|
||||
invokeReturn?: unknown;
|
||||
invokeError?: Error;
|
||||
metadata?: Record<string, unknown>;
|
||||
} = {},
|
||||
): jest.Mocked<Tool> {
|
||||
const {
|
||||
description = `Mock tool: ${toolName}`,
|
||||
invokeReturn = { result: 'success' },
|
||||
invokeError,
|
||||
metadata,
|
||||
} = opts;
|
||||
|
||||
const invoke = jest.fn().mockImplementation(async () => {
|
||||
await Promise.resolve();
|
||||
if (invokeError) {
|
||||
throw invokeError;
|
||||
}
|
||||
return invokeReturn;
|
||||
});
|
||||
|
||||
return {
|
||||
name: toolName,
|
||||
description,
|
||||
schema: z.object({}),
|
||||
invoke,
|
||||
metadata,
|
||||
} as unknown as jest.Mocked<Tool>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates multiple mock tools
|
||||
*/
|
||||
export function createMockTools(toolNames: string[]): Array<jest.Mocked<Tool>> {
|
||||
return toolNames.map((n) => createMockTool(n));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Logger } from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Creates a mock Logger for testing
|
||||
*/
|
||||
export function createMockLogger(): jest.Mocked<Logger> {
|
||||
return {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
trace: jest.fn(),
|
||||
log: jest.fn(),
|
||||
verbose: jest.fn(),
|
||||
scoped: jest.fn().mockReturnThis(),
|
||||
} as unknown as jest.Mocked<Logger>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
|
||||
import type { McpTransport, TransportType } from '../../transport/Transport';
|
||||
|
||||
/**
|
||||
* Creates a mock MCP Server
|
||||
*/
|
||||
export function createMockServer(): jest.Mocked<Server> {
|
||||
return {
|
||||
connect: jest.fn().mockResolvedValue(undefined),
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
setRequestHandler: jest.fn(),
|
||||
onclose: undefined,
|
||||
onerror: undefined,
|
||||
} as unknown as jest.Mocked<Server>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock McpTransport
|
||||
*/
|
||||
export function createMockTransport(
|
||||
sessionId: string,
|
||||
transportType: TransportType = 'sse',
|
||||
): jest.Mocked<McpTransport> {
|
||||
return {
|
||||
transportType,
|
||||
sessionId,
|
||||
send: jest.fn().mockResolvedValue(undefined),
|
||||
handleRequest: jest.fn().mockResolvedValue(undefined),
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
onclose: undefined,
|
||||
} as unknown as jest.Mocked<McpTransport>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Jest setup file for mcp/core tests
|
||||
* Cleans up mocks between tests to ensure test isolation
|
||||
*/
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
Reference in New Issue
Block a user