fix(cliproxyapi): rewrite mcp_* tool names to bypass Anthropic gate

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.
This commit is contained in:
OmniRoute Ops
2026-05-11 12:15:13 +00:00
parent 05212b0a3f
commit 83f25922b0

View File

@@ -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<string, unknown>): Map<string, string> {
const reverseMap = new Map<string, string>();
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<string, unknown>;
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<string, unknown>).content;
if (!Array.isArray(content)) continue;
for (const block of content) {
if (!block || typeof block !== "object") continue;
const b = block as Record<string, unknown>;
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<string, unknown>;
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,
});