mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
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:
parent
ae952ce971
commit
cd3ac05a1f
4 changed files with 222 additions and 33 deletions
|
|
@ -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))},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
(
|
||||
|
|
|
|||
73
litellm/responses/mcp/request_context.py
Normal file
73
litellm/responses/mcp/request_context.py
Normal 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"),
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue