From 0a051ae8ccf10b9c4f5ce5183c754cda19f1a9cf Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Tue, 20 Oct 2026 16:53:35 -0700 Subject: [PATCH] fix(mcp): list per-user auth MCP tools without a shared token (#47677) (cherry picked from commit 49b5444ef946277e85bfa3e4c71f4ba098ef6768) --- .../mcp_server/mcp_server_manager.py | 4 ++ .../outbound_credentials/adapter.py | 12 +++- .../mcp_server/test_mcp_server_manager.py | 59 ++++++++++++++++++- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 375c010a9a8..0cea03a2f68 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -4225,6 +4225,7 @@ class MCPServerManager: raw_headers: Mapping[str, str] | None = None, client_ip: str | None = None, protocol_version_override: MCPUpstreamProtocol | None = None, + for_tool_listing: bool = False, ) -> MCPClient: """ Create an MCPClient instance for the given server. @@ -4377,6 +4378,7 @@ class MCPServerManager: sampling_callback=sampling_cb, elicitation_callback=elicitation_cb, ), + for_tool_listing=for_tool_listing, ) # Create SigV4 auth if configured @@ -4421,6 +4423,7 @@ class MCPServerManager: sampling_callback=sampling_cb, elicitation_callback=elicitation_cb, ), + for_tool_listing=for_tool_listing, ) async def _get_tools_from_server( @@ -4529,6 +4532,7 @@ class MCPServerManager: user_api_key_auth=user_api_key_auth, raw_headers=raw_headers, client_ip=client_ip, + for_tool_listing=True, ) ## HANDLE OPENAPI TOOLS diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py index 42947e39530..e0448e68beb 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py @@ -427,6 +427,8 @@ def validate_static_credential( headers: Mapping[str, str], upstream_token_header: str | None = None, static_header_names: Iterable[str] = (), + *, + allow_absent: bool = False, ) -> Result[None, CredError]: if auth_type not in _STATIC_MODES: return Ok(None) @@ -442,17 +444,23 @@ def validate_static_credential( ) ) values: Final = tuple((name.lower(), value.strip()) for name, value in headers.items() if name.lower() in slots) + if allow_absent and not values: + return Ok(None) if any(_usable_credential_value(auth_type, name, value) for name, value in values): return Ok(None) return Error(CredError.of_misconfigured(f"{auth_type} requires a usable upstream credential")) -async def prepare_mcp_client(server: MCPServer, client: MCPClient) -> MCPClient: +async def prepare_mcp_client(server: MCPServer, client: MCPClient, *, for_tool_listing: bool = False) -> MCPClient: if server.auth_type not in _STATIC_MODES or client.transport_type == MCPTransport.stdio: return client request: Final = await client.prepare_request_auth() match validate_static_credential( - server.auth_type, request.headers, server.upstream_token_header, server.static_headers or () + server.auth_type, + request.headers, + server.upstream_token_header, + server.static_headers or (), + allow_absent=for_tool_listing and server.auth_type == MCPAuth.authorization, ): case Error(error): raise_public(error) diff --git a/tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 46264de738a..266903525b9 100644 --- a/tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -14047,6 +14047,7 @@ def _mcp_upstream(respond): class _DiscoveryUpstream: def __init__(self) -> None: self.requests: tuple[tuple[str, str], ...] = () + self.tools: tuple[dict[str, object], ...] = () self.outcome = "supported" self.entered = asyncio.Event() self.release = asyncio.Event() @@ -14092,7 +14093,7 @@ class _DiscoveryUpstream: "resources/templates/list": { "resourceTemplates": [{"name": "example", "uriTemplate": "test://{name}", "description": "original"}] }, - "tools/list": {"tools": []}, + "tools/list": {"tools": list(self.tools)}, }[payload.method] continuation: Final = ( {"nextCursor": "last-page"} @@ -14110,6 +14111,62 @@ def _discovery_server() -> MCPServer: return MCPServer(server_id="discovery", name="discovery", url="https://discovery.example/mcp", transport=MCPTransport.http) +@pytest.mark.asyncio +async def test_per_user_authorization_server_lists_without_static_credential() -> None: + upstream: Final = _DiscoveryUpstream() + upstream.tools = ({"name": "search", "inputSchema": {"type": "object"}},) + server: Final = MCPServer( + server_id="per-user-auth", + name="per-user-auth", + url="https://discovery.example/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.authorization, + ) + manager: Final = MCPServerManager() + manager.registry[server.server_id] = server + + with _mcp_upstream(upstream.respond): + tools: Final = await manager._get_tools_from_server(server, add_prefix=False) + + assert [tool.name for tool in tools] == ["search"] + assert ("tools/list", "") in upstream.requests + with pytest.raises(HTTPException, match="requires a usable upstream credential"): + await manager._create_mcp_client(server) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("auth_type", "credential", "upstream_token_header"), + ( + (MCPAuth.authorization, "Bearer", None), + (MCPAuth.authorization, "Bearer", "X-Token"), + (MCPAuth.authorization, " ", None), + (MCPAuth.authorization, " ", "X-Token"), + (MCPAuth.bearer_token, None, None), + ), +) +async def test_tool_listing_rejects_malformed_or_other_missing_static_credentials( + auth_type: MCPAuth, credential: str | None, upstream_token_header: str | None +) -> None: + upstream: Final = _DiscoveryUpstream() + server: Final = MCPServer( + server_id="invalid-static-auth", + name="invalid-static-auth", + url="https://discovery.example/mcp", + transport=MCPTransport.http, + auth_type=auth_type, + authentication_token=credential, + upstream_token_header=upstream_token_header, + ) + manager: Final = MCPServerManager() + manager.registry[server.server_id] = server + + with _mcp_upstream(upstream.respond), pytest.raises(MCPServerListError): + await manager._get_tools_from_server(server) + + assert upstream.requests == () + + @pytest.mark.asyncio @pytest.mark.parametrize("kind", ("prompts", "resources", "templates")) async def test_discovery_cache_reuses_raw_results_and_expires(kind: str) -> None: