n8n/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.ts

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

492 lines
12 KiB
TypeScript
Raw Normal View History

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