mcp-tool-chainer

#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, Tool, } from "@modelcontextprotocol/sdk/types.js"; import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import fs from 'fs'; import { JSONPath } from 'jsonpath-plus'; const CHAIN_RESULT = "CHAIN_RESULT"; let config: McpConfig; let tools: McpTool[] = []; interface McpToolWithoutInformation { name: string; version: string; serverJsonKey: string; clientTransportInitializer: { command: string; args: string[]; env: Record<string, string>; } } interface McpTool extends McpToolWithoutInformation { tool: Tool; } interface McpConfig { mcpServers: McpServers; } interface McpServers { [key: string]: ServerConfig; } interface ServerConfig { command: string; args: string[]; env: Record<string, string>; } const McpChainRequestSchema = z.object({ mcpPath: z.array(z.object({ toolName: z.string().describe("The fully qualified name of the tool to execute in the chain (e.g., 'browser_mcp_fetch_url', 'memory_server_create_entities'). This must match an available tool name exactly."), toolArgs: z.string().describe("JSON string containing the arguments for the tool. To pass the result from the previous tool in the chain, use the placeholder \"CHAIN_RESULT\". When passing to an array parameter, use [\"CHAIN_RESULT\"] format."), inputPath: z.string().optional().describe("Optional JSONPath expression to extract specific data from the previous tool's result before passing to this tool. Example: '$.count' will extract just the count field from a JSON response."), outputPath: z.string().optional().describe("Optional JSONPath expression to extract specific data from this tool's result before passing to the next tool in the chain. Example: '$.entities[0].name' would extract just the first entity name.") })).describe("An ordered array of tool configurations that will be executed sequentially to form a processing chain. Each tool receives the (optionally filtered) output from the previous tool.") }); function deepUnescape(str: string, depth: number = 0, maxDepth: number = 10) { try { // First try parsing directly return JSON.parse(str); } catch (e) { // If that fails, it might be a string with escaped content try { return JSON.parse(`"${str.replace(/"/g, '\\"')}"`); } catch (e2) { // For deeply nested escaping, try to recursively unescape if (str.includes('\\') && depth < maxDepth) { return deepUnescape(str.replace(/\\(.)/g, '$1'), depth + 1, maxDepth); } return str; } } } async function chainTools(mcpPath: { toolName: string; toolArgs: string; inputPath?: string; outputPath?: string; }[]) { // Implement MCP chaining let result: any = null; // Chain each MCP server for (let i = 0; i < mcpPath.length; i++) { const { toolName, inputPath, outputPath } = mcpPath[i]; // Create client for the current server const { client, tool } = await createToolClient(toolName); try { // Process the result with inputPath if specified (for all steps except first since no result yet) let processedResult = result; if (inputPath && i > 0 && result) { try { // If the text contains escaped characters that need unescaping if (typeof result === 'string') { try { // Try to parse the result string as JSON result = JSON.parse(result); } catch (e) { // If parsing fails, attempt to extract JSON portion const jsonStart = result.indexOf('{'); if (jsonStart >= 0) { result = result.substring(jsonStart) result = deepUnescape(result); } } } // Ensure we have a valid JSON object const jsonResult = typeof result === 'string' ? JSON.parse(result) : result; // Extract the specified path const extractedInput = JSONPath({ path: inputPath, json: jsonResult }); // If extractedInput is an array with one item, use that item // This handles the common JSONPath behavior of returning arrays processedResult = extractedInput.length === 1 ? extractedInput[0] : extractedInput; // If processedResult is a primitive value, just use it directly if (typeof processedResult !== 'object' || processedResult === null) { processedResult = processedResult; } else { // Otherwise, stringify the object processedResult = JSON.stringify(processedResult); } } catch (error) { console.warn(`Failed to apply inputPath '${inputPath}'. Input may not be valid JSON. Using original result.`); // Keep the original result } } // Define the input to use - either current chain result or the next input from inputs array let toolInput; if (i === 0) { // First tool just uses its args directly toolInput = mcpPath[i].toolArgs; } else { // For subsequent tools, replace CHAIN_RESULT with the processed result let isJson = false; try { JSON.parse(processedResult); isJson = true; } catch (e) { isJson = false; processedResult = JSON.stringify(processedResult).slice(1, -1); } if (typeof processedResult === 'string') { // Handle string replacements more robustly const jsonSafeResult = processedResult; if (mcpPath[i].toolArgs.includes(`"${CHAIN_RESULT}"`)) { // If CHAIN_RESULT is in quotes, replace the quoted version toolInput = mcpPath[i].toolArgs.replace(`"${CHAIN_RESULT}"`, `"${processedResult}"`); } else { // Otherwise replace just the token toolInput = mcpPath[i].toolArgs.replace(CHAIN_RESULT, jsonSafeResult); } } else { // This is a primitive value (number, boolean, etc.) that can be stringified toolInput = mcpPath[i].toolArgs.replace(CHAIN_RESULT, String(processedResult)); } } // Call the tool with the input const toolResponse = await client.callTool({ name: tool.name, arguments: JSON.parse(toolInput) }); // Update current input for the next MCP in the chain if (toolResponse.content) { result = JSON.parse(JSON.stringify(toolResponse.content))[0].text; // Apply outputPath if specified if (outputPath) { try { // Process result similarly to inputPath if (typeof result === 'string') { try { // Try to parse the result string as JSON result = JSON.parse(result); } catch (e) { // If parsing fails, attempt to extract JSON portion const jsonStart = result.indexOf('{'); if (jsonStart >= 0) { result = result.substring(jsonStart) result = deepUnescape(result); } } } // Ensure we have a valid JSON object const jsonResult = typeof result === 'string' ? JSON.parse(result) : result; // Extract the specified path const extractedOutput = JSONPath({ path: outputPath, json: jsonResult }); // If extractedOutput is an array with one item, use that item // This handles the common JSONPath behavior of returning arrays result = extractedOutput.length === 1 ? extractedOutput[0] : extractedOutput; // If result is a primitive value, stringify it properly result = JSON.stringify(result); } catch (error) { console.warn(`Failed to apply outputPath '${outputPath}'. Output may not be valid JSON. Using original output.`); } } } else { throw new Error(`Empty response from MCP server ${i + 1}`); } } finally { // Close the client transport if it exists if (client.transport) { await client.transport.close(); } await client.close(); } } return { content: [{ type: "text", text: result }] }; } // Add a utility function to help with conversion function convertZodToJsonSchema(schema: z.ZodType<any>) { const jsonSchema = zodToJsonSchema(schema); return { ...jsonSchema }; } const serverInfo = { name: "mcp_tool_chainer", version: "0.6.2" } // Create server instance const server = new Server( serverInfo, { capabilities: { tools: {} } } ); // List available tools server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "mcp_chain", description: "Chain together multiple MCP servers", inputSchema: convertZodToJsonSchema(McpChainRequestSchema) }, { name: "chainable_tools", description: "Discover tools from all MCP servers so the mcp_chain tool can be used", inputSchema: { type: "object", properties: {}, required: [] } }, { name: "discover_tools", description: "Rediscover tools from all MCP servers so the mcp_chain tool can be used", inputSchema: { type: "object", properties: {}, required: [] } } ] }; }); // Function to create a client for a specific MCP server async function createClientTransport(command: string, args: string[], env: Record<string, string>): Promise<StdioClientTransport> { const clientTransport = new StdioClientTransport({ command: command, args: args, env: env }); return clientTransport; } async function createToolClient(toolName: string): Promise<{ tool: Tool, client: Client }> { //Server names (t.name) are replaced by underscores? const storedTool = tools.find(t => ((formatName(t.name) + "_" + t.tool.name) === toolName) || t.tool.name === toolName || (formatName(t.serverJsonKey) + "_" + t.tool.name) === toolName); if (!storedTool) { throw new Error(`Tool ${toolName} not found`); } const client = new Client({ name: storedTool.name, version: storedTool.version, }); const clientTransport = await createClientTransport(storedTool.clientTransportInitializer.command, storedTool.clientTransportInitializer.args, storedTool.clientTransportInitializer.env); await client.connect(clientTransport); return { tool: storedTool.tool, client: client }; } // Handle tool execution server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case "chainable_tools": return { content: [{ type: "text", text: tools.map(t => formatName(t.name) + "_" + t.tool.name).join(", ") }] }; case "discover_tools": await startDiscovery(); //delay 3s await new Promise(resolve => setTimeout(resolve, 3000)); return { content: [{ type: "text", text: tools.map(t => formatName(t.name) + "_" + t.tool.name).join(", ") }] }; break; case "mcp_chain": const { mcpPath } = McpChainRequestSchema.parse(args); return chainTools(mcpPath); default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { if (error instanceof z.ZodError) { throw new Error( `Invalid arguments: ${error.errors .map((e) => `${e.path.join(".")}: ${e.message}`) .join(", ")}` ); } // Add detailed error logging const err = error as any; console.error("Error details:", { message: err.message, stack: err.stack, response: err.response?.data || null, status: err.response?.status || null, headers: err.response?.headers || null, name: err.name, fullError: JSON.stringify(err, Object.getOwnPropertyNames(err), 2) }); throw new Error(`Error executing tool ${name}: ${err.message}${err.response?.data ? ` - Response: ${JSON.stringify(err.response.data)}` : ''}`); } }); function formatName(name: string) { return name.replace("-", "_"); } async function startDiscovery() { tools = []; for (const serverKey of Object.keys(config.mcpServers)) { if (serverKey === "mcp_tool_chainer") { continue; } const serverData = config.mcpServers[serverKey]; const clientTransport = await createClientTransport(serverData.command, serverData.args, serverData.env); await clientTransport.start(); try { let sk = serverKey; clientTransport.onmessage = (message) => { let ct = clientTransport; ct.close(); let s = serverData; const parsedMessage = JSON.parse(JSON.stringify(message)); //Obviously i don't know how to use ZOD properly if (parsedMessage.id === 1) { const name = parsedMessage.result.serverInfo.name; const version = parsedMessage.result.serverInfo.version; if (name === serverInfo.name && version === serverInfo.version) { return; } const mapping = { name: name, version: version, clientTransportInitializer: { command: serverData.command, args: serverData.args, env: serverData.env } }; const client = new Client({ name: name, version: version, }); client.connect(new StdioClientTransport({ command: s.command, args: s.args, env: s.env })).then(() => { client.listTools().then((availTools) => { for (const t of availTools.tools) { tools.push({ ...mapping, tool: t, serverJsonKey: sk }); } }).catch((err) => { console.error("Error sending tools list request:", err); }).finally(() => { client.transport?.close(); client.close(); }); }); } } await clientTransport.send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "latest", capabilities: { tools: {} }, clientInfo: serverInfo } }); } catch (error) { console.error("Error during startup:", error); } } } // Start the server async function main() { try { const configFile = process.argv[2]; config = JSON.parse(fs.readFileSync(configFile, 'utf8')) as McpConfig; await startDiscovery(); const transport = new StdioServerTransport(); await server.connect(transport); } catch (error) { console.error("Error during startup:", error); process.exit(1); } } main().catch((error) => { console.error("Fatal error in main():", error); process.exit(1); });