From d1ad5a71c3a8951afdb4e9f2d5073d8b30ed928d Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 12 Jan 2026 13:43:17 -0500 Subject: [PATCH] fix: handle MCP tool names with hyphens in native tool calling When models like Claude Opus 4.5 use native tool calling, they convert hyphens to underscores in function names. This causes MCP tool names like mcp--atlassian-jira--search to be returned as mcp__atlassian_jira__search. Changes: - Add MCP_TOOL_SEPARATOR_MANGLED constant to recognize __ separator - Update isMcpTool() to recognize both mcp-- and mcp__ prefixes - Update parseMcpToolName() to handle both separator formats - Add generatePossibleOriginalNames() for underscore-to-hyphen combinations - Add findMatchingServerName() and findMatchingToolName() for fuzzy matching - Integrate fuzzy matching in UseMcpToolTool.validateToolExists() Fixes: #10642 --- .../assistant-message/NativeToolCallParser.ts | 11 +- src/core/tools/UseMcpToolTool.ts | 75 ++++-- src/utils/__tests__/mcp-name.spec.ts | 238 +++++++++++++++++- src/utils/mcp-name.ts | 158 ++++++++++-- 4 files changed, 429 insertions(+), 53 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index f6eac36a9c1..0e28d14044c 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -16,7 +16,7 @@ import type { ApiStreamToolCallDeltaChunk, ApiStreamToolCallEndChunk, } from "../../api/transform/stream" -import { MCP_TOOL_PREFIX, MCP_TOOL_SEPARATOR, parseMcpToolName } from "../../utils/mcp-name" +import { isMcpTool, parseMcpToolName } from "../../utils/mcp-name" /** * Helper type to extract properly typed native arguments for a given tool. @@ -242,8 +242,7 @@ export class NativeToolCallParser { toolCall.argumentsAccumulator += chunk // For dynamic MCP tools, we don't return partial updates - wait for final - const mcpPrefix = MCP_TOOL_PREFIX + MCP_TOOL_SEPARATOR - if (toolCall.name.startsWith(mcpPrefix)) { + if (isMcpTool(toolCall.name)) { return null } @@ -574,10 +573,8 @@ export class NativeToolCallParser { name: TName arguments: string }): ToolUse | McpToolUse | null { - // Check if this is a dynamic MCP tool (mcp--serverName--toolName) - const mcpPrefix = MCP_TOOL_PREFIX + MCP_TOOL_SEPARATOR - - if (typeof toolCall.name === "string" && toolCall.name.startsWith(mcpPrefix)) { + // Check if this is a dynamic MCP tool (mcp--serverName--toolName or mcp__serverName__toolName) + if (typeof toolCall.name === "string" && isMcpTool(toolCall.name)) { return this.parseDynamicMcpTool(toolCall) } diff --git a/src/core/tools/UseMcpToolTool.ts b/src/core/tools/UseMcpToolTool.ts index e7ed744c78c..ca2f0614274 100644 --- a/src/core/tools/UseMcpToolTool.ts +++ b/src/core/tools/UseMcpToolTool.ts @@ -4,6 +4,7 @@ import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" import { t } from "../../i18n" import type { ToolUse } from "../../shared/tools" +import { findMatchingServerName, findMatchingToolName } from "../../utils/mcp-name" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -53,14 +54,18 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { return } + // Use resolved names (handles model-mangled names like underscores instead of hyphens) + const resolvedServerName = toolValidation.resolvedServerName ?? serverName + const resolvedToolName = toolValidation.resolvedToolName ?? toolName + // Reset mistake count on successful validation task.consecutiveMistakeCount = 0 - // Get user approval + // Get user approval (show resolved names to user) const completeMessage = JSON.stringify({ type: "use_mcp_tool", - serverName, - toolName, + serverName: resolvedServerName, + toolName: resolvedToolName, arguments: params.arguments ? JSON.stringify(params.arguments) : undefined, } satisfies ClineAskUseMcpServer) @@ -71,11 +76,11 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { return } - // Execute the tool and process results + // Execute the tool with resolved names await this.executeToolAndProcessResult( task, - serverName, - toolName, + resolvedServerName, + resolvedToolName, parsedArguments, executionId, pushToolResult, @@ -156,7 +161,12 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { serverName: string, toolName: string, pushToolResult: (content: string) => void, - ): Promise<{ isValid: boolean; availableTools?: string[] }> { + ): Promise<{ + isValid: boolean + availableTools?: string[] + resolvedServerName?: string + resolvedToolName?: string + }> { try { // Get the MCP hub to access server information const provider = task.providerRef.deref() @@ -168,12 +178,16 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { } // Get all servers to find the specific one + // Use fuzzy matching to handle model-mangled names (hyphens converted to underscores) const servers = mcpHub.getAllServers() - const server = servers.find((s) => s.name === serverName) + const availableServersArray = servers.map((s) => s.name) + + // Try fuzzy matching for server name + const matchedServerName = findMatchingServerName(serverName, availableServersArray) + const server = matchedServerName ? servers.find((s) => s.name === matchedServerName) : null - if (!server) { + if (!server || !matchedServerName) { // Fail fast when server is unknown - const availableServersArray = servers.map((s) => s.name) const availableServers = availableServersArray.length > 0 ? availableServersArray.join(", ") : "No servers available" @@ -186,6 +200,9 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { return { isValid: false, availableTools: [] } } + // At this point, matchedServerName is guaranteed to be non-null + const resolvedServerName = matchedServerName + // Check if the server has tools defined if (!server.tools || server.tools.length === 0) { // No tools available on this server @@ -195,39 +212,42 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { "error", t("mcp:errors.toolNotFound", { toolName, - serverName, + serverName: resolvedServerName, availableTools: "No tools available", }), ) task.didToolFailInCurrentTurn = true - pushToolResult(formatResponse.unknownMcpToolError(serverName, toolName, [])) + pushToolResult(formatResponse.unknownMcpToolError(resolvedServerName, toolName, [])) return { isValid: false, availableTools: [] } } - // Check if the requested tool exists - const tool = server.tools.find((tool) => tool.name === toolName) + // Check if the requested tool exists using fuzzy matching + const availableToolNames = server.tools.map((tool) => tool.name) + const matchedToolName = findMatchingToolName(toolName, availableToolNames) + const tool = matchedToolName ? server.tools.find((t) => t.name === matchedToolName) : null - if (!tool) { + if (!tool || !matchedToolName) { // Tool not found - provide list of available tools - const availableToolNames = server.tools.map((tool) => tool.name) - task.consecutiveMistakeCount++ task.recordToolError("use_mcp_tool") await task.say( "error", t("mcp:errors.toolNotFound", { toolName, - serverName, + serverName: resolvedServerName, availableTools: availableToolNames.join(", "), }), ) task.didToolFailInCurrentTurn = true - pushToolResult(formatResponse.unknownMcpToolError(serverName, toolName, availableToolNames)) + pushToolResult(formatResponse.unknownMcpToolError(resolvedServerName, toolName, availableToolNames)) return { isValid: false, availableTools: availableToolNames } } + // At this point, matchedToolName is guaranteed to be non-null + const resolvedToolName = matchedToolName + // Check if the tool is disabled (enabledForPrompt is false) if (tool.enabledForPrompt === false) { // Tool is disabled - only show enabled tools @@ -239,20 +259,27 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { await task.say( "error", t("mcp:errors.toolDisabled", { - toolName, - serverName, + toolName: resolvedToolName, + serverName: resolvedServerName, availableTools: enabledToolNames.length > 0 ? enabledToolNames.join(", ") : "No enabled tools available", }), ) task.didToolFailInCurrentTurn = true - pushToolResult(formatResponse.unknownMcpToolError(serverName, toolName, enabledToolNames)) + pushToolResult( + formatResponse.unknownMcpToolError(resolvedServerName, resolvedToolName, enabledToolNames), + ) return { isValid: false, availableTools: enabledToolNames } } - // Tool exists and is enabled - return { isValid: true, availableTools: server.tools.map((tool) => tool.name) } + // Tool exists and is enabled - return resolved names for execution + return { + isValid: true, + availableTools: server.tools.map((tool) => tool.name), + resolvedServerName, + resolvedToolName, + } } catch (error) { // If there's an error during validation, log it but don't block the tool execution // The actual tool call might still fail with a proper error diff --git a/src/utils/__tests__/mcp-name.spec.ts b/src/utils/__tests__/mcp-name.spec.ts index 5511893f79e..01641e2605d 100644 --- a/src/utils/__tests__/mcp-name.spec.ts +++ b/src/utils/__tests__/mcp-name.spec.ts @@ -4,13 +4,18 @@ import { parseMcpToolName, isMcpTool, MCP_TOOL_SEPARATOR, + MCP_TOOL_SEPARATOR_MANGLED, MCP_TOOL_PREFIX, + generatePossibleOriginalNames, + findMatchingServerName, + findMatchingToolName, } from "../mcp-name" describe("mcp-name utilities", () => { describe("constants", () => { it("should have correct separator and prefix", () => { expect(MCP_TOOL_SEPARATOR).toBe("--") + expect(MCP_TOOL_SEPARATOR_MANGLED).toBe("__") expect(MCP_TOOL_PREFIX).toBe("mcp") }) }) @@ -21,6 +26,12 @@ describe("mcp-name utilities", () => { expect(isMcpTool("mcp--my_server--get_forecast")).toBe(true) }) + it("should return true for mangled MCP tool names (models convert -- to __)", () => { + expect(isMcpTool("mcp__server__tool")).toBe(true) + expect(isMcpTool("mcp__my_server__get_forecast")).toBe(true) + expect(isMcpTool("mcp__atlassian_jira__search")).toBe(true) + }) + it("should return false for non-MCP tool names", () => { expect(isMcpTool("server--tool")).toBe(false) expect(isMcpTool("tool")).toBe(false) @@ -28,7 +39,7 @@ describe("mcp-name utilities", () => { expect(isMcpTool("")).toBe(false) }) - it("should return false for old underscore format", () => { + it("should return false for old single underscore format", () => { expect(isMcpTool("mcp_server_tool")).toBe(false) }) @@ -128,10 +139,25 @@ describe("mcp-name utilities", () => { }) describe("parseMcpToolName", () => { - it("should parse valid mcp tool names", () => { + it("should parse valid mcp tool names with wasMangled=false", () => { expect(parseMcpToolName("mcp--server--tool")).toEqual({ serverName: "server", toolName: "tool", + wasMangled: false, + }) + }) + + it("should parse mangled mcp tool names (__ separator) with wasMangled=true", () => { + expect(parseMcpToolName("mcp__server__tool")).toEqual({ + serverName: "server", + toolName: "tool", + wasMangled: true, + }) + // This is the key case from issue #10642 - atlassian-jira gets mangled to atlassian_jira + expect(parseMcpToolName("mcp__atlassian_jira__search")).toEqual({ + serverName: "atlassian_jira", + toolName: "search", + wasMangled: true, }) }) @@ -140,7 +166,7 @@ describe("mcp-name utilities", () => { expect(parseMcpToolName("tool")).toBeNull() }) - it("should return null for old underscore format", () => { + it("should return null for old single underscore format", () => { expect(parseMcpToolName("mcp_server_tool")).toBeNull() }) @@ -148,6 +174,7 @@ describe("mcp-name utilities", () => { expect(parseMcpToolName("mcp--server--tool_name")).toEqual({ serverName: "server", toolName: "tool_name", + wasMangled: false, }) }) @@ -156,6 +183,7 @@ describe("mcp-name utilities", () => { expect(parseMcpToolName("mcp--my_server--tool")).toEqual({ serverName: "my_server", toolName: "tool", + wasMangled: false, }) }) @@ -163,12 +191,15 @@ describe("mcp-name utilities", () => { expect(parseMcpToolName("mcp--my_server--get_forecast")).toEqual({ serverName: "my_server", toolName: "get_forecast", + wasMangled: false, }) }) it("should return null for malformed names", () => { expect(parseMcpToolName("mcp--")).toBeNull() expect(parseMcpToolName("mcp--server")).toBeNull() + expect(parseMcpToolName("mcp__")).toBeNull() + expect(parseMcpToolName("mcp__server")).toBeNull() }) }) @@ -179,6 +210,7 @@ describe("mcp-name utilities", () => { expect(parsed).toEqual({ serverName: "server", toolName: "tool", + wasMangled: false, }) }) @@ -189,6 +221,7 @@ describe("mcp-name utilities", () => { expect(parsed).toEqual({ serverName: "my_server", toolName: "my_tool", + wasMangled: false, }) }) @@ -199,6 +232,7 @@ describe("mcp-name utilities", () => { expect(parsed).toEqual({ serverName: "my_server", toolName: "get_tool", + wasMangled: false, }) }) @@ -208,7 +242,205 @@ describe("mcp-name utilities", () => { expect(parsed).toEqual({ serverName: "Weather_API", toolName: "get_current_forecast", + wasMangled: false, + }) + }) + }) + + describe("generatePossibleOriginalNames", () => { + it("should include the original name in results", () => { + const results = generatePossibleOriginalNames("server") + expect(results).toContain("server") + }) + + it("should generate combinations with hyphens replacing underscores", () => { + const results = generatePossibleOriginalNames("my_server") + expect(results).toContain("my_server") + expect(results).toContain("my-server") + }) + + it("should generate all combinations for multiple underscores", () => { + // "a_b_c" has 2 underscores -> 2^2 = 4 combinations + const results = generatePossibleOriginalNames("a_b_c") + expect(results.length).toBe(4) + expect(results).toContain("a_b_c") + expect(results).toContain("a-b_c") + expect(results).toContain("a_b-c") + expect(results).toContain("a-b-c") + }) + + it("should generate 8 combinations for 3 underscores", () => { + // "a_b_c_d" has 3 underscores -> 2^3 = 8 combinations + const results = generatePossibleOriginalNames("a_b_c_d") + expect(results.length).toBe(8) + expect(results).toContain("a_b_c_d") + expect(results).toContain("a-b_c_d") + expect(results).toContain("a_b-c_d") + expect(results).toContain("a_b_c-d") + expect(results).toContain("a-b-c_d") + expect(results).toContain("a-b_c-d") + expect(results).toContain("a_b-c-d") + expect(results).toContain("a-b-c-d") + }) + + it("should handle the key issue #10642 case - atlassian_jira", () => { + const results = generatePossibleOriginalNames("atlassian_jira") + expect(results).toContain("atlassian_jira") + expect(results).toContain("atlassian-jira") // The original name + }) + + it("should handle names with no underscores", () => { + const results = generatePossibleOriginalNames("server") + expect(results).toEqual(["server"]) + }) + + it("should limit combinations for too many underscores (> 8)", () => { + const manyUnderscores = "a_b_c_d_e_f_g_h_i_j" // 9 underscores + const results = generatePossibleOriginalNames(manyUnderscores) + // Should only have 2 results: original and all-hyphens version + expect(results.length).toBe(2) + expect(results).toContain(manyUnderscores) + expect(results).toContain("a-b-c-d-e-f-g-h-i-j") + }) + + it("should handle exactly 8 underscores (256 combinations)", () => { + const eightUnderscores = "a_b_c_d_e_f_g_h_i" // 8 underscores + const results = generatePossibleOriginalNames(eightUnderscores) + // 2^8 = 256 combinations + expect(results.length).toBe(256) + }) + }) + + describe("findMatchingServerName", () => { + it("should return exact match first", () => { + const servers = ["my-server", "other-server"] + const result = findMatchingServerName("my-server", servers) + expect(result).toBe("my-server") + }) + + it("should find original hyphenated name from mangled name", () => { + const servers = ["atlassian-jira", "linear", "github"] + const result = findMatchingServerName("atlassian_jira", servers) + expect(result).toBe("atlassian-jira") + }) + + it("should return null if no match found", () => { + const servers = ["server1", "server2"] + const result = findMatchingServerName("unknown_server", servers) + expect(result).toBeNull() + }) + + it("should handle server names with multiple hyphens", () => { + const servers = ["my-cool-server", "another-server"] + const result = findMatchingServerName("my_cool_server", servers) + expect(result).toBe("my-cool-server") + }) + + it("should work with empty server list", () => { + const result = findMatchingServerName("server", []) + expect(result).toBeNull() + }) + + it("should match when original has underscores (not hyphens)", () => { + const servers = ["my_real_server", "other"] + const result = findMatchingServerName("my_real_server", servers) + expect(result).toBe("my_real_server") + }) + }) + + describe("findMatchingToolName", () => { + it("should return exact match first", () => { + const tools = ["get-data", "set-data"] + const result = findMatchingToolName("get-data", tools) + expect(result).toBe("get-data") + }) + + it("should find original hyphenated name from mangled name", () => { + const tools = ["get-user-info", "create-ticket", "search"] + const result = findMatchingToolName("get_user_info", tools) + expect(result).toBe("get-user-info") + }) + + it("should return null if no match found", () => { + const tools = ["tool1", "tool2"] + const result = findMatchingToolName("unknown_tool", tools) + expect(result).toBeNull() + }) + + it("should handle tool names with multiple hyphens", () => { + const tools = ["get-all-user-data", "search"] + const result = findMatchingToolName("get_all_user_data", tools) + expect(result).toBe("get-all-user-data") + }) + + it("should work with empty tool list", () => { + const result = findMatchingToolName("tool", []) + expect(result).toBeNull() + }) + + it("should match when original has underscores (not hyphens)", () => { + const tools = ["get_user", "search"] + const result = findMatchingToolName("get_user", tools) + expect(result).toBe("get_user") + }) + }) + + describe("issue #10642 - MCP tool names with hyphens fail", () => { + // End-to-end test for the specific bug reported in the issue + it("should correctly handle atlassian-jira being mangled to atlassian_jira", () => { + // The original MCP tool name as built by the system + const originalToolName = buildMcpToolName("atlassian-jira", "search") + expect(originalToolName).toBe("mcp--atlassian-jira--search") + + // What the model returns (hyphens converted to underscores) + const mangledToolName = "mcp__atlassian_jira__search" + + // isMcpTool should recognize both + expect(isMcpTool(originalToolName)).toBe(true) + expect(isMcpTool(mangledToolName)).toBe(true) + + // parseMcpToolName should parse both + const originalParsed = parseMcpToolName(originalToolName) + expect(originalParsed).toEqual({ + serverName: "atlassian-jira", + toolName: "search", + wasMangled: false, + }) + + const mangledParsed = parseMcpToolName(mangledToolName) + expect(mangledParsed).toEqual({ + serverName: "atlassian_jira", // Mangled name + toolName: "search", + wasMangled: true, + }) + + // findMatchingServerName should resolve mangled name back to original + const availableServers = ["atlassian-jira", "linear", "github"] + const resolvedServer = findMatchingServerName(mangledParsed!.serverName, availableServers) + expect(resolvedServer).toBe("atlassian-jira") + }) + + it("should handle litellm server with atlassian-jira tool", () => { + // From issue: mcp--litellm--atlassian-jira_search + const originalToolName = buildMcpToolName("litellm", "atlassian-jira_search") + expect(originalToolName).toBe("mcp--litellm--atlassian-jira_search") + + // Model might mangle it to: mcp__litellm__atlassian_jira_search + const mangledToolName = "mcp__litellm__atlassian_jira_search" + + expect(isMcpTool(mangledToolName)).toBe(true) + + const parsed = parseMcpToolName(mangledToolName) + expect(parsed).toEqual({ + serverName: "litellm", + toolName: "atlassian_jira_search", + wasMangled: true, }) + + // Find matching tool + const availableTools = ["atlassian-jira_search", "other_tool"] + const resolvedTool = findMatchingToolName(parsed!.toolName, availableTools) + expect(resolvedTool).toBe("atlassian-jira_search") }) }) }) diff --git a/src/utils/mcp-name.ts b/src/utils/mcp-name.ts index c81d5e770f0..0d6193ed33c 100644 --- a/src/utils/mcp-name.ts +++ b/src/utils/mcp-name.ts @@ -12,6 +12,13 @@ */ export const MCP_TOOL_SEPARATOR = "--" +/** + * Alternative separator that models may output. + * Some models (like Claude) convert hyphens to underscores in tool names, + * so "--" becomes "__". We need to recognize and handle this. + */ +export const MCP_TOOL_SEPARATOR_MANGLED = "__" + /** * Prefix for all MCP tool function names. */ @@ -19,12 +26,16 @@ export const MCP_TOOL_PREFIX = "mcp" /** * Check if a tool name is an MCP tool (starts with the MCP prefix and separator). + * Also recognizes mangled versions where models converted "--" to "__". * * @param toolName - The tool name to check - * @returns true if the tool name starts with "mcp--", false otherwise + * @returns true if the tool name starts with "mcp--" or "mcp__", false otherwise */ export function isMcpTool(toolName: string): boolean { - return toolName.startsWith(`${MCP_TOOL_PREFIX}${MCP_TOOL_SEPARATOR}`) + return ( + toolName.startsWith(`${MCP_TOOL_PREFIX}${MCP_TOOL_SEPARATOR}`) || + toolName.startsWith(`${MCP_TOOL_PREFIX}${MCP_TOOL_SEPARATOR_MANGLED}`) + ) } /** @@ -92,34 +103,143 @@ export function buildMcpToolName(serverName: string, toolName: string): string { /** * Parse an MCP tool function name back into server and tool names. * This handles sanitized names by splitting on the "--" separator. + * Also handles mangled names where models converted "--" to "__". * * Note: This returns the sanitized names, not the original names. * The original names cannot be recovered from the sanitized version. * - * @param mcpToolName - The full MCP tool name (e.g., "mcp--weather--get_forecast") - * @returns An object with serverName and toolName, or null if parsing fails + * @param mcpToolName - The full MCP tool name (e.g., "mcp--weather--get_forecast" or "mcp__weather__get_forecast") + * @returns An object with serverName, toolName, and wasMangled flag, or null if parsing fails */ -export function parseMcpToolName(mcpToolName: string): { serverName: string; toolName: string } | null { - const prefix = MCP_TOOL_PREFIX + MCP_TOOL_SEPARATOR - if (!mcpToolName.startsWith(prefix)) { - return null +export function parseMcpToolName( + mcpToolName: string, +): { serverName: string; toolName: string; wasMangled: boolean } | null { + // Try canonical format first: mcp--server--tool + const canonicalPrefix = MCP_TOOL_PREFIX + MCP_TOOL_SEPARATOR + if (mcpToolName.startsWith(canonicalPrefix)) { + const remainder = mcpToolName.slice(canonicalPrefix.length) + const separatorIndex = remainder.indexOf(MCP_TOOL_SEPARATOR) + if (separatorIndex !== -1) { + const serverName = remainder.slice(0, separatorIndex) + const toolName = remainder.slice(separatorIndex + MCP_TOOL_SEPARATOR.length) + if (serverName && toolName) { + return { serverName, toolName, wasMangled: false } + } + } + } + + // Try mangled format: mcp__server__tool (models may convert -- to __) + const mangledPrefix = MCP_TOOL_PREFIX + MCP_TOOL_SEPARATOR_MANGLED + if (mcpToolName.startsWith(mangledPrefix)) { + const remainder = mcpToolName.slice(mangledPrefix.length) + const separatorIndex = remainder.indexOf(MCP_TOOL_SEPARATOR_MANGLED) + if (separatorIndex !== -1) { + const serverName = remainder.slice(0, separatorIndex) + const toolName = remainder.slice(separatorIndex + MCP_TOOL_SEPARATOR_MANGLED.length) + if (serverName && toolName) { + return { serverName, toolName, wasMangled: true } + } + } + } + + return null +} + +/** + * Generate possible original names from a potentially mangled name. + * When models convert hyphens to underscores, we need to try matching + * the mangled name against servers/tools that may have had hyphens. + * + * Since we can't know which underscores were originally hyphens, we generate + * all possible combinations for fuzzy matching. + * + * For efficiency, we limit this to names with a reasonable number of underscores. + * + * @param mangledName - A name that may have had hyphens converted to underscores + * @returns An array of possible original names, including the input unchanged + */ +export function generatePossibleOriginalNames(mangledName: string): string[] { + const results: string[] = [mangledName] + + // Find positions of all underscores + const underscorePositions: number[] = [] + for (let i = 0; i < mangledName.length; i++) { + if (mangledName[i] === "_") { + underscorePositions.push(i) + } + } + + // Limit to prevent exponential explosion (2^n combinations) + // 8 underscores = 256 combinations, which is reasonable + if (underscorePositions.length > 8) { + // For too many underscores, just try the most common pattern: + // replace all underscores with hyphens + results.push(mangledName.replace(/_/g, "-")) + return results + } + + // Generate all combinations of replacing underscores with hyphens + const numCombinations = 1 << underscorePositions.length // 2^n + for (let mask = 1; mask < numCombinations; mask++) { + let variant = mangledName + for (let i = underscorePositions.length - 1; i >= 0; i--) { + if (mask & (1 << i)) { + const pos = underscorePositions[i] + variant = variant.slice(0, pos) + "-" + variant.slice(pos + 1) + } + } + results.push(variant) } - // Remove the "mcp--" prefix - const remainder = mcpToolName.slice(prefix.length) + return results +} - // Split on the separator to get server and tool names - const separatorIndex = remainder.indexOf(MCP_TOOL_SEPARATOR) - if (separatorIndex === -1) { - return null +/** + * Find a matching server name from a potentially mangled server name. + * Tries exact match first, then tries variations with underscores replaced by hyphens. + * + * @param mangledServerName - The server name from parsed MCP tool (may be mangled) + * @param availableServers - List of actual server names to match against + * @returns The matching server name, or null if no match found + */ +export function findMatchingServerName(mangledServerName: string, availableServers: string[]): string | null { + // Try exact match first + if (availableServers.includes(mangledServerName)) { + return mangledServerName } - const serverName = remainder.slice(0, separatorIndex) - const toolName = remainder.slice(separatorIndex + MCP_TOOL_SEPARATOR.length) + // Generate possible original names and try to find a match + const possibleNames = generatePossibleOriginalNames(mangledServerName) + for (const possibleName of possibleNames) { + if (availableServers.includes(possibleName)) { + return possibleName + } + } + + return null +} + +/** + * Find a matching tool name from a potentially mangled tool name. + * Tries exact match first, then tries variations with underscores replaced by hyphens. + * + * @param mangledToolName - The tool name from parsed MCP tool (may be mangled) + * @param availableTools - List of actual tool names to match against + * @returns The matching tool name, or null if no match found + */ +export function findMatchingToolName(mangledToolName: string, availableTools: string[]): string | null { + // Try exact match first + if (availableTools.includes(mangledToolName)) { + return mangledToolName + } - if (!serverName || !toolName) { - return null + // Generate possible original names and try to find a match + const possibleNames = generatePossibleOriginalNames(mangledToolName) + for (const possibleName of possibleNames) { + if (availableTools.includes(possibleName)) { + return possibleName + } } - return { serverName, toolName } + return null }