From 2b5159e478bca97252846a222baf8d654ba7e453 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 11:40:49 -0700 Subject: [PATCH] Build the MCP tool identifier in one place plugin.ts and tool-permissions.ts each reimplemented the mcp____ template literal independently of the parser in tool-name.ts, and tool-permissions.ts reconstructed the prefix a third time for stripping tiers on disconnect. A drift in the separator would have broken permission tiering or tool dispatch silently while parsing kept working. tool-name.ts now exports mcpToolPrefix and mcpToolName next to parseMcpToolName, and both call sites use them instead of local copies. --- src/mcp/plugin.ts | 5 +---- src/mcp/tool-name.test.ts | 24 ++++++++++++++++++++++++ src/mcp/tool-name.ts | 8 ++++++++ src/mcp/tool-permissions.ts | 12 ++++-------- 4 files changed, 37 insertions(+), 12 deletions(-) create mode 100644 src/mcp/tool-name.test.ts diff --git a/src/mcp/plugin.ts b/src/mcp/plugin.ts index 4a91a85d3..390c49eaf 100644 --- a/src/mcp/plugin.ts +++ b/src/mcp/plugin.ts @@ -5,10 +5,7 @@ import { gateToolCall } from "../plugins/permission-plugin.js"; import { scrubSecretShapedToolResultContent } from "../plugins/tool-result-secret-scrub.js"; import { truncateToolResultContent } from "../plugins/result-truncation-plugin.js"; import type { MCPClient } from "./client.js"; - -function mcpToolName(serverName: string, toolName: string): string { - return `mcp__${serverName}__${toolName}`; -} +import { mcpToolName } from "./tool-name.js"; // MCP results never reach the posix runner, so the secret-scrub and truncation // middleware in src/plugins never see them. Apply the same scrub-then-truncate diff --git a/src/mcp/tool-name.test.ts b/src/mcp/tool-name.test.ts new file mode 100644 index 000000000..e090636e0 --- /dev/null +++ b/src/mcp/tool-name.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { mcpToolName, mcpToolPrefix, parseMcpToolName } from "./tool-name.js"; + +describe("mcpToolName", () => { + test("builds the mcp____ identifier", () => { + expect(mcpToolName("linear", "list_projects")).toBe("mcp__linear__list_projects"); + }); + + test("round-trips with parseMcpToolName", () => { + const name = mcpToolName("railway", "get_logs"); + expect(parseMcpToolName(name)).toEqual({ server: "railway", tool: "get_logs" }); + }); +}); + +describe("mcpToolPrefix", () => { + test("matches the prefix of a built name for the same server", () => { + const server = "linear"; + expect(mcpToolName(server, "list_projects").startsWith(mcpToolPrefix(server))).toBe(true); + }); + + test("builds mcp____", () => { + expect(mcpToolPrefix("linear")).toBe("mcp__linear__"); + }); +}); diff --git a/src/mcp/tool-name.ts b/src/mcp/tool-name.ts index dc5e8af92..03f57b70c 100644 --- a/src/mcp/tool-name.ts +++ b/src/mcp/tool-name.ts @@ -9,6 +9,14 @@ export function isMcpToolName(name: string): boolean { return name.startsWith(MCP_PREFIX); } +export function mcpToolPrefix(serverName: string): string { + return `${MCP_PREFIX}${serverName}__`; +} + +export function mcpToolName(serverName: string, toolName: string): string { + return `${mcpToolPrefix(serverName)}${toolName}`; +} + export function parseMcpToolName(name: string): { server: string; tool: string } | null { if (!isMcpToolName(name)) return null; const rest = name.slice(MCP_PREFIX.length); diff --git a/src/mcp/tool-permissions.ts b/src/mcp/tool-permissions.ts index 536ab8c4e..d98a982db 100644 --- a/src/mcp/tool-permissions.ts +++ b/src/mcp/tool-permissions.ts @@ -1,5 +1,5 @@ import type { Tier } from "../permission/classify.js"; -import { isReadOnlyMcpTool } from "./tool-name.js"; +import { isReadOnlyMcpTool, mcpToolName, mcpToolPrefix } from "./tool-name.js"; // Subset of MCP ToolAnnotations used for permission tiering (hints from tools/list). export type McpToolAnnotations = { @@ -23,7 +23,7 @@ export function createMcpToolPermissionRegistry(): McpToolPermissionRegistry { tiers.set(name, tier); }, removeToolsForServer(serverName) { - const prefix = `mcp__${serverName}__`; + const prefix = mcpToolPrefix(serverName); for (const key of tiers.keys()) { if (key.startsWith(prefix)) tiers.delete(key); } @@ -37,10 +37,6 @@ export function createMcpToolPermissionRegistry(): McpToolPermissionRegistry { }; } -function mcpAgentToolName(serverName: string, toolName: string): string { - return `mcp__${serverName}__${toolName}`; -} - function hasAnnotationHints(annotations: McpToolAnnotations | undefined): boolean { if (annotations === undefined) return false; return ( @@ -60,7 +56,7 @@ export function tierFromMcpTool( if (hasAnnotationHints(annotations)) { return annotations!.readOnlyHint === true ? "allow" : "ask"; } - return isReadOnlyMcpTool(mcpAgentToolName(serverName, toolName)) ? "allow" : "ask"; + return isReadOnlyMcpTool(mcpToolName(serverName, toolName)) ? "allow" : "ask"; } export function registerMcpClientTools( @@ -69,6 +65,6 @@ export function registerMcpClientTools( tools: ReadonlyArray<{ name: string; annotations?: McpToolAnnotations }>, ): void { for (const tool of tools) { - registry.setTier(mcpAgentToolName(serverName, tool.name), tierFromMcpTool(tool.annotations, serverName, tool.name)); + registry.setTier(mcpToolName(serverName, tool.name), tierFromMcpTool(tool.annotations, serverName, tool.name)); } } \ No newline at end of file