mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +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 {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,8 +148,13 @@ export class McpServer {
|
|||||||
|
|
||||||
this.logger.debug(`Got request for ${requestedTool.name}, and executed it.`);
|
this.logger.debug(`Got request for ${requestedTool.name}, and executed it.`);
|
||||||
|
|
||||||
// TODO: Refactor this to no longer use the legacy tool result, but
|
if (typeof result === 'object') {
|
||||||
return { toolResult: result };
|
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) {
|
} catch (error) {
|
||||||
this.logger.error(`Error while executing Tool ${requestedTool.name}: ${error}`);
|
this.logger.error(`Error while executing Tool ${requestedTool.name}: ${error}`);
|
||||||
return { isError: true, content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
return { isError: true, content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
||||||
@@ -17,8 +17,8 @@ export class McpTrigger extends Node {
|
|||||||
displayName: 'MCP Server Trigger',
|
displayName: 'MCP Server Trigger',
|
||||||
name: 'mcpTrigger',
|
name: 'mcpTrigger',
|
||||||
icon: {
|
icon: {
|
||||||
light: 'file:mcp.svg',
|
light: 'file:../mcp.svg',
|
||||||
dark: 'file:mcp.dark.svg',
|
dark: 'file:../mcp.dark.svg',
|
||||||
},
|
},
|
||||||
group: ['trigger'],
|
group: ['trigger'],
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -27,6 +27,21 @@ export class McpTrigger extends Node {
|
|||||||
defaults: {
|
defaults: {
|
||||||
name: 'MCP Server Trigger',
|
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: {
|
triggerPanel: {
|
||||||
header: 'Listen for MCP events',
|
header: 'Listen for MCP events',
|
||||||
executionsHelp: {
|
executionsHelp: {
|
||||||
@@ -65,15 +80,6 @@ export class McpTrigger extends Node {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'httpCustomAuth',
|
|
||||||
required: true,
|
|
||||||
displayOptions: {
|
|
||||||
show: {
|
|
||||||
authentication: ['customAuth'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
properties: [
|
properties: [
|
||||||
{
|
{
|
||||||
@@ -84,7 +90,6 @@ export class McpTrigger extends Node {
|
|||||||
{ name: 'None', value: 'none' },
|
{ name: 'None', value: 'none' },
|
||||||
{ name: 'Bearer Auth', value: 'bearerAuth' },
|
{ name: 'Bearer Auth', value: 'bearerAuth' },
|
||||||
{ name: 'Header Auth', value: 'headerAuth' },
|
{ name: 'Header Auth', value: 'headerAuth' },
|
||||||
{ name: 'Custom Auth', value: 'customAuth' },
|
|
||||||
],
|
],
|
||||||
default: 'none',
|
default: 'none',
|
||||||
description: 'The way to authenticate',
|
description: 'The way to authenticate',
|
||||||
|
Before Width: | Height: | Size: 735 B After Width: | Height: | Size: 735 B |
|
Before Width: | Height: | Size: 735 B After Width: | Height: | Size: 735 B |
@@ -83,7 +83,8 @@
|
|||||||
"dist/nodes/llms/LMCohere/LmCohere.node.js",
|
"dist/nodes/llms/LMCohere/LmCohere.node.js",
|
||||||
"dist/nodes/llms/LMOllama/LmOllama.node.js",
|
"dist/nodes/llms/LMOllama/LmOllama.node.js",
|
||||||
"dist/nodes/llms/LMOpenHuggingFaceInference/LmOpenHuggingFaceInference.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/MemoryBufferWindow/MemoryBufferWindow.node.js",
|
||||||
"dist/nodes/memory/MemoryMotorhead/MemoryMotorhead.node.js",
|
"dist/nodes/memory/MemoryMotorhead/MemoryMotorhead.node.js",
|
||||||
"dist/nodes/memory/MemoryPostgresChat/MemoryPostgresChat.node.js",
|
"dist/nodes/memory/MemoryPostgresChat/MemoryPostgresChat.node.js",
|
||||||
|
|||||||
@@ -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 { BaseLLM } from '@langchain/core/language_models/llms';
|
||||||
import type { BaseMessage } from '@langchain/core/messages';
|
import type { BaseMessage } from '@langchain/core/messages';
|
||||||
import type { Tool } from '@langchain/core/tools';
|
import type { Tool } from '@langchain/core/tools';
|
||||||
|
import { Toolkit } from 'langchain/agents';
|
||||||
import type { BaseChatMemory } from 'langchain/memory';
|
import type { BaseChatMemory } from 'langchain/memory';
|
||||||
import { NodeConnectionTypes, NodeOperationError, jsonStringify } from 'n8n-workflow';
|
import { NodeConnectionTypes, NodeOperationError, jsonStringify } from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
@@ -189,14 +190,22 @@ export const getConnectedTools = async (
|
|||||||
convertStructuredTool: boolean = true,
|
convertStructuredTool: boolean = true,
|
||||||
escapeCurlyBrackets: boolean = false,
|
escapeCurlyBrackets: boolean = false,
|
||||||
) => {
|
) => {
|
||||||
const connectedTools =
|
const connectedTools = (
|
||||||
((await ctx.getInputConnectionData(NodeConnectionTypes.AiTool, 0)) as Tool[]) || [];
|
((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;
|
if (!enforceUniqueNames) return connectedTools;
|
||||||
|
|
||||||
const seenNames = new Set<string>();
|
const seenNames = new Set<string>();
|
||||||
|
|
||||||
const finalTools = [];
|
const finalTools: Tool[] = [];
|
||||||
|
|
||||||
for (const tool of connectedTools) {
|
for (const tool of connectedTools) {
|
||||||
const { name } = tool;
|
const { name } = tool;
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import { Embeddings } from '@langchain/core/embeddings';
|
|||||||
import type { InputValues, MemoryVariables, OutputValues } from '@langchain/core/memory';
|
import type { InputValues, MemoryVariables, OutputValues } from '@langchain/core/memory';
|
||||||
import type { BaseMessage } from '@langchain/core/messages';
|
import type { BaseMessage } from '@langchain/core/messages';
|
||||||
import { BaseRetriever } from '@langchain/core/retrievers';
|
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 { VectorStore } from '@langchain/core/vectorstores';
|
||||||
import { TextSplitter } from '@langchain/textsplitters';
|
import { TextSplitter } from '@langchain/textsplitters';
|
||||||
import type { BaseDocumentLoader } from 'langchain/dist/document_loaders/base';
|
import type { BaseDocumentLoader } from 'langchain/dist/document_loaders/base';
|
||||||
import type {
|
import type {
|
||||||
|
IDataObject,
|
||||||
IExecuteFunctions,
|
IExecuteFunctions,
|
||||||
INodeExecutionData,
|
INodeExecutionData,
|
||||||
ISupplyDataFunctions,
|
ISupplyDataFunctions,
|
||||||
@@ -94,9 +95,10 @@ export function callMethodSync<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logWrapper(
|
export function logWrapper<
|
||||||
originalInstance:
|
T extends
|
||||||
| Tool
|
| Tool
|
||||||
|
| StructuredTool
|
||||||
| BaseChatMemory
|
| BaseChatMemory
|
||||||
| BaseChatMessageHistory
|
| BaseChatMessageHistory
|
||||||
| BaseRetriever
|
| BaseRetriever
|
||||||
@@ -108,8 +110,7 @@ export function logWrapper(
|
|||||||
| VectorStore
|
| VectorStore
|
||||||
| N8nBinaryLoader
|
| N8nBinaryLoader
|
||||||
| N8nJsonLoader,
|
| N8nJsonLoader,
|
||||||
executeFunctions: IExecuteFunctions | ISupplyDataFunctions,
|
>(originalInstance: T, executeFunctions: IExecuteFunctions | ISupplyDataFunctions): T {
|
||||||
) {
|
|
||||||
return new Proxy(originalInstance, {
|
return new Proxy(originalInstance, {
|
||||||
get: (target, prop) => {
|
get: (target, prop) => {
|
||||||
let connectionType: NodeConnectionType | undefined;
|
let connectionType: NodeConnectionType | undefined;
|
||||||
@@ -372,8 +373,16 @@ export function logWrapper(
|
|||||||
if (prop === '_call' && '_call' in target) {
|
if (prop === '_call' && '_call' in target) {
|
||||||
return async (query: string): Promise<string> => {
|
return async (query: string): Promise<string> => {
|
||||||
connectionType = NodeConnectionTypes.AiTool;
|
connectionType = NodeConnectionTypes.AiTool;
|
||||||
|
const inputData: IDataObject = { query };
|
||||||
|
|
||||||
|
if (target.metadata?.isFromToolkit) {
|
||||||
|
inputData.tool = {
|
||||||
|
name: target.name,
|
||||||
|
description: target.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
const { index } = executeFunctions.addInputData(connectionType, [
|
const { index } = executeFunctions.addInputData(connectionType, [
|
||||||
[{ json: { query } }],
|
[{ json: inputData }],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const response = (await callMethodAsync.call(target, {
|
const response = (await callMethodAsync.call(target, {
|
||||||
@@ -384,7 +393,7 @@ export function logWrapper(
|
|||||||
arguments: [query],
|
arguments: [query],
|
||||||
})) as string;
|
})) as string;
|
||||||
|
|
||||||
logAiEvent(executeFunctions, 'ai-tool-called', { query, response });
|
logAiEvent(executeFunctions, 'ai-tool-called', { ...inputData, response });
|
||||||
executeFunctions.addOutputData(connectionType, index, [[{ json: { response } }]]);
|
executeFunctions.addOutputData(connectionType, index, [[{ json: { response } }]]);
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DynamicTool, type Tool } from '@langchain/core/tools';
|
import { DynamicTool, type Tool } from '@langchain/core/tools';
|
||||||
|
import { Toolkit } from 'langchain/agents';
|
||||||
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
||||||
import { NodeOperationError } from 'n8n-workflow';
|
import { NodeOperationError } from 'n8n-workflow';
|
||||||
import type { ISupplyDataFunctions, IExecuteFunctions, INode } from 'n8n-workflow';
|
import type { ISupplyDataFunctions, IExecuteFunctions, INode } from 'n8n-workflow';
|
||||||
@@ -242,6 +243,34 @@ describe('getConnectedTools', () => {
|
|||||||
const tools = await getConnectedTools(mockExecuteFunctions, true, false);
|
const tools = await getConnectedTools(mockExecuteFunctions, true, false);
|
||||||
expect(tools[0]).toBe(mockN8nTool);
|
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', () => {
|
describe('unwrapNestedOutput', () => {
|
||||||
|
|||||||
@@ -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<INodeType | IVersionedNodeType> {
|
getNode(fullNodeType: string): LoadedClass<INodeType | IVersionedNodeType> {
|
||||||
const [packageName, nodeType] = fullNodeType.split('.');
|
const [packageName, nodeType] = fullNodeType.split('.');
|
||||||
const { loaders } = this;
|
const { loaders } = this;
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ export class NodeTypes implements INodeTypes {
|
|||||||
|
|
||||||
const toolRequested = nodeType.endsWith('Tool');
|
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
|
// Make sure the nodeType to actually get from disk is the un-wrapped type
|
||||||
if (toolRequested) {
|
if (toolRequested) {
|
||||||
nodeType = nodeType.replace(/Tool$/, '');
|
nodeType = nodeType.replace(/Tool$/, '');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
SimplifiedNodeType,
|
SimplifiedNodeType,
|
||||||
} from '@/Interface';
|
} from '@/Interface';
|
||||||
import {
|
import {
|
||||||
|
AI_CATEGORY_MCP_NODES,
|
||||||
AI_CATEGORY_ROOT_NODES,
|
AI_CATEGORY_ROOT_NODES,
|
||||||
AI_CATEGORY_TOOLS,
|
AI_CATEGORY_TOOLS,
|
||||||
AI_CODE_NODE_TYPE,
|
AI_CODE_NODE_TYPE,
|
||||||
@@ -222,15 +223,16 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
|
|||||||
const aiSubNodes = difference(aiNodes, aiRootNodes);
|
const aiSubNodes = difference(aiNodes, aiRootNodes);
|
||||||
|
|
||||||
aiSubNodes.forEach((node) => {
|
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) {
|
if (section) {
|
||||||
const subSection = node.properties.codex?.subcategories?.[section]?.[0];
|
const subSection = subcategories[section]?.[0];
|
||||||
const sectionKey = subSection ?? section;
|
const sectionKey = subSection ?? section;
|
||||||
const currentItems = sectionsMap.get(sectionKey)?.items ?? [];
|
const currentItems = sectionsMap.get(sectionKey)?.items ?? [];
|
||||||
const isSubnodesSection =
|
const isSubnodesSection = !(
|
||||||
!node.properties.codex?.subcategories?.[AI_SUBCATEGORY].includes(
|
subcategories[AI_SUBCATEGORY].includes(AI_CATEGORY_ROOT_NODES) ||
|
||||||
AI_CATEGORY_ROOT_NODES,
|
subcategories[AI_SUBCATEGORY].includes(AI_CATEGORY_MCP_NODES)
|
||||||
);
|
);
|
||||||
|
|
||||||
let title = section;
|
let title = section;
|
||||||
|
|||||||
@@ -57,12 +57,18 @@ export function subcategorizeItems(items: SimplifiedNodeType[]) {
|
|||||||
// Only some subcategories are allowed
|
// Only some subcategories are allowed
|
||||||
let subcategories: string[] = [DEFAULT_SUBCATEGORY];
|
let subcategories: string[] = [DEFAULT_SUBCATEGORY];
|
||||||
|
|
||||||
WHITE_LISTED_SUBCATEGORIES.forEach((category) => {
|
const matchedSubcategories = WHITE_LISTED_SUBCATEGORIES.flatMap((category) => {
|
||||||
if (item.codex?.categories?.includes(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) => {
|
subcategories.forEach((subcategory: string) => {
|
||||||
if (!acc[subcategory]) {
|
if (!acc[subcategory]) {
|
||||||
acc[subcategory] = [];
|
acc[subcategory] = [];
|
||||||
|
|||||||
@@ -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 CALENDLY_TRIGGER_NODE_TYPE = 'n8n-nodes-base.calendlyTrigger';
|
||||||
export const CODE_NODE_TYPE = 'n8n-nodes-base.code';
|
export const CODE_NODE_TYPE = 'n8n-nodes-base.code';
|
||||||
export const AI_CODE_NODE_TYPE = '@n8n/n8n-nodes-langchain.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 CRON_NODE_TYPE = 'n8n-nodes-base.cron';
|
||||||
export const CLEARBIT_NODE_TYPE = 'n8n-nodes-base.clearbit';
|
export const CLEARBIT_NODE_TYPE = 'n8n-nodes-base.clearbit';
|
||||||
export const FILTER_NODE_TYPE = 'n8n-nodes-base.filter';
|
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_TEXT_SPLITTERS = 'Text Splitters';
|
||||||
export const AI_CATEGORY_OTHER_TOOLS = 'Other Tools';
|
export const AI_CATEGORY_OTHER_TOOLS = 'Other Tools';
|
||||||
export const AI_CATEGORY_ROOT_NODES = 'Root Nodes';
|
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_UNCATEGORIZED_CATEGORY = 'Miscellaneous';
|
||||||
export const AI_CODE_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolCode';
|
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';
|
export const AI_WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolWorkflow';
|
||||||
|
|||||||
@@ -46,7 +46,12 @@ function sanitizeFromAiParameterName(s: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// nodeName | [nodeName, highestUnsupportedVersion]
|
// 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 = [
|
const PATH_DENYLIST = [
|
||||||
'parameters.name',
|
'parameters.name',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
AI_MCP_TOOL_NODE_TYPE,
|
||||||
LIST_LIKE_NODE_OPERATIONS,
|
LIST_LIKE_NODE_OPERATIONS,
|
||||||
MAIN_HEADER_TABS,
|
MAIN_HEADER_TABS,
|
||||||
NODE_POSITION_CONFLICT_ALLOWLIST,
|
NODE_POSITION_CONFLICT_ALLOWLIST,
|
||||||
@@ -281,7 +282,11 @@ export function getGenericHints({
|
|||||||
const nodeHints: NodeHint[] = [];
|
const nodeHints: NodeHint[] = [];
|
||||||
|
|
||||||
// tools hints
|
// 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);
|
const stringifiedParameters = JSON.stringify(workflowNode.parameters);
|
||||||
if (!stringifiedParameters.includes('$fromAI')) {
|
if (!stringifiedParameters.includes('$fromAI')) {
|
||||||
nodeHints.push({
|
nodeHints.push({
|
||||||
|
|||||||
Reference in New Issue
Block a user