Skip to content

Commit f6aed2a

Browse files
committed
fix: use -- separator for MCP tool names to avoid underscore parsing ambiguity
The previous underscore-based format was incompatible with NativeToolCallParser.parseDynamicMcpTool() which uses underscore splitting. For a server named 'my server' with tool 'get_tool', the built name 'mcp_my_server_get_tool' got parsed incorrectly. Now using '--' (double hyphen) as the separator: - Format: mcp--serverName--toolName - Server names with underscores (from space sanitization) are correctly preserved - Double hyphens in names are collapsed to single to avoid separator conflicts
1 parent 55efa3f commit f6aed2a

File tree

3 files changed

+116
-56
lines changed

3 files changed

+116
-56
lines changed

src/core/assistant-message/NativeToolCallParser.ts

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
ApiStreamToolCallDeltaChunk,
1414
ApiStreamToolCallEndChunk,
1515
} from "../../api/transform/stream"
16+
import { MCP_TOOL_PREFIX, MCP_TOOL_SEPARATOR, parseMcpToolName } from "../../utils/mcp-name"
1617

1718
/**
1819
* Helper type to extract properly typed native arguments for a given tool.
@@ -238,7 +239,8 @@ export class NativeToolCallParser {
238239
toolCall.argumentsAccumulator += chunk
239240

240241
// For dynamic MCP tools, we don't return partial updates - wait for final
241-
if (toolCall.name.startsWith("mcp_")) {
242+
const mcpPrefix = MCP_TOOL_PREFIX + MCP_TOOL_SEPARATOR
243+
if (toolCall.name.startsWith(mcpPrefix)) {
242244
return null
243245
}
244246

@@ -554,8 +556,9 @@ export class NativeToolCallParser {
554556
name: TName
555557
arguments: string
556558
}): ToolUse<TName> | McpToolUse | null {
557-
// Check if this is a dynamic MCP tool (mcp_serverName_toolName)
558-
if (typeof toolCall.name === "string" && toolCall.name.startsWith("mcp_")) {
559+
// Check if this is a dynamic MCP tool (mcp--serverName--toolName)
560+
const mcpPrefix = MCP_TOOL_PREFIX + MCP_TOOL_SEPARATOR
561+
if (typeof toolCall.name === "string" && toolCall.name.startsWith(mcpPrefix)) {
559562
return this.parseDynamicMcpTool(toolCall)
560563
}
561564

@@ -811,7 +814,7 @@ export class NativeToolCallParser {
811814
}
812815

813816
/**
814-
* Parse dynamic MCP tools (named mcp_serverName_toolName).
817+
* Parse dynamic MCP tools (named mcp--serverName--toolName).
815818
* These are generated dynamically by getMcpServerTools() and are returned
816819
* as McpToolUse objects that preserve the original tool name.
817820
*
@@ -825,26 +828,19 @@ export class NativeToolCallParser {
825828
const args = JSON.parse(toolCall.arguments || "{}")
826829

827830
// Extract server_name and tool_name from the tool name itself
828-
// Format: mcp_serverName_toolName
829-
const nameParts = toolCall.name.split("_")
830-
if (nameParts.length < 3 || nameParts[0] !== "mcp") {
831+
// Format: mcp--serverName--toolName (using -- separator)
832+
const parsed = parseMcpToolName(toolCall.name)
833+
if (!parsed) {
831834
console.error(`Invalid dynamic MCP tool name format: ${toolCall.name}`)
832835
return null
833836
}
834837

835-
// Server name is the second part, tool name is everything after
836-
const serverName = nameParts[1]
837-
const toolName = nameParts.slice(2).join("_")
838-
839-
if (!serverName || !toolName) {
840-
console.error(`Could not extract server_name or tool_name from: ${toolCall.name}`)
841-
return null
842-
}
838+
const { serverName, toolName } = parsed
843839

844840
const result: McpToolUse = {
845841
type: "mcp_tool_use" as const,
846842
id: toolCall.id,
847-
// Keep the original tool name (e.g., "mcp_serverName_toolName") for API history
843+
// Keep the original tool name (e.g., "mcp--serverName--toolName") for API history
848844
name: toolCall.name,
849845
serverName,
850846
toolName,

src/utils/__tests__/mcp-name.spec.ts

Lines changed: 68 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1-
import { sanitizeMcpName, buildMcpToolName, parseMcpToolName } from "../mcp-name"
1+
import { sanitizeMcpName, buildMcpToolName, parseMcpToolName, MCP_TOOL_SEPARATOR, MCP_TOOL_PREFIX } from "../mcp-name"
22

33
describe("mcp-name utilities", () => {
4+
describe("constants", () => {
5+
it("should have correct separator and prefix", () => {
6+
expect(MCP_TOOL_SEPARATOR).toBe("--")
7+
expect(MCP_TOOL_PREFIX).toBe("mcp")
8+
})
9+
})
10+
411
describe("sanitizeMcpName", () => {
512
it("should return underscore placeholder for empty input", () => {
613
expect(sanitizeMcpName("")).toBe("_")
@@ -36,6 +43,12 @@ describe("mcp-name utilities", () => {
3643
expect(sanitizeMcpName("Server")).toBe("Server")
3744
})
3845

46+
it("should replace double-hyphen sequences with single hyphen to avoid separator conflicts", () => {
47+
expect(sanitizeMcpName("server--name")).toBe("server-name")
48+
expect(sanitizeMcpName("test---server")).toBe("test-server")
49+
expect(sanitizeMcpName("my----tool")).toBe("my-tool")
50+
})
51+
3952
it("should handle complex names with multiple issues", () => {
4053
expect(sanitizeMcpName("My Server @ Home!")).toBe("My_Server__Home")
4154
expect(sanitizeMcpName("123-test server")).toBe("_123-test_server")
@@ -49,65 +62,77 @@ describe("mcp-name utilities", () => {
4962
})
5063

5164
describe("buildMcpToolName", () => {
52-
it("should build tool name with mcp_ prefix", () => {
53-
expect(buildMcpToolName("server", "tool")).toBe("mcp_server_tool")
65+
it("should build tool name with mcp-- prefix and -- separators", () => {
66+
expect(buildMcpToolName("server", "tool")).toBe("mcp--server--tool")
5467
})
5568

5669
it("should sanitize both server and tool names", () => {
57-
expect(buildMcpToolName("my server", "my tool")).toBe("mcp_my_server_my_tool")
70+
expect(buildMcpToolName("my server", "my tool")).toBe("mcp--my_server--my_tool")
5871
})
5972

6073
it("should handle names with special characters", () => {
61-
expect(buildMcpToolName("server@name", "tool!name")).toBe("mcp_servername_toolname")
74+
expect(buildMcpToolName("server@name", "tool!name")).toBe("mcp--servername--toolname")
6275
})
6376

6477
it("should truncate long names to 64 characters", () => {
6578
const longServer = "a".repeat(50)
6679
const longTool = "b".repeat(50)
6780
const result = buildMcpToolName(longServer, longTool)
6881
expect(result.length).toBeLessThanOrEqual(64)
69-
expect(result.startsWith("mcp_")).toBe(true)
82+
expect(result.startsWith("mcp--")).toBe(true)
7083
})
7184

7285
it("should handle names starting with numbers", () => {
73-
expect(buildMcpToolName("123server", "456tool")).toBe("mcp__123server__456tool")
86+
expect(buildMcpToolName("123server", "456tool")).toBe("mcp--_123server--_456tool")
87+
})
88+
89+
it("should preserve underscores in server and tool names", () => {
90+
expect(buildMcpToolName("my_server", "my_tool")).toBe("mcp--my_server--my_tool")
7491
})
7592
})
7693

7794
describe("parseMcpToolName", () => {
7895
it("should parse valid mcp tool names", () => {
79-
expect(parseMcpToolName("mcp_server_tool")).toEqual({
96+
expect(parseMcpToolName("mcp--server--tool")).toEqual({
8097
serverName: "server",
8198
toolName: "tool",
8299
})
83100
})
84101

85102
it("should return null for non-mcp tool names", () => {
86-
expect(parseMcpToolName("server_tool")).toBeNull()
103+
expect(parseMcpToolName("server--tool")).toBeNull()
87104
expect(parseMcpToolName("tool")).toBeNull()
88105
})
89106

107+
it("should return null for old underscore format", () => {
108+
expect(parseMcpToolName("mcp_server_tool")).toBeNull()
109+
})
110+
90111
it("should handle tool names with underscores", () => {
91-
expect(parseMcpToolName("mcp_server_tool_name")).toEqual({
112+
expect(parseMcpToolName("mcp--server--tool_name")).toEqual({
92113
serverName: "server",
93114
toolName: "tool_name",
94115
})
95116
})
96117

97-
it("should handle server names with underscores (edge case)", () => {
98-
// Note: parseMcpToolName uses simple split, so it can't distinguish
99-
// server_name_tool from server + name_tool
100-
// The first underscore after 'mcp_' is treated as the server/tool separator
101-
const result = parseMcpToolName("mcp_my_server_tool")
102-
expect(result).toEqual({
103-
serverName: "my",
104-
toolName: "server_tool",
118+
it("should correctly handle server names with underscores (fixed from old behavior)", () => {
119+
// With the new -- separator, server names with underscores work correctly
120+
expect(parseMcpToolName("mcp--my_server--tool")).toEqual({
121+
serverName: "my_server",
122+
toolName: "tool",
123+
})
124+
})
125+
126+
it("should handle both server and tool names with underscores", () => {
127+
expect(parseMcpToolName("mcp--my_server--get_forecast")).toEqual({
128+
serverName: "my_server",
129+
toolName: "get_forecast",
105130
})
106131
})
107132

108133
it("should return null for malformed names", () => {
109-
expect(parseMcpToolName("mcp_")).toBeNull()
110-
expect(parseMcpToolName("mcp_server")).toBeNull()
134+
expect(parseMcpToolName("mcp--")).toBeNull()
135+
expect(parseMcpToolName("mcp--server")).toBeNull()
111136
})
112137
})
113138

@@ -121,14 +146,32 @@ describe("mcp-name utilities", () => {
121146
})
122147
})
123148

124-
it("should preserve sanitized names through roundtrip", () => {
125-
// Names with spaces become underscores, but roundtrip still works
126-
// for the sanitized version
149+
it("should preserve sanitized names through roundtrip with underscores", () => {
150+
// Names with underscores now work correctly through roundtrip
127151
const toolName = buildMcpToolName("my_server", "my_tool")
128152
const parsed = parseMcpToolName(toolName)
129153
expect(parsed).toEqual({
130-
serverName: "my",
131-
toolName: "server_my_tool",
154+
serverName: "my_server",
155+
toolName: "my_tool",
156+
})
157+
})
158+
159+
it("should handle spaces that get converted to underscores", () => {
160+
// "my server" becomes "my_server" after sanitization
161+
const toolName = buildMcpToolName("my server", "get tool")
162+
const parsed = parseMcpToolName(toolName)
163+
expect(parsed).toEqual({
164+
serverName: "my_server",
165+
toolName: "get_tool",
166+
})
167+
})
168+
169+
it("should handle complex server and tool names", () => {
170+
const toolName = buildMcpToolName("Weather API", "get_current_forecast")
171+
const parsed = parseMcpToolName(toolName)
172+
expect(parsed).toEqual({
173+
serverName: "Weather_API",
174+
toolName: "get_current_forecast",
132175
})
133176
})
134177
})

src/utils/mcp-name.ts

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,26 @@
88
* - Maximum length of 64 characters
99
*/
1010

11+
/**
12+
* Separator used between MCP prefix, server name, and tool name.
13+
* We use "--" (double hyphen) because:
14+
* 1. It's allowed by Gemini (dashes are permitted in function names)
15+
* 2. It won't conflict with underscores in sanitized server/tool names
16+
* 3. It's unique enough to be a reliable delimiter for parsing
17+
*/
18+
export const MCP_TOOL_SEPARATOR = "--"
19+
20+
/**
21+
* Prefix for all MCP tool function names.
22+
*/
23+
export const MCP_TOOL_PREFIX = "mcp"
24+
1125
/**
1226
* Sanitize a name to be safe for use in API function names.
27+
* This removes special characters and ensures the name starts correctly.
28+
*
29+
* Note: This does NOT remove dashes from names, but the separator "--" is
30+
* distinct enough (double hyphen) that single hyphens in names won't conflict.
1331
*
1432
* @param name - The original name (e.g., MCP server name or tool name)
1533
* @returns A sanitized name that conforms to API requirements
@@ -25,6 +43,9 @@ export function sanitizeMcpName(name: string): string {
2543
// Remove any characters that are not alphanumeric, underscores, dots, colons, or dashes
2644
sanitized = sanitized.replace(/[^a-zA-Z0-9_.\-:]/g, "")
2745

46+
// Replace any double-hyphen sequences with single hyphen to avoid separator conflicts
47+
sanitized = sanitized.replace(/--+/g, "-")
48+
2849
// Ensure the name starts with a letter or underscore
2950
if (sanitized.length > 0 && !/^[a-zA-Z_]/.test(sanitized)) {
3051
sanitized = "_" + sanitized
@@ -40,21 +61,20 @@ export function sanitizeMcpName(name: string): string {
4061

4162
/**
4263
* Build a full MCP tool function name from server and tool names.
43-
* The format is: mcp_{sanitized_server_name}_{sanitized_tool_name}
64+
* The format is: mcp--{sanitized_server_name}--{sanitized_tool_name}
4465
*
4566
* The total length is capped at 64 characters to conform to API limits.
4667
*
4768
* @param serverName - The MCP server name
4869
* @param toolName - The tool name
49-
* @returns A sanitized function name in the format mcp_serverName_toolName
70+
* @returns A sanitized function name in the format mcp--serverName--toolName
5071
*/
5172
export function buildMcpToolName(serverName: string, toolName: string): string {
5273
const sanitizedServer = sanitizeMcpName(serverName)
5374
const sanitizedTool = sanitizeMcpName(toolName)
5475

55-
// Build the full name: mcp_{server}_{tool}
56-
// "mcp_" = 4 chars, "_" separator = 1 char, max total = 64
57-
const fullName = `mcp_${sanitizedServer}_${sanitizedTool}`
76+
// Build the full name: mcp--{server}--{tool}
77+
const fullName = `${MCP_TOOL_PREFIX}${MCP_TOOL_SEPARATOR}${sanitizedServer}${MCP_TOOL_SEPARATOR}${sanitizedTool}`
5878

5979
// Truncate if necessary (max 64 chars for Gemini)
6080
if (fullName.length > 64) {
@@ -66,30 +86,31 @@ export function buildMcpToolName(serverName: string, toolName: string): string {
6686

6787
/**
6888
* Parse an MCP tool function name back into server and tool names.
69-
* This handles sanitized names by splitting on the expected format.
89+
* This handles sanitized names by splitting on the "--" separator.
7090
*
7191
* Note: This returns the sanitized names, not the original names.
7292
* The original names cannot be recovered from the sanitized version.
7393
*
74-
* @param mcpToolName - The full MCP tool name (e.g., "mcp_weather_get_forecast")
94+
* @param mcpToolName - The full MCP tool name (e.g., "mcp--weather--get_forecast")
7595
* @returns An object with serverName and toolName, or null if parsing fails
7696
*/
7797
export function parseMcpToolName(mcpToolName: string): { serverName: string; toolName: string } | null {
78-
if (!mcpToolName.startsWith("mcp_")) {
98+
const prefix = MCP_TOOL_PREFIX + MCP_TOOL_SEPARATOR
99+
if (!mcpToolName.startsWith(prefix)) {
79100
return null
80101
}
81102

82-
// Remove the "mcp_" prefix
83-
const remainder = mcpToolName.slice(4)
103+
// Remove the "mcp--" prefix
104+
const remainder = mcpToolName.slice(prefix.length)
84105

85-
// Find the first underscore to split server from tool
86-
const underscoreIndex = remainder.indexOf("_")
87-
if (underscoreIndex === -1) {
106+
// Split on the separator to get server and tool names
107+
const separatorIndex = remainder.indexOf(MCP_TOOL_SEPARATOR)
108+
if (separatorIndex === -1) {
88109
return null
89110
}
90111

91-
const serverName = remainder.slice(0, underscoreIndex)
92-
const toolName = remainder.slice(underscoreIndex + 1)
112+
const serverName = remainder.slice(0, separatorIndex)
113+
const toolName = remainder.slice(separatorIndex + MCP_TOOL_SEPARATOR.length)
93114

94115
if (!serverName || !toolName) {
95116
return null

0 commit comments

Comments
 (0)