mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
Replace hyphen encoding with fuzzy matching for MCP tool names (#10775)
This commit is contained in:
parent
83f123f270
commit
3877d02498
6 changed files with 361 additions and 162 deletions
|
|
@ -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")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
@ -43,6 +44,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
|
||||
|
||||
|
|
@ -50,7 +55,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)
|
||||
|
||||
|
|
@ -65,7 +70,7 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
|
|||
await this.executeToolAndProcessResult(
|
||||
task,
|
||||
serverName,
|
||||
toolName,
|
||||
resolvedToolName,
|
||||
parsedArguments,
|
||||
executionId,
|
||||
pushToolResult,
|
||||
|
|
@ -137,7 +142,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()
|
||||
|
|
@ -186,8 +191,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
|
||||
|
|
@ -232,8 +237,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
|
||||
|
|
|
|||
|
|
@ -577,5 +577,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", {})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<McpTool[]> {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
it("should have correct hyphen encoding", () => {
|
||||
expect(HYPHEN_ENCODING).toBe("___")
|
||||
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 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")
|
||||
})
|
||||
|
||||
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 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",
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle multiple encoded hyphens", () => {
|
||||
expect(normalizeMcpToolName("mcp__server__get___user___profile")).toBe("mcp--server--get___user___profile")
|
||||
})
|
||||
})
|
||||
|
||||
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 preserves hyphens", () => {
|
||||
// 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 outputs as-is (no mangling)
|
||||
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 (no change needed)
|
||||
const normalizedName = normalizeMcpToolName(modelOutput)
|
||||
expect(normalizedName).toBe("mcp--onellm--atlassian-jira_search")
|
||||
|
||||
// Step 4: Parse the normalized name (decodes ___ back to -)
|
||||
// Step 4: Parse
|
||||
const parsed = parseMcpToolName(normalizedName)
|
||||
expect(parsed).toEqual({
|
||||
serverName: "onellm",
|
||||
toolName: "atlassian-jira_search", // Original hyphen is preserved!
|
||||
toolName: "atlassian-jira_search",
|
||||
})
|
||||
})
|
||||
|
||||
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",
|
||||
})
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
// Step 2: Model converts ALL hyphens to underscores
|
||||
const modelOutput = "mcp__onellm__atlassian_jira_search"
|
||||
|
||||
// Step 3: Normalize
|
||||
const normalizedName = normalizeMcpToolName(modelOutput)
|
||||
expect(normalizedName).toBe("mcp--onellm--atlassian_jira_search")
|
||||
|
||||
// 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", // 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// Normalize for comparison to detect MCP tools regardless of separator style
|
||||
const normalized = normalizeForComparison(toolName)
|
||||
|
||||
// First, temporarily replace triple underscores with a placeholder
|
||||
const placeholder = "\x00HYPHEN\x00"
|
||||
let normalized = toolName.replace(/___/g, placeholder)
|
||||
// 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
|
||||
|
||||
// Now replace double underscores (separators) with double hyphens
|
||||
normalized = normalized.replace(/__/g, "--")
|
||||
// First, try to parse assuming all separators are underscores
|
||||
// Pattern: mcp__server__tool or mcp__server__tool_with_underscores
|
||||
const parts = toolName.split(/__|--/)
|
||||
|
||||
// Restore triple underscores from placeholder
|
||||
normalized = normalized.replace(new RegExp(placeholder, "g"), "___")
|
||||
|
||||
return normalized
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue