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 c1f10c245f8..bfb9800747d 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,23 @@ 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: + client_tool_names: Final = tuple( + t.get("name") for t in (other_tools or ()) if isinstance(t, dict) and isinstance(t.get("name"), str) + ) # kwargs-ok: extract client tool names tuple + 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: map membership test + return True + return False + + async def anthropic_messages_with_mcp( max_tokens: int, messages: Sequence[Mapping[str, object]], @@ -89,7 +106,7 @@ async def anthropic_messages_with_mcp( max_tokens=max_tokens, messages=list(messages), model=model, - tools=list(tools) if tools else None, + tools=list(tools) if tools else None, # kwargs-ok: pass tools list _skip_mcp_handler=True, **kwargs, ) @@ -144,6 +161,9 @@ async def anthropic_messages_with_mcp( if not tool_use_blocks: break + if _has_client_side_tool(tool_use_blocks, other_tools, tool_server_map): + break + tool_results = await LiteLLM_Proxy_MCP_Handler._execute_tool_calls( tool_server_map=tool_server_map, tool_calls=list(tool_use_blocks), @@ -157,12 +177,10 @@ async def anthropic_messages_with_mcp( request_tags=list(context.request_tags) if context.request_tags else None, ) - # Every tool call was skipped, so there is nothing to feed back; a - # tool_result message with empty content is rejected by Anthropic. 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 new file mode 100644 index 00000000000..66e67cec053 --- /dev/null +++ b/tests/test_mcp_client_tool_passthrough.py @@ -0,0 +1,153 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from litellm.llms.anthropic.experimental_pass_through.messages.mcp_handler import ( + anthropic_messages_with_mcp, +) + + +@pytest.mark.asyncio +async def test_mcp_auto_execute_bypasses_client_side_tools(): + """ + Ensure that if a response contains client-native tools (not present in tool_server_map), + 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"} + + mock_anthropic_response = { + "id": "msg_123", + "type": "message", + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "call_mcp", "name": "mcp_tool_1", "input": {}}, + { + "type": "tool_use", + "id": "call_client", + "name": "Read", + "input": {"file_path": "test.txt"}, + }, + ], + "stop_reason": "tool_use", + } + + mock_context = MagicMock() + mock_context.user_api_key_auth = None + mock_context.litellm_trace_id = "trace_123" + mock_context.mcp_auth_header = None + mock_context.mcp_server_auth_headers = None + mock_context.request_tags = None + mock_context.oauth2_headers = None + mock_context.raw_headers = None + mock_context.litellm_call_id = "call_123" + + path_resolve = "litellm.responses.mcp.request_context.MCPRequestContext.resolve" + path_parse = "litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._parse_mcp_tools" + path_process = ( + "litellm.responses.mcp.litellm_proxy_mcp_handler." + "LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform" + ) + path_auto = "litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._should_auto_execute_tools" + path_exec = "litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._execute_tool_calls" + path_call = "litellm.llms.anthropic.experimental_pass_through.messages.mcp_handler._AnthropicMessagesCall" + + with ( + patch(path_resolve, return_value=mock_context), + 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, + patch(path_call) as mock_call, + ): + mock_process.return_value = (mock_mcp_tools, mock_tool_server_map) + mock_fn = AsyncMock(return_value=mock_anthropic_response) + mock_call.return_value.fn = mock_fn + + response = await anthropic_messages_with_mcp( + max_tokens=100, + messages=[{"role": "user", "content": "Read test.txt and run image_understand"}], + model="claude-3-5-sonnet-20241022", + tools=[*mock_mcp_references, *client_tools], + ) + + mock_execute.assert_not_called() + assert response == mock_anthropic_response + + +@pytest.mark.asyncio +async def test_mcp_auto_execute_runs_server_side_mcp_tools(): + """ + Ensure that if a response contains ONLY server-side MCP tools, + auto-execution proceeds as expected and returns False for client-side tool check. + """ + mock_mcp_references = [{"type": "mcp", "server_url": "http://localhost/mcp", "require_approval": "never"}] + + mock_mcp_tools = [SimpleNamespace(name="mcp_tool_1", description="MCP Tool", inputSchema={"type": "object"})] + mock_tool_server_map = {"mcp_tool_1": "http://localhost/mcp"} + + mock_anthropic_response = { + "id": "msg_123", + "type": "message", + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "call_mcp", "name": "mcp_tool_1", "input": {}}, + ], + "stop_reason": "tool_use", + } + + mock_context = MagicMock() + mock_context.user_api_key_auth = None + mock_context.litellm_trace_id = "trace_123" + mock_context.mcp_auth_header = None + mock_context.mcp_server_auth_headers = None + mock_context.request_tags = None + mock_context.oauth2_headers = None + mock_context.raw_headers = None + mock_context.litellm_call_id = "call_123" + + path_resolve = "litellm.responses.mcp.request_context.MCPRequestContext.resolve" + path_parse = "litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._parse_mcp_tools" + path_process = ( + "litellm.responses.mcp.litellm_proxy_mcp_handler." + "LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform" + ) + path_auto = "litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._should_auto_execute_tools" + path_exec = "litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._execute_tool_calls" + path_call = "litellm.llms.anthropic.experimental_pass_through.messages.mcp_handler._AnthropicMessagesCall" + + with ( + patch(path_resolve, return_value=mock_context), + patch(path_parse, return_value=(mock_mcp_references, [])), + patch(path_process, new_callable=AsyncMock) as mock_process, + patch(path_auto, return_value=True), + patch(path_exec, new_callable=AsyncMock) as mock_execute, + patch(path_call) as mock_call, + ): + mock_process.return_value = (mock_mcp_tools, mock_tool_server_map) + mock_execute.return_value = [{"tool_call_id": "call_mcp", "result": "ok"}] + + mock_final_response = { + "id": "msg_124", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "done"}], + "stop_reason": "end_turn", + } + + mock_fn = AsyncMock(side_effect=[mock_anthropic_response, mock_final_response]) + mock_call.return_value.fn = mock_fn + + response = await anthropic_messages_with_mcp( + max_tokens=100, + messages=[{"role": "user", "content": "run mcp_tool_1"}], + model="claude-3-5-sonnet-20241022", + tools=mock_mcp_references, + ) + + mock_execute.assert_called_once() + assert response == mock_final_response