// ===================== // MCP CLIENT TOOL (AI-Agent only) // Compatible with OpenAPI-to-MCP servers // ===================== import { type IDataObject, type INodeExecutionData, type INodeType, type INodeTypeDescription, type INodePropertyOptions, type ILoadOptionsFunctions, NodeOperationError, } from 'n8n-workflow'; /* ----------------------------- Types (MCP Spec Compliant) ----------------------------- */ interface MCPPayload { name: string; description?: string; inputSchema?: IDataObject; } interface MCPToolsListResult { tools: MCPPayload[]; } interface JsonRpcRequest { jsonrpc: '2.0'; id: string | number; method: string; params?: any; } interface JsonRpcSuccessResponse { jsonrpc: '2.0'; id: string | number; result: T; } interface JsonRpcErrorResponse { jsonrpc: '2.0'; id: string | number; error: { code: number; message: string; data?: any }; } type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse; /* ----------------------------- Helpers ----------------------------- */ function parseEndpoint(endpoint: string): string { return endpoint.trim().replace(/\/+$/, ''); } function generateRpcId(): string { return `n8n_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } /* ----------------------------- Node Definition ----------------------------- */ export class McpClientTool implements INodeType { description: INodeTypeDescription = { displayName: 'MCP Client Tool', name: 'mcpClientTool', icon: 'file:icon.svg', group: ['transform'], version: 1, usableAsTool: true, description: 'Calls tools on an MCP-compliant server using the Model Context Protocol (JSON-RPC). ' + 'Use this node ONLY inside an AI-Agent; the agent will choose the tool automatically.', defaults: { name: 'MCP Client' }, inputs: ['main'], outputs: ['main'], credentials: [{ name: 'mcptool', required: false }], properties: [ { displayName: 'MCP Server Endpoint', name: 'endpoint', type: 'string', default: 'https://cld-dkr-prod-jenna-mcp.wibudev.com/mcp', description: 'The JSON-RPC endpoint of your MCP server', required: true, }, { displayName: 'Tool Selection', name: 'toolSelection', type: 'options', default: 'all', options: [ { name: 'All Tools', value: 'all' }, { name: 'Selected Tools', value: 'selected' }, { name: 'All Except Selected', value: 'except' }, ], }, { displayName: 'Selected Tools', name: 'selectedTools', type: 'multiOptions', default: [], typeOptions: { loadOptionsMethod: 'getMcpTools' }, displayOptions: { show: { toolSelection: ['selected', 'except'] } }, }, ], }; methods = { loadOptions: { async getMcpTools(this: ILoadOptionsFunctions): Promise { const endpoint = parseEndpoint(this.getNodeParameter('endpoint', 0) as string); const id = generateRpcId(); const request: JsonRpcRequest = { jsonrpc: '2.0', id, method: 'tools/list' }; let res: JsonRpcResponse; try { res = await this.helpers.request({ method: 'POST', uri: endpoint, json: true, body: request, }); } catch (e) { throw new NodeOperationError(this.getNode(), `MCP connect error: ${e}`); } if ('error' in res) throw new NodeOperationError( this.getNode(), `MCP error: [${res.error.code}] ${res.error.message}`, ); const tools = res.result?.tools; if (!Array.isArray(tools)) throw new NodeOperationError(this.getNode(), 'Invalid tools list'); return tools.map((t) => ({ name: t.name, value: t.name, description: t.description, })); }, }, loadMethods: { async getAiToolDefinitions(this: any) { const ep = parseEndpoint(this.getNodeParameter('endpoint', 0) as string); const id = generateRpcId(); const req: JsonRpcRequest = { jsonrpc: '2.0', id, method: 'tools/list' }; let res: JsonRpcResponse; try { res = await this.helpers.request({ method: 'POST', uri: ep, json: true, body: req, }); } catch { return []; } if ('error' in res || !Array.isArray(res.result?.tools)) return []; const mode = this.getNodeParameter('toolSelection', 0) as string; const selected = this.getNodeParameter('selectedTools', 0) as string[]; return res.result.tools .filter((t) => { if (mode === 'all') return true; if (mode === 'selected') return selected.includes(t.name); if (mode === 'except') return !selected.includes(t.name); return true; }) .map((t) => ({ name: t.name, description: t.description || '', parameters: t.inputSchema || { type: 'object', properties: {}, }, })); }, }, }; async execute(this: any): Promise { const items = this.getInputData(); const output: INodeExecutionData[] = []; const endpoint = parseEndpoint(this.getNodeParameter('endpoint', 0) as string); for (let i = 0; i < items.length; i++) { const json = items[i].json ?? {}; /* ========================== Ambil tool dari payload AI-Agent ========================== */ let toolName: string | undefined; let args: IDataObject = {}; toolName = json.tool || json.toolName || json.tool_name || json.name || json.function; if (json.arguments) args = json.arguments as IDataObject; else if (json.params) args = json.params as IDataObject; else if (json.parameters) args = json.parameters as IDataObject; else if (json.input) args = json.input as IDataObject; else if (toolName && Object.keys(json).length > 0) { const { tool, toolName: tn, tool_name, name, function: fn, ...rest } = json; args = rest; } if (!toolName || typeof toolName !== 'string') { throw new NodeOperationError( this.getNode(), 'Node ini hanya boleh dipakai di dalam AI-Agent. ' + 'Pastikan Agent dipasang di workflow dan node ini terdaftar sebagai tool.', ); } const req: JsonRpcRequest = { jsonrpc: '2.0', id: generateRpcId(), method: 'tools/call', params: { name: toolName, arguments: args }, }; try { const res: JsonRpcResponse = await this.helpers.request({ method: 'POST', uri: endpoint, json: true, body: req, }); if ('error' in res) { output.push({ json: { error: true, tool: toolName, ...res.error }, pairedItem: { item: i }, }); } else { output.push({ json: { error: false, tool: toolName, result: res.result }, pairedItem: { item: i }, }); } } catch (e: any) { output.push({ json: { error: true, tool: toolName, message: e.message || 'Unknown' }, pairedItem: { item: i }, }); } } return [output]; } }