Skip to content

[Bug]: MCP auto-execute (require_approval "never") hijacks client-side tool_use from agentic clients like Claude Code, breaking all non-MCP tools with "Error executing tool" #37031

Description

@luyufan498

What happened?

A model configured with an MCP tool reference using require_approval: "never" (so the proxy auto-executes it server-side) is deployed behind an agentic client that sends its own tools in the same request, such as Claude Code sending Read/Bash/Edit. The auto-execute loop takes over the entire tool_use turn and tries to execute every tool_use block the model emits, including the client's own non-MCP tools. Those tools are not in the MCP server map, so the proxy raises a KeyError that gets swallowed into Error executing tool: '<tool_name>' and fed back to the model. The model retries, fails again, and eventually gives up. From the client's perspective all of its native tools (Read, Bash, etc.) are broken for any request routed to that model

What I expected: when require_approval: "never" is set, the proxy should auto-execute only the MCP tools it injected. A tool_use whose name does not resolve to a known MCP server should be passed through to the client unchanged so the client can execute it locally, the way a normal tool_use turn works when no MCP tool is present

User Flow

Before a fix

  1. A user creates a virtual key and a team in the LiteLLM proxy UI, then registers an HTTP MCP server (say image_understand) and grants the team access to it
  2. The user attaches the MCP tool to a model via the model's LiteLLM Params, e.g. {"tools": [{"type": "mcp", "server_url": "litellm_proxy/mcp/image_understand", "require_approval": "never"}]}
  3. The user points an agentic client (Claude Code) at POST /v1/messages on the proxy, using that model and that virtual key. The client sends its own tools (Read, Bash, Edit, ...) alongside whatever the proxy injects
  4. The model decides to call Read on a local file. The proxy's auto-execute loop intercepts that tool_use, fails with Error executing tool: 'Read', and returns the error text to the model instead of handing the tool_use back to the client
  5. The model retries with Bash, gets Error executing tool: 'Bash', and eventually stops with a message saying the tools are unavailable. The user sees tool call failures and the client's native tooling is dead for that model

After a fix

  1. Same setup as above: MCP server registered, team granted access, MCP tool attached to the model with require_approval: "never"
  2. The user points Claude Code at POST /v1/messages with that model and key, sending its own tools
  3. The model calls Read on a local file. The proxy recognizes Read is not one of its MCP tools, returns the tool_use block to the client in the response, and lets the client execute it locally
  4. When the model calls the injected MCP tool (image_understand), the proxy auto-executes it server-side as before and feeds the result back
  5. Both the client's native tools and the injected MCP tool work in the same turn

Proof the bug occurs

I verified the root cause by reading the current main branch (v1.98.0-dev.2). I was not able to capture a full Claude Code trace against my own proxy in time for this report, so the proof below is the code path plus the user-visible symptom. I can add a full end-to-end curl reproduction if the maintainers want one

Config / setup: a model with an MCP tool attached via LiteLLM Params

{"tools": [{"type": "mcp", "server_url": "litellm_proxy/mcp/image_understand", "require_approval": "never"}]}

Version or commit: v1.98.0-dev.2 on main

Commands and their full output

The auto-execute loop in litellm/llms/anthropic/experimental_pass_through/messages/mcp_handler.py extracts every tool_use block without checking whether it belongs to an MCP server

# line 38-40
def _extract_tool_use_blocks(response):
    return tuple(block for block in _get_response_content(response) if block.get("type") == "tool_use")

# line 139-158 (abridged)
for _ in range(MAX_MCP_TOOL_USE_ITERATIONS):
    if _get_stop_reason(response) != "tool_use":
        break
    tool_use_blocks = _extract_tool_use_blocks(response)
    if not tool_use_blocks:
        break
    tool_results = await LiteLLM_Proxy_MCP_Handler._execute_tool_calls(
        tool_server_map=tool_server_map,
        tool_calls=list(tool_use_blocks),
        ...
    )

tool_server_map is built only from the injected MCP tools (in _process_mcp_tools_without_openai_transform). When _execute_tool_calls looks up a client-side tool name that is not in that map, it throws KeyError

# litellm/responses/mcp/litellm_proxy_mcp_handler.py line 675
server_name = tool_server_map[tool_name]

That KeyError is caught by the broad except at line 891-905 and turned into the user-visible error string

# line 891-905 (abridged)
except Exception as e:
    ...
    tool_results.append({
        "tool_call_id": tool_call_id,
        "result": f"Error executing tool: {e}",
        "name": tool_name,
    })

So a model emitting tool_use for Read produces Error executing tool: 'Read', which matches what I observed in the proxy spend logs and what Claude Code surfaced as "tool call failed"

The symptom from the agentic client side (Claude Code talking to the proxy over /v1/messages): the model attempts Read on a file that exists, gets Error executing tool: 'Read' back, tries Bash, gets Error executing tool: 'Bash', then tells the user it cannot read files or run commands. A plain curl client that sends no tools of its own works perfectly against the same model, because then every tool_use resolves to an MCP tool. This is why the bug is specific to agentic clients that bring their own tools

What part of LiteLLM is this about?

Proxy

What LiteLLM version are you on ?

v1.98.0-dev.2

Twitter / LinkedIn details

No response

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions