diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 0e198daf089..a070a9ff215 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -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 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____`` prefix from a tool name. + + claude-agent-sdk's MCP-server tool registration produces names of the + form ``mcp____`` (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____`` 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