import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; import { StructuredToolkit } from 'n8n-core'; import { type IDataObject, type IExecuteFunctions, type INodeExecutionData, NodeConnectionTypes, NodeOperationError, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities'; import { getTools } from './loadOptions'; import type { McpToolIncludeMode } from './types'; import { buildMcpToolName, createCallTool, getSelectedTools, mcpToolToDynamicTool } from './utils'; import { credentials, transportSelect } from '../shared/descriptions'; import type { McpAuthenticationOption, McpServerTransport } from '../shared/types'; import { connectMcpClient, getAllTools, getAuthHeaders, mapToNodeOperationError, tryRefreshOAuth2Token, } from '../shared/utils'; import type { JSONSchema7 } from 'json-schema'; import pick from 'lodash/pick'; /** * Get node parameters for MCP client configuration */ function getNodeConfig( ctx: ISupplyDataFunctions | IExecuteFunctions, itemIndex: number, ): { authentication: McpAuthenticationOption; timeout: number; serverTransport: McpServerTransport; endpointUrl: string; mode: McpToolIncludeMode; includeTools: string[]; excludeTools: string[]; } { const node = ctx.getNode(); const authentication = ctx.getNodeParameter( 'authentication', itemIndex, ) as McpAuthenticationOption; const timeout = ctx.getNodeParameter('options.timeout', itemIndex, 60000) as number; let serverTransport: McpServerTransport; let endpointUrl: string; if (node.typeVersion === 1) { serverTransport = 'sse'; endpointUrl = ctx.getNodeParameter('sseEndpoint', itemIndex) as string; } else { serverTransport = ctx.getNodeParameter('serverTransport', itemIndex) as McpServerTransport; endpointUrl = ctx.getNodeParameter('endpointUrl', itemIndex) as string; } const mode = ctx.getNodeParameter('include', itemIndex) as McpToolIncludeMode; const includeTools = ctx.getNodeParameter('includeTools', itemIndex, []) as string[]; const excludeTools = ctx.getNodeParameter('excludeTools', itemIndex, []) as string[]; return { authentication, timeout, serverTransport, endpointUrl, mode, includeTools, excludeTools, }; } /** * Connect to MCP server and get filtered tools */ async function connectAndGetTools( ctx: ISupplyDataFunctions | IExecuteFunctions, config: ReturnType, ) { const node = ctx.getNode(); const { headers } = await getAuthHeaders(ctx, config.authentication); const client = await connectMcpClient({ serverTransport: config.serverTransport, endpointUrl: config.endpointUrl, headers, name: node.type, version: node.typeVersion, onUnauthorized: async (headers) => await tryRefreshOAuth2Token(ctx, config.authentication, headers), }); if (!client.ok) { return { client, mcpTools: null, error: client.error }; } try { const allTools = await getAllTools(client.result); const mcpTools = getSelectedTools({ tools: allTools, mode: config.mode, includeTools: config.includeTools, excludeTools: config.excludeTools, }); return { client: client.result, mcpTools, error: null }; } catch (error) { await client.result.close(); throw error; } } export class McpClientTool implements INodeType { description: INodeTypeDescription = { displayName: 'MCP Client Tool', name: 'mcpClientTool', icon: { light: 'file:../mcp.svg', dark: 'file:../mcp.dark.svg', }, group: ['output'], version: [1, 1.1, 1.2], description: 'Connect tools from an MCP Server', defaults: { name: 'MCP Client', }, codex: { categories: ['AI'], subcategories: { AI: ['Tools'], Tools: ['Recommended Tools'], }, alias: ['Model Context Protocol', 'MCP Client'], resources: { primaryDocumentation: [ { url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolmcp/', }, ], }, }, inputs: [], outputs: [{ type: NodeConnectionTypes.AiTool, displayName: 'Tools' }], credentials, properties: [ getConnectionHintNoticeField([NodeConnectionTypes.AiAgent]), { displayName: 'SSE Endpoint', name: 'sseEndpoint', type: 'string', description: 'SSE Endpoint of your MCP server', placeholder: 'e.g. https://my-mcp-server.ai/sse', default: '', required: true, displayOptions: { show: { '@version': [1], }, }, }, { displayName: 'Endpoint', name: 'endpointUrl', type: 'string', description: 'Endpoint of your MCP server', placeholder: 'e.g. https://my-mcp-server.ai/mcp', default: '', required: true, displayOptions: { show: { '@version': [{ _cnd: { gte: 1.1 } }], }, }, }, transportSelect({ defaultOption: 'sse', displayOptions: { show: { '@version': [1.1], }, }, }), transportSelect({ defaultOption: 'httpStreamable', displayOptions: { show: { '@version': [{ _cnd: { gte: 1.2 } }], }, }, }), { displayName: 'Authentication', name: 'authentication', type: 'options', options: [ { name: 'Bearer Auth', value: 'bearerAuth', }, { name: 'Header Auth', value: 'headerAuth', }, { name: 'None', value: 'none', }, ], default: 'none', description: 'The way to authenticate with your endpoint', displayOptions: { show: { '@version': [{ _cnd: { lt: 1.2 } }], }, }, }, { displayName: 'Authentication', name: 'authentication', type: 'options', options: [ { name: 'Bearer Auth', value: 'bearerAuth', }, { name: 'Header Auth', value: 'headerAuth', }, { name: 'MCP OAuth2', value: 'mcpOAuth2Api', }, { name: 'Multiple Headers Auth', value: 'multipleHeadersAuth', }, { name: 'None', value: 'none', }, ], default: 'none', description: 'The way to authenticate with your endpoint', displayOptions: { show: { '@version': [{ _cnd: { gte: 1.2 } }], }, }, }, { displayName: 'Credentials', name: 'credentials', type: 'credentials', default: '', displayOptions: { show: { authentication: ['headerAuth', 'bearerAuth', 'mcpOAuth2Api', 'multipleHeadersAuth'], }, }, }, { displayName: 'Tools to Include', name: 'include', type: 'options', description: 'How to select the tools you want to be exposed to the AI Agent', default: 'all', options: [ { name: 'All', value: 'all', description: 'Also include all unchanged fields from the input', }, { name: 'Selected', value: 'selected', description: 'Also include the tools listed in the parameter "Tools to Include"', }, { name: 'All Except', value: 'except', description: 'Exclude the tools listed in the parameter "Tools to Exclude"', }, ], }, { displayName: 'Tools to Include', name: 'includeTools', type: 'multiOptions', default: [], description: 'Choose from the list, or specify IDs using an expression', typeOptions: { loadOptionsMethod: 'getTools', loadOptionsDependsOn: ['sseEndpoint'], }, displayOptions: { show: { include: ['selected'], }, }, }, { displayName: 'Tools to Exclude', name: 'excludeTools', type: 'multiOptions', default: [], description: 'Choose from the list, or specify IDs using an expression', typeOptions: { loadOptionsMethod: 'getTools', }, displayOptions: { show: { include: ['except'], }, }, }, { displayName: 'Options', name: 'options', placeholder: 'Add Option', description: 'Additional options to add', type: 'collection', default: {}, options: [ { displayName: 'Timeout', name: 'timeout', type: 'number', typeOptions: { minValue: 1, }, default: 60000, description: 'Time in ms to wait for tool calls to finish', }, ], }, ], }; methods = { loadOptions: { getTools, }, }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { const node = this.getNode(); const config = getNodeConfig(this, itemIndex); const setError = (error: NodeOperationError): SupplyData => { this.addOutputData(NodeConnectionTypes.AiTool, itemIndex, error); throw error; }; const signal = this.getExecutionCancelSignal(); if (signal?.aborted) { return setError(new NodeOperationError(node, 'Execution was cancelled', { itemIndex })); } const { client, mcpTools, error } = await connectAndGetTools(this, config); if (error) { this.logger.error('McpClientTool: Failed to connect to MCP Server', { error }); return setError(mapToNodeOperationError(node, error)); } this.logger.debug('McpClientTool: Successfully connected to MCP Server'); if (!mcpTools?.length) { return setError( new NodeOperationError(node, 'MCP Server returned no tools', { itemIndex, description: 'Connected successfully to your MCP server but it returned an empty list of tools.', }), ); } const tools = mcpTools.map((tool) => { const prefixedName = buildMcpToolName(node.name, tool.name); return logWrapper( mcpToolToDynamicTool( { ...tool, name: prefixedName }, createCallTool( tool.name, client, config.timeout, (errorMessage) => { const error = new NodeOperationError(node, errorMessage, { itemIndex }); void this.addOutputData(NodeConnectionTypes.AiTool, itemIndex, error); this.logger.error(`McpClientTool: Tool "${tool.name}" failed to execute`, { error, }); }, () => this.getExecutionCancelSignal(), ), ), this, ); }); this.logger.debug(`McpClientTool: Connected to MCP Server with ${tools.length} tools`); const toolkit = new StructuredToolkit(tools); return { response: toolkit, closeFunction: async () => await client.close() }; } async execute(this: IExecuteFunctions): Promise { const node = this.getNode(); const items = this.getInputData(); const returnData: INodeExecutionData[] = []; for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { const signal = this.getExecutionCancelSignal(); if (signal?.aborted) { throw new NodeOperationError(node, 'Execution was cancelled', { itemIndex }); } const item = items[itemIndex]; const config = getNodeConfig(this, itemIndex); const { client, mcpTools, error } = await connectAndGetTools(this, config); if (error) { throw new NodeOperationError(node, error.error, { itemIndex }); } try { if (!mcpTools?.length) { throw new NodeOperationError(node, 'MCP Server returned no tools', { itemIndex }); } // Check for tool name in item.json.tool (for toolkit execution from agent) if (!item.json.tool || typeof item.json.tool !== 'string') { throw new NodeOperationError(node, 'Tool name not found in item.json.tool or item.tool', { itemIndex, }); } const toolName = item.json.tool; for (const tool of mcpTools) { const prefixedName = buildMcpToolName(node.name, tool.name); if (toolName === prefixedName) { // Extract the tool name from arguments before passing to MCP const { tool: _, ...toolArguments } = item.json; const schema: JSONSchema7 = tool.inputSchema; // When additionalProperties is not explicitly true, filter to schema-defined properties. // Otherwise, pass all arguments through const sanitizedToolArguments: IDataObject = schema.additionalProperties !== true ? pick(toolArguments, Object.keys(schema.properties ?? {})) : toolArguments; const params: { name: string; arguments: IDataObject; } = { name: tool.name, arguments: sanitizedToolArguments, }; const result = await client.callTool(params, CallToolResultSchema, { timeout: config.timeout, signal: this.getExecutionCancelSignal(), }); returnData.push({ json: { response: result.content as IDataObject, }, pairedItem: { item: itemIndex, }, }); } } } finally { await client.close(); } } return [returnData]; } }