From 5db8543817c3cf331086731d054484874a28d485 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:03:25 -0700 Subject: [PATCH] fix(anthropic): preserve MCP tool results in the non-Anthropic Messages bridge (#42783) The tool_result user message built by the /v1/messages MCP loop used tuple content, which the Messages to Chat Completions adapter silently dropped, so non-Anthropic models re-requested the tool until the iteration cap or the provider rejected the follow-up. Emit list content so the existing tool_result branch translates it into a role tool message keyed by tool_call_id. Resolves LIT-8474 Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: bot_apk --- .../messages/mcp_handler.py | 4 +- .../integration/mcp/test_mcp_llm_endpoints.py | 11 --- .../messages/test_mcp_handler.py | 88 +++++++++++++------ 3 files changed, 62 insertions(+), 41 deletions(-) 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 5556b8a8a01..a0585dfb369 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/mcp_handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/mcp_handler.py @@ -50,14 +50,14 @@ def _build_tool_result_message(tool_results: Sequence[Mapping[str, object]]) -> """Turn executed tool results into the user message Anthropic expects.""" return AnthropicMessagesUserMessageParam( role="user", - content=tuple( + content=[ AnthropicMessagesToolResultParam( type="tool_result", tool_use_id=str(result.get("tool_call_id") or ""), content=str(result.get("result") or ""), ) for result in tool_results - ), + ], ) diff --git a/tests/integration/mcp/test_mcp_llm_endpoints.py b/tests/integration/mcp/test_mcp_llm_endpoints.py index 40d7c197066..6beea9ae8f4 100644 --- a/tests/integration/mcp/test_mcp_llm_endpoints.py +++ b/tests/integration/mcp/test_mcp_llm_endpoints.py @@ -258,16 +258,6 @@ def _peer_add_calls(peer: McpPeer) -> tuple[dict[str, object], ...]: ) -def _skip_if_bridge_drops_tool_result( - rig: Rig, requests: tuple[tuple[str, ...], ...], calls: tuple[object, ...] -) -> None: - if rig.surface == "messages_bridge" and len(calls) > 1 and len(requests) > 2: - pytest.skip( - "BUG: /v1/messages MCP tool loop over a non-Anthropic model drops the tool_result message, " - "so the tool is re-executed until the iteration cap" - ) - - @pytest.mark.parametrize("surface", SURFACES) def test_auto_approved_gateway_tool_is_listed_executed_once_and_fed_back(gateway: Gateway, surface: Surface) -> None: with _rig(gateway, surface) as rig: @@ -276,7 +266,6 @@ def test_auto_approved_gateway_tool_is_listed_executed_once_and_fed_back(gateway assert response.status_code == 200, response.text calls: Final = _peer_add_calls(rig.peer) requests: Final = rig.upstream_tools() - _skip_if_bridge_drops_tool_result(rig, requests, calls) assert [call["body"]["params"]["name"] for call in calls] == ["add"], calls assert calls[0]["body"]["params"]["arguments"] == ADD, calls assert len(requests) == 2, requests diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_mcp_handler.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_mcp_handler.py index a2301e227a8..93adde12c4b 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_mcp_handler.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_mcp_handler.py @@ -3,7 +3,9 @@ from unittest.mock import AsyncMock, patch import pytest - +from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import ( + LiteLLMAnthropicMessagesAdapter, +) from litellm.llms.anthropic.experimental_pass_through.messages.handler import ( anthropic_messages_handler, ) @@ -59,7 +61,7 @@ def test_anthropic_messages_handler_skips_the_gateway_on_recursion(): "litellm.llms.anthropic.experimental_pass_through.messages.mcp_handler.anthropic_messages_with_mcp", new=AsyncMock(return_value={"routed": True}), ) as routed: - with pytest.raises(ValueError, match='anthropic_messages_handler is not implemented for sync calls'): + with pytest.raises(ValueError, match="anthropic_messages_handler is not implemented for sync calls"): anthropic_messages_handler( max_tokens=100, messages=[{"role": "user", "content": "hi"}], @@ -78,7 +80,7 @@ def test_anthropic_messages_handler_leaves_native_tools_alone(): "litellm.llms.anthropic.experimental_pass_through.messages.mcp_handler.anthropic_messages_with_mcp", new=AsyncMock(return_value={"routed": True}), ) as routed: - with pytest.raises(ValueError, match='anthropic_messages_handler is not implemented for sync calls'): + with pytest.raises(ValueError, match="anthropic_messages_handler is not implemented for sync calls"): anthropic_messages_handler( max_tokens=100, messages=[{"role": "user", "content": "hi"}], @@ -115,8 +117,31 @@ def test_build_tool_result_message_uses_anthropic_tool_result_blocks(): message = _build_tool_result_message([{"tool_call_id": "toolu_1", "result": "9 sections", "name": "read_wiki"}]) assert message["role"] == "user" - assert list(message["content"]) == [ - {"type": "tool_result", "tool_use_id": "toolu_1", "content": "9 sections"} + assert message["content"] == [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "9 sections"}] + + +def test_build_tool_result_message_survives_the_chat_completions_bridge(): + """ + Regression test (LIT-8474): a non-Anthropic model behind /v1/messages must see + the executed tool result as a role="tool" message keyed by the tool_call_id. + + The bridge only translates list content, so a tuple-shaped user message was + dropped and the model re-requested the tool until the iteration cap. + """ + message = _build_tool_result_message( + [ + {"tool_call_id": "call_1", "result": "5", "name": "add"}, + {"tool_call_id": "call_2", "result": "7", "name": "add"}, + ] + ) + + translated = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai( + [message], model="hosted_vllm/gpt-4o-mini", custom_llm_provider="hosted_vllm" + ) + + assert translated == [ + {"role": "tool", "tool_call_id": "call_1", "content": "5"}, + {"role": "tool", "tool_call_id": "call_2", "content": "7"}, ] @@ -157,19 +182,23 @@ async def test_anthropic_messages_with_mcp_forwards_the_callers_mcp_credentials( {"stop_reason": "end_turn", "content": [{"type": "text", "text": "done"}]}, ] - with patch.object(MCPRequestContext, "resolve", return_value=context), patch.object( - mcp_handler.LiteLLM_Proxy_MCP_Handler - if hasattr(mcp_handler, "LiteLLM_Proxy_MCP_Handler") - else __import__( - "litellm.responses.mcp.litellm_proxy_mcp_handler", fromlist=["LiteLLM_Proxy_MCP_Handler"] - ).LiteLLM_Proxy_MCP_Handler, - "_process_mcp_tools_without_openai_transform", - new=process, - ), patch.object( - import_module("litellm.responses.mcp.litellm_proxy_mcp_handler").LiteLLM_Proxy_MCP_Handler, "_execute_tool_calls", - new=execute, - ), patch( - "litellm.anthropic_messages", new=AsyncMock(side_effect=responses) + with ( + patch.object(MCPRequestContext, "resolve", return_value=context), + patch.object( + mcp_handler.LiteLLM_Proxy_MCP_Handler + if hasattr(mcp_handler, "LiteLLM_Proxy_MCP_Handler") + else __import__( + "litellm.responses.mcp.litellm_proxy_mcp_handler", fromlist=["LiteLLM_Proxy_MCP_Handler"] + ).LiteLLM_Proxy_MCP_Handler, + "_process_mcp_tools_without_openai_transform", + new=process, + ), + patch.object( + import_module("litellm.responses.mcp.litellm_proxy_mcp_handler").LiteLLM_Proxy_MCP_Handler, + "_execute_tool_calls", + new=execute, + ), + patch("litellm.anthropic_messages", new=AsyncMock(side_effect=responses)), ): await mcp_handler.anthropic_messages_with_mcp( max_tokens=100, @@ -220,16 +249,19 @@ async def test_anthropic_messages_with_mcp_stops_when_every_tool_call_is_skipped } anthropic_messages_mock = AsyncMock(return_value=tool_use_response) - with patch.object( - MCPRequestContext, "resolve", return_value=MCPRequestContext(user_api_key_auth="auth") - ), patch.object( - import_module("litellm.responses.mcp.litellm_proxy_mcp_handler").LiteLLM_Proxy_MCP_Handler, "_process_mcp_tools_without_openai_transform", - new=AsyncMock(return_value=([], {})), - ), patch.object( - import_module("litellm.responses.mcp.litellm_proxy_mcp_handler").LiteLLM_Proxy_MCP_Handler, "_execute_tool_calls", - new=AsyncMock(return_value=[]), - ), patch( - "litellm.anthropic_messages", new=anthropic_messages_mock + with ( + patch.object(MCPRequestContext, "resolve", return_value=MCPRequestContext(user_api_key_auth="auth")), + patch.object( + import_module("litellm.responses.mcp.litellm_proxy_mcp_handler").LiteLLM_Proxy_MCP_Handler, + "_process_mcp_tools_without_openai_transform", + new=AsyncMock(return_value=([], {})), + ), + patch.object( + import_module("litellm.responses.mcp.litellm_proxy_mcp_handler").LiteLLM_Proxy_MCP_Handler, + "_execute_tool_calls", + new=AsyncMock(return_value=[]), + ), + patch("litellm.anthropic_messages", new=anthropic_messages_mock), ): result = await mcp_handler.anthropic_messages_with_mcp( max_tokens=100,