From 8360283c6f28f8d3e7cc60ae1a3982964954cf79 Mon Sep 17 00:00:00 2001 From: jeanpaul Date: Wed, 9 Apr 2025 14:45:24 +0200 Subject: [PATCH] feat(MCP Server Trigger Node): Add MCP Server Trigger node to expose tools to MCP clients (#14403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ --- .../@n8n/api-types/src/frontend-settings.ts | 2 + .../config/src/configs/endpoints.config.ts | 8 + packages/@n8n/config/test/config.test.ts | 2 + .../nodes/Mcp/FlushingSSEServerTransport.ts | 26 +++ .../nodes-langchain/nodes/Mcp/McpServer.ts | 195 ++++++++++++++++++ .../nodes/Mcp/McpTrigger.node.ts | 168 +++++++++++++++ .../FlushingSSEServerTransport.test.ts | 45 ++++ .../nodes/Mcp/__test__/McpServer.test.ts | 122 +++++++++++ .../Mcp/__test__/McpTrigger.node.test.ts | 92 +++++++++ .../nodes-langchain/nodes/Mcp/mcp.dark.svg | 7 + .../@n8n/nodes-langchain/nodes/Mcp/mcp.svg | 7 + .../agents/Agent/test/ToolsAgent.test.ts | 102 ++++----- packages/@n8n/nodes-langchain/package.json | 2 + .../@n8n/nodes-langchain/utils/helpers.ts | 2 +- packages/cli/src/abstract-server.ts | 28 ++- packages/cli/src/constants.ts | 2 + packages/cli/src/services/frontend.service.ts | 2 + packages/cli/src/webhooks/waiting-webhooks.ts | 2 +- packages/cli/src/webhooks/webhook-helpers.ts | 27 +++ .../node-execution-context/webhook-context.ts | 2 +- packages/frontend/editor-ui/src/Interface.ts | 2 + .../editor-ui/src/__tests__/defaults.ts | 2 + .../editor-ui/src/components/NodeWebhooks.vue | 15 +- .../src/composables/useCanvasOperations.ts | 3 +- .../src/composables/useWorkflowHelpers.ts | 26 ++- packages/frontend/editor-ui/src/constants.ts | 2 + .../src/plugins/i18n/locales/en.json | 5 + .../editor-ui/src/stores/root.store.ts | 20 +- .../credentials/HttpBearerAuth.credentials.ts | 43 ++++ packages/nodes-base/nodes/Form/Form.node.ts | 4 +- .../nodes/Form/v2/FormTriggerV2.node.ts | 4 +- packages/nodes-base/nodes/Wait/Wait.node.ts | 4 +- packages/nodes-base/nodes/Webhook/utils.ts | 14 ++ packages/nodes-base/package.json | 1 + packages/workflow/src/Interfaces.ts | 2 +- pnpm-lock.yaml | 57 ++++- 36 files changed, 942 insertions(+), 105 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/nodes/Mcp/FlushingSSEServerTransport.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/Mcp/McpServer.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/Mcp/McpTrigger.node.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/Mcp/__test__/FlushingSSEServerTransport.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/Mcp/__test__/McpServer.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/Mcp/__test__/McpTrigger.node.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/Mcp/mcp.dark.svg create mode 100644 packages/@n8n/nodes-langchain/nodes/Mcp/mcp.svg create mode 100644 packages/nodes-base/credentials/HttpBearerAuth.credentials.ts diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 3c84777e8e..2dbebcc85d 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -32,6 +32,8 @@ export interface FrontendSettings { endpointForm: string; endpointFormTest: string; endpointFormWaiting: string; + endpointMcp: string; + endpointMcpTest: string; endpointWebhook: string; endpointWebhookTest: string; endpointWebhookWaiting: string; diff --git a/packages/@n8n/config/src/configs/endpoints.config.ts b/packages/@n8n/config/src/configs/endpoints.config.ts index 994343c650..39637f464e 100644 --- a/packages/@n8n/config/src/configs/endpoints.config.ts +++ b/packages/@n8n/config/src/configs/endpoints.config.ts @@ -104,6 +104,14 @@ export class EndpointsConfig { @Env('N8N_ENDPOINT_WEBHOOK_WAIT') webhookWaiting: string = 'webhook-waiting'; + /** Path segment for MCP endpoints. */ + @Env('N8N_ENDPOINT_MCP') + mcp: string = 'mcp'; + + /** Path segment for test MCP endpoints. */ + @Env('N8N_ENDPOINT_MCP_TEST') + mcpTest: string = 'mcp-test'; + /** Whether to disable n8n's UI (frontend). */ @Env('N8N_DISABLE_UI') disableUi: boolean = false; diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 3d01912862..c3da048c53 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -186,6 +186,8 @@ describe('GlobalConfig', () => { form: 'form', formTest: 'form-test', formWaiting: 'form-waiting', + mcp: 'mcp', + mcpTest: 'mcp-test', payloadSizeMax: 16, formDataFileSizeMax: 200, rest: 'rest', diff --git a/packages/@n8n/nodes-langchain/nodes/Mcp/FlushingSSEServerTransport.ts b/packages/@n8n/nodes-langchain/nodes/Mcp/FlushingSSEServerTransport.ts new file mode 100644 index 0000000000..fe7cac525a --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/Mcp/FlushingSSEServerTransport.ts @@ -0,0 +1,26 @@ +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import type { Response } from 'express'; + +export type CompressionResponse = Response & { + /** + * `flush()` is defined in the compression middleware. + * This is necessary because the compression middleware sometimes waits + * for a certain amount of data before sending the data to the client + */ + flush: () => void; +}; + +export class FlushingSSEServerTransport extends SSEServerTransport { + constructor( + _endpoint: string, + private response: CompressionResponse, + ) { + super(_endpoint, response); + } + + async send(message: JSONRPCMessage): Promise { + await super.send(message); + this.response.flush(); + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/Mcp/McpServer.ts b/packages/@n8n/nodes-langchain/nodes/Mcp/McpServer.ts new file mode 100644 index 0000000000..0fad621191 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/Mcp/McpServer.ts @@ -0,0 +1,195 @@ +import type { Tool } from '@langchain/core/tools'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import { + JSONRPCMessageSchema, + ListToolsRequestSchema, + CallToolRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import type * as express from 'express'; +import { OperationalError, type Logger } from 'n8n-workflow'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { FlushingSSEServerTransport } from './FlushingSSEServerTransport'; +import type { CompressionResponse } from './FlushingSSEServerTransport'; + +/** + * Parses the JSONRPC message and checks whether the method used was a tool + * call. This is necessary in order to not have executions for listing tools + * and other commands sent by the MCP client + */ +function wasToolCall(body: string) { + try { + const message: unknown = JSON.parse(body); + const parsedMessage: JSONRPCMessage = JSONRPCMessageSchema.parse(message); + return ( + 'method' in parsedMessage && + 'id' in parsedMessage && + parsedMessage?.method === CallToolRequestSchema.shape.method.value + ); + } catch { + return false; + } +} + +export class McpServer { + servers: { [sessionId: string]: Server } = {}; + + transports: { [sessionId: string]: FlushingSSEServerTransport } = {}; + + logger: Logger; + + private tools: { [sessionId: string]: Tool[] } = {}; + + private resolveFunctions: { [sessionId: string]: CallableFunction } = {}; + + constructor(logger: Logger) { + this.logger = logger; + this.logger.debug('MCP Server created'); + } + + async connectTransport(postUrl: string, resp: CompressionResponse): Promise { + const transport = new FlushingSSEServerTransport(postUrl, resp); + const server = this.setUpServer(); + const { sessionId } = transport; + this.transports[sessionId] = transport; + this.servers[sessionId] = server; + + resp.on('close', async () => { + this.logger.debug(`Deleting transport for ${sessionId}`); + delete this.tools[sessionId]; + delete this.resolveFunctions[sessionId]; + delete this.transports[sessionId]; + delete this.servers[sessionId]; + }); + + await server.connect(transport); + + // Make sure we flush the compression middleware, so that it's not waiting for more content to be added to the buffer + if (resp.flush) { + resp.flush(); + } + } + + async handlePostMessage(req: express.Request, resp: CompressionResponse, connectedTools: Tool[]) { + const sessionId = req.query.sessionId as string; + const transport = this.transports[sessionId]; + this.tools[sessionId] = connectedTools; + if (transport) { + // We need to add a promise here because the `handlePostMessage` will send something to the + // MCP Server, that will run in a different context. This means that the return will happen + // almost immediately, and will lead to marking the sub-node as "running" in the final execution + await new Promise(async (resolve) => { + this.resolveFunctions[sessionId] = resolve; + await transport.handlePostMessage(req, resp, req.rawBody.toString()); + }); + delete this.resolveFunctions[sessionId]; + } else { + this.logger.warn(`No transport found for session ${sessionId}`); + resp.status(401).send('No transport found for sessionId'); + } + + if (resp.flush) { + resp.flush(); + } + + delete this.tools[sessionId]; // Clean up to avoid keeping all tools in memory + + return wasToolCall(req.rawBody.toString()); + } + + setUpServer(): Server { + const server = new Server( + { + name: 'n8n-mcp-server', + version: '0.1.0', + }, + { + capabilities: { tools: {} }, + }, + ); + + server.setRequestHandler(ListToolsRequestSchema, async (_, extra: RequestHandlerExtra) => { + if (!extra.sessionId) { + throw new OperationalError('Require a sessionId for the listing of tools'); + } + + return { + tools: this.tools[extra.sessionId].map((tool) => { + return { + name: tool.name, + description: tool.description, + inputSchema: zodToJsonSchema(tool.schema), + }; + }), + }; + }); + + server.setRequestHandler(CallToolRequestSchema, async (request, extra: RequestHandlerExtra) => { + if (!request.params?.name || !request.params?.arguments) { + throw new OperationalError('Require a name and arguments for the tool call'); + } + if (!extra.sessionId) { + throw new OperationalError('Require a sessionId for the tool call'); + } + + const requestedTool: Tool | undefined = this.tools[extra.sessionId].find( + (tool) => tool.name === request.params.name, + ); + if (!requestedTool) { + throw new OperationalError('Tool not found'); + } + + try { + const result = await requestedTool.invoke(request.params.arguments); + + this.resolveFunctions[extra.sessionId](); + + this.logger.debug(`Got request for ${requestedTool.name}, and executed it.`); + + // TODO: Refactor this to no longer use the legacy tool result, but + return { toolResult: result }; + } catch (error) { + this.logger.error(`Error while executing Tool ${requestedTool.name}: ${error}`); + return { isError: true, content: [{ type: 'text', text: `Error: ${error.message}` }] }; + } + }); + + server.onclose = () => { + this.logger.debug('Closing MCP Server'); + }; + server.onerror = (error: unknown) => { + this.logger.error(`MCP Error: ${error}`); + }; + return server; + } +} + +/** + * This singleton is shared across the instance, making sure we only have one server to worry about. + * It needs to stay in memory to keep track of the long-lived connections. + * It requires a logger at first creation to set everything up. + */ +export class McpServerSingleton { + static #instance: McpServerSingleton; + + private _serverData: McpServer; + + private constructor(logger: Logger) { + this._serverData = new McpServer(logger); + } + + static instance(logger: Logger): McpServer { + if (!McpServerSingleton.#instance) { + McpServerSingleton.#instance = new McpServerSingleton(logger); + logger.debug('Created singleton for MCP Servers'); + } + + return McpServerSingleton.#instance.serverData; + } + + get serverData() { + return this._serverData; + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/Mcp/McpTrigger.node.ts b/packages/@n8n/nodes-langchain/nodes/Mcp/McpTrigger.node.ts new file mode 100644 index 0000000000..f7d2e5e242 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/Mcp/McpTrigger.node.ts @@ -0,0 +1,168 @@ +import { WebhookAuthorizationError } from 'n8n-nodes-base/dist/nodes/Webhook/error'; +import { validateWebhookAuthentication } from 'n8n-nodes-base/dist/nodes/Webhook/utils'; +import type { INodeTypeDescription, IWebhookFunctions, IWebhookResponseData } from 'n8n-workflow'; +import { NodeConnectionTypes, Node } from 'n8n-workflow'; + +import { getConnectedTools } from '@utils/helpers'; + +import type { CompressionResponse } from './FlushingSSEServerTransport'; +import { McpServerSingleton } from './McpServer'; +import type { McpServer } from './McpServer'; + +const MCP_SSE_SETUP_PATH = 'sse'; +const MCP_SSE_MESSAGES_PATH = 'messages'; + +export class McpTrigger extends Node { + description: INodeTypeDescription = { + displayName: 'MCP Server Trigger', + name: 'mcpTrigger', + icon: { + light: 'file:mcp.svg', + dark: 'file:mcp.dark.svg', + }, + group: ['trigger'], + version: 1, + description: 'Expose n8n tools as an MCP Server endpoint', + activationMessage: 'You can now connect your MCP Clients to the SSE URL.', + defaults: { + name: 'MCP Server Trigger', + }, + triggerPanel: { + header: 'Listen for MCP events', + executionsHelp: { + inactive: + "This trigger has two modes: test and production.

Use test mode while you build your workflow. Click the 'test step' button, then make an MCP request to the test URL. The executions will show up in the editor.

Use production mode to run your workflow automatically. Activate the workflow, then make requests to the production URL. These executions will show up in the executions list, but not the editor.", + active: + "This trigger has two modes: test and production.

Use test mode while you build your workflow. Click the 'test step' button, then make an MCP request to the test URL. The executions will show up in the editor.

Use production mode to run your workflow automatically. Since your workflow is activated, you can make requests to the production URL. These executions will show up in the executions list, but not the editor.", + }, + activationHint: + 'Once you’ve finished building your workflow, run it without having to click this button by using the production URL.', + }, + inputs: [ + { + type: NodeConnectionTypes.AiTool, + displayName: 'Tools', + }, + ], + outputs: [], + credentials: [ + { + // eslint-disable-next-line n8n-nodes-base/node-class-description-credentials-name-unsuffixed + name: 'httpBearerAuth', + required: true, + displayOptions: { + show: { + authentication: ['bearerAuth'], + }, + }, + }, + { + name: 'httpHeaderAuth', + required: true, + displayOptions: { + show: { + authentication: ['headerAuth'], + }, + }, + }, + { + name: 'httpCustomAuth', + required: true, + displayOptions: { + show: { + authentication: ['customAuth'], + }, + }, + }, + ], + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { name: 'None', value: 'none' }, + { name: 'Bearer Auth', value: 'bearerAuth' }, + { name: 'Header Auth', value: 'headerAuth' }, + { name: 'Custom Auth', value: 'customAuth' }, + ], + default: 'none', + description: 'The way to authenticate', + }, + { + displayName: 'Path', + name: 'path', + type: 'string', + default: '', + placeholder: 'webhook', + required: true, + description: 'The base path for this MCP server', + }, + ], + webhooks: [ + { + name: 'setup', + httpMethod: 'GET', + responseMode: 'onReceived', + isFullPath: true, + path: `={{$parameter["path"]}}/${MCP_SSE_SETUP_PATH}`, + nodeType: 'mcp', + ndvHideMethod: true, + ndvHideUrl: false, + }, + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + isFullPath: true, + path: `={{$parameter["path"]}}/${MCP_SSE_MESSAGES_PATH}`, + nodeType: 'mcp', + ndvHideMethod: true, + ndvHideUrl: true, + }, + ], + }; + + async webhook(context: IWebhookFunctions): Promise { + const webhookName = context.getWebhookName(); + const req = context.getRequestObject(); + const resp = context.getResponseObject() as unknown as CompressionResponse; + + try { + await validateWebhookAuthentication(context, 'authentication'); + } catch (error) { + if (error instanceof WebhookAuthorizationError) { + resp.writeHead(error.responseCode); + resp.end(error.message); + return { noWebhookResponse: true }; + } + throw error; + } + + const mcpServer: McpServer = McpServerSingleton.instance(context.logger); + + if (webhookName === 'setup') { + // Sets up the transport and opens the long-lived connection. This resp + // will stay streaming, and is the channel that sends the events + const postUrl = req.path.replace( + new RegExp(`/${MCP_SSE_SETUP_PATH}$`), + `/${MCP_SSE_MESSAGES_PATH}`, + ); + await mcpServer.connectTransport(postUrl, resp); + + return { noWebhookResponse: true }; + } else if (webhookName === 'default') { + // This is the command-channel, and is actually executing the tools. This + // sends the response back through the long-lived connection setup in the + // 'setup' call + const connectedTools = await getConnectedTools(context, true); + + const wasToolCall = await mcpServer.handlePostMessage(req, resp, connectedTools); + + if (wasToolCall) return { noWebhookResponse: true, workflowData: [[{ json: {} }]] }; + return { noWebhookResponse: true }; + } + + return { workflowData: [[{ json: {} }]] }; + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/Mcp/__test__/FlushingSSEServerTransport.test.ts b/packages/@n8n/nodes-langchain/nodes/Mcp/__test__/FlushingSSEServerTransport.test.ts new file mode 100644 index 0000000000..b3471a4b0c --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/Mcp/__test__/FlushingSSEServerTransport.test.ts @@ -0,0 +1,45 @@ +import { jest } from '@jest/globals'; +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import { mock } from 'jest-mock-extended'; + +import { FlushingSSEServerTransport } from '../FlushingSSEServerTransport'; +import type { CompressionResponse } from '../FlushingSSEServerTransport'; + +describe('FlushingSSEServerTransport', () => { + const mockResponse = mock(); + let transport: FlushingSSEServerTransport; + const endpoint = '/test/endpoint'; + + beforeEach(() => { + jest.resetAllMocks(); + mockResponse.status.mockReturnThis(); + transport = new FlushingSSEServerTransport(endpoint, mockResponse); + }); + + it('should call flush after sending a message', async () => { + // Create a sample JSONRPC message + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: '123', + result: { success: true }, + }; + + // Send a message through the transport + await transport.start(); + await transport.send(message); + + expect(mockResponse.writeHead).toHaveBeenCalledWith(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }); + expect(mockResponse.write).toHaveBeenCalledWith( + // @ts-expect-error `_sessionId` is private + `event: endpoint\ndata: /test/endpoint?sessionId=${transport._sessionId}\n\n`, + ); + expect(mockResponse.write).toHaveBeenCalledWith( + `event: message\ndata: ${JSON.stringify(message)}\n\n`, + ); + expect(mockResponse.flush).toHaveBeenCalled(); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/Mcp/__test__/McpServer.test.ts b/packages/@n8n/nodes-langchain/nodes/Mcp/__test__/McpServer.test.ts new file mode 100644 index 0000000000..beddc18fec --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/Mcp/__test__/McpServer.test.ts @@ -0,0 +1,122 @@ +import { jest } from '@jest/globals'; +import type { Tool } from '@langchain/core/tools'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import type { Request } from 'express'; +import { captor, mock } from 'jest-mock-extended'; + +import type { CompressionResponse } from '../FlushingSSEServerTransport'; +import { FlushingSSEServerTransport } from '../FlushingSSEServerTransport'; +import { McpServer } from '../McpServer'; + +const sessionId = 'mock-session-id'; +const mockServer = mock(); +jest.mock('@modelcontextprotocol/sdk/server/index.js', () => { + return { + Server: jest.fn().mockImplementation(() => mockServer), + }; +}); + +const mockTransport = mock({ sessionId }); +jest.mock('../FlushingSSEServerTransport', () => { + return { + FlushingSSEServerTransport: jest.fn().mockImplementation(() => mockTransport), + }; +}); + +describe('McpServer', () => { + const mockRequest = mock({ query: { sessionId }, path: '/sse' }); + const mockResponse = mock(); + const mockTool = mock({ name: 'mockTool' }); + + let mcpServer: McpServer; + + beforeEach(() => { + jest.clearAllMocks(); + mockResponse.status.mockReturnThis(); + + mcpServer = new McpServer(mock()); + }); + + describe('connectTransport', () => { + const postUrl = '/post-url'; + + it('should set up a transport and server', async () => { + await mcpServer.connectTransport(postUrl, mockResponse); + + // Check that FlushingSSEServerTransport was initialized with correct params + expect(FlushingSSEServerTransport).toHaveBeenCalledWith(postUrl, mockResponse); + + // Check that Server was initialized + expect(Server).toHaveBeenCalled(); + + // Check that transport and server are stored + expect(mcpServer.transports[sessionId]).toBeDefined(); + expect(mcpServer.servers[sessionId]).toBeDefined(); + + // Check that connect was called on the server + expect(mcpServer.servers[sessionId].connect).toHaveBeenCalled(); + + // Check that flush was called if available + expect(mockResponse.flush).toHaveBeenCalled(); + }); + + it('should set up close handler that cleans up resources', async () => { + await mcpServer.connectTransport(postUrl, mockResponse); + + // Get the close callback and execute it + const closeCallbackCaptor = captor<() => Promise>(); + expect(mockResponse.on).toHaveBeenCalledWith('close', closeCallbackCaptor); + await closeCallbackCaptor.value(); + + // Check that resources were cleaned up + expect(mcpServer.transports[sessionId]).toBeUndefined(); + expect(mcpServer.servers[sessionId]).toBeUndefined(); + }); + }); + + describe('handlePostMessage', () => { + it('should call transport.handlePostMessage when transport exists', async () => { + mockTransport.handlePostMessage.mockImplementation(async () => { + // @ts-expect-error private property `resolveFunctions` + mcpServer.resolveFunctions[sessionId](); + }); + + // Add the transport directly + mcpServer.transports[sessionId] = mockTransport; + + mockRequest.rawBody = Buffer.from( + JSON.stringify({ + jsonrpc: '2.0', + method: 'tools/call', + id: 123, + params: { name: 'mockTool' }, + }), + ); + + // Call the method + const result = await mcpServer.handlePostMessage(mockRequest, mockResponse, [mockTool]); + + // Verify that transport's handlePostMessage was called + expect(mockTransport.handlePostMessage).toHaveBeenCalledWith( + mockRequest, + mockResponse, + expect.any(String), + ); + + // Verify that we check if it was a tool call + expect(result).toBe(true); + + // Verify flush was called + expect(mockResponse.flush).toHaveBeenCalled(); + }); + + it('should return 401 when transport does not exist', async () => { + // Call without setting up transport + await mcpServer.handlePostMessage(mockRequest, mockResponse, [mockTool]); + + // Verify error status was set + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.send).toHaveBeenCalledWith(expect.stringContaining('No transport found')); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/Mcp/__test__/McpTrigger.node.test.ts b/packages/@n8n/nodes-langchain/nodes/Mcp/__test__/McpTrigger.node.test.ts new file mode 100644 index 0000000000..9719ffd9f3 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/Mcp/__test__/McpTrigger.node.test.ts @@ -0,0 +1,92 @@ +import { jest } from '@jest/globals'; +import type { Tool } from '@langchain/core/tools'; +import type { Request, Response } from 'express'; +import { mock } from 'jest-mock-extended'; +import type { IWebhookFunctions } from 'n8n-workflow'; + +import type { McpServer } from '../McpServer'; +import { McpTrigger } from '../McpTrigger.node'; + +const mockTool = mock({ name: 'mockTool' }); +jest.mock('@utils/helpers', () => ({ + getConnectedTools: jest.fn().mockImplementation(() => [mockTool]), +})); + +const mockServer = mock(); +jest.mock('../McpServer', () => ({ + McpServerSingleton: { + instance: jest.fn().mockImplementation(() => mockServer), + }, +})); + +describe('McpTrigger Node', () => { + const sessionId = 'mock-session-id'; + const mockContext = mock(); + const mockRequest = mock({ query: { sessionId }, path: '/custom-path/sse' }); + const mockResponse = mock(); + let mcpTrigger: McpTrigger; + + beforeEach(() => { + jest.clearAllMocks(); + + mcpTrigger = new McpTrigger(); + + mockContext.getRequestObject.mockReturnValue(mockRequest); + mockContext.getResponseObject.mockReturnValue(mockResponse); + }); + + describe('webhook method', () => { + it('should handle setup webhook', async () => { + // Configure the context for setup webhook + mockContext.getWebhookName.mockReturnValue('setup'); + + // Call the webhook method + const result = await mcpTrigger.webhook(mockContext); + + // Verify that the connectTransport method was called with correct URL + expect(mockServer.connectTransport).toHaveBeenCalledWith( + '/custom-path/messages', + mockResponse, + ); + + // Verify the returned result has noWebhookResponse: true + expect(result).toEqual({ noWebhookResponse: true }); + }); + + it('should handle default webhook for tool execution', async () => { + // Configure the context for default webhook (tool execution) + mockContext.getWebhookName.mockReturnValue('default'); + + // Mock that the server executes a tool and returns true + mockServer.handlePostMessage.mockResolvedValueOnce(true); + + // Call the webhook method + const result = await mcpTrigger.webhook(mockContext); + + // Verify that handlePostMessage was called with request, response and tools + expect(mockServer.handlePostMessage).toHaveBeenCalledWith(mockRequest, mockResponse, [ + mockTool, + ]); + + // Verify the returned result when a tool was called + expect(result).toEqual({ + noWebhookResponse: true, + workflowData: [[{ json: {} }]], + }); + }); + + it('should handle default webhook when no tool was executed', async () => { + // Configure the context for default webhook + mockContext.getWebhookName.mockReturnValue('default'); + + // Mock that the server doesn't execute a tool and returns false + mockServer.handlePostMessage.mockResolvedValueOnce(false); + + // Call the webhook method + const result = await mcpTrigger.webhook(mockContext); + + // Verify the returned result when no tool was called + expect(result).toEqual({ noWebhookResponse: true }); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/Mcp/mcp.dark.svg b/packages/@n8n/nodes-langchain/nodes/Mcp/mcp.dark.svg new file mode 100644 index 0000000000..852417b1d2 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/Mcp/mcp.dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/@n8n/nodes-langchain/nodes/Mcp/mcp.svg b/packages/@n8n/nodes-langchain/nodes/Mcp/mcp.svg new file mode 100644 index 0000000000..8d95f2dd96 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/Mcp/mcp.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent.test.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent.test.ts index 365682c285..44ce14d0d0 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent.test.ts @@ -1,19 +1,16 @@ -// ToolsAgent.test.ts import type { BaseChatMemory } from '@langchain/community/memory/chat_memory'; import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import { HumanMessage } from '@langchain/core/messages'; import type { BaseMessagePromptTemplateLike } from '@langchain/core/prompts'; -import { FakeTool } from '@langchain/core/utils/testing'; import { Buffer } from 'buffer'; import { mock } from 'jest-mock-extended'; import type { ToolsAgentAction } from 'langchain/dist/agents/tool_calling/output_parser'; import type { Tool } from 'langchain/tools'; import type { IExecuteFunctions } from 'n8n-workflow'; -import { NodeOperationError, BINARY_ENCODING } from 'n8n-workflow'; +import { NodeOperationError, BINARY_ENCODING, NodeConnectionTypes } from 'n8n-workflow'; import type { ZodType } from 'zod'; import { z } from 'zod'; -import * as helpersModule from '@utils/helpers'; import type { N8nOutputParser } from '@utils/output_parsers/N8nOutputParser'; import { @@ -28,31 +25,16 @@ import { getTools, } from '../agents/ToolsAgent/execute'; -// We need to override the imported getConnectedTools so that we control its output. -jest.spyOn(helpersModule, 'getConnectedTools').mockResolvedValue([FakeTool as unknown as Tool]); - function getFakeOutputParser(returnSchema?: ZodType): N8nOutputParser { const fakeOutputParser = mock(); (fakeOutputParser.getSchema as jest.Mock).mockReturnValue(returnSchema); return fakeOutputParser; } -function createFakeExecuteFunctions(overrides: Partial = {}): IExecuteFunctions { - return { - getNodeParameter: jest - .fn() - .mockImplementation((_arg1: string, _arg2: number, defaultValue?: unknown) => { - return defaultValue; - }), - getNode: jest.fn().mockReturnValue({}), - getInputConnectionData: jest.fn().mockResolvedValue({}), - getInputData: jest.fn().mockReturnValue([]), - continueOnFail: jest.fn().mockReturnValue(false), - logger: { debug: jest.fn() }, - helpers: {}, - ...overrides, - } as unknown as IExecuteFunctions; -} +const mockHelpers = mock(); +const mockContext = mock({ helpers: mockHelpers }); + +beforeEach(() => jest.resetAllMocks()); describe('getOutputParserSchema', () => { it('should return a default schema if getSchema returns undefined', () => { @@ -74,6 +56,7 @@ describe('getOutputParserSchema', () => { describe('extractBinaryMessages', () => { it('should extract a binary message from the input data when no id is provided', async () => { const fakeItem = { + json: {}, binary: { img1: { mimeType: 'image/png', @@ -82,11 +65,9 @@ describe('extractBinaryMessages', () => { }, }, }; - const ctx = createFakeExecuteFunctions({ - getInputData: jest.fn().mockReturnValue([fakeItem]), - }); + mockContext.getInputData.mockReturnValue([fakeItem]); - const humanMsg: HumanMessage = await extractBinaryMessages(ctx, 0); + const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0); // Expect the HumanMessage's content to be an array containing one binary message. expect(Array.isArray(humanMsg.content)).toBe(true); expect(humanMsg.content[0]).toEqual({ @@ -97,6 +78,7 @@ describe('extractBinaryMessages', () => { it('should extract a binary message using binary stream if id is provided', async () => { const fakeItem = { + json: {}, binary: { img2: { mimeType: 'image/jpeg', @@ -105,21 +87,16 @@ describe('extractBinaryMessages', () => { }, }, }; - // Cast fakeHelpers as any to satisfy type requirements. - const fakeHelpers = { - getBinaryStream: jest.fn().mockResolvedValue('stream'), - binaryToBuffer: jest.fn().mockResolvedValue(Buffer.from('fakebufferdata')), - } as unknown as IExecuteFunctions['helpers']; - const ctx = createFakeExecuteFunctions({ - getInputData: jest.fn().mockReturnValue([fakeItem]), - helpers: fakeHelpers, - }); - const humanMsg: HumanMessage = await extractBinaryMessages(ctx, 0); + mockHelpers.getBinaryStream.mockResolvedValue(mock()); + mockHelpers.binaryToBuffer.mockResolvedValue(Buffer.from('fakebufferdata')); + mockContext.getInputData.mockReturnValue([fakeItem]); + + const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0); // eslint-disable-next-line @typescript-eslint/unbound-method - expect(fakeHelpers.getBinaryStream).toHaveBeenCalledWith('1234'); + expect(mockHelpers.getBinaryStream).toHaveBeenCalledWith('1234'); // eslint-disable-next-line @typescript-eslint/unbound-method - expect(fakeHelpers.binaryToBuffer).toHaveBeenCalled(); + expect(mockHelpers.binaryToBuffer).toHaveBeenCalled(); const expectedUrl = `data:image/jpeg;base64,${Buffer.from('fakebufferdata').toString( BINARY_ENCODING, )}`; @@ -173,48 +150,48 @@ describe('getChatModel', () => { const fakeChatModel = mock(); fakeChatModel.bindTools = jest.fn(); fakeChatModel.lc_namespace = ['chat_models']; + mockContext.getInputConnectionData.mockResolvedValue(fakeChatModel); - const ctx = createFakeExecuteFunctions({ - getInputConnectionData: jest.fn().mockResolvedValue(fakeChatModel), - }); - const model = await getChatModel(ctx); + const model = await getChatModel(mockContext); expect(model).toEqual(fakeChatModel); }); it('should throw if the model is not a valid chat model', async () => { const fakeInvalidModel = mock(); // missing bindTools & lc_namespace fakeInvalidModel.lc_namespace = []; - const ctx = createFakeExecuteFunctions({ - getInputConnectionData: jest.fn().mockResolvedValue(fakeInvalidModel), - getNode: jest.fn().mockReturnValue({}), - }); - await expect(getChatModel(ctx)).rejects.toThrow(NodeOperationError); + mockContext.getInputConnectionData.mockResolvedValue(fakeInvalidModel); + mockContext.getNode.mockReturnValue(mock()); + await expect(getChatModel(mockContext)).rejects.toThrow(NodeOperationError); }); }); describe('getOptionalMemory', () => { it('should return the memory if available', async () => { const fakeMemory = { some: 'memory' }; - const ctx = createFakeExecuteFunctions({ - getInputConnectionData: jest.fn().mockResolvedValue(fakeMemory), - }); - const memory = await getOptionalMemory(ctx); + mockContext.getInputConnectionData.mockResolvedValue(fakeMemory); + + const memory = await getOptionalMemory(mockContext); expect(memory).toEqual(fakeMemory); }); }); describe('getTools', () => { + beforeEach(() => { + const fakeTool = mock(); + mockContext.getInputConnectionData + .calledWith(NodeConnectionTypes.AiTool, 0) + .mockResolvedValue([fakeTool]); + }); + it('should retrieve tools without appending if outputParser is not provided', async () => { - const ctx = createFakeExecuteFunctions(); - const tools = await getTools(ctx); + const tools = await getTools(mockContext); expect(tools.length).toEqual(1); }); it('should retrieve tools and append the structured output parser tool if outputParser is provided', async () => { const fakeOutputParser = getFakeOutputParser(z.object({ text: z.string() })); - const ctx = createFakeExecuteFunctions(); - const tools = await getTools(ctx, fakeOutputParser); + const tools = await getTools(mockContext, fakeOutputParser); // Our fake getConnectedTools returns one tool; with outputParser, one extra is appended. expect(tools.length).toEqual(2); const dynamicTool = tools.find((t) => t.name === 'format_final_json_response'); @@ -225,6 +202,7 @@ describe('getTools', () => { describe('prepareMessages', () => { it('should include a binary message if binary data is present and passthroughBinaryImages is true', async () => { const fakeItem = { + json: {}, binary: { img1: { mimeType: 'image/png', @@ -232,10 +210,8 @@ describe('prepareMessages', () => { }, }, }; - const ctx = createFakeExecuteFunctions({ - getInputData: jest.fn().mockReturnValue([fakeItem]), - }); - const messages = await prepareMessages(ctx, 0, { + mockContext.getInputData.mockReturnValue([fakeItem]); + const messages = await prepareMessages(mockContext, 0, { systemMessage: 'Test system', passthroughBinaryImages: true, }); @@ -248,10 +224,8 @@ describe('prepareMessages', () => { it('should not include a binary message if no binary data is present', async () => { const fakeItem = { json: {} }; // no binary key - const ctx = createFakeExecuteFunctions({ - getInputData: jest.fn().mockReturnValue([fakeItem]), - }); - const messages = await prepareMessages(ctx, 0, { + mockContext.getInputData.mockReturnValue([fakeItem]); + const messages = await prepareMessages(mockContext, 0, { systemMessage: 'Test system', passthroughBinaryImages: true, }); diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 976e9c7018..9f378a034a 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -83,6 +83,7 @@ "dist/nodes/llms/LMCohere/LmCohere.node.js", "dist/nodes/llms/LMOllama/LmOllama.node.js", "dist/nodes/llms/LMOpenHuggingFaceInference/LmOpenHuggingFaceInference.node.js", + "dist/nodes/Mcp/McpTrigger.node.js", "dist/nodes/memory/MemoryBufferWindow/MemoryBufferWindow.node.js", "dist/nodes/memory/MemoryMotorhead/MemoryMotorhead.node.js", "dist/nodes/memory/MemoryPostgresChat/MemoryPostgresChat.node.js", @@ -167,6 +168,7 @@ "@langchain/qdrant": "0.1.1", "@langchain/redis": "0.1.0", "@langchain/textsplitters": "0.1.0", + "@modelcontextprotocol/sdk": "1.9.0", "@mozilla/readability": "0.6.0", "@n8n/json-schema-to-zod": "workspace:*", "@n8n/typeorm": "0.3.20-12", diff --git a/packages/@n8n/nodes-langchain/utils/helpers.ts b/packages/@n8n/nodes-langchain/utils/helpers.ts index 99bb541503..e7719c6790 100644 --- a/packages/@n8n/nodes-langchain/utils/helpers.ts +++ b/packages/@n8n/nodes-langchain/utils/helpers.ts @@ -184,7 +184,7 @@ export function escapeSingleCurlyBrackets(text?: string): string | undefined { } export const getConnectedTools = async ( - ctx: IExecuteFunctions, + ctx: IExecuteFunctions | IWebhookFunctions, enforceUniqueNames: boolean, convertStructuredTool: boolean = true, escapeCurlyBrackets: boolean = false, diff --git a/packages/cli/src/abstract-server.ts b/packages/cli/src/abstract-server.ts index 016b54cba5..0dd6b15607 100644 --- a/packages/cli/src/abstract-server.ts +++ b/packages/cli/src/abstract-server.ts @@ -53,6 +53,10 @@ export abstract class AbstractServer { protected endpointWebhookWaiting: string; + protected endpointMcp: string; + + protected endpointMcpTest: string; + protected webhooksEnabled = true; protected testWebhooksEnabled = false; @@ -73,15 +77,19 @@ export abstract class AbstractServer { this.sslKey = config.getEnv('ssl_key'); this.sslCert = config.getEnv('ssl_cert'); - this.restEndpoint = this.globalConfig.endpoints.rest; + const { endpoints } = this.globalConfig; + this.restEndpoint = endpoints.rest; - this.endpointForm = this.globalConfig.endpoints.form; - this.endpointFormTest = this.globalConfig.endpoints.formTest; - this.endpointFormWaiting = this.globalConfig.endpoints.formWaiting; + this.endpointForm = endpoints.form; + this.endpointFormTest = endpoints.formTest; + this.endpointFormWaiting = endpoints.formWaiting; - this.endpointWebhook = this.globalConfig.endpoints.webhook; - this.endpointWebhookTest = this.globalConfig.endpoints.webhookTest; - this.endpointWebhookWaiting = this.globalConfig.endpoints.webhookWaiting; + this.endpointWebhook = endpoints.webhook; + this.endpointWebhookTest = endpoints.webhookTest; + this.endpointWebhookWaiting = endpoints.webhookWaiting; + + this.endpointMcp = endpoints.mcp; + this.endpointMcpTest = endpoints.mcpTest; this.logger = Container.get(Logger); } @@ -202,6 +210,9 @@ export abstract class AbstractServer { `/${this.endpointWebhookWaiting}/:path{/:suffix}`, createWebhookHandlerFor(Container.get(WaitingWebhooks)), ); + + // Register a handler for live MCP servers + this.app.all(`/${this.endpointMcp}/*path`, liveWebhooksRequestHandler); } if (this.testWebhooksEnabled) { @@ -210,6 +221,9 @@ export abstract class AbstractServer { // Register a handler this.app.all(`/${this.endpointFormTest}/*path`, testWebhooksRequestHandler); this.app.all(`/${this.endpointWebhookTest}/*path`, testWebhooksRequestHandler); + + // Register a handler for test MCP servers + this.app.all(`/${this.endpointMcpTest}/*path`, testWebhooksRequestHandler); } // Block bots from scanning the application diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 4e25d313fa..fe5120abbd 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -29,6 +29,8 @@ export const STARTING_NODES = [ 'n8n-nodes-base.manualTrigger', ]; +export const MCP_TRIGGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.mcpTrigger'; + export const NODE_PACKAGE_PREFIX = 'n8n-nodes-'; export const STARTER_TEMPLATE_NAME = `${NODE_PACKAGE_PREFIX}starter`; diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index f8597059e9..6f11f39c6e 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -89,6 +89,8 @@ export class FrontendService { endpointForm: this.globalConfig.endpoints.form, endpointFormTest: this.globalConfig.endpoints.formTest, endpointFormWaiting: this.globalConfig.endpoints.formWaiting, + endpointMcp: this.globalConfig.endpoints.mcp, + endpointMcpTest: this.globalConfig.endpoints.mcpTest, endpointWebhook: this.globalConfig.endpoints.webhook, endpointWebhookTest: this.globalConfig.endpoints.webhookTest, endpointWebhookWaiting: this.globalConfig.endpoints.webhookWaiting, diff --git a/packages/cli/src/webhooks/waiting-webhooks.ts b/packages/cli/src/webhooks/waiting-webhooks.ts index 585a39a18c..0eefbde4ce 100644 --- a/packages/cli/src/webhooks/waiting-webhooks.ts +++ b/packages/cli/src/webhooks/waiting-webhooks.ts @@ -178,7 +178,7 @@ export class WaitingWebhooks implements IWebhookManager { webhook.httpMethod === req.method && webhook.path === (suffix ?? '') && webhook.webhookDescription.restartWebhook === true && - (webhook.webhookDescription.isForm || false) === this.includeForms, + (webhook.webhookDescription.nodeType === 'form' || false) === this.includeForms, ); if (webhookData === undefined) { diff --git a/packages/cli/src/webhooks/webhook-helpers.ts b/packages/cli/src/webhooks/webhook-helpers.ts index 4b60d31a76..9043081afb 100644 --- a/packages/cli/src/webhooks/webhook-helpers.ts +++ b/packages/cli/src/webhooks/webhook-helpers.ts @@ -48,6 +48,7 @@ import { finished } from 'stream/promises'; import { ActiveExecutions } from '@/active-executions'; import config from '@/config'; +import { MCP_TRIGGER_NODE_TYPE } from '@/constants'; import type { Project } from '@/databases/entities/project'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; @@ -446,6 +447,32 @@ export async function executeWebhook( } } + // TODO: remove this hack, and make sure that execution data is properly created before the MCP trigger is executed + if (workflowStartNode.type === MCP_TRIGGER_NODE_TYPE) { + // Initialize the data of the webhook node + const nodeExecutionStack: IExecuteData[] = []; + nodeExecutionStack.push({ + node: workflowStartNode, + data: { + main: [], + }, + source: null, + }); + runExecutionData = + runExecutionData || + ({ + startData: {}, + resultData: { + runData: {}, + }, + executionData: { + contextData: {}, + nodeExecutionStack, + waitingExecution: {}, + }, + } as IRunExecutionData); + } + try { webhookResultData = await Container.get(WebhookService).runWebhook( workflow, diff --git a/packages/core/src/execution-engine/node-execution-context/webhook-context.ts b/packages/core/src/execution-engine/node-execution-context/webhook-context.ts index 73f7164753..fa8224defb 100644 --- a/packages/core/src/execution-engine/node-execution-context/webhook-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/webhook-context.ts @@ -144,7 +144,7 @@ export class WebhookContext extends NodeExecutionContext implements IWebhookFunc // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment { json: this.additionalData.httpRequest?.body || {} }, ]; - const runExecutionData: IRunExecutionData = { + const runExecutionData: IRunExecutionData = this.runExecutionData ?? { resultData: { runData: {}, }, diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 2016b7db0a..ad7e2e9e5e 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -954,6 +954,8 @@ export interface RootState { endpointForm: string; endpointFormTest: string; endpointFormWaiting: string; + endpointMcp: string; + endpointMcpTest: string; endpointWebhook: string; endpointWebhookTest: string; endpointWebhookWaiting: string; diff --git a/packages/frontend/editor-ui/src/__tests__/defaults.ts b/packages/frontend/editor-ui/src/__tests__/defaults.ts index 49f7141fbc..df7a1a9e12 100644 --- a/packages/frontend/editor-ui/src/__tests__/defaults.ts +++ b/packages/frontend/editor-ui/src/__tests__/defaults.ts @@ -15,6 +15,8 @@ export const defaultSettings: FrontendSettings = { endpointForm: '', endpointFormTest: '', endpointFormWaiting: '', + endpointMcp: '', + endpointMcpTest: '', endpointWebhook: '', endpointWebhookTest: '', endpointWebhookWaiting: '', diff --git a/packages/frontend/editor-ui/src/components/NodeWebhooks.vue b/packages/frontend/editor-ui/src/components/NodeWebhooks.vue index 79cf28a21c..5af8beed83 100644 --- a/packages/frontend/editor-ui/src/components/NodeWebhooks.vue +++ b/packages/frontend/editor-ui/src/components/NodeWebhooks.vue @@ -4,6 +4,7 @@ import { useToast } from '@/composables/useToast'; import { CHAT_TRIGGER_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, + MCP_TRIGGER_NODE_TYPE, OPEN_URL_PANEL_TRIGGER_NODE_TYPES, PRODUCTION_ONLY_TRIGGER_NODE_TYPES, } from '@/constants'; @@ -31,7 +32,7 @@ const isMinimized = ref( props.nodeTypeDescription && !OPEN_URL_PANEL_TRIGGER_NODE_TYPES.includes(props.nodeTypeDescription.name), ); -const showUrlFor = ref('test'); +const showUrlFor = ref<'test' | 'production'>('test'); const isProductionOnly = computed(() => { return ( @@ -95,6 +96,18 @@ const baseText = computed(() => { copyMessage: i18n.baseText('nodeWebhooks.showMessage.message.formTrigger'), }; + case MCP_TRIGGER_NODE_TYPE: + return { + toggleTitle: i18n.baseText('nodeWebhooks.webhookUrls.mcpTrigger'), + clickToDisplay: i18n.baseText('nodeWebhooks.clickToDisplayWebhookUrls.mcpTrigger'), + clickToHide: i18n.baseText('nodeWebhooks.clickToHideWebhookUrls.mcpTrigger'), + clickToCopy: i18n.baseText('nodeWebhooks.clickToCopyWebhookUrls.mcpTrigger'), + testUrl: i18n.baseText('nodeWebhooks.testUrl'), + productionUrl: i18n.baseText('nodeWebhooks.productionUrl'), + copyTitle: i18n.baseText('nodeWebhooks.showMessage.title.mcpTrigger'), + copyMessage: undefined, + }; + default: return { toggleTitle: i18n.baseText('nodeWebhooks.webhookUrls'), diff --git a/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts b/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts index 9759171f93..83d5db3c0c 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts @@ -27,6 +27,7 @@ import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { EnterpriseEditionFeature, FORM_TRIGGER_NODE_TYPE, + MCP_TRIGGER_NODE_TYPE, STICKY_NODE_TYPE, UPDATE_WEBHOOK_ID_NODE_TYPES, WEBHOOK_NODE_TYPE, @@ -1071,7 +1072,7 @@ export function useCanvasOperations({ router }: { router: ReturnType { - const state = ref({ + const state = ref({ baseUrl: VUE_APP_URL_BASE_API ?? window.BASE_PATH, restEndpoint: !window.REST_ENDPOINT || window.REST_ENDPOINT === '{{REST_ENDPOINT}}' @@ -17,6 +17,8 @@ export const useRootStore = defineStore(STORES.ROOT, () => { endpointForm: 'form', endpointFormTest: 'form-test', endpointFormWaiting: 'form-waiting', + endpointMcp: 'mcp', + endpointMcpTest: 'mcp-test', endpointWebhook: 'webhook', endpointWebhookTest: 'webhook-test', endpointWebhookWaiting: 'webhook-waiting', @@ -49,10 +51,18 @@ export const useRootStore = defineStore(STORES.ROOT, () => { const webhookUrl = computed(() => `${state.value.urlBaseWebhook}${state.value.endpointWebhook}`); + const webhookTestUrl = computed( + () => `${state.value.urlBaseEditor}${state.value.endpointWebhookTest}`, + ); + const webhookWaitingUrl = computed( () => `${state.value.urlBaseEditor}${state.value.endpointWebhookWaiting}`, ); + const mcpUrl = computed(() => `${state.value.urlBaseWebhook}${state.value.endpointMcp}`); + + const mcpTestUrl = computed(() => `${state.value.urlBaseEditor}${state.value.endpointMcpTest}`); + const pushRef = computed(() => state.value.pushRef); const binaryDataMode = computed(() => state.value.binaryDataMode); @@ -67,10 +77,6 @@ export const useRootStore = defineStore(STORES.ROOT, () => { const OAuthCallbackUrls = computed(() => state.value.oauthCallbackUrls); - const webhookTestUrl = computed( - () => `${state.value.urlBaseEditor}${state.value.endpointWebhookTest}`, - ); - const restUrl = computed(() => `${state.value.baseUrl}${state.value.restEndpoint}`); const executionTimeout = computed(() => state.value.executionTimeout); @@ -164,7 +170,7 @@ export const useRootStore = defineStore(STORES.ROOT, () => { state.value.defaultLocale = locale; }; - const setBinaryDataMode = (binaryDataMode: string) => { + const setBinaryDataMode = (binaryDataMode: RootState['binaryDataMode']) => { state.value.binaryDataMode = binaryDataMode; }; @@ -175,6 +181,8 @@ export const useRootStore = defineStore(STORES.ROOT, () => { formUrl, formTestUrl, formWaitingUrl, + mcpUrl, + mcpTestUrl, webhookUrl, webhookTestUrl, webhookWaitingUrl, diff --git a/packages/nodes-base/credentials/HttpBearerAuth.credentials.ts b/packages/nodes-base/credentials/HttpBearerAuth.credentials.ts new file mode 100644 index 0000000000..d916080725 --- /dev/null +++ b/packages/nodes-base/credentials/HttpBearerAuth.credentials.ts @@ -0,0 +1,43 @@ +import type { IAuthenticateGeneric, ICredentialType, INodeProperties, Icon } from 'n8n-workflow'; + +// eslint-disable-next-line n8n-nodes-base/cred-class-name-unsuffixed +export class HttpBearerAuth implements ICredentialType { + // eslint-disable-next-line n8n-nodes-base/cred-class-field-name-unsuffixed + name = 'httpBearerAuth'; + + displayName = 'Bearer Auth'; + + documentationUrl = 'httpRequest'; + + genericAuth = true; + + icon: Icon = 'node:n8n-nodes-base.httpRequest'; + + properties: INodeProperties[] = [ + { + displayName: 'Bearer Token', + name: 'token', + type: 'string', + typeOptions: { + password: true, + }, + default: '', + }, + { + displayName: + 'This credential uses the "Authorization" header. To use a custom header, use a "Custom Auth" credential instead', + name: 'useCustomAuth', + type: 'notice', + default: '', + }, + ]; + + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + headers: { + Authorization: 'Bearer ={{$credentials.token}}', + }, + }, + }; +} diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index 4a9e9f245e..5d98716d4c 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -283,7 +283,7 @@ export class Form extends Node { path: '', restartWebhook: true, isFullPath: true, - isForm: true, + nodeType: 'form', }, { name: 'default', @@ -292,7 +292,7 @@ export class Form extends Node { path: '', restartWebhook: true, isFullPath: true, - isForm: true, + nodeType: 'form', }, ], properties: [ diff --git a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts index 39a5a9f9d6..b809d57798 100644 --- a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts +++ b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts @@ -52,7 +52,7 @@ const descriptionV2: INodeTypeDescription = { isFullPath: true, path: '={{ $parameter["path"] || $parameter["options"]?.path || $webhookId }}', ndvHideUrl: true, - isForm: true, + nodeType: 'form', }, { name: 'default', @@ -62,7 +62,7 @@ const descriptionV2: INodeTypeDescription = { isFullPath: true, path: '={{ $parameter["path"] || $parameter["options"]?.path || $webhookId }}', ndvHideMethod: true, - isForm: true, + nodeType: 'form', }, ], eventTriggerDescription: 'Waiting for you to submit the form', diff --git a/packages/nodes-base/nodes/Wait/Wait.node.ts b/packages/nodes-base/nodes/Wait/Wait.node.ts index 63a30ee42a..3934a528b9 100644 --- a/packages/nodes-base/nodes/Wait/Wait.node.ts +++ b/packages/nodes-base/nodes/Wait/Wait.node.ts @@ -256,7 +256,7 @@ export class Wait extends Webhook { path: webhookPath, restartWebhook: true, isFullPath: true, - isForm: true, + nodeType: 'form', }, { name: 'default', @@ -266,7 +266,7 @@ export class Wait extends Webhook { path: webhookPath, restartWebhook: true, isFullPath: true, - isForm: true, + nodeType: 'form', }, ], properties: [ diff --git a/packages/nodes-base/nodes/Webhook/utils.ts b/packages/nodes-base/nodes/Webhook/utils.ts index 8728b256db..13cb642f65 100644 --- a/packages/nodes-base/nodes/Webhook/utils.ts +++ b/packages/nodes-base/nodes/Webhook/utils.ts @@ -208,6 +208,20 @@ export async function validateWebhookAuthentication( // Provided authentication data is wrong throw new WebhookAuthorizationError(403); } + } else if (authentication === 'bearerAuth') { + let expectedAuth: ICredentialDataDecryptedObject | undefined; + try { + expectedAuth = await ctx.getCredentials('httpBearerAuth'); + } catch {} + + const expectedToken = expectedAuth?.token as string; + if (!expectedToken) { + throw new WebhookAuthorizationError(500, 'No authentication data defined on node!'); + } + + if (headers.authorization !== `Bearer ${expectedToken}`) { + throw new WebhookAuthorizationError(403); + } } else if (authentication === 'headerAuth') { // Special header with value is needed to call webhook let expectedAuth: ICredentialDataDecryptedObject | undefined; diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index c7fbb517e8..ba0461117f 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -167,6 +167,7 @@ "dist/credentials/HighLevelOAuth2Api.credentials.js", "dist/credentials/HomeAssistantApi.credentials.js", "dist/credentials/HttpBasicAuth.credentials.js", + "dist/credentials/HttpBearerAuth.credentials.js", "dist/credentials/HttpDigestAuth.credentials.js", "dist/credentials/HttpHeaderAuth.credentials.js", "dist/credentials/HttpCustomAuth.credentials.js", diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 91b9e5f72f..ba020ac3d6 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1988,7 +1988,7 @@ export interface IWebhookDescription { responseMode?: WebhookResponseMode | string; responseData?: WebhookResponseData | string; restartWebhook?: boolean; - isForm?: boolean; + nodeType?: 'webhook' | 'form' | 'mcp'; ndvHideUrl?: string | boolean; // If true the webhook will not be displayed in the editor ndvHideMethod?: string | boolean; // If true the method will not be displayed in the editor } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7301392df4..a126b914c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -613,6 +613,9 @@ importers: '@langchain/textsplitters': specifier: 0.1.0 version: 0.1.0(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))) + '@modelcontextprotocol/sdk': + specifier: 1.9.0 + version: 1.9.0 '@mozilla/readability': specifier: 0.6.0 version: 0.6.0 @@ -4574,6 +4577,10 @@ packages: peerDependencies: zod: '>= 3' + '@modelcontextprotocol/sdk@1.9.0': + resolution: {integrity: sha512-Jq2EUCQpe0iyO5FGpzVYDNFR6oR53AIrwph9yWl7uSc7IWUMsrmpmSaTGra5hQNunXpM+9oit85p924jWuHzUA==} + engines: {node: '>=18'} + '@mongodb-js/saslprep@1.1.9': resolution: {integrity: sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==} @@ -7643,6 +7650,10 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + cpu-features@0.0.10: resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} engines: {node: '>=10.0.0'} @@ -8525,10 +8536,18 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.0.1: + resolution: {integrity: sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==} + engines: {node: '>=18.0.0'} + eventsource@2.0.2: resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==} engines: {node: '>=12.0.0'} + eventsource@3.0.6: + resolution: {integrity: sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==} + engines: {node: '>=18.0.0'} + execa@4.1.0: resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} engines: {node: '>=10'} @@ -16950,6 +16969,21 @@ snapshots: dependencies: zod: 3.24.1 + '@modelcontextprotocol/sdk@1.9.0': + dependencies: + content-type: 1.0.5 + cors: 2.8.5 + cross-spawn: 7.0.6 + eventsource: 3.0.6 + express: 5.1.0 + express-rate-limit: 7.5.0(express@5.1.0) + pkce-challenge: 5.0.0(patch_hash=651e785d0b7bbf5be9210e1e895c39a16dc3ce8a5a3843b4819565fb6e175b90) + raw-body: 3.0.0 + zod: 3.24.1 + zod-to-json-schema: 3.24.1(zod@3.24.1) + transitivePeerDependencies: + - supports-color + '@mongodb-js/saslprep@1.1.9': dependencies: sparse-bitfield: 3.0.3 @@ -20821,6 +20855,11 @@ snapshots: core-util-is@1.0.3: {} + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cpu-features@0.0.10: dependencies: buildcheck: 0.0.6 @@ -21697,7 +21736,7 @@ snapshots: eslint-import-resolver-node@0.3.9: dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) is-core-module: 2.13.1 resolve: 1.22.8 transitivePeerDependencies: @@ -21722,7 +21761,7 @@ snapshots: eslint-module-utils@2.8.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) optionalDependencies: '@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@5.8.2) eslint: 8.57.0 @@ -21742,7 +21781,7 @@ snapshots: array.prototype.findlastindex: 1.2.3 array.prototype.flat: 1.3.2 array.prototype.flatmap: 1.3.2 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 @@ -21937,8 +21976,14 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.0.1: {} + eventsource@2.0.2: {} + eventsource@3.0.6: + dependencies: + eventsource-parser: 3.0.1 + execa@4.1.0: dependencies: cross-spawn: 7.0.6 @@ -22555,7 +22600,7 @@ snapshots: array-parallel: 0.1.3 array-series: 0.1.5 cross-spawn: 4.0.2 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -25427,7 +25472,7 @@ snapshots: pdf-parse@1.1.1: dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) node-ensure: 0.0.0 transitivePeerDependencies: - supports-color @@ -26271,7 +26316,7 @@ snapshots: rhea@1.0.24: dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color