diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/mcp_handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/mcp_handler.py index 15cf448b426..6d51c32bfd7 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/mcp_handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/mcp_handler.py @@ -60,6 +60,25 @@ def _build_tool_result_message(tool_results: Sequence[Mapping[str, object]]) -> ) +def _has_client_side_tool( + tool_use_blocks: Sequence[Mapping[str, object]], + other_tools: Sequence[Mapping[str, object]] | None, + tool_server_map: Mapping[str, str], +) -> bool: # kwargs-ok: helper function inspecting tool blocks for client passthrough + client_tool_names = { + t.get("name") # kwargs-ok: extract client tool name + for t in (other_tools or ()) + if isinstance(t, dict) and t.get("name") # kwargs-ok: extract client tool name + } + for block in tool_use_blocks: + name = block.get("name") # kwargs-ok: extract block tool name + if name in client_tool_names or ( + bool(tool_server_map) and name not in tool_server_map + ): # kwargs-ok: check map membership + return True + return False + + async def anthropic_messages_with_mcp( max_tokens: int, messages: Sequence[Mapping[str, object]], @@ -85,11 +104,12 @@ async def anthropic_messages_with_mcp( mcp_references, other_tools = LiteLLM_Proxy_MCP_Handler._parse_mcp_tools(tools) if not mcp_references: + formatted_tools: Final = list(tools) if tools is not None else None return await _AnthropicMessagesCall(fn=litellm.anthropic_messages).fn( max_tokens=max_tokens, messages=list(messages), model=model, - tools=list(tools) if tools else None, # kwargs-ok: param pass-through + tools=formatted_tools, _skip_mcp_handler=True, **kwargs, ) @@ -136,12 +156,6 @@ async def anthropic_messages_with_mcp( messages=list(working_messages), stream=False, **base_call_args ) - client_tool_names = set() - if other_tools: - for tool_item in other_tools: - if isinstance(tool_item, dict) and "name" in tool_item: - client_tool_names.add(tool_item["name"]) # kwargs-ok: extract client tool name - for _ in range(MAX_MCP_TOOL_USE_ITERATIONS): if _get_stop_reason(response) != "tool_use": break @@ -150,14 +164,7 @@ async def anthropic_messages_with_mcp( if not tool_use_blocks: break - has_client_side_tool = False - for block in tool_use_blocks: - tool_name = block.get("name") # kwargs-ok: extract block tool name - if tool_name in client_tool_names or (bool(tool_server_map) and tool_name not in tool_server_map): - has_client_side_tool = True - break - - if has_client_side_tool: + if _has_client_side_tool(tool_use_blocks, other_tools, tool_server_map): break tool_results = await LiteLLM_Proxy_MCP_Handler._execute_tool_calls( @@ -176,7 +183,7 @@ async def anthropic_messages_with_mcp( if not tool_results: break - working_messages = ( + working_messages = ( # rebind-ok: append assistant and tool_result turns to working context *working_messages, {"role": "assistant", "content": list(_get_response_content(response))}, _build_tool_result_message(tool_results), diff --git a/tests/test_mcp_client_tool_passthrough.py b/tests/test_mcp_client_tool_passthrough.py index 7738102d020..00aada49450 100644 --- a/tests/test_mcp_client_tool_passthrough.py +++ b/tests/test_mcp_client_tool_passthrough.py @@ -15,6 +15,7 @@ async def test_mcp_auto_execute_bypasses_client_side_tools(): the auto-execution loop breaks early and passes the response back to the client. """ mock_mcp_references = [{"type": "mcp", "server_url": "http://localhost/mcp", "require_approval": "never"}] + client_tools = [{"name": "Read", "description": "Client Read tool"}] mock_mcp_tools = [SimpleNamespace(name="mcp_tool_1", description="MCP Tool", inputSchema={"type": "object"})] mock_tool_server_map = {"mcp_tool_1": "http://localhost/mcp"} @@ -57,7 +58,7 @@ async def test_mcp_auto_execute_bypasses_client_side_tools(): with ( patch(path_resolve, return_value=mock_context), - patch(path_parse, return_value=(mock_mcp_references, [])), + patch(path_parse, return_value=(mock_mcp_references, client_tools)), patch(path_process, new_callable=AsyncMock) as mock_process, patch(path_auto, return_value=True), patch(path_exec, new_callable=AsyncMock) as mock_execute, @@ -71,7 +72,7 @@ async def test_mcp_auto_execute_bypasses_client_side_tools(): max_tokens=100, messages=[{"role": "user", "content": "Read test.txt and run image_understand"}], model="claude-3-5-sonnet-20241022", - tools=mock_mcp_references, + tools=[*mock_mcp_references, *client_tools], ) mock_execute.assert_not_called()