diff --git a/actions/setup/js/mcp_server_core.cjs b/actions/setup/js/mcp_server_core.cjs index e41b6c20674..dd8f6dd9b06 100644 --- a/actions/setup/js/mcp_server_core.cjs +++ b/actions/setup/js/mcp_server_core.cjs @@ -466,6 +466,69 @@ function registerTool(server, tool) { server.debug(`Registered tool: ${normalizedName}`); } +/** + * Calculate Levenshtein distance between two strings + * @param {string} a - First string + * @param {string} b - Second string + * @returns {number} Edit distance + */ +function levenshteinDistance(a, b) { + const matrix = []; + + // Initialize first column + for (let i = 0; i <= b.length; i++) { + matrix[i] = [i]; + } + + // Initialize first row + for (let j = 0; j <= a.length; j++) { + matrix[0][j] = j; + } + + // Fill in the rest of the matrix + for (let i = 1; i <= b.length; i++) { + for (let j = 1; j <= a.length; j++) { + if (b.charAt(i - 1) === a.charAt(j - 1)) { + matrix[i][j] = matrix[i - 1][j - 1]; + } else { + matrix[i][j] = Math.min( + matrix[i - 1][j - 1] + 1, // substitution + matrix[i][j - 1] + 1, // insertion + matrix[i - 1][j] + 1 // deletion + ); + } + } + } + + return matrix[b.length][a.length]; +} + +/** + * Find similar tool names from available tools + * @param {string} requestedTool - The tool name that was requested + * @param {Object} availableTools - Object of available tools + * @param {number} maxSuggestions - Maximum number of suggestions to return + * @returns {Array<{name: string, distance: number}>} Array of similar tool names with distances + */ +function findSimilarTools(requestedTool, availableTools, maxSuggestions = 3) { + const normalizedRequested = normalizeTool(requestedTool); + const suggestions = []; + + // Calculate distance for each available tool + for (const toolName of Object.keys(availableTools)) { + const distance = levenshteinDistance(normalizedRequested, toolName); + suggestions.push({ name: toolName, distance }); + } + + // Sort by distance (closest first) and take top N + suggestions.sort((a, b) => a.distance - b.distance); + + // Only return suggestions that are reasonably similar + // (distance <= half the length of the requested tool name + 3) + const maxDistance = Math.floor(normalizedRequested.length / 2) + 3; + return suggestions.filter(s => s.distance <= maxDistance).slice(0, maxSuggestions); +} + /** * Normalize a tool name (convert dashes to underscores, lowercase) * @param {string} name - The tool name to normalize @@ -530,9 +593,18 @@ async function handleRequest(server, request, defaultHandler) { } const tool = server.tools[normalizeTool(name)]; if (!tool) { + // Find similar tools to suggest + const similarTools = findSimilarTools(name, server.tools); + let errorMessage = `Tool '${name}' not found`; + + if (similarTools.length > 0) { + const suggestions = similarTools.map(s => s.name).join(", "); + errorMessage += `. Did you mean one of these: ${suggestions}?`; + } + throw { code: -32602, - message: `Tool '${name}' not found`, + message: errorMessage, }; } @@ -649,7 +721,16 @@ async function handleMessage(server, req, defaultHandler) { } const tool = server.tools[normalizeTool(name)]; if (!tool) { - server.replyError(id, -32601, `Tool not found: ${name} (${normalizeTool(name)})`); + // Find similar tools to suggest + const similarTools = findSimilarTools(name, server.tools); + let errorMessage = `Tool not found: ${name} (${normalizeTool(name)})`; + + if (similarTools.length > 0) { + const suggestions = similarTools.map(s => s.name).join(", "); + errorMessage += `. Did you mean one of these: ${suggestions}?`; + } + + server.replyError(id, -32601, errorMessage); return; } @@ -744,4 +825,6 @@ module.exports = { processReadBuffer, start, loadToolHandlers, + findSimilarTools, + levenshteinDistance, }; diff --git a/actions/setup/js/mcp_server_core.test.cjs b/actions/setup/js/mcp_server_core.test.cjs index 8430565909c..0fe25fc0ffd 100644 --- a/actions/setup/js/mcp_server_core.test.cjs +++ b/actions/setup/js/mcp_server_core.test.cjs @@ -905,4 +905,165 @@ echo "result=$INPUT_MY_INPUT" >> $GITHUB_OUTPUT expect(resultContent.outputs.result).toBe("test-value"); }); }); + + describe("levenshteinDistance", () => { + it("should calculate correct distance for identical strings", async () => { + const { levenshteinDistance } = await import("./mcp_server_core.cjs"); + expect(levenshteinDistance("test", "test")).toBe(0); + }); + + it("should calculate correct distance for single character change", async () => { + const { levenshteinDistance } = await import("./mcp_server_core.cjs"); + expect(levenshteinDistance("cat", "bat")).toBe(1); + expect(levenshteinDistance("cat", "cut")).toBe(1); + }); + + it("should calculate correct distance for insertions/deletions", async () => { + const { levenshteinDistance } = await import("./mcp_server_core.cjs"); + expect(levenshteinDistance("cat", "ca")).toBe(1); + expect(levenshteinDistance("cat", "cats")).toBe(1); + }); + + it("should calculate correct distance for multiple changes", async () => { + const { levenshteinDistance } = await import("./mcp_server_core.cjs"); + expect(levenshteinDistance("cat", "dog")).toBe(3); + }); + }); + + describe("findSimilarTools", () => { + it("should find tools with typos", async () => { + const { findSimilarTools } = await import("./mcp_server_core.cjs"); + const tools = { + add_comment: {}, + add_name: {}, + missing_tool: {}, + }; + + const similar = findSimilarTools("add_comentt", tools, 3); + expect(similar.length).toBeGreaterThan(0); + expect(similar[0].name).toBe("add_comment"); + expect(similar[0].distance).toBeLessThanOrEqual(2); + }); + + it("should find tools with dashes normalized", async () => { + const { findSimilarTools } = await import("./mcp_server_core.cjs"); + const tools = { + dispatch_workflow: {}, + add_comment: {}, + }; + + const similar = findSimilarTools("dispatch-workflow", tools, 3); + expect(similar.length).toBeGreaterThan(0); + expect(similar[0].name).toBe("dispatch_workflow"); + expect(similar[0].distance).toBe(0); // Should match exactly after normalization + }); + + it("should return empty array for completely different names", async () => { + const { findSimilarTools } = await import("./mcp_server_core.cjs"); + const tools = { + short: {}, + }; + + const similar = findSimilarTools("verylongdifferenttoolname", tools, 3); + expect(similar.length).toBe(0); // Distance too large + }); + + it("should limit results to maxSuggestions", async () => { + const { findSimilarTools } = await import("./mcp_server_core.cjs"); + const tools = { + tool_a: {}, + tool_b: {}, + tool_c: {}, + tool_d: {}, + tool_e: {}, + }; + + const similar = findSimilarTools("tool_x", tools, 2); + expect(similar.length).toBeLessThanOrEqual(2); + }); + + it("should sort by distance (closest first)", async () => { + const { findSimilarTools } = await import("./mcp_server_core.cjs"); + const tools = { + add_name: {}, + add_comment: {}, + missing_tool: {}, + }; + + const similar = findSimilarTools("add_nam", tools, 3); + expect(similar.length).toBeGreaterThan(0); + expect(similar[0].name).toBe("add_name"); + expect(similar[0].distance).toBe(1); + }); + }); + + describe("tool not found error with suggestions", () => { + it("should suggest similar tools when tool is not found", async () => { + const { createServer, registerTool, handleMessage } = await import("./mcp_server_core.cjs"); + const server = createServer({ name: "test-server", version: "1.0.0" }); + + // Register some tools + registerTool(server, { + name: "add_comment", + description: "Add comment", + inputSchema: { type: "object", properties: {} }, + handler: () => ({ content: [{ type: "text", text: "ok" }] }), + }); + registerTool(server, { + name: "add_name", + description: "Add name", + inputSchema: { type: "object", properties: {} }, + handler: () => ({ content: [{ type: "text", text: "ok" }] }), + }); + + const results = []; + server.writeMessage = msg => results.push(msg); + server.replyResult = (id, result) => results.push({ jsonrpc: "2.0", id, result }); + server.replyError = (id, code, message) => results.push({ jsonrpc: "2.0", id, error: { code, message } }); + + // Try to call a tool that doesn't exist but is similar + await handleMessage(server, { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "add_comentt", arguments: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0].error).toBeDefined(); + expect(results[0].error.message).toContain("not found"); + expect(results[0].error.message).toContain("Did you mean one of these"); + expect(results[0].error.message).toContain("add_comment"); + }); + + it("should not suggest tools if none are similar", async () => { + const { createServer, registerTool, handleMessage } = await import("./mcp_server_core.cjs"); + const server = createServer({ name: "test-server", version: "1.0.0" }); + + registerTool(server, { + name: "x", + description: "Test", + inputSchema: { type: "object", properties: {} }, + handler: () => ({ content: [{ type: "text", text: "ok" }] }), + }); + + const results = []; + server.writeMessage = msg => results.push(msg); + server.replyResult = (id, result) => results.push({ jsonrpc: "2.0", id, result }); + server.replyError = (id, code, message) => results.push({ jsonrpc: "2.0", id, error: { code, message } }); + + // Try to call a completely different tool + await handleMessage(server, { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "completelydifferenttoolname", arguments: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0].error).toBeDefined(); + expect(results[0].error.message).toContain("not found"); + expect(results[0].error.message).not.toContain("Did you mean"); + }); + }); }); diff --git a/actions/setup/js/safe_outputs_tools_loader.cjs b/actions/setup/js/safe_outputs_tools_loader.cjs index 02d8f6e7896..5d5af915382 100644 --- a/actions/setup/js/safe_outputs_tools_loader.cjs +++ b/actions/setup/js/safe_outputs_tools_loader.cjs @@ -27,6 +27,16 @@ function loadTools(server) { server.debug(`Tools file read successfully, attempting to parse JSON`); const tools = JSON.parse(toolsFileContent); server.debug(`Successfully parsed ${tools.length} tools from file`); + + // Log details about dispatch_workflow tools for debugging + const dispatchWorkflowTools = tools.filter(t => t._workflow_name); + if (dispatchWorkflowTools.length > 0) { + server.debug(` Found ${dispatchWorkflowTools.length} dispatch_workflow tools:`); + dispatchWorkflowTools.forEach(t => { + server.debug(` - ${t.name} (workflow: ${t._workflow_name})`); + }); + } + return tools; } catch (error) { server.debug(`Error reading tools file: ${getErrorMessage(error)}`); @@ -94,9 +104,19 @@ function registerPredefinedTools(server, tools, config, registerTool, normalizeT // Check if this is a dispatch_workflow tool (has _workflow_name metadata) // These tools are dynamically generated with workflow-specific names - if (tool._workflow_name && config.dispatch_workflow) { - registerTool(server, tool); - return; + if (tool._workflow_name) { + server.debug(`Found dispatch_workflow tool: ${tool.name} (_workflow_name: ${tool._workflow_name})`); + if (config.dispatch_workflow) { + server.debug(` dispatch_workflow config exists, registering tool`); + registerTool(server, tool); + return; + } else { + // Note: Using server.debug() with "WARNING:" prefix since MCP server only provides + // debug and debugError methods. The prefix helps identify severity in logs. + server.debug(` WARNING: dispatch_workflow config is missing or falsy - tool will NOT be registered`); + server.debug(` Config keys: ${Object.keys(config).join(", ")}`); + server.debug(` config.dispatch_workflow value: ${JSON.stringify(config.dispatch_workflow)}`); + } } }); }