mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
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:
@@ -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<ILoadOptionsFunctions>({ getNode: jest.fn(() => mock<INode>({ 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<INode>({ typeVersion: 1 });
|
||||
await expect(
|
||||
getTools.call(mock<ILoadOptionsFunctions>({ 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<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ 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<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() =>
|
||||
mock<INode>({
|
||||
typeVersion: 1,
|
||||
}),
|
||||
),
|
||||
getNodeParameter: jest.fn((key, _index) => {
|
||||
const parameters: Record<string, any> = {
|
||||
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<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() =>
|
||||
mock<INode>({
|
||||
typeVersion: 1,
|
||||
}),
|
||||
),
|
||||
getNodeParameter: jest.fn((key, _index) => {
|
||||
const parameters: Record<string, any> = {
|
||||
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<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })),
|
||||
getNodeParameter: jest.fn((key, _index) => {
|
||||
const parameters: Record<string, any> = {
|
||||
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<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })),
|
||||
getNodeParameter: jest.fn((key, _index) => {
|
||||
const parameters: Record<string, any> = {
|
||||
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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getTools',
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
include: ['except'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
methods = {
|
||||
loadOptions: {
|
||||
getTools,
|
||||
},
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
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() };
|
||||
}
|
||||
}
|
||||
@@ -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<INodePropertyOptions[]> {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
@@ -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';
|
||||
212
packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/utils.ts
Normal file
212
packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/utils.ts
Normal file
@@ -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<McpTool[]> {
|
||||
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<ReturnType<Client['callTool']>>;
|
||||
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<DynamicStructuredTool<ZodTypeAny>>) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
function safeCreateUrl(url: string, baseUrl?: string | URL): Result<URL, Error> {
|
||||
try {
|
||||
return createResultOk(new URL(url, baseUrl));
|
||||
} catch (error) {
|
||||
return createResultError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAndValidateUrl(input: string): Result<URL, Error> {
|
||||
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<string, string>;
|
||||
name: string;
|
||||
version: number;
|
||||
}): Promise<Result<Client, ConnectMcpClientError>> {
|
||||
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<IExecuteFunctions, 'getCredentials'>,
|
||||
authentication: McpAuthenticationOption,
|
||||
): Promise<{ headers?: Record<string, string> }> {
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
await super.send(message);
|
||||
this.response.flush();
|
||||
}
|
||||
}
|
||||
200
packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/McpServer.ts
Normal file
200
packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/McpServer.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
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<void> {
|
||||
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.`);
|
||||
|
||||
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}` }] };
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
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',
|
||||
},
|
||||
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: {
|
||||
inactive:
|
||||
"This trigger has two modes: test and production.<br /><br /><b>Use test mode while you build your workflow</b>. Click the 'test step' button, then make an MCP request to the test URL. The executions will show up in the editor.<br /><br /><b>Use production mode to run your workflow automatically</b>. <a data-key='activate'>Activate</a> the workflow, then make requests to the production URL. These executions will show up in the <a data-key='executions'>executions list</a>, but not the editor.",
|
||||
active:
|
||||
"This trigger has two modes: test and production.<br /><br /><b>Use test mode while you build your workflow</b>. Click the 'test step' button, then make an MCP request to the test URL. The executions will show up in the editor.<br /><br /><b>Use production mode to run your workflow automatically</b>. Since your workflow is activated, you can make requests to the production URL. These executions will show up in the <a data-key='executions'>executions list</a>, 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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'None', value: 'none' },
|
||||
{ name: 'Bearer Auth', value: 'bearerAuth' },
|
||||
{ name: 'Header Auth', value: 'headerAuth' },
|
||||
],
|
||||
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<IWebhookResponseData> {
|
||||
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: {} }]] };
|
||||
}
|
||||
}
|
||||
@@ -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<CompressionResponse>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<Server>();
|
||||
jest.mock('@modelcontextprotocol/sdk/server/index.js', () => {
|
||||
return {
|
||||
Server: jest.fn().mockImplementation(() => mockServer),
|
||||
};
|
||||
});
|
||||
|
||||
const mockTransport = mock<FlushingSSEServerTransport>({ sessionId });
|
||||
jest.mock('../FlushingSSEServerTransport', () => {
|
||||
return {
|
||||
FlushingSSEServerTransport: jest.fn().mockImplementation(() => mockTransport),
|
||||
};
|
||||
});
|
||||
|
||||
describe('McpServer', () => {
|
||||
const mockRequest = mock<Request>({ query: { sessionId }, path: '/sse' });
|
||||
const mockResponse = mock<CompressionResponse>();
|
||||
const mockTool = mock<Tool>({ 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<void>>();
|
||||
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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Tool>({ name: 'mockTool' });
|
||||
jest.mock('@utils/helpers', () => ({
|
||||
getConnectedTools: jest.fn().mockImplementation(() => [mockTool]),
|
||||
}));
|
||||
|
||||
const mockServer = mock<McpServer>();
|
||||
jest.mock('../McpServer', () => ({
|
||||
McpServerSingleton: {
|
||||
instance: jest.fn().mockImplementation(() => mockServer),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('McpTrigger Node', () => {
|
||||
const sessionId = 'mock-session-id';
|
||||
const mockContext = mock<IWebhookFunctions>();
|
||||
const mockRequest = mock<Request>({ query: { sessionId }, path: '/custom-path/sse' });
|
||||
const mockResponse = mock<Response>();
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
7
packages/@n8n/nodes-langchain/nodes/mcp/mcp.dark.svg
Normal file
7
packages/@n8n/nodes-langchain/nodes/mcp/mcp.dark.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="180" height="180" viewBox="0 0 195 195" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke="#fff" stroke-width="12" stroke-linecap="round">
|
||||
<path d="M25 97.8528L92.8823 29.9706C102.255 20.598 117.451 20.598 126.823 29.9706V29.9706C136.196 39.3431 136.196 54.5391 126.823 63.9117L75.5581 115.177"/>
|
||||
<path d="M76.2653 114.47L126.823 63.9117C136.196 54.5391 151.392 54.5391 160.765 63.9117L161.118 64.2652C170.491 73.6378 170.491 88.8338 161.118 98.2063L99.7248 159.6C96.6006 162.724 96.6006 167.789 99.7248 170.913L112.331 183.52"/>
|
||||
<path d="M109.853 46.9411L59.6482 97.1457C50.2757 106.518 50.2757 121.714 59.6482 131.087V131.087C69.0208 140.459 84.2168 140.459 93.5894 131.087L143.794 80.8822"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 735 B |
7
packages/@n8n/nodes-langchain/nodes/mcp/mcp.svg
Normal file
7
packages/@n8n/nodes-langchain/nodes/mcp/mcp.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="180" height="180" viewBox="0 0 195 195" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke="#000" stroke-width="12" stroke-linecap="round">
|
||||
<path d="M25 97.8528L92.8823 29.9706C102.255 20.598 117.451 20.598 126.823 29.9706V29.9706C136.196 39.3431 136.196 54.5391 126.823 63.9117L75.5581 115.177"/>
|
||||
<path d="M76.2653 114.47L126.823 63.9117C136.196 54.5391 151.392 54.5391 160.765 63.9117L161.118 64.2652C170.491 73.6378 170.491 88.8338 161.118 98.2063L99.7248 159.6C96.6006 162.724 96.6006 167.789 99.7248 170.913L112.331 183.52"/>
|
||||
<path d="M109.853 46.9411L59.6482 97.1457C50.2757 106.518 50.2757 121.714 59.6482 131.087V131.087C69.0208 140.459 84.2168 140.459 93.5894 131.087L143.794 80.8822"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 735 B |
Reference in New Issue
Block a user