Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions src/core/assistant-message/NativeToolCallParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type {
ApiStreamToolCallDeltaChunk,
ApiStreamToolCallEndChunk,
} from "../../api/transform/stream"
import { MCP_TOOL_PREFIX, MCP_TOOL_SEPARATOR, parseMcpToolName } from "../../utils/mcp-name"
import { MCP_TOOL_PREFIX, MCP_TOOL_SEPARATOR, parseMcpToolName, normalizeMcpToolName } from "../../utils/mcp-name"

/**
* Helper type to extract properly typed native arguments for a given tool.
Expand Down Expand Up @@ -52,7 +52,7 @@ export type ToolCallStreamEvent = ApiStreamToolCallStartChunk | ApiStreamToolCal
*/
export class NativeToolCallParser {
// Streaming state management for argument accumulation (keyed by tool call id)
// Note: name is string to accommodate dynamic MCP tools (mcp_serverName_toolName)
// Note: name is string to accommodate dynamic MCP tools (mcp--serverName--toolName)
private static streamingToolCalls = new Map<
string,
{
Expand Down Expand Up @@ -199,7 +199,7 @@ export class NativeToolCallParser {
/**
* Start streaming a new tool call.
* Initializes tracking for incremental argument parsing.
* Accepts string to support both ToolName and dynamic MCP tools (mcp_serverName_toolName).
* Accepts string to support both ToolName and dynamic MCP tools (mcp--serverName--toolName).
*/
public static startStreamingToolCall(id: string, name: string): void {
this.streamingToolCalls.set(id, {
Expand Down Expand Up @@ -575,10 +575,16 @@ export class NativeToolCallParser {
arguments: string
}): ToolUse<TName> | McpToolUse | null {
// Check if this is a dynamic MCP tool (mcp--serverName--toolName)
// Also handle models that output underscores instead of hyphens (mcp__serverName__toolName)
const mcpPrefix = MCP_TOOL_PREFIX + MCP_TOOL_SEPARATOR

if (typeof toolCall.name === "string" && toolCall.name.startsWith(mcpPrefix)) {
return this.parseDynamicMcpTool(toolCall)
if (typeof toolCall.name === "string") {
// Normalize the tool name to handle models that output underscores instead of hyphens
const normalizedName = normalizeMcpToolName(toolCall.name)
if (normalizedName.startsWith(mcpPrefix)) {
// Pass the original tool call but with normalized name for parsing
return this.parseDynamicMcpTool({ ...toolCall, name: normalizedName })
}
}

// Resolve tool alias to canonical name
Expand Down Expand Up @@ -865,11 +871,15 @@ export class NativeToolCallParser {
// Parse the arguments - these are the actual tool arguments passed directly
const args = JSON.parse(toolCall.arguments || "{}")

// Normalize the tool name to handle models that output underscores instead of hyphens
// e.g., mcp__serverName__toolName -> mcp--serverName--toolName
const normalizedName = normalizeMcpToolName(toolCall.name)

// Extract server_name and tool_name from the tool name itself
// Format: mcp--serverName--toolName (using -- separator)
const parsed = parseMcpToolName(toolCall.name)
const parsed = parseMcpToolName(normalizedName)
if (!parsed) {
console.error(`Invalid dynamic MCP tool name format: ${toolCall.name}`)
console.error(`Invalid dynamic MCP tool name format: ${toolCall.name} (normalized: ${normalizedName})`)
return null
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
Expand Down
162 changes: 149 additions & 13 deletions src/utils/__tests__/mcp-name.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import {
sanitizeMcpName,
buildMcpToolName,
parseMcpToolName,
decodeMcpName,
normalizeMcpToolName,
isMcpTool,
MCP_TOOL_SEPARATOR,
MCP_TOOL_PREFIX,
HYPHEN_ENCODING,
} from "../mcp-name"

describe("mcp-name utilities", () => {
Expand All @@ -13,6 +16,10 @@ 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("isMcpTool", () => {
Expand Down Expand Up @@ -53,22 +60,27 @@ describe("mcp-name utilities", () => {
expect(sanitizeMcpName("test#$%^&*()")).toBe("test")
})

it("should keep valid characters (alphanumeric, underscore, dash)", () => {
it("should keep alphanumeric and underscores, but encode 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("Server123")).toBe("Server123")
})

it("should remove dots and colons for AWS Bedrock compatibility", () => {
// 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("awslabs.aws-documentation-mcp-server")).toBe("awslabsaws-documentation-mcp-server")
// Hyphens are encoded as triple underscores
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")
expect(sanitizeMcpName("-server")).toBe("_-server")
// Hyphen at start is encoded to ___, which starts with underscore (valid)
expect(sanitizeMcpName("-server")).toBe("___server")
// Dots are removed, so ".server" becomes "server" which starts with a letter
expect(sanitizeMcpName(".server")).toBe("server")
})
Expand All @@ -79,22 +91,46 @@ describe("mcp-name utilities", () => {
expect(sanitizeMcpName("Server")).toBe("Server")
})

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 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 handle complex names with multiple issues", () => {
expect(sanitizeMcpName("My Server @ Home!")).toBe("My_Server__Home")
expect(sanitizeMcpName("123-test server")).toBe("_123-test_server")
// Hyphen is encoded as ___
expect(sanitizeMcpName("123-test server")).toBe("_123___test_server")
})

it("should return placeholder for names that become empty after sanitization", () => {
expect(sanitizeMcpName("@#$%")).toBe("_unnamed")
// Spaces become underscores, which is a valid character, so it returns "_"
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")
})
})

describe("buildMcpToolName", () => {
Expand Down Expand Up @@ -125,6 +161,11 @@ describe("mcp-name utilities", () => {
it("should preserve underscores in server and tool names", () => {
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")
})
})

describe("parseMcpToolName", () => {
Expand All @@ -151,8 +192,7 @@ describe("mcp-name utilities", () => {
})
})

it("should correctly handle server names with underscores (fixed from old behavior)", () => {
// With the new -- separator, server names with underscores work correctly
it("should correctly handle server names with underscores", () => {
expect(parseMcpToolName("mcp--my_server--tool")).toEqual({
serverName: "my_server",
toolName: "tool",
Expand All @@ -166,6 +206,14 @@ 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({
serverName: "onellm",
toolName: "atlassian-jira_search",
})
})

it("should return null for malformed names", () => {
expect(parseMcpToolName("mcp--")).toBeNull()
expect(parseMcpToolName("mcp--server")).toBeNull()
Expand All @@ -183,7 +231,6 @@ describe("mcp-name utilities", () => {
})

it("should preserve sanitized names through roundtrip with underscores", () => {
// Names with underscores now work correctly through roundtrip
const toolName = buildMcpToolName("my_server", "my_tool")
const parsed = parseMcpToolName(toolName)
expect(parsed).toEqual({
Expand All @@ -193,7 +240,6 @@ describe("mcp-name utilities", () => {
})

it("should handle spaces that get converted to underscores", () => {
// "my server" becomes "my_server" after sanitization
const toolName = buildMcpToolName("my server", "get tool")
const parsed = parseMcpToolName(toolName)
expect(parsed).toEqual({
Expand All @@ -210,5 +256,95 @@ describe("mcp-name utilities", () => {
toolName: "get_current_forecast",
})
})

it("should preserve hyphens through roundtrip via encoding/decoding", () => {
// This is the key test: hyphens survive the roundtrip
const toolName = buildMcpToolName("onellm", "atlassian-jira_search")
expect(toolName).toBe("mcp--onellm--atlassian___jira_search")

const parsed = parseMcpToolName(toolName)
expect(parsed).toEqual({
serverName: "onellm",
toolName: "atlassian-jira_search", // Hyphen is preserved!
})
})

it("should handle tool names with multiple hyphens", () => {
const toolName = buildMcpToolName("server", "get-user-profile")
const parsed = parseMcpToolName(toolName)
expect(parsed).toEqual({
serverName: "server",
toolName: "get-user-profile",
})
})
})

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 ___)
const builtName = buildMcpToolName("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 3: Normalize the separators back (__ becomes --)
const normalizedName = normalizeMcpToolName(mangledName)
expect(normalizedName).toBe("mcp--onellm--atlassian___jira_search")

// Step 4: Parse the normalized name (decodes ___ back to -)
const parsed = parseMcpToolName(normalizedName)
expect(parsed).toEqual({
serverName: "onellm",
toolName: "atlassian-jira_search", // Original hyphen is preserved!
})
})

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")

// Model mangles
const mangledName = "mcp__server__get___user___profile"

// Normalize
const normalizedName = normalizeMcpToolName(mangledName)
expect(normalizedName).toBe("mcp--server--get___user___profile")

// Parse
const parsed = parseMcpToolName(normalizedName)
expect(parsed).toEqual({
serverName: "server",
toolName: "get-user-profile",
})
})
})
})
Loading
Loading