fix(anthropic-passthrough): strip mcp__<server>__ namespace prefix for non-Anthropic tool routes

When the wallet (or any claude-agent-sdk-based caller) registers tools
via an MCP server, the SDK exposes tool names with an MCP namespace
prefix of the form `mcp__<server>__<tool>` (e.g.,
`mcp__wallet__transfer`). Anthropic-native models call these correctly.
Gemini-2.5-flash-lite (and likely some other lower-tier providers
routed through this Anthropic→OpenAI translator) intermittently drops
the prefix and emits just the base name (`transfer`), which the SDK
then fails to find with `No such tool available`.

The full §5 paper-experiment audit of 162 failed Gemini sessions
(cryptitalk-wallet-litellm#10 audit) shows this is the single biggest
failure mode — ~62% of failures and ~36% of all tool-call events drop
the prefix.

Fix: in translate_anthropic_tools_to_openai, strip the
`mcp__<server>__` prefix before forwarding to the OpenAI-flavor
provider, and record the original<->stripped mapping in the existing
tool_name_mapping table. The response-side restorer
(_translate_openai_content_to_anthropic) already uses this mapping to
map returned tool_use.name values back to the SDK-registered original,
so no response-side change is needed.

Collision-safe: if two tools would strip to the same base name (e.g.,
mcp__wallet__balance and mcp__bank__balance both -> "balance"), the
strip is skipped for both. The mapping table preserves uniqueness.

Test plan: smoke-tested the strip helper against MCP-prefixed names,
non-prefixed names, multi-underscore tool names, and dash-containing
server names. Collision detection verified.

Refs: cryptitalk-wallet-litellm#10 #11
This commit is contained in:
Crypti Talk 2026-05-22 00:45:04 +00:00
parent b3cea19d60
commit fc71f84feb

View file

@ -1,6 +1,7 @@
import copy
import hashlib
import json
import re
from typing import (
TYPE_CHECKING,
Any,
@ -46,6 +47,41 @@ def truncate_tool_name(name: str) -> str:
return f"{name[:TOOL_NAME_PREFIX_LENGTH]}_{name_hash}"
# Pattern: mcp__<server_name>__<tool_name>
# Server name and tool name may contain letters, digits, dashes, single
# underscores — but the double-underscore separator is the boundary marker.
_MCP_NAMESPACE_PATTERN = re.compile(r"^mcp__([^_]+(?:_[^_]+)*)__(.+)$")
def strip_mcp_namespace(name: str) -> str:
"""Strip the ``mcp__<server>__`` prefix from a tool name.
claude-agent-sdk's MCP-server tool registration produces names of the
form ``mcp__<server>__<tool>`` (e.g., ``mcp__wallet__transfer``).
Some downstream model providers — Gemini-2.5-flash-lite in particular —
intermittently drop the prefix and emit only the base tool name (e.g.,
``transfer``). Without rewriting on the gateway side, the SDK then
fails the tool lookup with ``No such tool available``.
Solution: strip the prefix in the request to the model, store the
forward mapping (``transfer`` -> ``mcp__wallet__transfer``), and
restore the full name on the response side before returning to the
SDK. Same general pattern as truncate_tool_name's mapping table.
Args:
name: The original tool name (possibly mcp__-prefixed)
Returns:
The base tool name with the ``mcp__<server>__`` prefix removed,
or the original name unchanged if no prefix is present.
"""
match = _MCP_NAMESPACE_PATTERN.match(name)
if match is None:
return name
base = match.group(2)
return base or name
def create_tool_name_mapping(
tools: List[Dict[str, Any]],
) -> Dict[str, str]:
@ -807,6 +843,30 @@ class LiteLLMAnthropicMessagesAdapter:
tool_name_mapping: Dict[str, str] = {}
mapped_tool_params = ["name", "input_schema", "description", "cache_control"]
# First pass: detect MCP-namespace-strip collisions. If two tools
# would strip to the same base name (e.g., mcp__wallet__balance and
# mcp__bank__balance both strip to "balance"), we must NOT strip
# — collisions would corrupt the tool_name_mapping reverse lookup.
_strip_candidates: Dict[str, str] = {} # original_name -> stripped_name
_strip_collision: set = set() # original_names that collide
_stripped_counts: Dict[str, int] = {}
for idx, tool in enumerate(tools):
tool_type = tool.get("type", "")
if any(tool_type.startswith(t.value) for t in ANTHROPIC_HOSTED_TOOLS):
continue
raw = tool.get("name")
if raw is None or (isinstance(raw, str) and not str(raw).strip()):
continue
orig = str(raw)
stripped = strip_mcp_namespace(orig)
if stripped != orig:
_strip_candidates[orig] = stripped
_stripped_counts[stripped] = _stripped_counts.get(stripped, 0) + 1
# Mark any candidate whose stripped name appears more than once.
for orig, stripped in _strip_candidates.items():
if _stripped_counts.get(stripped, 0) > 1:
_strip_collision.add(orig)
for idx, tool in enumerate(tools):
# Check if this is an Anthropic-native tool that should be kept as-is
tool_type = tool.get("type", "")
@ -822,9 +882,20 @@ class LiteLLMAnthropicMessagesAdapter:
original_name = f"litellm_unnamed_tool_{idx}"
else:
original_name = str(raw_name)
truncated_name = truncate_tool_name(original_name)
# Strip MCP namespace prefix unless it would collide. Then
# truncate. The mapping table records short<->original so the
# response-side restorer can reverse it back to the SDK's
# expected name.
if original_name in _strip_collision:
stripped_name = original_name
else:
stripped_name = strip_mcp_namespace(original_name)
truncated_name = truncate_tool_name(stripped_name)
# Store mapping if name was truncated
# Store mapping if name changed (either MCP-prefix-stripped or
# truncated). On the response side, the tool_use.name returned
# by the model will be looked up here to restore the full
# SDK-registered name.
if truncated_name != original_name:
tool_name_mapping[truncated_name] = original_name