diff --git a/src/core/prompts/tools/native-tools/__tests__/mcp_server.spec.ts b/src/core/prompts/tools/native-tools/__tests__/mcp_server.spec.ts index 932468cd9b1..ddd7caaccf4 100644 --- a/src/core/prompts/tools/native-tools/__tests__/mcp_server.spec.ts +++ b/src/core/prompts/tools/native-tools/__tests__/mcp_server.spec.ts @@ -89,7 +89,7 @@ describe("getMcpServerTools", () => { // Should only have one tool (from project server) expect(result).toHaveLength(1) - expect(getFunction(result[0]).name).toBe("mcp--context7--resolve___library___id") + expect(getFunction(result[0]).name).toBe("mcp--context7--resolve-library-id") // Project server takes priority expect(getFunction(result[0]).description).toBe("Project description") }) diff --git a/src/core/tools/UseMcpToolTool.ts b/src/core/tools/UseMcpToolTool.ts index e7ed744c78c..caaa32dba2f 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 { toolNamesMatch } from "../../utils/mcp-name" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -53,6 +54,10 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { return } + // Use the resolved tool name (original name from the server) for MCP calls + // This handles cases where models mangle hyphens to underscores + const resolvedToolName = toolValidation.resolvedToolName ?? toolName + // Reset mistake count on successful validation task.consecutiveMistakeCount = 0 @@ -60,7 +65,7 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { const completeMessage = JSON.stringify({ type: "use_mcp_tool", serverName, - toolName, + toolName: resolvedToolName, arguments: params.arguments ? JSON.stringify(params.arguments) : undefined, } satisfies ClineAskUseMcpServer) @@ -75,7 +80,7 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { await this.executeToolAndProcessResult( task, serverName, - toolName, + resolvedToolName, parsedArguments, executionId, pushToolResult, @@ -156,7 +161,7 @@ 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[]; resolvedToolName?: string }> { try { // Get the MCP hub to access server information const provider = task.providerRef.deref() @@ -205,8 +210,8 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { 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 to handle model mangling of hyphens) + const tool = server.tools.find((t) => toolNamesMatch(t.name, toolName)) if (!tool) { // Tool not found - provide list of available tools @@ -251,8 +256,8 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { 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 the original tool name for use with the MCP server + return { isValid: true, availableTools: server.tools.map((t) => t.name), resolvedToolName: tool.name } } 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/core/tools/__tests__/useMcpToolTool.spec.ts b/src/core/tools/__tests__/useMcpToolTool.spec.ts index 130047ae15b..83de5831f9c 100644 --- a/src/core/tools/__tests__/useMcpToolTool.spec.ts +++ b/src/core/tools/__tests__/useMcpToolTool.spec.ts @@ -536,5 +536,60 @@ describe("useMcpToolTool", () => { expect(callToolMock).not.toHaveBeenCalled() expect(mockAskApproval).not.toHaveBeenCalled() }) + + it("should match tool names using fuzzy matching (hyphens vs underscores)", async () => { + // This tests the scenario where models mangle hyphens to underscores + // e.g., model sends "get_user_profile" but actual tool name is "get-user-profile" + mockTask.consecutiveMistakeCount = 0 + + const callToolMock = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "Success" }], + }) + + const mockServers = [ + { + name: "test-server", + tools: [{ name: "get-user-profile", description: "Gets a user profile" }], + }, + ] + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + getAllServers: vi.fn().mockReturnValue(mockServers), + callTool: callToolMock, + }), + postMessageToWebview: vi.fn(), + }) + + // Model sends the mangled version with underscores + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test-server", + tool_name: "get_user_profile", // Model mangled hyphens to underscores + arguments: "{}", + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) + + // Tool should be found and executed + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockTask.recordToolError).not.toHaveBeenCalled() + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_request_started") + + // The original tool name (with hyphens) should be passed to callTool + expect(callToolMock).toHaveBeenCalledWith("test-server", "get-user-profile", {}) + }) }) }) diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index 52eb4a064bd..8959d0d1cb6 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -38,7 +38,7 @@ import { fileExistsAtPath } from "../../utils/fs" import { arePathsEqual, getWorkspacePath } from "../../utils/path" import { injectVariables } from "../../utils/config" import { safeWriteJson } from "../../utils/safeWriteJson" -import { sanitizeMcpName } from "../../utils/mcp-name" +import { sanitizeMcpName, toolNamesMatch } from "../../utils/mcp-name" // Discriminated union for connection states export type ConnectedMcpConnection = { @@ -940,16 +940,30 @@ export class McpHub { * Find a connection by sanitized server name. * This is used when parsing MCP tool responses where the server name has been * sanitized (e.g., hyphens replaced with underscores) for API compliance. + * Uses fuzzy matching to handle cases where models convert hyphens to underscores. * @param sanitizedServerName The sanitized server name from the API tool call * @returns The original server name if found, or null if no match */ public findServerNameBySanitizedName(sanitizedServerName: string): string | null { + // First, check for an exact match const exactMatch = this.connections.find((conn) => conn.server.name === sanitizedServerName) if (exactMatch) { return exactMatch.server.name } - return this.sanitizedNameRegistry.get(sanitizedServerName) ?? null + // Check the registry for sanitized name mapping + const registryMatch = this.sanitizedNameRegistry.get(sanitizedServerName) + if (registryMatch) { + return registryMatch + } + + // Use fuzzy matching: treat hyphens and underscores as equivalent + const fuzzyMatch = this.connections.find((conn) => toolNamesMatch(conn.server.name, sanitizedServerName)) + if (fuzzyMatch) { + return fuzzyMatch.server.name + } + + return null } private async fetchToolsList(serverName: string, source?: "global" | "project"): Promise { diff --git a/src/utils/__tests__/mcp-name.spec.ts b/src/utils/__tests__/mcp-name.spec.ts index 0f3e37d5750..3bdc88c7900 100644 --- a/src/utils/__tests__/mcp-name.spec.ts +++ b/src/utils/__tests__/mcp-name.spec.ts @@ -2,12 +2,12 @@ import { sanitizeMcpName, buildMcpToolName, parseMcpToolName, - decodeMcpName, normalizeMcpToolName, + normalizeForComparison, + toolNamesMatch, isMcpTool, MCP_TOOL_SEPARATOR, MCP_TOOL_PREFIX, - HYPHEN_ENCODING, } from "../mcp-name" describe("mcp-name utilities", () => { @@ -16,16 +16,58 @@ describe("mcp-name utilities", () => { expect(MCP_TOOL_SEPARATOR).toBe("--") expect(MCP_TOOL_PREFIX).toBe("mcp") }) + }) + + describe("normalizeForComparison", () => { + it("should convert hyphens to underscores", () => { + expect(normalizeForComparison("get-user-profile")).toBe("get_user_profile") + }) + + it("should not modify strings without hyphens", () => { + expect(normalizeForComparison("get_user_profile")).toBe("get_user_profile") + expect(normalizeForComparison("tool")).toBe("tool") + }) + + it("should handle mixed hyphens and underscores", () => { + expect(normalizeForComparison("get-user_profile")).toBe("get_user_profile") + }) - it("should have correct hyphen encoding", () => { - expect(HYPHEN_ENCODING).toBe("___") + it("should handle multiple hyphens", () => { + expect(normalizeForComparison("mcp--server--tool")).toBe("mcp__server__tool") + }) + }) + + describe("toolNamesMatch", () => { + it("should match identical names", () => { + expect(toolNamesMatch("get_user", "get_user")).toBe(true) + expect(toolNamesMatch("get-user", "get-user")).toBe(true) + }) + + it("should match names with hyphens vs underscores", () => { + expect(toolNamesMatch("get-user", "get_user")).toBe(true) + expect(toolNamesMatch("get_user", "get-user")).toBe(true) + }) + + it("should match complex MCP tool names", () => { + expect(toolNamesMatch("mcp--server--get-user-profile", "mcp__server__get_user_profile")).toBe(true) + }) + + it("should not match different names", () => { + expect(toolNamesMatch("get_user", "get_profile")).toBe(false) }) }) describe("isMcpTool", () => { - it("should return true for valid MCP tool names", () => { + it("should return true for valid MCP tool names with hyphens", () => { expect(isMcpTool("mcp--server--tool")).toBe(true) expect(isMcpTool("mcp--my_server--get_forecast")).toBe(true) + expect(isMcpTool("mcp--server--get-user-profile")).toBe(true) + }) + + it("should return true for MCP tool names with underscore separators", () => { + // Models may convert hyphens to underscores + expect(isMcpTool("mcp__server__tool")).toBe(true) + expect(isMcpTool("mcp__my_server__get_forecast")).toBe(true) }) it("should return false for non-MCP tool names", () => { @@ -35,7 +77,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) }) @@ -60,10 +102,9 @@ describe("mcp-name utilities", () => { expect(sanitizeMcpName("test#$%^&*()")).toBe("test") }) - it("should keep alphanumeric and underscores, but encode hyphens", () => { + it("should keep alphanumeric, underscores, and hyphens", () => { expect(sanitizeMcpName("server_name")).toBe("server_name") - // Hyphens are now encoded as triple underscores - expect(sanitizeMcpName("server-name")).toBe("server___name") + expect(sanitizeMcpName("server-name")).toBe("server-name") expect(sanitizeMcpName("Server123")).toBe("Server123") }) @@ -71,16 +112,14 @@ describe("mcp-name utilities", () => { // Dots and colons are NOT allowed due to AWS Bedrock restrictions expect(sanitizeMcpName("server.name")).toBe("servername") expect(sanitizeMcpName("server:name")).toBe("servername") - // Hyphens are encoded as triple underscores - expect(sanitizeMcpName("awslabs.aws-documentation-mcp-server")).toBe( - "awslabsaws___documentation___mcp___server", - ) + // Hyphens are preserved + expect(sanitizeMcpName("awslabs.aws-documentation-mcp-server")).toBe("awslabsaws-documentation-mcp-server") }) it("should prepend underscore if name starts with non-letter/underscore", () => { expect(sanitizeMcpName("123server")).toBe("_123server") - // Hyphen at start is encoded to ___, which starts with underscore (valid) - expect(sanitizeMcpName("-server")).toBe("___server") + // Hyphen at start still needs underscore prefix (function names must start with letter/underscore) + expect(sanitizeMcpName("-server")).toBe("_-server") // Dots are removed, so ".server" becomes "server" which starts with a letter expect(sanitizeMcpName(".server")).toBe("server") }) @@ -91,17 +130,15 @@ describe("mcp-name utilities", () => { expect(sanitizeMcpName("Server")).toBe("Server") }) - it("should replace double-hyphen sequences with single hyphen then encode", () => { - // Double hyphens become single hyphen, then encoded as ___ - expect(sanitizeMcpName("server--name")).toBe("server___name") - expect(sanitizeMcpName("test---server")).toBe("test___server") - expect(sanitizeMcpName("my----tool")).toBe("my___tool") + it("should replace double-hyphen sequences with single hyphen to avoid separator conflicts", () => { + expect(sanitizeMcpName("server--name")).toBe("server-name") + expect(sanitizeMcpName("test---server")).toBe("test-server") + expect(sanitizeMcpName("my----tool")).toBe("my-tool") }) it("should handle complex names with multiple issues", () => { expect(sanitizeMcpName("My Server @ Home!")).toBe("My_Server__Home") - // Hyphen is encoded as ___ - expect(sanitizeMcpName("123-test server")).toBe("_123___test_server") + expect(sanitizeMcpName("123-test server")).toBe("_123-test_server") }) it("should return placeholder for names that become empty after sanitization", () => { @@ -110,26 +147,10 @@ describe("mcp-name utilities", () => { expect(sanitizeMcpName(" ")).toBe("_") }) - it("should encode hyphens as triple underscores for model compatibility", () => { - // This is the key feature: hyphens are encoded so they survive model tool calling - expect(sanitizeMcpName("atlassian-jira_search")).toBe("atlassian___jira_search") - expect(sanitizeMcpName("atlassian-confluence_search")).toBe("atlassian___confluence_search") - }) - }) - - describe("decodeMcpName", () => { - it("should decode triple underscores back to hyphens", () => { - expect(decodeMcpName("server___name")).toBe("server-name") - expect(decodeMcpName("atlassian___jira_search")).toBe("atlassian-jira_search") - }) - - it("should not modify names without triple underscores", () => { - expect(decodeMcpName("server_name")).toBe("server_name") - expect(decodeMcpName("tool")).toBe("tool") - }) - - it("should handle multiple encoded hyphens", () => { - expect(decodeMcpName("a___b___c")).toBe("a-b-c") + it("should preserve hyphens in tool names", () => { + // Hyphens are preserved, not encoded + expect(sanitizeMcpName("atlassian-jira_search")).toBe("atlassian-jira_search") + expect(sanitizeMcpName("atlassian-confluence_search")).toBe("atlassian-confluence_search") }) }) @@ -162,26 +183,38 @@ describe("mcp-name utilities", () => { expect(buildMcpToolName("my_server", "my_tool")).toBe("mcp--my_server--my_tool") }) - it("should encode hyphens in tool names", () => { - // Hyphens are encoded as triple underscores - expect(buildMcpToolName("onellm", "atlassian-jira_search")).toBe("mcp--onellm--atlassian___jira_search") + it("should preserve hyphens in tool names", () => { + // Hyphens are preserved (not encoded) + expect(buildMcpToolName("onellm", "atlassian-jira_search")).toBe("mcp--onellm--atlassian-jira_search") + }) + + it("should handle tool names with multiple hyphens", () => { + expect(buildMcpToolName("server", "get-user-profile")).toBe("mcp--server--get-user-profile") }) }) describe("parseMcpToolName", () => { - it("should parse valid mcp tool names", () => { + it("should parse valid mcp tool names with hyphen separators", () => { expect(parseMcpToolName("mcp--server--tool")).toEqual({ serverName: "server", toolName: "tool", }) }) + it("should parse MCP tool names with underscore separators (model output)", () => { + // Models may convert hyphens to underscores + expect(parseMcpToolName("mcp__server__tool")).toEqual({ + serverName: "server", + toolName: "tool", + }) + }) + it("should return null for non-mcp tool names", () => { expect(parseMcpToolName("server--tool")).toBeNull() 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() }) @@ -206,9 +239,8 @@ describe("mcp-name utilities", () => { }) }) - it("should decode triple underscores back to hyphens", () => { - // This is the key feature: encoded hyphens are decoded back - expect(parseMcpToolName("mcp--onellm--atlassian___jira_search")).toEqual({ + it("should handle tool names with hyphens", () => { + expect(parseMcpToolName("mcp--onellm--atlassian-jira_search")).toEqual({ serverName: "onellm", toolName: "atlassian-jira_search", }) @@ -220,6 +252,34 @@ describe("mcp-name utilities", () => { }) }) + describe("normalizeMcpToolName", () => { + it("should convert underscore separators to hyphen separators", () => { + expect(normalizeMcpToolName("mcp__server__tool")).toBe("mcp--server--tool") + }) + + it("should not modify names that already have hyphen separators", () => { + expect(normalizeMcpToolName("mcp--server--tool")).toBe("mcp--server--tool") + }) + + it("should not modify non-MCP tool names", () => { + expect(normalizeMcpToolName("read_file")).toBe("read_file") + expect(normalizeMcpToolName("some__tool")).toBe("some__tool") + }) + + it("should preserve underscores within names while normalizing separators", () => { + // Model outputs: mcp__my_server__get_user_profile + // Should become: mcp--my_server--get_user_profile (preserving underscores in names) + expect(normalizeMcpToolName("mcp__my_server__get_user_profile")).toBe("mcp--my_server--get_user_profile") + }) + + it("should handle tool names that originally had hyphens (converted by model)", () => { + // Original: mcp--server--get-user-profile + // Model outputs: mcp__server__get_user_profile (hyphens converted to underscores) + // Normalized: mcp--server--get_user_profile + expect(normalizeMcpToolName("mcp__server__get_user_profile")).toBe("mcp--server--get_user_profile") + }) + }) + describe("roundtrip behavior", () => { it("should be able to parse names that were built", () => { const toolName = buildMcpToolName("server", "tool") @@ -230,7 +290,7 @@ describe("mcp-name utilities", () => { }) }) - it("should preserve sanitized names through roundtrip with underscores", () => { + it("should preserve names through roundtrip with underscores", () => { const toolName = buildMcpToolName("my_server", "my_tool") const parsed = parseMcpToolName(toolName) expect(parsed).toEqual({ @@ -257,15 +317,16 @@ describe("mcp-name utilities", () => { }) }) - it("should preserve hyphens through roundtrip via encoding/decoding", () => { - // This is the key test: hyphens survive the roundtrip + it("should preserve hyphens through roundtrip", () => { + // Build with hyphens in tool name const toolName = buildMcpToolName("onellm", "atlassian-jira_search") - expect(toolName).toBe("mcp--onellm--atlassian___jira_search") + expect(toolName).toBe("mcp--onellm--atlassian-jira_search") + // Parse directly const parsed = parseMcpToolName(toolName) expect(parsed).toEqual({ serverName: "onellm", - toolName: "atlassian-jira_search", // Hyphen is preserved! + toolName: "atlassian-jira_search", }) }) @@ -279,72 +340,134 @@ describe("mcp-name utilities", () => { }) }) - describe("normalizeMcpToolName", () => { - it("should convert underscore separators to hyphen separators", () => { - expect(normalizeMcpToolName("mcp__server__tool")).toBe("mcp--server--tool") - }) + describe("model compatibility - full flow", () => { + it("should handle the complete flow when model preserves hyphens", () => { + // Step 1: Build the tool name + const builtName = buildMcpToolName("onellm", "atlassian-jira_search") + expect(builtName).toBe("mcp--onellm--atlassian-jira_search") - it("should not modify names that already have hyphen separators", () => { - expect(normalizeMcpToolName("mcp--server--tool")).toBe("mcp--server--tool") - }) + // Step 2: Model outputs as-is (no mangling) + const modelOutput = "mcp--onellm--atlassian-jira_search" - it("should not modify non-MCP tool names", () => { - expect(normalizeMcpToolName("read_file")).toBe("read_file") - expect(normalizeMcpToolName("some__tool")).toBe("some__tool") - }) + // Step 3: Normalize (no change needed) + const normalizedName = normalizeMcpToolName(modelOutput) + expect(normalizedName).toBe("mcp--onellm--atlassian-jira_search") - it("should preserve triple underscores (encoded hyphens) while normalizing separators", () => { - // Model outputs: mcp__onellm__atlassian___jira_search - // Should become: mcp--onellm--atlassian___jira_search - expect(normalizeMcpToolName("mcp__onellm__atlassian___jira_search")).toBe( - "mcp--onellm--atlassian___jira_search", - ) + // Step 4: Parse + const parsed = parseMcpToolName(normalizedName) + expect(parsed).toEqual({ + serverName: "onellm", + toolName: "atlassian-jira_search", + }) }) - it("should handle multiple encoded hyphens", () => { - expect(normalizeMcpToolName("mcp__server__get___user___profile")).toBe("mcp--server--get___user___profile") + it("should handle the complete flow when model converts separators only", () => { + // Step 1: Build the tool name + const builtName = buildMcpToolName("onellm", "atlassian-jira_search") + expect(builtName).toBe("mcp--onellm--atlassian-jira_search") + + // Step 2: Model converts -- separators to __ + const modelOutput = "mcp__onellm__atlassian-jira_search" + + // Step 3: Normalize the separators back + const normalizedName = normalizeMcpToolName(modelOutput) + expect(normalizedName).toBe("mcp--onellm--atlassian-jira_search") + + // Step 4: Parse + const parsed = parseMcpToolName(normalizedName) + expect(parsed).toEqual({ + serverName: "onellm", + toolName: "atlassian-jira_search", + }) }) - }) - describe("model compatibility - full flow", () => { - it("should handle the complete flow: build -> model mangles -> normalize -> parse", () => { - // Step 1: Build the tool name (hyphens encoded as ___) + it("should handle the complete flow when model converts ALL hyphens to underscores", () => { + // Step 1: Build the tool name const builtName = buildMcpToolName("onellm", "atlassian-jira_search") - expect(builtName).toBe("mcp--onellm--atlassian___jira_search") + expect(builtName).toBe("mcp--onellm--atlassian-jira_search") - // Step 2: Model mangles the separators (-- becomes __) - const mangledName = "mcp__onellm__atlassian___jira_search" + // Step 2: Model converts ALL hyphens to underscores + const modelOutput = "mcp__onellm__atlassian_jira_search" - // Step 3: Normalize the separators back (__ becomes --) - const normalizedName = normalizeMcpToolName(mangledName) - expect(normalizedName).toBe("mcp--onellm--atlassian___jira_search") + // Step 3: Normalize + const normalizedName = normalizeMcpToolName(modelOutput) + expect(normalizedName).toBe("mcp--onellm--atlassian_jira_search") - // Step 4: Parse the normalized name (decodes ___ back to -) + // Step 4: Parse - the tool name now has underscore instead of hyphen const parsed = parseMcpToolName(normalizedName) expect(parsed).toEqual({ serverName: "onellm", - toolName: "atlassian-jira_search", // Original hyphen is preserved! + toolName: "atlassian_jira_search", // Note: underscore, not hyphen }) + + // Step 5: Use fuzzy matching to find the original tool + expect(toolNamesMatch("atlassian-jira_search", parsed!.toolName)).toBe(true) }) it("should handle tool names with multiple hyphens through the full flow", () => { // Build const builtName = buildMcpToolName("server", "get-user-profile") - expect(builtName).toBe("mcp--server--get___user___profile") + expect(builtName).toBe("mcp--server--get-user-profile") - // Model mangles - const mangledName = "mcp__server__get___user___profile" + // Model converts all hyphens to underscores + const modelOutput = "mcp__server__get_user_profile" // Normalize - const normalizedName = normalizeMcpToolName(mangledName) - expect(normalizedName).toBe("mcp--server--get___user___profile") + const normalizedName = normalizeMcpToolName(modelOutput) + expect(normalizedName).toBe("mcp--server--get_user_profile") // Parse const parsed = parseMcpToolName(normalizedName) expect(parsed).toEqual({ serverName: "server", - toolName: "get-user-profile", + toolName: "get_user_profile", + }) + + // Use fuzzy matching to find the original tool + expect(toolNamesMatch("get-user-profile", parsed!.toolName)).toBe(true) + }) + }) + + describe("edge cases", () => { + it("should handle very long tool names by truncating", () => { + const longServer = "very-long-server-name-that-exceeds" + const longTool = "very-long-tool-name-that-also-exceeds" + const result = buildMcpToolName(longServer, longTool) + + expect(result.length).toBeLessThanOrEqual(64) + // Should still be parseable + const parsed = parseMcpToolName(result) + expect(parsed).not.toBeNull() + expect(parsed?.serverName).toBeDefined() + }) + + it("should handle server names with hyphens", () => { + const toolName = buildMcpToolName("my-server", "tool") + expect(toolName).toBe("mcp--my-server--tool") + + const parsed = parseMcpToolName(toolName) + expect(parsed).toEqual({ + serverName: "my-server", + toolName: "tool", }) }) + + it("should handle both server and tool names with hyphens", () => { + const toolName = buildMcpToolName("my-server", "get-user") + expect(toolName).toBe("mcp--my-server--get-user") + + // When model converts all hyphens + const modelOutput = "mcp__my_server__get_user" + const parsed = parseMcpToolName(modelOutput) + + expect(parsed).toEqual({ + serverName: "my_server", + toolName: "get_user", + }) + + // Fuzzy match should work + expect(toolNamesMatch("my-server", parsed!.serverName)).toBe(true) + expect(toolNamesMatch("get-user", parsed!.toolName)).toBe(true) + }) }) }) diff --git a/src/utils/mcp-name.ts b/src/utils/mcp-name.ts index 3e6d1ab8a47..5f75f49c64e 100644 --- a/src/utils/mcp-name.ts +++ b/src/utils/mcp-name.ts @@ -18,17 +18,15 @@ export const MCP_TOOL_SEPARATOR = "--" export const MCP_TOOL_PREFIX = "mcp" /** - * Encoding for hyphens in tool names. - * We use triple underscores because: - * 1. It's unlikely to appear naturally in tool names - * 2. It's safe for all API providers - * 3. It allows us to preserve hyphens through the encoding/decoding process + * Normalize a string for comparison by treating hyphens and underscores as equivalent. + * This is used to match tool names when models convert hyphens to underscores. * - * This solves the problem where models (especially Claude) convert hyphens to underscores - * in tool names when using native tool calling. By encoding hyphens as triple underscores, - * we can decode them back to hyphens when parsing the tool name. + * @param name - The name to normalize + * @returns The normalized name with all hyphens converted to underscores */ -export const HYPHEN_ENCODING = "___" +export function normalizeForComparison(name: string): string { + return name.replace(/-/g, "_") +} /** * Normalize an MCP tool name by converting underscore separators back to hyphens. @@ -37,47 +35,54 @@ export const HYPHEN_ENCODING = "___" * * For example: "mcp__server__tool" -> "mcp--server--tool" * + * This function uses fuzzy matching - it treats hyphens and underscores as equivalent + * when normalizing the separator pattern. + * * @param toolName - The tool name that may have underscore separators * @returns The normalized tool name with hyphen separators */ export function normalizeMcpToolName(toolName: string): string { - // Only normalize if it looks like an MCP tool with underscore separators - if (toolName.startsWith("mcp__")) { - // Replace double underscores with double hyphens for the separators - // We need to be careful to only replace the separators, not the encoded hyphens (triple underscores) - // Pattern: mcp__server__tool -> mcp--server--tool - // But: mcp__server__tool___name should become mcp--server--tool___name (preserve triple underscores) - - // First, temporarily replace triple underscores with a placeholder - const placeholder = "\x00HYPHEN\x00" - let normalized = toolName.replace(/___/g, placeholder) - - // Now replace double underscores (separators) with double hyphens - normalized = normalized.replace(/__/g, "--") - - // Restore triple underscores from placeholder - normalized = normalized.replace(new RegExp(placeholder, "g"), "___") - - return normalized + // Normalize for comparison to detect MCP tools regardless of separator style + const normalized = normalizeForComparison(toolName) + + // Only normalize if it looks like an MCP tool (starts with mcp__) + if (normalized.startsWith("mcp__")) { + // Find the pattern: mcp{sep}server{sep}tool where sep is -- or __ + // We need to convert the separators while preserving the rest + + // First, try to parse assuming all separators are underscores + // Pattern: mcp__server__tool or mcp__server__tool_with_underscores + const parts = toolName.split(/__|--/) + + if (parts.length >= 3 && parts[0].toLowerCase() === "mcp") { + // Reconstruct with proper -- separators + const serverName = parts[1] + const toolNamePart = parts.slice(2).join("--") // Rejoin in case tool name had separator + return `${MCP_TOOL_PREFIX}${MCP_TOOL_SEPARATOR}${serverName}${MCP_TOOL_SEPARATOR}${toolNamePart}` + } } return toolName } /** * Check if a tool name is an MCP tool (starts with the MCP prefix and separator). + * Uses fuzzy matching to handle both hyphen and underscore separators. * * @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}`) + const normalized = normalizeForComparison(toolName) + return normalized.startsWith(`${MCP_TOOL_PREFIX}__`) } /** * Sanitize a name to be safe for use in API function names. - * This removes special characters, ensures the name starts correctly, - * and encodes hyphens as triple underscores to preserve them through - * the model's tool calling process. + * This removes special characters and ensures the name starts correctly. + * + * Note: Hyphens are preserved since they are valid in function names. + * Models may convert hyphens to underscores, but we handle this with + * fuzzy matching when parsing tool names. * * @param name - The original name (e.g., MCP server name or tool name) * @returns A sanitized name that conforms to API requirements @@ -90,17 +95,12 @@ export function sanitizeMcpName(name: string): string { // Replace spaces with underscores first let sanitized = name.replace(/\s+/g, "_") - // Only allow alphanumeric, underscores, and dashes + // Only allow alphanumeric, underscores, and hyphens sanitized = sanitized.replace(/[^a-zA-Z0-9_\-]/g, "") // Replace any double-hyphen sequences with single hyphen to avoid separator conflicts sanitized = sanitized.replace(/--+/g, "-") - // Encode single hyphens as triple underscores to preserve them - // This allows us to decode them back to hyphens when parsing - // e.g., "atlassian-jira_search" -> "atlassian___jira_search" - sanitized = sanitized.replace(/-/g, HYPHEN_ENCODING) - // Ensure the name starts with a letter or underscore if (sanitized.length > 0 && !/^[a-zA-Z_]/.test(sanitized)) { sanitized = "_" + sanitized @@ -139,33 +139,24 @@ export function buildMcpToolName(serverName: string, toolName: string): string { return fullName } -/** - * Decode a sanitized name back to its original form by converting - * triple underscores back to hyphens. - * - * @param sanitizedName - The sanitized name with encoded hyphens - * @returns The decoded name with hyphens restored - */ -export function decodeMcpName(sanitizedName: string): string { - return sanitizedName.replace(new RegExp(HYPHEN_ENCODING, "g"), "-") -} - /** * Parse an MCP tool function name back into server and tool names. - * This handles sanitized names by splitting on the "--" separator - * and decoding triple underscores back to hyphens. + * This handles both hyphen and underscore separators using fuzzy matching. * - * @param mcpToolName - The full MCP tool name (e.g., "mcp--weather--get_forecast") + * @param mcpToolName - The full MCP tool name (e.g., "mcp--weather--get_forecast" or "mcp__weather__get_forecast") * @returns An object with serverName and toolName, or null if parsing fails */ export function parseMcpToolName(mcpToolName: string): { serverName: string; toolName: string } | null { + // Normalize the name to handle both separator styles + const normalizedName = normalizeMcpToolName(mcpToolName) + const prefix = MCP_TOOL_PREFIX + MCP_TOOL_SEPARATOR - if (!mcpToolName.startsWith(prefix)) { + if (!normalizedName.startsWith(prefix)) { return null } // Remove the "mcp--" prefix - const remainder = mcpToolName.slice(prefix.length) + const remainder = normalizedName.slice(prefix.length) // Split on the separator to get server and tool names const separatorIndex = remainder.indexOf(MCP_TOOL_SEPARATOR) @@ -180,9 +171,20 @@ export function parseMcpToolName(mcpToolName: string): { serverName: string; too return null } - // Decode triple underscores back to hyphens return { - serverName: decodeMcpName(serverName), - toolName: decodeMcpName(toolName), + serverName, + toolName, } } + +/** + * Check if two tool names match using fuzzy comparison. + * Treats hyphens and underscores as equivalent. + * + * @param name1 - First tool name + * @param name2 - Second tool name + * @returns true if the names match (treating - and _ as equivalent) + */ +export function toolNamesMatch(name1: string, name2: string): boolean { + return normalizeForComparison(name1) === normalizeForComparison(name2) +}