From 34252f53f9ca586c15f40713678074a358d877f1 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Wed, 9 Apr 2025 17:31:53 +0200 Subject: [PATCH] feat(MCP Client Tool Node): Add MCP Client Tool Node to connect to MCP servers over SSE (#14464) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ Co-authored-by: JP van Oosten --- .../McpClientTool/McpClientTool.node.test.ts | 287 ++++++++++++++++++ .../mcp/McpClientTool/McpClientTool.node.ts | 262 ++++++++++++++++ .../nodes/mcp/McpClientTool/loadOptions.ts | 32 ++ .../nodes/mcp/McpClientTool/types.ts | 7 + .../nodes/mcp/McpClientTool/utils.ts | 212 +++++++++++++ .../McpTrigger}/FlushingSSEServerTransport.ts | 0 .../{Mcp => mcp/McpTrigger}/McpServer.ts | 9 +- .../McpTrigger}/McpTrigger.node.ts | 29 +- .../FlushingSSEServerTransport.test.ts | 0 .../McpTrigger}/__test__/McpServer.test.ts | 0 .../__test__/McpTrigger.node.test.ts | 0 .../nodes/{Mcp => mcp}/mcp.dark.svg | 0 .../nodes/{Mcp => mcp}/mcp.svg | 0 packages/@n8n/nodes-langchain/package.json | 3 +- .../@n8n/nodes-langchain/utils/helpers.ts | 15 +- .../@n8n/nodes-langchain/utils/logWrapper.ts | 23 +- .../utils/tests/helpers.test.ts | 29 ++ .../cli/src/load-nodes-and-credentials.ts | 7 + packages/cli/src/node-types.ts | 6 + .../NodeCreator/composables/useViewStacks.ts | 14 +- .../src/components/Node/NodeCreator/utils.ts | 10 +- packages/frontend/editor-ui/src/constants.ts | 2 + .../src/utils/fromAIOverrideUtils.ts | 7 +- .../editor-ui/src/utils/nodeViewUtils.ts | 7 +- 24 files changed, 926 insertions(+), 35 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/loadOptions.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/types.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/utils.ts rename packages/@n8n/nodes-langchain/nodes/{Mcp => mcp/McpTrigger}/FlushingSSEServerTransport.ts (100%) rename packages/@n8n/nodes-langchain/nodes/{Mcp => mcp/McpTrigger}/McpServer.ts (95%) rename packages/@n8n/nodes-langchain/nodes/{Mcp => mcp/McpTrigger}/McpTrigger.node.ts (92%) rename packages/@n8n/nodes-langchain/nodes/{Mcp => mcp/McpTrigger}/__test__/FlushingSSEServerTransport.test.ts (100%) rename packages/@n8n/nodes-langchain/nodes/{Mcp => mcp/McpTrigger}/__test__/McpServer.test.ts (100%) rename packages/@n8n/nodes-langchain/nodes/{Mcp => mcp/McpTrigger}/__test__/McpTrigger.node.test.ts (100%) rename packages/@n8n/nodes-langchain/nodes/{Mcp => mcp}/mcp.dark.svg (100%) rename packages/@n8n/nodes-langchain/nodes/{Mcp => mcp}/mcp.svg (100%) diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.test.ts new file mode 100644 index 0000000000..a1a92261a7 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.test.ts @@ -0,0 +1,287 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { mock } from 'jest-mock-extended'; +import { + NodeOperationError, + type ILoadOptionsFunctions, + type INode, + type ISupplyDataFunctions, +} from 'n8n-workflow'; + +import { getTools } from './loadOptions'; +import { McpClientTool } from './McpClientTool.node'; +import { McpToolkit } from './utils'; + +jest.mock('@modelcontextprotocol/sdk/client/sse.js'); +jest.mock('@modelcontextprotocol/sdk/client/index.js'); + +describe('McpClientTool', () => { + describe('loadOptions: getTools', () => { + it('should return a list of tools', async () => { + jest.spyOn(Client.prototype, 'connect').mockResolvedValue(); + jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({ + tools: [ + { + name: 'MyTool', + description: 'MyTool does something', + inputSchema: { type: 'object', properties: { input: { type: 'string' } } }, + }, + ], + }); + + const result = await getTools.call( + mock({ getNode: jest.fn(() => mock({ typeVersion: 1 })) }), + ); + + expect(result).toEqual([ + { + description: 'MyTool does something', + name: 'MyTool', + value: 'MyTool', + }, + ]); + }); + + it('should handle errors', async () => { + jest.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error('Fail!')); + + const node = mock({ typeVersion: 1 }); + await expect( + getTools.call(mock({ getNode: jest.fn(() => node) })), + ).rejects.toEqual(new NodeOperationError(node, 'Could not connect to your MCP server')); + }); + }); + + describe('supplyData', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should return a valid toolkit with usable tools', async () => { + jest.spyOn(Client.prototype, 'connect').mockResolvedValue(); + jest + .spyOn(Client.prototype, 'callTool') + .mockResolvedValue({ content: [{ type: 'text', text: 'result from tool' }] }); + jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({ + tools: [ + { + name: 'MyTool1', + description: 'MyTool1 does something', + inputSchema: { type: 'object', properties: { input: { type: 'string' } } }, + }, + { + name: 'MyTool2', + description: 'MyTool2 does something', + inputSchema: { type: 'object', properties: { input2: { type: 'string' } } }, + }, + ], + }); + + const supplyDataResult = await new McpClientTool().supplyData.call( + mock({ + getNode: jest.fn(() => mock({ typeVersion: 1 })), + logger: { debug: jest.fn(), error: jest.fn() }, + addInputData: jest.fn(() => ({ index: 0 })), + }), + 0, + ); + + expect(supplyDataResult.closeFunction).toBeInstanceOf(Function); + expect(supplyDataResult.response).toBeInstanceOf(McpToolkit); + + const tools = (supplyDataResult.response as McpToolkit).getTools(); + expect(tools).toHaveLength(2); + + const toolCallResult = await tools[0].invoke({ input: 'foo' }); + expect(toolCallResult).toEqual([{ type: 'text', text: 'result from tool' }]); + }); + + it('should support selecting tools to expose', async () => { + jest.spyOn(Client.prototype, 'connect').mockResolvedValue(); + jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({ + tools: [ + { + name: 'MyTool1', + description: 'MyTool1 does something', + inputSchema: { type: 'object', properties: { input: { type: 'string' } } }, + }, + { + name: 'MyTool2', + description: 'MyTool2 does something', + inputSchema: { type: 'object', properties: { input2: { type: 'string' } } }, + }, + ], + }); + + const supplyDataResult = await new McpClientTool().supplyData.call( + mock({ + getNode: jest.fn(() => + mock({ + typeVersion: 1, + }), + ), + getNodeParameter: jest.fn((key, _index) => { + const parameters: Record = { + include: 'selected', + includeTools: ['MyTool2'], + }; + return parameters[key]; + }), + logger: { debug: jest.fn(), error: jest.fn() }, + addInputData: jest.fn(() => ({ index: 0 })), + }), + 0, + ); + + expect(supplyDataResult.closeFunction).toBeInstanceOf(Function); + expect(supplyDataResult.response).toBeInstanceOf(McpToolkit); + + const tools = (supplyDataResult.response as McpToolkit).getTools(); + expect(tools).toHaveLength(1); + expect(tools[0].name).toBe('MyTool2'); + }); + + it('should support selecting tools to exclude', async () => { + jest.spyOn(Client.prototype, 'connect').mockResolvedValue(); + jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({ + tools: [ + { + name: 'MyTool1', + description: 'MyTool1 does something', + inputSchema: { type: 'object', properties: { input: { type: 'string' } } }, + }, + { + name: 'MyTool2', + description: 'MyTool2 does something', + inputSchema: { type: 'object', properties: { input2: { type: 'string' } } }, + }, + ], + }); + + const supplyDataResult = await new McpClientTool().supplyData.call( + mock({ + getNode: jest.fn(() => + mock({ + typeVersion: 1, + }), + ), + getNodeParameter: jest.fn((key, _index) => { + const parameters: Record = { + include: 'except', + excludeTools: ['MyTool2'], + }; + return parameters[key]; + }), + logger: { debug: jest.fn(), error: jest.fn() }, + addInputData: jest.fn(() => ({ index: 0 })), + }), + 0, + ); + + expect(supplyDataResult.closeFunction).toBeInstanceOf(Function); + expect(supplyDataResult.response).toBeInstanceOf(McpToolkit); + + const tools = (supplyDataResult.response as McpToolkit).getTools(); + expect(tools).toHaveLength(1); + expect(tools[0].name).toBe('MyTool1'); + }); + + it('should support header auth', async () => { + jest.spyOn(Client.prototype, 'connect').mockResolvedValue(); + jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({ + tools: [ + { + name: 'MyTool1', + description: 'MyTool1 does something', + inputSchema: { type: 'object', properties: { input: { type: 'string' } } }, + }, + ], + }); + + const supplyDataResult = await new McpClientTool().supplyData.call( + mock({ + getNode: jest.fn(() => mock({ typeVersion: 1 })), + getNodeParameter: jest.fn((key, _index) => { + const parameters: Record = { + include: 'except', + excludeTools: ['MyTool2'], + authentication: 'headerAuth', + sseEndpoint: 'https://my-mcp-endpoint.ai/sse', + }; + return parameters[key]; + }), + logger: { debug: jest.fn(), error: jest.fn() }, + addInputData: jest.fn(() => ({ index: 0 })), + getCredentials: jest.fn().mockResolvedValue({ name: 'my-header', value: 'header-value' }), + }), + 0, + ); + + expect(supplyDataResult.closeFunction).toBeInstanceOf(Function); + expect(supplyDataResult.response).toBeInstanceOf(McpToolkit); + + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue(mock()); + const url = new URL('https://my-mcp-endpoint.ai/sse'); + expect(SSEClientTransport).toHaveBeenCalledTimes(1); + expect(SSEClientTransport).toHaveBeenCalledWith(url, { + eventSourceInit: { fetch: expect.any(Function) }, + requestInit: { headers: { 'my-header': 'header-value' } }, + }); + + const customFetch = jest.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch; + await customFetch?.(url); + expect(fetchSpy).toHaveBeenCalledWith(url, { + headers: { Accept: 'text/event-stream', 'my-header': 'header-value' }, + }); + }); + + it('should support bearer auth', async () => { + jest.spyOn(Client.prototype, 'connect').mockResolvedValue(); + jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({ + tools: [ + { + name: 'MyTool1', + description: 'MyTool1 does something', + inputSchema: { type: 'object', properties: { input: { type: 'string' } } }, + }, + ], + }); + + const supplyDataResult = await new McpClientTool().supplyData.call( + mock({ + getNode: jest.fn(() => mock({ typeVersion: 1 })), + getNodeParameter: jest.fn((key, _index) => { + const parameters: Record = { + include: 'except', + excludeTools: ['MyTool2'], + authentication: 'bearerAuth', + sseEndpoint: 'https://my-mcp-endpoint.ai/sse', + }; + return parameters[key]; + }), + logger: { debug: jest.fn(), error: jest.fn() }, + addInputData: jest.fn(() => ({ index: 0 })), + getCredentials: jest.fn().mockResolvedValue({ token: 'my-token' }), + }), + 0, + ); + + expect(supplyDataResult.closeFunction).toBeInstanceOf(Function); + expect(supplyDataResult.response).toBeInstanceOf(McpToolkit); + + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue(mock()); + const url = new URL('https://my-mcp-endpoint.ai/sse'); + expect(SSEClientTransport).toHaveBeenCalledTimes(1); + expect(SSEClientTransport).toHaveBeenCalledWith(url, { + eventSourceInit: { fetch: expect.any(Function) }, + requestInit: { headers: { Authorization: 'Bearer my-token' } }, + }); + + const customFetch = jest.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch; + await customFetch?.(url); + expect(fetchSpy).toHaveBeenCalledWith(url, { + headers: { Accept: 'text/event-stream', Authorization: 'Bearer my-token' }, + }); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.ts new file mode 100644 index 0000000000..6de92816da --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.ts @@ -0,0 +1,262 @@ +import { + NodeConnectionTypes, + NodeOperationError, + type INodeType, + type INodeTypeDescription, + type ISupplyDataFunctions, + type SupplyData, +} from 'n8n-workflow'; + +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + +import { getTools } from './loadOptions'; +import type { McpAuthenticationOption, McpToolIncludeMode } from './types'; +import { + connectMcpClient, + createCallTool, + getAllTools, + getAuthHeaders, + getSelectedTools, + McpToolkit, + mcpToolToDynamicTool, +} from './utils'; + +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, + description: 'Connect tools from an MCP Server', + defaults: { + name: 'MCP Client', + }, + codex: { + categories: ['AI'], + subcategories: { + AI: ['Model Context Protocol', 'Tools'], + }, + alias: ['Model Context Protocol', 'MCP Client'], + resources: { + primaryDocumentation: [ + { + url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.mcpclienttool/', + }, + ], + }, + }, + inputs: [], + outputs: [{ type: NodeConnectionTypes.AiTool, displayName: 'Tools' }], + 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'], + }, + }, + }, + ], + 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, + }, + { + 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 SSE endpoint', + }, + { + displayName: 'Credentials', + name: 'credentials', + type: 'credentials', + default: '', + displayOptions: { + show: { + authentication: ['headerAuth', 'bearerAuth'], + }, + }, + }, + { + 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'], + }, + }, + }, + ], + }; + + methods = { + loadOptions: { + getTools, + }, + }; + + async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { + const authentication = this.getNodeParameter( + 'authentication', + itemIndex, + ) as McpAuthenticationOption; + const sseEndpoint = this.getNodeParameter('sseEndpoint', itemIndex) as string; + const node = this.getNode(); + const { headers } = await getAuthHeaders(this, authentication); + const client = await connectMcpClient({ + sseEndpoint, + headers, + name: node.type, + version: node.typeVersion, + }); + + const setError = (message: string, description?: string): SupplyData => { + const error = new NodeOperationError(node, message, { itemIndex, description }); + this.addOutputData(NodeConnectionTypes.AiTool, itemIndex, error); + throw error; + }; + + if (!client.ok) { + this.logger.error('McpClientTool: Failed to connect to MCP Server', { + error: client.error, + }); + + switch (client.error.type) { + case 'invalid_url': + return setError('Could not connect to your MCP server. The provided URL is invalid.'); + case 'connection': + default: + return setError('Could not connect to your MCP server'); + } + } + + this.logger.debug('McpClientTool: Successfully connected to MCP Server'); + + const mode = this.getNodeParameter('include', itemIndex) as McpToolIncludeMode; + const includeTools = this.getNodeParameter('includeTools', itemIndex, []) as string[]; + const excludeTools = this.getNodeParameter('excludeTools', itemIndex, []) as string[]; + + const allTools = await getAllTools(client.result); + const mcpTools = getSelectedTools({ + tools: allTools, + mode, + includeTools, + excludeTools, + }); + + if (!mcpTools.length) { + return setError( + 'MCP Server returned no tools', + 'Connected successfully to your MCP server but it returned an empty list of tools.', + ); + } + + const tools = mcpTools.map((tool) => + logWrapper( + mcpToolToDynamicTool( + tool, + createCallTool(tool.name, client.result, (error) => { + this.logger.error(`McpClientTool: Tool "${tool.name}" failed to execute`, { error }); + throw new NodeOperationError(node, `Failed to execute tool "${tool.name}"`, { + description: error, + }); + }), + ), + this, + ), + ); + + this.logger.debug(`McpClientTool: Connected to MCP Server with ${tools.length} tools`); + + const toolkit = new McpToolkit(tools); + + return { response: toolkit, closeFunction: async () => await client.result.close() }; + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/loadOptions.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/loadOptions.ts new file mode 100644 index 0000000000..b4be13c18f --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/loadOptions.ts @@ -0,0 +1,32 @@ +import { + type ILoadOptionsFunctions, + type INodePropertyOptions, + NodeOperationError, +} from 'n8n-workflow'; + +import type { McpAuthenticationOption } from './types'; +import { connectMcpClient, getAllTools, getAuthHeaders } from './utils'; + +export async function getTools(this: ILoadOptionsFunctions): Promise { + const authentication = this.getNodeParameter('authentication') as McpAuthenticationOption; + const sseEndpoint = this.getNodeParameter('sseEndpoint') as string; + const node = this.getNode(); + const { headers } = await getAuthHeaders(this, authentication); + const client = await connectMcpClient({ + sseEndpoint, + headers, + name: node.type, + version: node.typeVersion, + }); + + if (!client.ok) { + throw new NodeOperationError(this.getNode(), 'Could not connect to your MCP server'); + } + + const tools = await getAllTools(client.result); + return tools.map((tool) => ({ + name: tool.name, + value: tool.name, + description: tool.description, + })); +} diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/types.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/types.ts new file mode 100644 index 0000000000..05ea55245e --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/types.ts @@ -0,0 +1,7 @@ +import type { JSONSchema7 } from 'json-schema'; + +export type McpTool = { name: string; description?: string; inputSchema: JSONSchema7 }; + +export type McpToolIncludeMode = 'all' | 'selected' | 'except'; + +export type McpAuthenticationOption = 'none' | 'headerAuth' | 'bearerAuth'; diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/utils.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/utils.ts new file mode 100644 index 0000000000..a1f7238a0e --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/utils.ts @@ -0,0 +1,212 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { CompatibilityCallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import { Toolkit } from 'langchain/agents'; +import { DynamicStructuredTool, type DynamicStructuredToolInput } from 'langchain/tools'; +import { + createResultError, + createResultOk, + type IDataObject, + type IExecuteFunctions, + type Result, +} from 'n8n-workflow'; +import { type ZodTypeAny } from 'zod'; + +import { convertJsonSchemaToZod } from '@utils/schemaParsing'; + +import type { McpAuthenticationOption, McpTool, McpToolIncludeMode } from './types'; + +export async function getAllTools(client: Client, cursor?: string): Promise { + const { tools, nextCursor } = await client.listTools({ cursor }); + + if (nextCursor) { + return (tools as McpTool[]).concat(await getAllTools(client, nextCursor)); + } + + return tools as McpTool[]; +} + +export function getSelectedTools({ + mode, + includeTools, + excludeTools, + tools, +}: { + mode: McpToolIncludeMode; + includeTools?: string[]; + excludeTools?: string[]; + tools: McpTool[]; +}) { + switch (mode) { + case 'selected': { + if (!includeTools?.length) return tools; + const include = new Set(includeTools); + return tools.filter((tool) => include.has(tool.name)); + } + case 'except': { + const except = new Set(excludeTools ?? []); + return tools.filter((tool) => !except.has(tool.name)); + } + case 'all': + default: + return tools; + } +} + +export const getErrorDescriptionFromToolCall = (result: unknown): string | undefined => { + if (result && typeof result === 'object') { + if ('content' in result && Array.isArray(result.content)) { + const errorMessage = (result.content as Array<{ type: 'text'; text: string }>).find( + (content) => content && typeof content === 'object' && typeof content.text === 'string', + )?.text; + return errorMessage; + } else if ('toolResult' in result && typeof result.toolResult === 'string') { + return result.toolResult; + } + if ('message' in result && typeof result.message === 'string') { + return result.message; + } + } + + return undefined; +}; + +export const createCallTool = + (name: string, client: Client, onError: (error: string | undefined) => void) => + async (args: IDataObject) => { + let result: Awaited>; + try { + result = await client.callTool({ name, arguments: args }, CompatibilityCallToolResultSchema); + } catch (error) { + return onError(getErrorDescriptionFromToolCall(error)); + } + + if (result.isError) { + return onError(getErrorDescriptionFromToolCall(result)); + } + + if (result.toolResult !== undefined) { + return result.toolResult; + } + + if (result.content !== undefined) { + return result.content; + } + + return result; + }; + +export function mcpToolToDynamicTool( + tool: McpTool, + onCallTool: DynamicStructuredToolInput['func'], +) { + return new DynamicStructuredTool({ + name: tool.name, + description: tool.description ?? '', + schema: convertJsonSchemaToZod(tool.inputSchema), + func: onCallTool, + metadata: { isFromToolkit: true }, + }); +} + +export class McpToolkit extends Toolkit { + constructor(public tools: Array>) { + super(); + } +} + +function safeCreateUrl(url: string, baseUrl?: string | URL): Result { + try { + return createResultOk(new URL(url, baseUrl)); + } catch (error) { + return createResultError(error); + } +} + +function normalizeAndValidateUrl(input: string): Result { + const withProtocol = !/^https?:\/\//i.test(input) ? `https://${input}` : input; + const parsedUrl = safeCreateUrl(withProtocol); + + if (!parsedUrl.ok) { + return createResultError(parsedUrl.error); + } + + return parsedUrl; +} + +type ConnectMcpClientError = + | { type: 'invalid_url'; error: Error } + | { type: 'connection'; error: Error }; +export async function connectMcpClient({ + headers, + sseEndpoint, + name, + version, +}: { + sseEndpoint: string; + headers?: Record; + name: string; + version: number; +}): Promise> { + try { + const endpoint = normalizeAndValidateUrl(sseEndpoint); + + if (!endpoint.ok) { + return createResultError({ type: 'invalid_url', error: endpoint.error }); + } + + const transport = new SSEClientTransport(endpoint.result, { + eventSourceInit: { + fetch: async (url, init) => + await fetch(url, { + ...init, + headers: { + ...headers, + Accept: 'text/event-stream', + }, + }), + }, + requestInit: { headers }, + }); + + const client = new Client( + { name, version: version.toString() }, + { capabilities: { tools: {} } }, + ); + + await client.connect(transport); + return createResultOk(client); + } catch (error) { + return createResultError({ type: 'connection', error }); + } +} + +export async function getAuthHeaders( + ctx: Pick, + authentication: McpAuthenticationOption, +): Promise<{ headers?: Record }> { + switch (authentication) { + case 'headerAuth': { + const header = await ctx + .getCredentials<{ name: string; value: string }>('httpHeaderAuth') + .catch(() => null); + + if (!header) return {}; + + return { headers: { [header.name]: header.value } }; + } + case 'bearerAuth': { + const result = await ctx + .getCredentials<{ token: string }>('httpBearerAuth') + .catch(() => null); + + if (!result) return {}; + + return { headers: { Authorization: `Bearer ${result.token}` } }; + } + case 'none': + default: { + return {}; + } + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/Mcp/FlushingSSEServerTransport.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/FlushingSSEServerTransport.ts similarity index 100% rename from packages/@n8n/nodes-langchain/nodes/Mcp/FlushingSSEServerTransport.ts rename to packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/FlushingSSEServerTransport.ts diff --git a/packages/@n8n/nodes-langchain/nodes/Mcp/McpServer.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/McpServer.ts similarity index 95% rename from packages/@n8n/nodes-langchain/nodes/Mcp/McpServer.ts rename to packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/McpServer.ts index 0fad621191..7723cae019 100644 --- a/packages/@n8n/nodes-langchain/nodes/Mcp/McpServer.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/McpServer.ts @@ -148,8 +148,13 @@ export class McpServer { 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 }; + if (typeof result === 'object') { + return { content: [{ type: 'text', text: JSON.stringify(result) }] }; + } + if (typeof result === 'string') { + return { content: [{ type: 'text', text: result }] }; + } + return { content: [{ type: 'text', text: String(result) }] }; } catch (error) { this.logger.error(`Error while executing Tool ${requestedTool.name}: ${error}`); return { isError: true, content: [{ type: 'text', text: `Error: ${error.message}` }] }; diff --git a/packages/@n8n/nodes-langchain/nodes/Mcp/McpTrigger.node.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/McpTrigger.node.ts similarity index 92% rename from packages/@n8n/nodes-langchain/nodes/Mcp/McpTrigger.node.ts rename to packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/McpTrigger.node.ts index f7d2e5e242..0ef34f6e9b 100644 --- a/packages/@n8n/nodes-langchain/nodes/Mcp/McpTrigger.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/McpTrigger.node.ts @@ -17,8 +17,8 @@ export class McpTrigger extends Node { displayName: 'MCP Server Trigger', name: 'mcpTrigger', icon: { - light: 'file:mcp.svg', - dark: 'file:mcp.dark.svg', + light: 'file:../mcp.svg', + dark: 'file:../mcp.dark.svg', }, group: ['trigger'], version: 1, @@ -27,6 +27,21 @@ export class McpTrigger extends Node { defaults: { name: 'MCP Server Trigger', }, + codex: { + categories: ['AI', 'Core Nodes'], + subcategories: { + AI: ['Root Nodes', 'Model Context Protocol'], + 'Core Nodes': ['Other Trigger Nodes'], + }, + alias: ['Model Context Protocol', 'MCP Server'], + resources: { + primaryDocumentation: [ + { + url: 'https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.mcptrigger/', + }, + ], + }, + }, triggerPanel: { header: 'Listen for MCP events', executionsHelp: { @@ -65,15 +80,6 @@ export class McpTrigger extends Node { }, }, }, - { - name: 'httpCustomAuth', - required: true, - displayOptions: { - show: { - authentication: ['customAuth'], - }, - }, - }, ], properties: [ { @@ -84,7 +90,6 @@ export class McpTrigger extends Node { { 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', diff --git a/packages/@n8n/nodes-langchain/nodes/Mcp/__test__/FlushingSSEServerTransport.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/__test__/FlushingSSEServerTransport.test.ts similarity index 100% rename from packages/@n8n/nodes-langchain/nodes/Mcp/__test__/FlushingSSEServerTransport.test.ts rename to packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/__test__/FlushingSSEServerTransport.test.ts diff --git a/packages/@n8n/nodes-langchain/nodes/Mcp/__test__/McpServer.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/__test__/McpServer.test.ts similarity index 100% rename from packages/@n8n/nodes-langchain/nodes/Mcp/__test__/McpServer.test.ts rename to packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/__test__/McpServer.test.ts diff --git a/packages/@n8n/nodes-langchain/nodes/Mcp/__test__/McpTrigger.node.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/__test__/McpTrigger.node.test.ts similarity index 100% rename from packages/@n8n/nodes-langchain/nodes/Mcp/__test__/McpTrigger.node.test.ts rename to packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/__test__/McpTrigger.node.test.ts diff --git a/packages/@n8n/nodes-langchain/nodes/Mcp/mcp.dark.svg b/packages/@n8n/nodes-langchain/nodes/mcp/mcp.dark.svg similarity index 100% rename from packages/@n8n/nodes-langchain/nodes/Mcp/mcp.dark.svg rename to packages/@n8n/nodes-langchain/nodes/mcp/mcp.dark.svg diff --git a/packages/@n8n/nodes-langchain/nodes/Mcp/mcp.svg b/packages/@n8n/nodes-langchain/nodes/mcp/mcp.svg similarity index 100% rename from packages/@n8n/nodes-langchain/nodes/Mcp/mcp.svg rename to packages/@n8n/nodes-langchain/nodes/mcp/mcp.svg diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 9f378a034a..a0c324fd19 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -83,7 +83,8 @@ "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/mcp/McpClientTool/McpClientTool.node.js", + "dist/nodes/mcp/McpTrigger/McpTrigger.node.js", "dist/nodes/memory/MemoryBufferWindow/MemoryBufferWindow.node.js", "dist/nodes/memory/MemoryMotorhead/MemoryMotorhead.node.js", "dist/nodes/memory/MemoryPostgresChat/MemoryPostgresChat.node.js", diff --git a/packages/@n8n/nodes-langchain/utils/helpers.ts b/packages/@n8n/nodes-langchain/utils/helpers.ts index e7719c6790..92fea1b285 100644 --- a/packages/@n8n/nodes-langchain/utils/helpers.ts +++ b/packages/@n8n/nodes-langchain/utils/helpers.ts @@ -3,6 +3,7 @@ import type { BaseChatModel } from '@langchain/core/language_models/chat_models' import type { BaseLLM } from '@langchain/core/language_models/llms'; import type { BaseMessage } from '@langchain/core/messages'; import type { Tool } from '@langchain/core/tools'; +import { Toolkit } from 'langchain/agents'; import type { BaseChatMemory } from 'langchain/memory'; import { NodeConnectionTypes, NodeOperationError, jsonStringify } from 'n8n-workflow'; import type { @@ -189,14 +190,22 @@ export const getConnectedTools = async ( convertStructuredTool: boolean = true, escapeCurlyBrackets: boolean = false, ) => { - const connectedTools = - ((await ctx.getInputConnectionData(NodeConnectionTypes.AiTool, 0)) as Tool[]) || []; + const connectedTools = ( + ((await ctx.getInputConnectionData(NodeConnectionTypes.AiTool, 0)) as Array) ?? + [] + ).flatMap((toolOrToolkit) => { + if (toolOrToolkit instanceof Toolkit) { + return toolOrToolkit.getTools() as Tool[]; + } + + return toolOrToolkit; + }); if (!enforceUniqueNames) return connectedTools; const seenNames = new Set(); - const finalTools = []; + const finalTools: Tool[] = []; for (const tool of connectedTools) { const { name } = tool; diff --git a/packages/@n8n/nodes-langchain/utils/logWrapper.ts b/packages/@n8n/nodes-langchain/utils/logWrapper.ts index a58217f005..209929de50 100644 --- a/packages/@n8n/nodes-langchain/utils/logWrapper.ts +++ b/packages/@n8n/nodes-langchain/utils/logWrapper.ts @@ -6,11 +6,12 @@ import { Embeddings } from '@langchain/core/embeddings'; import type { InputValues, MemoryVariables, OutputValues } from '@langchain/core/memory'; import type { BaseMessage } from '@langchain/core/messages'; import { BaseRetriever } from '@langchain/core/retrievers'; -import type { Tool } from '@langchain/core/tools'; +import type { StructuredTool, Tool } from '@langchain/core/tools'; import { VectorStore } from '@langchain/core/vectorstores'; import { TextSplitter } from '@langchain/textsplitters'; import type { BaseDocumentLoader } from 'langchain/dist/document_loaders/base'; import type { + IDataObject, IExecuteFunctions, INodeExecutionData, ISupplyDataFunctions, @@ -94,9 +95,10 @@ export function callMethodSync( } } -export function logWrapper( - originalInstance: +export function logWrapper< + T extends | Tool + | StructuredTool | BaseChatMemory | BaseChatMessageHistory | BaseRetriever @@ -108,8 +110,7 @@ export function logWrapper( | VectorStore | N8nBinaryLoader | N8nJsonLoader, - executeFunctions: IExecuteFunctions | ISupplyDataFunctions, -) { +>(originalInstance: T, executeFunctions: IExecuteFunctions | ISupplyDataFunctions): T { return new Proxy(originalInstance, { get: (target, prop) => { let connectionType: NodeConnectionType | undefined; @@ -372,8 +373,16 @@ export function logWrapper( if (prop === '_call' && '_call' in target) { return async (query: string): Promise => { connectionType = NodeConnectionTypes.AiTool; + const inputData: IDataObject = { query }; + + if (target.metadata?.isFromToolkit) { + inputData.tool = { + name: target.name, + description: target.description, + }; + } const { index } = executeFunctions.addInputData(connectionType, [ - [{ json: { query } }], + [{ json: inputData }], ]); const response = (await callMethodAsync.call(target, { @@ -384,7 +393,7 @@ export function logWrapper( arguments: [query], })) as string; - logAiEvent(executeFunctions, 'ai-tool-called', { query, response }); + logAiEvent(executeFunctions, 'ai-tool-called', { ...inputData, response }); executeFunctions.addOutputData(connectionType, index, [[{ json: { response } }]]); return response; }; diff --git a/packages/@n8n/nodes-langchain/utils/tests/helpers.test.ts b/packages/@n8n/nodes-langchain/utils/tests/helpers.test.ts index b86a18a76e..d9de5b1d3b 100644 --- a/packages/@n8n/nodes-langchain/utils/tests/helpers.test.ts +++ b/packages/@n8n/nodes-langchain/utils/tests/helpers.test.ts @@ -1,4 +1,5 @@ import { DynamicTool, type Tool } from '@langchain/core/tools'; +import { Toolkit } from 'langchain/agents'; import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; import { NodeOperationError } from 'n8n-workflow'; import type { ISupplyDataFunctions, IExecuteFunctions, INode } from 'n8n-workflow'; @@ -242,6 +243,34 @@ describe('getConnectedTools', () => { const tools = await getConnectedTools(mockExecuteFunctions, true, false); expect(tools[0]).toBe(mockN8nTool); }); + + it('should flatten tools from a toolkit', async () => { + class MockToolkit extends Toolkit { + tools: Tool[]; + + constructor(tools: unknown[]) { + super(); + this.tools = tools as Tool[]; + } + } + const mockTools = [ + { name: 'tool1', description: 'desc1' }, + + new MockToolkit([ + { name: 'toolkitTool1', description: 'toolkitToolDesc1' }, + { name: 'toolkitTool2', description: 'toolkitToolDesc2' }, + ]), + ]; + + mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools); + + const tools = await getConnectedTools(mockExecuteFunctions, false); + expect(tools).toEqual([ + { name: 'tool1', description: 'desc1' }, + { name: 'toolkitTool1', description: 'toolkitToolDesc1' }, + { name: 'toolkitTool2', description: 'toolkitToolDesc2' }, + ]); + }); }); describe('unwrapNestedOutput', () => { diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index 247941cd54..fd189a845f 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -400,6 +400,13 @@ export class LoadNodesAndCredentials { } } + recognizesNode(fullNodeType: string): boolean { + const [packageName, nodeType] = fullNodeType.split('.'); + const { loaders } = this; + const loader = loaders[packageName]; + return !!loader && nodeType in loader.known.nodes; + } + getNode(fullNodeType: string): LoadedClass { const [packageName, nodeType] = fullNodeType.split('.'); const { loaders } = this; diff --git a/packages/cli/src/node-types.ts b/packages/cli/src/node-types.ts index 1fc6dd651b..e4a678f0be 100644 --- a/packages/cli/src/node-types.ts +++ b/packages/cli/src/node-types.ts @@ -37,6 +37,12 @@ export class NodeTypes implements INodeTypes { const toolRequested = nodeType.endsWith('Tool'); + // If an existing node name ends in `Tool`, then return that node, instead of creating a fake Tool node + if (toolRequested && this.loadNodesAndCredentials.recognizesNode(nodeType)) { + const node = this.loadNodesAndCredentials.getNode(nodeType); + return NodeHelpers.getVersionedNodeType(node.type, version); + } + // Make sure the nodeType to actually get from disk is the un-wrapped type if (toolRequested) { nodeType = nodeType.replace(/Tool$/, ''); diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/composables/useViewStacks.ts b/packages/frontend/editor-ui/src/components/Node/NodeCreator/composables/useViewStacks.ts index ed206d82ef..14b29dfb1d 100644 --- a/packages/frontend/editor-ui/src/components/Node/NodeCreator/composables/useViewStacks.ts +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/composables/useViewStacks.ts @@ -6,6 +6,7 @@ import type { SimplifiedNodeType, } from '@/Interface'; import { + AI_CATEGORY_MCP_NODES, AI_CATEGORY_ROOT_NODES, AI_CATEGORY_TOOLS, AI_CODE_NODE_TYPE, @@ -222,16 +223,17 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => { const aiSubNodes = difference(aiNodes, aiRootNodes); aiSubNodes.forEach((node) => { - const section = node.properties.codex?.subcategories?.[AI_SUBCATEGORY]?.[0]; + const subcategories = node.properties.codex?.subcategories ?? {}; + const section = subcategories[AI_SUBCATEGORY]?.[0]; if (section) { - const subSection = node.properties.codex?.subcategories?.[section]?.[0]; + const subSection = subcategories[section]?.[0]; const sectionKey = subSection ?? section; const currentItems = sectionsMap.get(sectionKey)?.items ?? []; - const isSubnodesSection = - !node.properties.codex?.subcategories?.[AI_SUBCATEGORY].includes( - AI_CATEGORY_ROOT_NODES, - ); + const isSubnodesSection = !( + subcategories[AI_SUBCATEGORY].includes(AI_CATEGORY_ROOT_NODES) || + subcategories[AI_SUBCATEGORY].includes(AI_CATEGORY_MCP_NODES) + ); let title = section; if (isSubnodesSection) { diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/utils.ts b/packages/frontend/editor-ui/src/components/Node/NodeCreator/utils.ts index f829c56344..cc90c8b853 100644 --- a/packages/frontend/editor-ui/src/components/Node/NodeCreator/utils.ts +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/utils.ts @@ -57,12 +57,18 @@ export function subcategorizeItems(items: SimplifiedNodeType[]) { // Only some subcategories are allowed let subcategories: string[] = [DEFAULT_SUBCATEGORY]; - WHITE_LISTED_SUBCATEGORIES.forEach((category) => { + const matchedSubcategories = WHITE_LISTED_SUBCATEGORIES.flatMap((category) => { if (item.codex?.categories?.includes(category)) { - subcategories = item.codex?.subcategories?.[category] ?? []; + return item.codex?.subcategories?.[category] ?? []; } + + return []; }); + if (matchedSubcategories.length > 0) { + subcategories = matchedSubcategories; + } + subcategories.forEach((subcategory: string) => { if (!acc[subcategory]) { acc[subcategory] = []; diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts index 0636ec5e06..413624895e 100644 --- a/packages/frontend/editor-ui/src/constants.ts +++ b/packages/frontend/editor-ui/src/constants.ts @@ -118,6 +118,7 @@ export const BAMBOO_HR_NODE_TYPE = 'n8n-nodes-base.bambooHr'; export const CALENDLY_TRIGGER_NODE_TYPE = 'n8n-nodes-base.calendlyTrigger'; export const CODE_NODE_TYPE = 'n8n-nodes-base.code'; export const AI_CODE_NODE_TYPE = '@n8n/n8n-nodes-langchain.code'; +export const AI_MCP_TOOL_NODE_TYPE = '@n8n/n8n-nodes-langchain.mcpClientTool'; export const CRON_NODE_TYPE = 'n8n-nodes-base.cron'; export const CLEARBIT_NODE_TYPE = 'n8n-nodes-base.clearbit'; export const FILTER_NODE_TYPE = 'n8n-nodes-base.filter'; @@ -298,6 +299,7 @@ export const AI_CATEGORY_DOCUMENT_LOADERS = 'Document Loaders'; export const AI_CATEGORY_TEXT_SPLITTERS = 'Text Splitters'; export const AI_CATEGORY_OTHER_TOOLS = 'Other Tools'; export const AI_CATEGORY_ROOT_NODES = 'Root Nodes'; +export const AI_CATEGORY_MCP_NODES = 'Model Context Protocol'; export const AI_UNCATEGORIZED_CATEGORY = 'Miscellaneous'; export const AI_CODE_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolCode'; export const AI_WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolWorkflow'; diff --git a/packages/frontend/editor-ui/src/utils/fromAIOverrideUtils.ts b/packages/frontend/editor-ui/src/utils/fromAIOverrideUtils.ts index cea75a3956..08a0efbcd8 100644 --- a/packages/frontend/editor-ui/src/utils/fromAIOverrideUtils.ts +++ b/packages/frontend/editor-ui/src/utils/fromAIOverrideUtils.ts @@ -46,7 +46,12 @@ function sanitizeFromAiParameterName(s: string) { } // nodeName | [nodeName, highestUnsupportedVersion] -const NODE_DENYLIST = ['toolCode', 'toolHttpRequest', ['toolWorkflow', 1.2]] as const; +const NODE_DENYLIST = [ + 'toolCode', + 'toolHttpRequest', + 'mcpClientTool', + ['toolWorkflow', 1.2], +] as const; const PATH_DENYLIST = [ 'parameters.name', diff --git a/packages/frontend/editor-ui/src/utils/nodeViewUtils.ts b/packages/frontend/editor-ui/src/utils/nodeViewUtils.ts index 61d402ce8b..67529f79d1 100644 --- a/packages/frontend/editor-ui/src/utils/nodeViewUtils.ts +++ b/packages/frontend/editor-ui/src/utils/nodeViewUtils.ts @@ -1,4 +1,5 @@ import { + AI_MCP_TOOL_NODE_TYPE, LIST_LIKE_NODE_OPERATIONS, MAIN_HEADER_TABS, NODE_POSITION_CONFLICT_ALLOWLIST, @@ -281,7 +282,11 @@ export function getGenericHints({ const nodeHints: NodeHint[] = []; // tools hints - if (node?.type.toLocaleLowerCase().includes('tool') && hasNodeRun) { + if ( + node?.type.toLocaleLowerCase().includes('tool') && + node?.type !== AI_MCP_TOOL_NODE_TYPE && + hasNodeRun + ) { const stringifiedParameters = JSON.stringify(workflowNode.parameters); if (!stringifiedParameters.includes('$fromAI')) { nodeHints.push({