Skip to content
87 changes: 85 additions & 2 deletions actions/setup/js/mcp_server_core.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -744,4 +825,6 @@ module.exports = {
processReadBuffer,
start,
loadToolHandlers,
findSimilarTools,
levenshteinDistance,
};
161 changes: 161 additions & 0 deletions actions/setup/js/mcp_server_core.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});
26 changes: 23 additions & 3 deletions actions/setup/js/safe_outputs_tools_loader.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`);
Expand Down Expand Up @@ -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)}`);
}
}
});
}
Expand Down
Loading