Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit a1cfe4c

Browse files
committed
fix: use hash suffix instead of truncation for long MCP tool names
Addresses Issue #10766 where MCP tool names with hyphens fail when using native tool calling due to truncation cutting in the middle of hyphen encodings (___). Changes: - Add hash suffix (8 hex chars) when tool names exceed 64 characters instead of simple truncation - Register shortened names in a registry to allow lookup of original server/tool names - Parse function now checks registry first for shortened names - Add comprehensive tests for hash suffix behavior and roundtrip This ensures that long tool names with hyphens can be correctly resolved even after shortening, preserving the original tool identity.
1 parent 8b9f02a commit a1cfe4c

2 files changed

Lines changed: 250 additions & 5 deletions

File tree

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

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,19 @@ import {
88
MCP_TOOL_SEPARATOR,
99
MCP_TOOL_PREFIX,
1010
HYPHEN_ENCODING,
11+
MAX_TOOL_NAME_LENGTH,
12+
HASH_SUFFIX_LENGTH,
13+
mcpToolNameRegistry,
14+
clearMcpToolNameRegistry,
15+
computeHashSuffix,
1116
} from "../mcp-name"
1217

1318
describe("mcp-name utilities", () => {
19+
// Clear the registry before each test to ensure isolation
20+
beforeEach(() => {
21+
clearMcpToolNameRegistry()
22+
})
23+
1424
describe("constants", () => {
1525
it("should have correct separator and prefix", () => {
1626
expect(MCP_TOOL_SEPARATOR).toBe("--")
@@ -20,6 +30,14 @@ describe("mcp-name utilities", () => {
2030
it("should have correct hyphen encoding", () => {
2131
expect(HYPHEN_ENCODING).toBe("___")
2232
})
33+
34+
it("should have correct max tool name length", () => {
35+
expect(MAX_TOOL_NAME_LENGTH).toBe(64)
36+
})
37+
38+
it("should have correct hash suffix length", () => {
39+
expect(HASH_SUFFIX_LENGTH).toBe(8)
40+
})
2341
})
2442

2543
describe("isMcpTool", () => {
@@ -146,12 +164,52 @@ describe("mcp-name utilities", () => {
146164
expect(buildMcpToolName("server@name", "tool!name")).toBe("mcp--servername--toolname")
147165
})
148166

149-
it("should truncate long names to 64 characters", () => {
167+
it("should truncate long names to 64 characters with hash suffix", () => {
150168
const longServer = "a".repeat(50)
151169
const longTool = "b".repeat(50)
152170
const result = buildMcpToolName(longServer, longTool)
153171
expect(result.length).toBeLessThanOrEqual(64)
172+
expect(result.length).toBe(64)
154173
expect(result.startsWith("mcp--")).toBe(true)
174+
// Should end with underscore + 8 char hash suffix
175+
expect(result).toMatch(/_[a-f0-9]{8}$/)
176+
})
177+
178+
it("should use hash suffix for long names and register them", () => {
179+
const longServer = "a".repeat(50)
180+
const longTool = "b".repeat(50)
181+
const result = buildMcpToolName(longServer, longTool)
182+
183+
// The shortened name should be registered
184+
expect(mcpToolNameRegistry.has(result)).toBe(true)
185+
const registered = mcpToolNameRegistry.get(result)
186+
expect(registered).toEqual({
187+
serverName: longServer,
188+
toolName: longTool,
189+
})
190+
})
191+
192+
it("should produce deterministic hash suffixes", () => {
193+
const longServer = "a".repeat(50)
194+
const longTool = "b".repeat(50)
195+
// Build the same name twice
196+
clearMcpToolNameRegistry()
197+
const result1 = buildMcpToolName(longServer, longTool)
198+
clearMcpToolNameRegistry()
199+
const result2 = buildMcpToolName(longServer, longTool)
200+
// Should produce identical results
201+
expect(result1).toBe(result2)
202+
})
203+
204+
it("should produce unique hash suffixes for different tools", () => {
205+
const longServer = "a".repeat(50)
206+
const result1 = buildMcpToolName(longServer, "tool1_" + "x".repeat(40))
207+
const result2 = buildMcpToolName(longServer, "tool2_" + "y".repeat(40))
208+
// Both should be truncated
209+
expect(result1.length).toBe(64)
210+
expect(result2.length).toBe(64)
211+
// Should have different hash suffixes
212+
expect(result1).not.toBe(result2)
155213
})
156214

157215
it("should handle names starting with numbers", () => {
@@ -347,4 +405,119 @@ describe("mcp-name utilities", () => {
347405
})
348406
})
349407
})
408+
409+
describe("computeHashSuffix", () => {
410+
it("should compute deterministic hash for the same inputs", () => {
411+
const hash1 = computeHashSuffix("server", "tool")
412+
const hash2 = computeHashSuffix("server", "tool")
413+
expect(hash1).toBe(hash2)
414+
})
415+
416+
it("should return 8-character hex string", () => {
417+
const hash = computeHashSuffix("server", "tool")
418+
expect(hash).toHaveLength(8)
419+
expect(hash).toMatch(/^[a-f0-9]{8}$/)
420+
})
421+
422+
it("should produce different hashes for different inputs", () => {
423+
const hash1 = computeHashSuffix("server1", "tool")
424+
const hash2 = computeHashSuffix("server2", "tool")
425+
expect(hash1).not.toBe(hash2)
426+
})
427+
})
428+
429+
describe("hash suffix roundtrip - fixes issue #10766", () => {
430+
it("should preserve original names through roundtrip with long hyphenated tool names", () => {
431+
// This is the exact scenario from issue #10766
432+
// Tool name with many hyphens that exceeds 64 chars when encoded
433+
const serverName = "abcdefghij-kl-mnop-qrs-tuv"
434+
const toolName = "wxyz-abcd-efghijk-lmno"
435+
436+
// Build the tool name
437+
const builtName = buildMcpToolName(serverName, toolName)
438+
439+
// Should be truncated to 64 chars with hash suffix
440+
expect(builtName.length).toBe(64)
441+
expect(builtName).toMatch(/_[a-f0-9]{8}$/)
442+
443+
// The critical fix: parsing should return the ORIGINAL names
444+
const parsed = parseMcpToolName(builtName)
445+
expect(parsed).toEqual({
446+
serverName: serverName, // Original with hyphens!
447+
toolName: toolName, // Original with hyphens!
448+
})
449+
})
450+
451+
it("should not corrupt hyphen encoding when truncation is needed", () => {
452+
// Long server and tool names that would cause truncation mid-encoding
453+
const serverName = "very-long-server-name-with-many-hyphens"
454+
const toolName = "another-long-tool-name-with-hyphens"
455+
456+
// Build the tool name
457+
const builtName = buildMcpToolName(serverName, toolName)
458+
459+
// Should be truncated to 64 chars
460+
expect(builtName.length).toBe(64)
461+
462+
// Parse should return original names (via registry lookup)
463+
const parsed = parseMcpToolName(builtName)
464+
expect(parsed).toEqual({
465+
serverName: serverName,
466+
toolName: toolName,
467+
})
468+
})
469+
470+
it("should work correctly when names do not need truncation", () => {
471+
// Short names that don't need truncation
472+
const serverName = "server"
473+
const toolName = "get-data"
474+
475+
const builtName = buildMcpToolName(serverName, toolName)
476+
477+
// Should NOT have hash suffix
478+
expect(builtName).toBe("mcp--server--get___data")
479+
expect(builtName.length).toBeLessThan(64)
480+
481+
// Normal decode path should work
482+
const parsed = parseMcpToolName(builtName)
483+
expect(parsed).toEqual({
484+
serverName: "server",
485+
toolName: "get-data", // Hyphen decoded from ___
486+
})
487+
})
488+
489+
it("should handle the registry lookup for shortened names", () => {
490+
const serverName = "a".repeat(30)
491+
const toolName = "b".repeat(30) + "-hyphen"
492+
493+
// Build registers the shortened name
494+
const builtName = buildMcpToolName(serverName, toolName)
495+
496+
// Verify it's in the registry
497+
expect(mcpToolNameRegistry.has(builtName)).toBe(true)
498+
499+
// Parse uses the registry to get original names
500+
const parsed = parseMcpToolName(builtName)
501+
expect(parsed).toEqual({
502+
serverName: serverName,
503+
toolName: toolName,
504+
})
505+
})
506+
})
507+
508+
describe("clearMcpToolNameRegistry", () => {
509+
it("should clear all registered tool names", () => {
510+
// Register some tool names via buildMcpToolName
511+
const longServer = "a".repeat(50)
512+
const longTool = "b".repeat(50)
513+
buildMcpToolName(longServer, longTool)
514+
515+
expect(mcpToolNameRegistry.size).toBeGreaterThan(0)
516+
517+
// Clear the registry
518+
clearMcpToolNameRegistry()
519+
520+
expect(mcpToolNameRegistry.size).toBe(0)
521+
})
522+
})
350523
})

‎src/utils/mcp-name.ts‎

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* API function name requirements across all providers.
44
*/
55

6+
import * as crypto from "crypto"
7+
68
/**
79
* Separator used between MCP prefix, server name, and tool name.
810
* We use "--" (double hyphen) because:
@@ -30,6 +32,50 @@ export const MCP_TOOL_PREFIX = "mcp"
3032
*/
3133
export const HYPHEN_ENCODING = "___"
3234

35+
/**
36+
* Maximum length for tool names (Gemini's limit).
37+
*/
38+
export const MAX_TOOL_NAME_LENGTH = 64
39+
40+
/**
41+
* Length of hash suffix used when truncation is needed.
42+
* Using 8 characters from base36 gives us ~2.8 trillion combinations,
43+
* which is more than enough to avoid collisions.
44+
*/
45+
export const HASH_SUFFIX_LENGTH = 8
46+
47+
/**
48+
* Registry mapping shortened tool names (with hash suffix) to their original
49+
* server and tool names. This is used to look up the original names when
50+
* the model returns a shortened tool name.
51+
*
52+
* Key: shortened MCP tool name (e.g., "mcp--server--tool_a1b2c3d4")
53+
* Value: { serverName, toolName } with original (decoded) names
54+
*/
55+
export const mcpToolNameRegistry = new Map<string, { serverName: string; toolName: string }>()
56+
57+
/**
58+
* Clear the MCP tool name registry.
59+
* Should be called when MCP servers are refreshed or disconnected.
60+
*/
61+
export function clearMcpToolNameRegistry(): void {
62+
mcpToolNameRegistry.clear()
63+
}
64+
65+
/**
66+
* Compute a deterministic hash suffix for a given server and tool name combination.
67+
* Uses the original (not encoded) names to ensure consistency.
68+
*
69+
* @param serverName - The original server name (before sanitization)
70+
* @param toolName - The original tool name (before sanitization)
71+
* @returns An 8-character alphanumeric hash suffix
72+
*/
73+
export function computeHashSuffix(serverName: string, toolName: string): string {
74+
const hash = crypto.createHash("sha256").update(`${serverName}:${toolName}`).digest("hex")
75+
// Use first 8 hex characters for the suffix
76+
return hash.slice(0, HASH_SUFFIX_LENGTH)
77+
}
78+
3379
/**
3480
* Normalize an MCP tool name by converting underscore separators back to hyphens.
3581
* This handles the case where models (especially Claude) convert hyphens to underscores
@@ -119,6 +165,8 @@ export function sanitizeMcpName(name: string): string {
119165
* The format is: mcp--{sanitized_server_name}--{sanitized_tool_name}
120166
*
121167
* The total length is capped at 64 characters to conform to API limits.
168+
* When truncation is needed, a hash suffix is appended to preserve uniqueness
169+
* and the mapping is stored in the registry for later lookup.
122170
*
123171
* @param serverName - The MCP server name
124172
* @param toolName - The tool name
@@ -131,12 +179,26 @@ export function buildMcpToolName(serverName: string, toolName: string): string {
131179
// Build the full name: mcp--{server}--{tool}
132180
const fullName = `${MCP_TOOL_PREFIX}${MCP_TOOL_SEPARATOR}${sanitizedServer}${MCP_TOOL_SEPARATOR}${sanitizedTool}`
133181

134-
// Truncate if necessary (max 64 chars for Gemini)
135-
if (fullName.length > 64) {
136-
return fullName.slice(0, 64)
182+
// If within limit, return as-is
183+
if (fullName.length <= MAX_TOOL_NAME_LENGTH) {
184+
return fullName
137185
}
138186

139-
return fullName
187+
// Need to truncate: use hash suffix to preserve uniqueness
188+
// Format: truncated_name_HASHSUFFIX (underscore + 8 hex chars = 9 chars for suffix)
189+
const hashSuffix = computeHashSuffix(serverName, toolName)
190+
const suffixWithSeparator = `_${hashSuffix}` // "_" + 8 chars = 9 chars
191+
const maxTruncatedLength = MAX_TOOL_NAME_LENGTH - suffixWithSeparator.length // 64 - 9 = 55
192+
193+
// Truncate the full name and append hash suffix
194+
const truncatedBase = fullName.slice(0, maxTruncatedLength)
195+
const shortenedName = `${truncatedBase}${suffixWithSeparator}`
196+
197+
// Register the mapping from shortened name to original names
198+
// Store the original (decoded) names so parseMcpToolName can return them directly
199+
mcpToolNameRegistry.set(shortenedName, { serverName, toolName })
200+
201+
return shortenedName
140202
}
141203

142204
/**
@@ -155,6 +217,9 @@ export function decodeMcpName(sanitizedName: string): string {
155217
* This handles sanitized names by splitting on the "--" separator
156218
* and decoding triple underscores back to hyphens.
157219
*
220+
* For shortened names (those with hash suffixes), this first checks
221+
* the registry for the original names.
222+
*
158223
* @param mcpToolName - The full MCP tool name (e.g., "mcp--weather--get_forecast")
159224
* @returns An object with serverName and toolName, or null if parsing fails
160225
*/
@@ -164,6 +229,13 @@ export function parseMcpToolName(mcpToolName: string): { serverName: string; too
164229
return null
165230
}
166231

232+
// First, check if this is a shortened name in the registry
233+
// This handles names that were truncated with hash suffixes
234+
const registeredName = mcpToolNameRegistry.get(mcpToolName)
235+
if (registeredName) {
236+
return registeredName
237+
}
238+
167239
// Remove the "mcp--" prefix
168240
const remainder = mcpToolName.slice(prefix.length)
169241

0 commit comments

Comments
 (0)