From 83f25922b05715737dcfd825312b9496a69b7de4 Mon Sep 17 00:00:00 2001 From: OmniRoute Ops Date: Mon, 11 May 2026 12:15:13 +0000 Subject: [PATCH] fix(cliproxyapi): rewrite mcp_* tool names to bypass Anthropic gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic Messages API silently gates client-declared tool names matching ^mcp_[^_].* behind their "Extra usage required" / "out of extra usage" 400 error — the prefix is reserved for their server-side MCP connector tools. The error is misleading: it looks like a quota exhaustion, but it's body- shape gating triggered purely by the regex match on `tools[].name`. Bisected character-by-character against the real Anthropic API via CPA (uTLS spoof, Claude OAuth account): Gate hit (HTTP 400): mcp_call, mcp_query, mcp_x, mcp_test, mcp_anything Bypass (HTTP 200): Mcp_call, MCP_call, _mcp_call, xmcp_call, mcp__call, mcp-call, mcpcall, my_mcp_call Independent of system prompt, metadata.user_id shape, thinking/output_config presence, request size, or tool count. Capy declares mcp_call + mcp_query for its MCP bridge, so every Capy Claude-BYOK request 400'd. Fix: in CliproxyapiExecutor.transformRequest, for Anthropic-shape bodies, rewrite tool names matching ^mcp_[^_] to "M" + name.slice(1) (mcp_call → Mcp_call). Same rewrite applied to: - tools[].name - messages[].content[type=tool_use].name (for multi-turn) - tool_choice.name (when type=tool) Build a reverse map {rewritten → original} and attach as body._toolNameMap. chatCore.ts:mergeResponseToolNameMap already reads this from finalBody and forwards it to the SSE passthrough stream (utils/stream.ts:restoreClaudePassthroughToolUseName), which rewrites tool_use.name back to the client's original namespace on response chunks. End-to-end: Capy sends mcp_call → CPA/Anthropic sees Mcp_call → response tool_use blocks emit Mcp_call → client receives mcp_call. Capy's dispatch keyed on mcp_call works unchanged. _toolNameMap is filtered out of the JSON.stringify body sent to CPA so the in-memory channel doesn't leak over the wire. --- open-sse/executors/cliproxyapi.ts | 96 ++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/open-sse/executors/cliproxyapi.ts b/open-sse/executors/cliproxyapi.ts index 85ca6534f4..dd048a0531 100644 --- a/open-sse/executors/cliproxyapi.ts +++ b/open-sse/executors/cliproxyapi.ts @@ -27,6 +27,73 @@ const DEFAULT_PORT = 8317; const DEFAULT_HOST = "127.0.0.1"; const HEALTH_CHECK_TIMEOUT_MS = 5000; +// Anthropic's reserved tool-name namespace: ^mcp_[^_].* triggers their +// server-side MCP connector billing gate, returning a misleading +// "out of extra usage" 400. Two-underscore (mcp__X) and capitalized +// (Mcp_X) variants pass cleanly. +const MCP_RESERVED_PREFIX_RE = /^mcp_(?=[^_])/; + +function rewriteMcpToolName(name: string): string | null { + if (typeof name !== "string" || !MCP_RESERVED_PREFIX_RE.test(name)) return null; + return "M" + name.slice(1); // mcp_call → Mcp_call +} + +function applyMcpToolNameRewrite(body: Record): Map { + const reverseMap = new Map(); + const remember = (original: string, rewritten: string) => { + reverseMap.set(rewritten, original); + }; + + const tools = body.tools; + if (Array.isArray(tools)) { + for (const tool of tools) { + if (!tool || typeof tool !== "object") continue; + const t = tool as Record; + const original = typeof t.name === "string" ? t.name : ""; + const rewritten = rewriteMcpToolName(original); + if (rewritten) { + t.name = rewritten; + remember(original, rewritten); + } + } + } + + const messages = body.messages; + if (Array.isArray(messages)) { + for (const msg of messages) { + if (!msg || typeof msg !== "object") continue; + const content = (msg as Record).content; + if (!Array.isArray(content)) continue; + for (const block of content) { + if (!block || typeof block !== "object") continue; + const b = block as Record; + if (b.type !== "tool_use") continue; + const original = typeof b.name === "string" ? b.name : ""; + const rewritten = rewriteMcpToolName(original); + if (rewritten) { + b.name = rewritten; + remember(original, rewritten); + } + } + } + } + + const toolChoice = body.tool_choice; + if (toolChoice && typeof toolChoice === "object") { + const tc = toolChoice as Record; + if (tc.type === "tool" && typeof tc.name === "string") { + const rewritten = rewriteMcpToolName(tc.name); + if (rewritten) { + const original = tc.name; + tc.name = rewritten; + remember(original, rewritten); + } + } + } + + return reverseMap; +} + function resolveCliproxyapiBaseUrl(): string { const host = process.env.CLIPROXYAPI_HOST || DEFAULT_HOST; const port = parseInt(process.env.CLIPROXYAPI_PORT || String(DEFAULT_PORT), 10); @@ -146,6 +213,24 @@ export class CliproxyapiExecutor extends BaseExecutor { delete transformed.thinking; delete transformed.output_config; delete transformed.context_management; + + // Rewrite tool names matching Anthropic's reserved ^mcp_[^_] namespace. + // Anthropic returns "out of extra usage" / "Extra usage required" 400 + // when a client-declared tool name collides with their server-side MCP + // connector tools. Bisected character-by-character against the real + // Anthropic API via CPA (uTLS spoof, Claude OAuth): + // mcp_call, mcp_query, mcp_x, mcp_test → 400 (gate hit) + // Mcp_call, _mcp_call, mcp__call, mcp-call, mcpcall, my_mcp_call → 200 + // The "Mcp_" capitalization is the smallest stable rewrite that + // preserves readability. The reverse map below is propagated to + // chatCore via body._toolNameMap, which the SSE passthrough stream + // uses (utils/stream.ts:restoreClaudePassthroughToolUseName) to + // rewrite tool_use.name back to the client's original namespace on + // the response side. Capy sees mcp_call back in tool_use blocks. + const toolNameMap = applyMcpToolNameRewrite(transformed); + if (toolNameMap.size > 0) { + transformed._toolNameMap = toolNameMap; + } } return transformed; @@ -179,10 +264,19 @@ export class CliproxyapiExecutor extends BaseExecutor { input.log?.info?.("CPA", `CLIProxyAPI → ${url} (model: ${input.model}, shape: ${shape})`); + // _toolNameMap is an in-memory channel to chatCore for response-side + // tool name restoration; never send it over the wire. + const wireBody = + transformedBody && typeof transformedBody === "object" + ? JSON.stringify(transformedBody, (key, value) => + key === "_toolNameMap" ? undefined : value + ) + : JSON.stringify(transformedBody); + const response = await fetch(url, { method: "POST", headers, - body: JSON.stringify(transformedBody), + body: wireBody, signal: combinedSignal, });