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

Commit 7be724f

Browse files
committed
fix: replace unbounded registry with LRU cache for MCP tool names
- Remove mcpToolNameRegistry (unbounded Map) and clearMcpToolNameRegistry - Add LRU cache with size limit of 100 entries for encoded name computation - Add findToolByEncodedMcpName() to compare encoded names at lookup time - Add hasHashSuffix() helper for checking if a name needs lookup - Pass original encoded MCP name through the tool call stack - Update UseMcpToolTool.validateToolExists() to use encoded comparison - Update tests to cover new functions and LRU cache behavior This addresses the reviewer feedback to avoid memory leaks from the unbounded registry by comparing encoded tool names on both sides and using an LRU cache for performance.
1 parent 865e9a3 commit 7be724f

5 files changed

Lines changed: 245 additions & 108 deletions

File tree

‎src/core/assistant-message/presentAssistantMessage.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ export async function presentAssistantMessage(cline: Task) {
276276

277277
// Execute the MCP tool using the same handler as use_mcp_tool
278278
// Create a synthetic ToolUse block that the useMcpToolTool can handle
279+
// Include the original encoded MCP name for lookup via encoded name comparison
279280
const syntheticToolUse: ToolUse<"use_mcp_tool"> = {
280281
type: "tool_use",
281282
id: mcpBlock.id,
@@ -290,6 +291,7 @@ export async function presentAssistantMessage(cline: Task) {
290291
server_name: resolvedServerName,
291292
tool_name: mcpBlock.toolName,
292293
arguments: mcpBlock.arguments,
294+
_encodedMcpName: mcpBlock.name, // Original encoded name for lookup via comparison
293295
},
294296
}
295297

‎src/core/tools/UseMcpToolTool.ts‎

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Task } from "../task/Task"
44
import { formatResponse } from "../prompts/responses"
55
import { t } from "../../i18n"
66
import type { ToolUse } from "../../shared/tools"
7+
import { findToolByEncodedMcpName, hasHashSuffix } from "../../utils/mcp-name"
78

89
import { BaseTool, ToolCallbacks } from "./BaseTool"
910

@@ -35,7 +36,11 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
3536
}
3637
}
3738

38-
async execute(params: UseMcpToolParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
39+
async execute(
40+
params: UseMcpToolParams & { _encodedMcpName?: string },
41+
task: Task,
42+
callbacks: ToolCallbacks,
43+
): Promise<void> {
3944
const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks
4045

4146
try {
@@ -48,19 +53,30 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
4853
const { serverName, toolName, parsedArguments } = validation
4954

5055
// Validate that the tool exists on the server
51-
const toolValidation = await this.validateToolExists(task, serverName, toolName, pushToolResult)
56+
// Pass the original encoded MCP name for lookup via comparison when direct lookup fails
57+
const encodedMcpName = params._encodedMcpName
58+
const toolValidation = await this.validateToolExists(
59+
task,
60+
serverName,
61+
toolName,
62+
pushToolResult,
63+
encodedMcpName,
64+
)
5265
if (!toolValidation.isValid) {
5366
return
5467
}
5568

69+
// Use the resolved tool name (may differ from parsed name for shortened names)
70+
const resolvedToolName = toolValidation.resolvedToolName || toolName
71+
5672
// Reset mistake count on successful validation
5773
task.consecutiveMistakeCount = 0
5874

5975
// Get user approval
6076
const completeMessage = JSON.stringify({
6177
type: "use_mcp_tool",
6278
serverName,
63-
toolName,
79+
toolName: resolvedToolName,
6480
arguments: params.arguments ? JSON.stringify(params.arguments) : undefined,
6581
} satisfies ClineAskUseMcpServer)
6682

@@ -75,7 +91,7 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
7591
await this.executeToolAndProcessResult(
7692
task,
7793
serverName,
78-
toolName,
94+
resolvedToolName,
7995
parsedArguments,
8096
executionId,
8197
pushToolResult,
@@ -156,7 +172,8 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
156172
serverName: string,
157173
toolName: string,
158174
pushToolResult: (content: string) => void,
159-
): Promise<{ isValid: boolean; availableTools?: string[] }> {
175+
encodedMcpName?: string,
176+
): Promise<{ isValid: boolean; availableTools?: string[]; resolvedToolName?: string }> {
160177
try {
161178
// Get the MCP hub to access server information
162179
const provider = task.providerRef.deref()
@@ -205,8 +222,20 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
205222
return { isValid: false, availableTools: [] }
206223
}
207224

208-
// Check if the requested tool exists
209-
const tool = server.tools.find((tool) => tool.name === toolName)
225+
// Check if the requested tool exists by direct name match
226+
let tool = server.tools.find((tool) => tool.name === toolName)
227+
let resolvedToolName = toolName
228+
229+
// If direct lookup fails and we have an encoded MCP name with hash suffix,
230+
// try to find the tool by comparing encoded names
231+
if (!tool && encodedMcpName && hasHashSuffix(encodedMcpName)) {
232+
const availableToolNames = server.tools.map((t) => t.name)
233+
const matchedToolName = findToolByEncodedMcpName(serverName, encodedMcpName, availableToolNames)
234+
if (matchedToolName) {
235+
tool = server.tools.find((t) => t.name === matchedToolName)
236+
resolvedToolName = matchedToolName
237+
}
238+
}
210239

211240
if (!tool) {
212241
// Tool not found - provide list of available tools
@@ -252,7 +281,7 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
252281
}
253282

254283
// Tool exists and is enabled
255-
return { isValid: true, availableTools: server.tools.map((tool) => tool.name) }
284+
return { isValid: true, availableTools: server.tools.map((tool) => tool.name), resolvedToolName }
256285
} catch (error) {
257286
// If there's an error during validation, log it but don't block the tool execution
258287
// The actual tool call might still fail with a proper error

‎src/shared/tools.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,12 @@ export type NativeToolArgs = {
108108
search_files: { path: string; regex: string; file_pattern?: string | null }
109109
switch_mode: { mode_slug: string; reason: string }
110110
update_todo_list: { todos: string }
111-
use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record<string, unknown> }
111+
use_mcp_tool: {
112+
server_name: string
113+
tool_name: string
114+
arguments?: Record<string, unknown>
115+
_encodedMcpName?: string
116+
}
112117
write_to_file: { path: string; content: string }
113118
// Add more tools as they are migrated to native protocol
114119
}

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

Lines changed: 109 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,16 @@ import {
1010
HYPHEN_ENCODING,
1111
MAX_TOOL_NAME_LENGTH,
1212
HASH_SUFFIX_LENGTH,
13-
mcpToolNameRegistry,
14-
clearMcpToolNameRegistry,
13+
clearEncodedNameCache,
1514
computeHashSuffix,
15+
findToolByEncodedMcpName,
16+
hasHashSuffix,
1617
} from "../mcp-name"
1718

1819
describe("mcp-name utilities", () => {
19-
// Clear the registry before each test to ensure isolation
20+
// Clear the cache before each test to ensure isolation
2021
beforeEach(() => {
21-
clearMcpToolNameRegistry()
22+
clearEncodedNameCache()
2223
})
2324

2425
describe("constants", () => {
@@ -175,27 +176,27 @@ describe("mcp-name utilities", () => {
175176
expect(result).toMatch(/_[a-f0-9]{8}$/)
176177
})
177178

178-
it("should use hash suffix for long names and register them", () => {
179+
it("should use hash suffix for long names and cache them", () => {
179180
const longServer = "a".repeat(80)
180181
const longTool = "b".repeat(80)
181182
const result = buildMcpToolName(longServer, longTool)
182183

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-
})
184+
// The shortened name should be deterministic
185+
expect(result.length).toBe(128)
186+
expect(result).toMatch(/_[a-f0-9]{8}$/)
187+
188+
// Building again should return the same result (from cache)
189+
const result2 = buildMcpToolName(longServer, longTool)
190+
expect(result2).toBe(result)
190191
})
191192

192193
it("should produce deterministic hash suffixes", () => {
193194
const longServer = "a".repeat(80)
194195
const longTool = "b".repeat(80)
195-
// Build the same name twice
196-
clearMcpToolNameRegistry()
196+
// Build the same name twice with cache cleared between
197+
clearEncodedNameCache()
197198
const result1 = buildMcpToolName(longServer, longTool)
198-
clearMcpToolNameRegistry()
199+
clearEncodedNameCache()
199200
const result2 = buildMcpToolName(longServer, longTool)
200201
// Should produce identical results
201202
expect(result1).toBe(result2)
@@ -427,97 +428,132 @@ describe("mcp-name utilities", () => {
427428
})
428429

429430
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 128 chars when encoded
433-
const serverName = "abcdefghij-kl-mnop-qrs-tuv-with-extra-long-suffix-to-exceed-limit"
434-
const toolName = "wxyz-abcd-efghijk-lmno-plus-additional-long-suffix-here"
431+
it("should work correctly when names do not need truncation", () => {
432+
// Short names that don't need truncation
433+
const serverName = "server"
434+
const toolName = "get-data"
435435

436-
// Build the tool name
437436
const builtName = buildMcpToolName(serverName, toolName)
438437

439-
// Should be truncated to 128 chars with hash suffix
440-
expect(builtName.length).toBe(128)
441-
expect(builtName).toMatch(/_[a-f0-9]{8}$/)
438+
// Should NOT have hash suffix
439+
expect(builtName).toBe("mcp--server--get___data")
440+
expect(builtName.length).toBeLessThan(128)
442441

443-
// The critical fix: parsing should return the ORIGINAL names
442+
// Normal decode path should work
444443
const parsed = parseMcpToolName(builtName)
445444
expect(parsed).toEqual({
446-
serverName: serverName, // Original with hyphens!
447-
toolName: toolName, // Original with hyphens!
445+
serverName: "server",
446+
toolName: "get-data", // Hyphen decoded from ___
448447
})
449448
})
450449

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-and-extra-content-to-exceed"
454-
const toolName = "another-long-tool-name-with-hyphens-and-extra-content-to-exceed"
450+
it("should find tool by encoded name comparison for shortened names", () => {
451+
// This is the new approach: instead of registry, compare encoded names
452+
const serverName = "abcdefghij-kl-mnop-qrs-tuv-with-extra-long-suffix-to-exceed-limit"
453+
const toolName = "wxyz-abcd-efghijk-lmno-plus-additional-long-suffix-here"
455454

456-
// Build the tool name
457-
const builtName = buildMcpToolName(serverName, toolName)
455+
// Build the encoded tool name
456+
const encodedName = buildMcpToolName(serverName, toolName)
458457

459-
// Should be truncated to 128 chars
460-
expect(builtName.length).toBe(128)
458+
// Should be truncated to 128 chars with hash suffix
459+
expect(encodedName.length).toBe(128)
460+
expect(encodedName).toMatch(/_[a-f0-9]{8}$/)
461461

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-
})
462+
// The new approach: use findToolByEncodedMcpName to find the matching tool
463+
const availableTools = [toolName, "other-tool", "another-tool"]
464+
const foundTool = findToolByEncodedMcpName(serverName, encodedName, availableTools)
465+
expect(foundTool).toBe(toolName)
468466
})
469467

470-
it("should work correctly when names do not need truncation", () => {
471-
// Short names that don't need truncation
468+
it("should not find tool when none matches the encoded name", () => {
472469
const serverName = "server"
473-
const toolName = "get-data"
470+
const encodedName = "mcp--server--nonexistent_tool_a1b2c3d4"
474471

475-
const builtName = buildMcpToolName(serverName, toolName)
472+
const availableTools = ["tool1", "tool2", "tool3"]
473+
const foundTool = findToolByEncodedMcpName(serverName, encodedName, availableTools)
474+
expect(foundTool).toBeNull()
475+
})
476+
})
476477

477-
// Should NOT have hash suffix
478-
expect(builtName).toBe("mcp--server--get___data")
479-
expect(builtName.length).toBeLessThan(128)
478+
describe("findToolByEncodedMcpName", () => {
479+
it("should find tool by exact encoded name match", () => {
480+
const serverName = "myserver"
481+
const toolName = "get-data"
482+
const encodedName = buildMcpToolName(serverName, toolName)
480483

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-
})
484+
const availableTools = ["other-tool", "get-data", "another-tool"]
485+
const foundTool = findToolByEncodedMcpName(serverName, encodedName, availableTools)
486+
expect(foundTool).toBe("get-data")
487487
})
488488

489-
it("should handle the registry lookup for shortened names", () => {
489+
it("should find tool for shortened names with hash suffix", () => {
490490
const serverName = "a".repeat(80)
491491
const toolName = "b".repeat(80) + "-hyphen"
492+
const encodedName = buildMcpToolName(serverName, toolName)
492493

493-
// Build registers the shortened name
494-
const builtName = buildMcpToolName(serverName, toolName)
494+
// The encoded name should have a hash suffix
495+
expect(hasHashSuffix(encodedName)).toBe(true)
495496

496-
// Verify it's in the registry
497-
expect(mcpToolNameRegistry.has(builtName)).toBe(true)
497+
const availableTools = ["other-tool", toolName, "another-tool"]
498+
const foundTool = findToolByEncodedMcpName(serverName, encodedName, availableTools)
499+
expect(foundTool).toBe(toolName)
500+
})
498501

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-
})
502+
it("should return null when no tool matches", () => {
503+
const serverName = "server"
504+
const encodedName = buildMcpToolName(serverName, "nonexistent")
505+
506+
const availableTools = ["tool1", "tool2"]
507+
const foundTool = findToolByEncodedMcpName(serverName, encodedName, availableTools)
508+
expect(foundTool).toBeNull()
509+
})
510+
511+
it("should handle empty available tools list", () => {
512+
const serverName = "server"
513+
const encodedName = buildMcpToolName(serverName, "tool")
514+
515+
const foundTool = findToolByEncodedMcpName(serverName, encodedName, [])
516+
expect(foundTool).toBeNull()
505517
})
506518
})
507519

508-
describe("clearMcpToolNameRegistry", () => {
509-
it("should clear all registered tool names", () => {
510-
// Register some tool names via buildMcpToolName
520+
describe("hasHashSuffix", () => {
521+
it("should return true for names with hash suffix", () => {
522+
expect(hasHashSuffix("mcp--server--tool_a1b2c3d4")).toBe(true)
523+
expect(hasHashSuffix("mcp--server--tool_12345678")).toBe(true)
524+
expect(hasHashSuffix("mcp--server--tool_abcdef00")).toBe(true)
525+
})
526+
527+
it("should return false for names without hash suffix", () => {
528+
expect(hasHashSuffix("mcp--server--tool")).toBe(false)
529+
expect(hasHashSuffix("mcp--server--tool_name")).toBe(false)
530+
expect(hasHashSuffix("mcp--server--tool___name")).toBe(false)
531+
})
532+
533+
it("should return false for names with partial hash patterns", () => {
534+
// Not enough hex chars
535+
expect(hasHashSuffix("mcp--server--tool_abc")).toBe(false)
536+
// No underscore before hash
537+
expect(hasHashSuffix("mcp--server--toola1b2c3d4")).toBe(false)
538+
})
539+
})
540+
541+
describe("clearEncodedNameCache", () => {
542+
it("should clear the encoded name cache", () => {
543+
// Build some names to populate cache
511544
const longServer = "a".repeat(80)
512545
const longTool = "b".repeat(80)
513546
buildMcpToolName(longServer, longTool)
547+
buildMcpToolName("server", "tool")
514548

515-
expect(mcpToolNameRegistry.size).toBeGreaterThan(0)
516-
517-
// Clear the registry
518-
clearMcpToolNameRegistry()
549+
// Clear the cache
550+
clearEncodedNameCache()
519551

520-
expect(mcpToolNameRegistry.size).toBe(0)
552+
// Verify cache is cleared by checking that rebuilding takes the same path
553+
// (we can't directly access the cache, but the function should work)
554+
const result = buildMcpToolName(longServer, longTool)
555+
expect(result.length).toBe(128)
556+
expect(result).toMatch(/_[a-f0-9]{8}$/)
521557
})
522558
})
523559
})

0 commit comments

Comments
 (0)