Skip to content
Draft
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
2 changes: 2 additions & 0 deletions src/core/assistant-message/presentAssistantMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ export async function presentAssistantMessage(cline: Task) {

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

Expand Down
45 changes: 37 additions & 8 deletions src/core/tools/UseMcpToolTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { findToolByEncodedMcpName, hasHashSuffix } from "../../utils/mcp-name"

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

Expand Down Expand Up @@ -35,7 +36,11 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
}
}

async execute(params: UseMcpToolParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
async execute(
params: UseMcpToolParams & { _encodedMcpName?: string },
task: Task,
callbacks: ToolCallbacks,
): Promise<void> {
const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks

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

// Validate that the tool exists on the server
const toolValidation = await this.validateToolExists(task, serverName, toolName, pushToolResult)
// Pass the original encoded MCP name for lookup via comparison when direct lookup fails
const encodedMcpName = params._encodedMcpName
const toolValidation = await this.validateToolExists(
task,
serverName,
toolName,
pushToolResult,
encodedMcpName,
)
if (!toolValidation.isValid) {
return
}

// Use the resolved tool name (may differ from parsed name for shortened names)
const resolvedToolName = toolValidation.resolvedToolName || toolName

// Reset mistake count on successful validation
task.consecutiveMistakeCount = 0

// Get user approval
const completeMessage = JSON.stringify({
type: "use_mcp_tool",
serverName,
toolName,
toolName: resolvedToolName,
arguments: params.arguments ? JSON.stringify(params.arguments) : undefined,
} satisfies ClineAskUseMcpServer)

Expand All @@ -75,7 +91,7 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
await this.executeToolAndProcessResult(
task,
serverName,
toolName,
resolvedToolName,
parsedArguments,
executionId,
pushToolResult,
Expand Down Expand Up @@ -156,7 +172,8 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
serverName: string,
toolName: string,
pushToolResult: (content: string) => void,
): Promise<{ isValid: boolean; availableTools?: string[] }> {
encodedMcpName?: string,
): Promise<{ isValid: boolean; availableTools?: string[]; resolvedToolName?: string }> {
try {
// Get the MCP hub to access server information
const provider = task.providerRef.deref()
Expand Down Expand Up @@ -205,8 +222,20 @@ 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 by direct name match
let tool = server.tools.find((tool) => tool.name === toolName)
let resolvedToolName = toolName

// If direct lookup fails and we have an encoded MCP name with hash suffix,
// try to find the tool by comparing encoded names
if (!tool && encodedMcpName && hasHashSuffix(encodedMcpName)) {
const availableToolNames = server.tools.map((t) => t.name)
const matchedToolName = findToolByEncodedMcpName(serverName, encodedMcpName, availableToolNames)
if (matchedToolName) {
tool = server.tools.find((t) => t.name === matchedToolName)
resolvedToolName = matchedToolName
}
}

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

// Tool exists and is enabled
return { isValid: true, availableTools: server.tools.map((tool) => tool.name) }
return { isValid: true, availableTools: server.tools.map((tool) => tool.name), resolvedToolName }
} 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
Expand Down
7 changes: 6 additions & 1 deletion src/shared/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,12 @@ export type NativeToolArgs = {
search_files: { path: string; regex: string; file_pattern?: string | null }
switch_mode: { mode_slug: string; reason: string }
update_todo_list: { todos: string }
use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record<string, unknown> }
use_mcp_tool: {
server_name: string
tool_name: string
arguments?: Record<string, unknown>
_encodedMcpName?: string
}
write_to_file: { path: string; content: string }
// Add more tools as they are migrated to native protocol
}
Expand Down
211 changes: 210 additions & 1 deletion src/utils/__tests__/mcp-name.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,20 @@ import {
MCP_TOOL_SEPARATOR,
MCP_TOOL_PREFIX,
HYPHEN_ENCODING,
MAX_TOOL_NAME_LENGTH,
HASH_SUFFIX_LENGTH,
clearEncodedNameCache,
computeHashSuffix,
findToolByEncodedMcpName,
hasHashSuffix,
} from "../mcp-name"

describe("mcp-name utilities", () => {
// Clear the cache before each test to ensure isolation
beforeEach(() => {
clearEncodedNameCache()
})

describe("constants", () => {
it("should have correct separator and prefix", () => {
expect(MCP_TOOL_SEPARATOR).toBe("--")
Expand All @@ -20,6 +31,14 @@ describe("mcp-name utilities", () => {
it("should have correct hyphen encoding", () => {
expect(HYPHEN_ENCODING).toBe("___")
})

it("should have correct max tool name length", () => {
expect(MAX_TOOL_NAME_LENGTH).toBe(64)
})

it("should have correct hash suffix length", () => {
expect(HASH_SUFFIX_LENGTH).toBe(8)
})
})

describe("isMcpTool", () => {
Expand Down Expand Up @@ -146,12 +165,52 @@ describe("mcp-name utilities", () => {
expect(buildMcpToolName("server@name", "tool!name")).toBe("mcp--servername--toolname")
})

it("should truncate long names to 64 characters", () => {
it("should truncate long names to 64 characters with hash suffix", () => {
const longServer = "a".repeat(50)
const longTool = "b".repeat(50)
const result = buildMcpToolName(longServer, longTool)
expect(result.length).toBeLessThanOrEqual(64)
expect(result.length).toBe(64)
expect(result.startsWith("mcp--")).toBe(true)
// Should end with underscore + 8 char hash suffix
expect(result).toMatch(/_[a-f0-9]{8}$/)
})

it("should use hash suffix for long names and cache them", () => {
const longServer = "a".repeat(50)
const longTool = "b".repeat(50)
const result = buildMcpToolName(longServer, longTool)

// The shortened name should be deterministic
expect(result.length).toBe(64)
expect(result).toMatch(/_[a-f0-9]{8}$/)

// Building again should return the same result (from cache)
const result2 = buildMcpToolName(longServer, longTool)
expect(result2).toBe(result)
})

it("should produce deterministic hash suffixes", () => {
const longServer = "a".repeat(50)
const longTool = "b".repeat(50)
// Build the same name twice with cache cleared between
clearEncodedNameCache()
const result1 = buildMcpToolName(longServer, longTool)
clearEncodedNameCache()
const result2 = buildMcpToolName(longServer, longTool)
// Should produce identical results
expect(result1).toBe(result2)
})

it("should produce unique hash suffixes for different tools", () => {
const longServer = "a".repeat(50)
const result1 = buildMcpToolName(longServer, "tool1_" + "x".repeat(40))
const result2 = buildMcpToolName(longServer, "tool2_" + "y".repeat(40))
// Both should be truncated
expect(result1.length).toBe(64)
expect(result2.length).toBe(64)
// Should have different hash suffixes
expect(result1).not.toBe(result2)
})

it("should handle names starting with numbers", () => {
Expand Down Expand Up @@ -347,4 +406,154 @@ describe("mcp-name utilities", () => {
})
})
})

describe("computeHashSuffix", () => {
it("should compute deterministic hash for the same inputs", () => {
const hash1 = computeHashSuffix("server", "tool")
const hash2 = computeHashSuffix("server", "tool")
expect(hash1).toBe(hash2)
})

it("should return 8-character hex string", () => {
const hash = computeHashSuffix("server", "tool")
expect(hash).toHaveLength(8)
expect(hash).toMatch(/^[a-f0-9]{8}$/)
})

it("should produce different hashes for different inputs", () => {
const hash1 = computeHashSuffix("server1", "tool")
const hash2 = computeHashSuffix("server2", "tool")
expect(hash1).not.toBe(hash2)
})
})

describe("hash suffix roundtrip - fixes issue #10766", () => {
it("should work correctly when names do not need truncation", () => {
// Short names that don't need truncation
const serverName = "server"
const toolName = "get-data"

const builtName = buildMcpToolName(serverName, toolName)

// Should NOT have hash suffix
expect(builtName).toBe("mcp--server--get___data")
expect(builtName.length).toBeLessThan(64)

// Normal decode path should work
const parsed = parseMcpToolName(builtName)
expect(parsed).toEqual({
serverName: "server",
toolName: "get-data", // Hyphen decoded from ___
})
})

it("should find tool by encoded name comparison for shortened names", () => {
// This is the new approach: instead of registry, compare encoded names
const serverName = "abcdefghij-kl-mnop-qrs-tuv-with-extra-long-suffix-to-exceed-limit"
const toolName = "wxyz-abcd-efghijk-lmno-plus-additional-long-suffix-here"

// Build the encoded tool name
const encodedName = buildMcpToolName(serverName, toolName)

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

// The new approach: use findToolByEncodedMcpName to find the matching tool
const availableTools = [toolName, "other-tool", "another-tool"]
const foundTool = findToolByEncodedMcpName(serverName, encodedName, availableTools)
expect(foundTool).toBe(toolName)
})

it("should not find tool when none matches the encoded name", () => {
const serverName = "server"
const encodedName = "mcp--server--nonexistent_tool_a1b2c3d4"

const availableTools = ["tool1", "tool2", "tool3"]
const foundTool = findToolByEncodedMcpName(serverName, encodedName, availableTools)
expect(foundTool).toBeNull()
})
})

describe("findToolByEncodedMcpName", () => {
it("should find tool by exact encoded name match", () => {
const serverName = "myserver"
const toolName = "get-data"
const encodedName = buildMcpToolName(serverName, toolName)

const availableTools = ["other-tool", "get-data", "another-tool"]
const foundTool = findToolByEncodedMcpName(serverName, encodedName, availableTools)
expect(foundTool).toBe("get-data")
})

it("should find tool for shortened names with hash suffix", () => {
const serverName = "a".repeat(50)
const toolName = "b".repeat(50) + "-hyphen"
const encodedName = buildMcpToolName(serverName, toolName)

// The encoded name should have a hash suffix
expect(hasHashSuffix(encodedName)).toBe(true)

const availableTools = ["other-tool", toolName, "another-tool"]
const foundTool = findToolByEncodedMcpName(serverName, encodedName, availableTools)
expect(foundTool).toBe(toolName)
})

it("should return null when no tool matches", () => {
const serverName = "server"
const encodedName = buildMcpToolName(serverName, "nonexistent")

const availableTools = ["tool1", "tool2"]
const foundTool = findToolByEncodedMcpName(serverName, encodedName, availableTools)
expect(foundTool).toBeNull()
})

it("should handle empty available tools list", () => {
const serverName = "server"
const encodedName = buildMcpToolName(serverName, "tool")

const foundTool = findToolByEncodedMcpName(serverName, encodedName, [])
expect(foundTool).toBeNull()
})
})

describe("hasHashSuffix", () => {
it("should return true for names with hash suffix", () => {
expect(hasHashSuffix("mcp--server--tool_a1b2c3d4")).toBe(true)
expect(hasHashSuffix("mcp--server--tool_12345678")).toBe(true)
expect(hasHashSuffix("mcp--server--tool_abcdef00")).toBe(true)
})

it("should return false for names without hash suffix", () => {
expect(hasHashSuffix("mcp--server--tool")).toBe(false)
expect(hasHashSuffix("mcp--server--tool_name")).toBe(false)
expect(hasHashSuffix("mcp--server--tool___name")).toBe(false)
})

it("should return false for names with partial hash patterns", () => {
// Not enough hex chars
expect(hasHashSuffix("mcp--server--tool_abc")).toBe(false)
// No underscore before hash
expect(hasHashSuffix("mcp--server--toola1b2c3d4")).toBe(false)
})
})

describe("clearEncodedNameCache", () => {
it("should clear the encoded name cache", () => {
// Build some names to populate cache
const longServer = "a".repeat(50)
const longTool = "b".repeat(50)
buildMcpToolName(longServer, longTool)
buildMcpToolName("server", "tool")

// Clear the cache
clearEncodedNameCache()

// Verify cache is cleared by checking that rebuilding takes the same path
// (we can't directly access the cache, but the function should work)
const result = buildMcpToolName(longServer, longTool)
expect(result.length).toBe(64)
expect(result).toMatch(/_[a-f0-9]{8}$/)
})
})
})
Loading
Loading