feat(MCP Client Tool Node): Add MCP Client Tool Node to connect to MCP servers over SSE (#14464)

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
Co-authored-by: JP van Oosten <jp@n8n.io>
This commit is contained in:
Elias Meire
2025-04-09 17:31:53 +02:00
committed by GitHub
parent b52f9f0f6c
commit 34252f53f9
24 changed files with 926 additions and 35 deletions

View File

@@ -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<Toolkit | Tool>) ??
[]
).flatMap((toolOrToolkit) => {
if (toolOrToolkit instanceof Toolkit) {
return toolOrToolkit.getTools() as Tool[];
}
return toolOrToolkit;
});
if (!enforceUniqueNames) return connectedTools;
const seenNames = new Set<string>();
const finalTools = [];
const finalTools: Tool[] = [];
for (const tool of connectedTools) {
const { name } = tool;

View File

@@ -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<T>(
}
}
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<string> => {
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;
};

View File

@@ -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', () => {