mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user