fix(mcp): forward the caller's MCP credentials from every gateway surface

The /v1/messages handler resolved only the auth object and the trace id, so tool
listing and tool execution ran without the caller's MCP auth headers. That fails
quietly rather than loudly: the tool still executes, just with no credentials, so
every server behind interactive OAuth, a bearer token or per-user env vars returns
nothing while the model reports it has no access. Only a no-auth server looks
healthy, which is exactly what the first proof used.

Threading the missing arguments would have left the real problem in place. Each
gateway surface rebuilds the same context by hand (responses/main.py twice,
chat_completions_handler, mcp_streaming_iterator), which is why a new surface
drops fields; this adds a fifth that dropped six of eight. Resolve it once into a
frozen MCPRequestContext and have the handlers take that, so a field cannot be
forgotten at a call site. chat_completions_handler now uses it too, and the
resolver reads user_api_key_auth from both metadata keys because
LITELLM_METADATA_ROUTES carry it in litellm_metadata while chat uses metadata.

Also stop the loop when every tool call was skipped. tool_results is empty then,
and the tool_result message built from it has empty content, which Anthropic
rejects; the caller saw a 400 from mid-loop instead of the model's own answer.

Tests pin both: dropping the headers from either listing or execution fails, and
so does removing the empty-results guard.
This commit is contained in:
Tin Chi Lo 2026-07-16 19:00:41 -07:00
parent ae952ce971
commit cd3ac05a1f
4 changed files with 222 additions and 33 deletions

View file

@ -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))},

View file

@ -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)
(

View file

@ -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"),
)

View file

@ -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