diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 90da07cc8d..5cf4a5abe6 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -16,7 +16,14 @@ import type { ApiStreamToolCallDeltaChunk, ApiStreamToolCallEndChunk, } from "../../api/transform/stream" -import { MCP_TOOL_PREFIX, MCP_TOOL_SEPARATOR, parseMcpToolName, normalizeMcpToolName } from "../../utils/mcp-name" +import { + MAX_TOOL_NAME_LENGTH, + MCP_TOOL_PREFIX, + MCP_TOOL_SEPARATOR, + buildMcpToolName, + parseMcpToolName, + normalizeMcpToolName, +} from "../../utils/mcp-name" /** * Helper type to extract properly typed native arguments for a given tool. @@ -1141,8 +1148,13 @@ export class NativeToolCallParser { const result: McpToolUse = { type: "mcp_tool_use" as const, id: toolCall.id, - // Keep the original tool name (e.g., "mcp--serverName--toolName") for API history - name: toolCall.name, + // Keep the original tool name (e.g., "mcp--serverName--toolName") for API history. + // A name over the provider limit can only be the uncut name; providers reject it in + // history, so record it under the name that was declared to the model instead. + name: + toolCall.name.length > MAX_TOOL_NAME_LENGTH + ? buildMcpToolName(serverName, toolName) + : toolCall.name, serverName, toolName, arguments: args, diff --git a/src/core/assistant-message/__tests__/NativeToolCallParser-mcp-long-name.spec.ts b/src/core/assistant-message/__tests__/NativeToolCallParser-mcp-long-name.spec.ts new file mode 100644 index 0000000000..baf663a8e1 --- /dev/null +++ b/src/core/assistant-message/__tests__/NativeToolCallParser-mcp-long-name.spec.ts @@ -0,0 +1,59 @@ +import { buildMcpToolName, resolveMcpToolByName, toolNamesMatch } from "../../../utils/mcp-name" +import { NativeToolCallParser } from "../NativeToolCallParser" + +const SERVER = "company-gateway-tools" +const TOOL = "confluence-confluence_get_space_page_tree" + +describe("NativeToolCallParser.parseDynamicMcpTool name for API history", () => { + it("records an over-long uncut name under the declared name", () => { + const result = NativeToolCallParser.parseDynamicMcpTool({ + id: "toolu_1", + name: `mcp--${SERVER}--${TOOL}`, + arguments: "{}", + }) + + expect(result?.name).toBe(buildMcpToolName(SERVER, TOOL)) + expect(result?.name.length).toBeLessThanOrEqual(64) + }) + + it("resolves an over-long uncut name whose hyphens the model turned into underscores", () => { + const mangled = `mcp--${SERVER}--${TOOL}`.replace(/-/g, "_") + + const result = NativeToolCallParser.parseDynamicMcpTool({ id: "toolu_5", name: mangled, arguments: "{}" }) + + expect(result).not.toBeNull() + expect(result!.name.length).toBeLessThanOrEqual(64) + // The recorded name is the declared one, up to the hyphen/underscore spelling. + expect(toolNamesMatch(result!.name, buildMcpToolName(SERVER, TOOL))).toBe(true) + expect(resolveMcpToolByName([{ name: SERVER, tools: [{ name: TOOL }] }], result!.name)).toEqual({ + serverName: SERVER, + toolName: TOOL, + }) + }) + + it("keeps the declared name the model returned", () => { + const declared = buildMcpToolName(SERVER, TOOL) + + const result = NativeToolCallParser.parseDynamicMcpTool({ id: "toolu_2", name: declared, arguments: "{}" }) + + expect(result?.name).toBe(declared) + }) + + it("parses the declared name of a tool on a server with a very long name", () => { + const declared = buildMcpToolName("s".repeat(60), TOOL) + + const result = NativeToolCallParser.parseDynamicMcpTool({ id: "toolu_4", name: declared, arguments: "{}" }) + + expect(result).toMatchObject({ type: "mcp_tool_use", name: declared }) + }) + + it("keeps a short name verbatim", () => { + const result = NativeToolCallParser.parseDynamicMcpTool({ + id: "toolu_3", + name: "mcp--weather--get_forecast", + arguments: "{}", + }) + + expect(result).toMatchObject({ name: "mcp--weather--get_forecast", serverName: "weather" }) + }) +}) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts index 72c343c1eb..a949f1c4fa 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts @@ -88,7 +88,10 @@ interface MockTask { providerRef: { deref: () => { getState: ReturnType - getMcpHub?: () => { findServerNameBySanitizedName: (name: string) => string | undefined } + getMcpHub?: () => { + findServerNameBySanitizedName: (name: string) => string | undefined + getAllServers: () => Array<{ name: string; tools?: Array<{ name: string }> }> + } } } say: ReturnType @@ -368,7 +371,7 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { mockTask.providerRef = { deref: () => ({ getState: vi.fn().mockResolvedValue({ mode: "code", customModes: [] }), - getMcpHub: () => ({ findServerNameBySanitizedName: () => undefined }), + getMcpHub: () => ({ findServerNameBySanitizedName: () => undefined, getAllServers: () => [] }), }), } @@ -428,7 +431,7 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { mockTask.providerRef = { deref: () => ({ getState: vi.fn().mockResolvedValue({ mode: "code", customModes: [] }), - getMcpHub: () => ({ findServerNameBySanitizedName: () => undefined }), + getMcpHub: () => ({ findServerNameBySanitizedName: () => undefined, getAllServers: () => [] }), }), } @@ -541,7 +544,7 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { mockTask.providerRef = { deref: () => ({ getState: vi.fn().mockResolvedValue({ mode: "code", customModes: [] }), - getMcpHub: () => ({ findServerNameBySanitizedName: () => undefined }), + getMcpHub: () => ({ findServerNameBySanitizedName: () => undefined, getAllServers: () => [] }), }), } @@ -581,7 +584,7 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { mockTask.providerRef = { deref: () => ({ getState: vi.fn().mockResolvedValue({ mode: "code", customModes: [] }), - getMcpHub: () => ({ findServerNameBySanitizedName: () => undefined }), + getMcpHub: () => ({ findServerNameBySanitizedName: () => undefined, getAllServers: () => [] }), }), } @@ -642,7 +645,7 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { mockTask.providerRef = { deref: () => ({ getState: vi.fn().mockResolvedValue({ mode: "code", customModes: [] }), - getMcpHub: () => ({ findServerNameBySanitizedName: () => undefined }), + getMcpHub: () => ({ findServerNameBySanitizedName: () => undefined, getAllServers: () => [] }), }), } diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts index 081e27e687..7ad7690f31 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts @@ -491,6 +491,7 @@ describe("presentAssistantMessage - Custom Tool Recording", () => { }), getMcpHub: () => ({ findServerNameBySanitizedName: () => "test-server", + getAllServers: () => [], executeToolCall: vi.fn().mockResolvedValue({ content: [{ type: "text", text: "result" }] }), }), }), diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts index d2e04b89a8..2325b1f9d9 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts @@ -9,6 +9,7 @@ import { getModeBySlug } from "../../../shared/modes" import type { Task } from "../../task/Task" import type { RequestPolicySnapshot } from "../../prompts/tools/effective-tool-policy" import { useMcpToolTool } from "../../tools/UseMcpToolTool" +import { buildMcpToolName, parseMcpToolName } from "../../../utils/mcp-name" vi.mock("../../task/Task") vi.mock("../../../shared/modes", async (importOriginal) => { @@ -80,7 +81,10 @@ interface MockTask { deref: () => | { getState: ReturnType - getMcpHub?: () => { findServerNameBySanitizedName: (name: string) => string | undefined } + getMcpHub?: () => { + findServerNameBySanitizedName: (name: string) => string | undefined + getAllServers: () => Array<{ name: string; tools?: Array<{ name: string }> }> + } } | undefined } @@ -379,6 +383,7 @@ describe("presentAssistantMessage - tool usage attribution", () => { }), getMcpHub: () => ({ findServerNameBySanitizedName: () => "my_server", + getAllServers: () => [], }), }), } @@ -548,6 +553,7 @@ describe("presentAssistantMessage - tool usage attribution", () => { }), getMcpHub: () => ({ findServerNameBySanitizedName: () => "my_server", + getAllServers: () => [], }), }), } @@ -638,6 +644,7 @@ describe("presentAssistantMessage - tool usage attribution", () => { }), getMcpHub: () => ({ findServerNameBySanitizedName: () => "my_server", + getAllServers: () => [], }), }), } @@ -680,6 +687,7 @@ describe("presentAssistantMessage - tool usage attribution", () => { }), getMcpHub: () => ({ findServerNameBySanitizedName: () => "my_server", + getAllServers: () => [], }), }), } @@ -787,6 +795,7 @@ describe("presentAssistantMessage - tool usage attribution", () => { }), getMcpHub: () => ({ findServerNameBySanitizedName: () => "my_server", + getAllServers: () => [], }), }), } @@ -932,6 +941,43 @@ describe("presentAssistantMessage - tool usage attribution", () => { expect(handleSpy).toHaveBeenCalledTimes(1) }) + it("resolves a hash-shortened MCP tool name to its server and tool before executing", async () => { + const serverName = "company-gateway-tools" + const realToolName = "confluence-confluence_get_space_page_tree" + const declaredName = buildMcpToolName(serverName, realToolName) + const mockHub = { + findServerNameBySanitizedName: () => serverName, + getAllServers: () => [{ name: serverName, tools: [{ name: realToolName, enabledForPrompt: true }] }], + } + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ mode: "code", customModes: [], disabledTools: [] }), + getMcpHub: () => mockHub, + }), + } + const handleSpy = vi.spyOn(useMcpToolTool, "handle").mockResolvedValue(undefined) + // The parser can only recover a cut tool name from the shortened one. + const parsed = parseMcpToolName(declaredName)! + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_hashed_name", + name: declaredName, + serverName: parsed.serverName, + toolName: parsed.toolName, + arguments: {}, + partial: false, + }, + ] + + await presentAssistantMessage(mockTask as unknown as Task, baseSnapshot) + + expect(handleSpy).toHaveBeenCalledTimes(1) + expect(handleSpy.mock.calls[0][1]).toMatchObject({ + nativeArgs: { server_name: serverName, tool_name: realToolName }, + }) + }) + it("performs no provider-state read on the native validation path", async () => { // Handler mocked so the only reachable pre-handler code is the // validation arm itself; any getState call inside the presenter's @@ -947,6 +993,7 @@ describe("presentAssistantMessage - tool usage attribution", () => { getState, getMcpHub: () => ({ findServerNameBySanitizedName: () => "my_server", + getAllServers: () => [], }), }), } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index fa9057d6d5..4182bb7c05 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -44,6 +44,7 @@ import { codebaseSearchTool } from "../tools/CodebaseSearchTool" import { formatResponse } from "../prompts/responses" import { sanitizeToolUseId } from "../../utils/tool-id" +import { resolveMcpToolByName, toolNamesMatch } from "../../utils/mcp-name" /** * Maps a raw, potentially model-controlled tool name to a safe analytics key. @@ -329,6 +330,25 @@ async function presentAssistantMessageBlock(cline: Task, policySnapshot: Request } } + // A name shortened to fit the provider limit cannot be parsed back into its tool, so + // when the parsed names match nothing, look the called name up among the declared ones. + let resolvedToolName = mcpBlock.toolName + if (mcpHub && !mcpBlock.partial) { + const servers = mcpHub.getAllServers() + const parsedServer = servers.find((server) => server.name === resolvedServerName) + const parsedToolExists = parsedServer?.tools?.some((tool) => + toolNamesMatch(tool.name, resolvedToolName), + ) + + if (!parsedToolExists) { + const resolved = resolveMcpToolByName(servers, mcpBlock.name) + if (resolved) { + resolvedServerName = resolved.serverName + resolvedToolName = resolved.toolName + } + } + } + // Execute the MCP tool using the same handler as use_mcp_tool // Create a synthetic ToolUse block that the useMcpToolTool can handle const syntheticToolUse: ToolUse<"use_mcp_tool"> = { @@ -337,13 +357,13 @@ async function presentAssistantMessageBlock(cline: Task, policySnapshot: Request name: "use_mcp_tool", params: { server_name: resolvedServerName, - tool_name: mcpBlock.toolName, + tool_name: resolvedToolName, arguments: JSON.stringify(mcpBlock.arguments), }, partial: mcpBlock.partial, nativeArgs: { server_name: resolvedServerName, - tool_name: mcpBlock.toolName, + tool_name: resolvedToolName, arguments: mcpBlock.arguments, }, } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index e573a7f107..c573bb6f67 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -140,6 +140,7 @@ import { type AutoDenyDetail, AutoApprovalHandler, checkAutoApproval } from "../ import { MessageManager } from "../message-manager" import { validateAndFixToolResultIds } from "./validateToolResultIds" import { mergeConsecutiveApiMessages } from "./mergeConsecutiveApiMessages" +import { capMcpToolUseNames } from "./capMcpToolUseNames" import { prepareApiConversationMessage } from "./apiConversationHistory" import { shouldAddUserMessageToHistory } from "./messageCounting" import { type TaskExecutionContext } from "./providerHandoff" @@ -5831,7 +5832,7 @@ export class Task extends EventEmitter implements TaskLike { // For API only: merge consecutive user messages (excludes summary messages per // mergeConsecutiveApiMessages implementation) without mutating stored history. const mergedForApi = mergeConsecutiveApiMessages(messagesSinceLastSummary, { roles: ["user"] }) - const messagesWithoutImages = maybeRemoveImageBlocks(mergedForApi, this.api) + const messagesWithoutImages = maybeRemoveImageBlocks(capMcpToolUseNames(mergedForApi), this.api) const cleanConversationHistory = this.buildCleanConversationHistory( messagesWithoutImages as ApiMessage[], requestModelInfo, diff --git a/src/core/task/__tests__/capMcpToolUseNames.spec.ts b/src/core/task/__tests__/capMcpToolUseNames.spec.ts new file mode 100644 index 0000000000..ea3ca40193 --- /dev/null +++ b/src/core/task/__tests__/capMcpToolUseNames.spec.ts @@ -0,0 +1,46 @@ +import type { Anthropic } from "@anthropic-ai/sdk" + +import type { ApiMessage } from "../../task-persistence" +import { buildMcpToolName } from "../../../utils/mcp-name" +import { capMcpToolUseNames } from "../capMcpToolUseNames" + +type Block = Anthropic.Messages.ContentBlockParam + +const SERVER = "company-gateway-tools" +const TOOL = "confluence-confluence_get_space_page_tree" +const UNCUT = `mcp--${SERVER}--${TOOL}` + +const use = (name: string): Block => ({ type: "tool_use", id: "toolu_1", name, input: {} }) +const assistant = (...content: Block[]): ApiMessage => ({ role: "assistant", content }) + +describe("capMcpToolUseNames", () => { + it("renames an over-long MCP tool_use to the declared name", () => { + const [message] = capMcpToolUseNames([assistant({ type: "text", text: "calling" }, use(UNCUT))]) + + expect(message.content).toEqual([{ type: "text", text: "calling" }, use(buildMcpToolName(SERVER, TOOL))]) + }) + + it("leaves short names, non-MCP names and other roles untouched", () => { + const messages: ApiMessage[] = [ + { role: "user", content: "hi" }, + assistant(use("read_file"), use("mcp--weather--get_forecast")), + ] + + expect(capMcpToolUseNames(messages)).toEqual(messages) + }) + + it("leaves an over-long name it cannot parse as an MCP tool", () => { + const name = "x".repeat(70) + + expect(capMcpToolUseNames([assistant(use(name))])[0].content).toEqual([use(name)]) + }) + + it("does not mutate its input", () => { + const input = [assistant(use(UNCUT))] + const snapshot = structuredClone(input) + + capMcpToolUseNames(input) + + expect(input).toEqual(snapshot) + }) +}) diff --git a/src/core/task/capMcpToolUseNames.ts b/src/core/task/capMcpToolUseNames.ts new file mode 100644 index 0000000000..09ac0d3386 --- /dev/null +++ b/src/core/task/capMcpToolUseNames.ts @@ -0,0 +1,32 @@ +import { Anthropic } from "@anthropic-ai/sdk" + +import type { ApiMessage } from "../task-persistence" +import { MAX_TOOL_NAME_LENGTH, buildMcpToolName, parseMcpToolName } from "../../utils/mcp-name" + +/** + * Rewrites MCP `tool_use` names longer than the provider limit to the name that was declared + * to the model. Providers such as Bedrock Converse and Azure OpenAI reject over-long names in + * history, so a task that already recorded one would otherwise fail on every retry. + * + * Used for API request shaping only; stored history is not mutated. + */ +export function capMcpToolUseNames(messages: ApiMessage[]): ApiMessage[] { + return messages.map((message) => { + if (message.role !== "assistant" || !Array.isArray(message.content)) return message + + const needsRename = message.content.some( + (block) => block.type === "tool_use" && block.name.length > MAX_TOOL_NAME_LENGTH, + ) + if (!needsRename) return message + + return { + ...message, + content: message.content.map((block): Anthropic.Messages.ContentBlockParam => { + if (block.type !== "tool_use" || block.name.length <= MAX_TOOL_NAME_LENGTH) return block + + const parsed = parseMcpToolName(block.name) + return parsed ? { ...block, name: buildMcpToolName(parsed.serverName, parsed.toolName) } : block + }), + } + }) +} diff --git a/src/utils/__tests__/mcp-name-long.spec.ts b/src/utils/__tests__/mcp-name-long.spec.ts new file mode 100644 index 0000000000..b03325153f --- /dev/null +++ b/src/utils/__tests__/mcp-name-long.spec.ts @@ -0,0 +1,104 @@ +import { buildMcpToolName, parseMcpToolName, resolveMcpToolByName } from "../mcp-name" + +const SERVER = "company-gateway-tools" +const TOOL = "confluence-confluence_get_space_page_tree" +const UNCUT = `mcp--${SERVER}--${TOOL}` + +describe("long MCP tool names", () => { + it("keeps names within the limit unchanged", () => { + expect(buildMcpToolName("weather", "get_forecast")).toBe("mcp--weather--get_forecast") + }) + + it("shortens an over-long name to exactly 64 characters with a hash suffix", () => { + const name = buildMcpToolName(SERVER, TOOL) + + expect(UNCUT.length).toBeGreaterThan(64) + expect(name).toHaveLength(64) + expect(name.slice(0, 55)).toBe(UNCUT.slice(0, 55)) + expect(name.slice(55)).toMatch(/^_[0-9a-f]{8}$/) + }) + + it.each([ + { toolLength: 50, shortened: false }, + { toolLength: 51, shortened: true }, + ])( + "treats a $toolLength-character tool as shortened: $shortened at the 64-character limit", + ({ toolLength, shortened }) => { + const tool = "t".repeat(toolLength) + const uncut = `mcp--weather--${tool}` + const name = buildMcpToolName("weather", tool) + + expect(uncut).toHaveLength(14 + toolLength) + expect(name).toHaveLength(64) + if (shortened) { + expect(name).toMatch(/_[0-9a-f]{8}$/) + } else { + expect(name).toBe(uncut) + } + }, + ) + + describe("with a long server name", () => { + it.each([46, 47, 48, 49, 50, 70])("keeps both separators for a %i-character server name", (serverLength) => { + const server = `s${"x".repeat(serverLength - 1)}` + const name = buildMcpToolName(server, TOOL) + const parsed = parseMcpToolName(name) + + expect(name).toHaveLength(64) + expect(name).toMatch(/_[0-9a-f]{8}$/) + expect(parsed).not.toBeNull() + expect(server.startsWith(parsed!.serverName)).toBe(true) + expect(parsed!.toolName.length).toBeGreaterThan(0) + expect(resolveMcpToolByName([{ name: server, tools: [{ name: TOOL }] }], name)).toEqual({ + serverName: server, + toolName: TOOL, + }) + }) + + it("drops a hyphen at the cut so it does not merge with the separator", () => { + const server = `${"a".repeat(46)}-${"b".repeat(10)}` + const name = buildMcpToolName(server, TOOL) + + expect(name).not.toContain("---") + expect(parseMcpToolName(name)?.serverName).toBe("a".repeat(46)) + }) + }) + + it("is deterministic and keeps tools that share a 55-character prefix distinct", () => { + const first = buildMcpToolName(SERVER, `${TOOL}_a`) + const second = buildMcpToolName(SERVER, `${TOOL}_b`) + + expect(buildMcpToolName(SERVER, `${TOOL}_a`)).toBe(first) + expect(first.slice(0, 55)).toBe(second.slice(0, 55)) + expect(first).not.toBe(second) + }) + + describe("resolveMcpToolByName", () => { + const servers = [ + { name: "other", tools: [{ name: "ping" }] }, + { name: SERVER, tools: [{ name: TOOL }, { name: `${TOOL}_b` }] }, + ] + + it.each([ + { label: "the declared (hashed) name", called: buildMcpToolName(SERVER, TOOL) }, + { label: "the full uncut name", called: UNCUT }, + { label: "a name with underscores for hyphens", called: buildMcpToolName(SERVER, TOOL).replace(/-/g, "_") }, + ])("resolves $label to the original server and tool", ({ called }) => { + expect(resolveMcpToolByName(servers, called)).toEqual({ serverName: SERVER, toolName: TOOL }) + }) + + it("resolves a short name", () => { + expect(resolveMcpToolByName(servers, "mcp--other--ping")).toEqual({ serverName: "other", toolName: "ping" }) + }) + + it("returns null for an unknown name", () => { + expect(resolveMcpToolByName(servers, "mcp--company-gateway-tools--missing")).toBeNull() + }) + + it("cannot recover the tool from the hashed name alone, which is why the lookup exists", () => { + const parsed = parseMcpToolName(buildMcpToolName(SERVER, TOOL)) + + expect(parsed?.toolName).not.toBe(TOOL) + }) + }) +}) diff --git a/src/utils/mcp-name.ts b/src/utils/mcp-name.ts index 7512fff80a..486cfda1c6 100644 --- a/src/utils/mcp-name.ts +++ b/src/utils/mcp-name.ts @@ -1,3 +1,5 @@ +import { createHash } from "crypto" + /** * Utilities for sanitizing MCP server and tool names to conform to * API function name requirements across all providers. @@ -114,29 +116,84 @@ export function sanitizeMcpName(name: string): string { return sanitized } +/** + * Maximum length providers accept for a function name (Gemini, Bedrock, Azure OpenAI). + */ +export const MAX_TOOL_NAME_LENGTH = 64 + +const HASH_SUFFIX_LENGTH = 8 + +function buildUncutMcpToolName(serverName: string, toolName: string): string { + const sanitizedServer = sanitizeMcpName(serverName) + const sanitizedTool = sanitizeMcpName(toolName) + + return `${MCP_TOOL_PREFIX}${MCP_TOOL_SEPARATOR}${sanitizedServer}${MCP_TOOL_SEPARATOR}${sanitizedTool}` +} + /** * Build a full MCP tool function name from server and tool names. * The format is: mcp--{sanitized_server_name}--{sanitized_tool_name} * - * The total length is capped at 64 characters to conform to API limits. + * The total length is capped at 64 characters to conform to API limits. A name that + * is too long is cut and given a short hash of the full name, so distinct tools stay + * distinct and the result does not look like a word cut in half that a model would + * want to complete. Use `resolveMcpToolByName` to map such a name back to its tool. * * @param serverName - The MCP server name * @param toolName - The tool name * @returns A sanitized function name in the format mcp--serverName--toolName */ export function buildMcpToolName(serverName: string, toolName: string): string { - const sanitizedServer = sanitizeMcpName(serverName) - const sanitizedTool = sanitizeMcpName(toolName) + const fullName = buildUncutMcpToolName(serverName, toolName) - // Build the full name: mcp--{server}--{tool} - const fullName = `${MCP_TOOL_PREFIX}${MCP_TOOL_SEPARATOR}${sanitizedServer}${MCP_TOOL_SEPARATOR}${sanitizedTool}` + if (fullName.length <= MAX_TOOL_NAME_LENGTH) { + return fullName + } + + // Models may return the name with hyphens turned into underscores; hash the normalized spelling + // so such a name still rebuilds to the declared one. + const hash = createHash("sha256") + .update(normalizeForComparison(fullName)) + .digest("hex") + .slice(0, HASH_SUFFIX_LENGTH) + const budget = MAX_TOOL_NAME_LENGTH - HASH_SUFFIX_LENGTH - 1 + const prefix = `${MCP_TOOL_PREFIX}${MCP_TOOL_SEPARATOR}` + // Cap the server part so the second separator and at least one tool character survive; + // otherwise the shortened name no longer parses as mcp--server--tool and the call is dropped. + // A trailing hyphen is removed so it cannot merge with the separator. + const maxServerLength = budget - prefix.length - MCP_TOOL_SEPARATOR.length - 1 + const server = sanitizeMcpName(serverName).slice(0, maxServerLength).replace(/-+$/, "") + const head = `${prefix}${server}${MCP_TOOL_SEPARATOR}` + const tool = sanitizeMcpName(toolName).slice(0, budget - head.length) + + return `${head}${tool}_${hash}` +} - // Truncate if necessary (max 64 chars for Gemini) - if (fullName.length > 64) { - return fullName.slice(0, 64) +/** + * Find the server and tool a model-supplied MCP function name refers to, whether it is the + * name declared to the model (possibly shortened with a hash) or the full uncut name. + * Hyphens and underscores are treated as equivalent, as elsewhere. + * + * @returns The original server and tool names, or null when nothing matches + */ +export function resolveMcpToolByName( + servers: ReadonlyArray<{ name: string; tools?: ReadonlyArray<{ name: string }> }>, + calledName: string, +): { serverName: string; toolName: string } | null { + const wanted = normalizeForComparison(normalizeMcpToolName(calledName)) + + for (const server of servers) { + for (const tool of server.tools ?? []) { + const declared = buildMcpToolName(server.name, tool.name) + const uncut = buildUncutMcpToolName(server.name, tool.name) + + if (normalizeForComparison(declared) === wanted || normalizeForComparison(uncut) === wanted) { + return { serverName: server.name, toolName: tool.name } + } + } } - return fullName + return null } /**