فهرست منبع

Replace hyphen encoding with fuzzy matching for MCP tool names (#10775)

Daniel 3 هفته پیش
والد
کامیت
3877d02498

+ 1 - 1
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)
 		// Should only have one tool (from project server)
 		expect(result).toHaveLength(1)
 		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
 		// Project server takes priority
 		expect(getFunction(result[0]).description).toBe("Project description")
 		expect(getFunction(result[0]).description).toBe("Project description")
 	})
 	})

+ 12 - 7
src/core/tools/UseMcpToolTool.ts

@@ -4,6 +4,7 @@ import { Task } from "../task/Task"
 import { formatResponse } from "../prompts/responses"
 import { formatResponse } from "../prompts/responses"
 import { t } from "../../i18n"
 import { t } from "../../i18n"
 import type { ToolUse } from "../../shared/tools"
 import type { ToolUse } from "../../shared/tools"
+import { toolNamesMatch } from "../../utils/mcp-name"
 
 
 import { BaseTool, ToolCallbacks } from "./BaseTool"
 import { BaseTool, ToolCallbacks } from "./BaseTool"
 
 
@@ -43,6 +44,10 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
 				return
 				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
 			// Reset mistake count on successful validation
 			task.consecutiveMistakeCount = 0
 			task.consecutiveMistakeCount = 0
 
 
@@ -50,7 +55,7 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
 			const completeMessage = JSON.stringify({
 			const completeMessage = JSON.stringify({
 				type: "use_mcp_tool",
 				type: "use_mcp_tool",
 				serverName,
 				serverName,
-				toolName,
+				toolName: resolvedToolName,
 				arguments: params.arguments ? JSON.stringify(params.arguments) : undefined,
 				arguments: params.arguments ? JSON.stringify(params.arguments) : undefined,
 			} satisfies ClineAskUseMcpServer)
 			} satisfies ClineAskUseMcpServer)
 
 
@@ -65,7 +70,7 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
 			await this.executeToolAndProcessResult(
 			await this.executeToolAndProcessResult(
 				task,
 				task,
 				serverName,
 				serverName,
-				toolName,
+				resolvedToolName,
 				parsedArguments,
 				parsedArguments,
 				executionId,
 				executionId,
 				pushToolResult,
 				pushToolResult,
@@ -137,7 +142,7 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
 		serverName: string,
 		serverName: string,
 		toolName: string,
 		toolName: string,
 		pushToolResult: (content: string) => void,
 		pushToolResult: (content: string) => void,
-	): Promise<{ isValid: boolean; availableTools?: string[] }> {
+	): Promise<{ isValid: boolean; availableTools?: string[]; resolvedToolName?: string }> {
 		try {
 		try {
 			// Get the MCP hub to access server information
 			// Get the MCP hub to access server information
 			const provider = task.providerRef.deref()
 			const provider = task.providerRef.deref()
@@ -186,8 +191,8 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
 				return { isValid: false, availableTools: [] }
 				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) {
 			if (!tool) {
 				// Tool not found - provide list of available tools
 				// Tool not found - provide list of available tools
@@ -232,8 +237,8 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
 				return { isValid: false, availableTools: 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 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) {
 		} catch (error) {
 			// If there's an error during validation, log it but don't block the tool execution
 			// 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
 			// The actual tool call might still fail with a proper error

+ 55 - 0
src/core/tools/__tests__/useMcpToolTool.spec.ts

@@ -577,5 +577,60 @@ describe("useMcpToolTool", () => {
 			expect(callToolMock).not.toHaveBeenCalled()
 			expect(callToolMock).not.toHaveBeenCalled()
 			expect(mockAskApproval).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", {})
+		})
 	})
 	})
 })
 })

+ 16 - 2
src/services/mcp/McpHub.ts

@@ -38,7 +38,7 @@ import { fileExistsAtPath } from "../../utils/fs"
 import { arePathsEqual, getWorkspacePath } from "../../utils/path"
 import { arePathsEqual, getWorkspacePath } from "../../utils/path"
 import { injectVariables } from "../../utils/config"
 import { injectVariables } from "../../utils/config"
 import { safeWriteJson } from "../../utils/safeWriteJson"
 import { safeWriteJson } from "../../utils/safeWriteJson"
-import { sanitizeMcpName } from "../../utils/mcp-name"
+import { sanitizeMcpName, toolNamesMatch } from "../../utils/mcp-name"
 
 
 // Discriminated union for connection states
 // Discriminated union for connection states
 export type ConnectedMcpConnection = {
 export type ConnectedMcpConnection = {
@@ -940,16 +940,30 @@ export class McpHub {
 	 * Find a connection by sanitized server name.
 	 * Find a connection by sanitized server name.
 	 * This is used when parsing MCP tool responses where the server name has been
 	 * This is used when parsing MCP tool responses where the server name has been
 	 * sanitized (e.g., hyphens replaced with underscores) for API compliance.
 	 * 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
 	 * @param sanitizedServerName The sanitized server name from the API tool call
 	 * @returns The original server name if found, or null if no match
 	 * @returns The original server name if found, or null if no match
 	 */
 	 */
 	public findServerNameBySanitizedName(sanitizedServerName: string): string | null {
 	public findServerNameBySanitizedName(sanitizedServerName: string): string | null {
+		// First, check for an exact match
 		const exactMatch = this.connections.find((conn) => conn.server.name === sanitizedServerName)
 		const exactMatch = this.connections.find((conn) => conn.server.name === sanitizedServerName)
 		if (exactMatch) {
 		if (exactMatch) {
 			return exactMatch.server.name
 			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<McpTool[]> {
 	private async fetchToolsList(serverName: string, source?: "global" | "project"): Promise<McpTool[]> {

+ 215 - 92
src/utils/__tests__/mcp-name.spec.ts

@@ -2,12 +2,12 @@ import {
 	sanitizeMcpName,
 	sanitizeMcpName,
 	buildMcpToolName,
 	buildMcpToolName,
 	parseMcpToolName,
 	parseMcpToolName,
-	decodeMcpName,
 	normalizeMcpToolName,
 	normalizeMcpToolName,
+	normalizeForComparison,
+	toolNamesMatch,
 	isMcpTool,
 	isMcpTool,
 	MCP_TOOL_SEPARATOR,
 	MCP_TOOL_SEPARATOR,
 	MCP_TOOL_PREFIX,
 	MCP_TOOL_PREFIX,
-	HYPHEN_ENCODING,
 } from "../mcp-name"
 } from "../mcp-name"
 
 
 describe("mcp-name utilities", () => {
 describe("mcp-name utilities", () => {
@@ -16,16 +16,58 @@ describe("mcp-name utilities", () => {
 			expect(MCP_TOOL_SEPARATOR).toBe("--")
 			expect(MCP_TOOL_SEPARATOR).toBe("--")
 			expect(MCP_TOOL_PREFIX).toBe("mcp")
 			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", () => {
 	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--server--tool")).toBe(true)
 			expect(isMcpTool("mcp--my_server--get_forecast")).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", () => {
 		it("should return false for non-MCP tool names", () => {
@@ -35,7 +77,7 @@ describe("mcp-name utilities", () => {
 			expect(isMcpTool("")).toBe(false)
 			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)
 			expect(isMcpTool("mcp_server_tool")).toBe(false)
 		})
 		})
 
 
@@ -60,10 +102,9 @@ describe("mcp-name utilities", () => {
 			expect(sanitizeMcpName("test#$%^&*()")).toBe("test")
 			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")
 			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")
 			expect(sanitizeMcpName("Server123")).toBe("Server123")
 		})
 		})
 
 
@@ -71,16 +112,14 @@ describe("mcp-name utilities", () => {
 			// Dots and colons are NOT allowed due to AWS Bedrock restrictions
 			// Dots and colons are NOT allowed due to AWS Bedrock restrictions
 			expect(sanitizeMcpName("server.name")).toBe("servername")
 			expect(sanitizeMcpName("server.name")).toBe("servername")
 			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", () => {
 		it("should prepend underscore if name starts with non-letter/underscore", () => {
 			expect(sanitizeMcpName("123server")).toBe("_123server")
 			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
 			// Dots are removed, so ".server" becomes "server" which starts with a letter
 			expect(sanitizeMcpName(".server")).toBe("server")
 			expect(sanitizeMcpName(".server")).toBe("server")
 		})
 		})
@@ -91,17 +130,15 @@ describe("mcp-name utilities", () => {
 			expect(sanitizeMcpName("Server")).toBe("Server")
 			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", () => {
 		it("should handle complex names with multiple issues", () => {
 			expect(sanitizeMcpName("My Server @ Home!")).toBe("My_Server__Home")
 			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", () => {
 		it("should return placeholder for names that become empty after sanitization", () => {
@@ -110,26 +147,10 @@ describe("mcp-name utilities", () => {
 			expect(sanitizeMcpName("   ")).toBe("_")
 			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")
 			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", () => {
 	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({
 			expect(parseMcpToolName("mcp--server--tool")).toEqual({
 				serverName: "server",
 				serverName: "server",
 				toolName: "tool",
 				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", () => {
 		it("should return null for non-mcp tool names", () => {
 			expect(parseMcpToolName("server--tool")).toBeNull()
 			expect(parseMcpToolName("server--tool")).toBeNull()
 			expect(parseMcpToolName("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()
 			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",
 				serverName: "onellm",
 				toolName: "atlassian-jira_search",
 				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", () => {
 	describe("roundtrip behavior", () => {
 		it("should be able to parse names that were built", () => {
 		it("should be able to parse names that were built", () => {
 			const toolName = buildMcpToolName("server", "tool")
 			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 toolName = buildMcpToolName("my_server", "my_tool")
 			const parsed = parseMcpToolName(toolName)
 			const parsed = parseMcpToolName(toolName)
 			expect(parsed).toEqual({
 			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")
 			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)
 			const parsed = parseMcpToolName(toolName)
 			expect(parsed).toEqual({
 			expect(parsed).toEqual({
 				serverName: "onellm",
 				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")
 			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)
 			const parsed = parseMcpToolName(normalizedName)
 			expect(parsed).toEqual({
 			expect(parsed).toEqual({
 				serverName: "onellm",
 				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", () => {
 		it("should handle tool names with multiple hyphens through the full flow", () => {
 			// Build
 			// Build
 			const builtName = buildMcpToolName("server", "get-user-profile")
 			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
 			// 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
 			// Parse
 			const parsed = parseMcpToolName(normalizedName)
 			const parsed = parseMcpToolName(normalizedName)
 			expect(parsed).toEqual({
 			expect(parsed).toEqual({
 				serverName: "server",
 				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)
+		})
 	})
 	})
 })
 })

+ 59 - 57
src/utils/mcp-name.ts

@@ -18,17 +18,15 @@ export const MCP_TOOL_SEPARATOR = "--"
 export const MCP_TOOL_PREFIX = "mcp"
 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.
  * 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"
  * 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
  * @param toolName - The tool name that may have underscore separators
  * @returns The normalized tool name with hyphen separators
  * @returns The normalized tool name with hyphen separators
  */
  */
 export function normalizeMcpToolName(toolName: string): string {
 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
 	return toolName
 }
 }
 
 
 /**
 /**
  * Check if a tool name is an MCP tool (starts with the MCP prefix and separator).
  * 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
  * @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 {
 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.
  * 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)
  * @param name - The original name (e.g., MCP server name or tool name)
  * @returns A sanitized name that conforms to API requirements
  * @returns A sanitized name that conforms to API requirements
@@ -90,17 +95,12 @@ export function sanitizeMcpName(name: string): string {
 	// Replace spaces with underscores first
 	// Replace spaces with underscores first
 	let sanitized = name.replace(/\s+/g, "_")
 	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, "")
 	sanitized = sanitized.replace(/[^a-zA-Z0-9_\-]/g, "")
 
 
 	// Replace any double-hyphen sequences with single hyphen to avoid separator conflicts
 	// Replace any double-hyphen sequences with single hyphen to avoid separator conflicts
 	sanitized = sanitized.replace(/--+/g, "-")
 	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
 	// Ensure the name starts with a letter or underscore
 	if (sanitized.length > 0 && !/^[a-zA-Z_]/.test(sanitized)) {
 	if (sanitized.length > 0 && !/^[a-zA-Z_]/.test(sanitized)) {
 		sanitized = "_" + sanitized
 		sanitized = "_" + sanitized
@@ -139,33 +139,24 @@ export function buildMcpToolName(serverName: string, toolName: string): string {
 	return fullName
 	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.
  * 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
  * @returns An object with serverName and toolName, or null if parsing fails
  */
  */
 export function parseMcpToolName(mcpToolName: string): { serverName: string; toolName: string } | null {
 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
 	const prefix = MCP_TOOL_PREFIX + MCP_TOOL_SEPARATOR
-	if (!mcpToolName.startsWith(prefix)) {
+	if (!normalizedName.startsWith(prefix)) {
 		return null
 		return null
 	}
 	}
 
 
 	// Remove the "mcp--" prefix
 	// 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
 	// Split on the separator to get server and tool names
 	const separatorIndex = remainder.indexOf(MCP_TOOL_SEPARATOR)
 	const separatorIndex = remainder.indexOf(MCP_TOOL_SEPARATOR)
@@ -180,9 +171,20 @@ export function parseMcpToolName(mcpToolName: string): { serverName: string; too
 		return null
 		return null
 	}
 	}
 
 
-	// Decode triple underscores back to hyphens
 	return {
 	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)
+}