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 392b9e2e02d..813d4a62089 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/mcp_handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/mcp_handler.py @@ -10,6 +10,7 @@ tool through a ``tool_use`` content block, and results are fed back as from typing import Any, AsyncIterator, Mapping, Sequence, Union from litellm._logging import verbose_logger +from litellm.responses.mcp.request_context import MCPRequestContext from litellm.types.llms.anthropic import ( AnthropicMessagesTool, AnthropicMessagesToolResultParam, @@ -54,19 +55,6 @@ def _build_tool_result_message(tool_results: Sequence[Mapping[str, Any]]) -> Ant ) -def _resolve_user_api_key_auth( - kwargs: Mapping[str, Any], -) -> Any: # any-ok: UserAPIKeyAuth is proxy-only, importing it here would create a cycle - """`/v1/messages` is a LITELLM_METADATA_ROUTE, so the auth object rides in litellm_metadata.""" - litellm_metadata = kwargs.get("litellm_metadata") or {} - metadata = kwargs.get("metadata") or {} - return ( - kwargs.get("user_api_key_auth") - or litellm_metadata.get("user_api_key_auth") - or metadata.get("user_api_key_auth") - ) - - async def anthropic_messages_with_mcp( max_tokens: int, messages: Sequence[Mapping[str, Any]], @@ -101,15 +89,18 @@ async def anthropic_messages_with_mcp( **kwargs, ) - user_api_key_auth = _resolve_user_api_key_auth(kwargs) + context = MCPRequestContext.resolve(kwargs=dict(kwargs), tools=tools) ( deduplicated_mcp_tools, tool_server_map, ) = await LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform( - user_api_key_auth, + context.user_api_key_auth, mcp_references, - litellm_trace_id=kwargs.get("litellm_trace_id"), + litellm_trace_id=context.litellm_trace_id, + mcp_auth_header=context.mcp_auth_header, + mcp_server_auth_headers=context.mcp_server_auth_headers, + request_tags=list(context.request_tags) if context.request_tags else None, ) anthropic_tools: Sequence[AnthropicMessagesTool] = tuple( @@ -149,10 +140,21 @@ async def anthropic_messages_with_mcp( tool_results = await LiteLLM_Proxy_MCP_Handler._execute_tool_calls( tool_server_map=tool_server_map, tool_calls=list(tool_use_blocks), - user_api_key_auth=user_api_key_auth, - litellm_trace_id=kwargs.get("litellm_trace_id"), + user_api_key_auth=context.user_api_key_auth, + mcp_auth_header=context.mcp_auth_header, + mcp_server_auth_headers=context.mcp_server_auth_headers, + oauth2_headers=context.oauth2_headers, + raw_headers=context.raw_headers, + litellm_call_id=context.litellm_call_id, + litellm_trace_id=context.litellm_trace_id, + 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, {"role": "assistant", "content": list(_get_response_content(response))}, diff --git a/litellm/responses/mcp/chat_completions_handler.py b/litellm/responses/mcp/chat_completions_handler.py index f2ccfd430ae..5c3e0cf0902 100644 --- a/litellm/responses/mcp/chat_completions_handler.py +++ b/litellm/responses/mcp/chat_completions_handler.py @@ -12,7 +12,7 @@ from typing import ( from litellm.responses.mcp.litellm_proxy_mcp_handler import ( LiteLLM_Proxy_MCP_Handler, ) -from litellm.responses.utils import ResponsesAPIRequestUtils +from litellm.responses.mcp.request_context import MCPRequestContext from litellm.types.utils import ModelResponse from litellm.utils import CustomStreamWrapper @@ -114,20 +114,13 @@ async def acompletion_with_mcp( **kwargs, ) - # Extract user_api_key_auth from metadata or kwargs - user_api_key_auth = kwargs.get("user_api_key_auth") or ((kwargs.get("metadata", {}) or {}).get("user_api_key_auth")) - request_tags = LiteLLM_Proxy_MCP_Handler._get_parent_request_tags(kwargs) - - # Extract MCP auth headers before fetching tools (needed for dynamic auth) - ( - mcp_auth_header, - mcp_server_auth_headers, - oauth2_headers, - raw_headers, - ) = ResponsesAPIRequestUtils.extract_mcp_headers_from_request( - secret_fields=kwargs.get("secret_fields"), - tools=tools, - ) + context = MCPRequestContext.resolve(kwargs=kwargs, tools=tools) + user_api_key_auth = context.user_api_key_auth + request_tags = list(context.request_tags) if context.request_tags else None + mcp_auth_header = context.mcp_auth_header + mcp_server_auth_headers = context.mcp_server_auth_headers + oauth2_headers = context.oauth2_headers + raw_headers = context.raw_headers # Process MCP tools (pass auth headers for dynamic auth) ( diff --git a/litellm/responses/mcp/request_context.py b/litellm/responses/mcp/request_context.py new file mode 100644 index 00000000000..fa03e677b39 --- /dev/null +++ b/litellm/responses/mcp/request_context.py @@ -0,0 +1,73 @@ +""" +The per-request context an MCP gateway handler needs. + +Listing and executing MCP tools both need the caller's identity, their MCP auth +headers, and the request's trace/tag identifiers. Every gateway surface resolves +the same set from its own kwargs, so resolving it in one place keeps a new +surface from silently dropping a field: omitting the auth headers, for instance, +still executes the tool, just with no credentials. +""" + +from dataclasses import dataclass +from typing import Any, Iterable, Mapping, Sequence, Union + + +@dataclass(frozen=True, slots=True) +class MCPRequestContext: + """Everything a gateway handler must forward to MCP tool listing and execution.""" + + user_api_key_auth: Any # any-ok: UserAPIKeyAuth is proxy-only; importing it here would create a cycle + mcp_auth_header: Union[str, None] = None + mcp_server_auth_headers: Union[Mapping[str, Mapping[str, str]], None] = None + oauth2_headers: Union[Mapping[str, str], None] = None + raw_headers: Union[Mapping[str, str], None] = None + request_tags: Union[Sequence[str], None] = None + litellm_trace_id: Union[str, None] = None + litellm_call_id: Union[str, None] = None + + @classmethod + def resolve( + cls, + kwargs: Mapping[str, Any], + tools: Union[Iterable[Any], None], + ) -> "MCPRequestContext": + """ + Build the context from a gateway handler's kwargs. + + ``user_api_key_auth`` is read from both metadata keys because routes differ: + LITELLM_METADATA_ROUTES (``/v1/messages``, ``/responses``) carry it in + ``litellm_metadata`` while ``/chat/completions`` uses ``metadata``. + """ + from litellm.responses.mcp.litellm_proxy_mcp_handler import ( + LiteLLM_Proxy_MCP_Handler, + ) + from litellm.responses.utils import ResponsesAPIRequestUtils + + litellm_metadata = kwargs.get("litellm_metadata") or {} + metadata = kwargs.get("metadata") or {} + user_api_key_auth = ( + kwargs.get("user_api_key_auth") + or litellm_metadata.get("user_api_key_auth") + or metadata.get("user_api_key_auth") + ) + + ( + mcp_auth_header, + mcp_server_auth_headers, + oauth2_headers, + raw_headers, + ) = ResponsesAPIRequestUtils.extract_mcp_headers_from_request( + secret_fields=kwargs.get("secret_fields"), + tools=tools, + ) + + return cls( + user_api_key_auth=user_api_key_auth, + mcp_auth_header=mcp_auth_header, + mcp_server_auth_headers=mcp_server_auth_headers, + oauth2_headers=oauth2_headers, + raw_headers=raw_headers, + request_tags=LiteLLM_Proxy_MCP_Handler._get_parent_request_tags(dict(kwargs)), + litellm_trace_id=kwargs.get("litellm_trace_id"), + litellm_call_id=kwargs.get("litellm_call_id"), + ) 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 3faa6b1e4e2..060c3e459d0 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 @@ -120,3 +120,124 @@ def test_build_tool_result_message_uses_anthropic_tool_result_blocks(): assert list(message["content"]) == [ {"type": "tool_result", "tool_use_id": "toolu_1", "content": "9 sections"} ] + + +@pytest.mark.asyncio +async def test_anthropic_messages_with_mcp_forwards_the_callers_mcp_credentials(): + """ + Regression test (LIT-4517): the caller's MCP auth must reach both tool listing + and tool execution on /v1/messages. + + Given: A request carrying MCP auth headers and request tags + When: The gateway lists and then executes an MCP tool + Then: Both calls receive the caller's credentials, tags and trace ids + + Dropping them does not fail loudly; the tool still executes, just with no + credentials, so every auth-requiring MCP server (interactive OAuth, bearer + token, per-user env) silently returns nothing while the model claims it has + no access. Only a no-auth server would look healthy. + """ + from litellm.llms.anthropic.experimental_pass_through.messages import mcp_handler + from litellm.responses.mcp.request_context import MCPRequestContext + + context = MCPRequestContext( + user_api_key_auth="auth-object", + mcp_auth_header="legacy-header", + mcp_server_auth_headers={"deepwiki": {"authorization": "Bearer per-server"}}, + oauth2_headers={"authorization": "Bearer oauth"}, + raw_headers={"x-trace": "abc"}, + request_tags=["team-a"], + litellm_trace_id="trace-123", + litellm_call_id="call-456", + ) + + process = AsyncMock(return_value=([], {})) + execute = AsyncMock(return_value=[{"tool_call_id": "toolu_1", "result": "ok", "name": "t"}]) + responses = [ + {"stop_reason": "tool_use", "content": [{"type": "tool_use", "id": "toolu_1", "name": "t", "input": {}}]}, + {"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( + "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, + messages=[{"role": "user", "content": "hi"}], + model="claude-sonnet-4-5", + tools=[MCP_REFERENCE], + ) + + listing = process.call_args.kwargs + assert listing["mcp_auth_header"] == "legacy-header", "tool listing must use the caller's MCP auth" + assert listing["mcp_server_auth_headers"] == {"deepwiki": {"authorization": "Bearer per-server"}} + assert listing["request_tags"] == ["team-a"] + assert listing["litellm_trace_id"] == "trace-123" + + execution = execute.call_args.kwargs + assert execution["user_api_key_auth"] == "auth-object" + assert execution["mcp_auth_header"] == "legacy-header", "tool execution must use the caller's MCP auth" + assert execution["mcp_server_auth_headers"] == {"deepwiki": {"authorization": "Bearer per-server"}} + assert execution["oauth2_headers"] == {"authorization": "Bearer oauth"} + assert execution["raw_headers"] == {"x-trace": "abc"} + assert execution["litellm_call_id"] == "call-456" + assert execution["litellm_trace_id"] == "trace-123" + assert execution["request_tags"] == ["team-a"] + + +@pytest.mark.asyncio +async def test_anthropic_messages_with_mcp_stops_when_every_tool_call_is_skipped(): + """ + Regression test (LIT-4517): a tool_use turn whose calls all get skipped must + end the loop, not send an empty tool_result message. + + Given: The model asks for a tool but the executor skips it (unresolvable name) + When: The gateway loop handles the empty result set + Then: It returns the last response instead of calling the model again + + _build_tool_result_message([]) produces a user message with empty content, and + Anthropic rejects that, so the caller would get an unhandled 400 from the middle + of the loop rather than the model's own answer. + """ + from litellm.llms.anthropic.experimental_pass_through.messages import mcp_handler + from litellm.responses.mcp.request_context import MCPRequestContext + + tool_use_response = { + "stop_reason": "tool_use", + "content": [{"type": "tool_use", "id": "toolu_1", "name": "gone", "input": {}}], + } + anthropic_messages_mock = AsyncMock(return_value=tool_use_response) + + with patch.object( + MCPRequestContext, "resolve", return_value=MCPRequestContext(user_api_key_auth="auth") + ), patch( + "litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform", + new=AsyncMock(return_value=([], {})), + ), patch( + "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, + messages=[{"role": "user", "content": "hi"}], + model="claude-sonnet-4-5", + tools=[MCP_REFERENCE], + ) + + assert anthropic_messages_mock.await_count == 1, ( + "With no tool results there is nothing to send back, so the loop must not call the model again" + ) + assert result == tool_use_response