Files
n8n-nodes-mcp-tool/McpClientTool.node.v1.txt
bipproduction f07b60b310 tambahan
2025-12-06 19:39:33 +08:00

377 lines
9.8 KiB
Plaintext

import {
type IDataObject,
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
type INodePropertyOptions,
type ILoadOptionsFunctions,
NodeOperationError,
} from 'n8n-workflow';
import { OptionsWithUri } from 'request';
/* -------------------------
Types
------------------------- */
interface MCPPayload {
name: string;
description?: string;
inputSchema?: IDataObject;
'x-props': {
method: string;
path: string;
operationId?: string;
tag?: string;
deprecated?: boolean;
summary?: string;
};
}
interface MCPToolsResponse {
tools: MCPPayload[];
}
/* -------------------------
Helper Functions
------------------------- */
/**
* Extract base URL and MCP path from endpoint
* Examples:
* - "https://example.com/mcp" → { baseUrl: "https://example.com", mcpPath: "/mcp" }
* - "https://example.com" → { baseUrl: "https://example.com", mcpPath: "" }
*/
function parseEndpoint(endpoint: string): { baseUrl: string; mcpPath: string } {
const trimmed = endpoint.trim().replace(/\/+$/, '');
// Check if endpoint has /mcp or similar path
const url = new URL(trimmed);
const pathname = url.pathname;
if (pathname && pathname !== '/') {
// Has path component (e.g., /mcp)
return {
baseUrl: `${url.protocol}//${url.host}`,
mcpPath: pathname
};
}
// No path component
return {
baseUrl: trimmed,
mcpPath: ''
};
}
/* -------------------------
Node Implementation
------------------------- */
export class McpClientTool implements INodeType {
description: INodeTypeDescription = {
displayName: 'MCP Client Tool',
name: 'mcpClientTool',
icon: 'file:icon.svg',
group: ['transform'],
version: 1,
usableAsTool: true,
description:
'Dynamically executes tools from an MCP server based on AI agent input. Fetches tool metadata from /tools and provides dynamic tool definitions for agents.',
defaults: { name: 'MCP Client' },
inputs: ['main'],
outputs: ['main'],
credentials: [{ name: 'mcptool', required: false }],
properties: [
{
displayName: 'Endpoint',
name: 'endpoint',
type: 'string',
default: 'https://cld-dkr-prod-jenna-mcp.wibudev.com/mcp',
required: true,
description: 'MCP server endpoint. Can include path (e.g., /mcp) for metadata endpoints.',
},
{
displayName: 'Server Transport',
name: 'transport',
type: 'options',
options: [
{ name: 'HTTP (Streamable)', value: 'http' },
{ name: 'Server Sent Events (Deprecated)', value: 'sse' },
],
default: 'http',
},
{
displayName: 'Tools to Include',
name: 'toolSelection',
type: 'options',
options: [
{ name: 'All', value: 'all' },
{ name: 'Selected', value: 'selected' },
{ name: 'All Except', value: 'except' },
],
default: 'all',
},
{
displayName: 'Selected Tools',
name: 'selectedTools',
type: 'multiOptions',
typeOptions: {
loadOptionsMethod: 'getMcpTools',
},
default: [],
displayOptions: {
show: {
toolSelection: ['selected', 'except'],
},
},
},
],
};
/* ----------------------------------------
loadOptions + loadMethods
---------------------------------------- */
methods = {
loadOptions: {
/** Fetch tools for UI multi-select */
async getMcpTools(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const endpoint = this.getNodeParameter('endpoint', 0) as string;
const { baseUrl, mcpPath } = parseEndpoint(endpoint);
let authHeader: any = '';
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const credentials = await this.getCredentials('mcptool');
if (credentials?.authHeader) authHeader = credentials.authHeader;
} catch {}
// Use full endpoint (with MCP path) for /tools
const toolsUrl = `${baseUrl}${mcpPath}/tools`;
const requestOptions: OptionsWithUri = {
method: 'GET',
uri: toolsUrl,
json: true,
headers: authHeader ? { Authorization: authHeader } : {},
timeout: 15000,
};
let toolsRes: MCPToolsResponse;
try {
toolsRes = await (this as any).helpers.request(requestOptions);
} catch (err) {
throw new NodeOperationError(
this.getNode(),
`Failed to fetch MCP tools from ${toolsUrl}: ${(err as Error).message}`,
);
}
if (!toolsRes?.tools) {
throw new NodeOperationError(this.getNode(), 'Invalid MCP /tools response');
}
return toolsRes.tools.map((tool) => ({
name: `${tool.name} — ${tool.description || tool['x-props'].summary || ''}`,
value: tool.name,
}));
},
},
/* ----------------------------------------
LLM / Agent function definitions
---------------------------------------- */
loadMethods: {
async getAiToolDefinitions(this: any) {
const endpoint = this.getNodeParameter?.('endpoint', 0) as string;
if (!endpoint) return [];
const { baseUrl, mcpPath } = parseEndpoint(endpoint);
let authHeader = '';
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const credentials = await this.getCredentials?.('mcptool');
if (credentials?.authHeader) authHeader = credentials.authHeader;
} catch {}
// Use full endpoint (with MCP path) for /tools
const toolsUrl = `${baseUrl}${mcpPath}/tools`;
let toolsRes: MCPToolsResponse;
try {
toolsRes = await (this as any).helpers.request({
method: 'GET',
uri: toolsUrl,
json: true,
headers: authHeader ? { Authorization: authHeader } : {},
timeout: 15000,
});
} catch {
return [];
}
if (!Array.isArray(toolsRes?.tools)) return [];
const mode = this.getNodeParameter?.('toolSelection', 0) as string;
const selected = this.getNodeParameter?.('selectedTools', 0) as string[];
const filtered = toolsRes.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;
});
return filtered.map((t) => ({
name: t.name,
description: t.description || t['x-props'].summary || '',
parameters:
t.inputSchema && typeof t.inputSchema === 'object'
? t.inputSchema
: { type: 'object', properties: {} },
__meta: { ...t['x-props'] },
}));
},
},
};
/* ----------------------------------------
EXECUTION: Run tool from AI input
---------------------------------------- */
async execute(this: any): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const output: INodeExecutionData[] = [];
const endpoint = this.getNodeParameter('endpoint', 0) as string;
const { baseUrl, mcpPath } = parseEndpoint(endpoint);
let authHeader = '';
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const credentials = await this.getCredentials('mcptool');
if (credentials?.authHeader) authHeader = credentials.authHeader;
} catch {}
/* Fetch tools once to build map */
// Use full endpoint (with MCP path) for /tools
const toolsUrl = `${baseUrl}${mcpPath}/tools`;
let toolsRes: MCPToolsResponse;
try {
toolsRes = await this.helpers.request({
method: 'GET',
uri: toolsUrl,
json: true,
headers: authHeader ? { Authorization: authHeader } : {},
timeout: 15000,
});
} catch (err) {
throw new NodeOperationError(
this.getNode(),
`Failed to fetch MCP tools during execution from ${toolsUrl}: ${(err as Error).message}`,
);
}
if (!toolsRes?.tools) {
throw new NodeOperationError(this.getNode(), 'Invalid tools list from MCP server');
}
const toolMap = new Map<string, MCPPayload>();
for (const t of toolsRes.tools) toolMap.set(t.name, t);
/* ----------------------------------------
Execute each item
---------------------------------------- */
for (const item of items) {
try {
const json = item.json || {};
// Accept multiple naming styles
const toolName =
json.tool ||
json.toolName ||
json.tool_name ||
null;
if (!toolName) {
throw new NodeOperationError(
this.getNode(),
`Missing "tool" field in input JSON. Expected { "tool": "<name>", "arguments": { ... } }`,
);
}
const tool = toolMap.get(toolName);
if (!tool) {
throw new NodeOperationError(
this.getNode(),
`Tool "${toolName}" not found. Available: ${[...toolMap.keys()].join(', ')}`,
);
}
let args: IDataObject =
json.arguments ||
json.args ||
json.parameters ||
{};
/* Normalize HTTP info */
const method = (tool['x-props'].method || 'GET').toUpperCase();
let path = tool['x-props'].path || '/';
if (!path.startsWith('/')) path = `/${path}`;
// ✅ FIXED: Use only baseUrl for tool execution (without MCP path)
// This ensures we hit the actual API endpoints, not the MCP metadata endpoints
const url = `${baseUrl}${path}`;
const req: OptionsWithUri = {
method,
uri: url,
json: true,
headers: authHeader ? { Authorization: authHeader } : {},
timeout: 20000,
};
// GET/DELETE → querystring
if (method === 'GET' || method === 'DELETE') {
req.qs = args;
} else {
req.body = args;
}
let response: any;
try {
response = await this.helpers.request(req);
} catch (err: any) {
throw new NodeOperationError(
this.getNode(),
`Error calling tool "${toolName}" at ${url}: ${err.message || err}`,
);
}
/* Response */
output.push({
json: {
error: false,
tool: toolName,
method,
path,
url, // Added for debugging
response,
},
});
} catch (error) {
output.push({
json: {
error: true,
message: (error as Error).message,
input: item.json,
},
});
}
}
return [output];
}
}