diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index d2986a3cd82..e7ffef0e4e3 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -220,6 +220,12 @@ class MCPRequestHandler: # when EVERY target is auth_type=oauth2 with delegate_auth_to_upstream # set; fails closed otherwise. validated_user_api_key_auth = UserAPIKeyAuth() + elif MCPRequestHandler._target_servers_are_true_passthrough( + path=request_route, + mcp_servers=mcp_servers, + client_ip=IPAddressUtils.get_mcp_client_ip(request), + ): + validated_user_api_key_auth = UserAPIKeyAuth() elif oauth2_headers: # Authorization on a non-delegated server: the bearer must be a real # LiteLLM credential, so a failed validation is a genuine 401/403 and @@ -399,6 +405,33 @@ class MCPRequestHandler: return False return True + @staticmethod + def _target_servers_are_true_passthrough( + path: str, mcp_servers: Optional[list[str]], client_ip: Optional[str] + ) -> bool: + """ + True only when EVERY MCP server the request targets is ``auth_type == true_passthrough``. + Fails closed when any target does not opt in or cannot be resolved. + + Used by :meth:`process_mcp_request` to skip LiteLLM admission auth entirely: the gateway is a + transparent proxy and the caller's ``Authorization`` is an upstream token, never a LiteLLM key. + Mirrors :meth:`_target_servers_delegate_auth_to_upstream`; a mixed-target request keeps normal auth. + """ + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.types.mcp import MCPAuth + + target_names = MCPRequestHandler._resolve_target_server_names(path=path, mcp_servers_header=mcp_servers) + if not target_names: + return False + + for name in target_names: + server = global_mcp_server_manager.get_mcp_server_by_name(name, client_ip=client_ip) + if server is None or server.auth_type != MCPAuth.true_passthrough: + return False + return True + @staticmethod def _resolve_target_server_names(path: str, mcp_servers_header: Optional[List[str]]) -> List[str]: """ @@ -558,10 +591,19 @@ class MCPRequestHandler: ASGI headers are in format: List[List[bytes, bytes]] We need to convert them to the format Headers expects. + + Collapsing the ASGI list into a dict keeps the last value for a duplicated + header name, so a request carrying more than one ``Authorization`` is + rejected first: for the client-forwarded token modes the gateway relays the + caller's ``Authorization`` upstream, so a duplicate would make which token is + forwarded ambiguous (and diverge from what admission inspected). Multiple + ``Authorization`` headers is malformed for bearer auth anyway (RFC 9110: not + a comma-combinable field), so fail closed with a 400. """ + raw_headers = scope.get("headers", []) + MCPRequestHandler._reject_duplicate_authorization(raw_headers) try: # ASGI headers are list of [name: bytes, value: bytes] pairs - raw_headers = scope.get("headers", []) # Convert bytes to strings and create dict for Headers constructor headers_dict = {name.decode("latin-1"): value.decode("latin-1") for name, value in raw_headers} return Headers(headers_dict) @@ -570,6 +612,26 @@ class MCPRequestHandler: # Return empty Headers object with empty dict return Headers({}) + @staticmethod + def _reject_duplicate_authorization(raw_headers: object) -> None: + """Raise 400 when the raw ASGI headers carry more than one ``Authorization`` header.""" + if not isinstance(raw_headers, (list, tuple)): + return + count = 0 + for entry in raw_headers: + if not isinstance(entry, (list, tuple)) or len(entry) < 1: + continue + name = entry[0] + if isinstance(name, (bytes, bytearray)) and bytes(name).lower() == b"authorization": + count += 1 + elif isinstance(name, str) and name.lower() == "authorization": + count += 1 + if count > 1: + raise HTTPException( + status_code=400, + detail="Multiple Authorization headers are not allowed", + ) + @staticmethod async def get_allowed_mcp_servers( user_api_key_auth: Optional[UserAPIKeyAuth] = None, diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index d4dbee37cdc..c87e900aa2a 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -1302,11 +1302,15 @@ async def _build_oauth_protected_resource_response( """ Build OAuth protected resource response with the appropriate URL pattern. - For pass-through MCP servers (``MCPServer.is_oauth_passthrough``), the - gateway proxies the upstream's own ``oauth-protected-resource`` metadata - so that standards-compliant MCP clients discover the **upstream** IdP - instead of the gateway. The ``resource`` field is rewritten to the - gateway's own URL so clients present the bearer token back to the gateway. + For pass-through MCP servers, the gateway proxies the upstream's own + ``oauth-protected-resource`` metadata so standards-compliant MCP clients + discover the **upstream** IdP instead of the gateway. For ``true_passthrough`` + and ``oauth_delegate`` the metadata is returned verbatim (``resource`` stays + the upstream): the caller's token is forwarded to and validated by the + upstream, so its audience must be the upstream — rewriting it to the gateway + would make a strict IdP (e.g. Entra) refuse to mint it or the upstream reject + it. Only the legacy ``is_oauth_passthrough`` opt-in rewrites ``resource`` to + the gateway's own URL so clients present the bearer token back to the gateway. Args: request: FastAPI Request object @@ -1347,7 +1351,9 @@ async def _build_oauth_protected_resource_response( # Pass-through branch: proxy the upstream's own metadata so discovery # directs the client at the real IdP (Okta, Keycloak, …) instead of us. - if mcp_server is not None and mcp_server.is_oauth_passthrough: + if mcp_server is not None and ( + mcp_server.is_oauth_passthrough or mcp_server.is_oauth_delegate or mcp_server.is_true_passthrough + ): try: upstream_metadata = await fetch_upstream_oauth_protected_resource(mcp_server) except Exception as exc: @@ -1363,8 +1369,9 @@ async def _build_oauth_protected_resource_response( ) if upstream_metadata is not None: - response = {**upstream_metadata, "resource": resource_url} - return response + if mcp_server.is_true_passthrough or mcp_server.is_oauth_delegate: + return upstream_metadata + return {**upstream_metadata, "resource": resource_url} # Upstream responded but with non-200 or non-dict payload. For # pass-through servers the gateway is NOT the authorization server, diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 8e4b3c57bbb..c4ad673b88f 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -81,6 +81,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.token_exchange_ ) from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( AuthorizationCodeConfig, + PassthroughConfig, ServerSpec, TokenExchangeConfig, ) @@ -216,6 +217,13 @@ def _should_strip_caller_authorization( pass-through cold-start case (RFC 9728) the bearer in ``Authorization`` is the upstream OAuth token and must be forwarded, so we keep it. + - **oauth_delegate servers**: admission always runs and there is no + anonymous path, so the caller's separate ``Authorization`` is + forwarded only when a distinct ``x-litellm-api-key`` carried + admission. Without that header the ``Authorization`` *was* the + admission credential — a virtual key, an IdP JWT, or an SSO / OIDC / + session token whose ``api_key`` is ``None`` — and must never reach + the upstream, so it is stripped regardless of the ``api_key`` value. """ if mcp_server.auth_type == MCPAuth.oauth2_token_exchange: # OBO: the inbound Authorization is the subject token. It is exchanged at the IdP and only the @@ -229,11 +237,13 @@ def _should_strip_caller_authorization( # upstream — it would override another user's stored credential. Delegate and # pass-through return None from to_server_spec and keep forwarding the bearer. return True - if not mcp_server.is_oauth_passthrough: + if not (mcp_server.is_oauth_passthrough or mcp_server.is_oauth_delegate): return False normalized_raw_headers = {str(k).lower(): v for k, v in (raw_headers or {}).items() if isinstance(k, str)} has_explicit_litellm_admission_header = normalized_raw_headers.get("x-litellm-api-key") is not None + if mcp_server.is_oauth_delegate: + return not has_explicit_litellm_admission_header admission_consumed_authorization_as_litellm_key = ( user_api_key_auth is not None and bool(getattr(user_api_key_auth, "api_key", None)) @@ -326,6 +336,89 @@ async def _resolve_byok_mcp_auth_header( return mcp_auth_header +def _client_forwarded_authorization_headers( + mcp_server: MCPServer, + oauth2_headers: Optional[dict[str, str]], + raw_headers: Optional[dict[str, str]], + user_api_key_auth: Optional[UserAPIKeyAuth], +) -> Optional[dict[str, str]]: + """Egress headers for the client-forwarded-token modes (``true_passthrough`` / ``oauth_delegate``). + + Forwards the caller's ``Authorization`` to the upstream, stripped when + ``_should_strip_caller_authorization`` says it was consumed as the LiteLLM admission key. Shared by + ``_call_regular_mcp_tool`` and ``server.py``'s ``_prepare_mcp_server_headers`` so the two egress + paths cannot drift, mirroring the ``_should_strip_caller_authorization`` split. + """ + extra_headers = oauth2_headers.copy() if oauth2_headers else None + if extra_headers and _should_strip_caller_authorization( + mcp_server=mcp_server, + raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, + ): + return _without_authorization(extra_headers) + return extra_headers + + +def _take_forwarded_authorization( + headers: Optional[dict[str, str]], +) -> tuple[Optional[str], Optional[dict[str, str]]]: + """Pop the ``Authorization`` value out of ``headers`` (case-insensitive), returning it with the + remaining headers, so the passthrough resolver arm is the single Authorization source rather than + the header also riding in ``extra_headers`` (which the resolved auth would then defer to).""" + if not headers: + return None, headers + value = next((v for k, v in headers.items() if k.lower() == "authorization"), None) + return value, _without_authorization(headers) + + +def _passthrough_token_from_mcp_auth_header( + mcp_auth_header: Optional[Union[str, dict[str, str]]], +) -> Optional[str]: + """The caller's per-server upstream credential for a passthrough-mode server, or None. + + Sourced from ``x-mcp-{alias}-authorization`` (string or per-header dict form) or the deprecated + global ``x-mcp-auth`` fallback. Per-server headers are the multi-server shape: they bind one + token to one server, so an aggregate scope with several passthrough-mode servers never replays + a single credential across upstreams. The value is forwarded verbatim, so it must be the full + header value (e.g. ``Bearer ``).""" + if isinstance(mcp_auth_header, str): + return mcp_auth_header or None + if isinstance(mcp_auth_header, dict): + return next((v for k, v in mcp_auth_header.items() if k.lower() == "authorization"), None) + return None + + +def _consumes_caller_authorization(server: MCPServer) -> bool: + """True when this server's egress forwards the caller's request-wide ``Authorization`` upstream: + the client-forwarded token modes, legacy OAuth pass-through, and legacy upstream-delegated + interactive oauth2. An unstamped oauth2 row (flow column not yet backfilled) reads as a consumer, + which errs toward suppression — the fail-safe direction.""" + if server.is_true_passthrough or server.is_oauth_delegate or server.is_oauth_passthrough: + return True + return ( + server.auth_type == MCPAuth.oauth2 + and getattr(server, "delegate_auth_to_upstream", False) is True + and not server.has_client_credentials + ) + + +def _caller_authorization_fans_out( + server: MCPServer, + scope_servers: Optional[list[MCPServer]], +) -> bool: + """True when forwarding the caller's request-wide ``Authorization`` to ``server`` inside a + listing fan-out would replay one credential against multiple upstreams: another server in the + scope also consumes it (RFC 9700 cross-resource replay). ``scope_servers`` is None for + explicitly-addressed operations (tool call, get_prompt, read_resource, single-server routes), + where the client named the one target and the gateway is not choosing recipients.""" + if scope_servers is None: + return False + return any( + other is not None and other.server_id != server.server_id and _consumes_caller_authorization(other) + for other in scope_servers + ) + + def _extract_upstream_auth_failure( exc: BaseException, ) -> Optional[tuple[int, Optional[str]]]: @@ -1635,15 +1728,18 @@ class MCPServerManager: delegate_server_ids = [ server.server_id for server in self.get_registry().values() - if getattr(server, "auth_type", None) == MCPAuth.oauth2 - and getattr(server, "delegate_auth_to_upstream", False) is True - # M2M servers must not be exposed anonymously: an - # unauthenticated caller would get LiteLLM to proxy tool - # calls using its stored client_credentials. Resolve the flow - # rather than reading has_client_credentials so an unstamped - # M2M-shape row (null column, verbatim-read as non-M2M) still - # fails closed here, matching the anonymous-delegate auth gate. - and MCPServerManager.effective_oauth2_flow(server) != "client_credentials" + if ( + getattr(server, "auth_type", None) == MCPAuth.oauth2 + and getattr(server, "delegate_auth_to_upstream", False) is True + # M2M servers must not be exposed anonymously: an + # unauthenticated caller would get LiteLLM to proxy tool + # calls using its stored client_credentials. Resolve the flow + # rather than reading has_client_credentials so an unstamped + # M2M-shape row (null column, verbatim-read as non-M2M) still + # fails closed here, matching the anonymous-delegate auth gate. + and MCPServerManager.effective_oauth2_flow(server) != "client_credentials" + ) + or getattr(server, "auth_type", None) == MCPAuth.true_passthrough ] combined_servers.update(delegate_server_ids) @@ -2241,16 +2337,17 @@ class MCPServerManager: spec = None if transport == MCPTransport.stdio else to_server_spec(server) provider = cred_provider or self._cred_provider # A caller-supplied per-request override (mcp_auth_header / x-mcp-*) defers to the v1 path - # so it wins - except for the per-user modes the v2 resolver owns (authorization_code's - # stored token and token_exchange's RFC 8693 minted token). A caller must not be able to - # substitute another user's stored credential, nor silently disable the OBO exchange and - # forward an arbitrary bearer upstream, so we keep the v2 spec and ignore the override for - # both; the REST tools preview supplies its not-yet-persisted token through the resolver - # (cred_provider), never this path. + # so it wins - except for the modes the v2 resolver owns per-caller (authorization_code's + # stored token, token_exchange's RFC 8693 minted token, and the passthrough modes' + # forwarded caller token). A caller must not be able to substitute another user's stored + # credential, nor silently disable the OBO exchange and forward an arbitrary bearer + # upstream, so we keep the v2 spec and ignore the override for these; the REST tools + # preview supplies its not-yet-persisted token through the resolver (cred_provider), + # never this path. if ( spec is not None and mcp_auth_header - and not isinstance(spec.config, (AuthorizationCodeConfig, TokenExchangeConfig)) + and not isinstance(spec.config, (AuthorizationCodeConfig, PassthroughConfig, TokenExchangeConfig)) ): spec = None auth_value = ( @@ -2317,11 +2414,17 @@ class MCPServerManager: server_url = server.url or "" if spec is not None: + inbound_token = subject_token + if isinstance(spec.config, PassthroughConfig): + inbound_token, extra_headers = _take_forwarded_authorization(extra_headers) + per_server_token = _passthrough_token_from_mcp_auth_header(mcp_auth_header) + if per_server_token is not None: + inbound_token = per_server_token resolved_auth, extra_headers = await self._resolve_v2_auth( server=server, spec=spec, provider=provider, - subject_token=subject_token, + subject_token=inbound_token, user_api_key_auth=user_api_key_auth, extra_headers=extra_headers, ) @@ -3743,6 +3846,13 @@ class MCPServerManager: user_api_key_auth=user_api_key_auth, ): extra_headers = _without_authorization(extra_headers) + elif mcp_server.is_true_passthrough or mcp_server.is_oauth_delegate: + extra_headers = _client_forwarded_authorization_headers( + mcp_server=mcp_server, + oauth2_headers=oauth2_headers, + raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, + ) if mcp_server.extra_headers and raw_headers: if extra_headers is None: diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py index 05896bfff74..e87e8081ced 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py @@ -23,6 +23,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( AuthorizationCodeConfig, CredError, NoneConfig, + PassthroughConfig, ServerSpec, SharedKey, Subject, @@ -62,9 +63,10 @@ def to_server_spec(server: MCPServer) -> Optional[ServerSpec]: an ``assert_never`` tail, so a newly added auth mode fails the type gate here until it is explicitly mapped or explicitly deferred, rather than silently falling through to v1. Live modes: ``none``, the static-header family (``api_key`` plus the Authorization schemes, - all shared-key), ``oauth2`` per-user tokens (``authorization_code``), and - ``oauth2_token_exchange`` (OBO); client_credentials (M2M), delegated/passthrough - oauth2, and SigV4 return None and stay on v1. + all shared-key), ``oauth2`` per-user tokens (``authorization_code``), ``oauth2_token_exchange`` + (OBO), and the client-forwarded token modes ``true_passthrough`` / ``oauth_delegate`` + (``PassthroughConfig``); client_credentials (M2M), delegated/passthrough oauth2, and SigV4 + return None and stay on v1. """ if server.is_byok: return None # per-user BYOK source not migrated yet -> defer to v1 (any auth_type) @@ -94,6 +96,8 @@ def to_server_spec(server: MCPServer) -> Optional[ServerSpec]: ) # client_credentials (M2M) and delegate/passthrough oauth2 stay on v1 return None + case MCPAuth.true_passthrough | MCPAuth.oauth_delegate: + return ServerSpec(server_id=server.server_id, resource=resource, config=PassthroughConfig()) case MCPAuth.oauth2_token_exchange: return _token_exchange_spec(server, resource) case MCPAuth.aws_sigv4: diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py index c82ce1037d6..ecfd471190c 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py @@ -7,10 +7,11 @@ no precedence cascade. It is wildcard-free with an `assert_never` tail, so addin an arm fails the type gate (basedpyright `reportMatchNotExhaustive`); a bypassed gate fails loudly at runtime instead of returning `None`. -`none` and `api_key` (shared-key source) are live, as is `authorization_code`, which reads the -user's token from the injected `OAuthTokenStore`, and `token_exchange`, which swaps the caller's -inbound token through the injected `TokenExchanger`. The remaining arms are `not_implemented` stubs -that each land in a follow-up PR with their seam. Pure v2: no imports from v1. +`none`, `api_key` (shared-key source), and `passthrough` (forwards the caller's own inbound token) +are live, as is `authorization_code`, which reads the user's token from the injected +`OAuthTokenStore`, and `token_exchange`, which swaps the caller's inbound token through the +injected `TokenExchanger`. The remaining arms are `not_implemented` stubs that each land in a +follow-up PR with their seam. Pure v2: no imports from v1. """ from __future__ import annotations @@ -97,7 +98,7 @@ class UpstreamCredentialProvider: case ApiKeyConfig() as config: return self._api_key(config) case PassthroughConfig(): - return _not_implemented(AuthSpecKind.passthrough) + return self._passthrough(subject) case ClientCredentialsConfig(): return _not_implemented(AuthSpecKind.client_credentials) case TokenExchangeConfig() as config: @@ -118,6 +119,18 @@ class UpstreamCredentialProvider: """ return await self._authz_token(subject, server) is not None + def _passthrough(self, subject: Subject) -> Result[httpx.Auth, CredError]: + """Forward the caller's own upstream credential verbatim; the gateway mints nothing. + + The inbound token is the caller's already-disambiguated ``Authorization`` (never the LiteLLM + admission credential; the edge adapter drops that before building the ``Subject``). When it is + absent the request is sent unauthenticated so the upstream's own 401 surfaces, rather than the + gateway challenging on the upstream's behalf. + """ + if subject.inbound_token is None: + return Ok(NoOpAuth()) + return Ok(StaticHeaderAuth(subject.inbound_token.get_secret_value(), header_name="Authorization")) + def _api_key(self, config: ApiKeyConfig) -> Result[httpx.Auth, CredError]: match config.key_source: case SharedKey() as source: diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index c03e49a1628..55a0fa083c0 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -336,6 +336,8 @@ if MCP_AVAILABLE: ) from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( MCPServerManager, + _caller_authorization_fans_out, + _client_forwarded_authorization_headers, _should_strip_caller_authorization, _without_authorization, global_mcp_server_manager, @@ -1444,6 +1446,35 @@ if MCP_AVAILABLE: return allowed_mcp_servers + def _client_has_per_server_auth_header( + server: MCPServer, + mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]], + ) -> bool: + """True if the request carries a per-server ``x-mcp-{alias}-authorization`` + header for this server. This is the multi-server binding: it names one + upstream, so it is unambiguously the caller's upstream token regardless of + auth mode (never the LiteLLM admission credential). + + Resolves through the same ``lookup_mcp_server_auth_in_headers`` egress uses, so + the connect gate and egress agree on which per-server header names match: a + dashboard client sends ``x-mcp-{sanitize_mcp_alias_for_header(alias)}-authorization``, + and matching only the raw alias here would 401 a token egress would forward. + """ + if not mcp_server_auth_headers: + return False + from litellm.proxy._experimental.mcp_server.utils import ( + lookup_mcp_server_auth_in_headers, + ) + + server_headers = lookup_mcp_server_auth_in_headers( + mcp_server_auth_headers, alias=server.alias, server_name=server.server_name + ) + if isinstance(server_headers, str): + return bool(server_headers.strip()) + if isinstance(server_headers, dict): + return any(isinstance(hk, str) and hk.lower() == "authorization" for hk in server_headers) + return False + def _client_has_passthrough_authorization( server: MCPServer, oauth2_headers: Optional[Dict[str, str]], @@ -1461,24 +1492,7 @@ if MCP_AVAILABLE: for k in oauth2_headers.keys(): if k.lower() == "authorization": return True - if mcp_server_auth_headers: - for key in (server.alias, server.server_name, server.name): - if not key: - continue - server_headers = None - for k, v in mcp_server_auth_headers.items(): - if k.lower() == key.lower(): - server_headers = v - break - if server_headers is None: - continue - if isinstance(server_headers, str) and server_headers.strip(): - return True - if isinstance(server_headers, dict): - for hk in server_headers.keys(): - if hk.lower() == "authorization": - return True - return False + return _client_has_per_server_auth_header(server, mcp_server_auth_headers) async def _get_user_oauth_extra_headers_from_db( server: MCPServer, @@ -1533,8 +1547,16 @@ if MCP_AVAILABLE: oauth2_headers: Optional[Dict[str, str]], raw_headers: Optional[Dict[str, str]], user_api_key_auth: Optional[UserAPIKeyAuth] = None, + scope_servers: Optional[list[MCPServer]] = None, ) -> Tuple[Optional[Union[Dict[str, str], str]], Optional[Dict[str, str]]]: - """Build auth and extra headers for a server.""" + """Build auth and extra headers for a server. + + ``scope_servers`` is the full server list a fan-out handler iterates. Passing it lets the + client-forwarded token modes withhold the caller's request-wide ``Authorization`` when + another server in the scope would also receive it (``_caller_authorization_fans_out``); + explicitly-addressed operations leave it None. Per-server ``x-mcp-{alias}-authorization`` + headers are unaffected — they bind one token to one server and are the multi-server shape. + """ server_auth_header: Optional[Union[Dict[str, str], str]] = None if mcp_server_auth_headers: from litellm.proxy._experimental.mcp_server.utils import ( @@ -1548,6 +1570,16 @@ if MCP_AVAILABLE: ) extra_headers: Optional[Dict[str, str]] = None + is_client_forwarded_mode = server.is_true_passthrough or server.is_oauth_delegate + # In a multi-server listing scope the request-wide Authorization can only carry one token, + # so it is withheld from a client-forwarded server when another server in scope also consumes + # it (RFC 9700 cross-resource replay); such scopes must bind per-server via + # x-mcp-{alias}-authorization. The decision is computed once so BOTH the forwarding branch and + # the extra_headers copy loop below honor it — otherwise a server that lists Authorization in + # extra_headers would re-copy the withheld bearer from raw_headers and replay it anyway. + withhold_forwarded_authorization = is_client_forwarded_mode and _caller_authorization_fans_out( + server, scope_servers + ) if server.auth_type == MCPAuth.oauth2: # For OAuth2 M2M servers, upstream Authorization must come from # client_credentials token fetch, never from caller headers. @@ -1566,6 +1598,14 @@ if MCP_AVAILABLE: user_api_key_auth=user_api_key_auth, ): extra_headers = _without_authorization(extra_headers) + elif is_client_forwarded_mode: + if not withhold_forwarded_authorization: + extra_headers = _client_forwarded_authorization_headers( + mcp_server=server, + oauth2_headers=oauth2_headers, + raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, + ) if server.extra_headers and raw_headers: if extra_headers is None: @@ -1586,7 +1626,9 @@ if MCP_AVAILABLE: for header in server.extra_headers: if not isinstance(header, str): continue - if header.lower() == "authorization" and strip_caller_authorization: + if header.lower() == "authorization" and ( + strip_caller_authorization or withhold_forwarded_authorization + ): continue header_value = normalized_raw_headers.get(header.lower()) if header_value is None: @@ -1790,6 +1832,7 @@ if MCP_AVAILABLE: oauth2_headers=oauth2_headers, raw_headers=raw_headers, user_api_key_auth=user_api_key_auth, + scope_servers=allowed_mcp_servers, ) # Prefer server-stored per-user OAuth when configured, so a stale @@ -1976,6 +2019,7 @@ if MCP_AVAILABLE: oauth2_headers=oauth2_headers, raw_headers=raw_headers, user_api_key_auth=user_api_key_auth, + scope_servers=allowed_mcp_servers, ) try: @@ -2028,6 +2072,7 @@ if MCP_AVAILABLE: oauth2_headers=oauth2_headers, raw_headers=raw_headers, user_api_key_auth=user_api_key_auth, + scope_servers=allowed_mcp_servers, ) try: @@ -2078,6 +2123,7 @@ if MCP_AVAILABLE: oauth2_headers=oauth2_headers, raw_headers=raw_headers, user_api_key_auth=user_api_key_auth, + scope_servers=allowed_mcp_servers, ) try: @@ -3582,6 +3628,41 @@ if MCP_AVAILABLE: headers={"www-authenticate": www_authenticate}, ) + if ( + server + and server.is_oauth_delegate + and len(mcp_servers or []) == 1 + and _get_forwarded_auth_from_scope(scope) is None + and not _client_has_per_server_auth_header(server, mcp_server_auth_headers) + ): + www_authenticate = _get_passthrough_www_authenticate( + scope=scope, + server_name=server_name, + ) + raise HTTPException( + status_code=401, + detail="Unauthorized", + headers={"www-authenticate": www_authenticate}, + ) + + if ( + server + and server.is_true_passthrough + and len(mcp_servers or []) == 1 + and not _scope_has_authorization_header(scope) + and not _client_has_per_server_auth_header(server, mcp_server_auth_headers) + ): + upstream_status, upstream_www_authenticate = await _probe_upstream_auth(server.url or "", "") + if upstream_status == 401 and upstream_www_authenticate: + raise HTTPException( + status_code=401, + detail="Unauthorized", + headers={"www-authenticate": upstream_www_authenticate}, + ) + + def _scope_has_authorization_header(scope: Scope) -> bool: + return any(key.lower() == b"authorization" for key, _ in scope.get("headers", [])) + def _get_forwarded_auth_from_scope(scope: Scope) -> Optional[str]: """Return the upstream-bound ``Authorization`` header value, or None. @@ -3609,7 +3690,7 @@ if MCP_AVAILABLE: url: str, auth_header: str, timeout: float = 5.0, - ) -> tuple: + ) -> tuple[int, Optional[str]]: """JSON-RPC initialize-probe the upstream URL to check whether the token is accepted. Uses POST so StreamableHTTP MCP servers run the same auth path as a @@ -3639,8 +3720,8 @@ if MCP_AVAILABLE: }, } probe_headers = { - "Authorization": auth_header, "Accept": "application/json, text/event-stream", + **({"Authorization": auth_header} if auth_header else {}), } try: resp = await client.post( diff --git a/litellm/types/mcp.py b/litellm/types/mcp.py index 9c564a3c7a6..d273e8ec4db 100644 --- a/litellm/types/mcp.py +++ b/litellm/types/mcp.py @@ -38,6 +38,8 @@ class MCPAuth(str, enum.Enum): aws_sigv4 = "aws_sigv4" token = "token" oauth2_token_exchange = "oauth2_token_exchange" + true_passthrough = "true_passthrough" + oauth_delegate = "oauth_delegate" # RFC 8693 default subject_token_type. A NULL column / omitted config key means @@ -60,6 +62,8 @@ MCPAuthType = Optional[ MCPAuth.aws_sigv4, MCPAuth.token, MCPAuth.oauth2_token_exchange, + MCPAuth.true_passthrough, + MCPAuth.oauth_delegate, ] ] diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 522a6f09165..f102ab5b7b9 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -152,6 +152,18 @@ class MCPServer(BaseModel): """True if this is an OAuth2 server that relies on per-user tokens (no client_credentials).""" return self.auth_type == MCPAuth.oauth2 and not self.has_client_credentials + @property + def is_true_passthrough(self) -> bool: + """True for the transparent-proxy mode: LiteLLM performs no admission auth and forwards the + client's ``Authorization`` to the upstream unchanged.""" + return self.auth_type == MCPAuth.true_passthrough + + @property + def is_oauth_delegate(self) -> bool: + """True for the delegated-upstream-OAuth mode: LiteLLM still admits the caller (API key / SSO / + JWT) but forwards the caller's separate upstream ``Authorization`` unchanged, minting nothing.""" + return self.auth_type == MCPAuth.oauth_delegate + @property def requires_per_user_auth(self) -> bool: """ @@ -167,6 +179,9 @@ class MCPServer(BaseModel): if self.needs_user_oauth_token: return True + if self.is_true_passthrough or self.is_oauth_delegate: + return True + # PAT passthrough: auth_type is none but extra_headers includes auth headers if self.auth_type == MCPAuth.none and self.extra_headers: auth_header_names = {"authorization", "x-api-key", "api-key", "apikey"} diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index 3db0f8540f9..ebaa6bc7cc0 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -1,14 +1,13 @@ import json import os import sys -from unittest.mock import AsyncMock, MagicMock, call as mock_call, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from fastapi import HTTPException from fastapi.testclient import TestClient -sys.path.insert( - 0, os.path.abspath("../../../..") -) # Adds the parent directory to the system path +sys.path.insert(0, os.path.abspath("../../../..")) # Adds the parent directory to the system path from starlette.datastructures import Headers @@ -78,20 +77,14 @@ class TestMCPRequestHandler: ) # Mock the helper methods instead of database calls - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_key" - ) as mock_key_servers: - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_team" - ) as mock_team_servers: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_key") as mock_key_servers: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_team") as mock_team_servers: # Set up return values mock_key_servers.return_value = key_servers mock_team_servers.return_value = team_servers # Call the method - result = await MCPRequestHandler.get_allowed_mcp_servers( - user_api_key_auth=mock_user_auth - ) + result = await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth=mock_user_auth) # Assert the result (order-independent comparison) assert sorted(result) == sorted(expected_result) @@ -148,20 +141,14 @@ class TestMCPRequestHandler: ) # Mock the helper functions - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_key" - ) as mock_key_servers: - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_team" - ) as mock_team_servers: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_key") as mock_key_servers: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_team") as mock_team_servers: # Configure mocks to return the test data mock_key_servers.return_value = key_servers mock_team_servers.return_value = team_servers # Call the method - result = await MCPRequestHandler.get_allowed_mcp_servers( - user_api_key_auth - ) + result = await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth) # Assert the result (order-independent comparison) assert sorted(result) == sorted(expected_servers) @@ -186,9 +173,7 @@ class TestMCPRequestHandler: ): """The require_key_mcp_access_defined general setting flips an empty key from inheriting its team's MCP servers (default) to inheriting none.""" - auth = UserAPIKeyAuth( - api_key="test-key", user_id="test-user", team_id="test-team" - ) + auth = UserAPIKeyAuth(api_key="test-key", user_id="test-user", team_id="test-team") with ( patch.object( MCPRequestHandler, @@ -272,23 +257,22 @@ class TestMCPRequestHandler: async def test_no_mcp_servers_sentinel_returns_empty(self, team_servers): """A key scoped to the no-mcp-servers sentinel resolves to zero servers, overriding team inheritance and never leaking the sentinel marker.""" - user_api_key_auth = UserAPIKeyAuth( - api_key="test-key", user_id="test-user", team_id="test-team" - ) + user_api_key_auth = UserAPIKeyAuth(api_key="test-key", user_id="test-user", team_id="test-team") key_object_permission = MagicMock() - key_object_permission.mcp_servers = [ - SpecialMCPServerNames.no_mcp_servers.value - ] + key_object_permission.mcp_servers = [SpecialMCPServerNames.no_mcp_servers.value] - with patch.object( - MCPRequestHandler, - "_get_key_object_permission", - return_value=key_object_permission, - ), patch.object( - MCPRequestHandler, - "_get_allowed_mcp_servers_for_team", - new_callable=AsyncMock, - return_value=team_servers, + with ( + patch.object( + MCPRequestHandler, + "_get_key_object_permission", + return_value=key_object_permission, + ), + patch.object( + MCPRequestHandler, + "_get_allowed_mcp_servers_for_team", + new_callable=AsyncMock, + return_value=team_servers, + ), ): result = await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth) @@ -309,9 +293,7 @@ class TestMCPRequestHandler: "_get_key_object_permission", return_value=key_object_permission, ): - result = await MCPRequestHandler._get_allowed_mcp_servers_for_key( - user_api_key_auth - ) + result = await MCPRequestHandler._get_allowed_mcp_servers_for_key(user_api_key_auth) assert result == [SpecialMCPServerNames.no_mcp_servers.value] @@ -320,9 +302,7 @@ class TestMCPRequestHandler: # Test case: None values in database mock_prisma_client = MagicMock() - mock_prisma_client.db.litellm_objectpermissiontable.find_unique.return_value = ( - None - ) + mock_prisma_client.db.litellm_objectpermissiontable.find_unique.return_value = None mock_prisma_client.db.litellm_teamtable.find_unique.return_value = None user_api_key_auth = UserAPIKeyAuth( @@ -337,9 +317,7 @@ class TestMCPRequestHandler: assert result == [] # Test case: Exception handling - mock_prisma_client.db.litellm_objectpermissiontable.find_unique.side_effect = ( - Exception("DB Error") - ) + mock_prisma_client.db.litellm_objectpermissiontable.find_unique.side_effect = Exception("DB Error") with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client): result = await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth) @@ -384,15 +362,9 @@ class TestMCPRequestHandler: access_group_ids=["grp-mcp"], ) with ( - patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_key" - ) as mock_key, - patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_team" - ) as mock_team, - patch.object( - MCPRequestHandler, "_get_key_access_group_mcp_server_extras" - ) as mock_grants, + patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_key") as mock_key, + patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_team") as mock_team, + patch.object(MCPRequestHandler, "_get_key_access_group_mcp_server_extras") as mock_grants, ): mock_key.return_value = key_servers mock_team.return_value = team_servers @@ -414,13 +386,9 @@ class TestMCPRequestHandler: "litellm.proxy.auth.auth_checks._get_mcp_server_ids_from_access_groups", new=AsyncMock(return_value=[]), ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): - result = await MCPRequestHandler._get_key_access_group_mcp_server_extras( - auth - ) + result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(auth) assert result == [] # expand_permission_list must not be reached when there are no raw ids. mock_mgr.expand_permission_list.assert_not_called() @@ -433,14 +401,10 @@ class TestMCPRequestHandler: "litellm.proxy.auth.auth_checks._get_mcp_server_ids_from_access_groups", new=AsyncMock(return_value=["alias-a", "srv-b"]), ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.expand_permission_list.return_value = ["srv-a", "srv-b"] - result = await MCPRequestHandler._get_key_access_group_mcp_server_extras( - auth - ) + result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(auth) assert sorted(result) == ["srv-a", "srv-b"] mock_mgr.expand_permission_list.assert_called_once_with(["alias-a", "srv-b"]) @@ -451,9 +415,7 @@ class TestMCPRequestHandler: "litellm.proxy.auth.auth_checks._get_mcp_server_ids_from_access_groups", new=AsyncMock(side_effect=Exception("db down")), ): - result = await MCPRequestHandler._get_key_access_group_mcp_server_extras( - auth - ) + result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(auth) assert result == [] @pytest.mark.parametrize( @@ -743,9 +705,7 @@ class TestMCPRequestHandler: # Verify MCP servers mcp_servers_header = extracted_headers.get(SpecialHeaders.mcp_servers.value) mcp_servers = None - if ( - mcp_servers_header is not None - ): # Changed from 'if mcp_servers_header:' to handle empty strings + if mcp_servers_header is not None: # Changed from 'if mcp_servers_header:' to handle empty strings try: # First try to parse as JSON array for backward compatibility try: @@ -754,16 +714,12 @@ class TestMCPRequestHandler: mcp_servers = None except (json.JSONDecodeError, TypeError, ValueError): # If JSON parsing fails, treat as comma-separated list - mcp_servers = [ - s.strip() for s in mcp_servers_header.split(",") if s.strip() - ] + mcp_servers = [s.strip() for s in mcp_servers_header.split(",") if s.strip()] except Exception: mcp_servers = None # If we got an empty string or parsing resulted in no servers, return empty list - if mcp_servers_header == "" or ( - mcp_servers is not None and len(mcp_servers) == 0 - ): + if mcp_servers_header == "" or (mcp_servers is not None and len(mcp_servers) == 0): mcp_servers = [] assert mcp_servers == expected_result["mcp_servers"] @@ -795,6 +751,40 @@ class TestMCPRequestHandler: # For these tests, mcp_server_auth_headers should be empty assert mcp_server_auth_headers == {} + def test_duplicate_authorization_header_is_rejected(self): + """A request carrying more than one Authorization header is malformed for bearer auth and, + for the client-forwarded token modes, would make which upstream token is forwarded ambiguous. + The ingress header converter must reject it with a 400 rather than silently keeping one.""" + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/tp_server", + "headers": [ + (b"authorization", b"Bearer upstream-token-a"), + (b"authorization", b"Bearer upstream-token-b"), + (b"content-type", b"application/json"), + ], + } + with pytest.raises(HTTPException) as exc_info: + MCPRequestHandler._safe_get_headers_from_scope(scope) + assert exc_info.value.status_code == 400 + assert "Authorization" in str(exc_info.value.detail) + + def test_single_authorization_header_is_forwarded_verbatim(self): + """The rejection must not disturb the normal single-Authorization case: the value passes + through unchanged (guards against the duplicate check over-matching).""" + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/tp_server", + "headers": [ + (b"authorization", b"Bearer upstream-token"), + (b"content-type", b"application/json"), + ], + } + headers = MCPRequestHandler._safe_get_headers_from_scope(scope) + assert headers.get("authorization") == "Bearer upstream-token" + @pytest.mark.asyncio class TestMCPOAuth2AuthFlow: @@ -833,9 +823,7 @@ class TestMCPOAuth2AuthFlow: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", new_callable=AsyncMock, ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = oauth2_server ( @@ -851,10 +839,7 @@ class TestMCPOAuth2AuthFlow: # The upstream token is never validated as a LiteLLM key ... mock_auth.assert_not_called() # ... and is preserved for upstream forwarding. - assert ( - oauth2_headers.get("Authorization") - == "Bearer atlassian-oauth2-access-token-xyz" - ) + assert oauth2_headers.get("Authorization") == "Bearer atlassian-oauth2-access-token-xyz" async def test_explicit_litellm_key_with_oauth2_authorization(self): """ @@ -893,9 +878,7 @@ class TestMCPOAuth2AuthFlow: assert call_args.kwargs["api_key"] == "sk-litellm-valid-key" # OAuth2 headers should still contain the Authorization token - assert ( - oauth2_headers.get("Authorization") == "Bearer atlassian-oauth2-token" - ) + assert oauth2_headers.get("Authorization") == "Bearer atlassian-oauth2-token" async def test_litellm_key_in_authorization_backward_compat(self): """ @@ -997,9 +980,7 @@ class TestMCPOAuth2AuthFlow: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_proxy_exception, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = oauth2_server with pytest.raises(ProxyException) as exc_info: @@ -1068,9 +1049,7 @@ class TestMCPPublicRouteGuard: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): # Explicit unresolvable target — proves auth still fails even # when the registry has no info to fall back to. @@ -1101,9 +1080,7 @@ class TestMCPPublicRouteGuard: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = None with pytest.raises(HTTPException) as exc_info: @@ -1157,9 +1134,7 @@ class TestMCPPassthroughColdStartAdmission: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, patch( "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp._is_mcp_passthrough_cold_start" ) as mock_cold_start, @@ -1199,9 +1174,7 @@ class TestMCPPassthroughColdStartAdmission: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = ( TestMCPPassthroughColdStartAdmission._make_passthrough_server() @@ -1229,9 +1202,7 @@ class TestMCPPassthroughColdStartAdmission: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = ( TestMCPPassthroughColdStartAdmission._make_passthrough_server() @@ -1263,18 +1234,14 @@ class TestMCPPassthroughColdStartAdmission: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.IPAddressUtils.get_mcp_client_ip", return_value="203.0.113.10", ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = None with pytest.raises(HTTPException) as exc_info: await MCPRequestHandler.process_mcp_request(scope) assert exc_info.value.status_code == 401 - mock_mgr.get_mcp_server_by_name.assert_any_call( - "passthrough_server", client_ip="203.0.113.10" - ) + mock_mgr.get_mcp_server_by_name.assert_any_call("passthrough_server", client_ip="203.0.113.10") async def test_cold_start_propagates_non_401_http_error(self): from fastapi import HTTPException @@ -1294,9 +1261,7 @@ class TestMCPPassthroughColdStartAdmission: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_forbidden, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = ( TestMCPPassthroughColdStartAdmission._make_passthrough_server() @@ -1329,9 +1294,7 @@ class TestMCPPassthroughColdStartAdmission: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_server_error, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = ( TestMCPPassthroughColdStartAdmission._make_passthrough_server() @@ -1357,9 +1320,7 @@ class TestMCPPassthroughColdStartAdmission: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = ( TestMCPPassthroughColdStartAdmission._make_passthrough_server() @@ -1367,9 +1328,7 @@ class TestMCPPassthroughColdStartAdmission: auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) assert isinstance(auth_result, UserAPIKeyAuth) - mock_mgr.get_mcp_server_by_name.assert_any_call( - "passthrough_server", client_ip="" - ) + mock_mgr.get_mcp_server_by_name.assert_any_call("passthrough_server", client_ip="") async def test_cold_start_allows_proxy_exception_401_for_path_target(self): from litellm.proxy._types import ProxyException @@ -1394,9 +1353,7 @@ class TestMCPPassthroughColdStartAdmission: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = ( TestMCPPassthroughColdStartAdmission._make_passthrough_server() @@ -1404,9 +1361,7 @@ class TestMCPPassthroughColdStartAdmission: auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) assert isinstance(auth_result, UserAPIKeyAuth) - mock_mgr.get_mcp_server_by_name.assert_any_call( - "passthrough_server", client_ip="" - ) + mock_mgr.get_mcp_server_by_name.assert_any_call("passthrough_server", client_ip="") @pytest.mark.asyncio @@ -1450,12 +1405,10 @@ class TestMCPOAuth2FallbackTargetGating: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): - mock_mgr.get_mcp_server_by_name.return_value = ( - TestMCPOAuth2FallbackTargetGating._make_server(MCPAuth.api_key) + mock_mgr.get_mcp_server_by_name.return_value = TestMCPOAuth2FallbackTargetGating._make_server( + MCPAuth.api_key ) with pytest.raises(HTTPException) as exc_info: await MCPRequestHandler.process_mcp_request(scope) @@ -1483,9 +1436,7 @@ class TestMCPOAuth2FallbackTargetGating: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = None with pytest.raises(HTTPException) as exc_info: @@ -1523,12 +1474,10 @@ class TestMCPOAuth2FallbackTargetGating: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): - mock_mgr.get_mcp_server_by_name.return_value = ( - TestMCPOAuth2FallbackTargetGating._make_server(MCPAuth.oauth2) + mock_mgr.get_mcp_server_by_name.return_value = TestMCPOAuth2FallbackTargetGating._make_server( + MCPAuth.oauth2 ) with pytest.raises(HTTPException) as exc_info: await MCPRequestHandler.process_mcp_request(scope) @@ -1562,15 +1511,11 @@ class TestMCPOAuth2FallbackTargetGating: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): - mock_mgr.get_mcp_server_by_name.return_value = ( - TestMCPOAuth2FallbackTargetGating._make_server( - auth_type=MCPAuth.none, - is_oauth_passthrough=True, - ) + mock_mgr.get_mcp_server_by_name.return_value = TestMCPOAuth2FallbackTargetGating._make_server( + auth_type=MCPAuth.none, + is_oauth_passthrough=True, ) auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) assert isinstance(auth_result, UserAPIKeyAuth) @@ -1598,9 +1543,7 @@ class TestMCPOAuth2FallbackTargetGating: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.IPAddressUtils.get_mcp_client_ip", return_value="203.0.113.10", ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = None with pytest.raises(HTTPException) as exc_info: @@ -1612,9 +1555,7 @@ class TestMCPOAuth2FallbackTargetGating: # resolve to ``None`` (hidden by client IP) so neither bypass # opens. Use ``assert_any_call`` to assert the IP-scoped lookup # happened without locking the count. - mock_mgr.get_mcp_server_by_name.assert_any_call( - "hidden_oauth2_server", client_ip="203.0.113.10" - ) + mock_mgr.get_mcp_server_by_name.assert_any_call("hidden_oauth2_server", client_ip="203.0.113.10") async def test_fallback_blocked_when_any_target_in_header_is_not_oauth2(self): """ @@ -1649,9 +1590,7 @@ class TestMCPOAuth2FallbackTargetGating: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.side_effect = mock_lookup with pytest.raises(HTTPException) as exc_info: @@ -1732,17 +1671,10 @@ class TestMCPDelegateAuthToUpstream: delegate_auth_to_upstream=True, available_on_public_internet=True, ) - assert ( - manager._build_mcp_server_table(delegated).delegate_auth_to_upstream is True - ) + assert manager._build_mcp_server_table(delegated).delegate_auth_to_upstream is True - not_delegated = delegated.model_copy( - update={"delegate_auth_to_upstream": False} - ) - assert ( - manager._build_mcp_server_table(not_delegated).delegate_auth_to_upstream - is False - ) + not_delegated = delegated.model_copy(update={"delegate_auth_to_upstream": False}) + assert manager._build_mcp_server_table(not_delegated).delegate_auth_to_upstream is False def test_build_mcp_server_table_preserves_oauth_passthrough(self): """Registry → API list rows must expose oauth_passthrough for the UI. @@ -1773,9 +1705,7 @@ class TestMCPDelegateAuthToUpstream: assert row.delegate_auth_to_upstream is False not_passthrough = passthrough.model_copy(update={"oauth_passthrough": False}) - assert ( - manager._build_mcp_server_table(not_passthrough).oauth_passthrough is False - ) + assert manager._build_mcp_server_table(not_passthrough).oauth_passthrough is False async def test_delegate_skips_litellm_auth_with_no_authorization(self): """ @@ -1796,15 +1726,11 @@ class TestMCPDelegateAuthToUpstream: patch( "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): - mock_mgr.get_mcp_server_by_name.return_value = ( - TestMCPDelegateAuthToUpstream._make_server( - auth_type=MCPAuth.oauth2, - delegate_auth_to_upstream=True, - ) + mock_mgr.get_mcp_server_by_name.return_value = TestMCPDelegateAuthToUpstream._make_server( + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, ) auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) assert isinstance(auth_result, UserAPIKeyAuth) @@ -1834,15 +1760,11 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", new_callable=AsyncMock, ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): - mock_mgr.get_mcp_server_by_name.return_value = ( - TestMCPDelegateAuthToUpstream._make_server( - auth_type=MCPAuth.oauth2, - delegate_auth_to_upstream=True, - ) + mock_mgr.get_mcp_server_by_name.return_value = TestMCPDelegateAuthToUpstream._make_server( + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, ) ( auth_result, @@ -1880,15 +1802,11 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): - mock_mgr.get_mcp_server_by_name.return_value = ( - TestMCPDelegateAuthToUpstream._make_server( - auth_type=MCPAuth.oauth2, - delegate_auth_to_upstream=False, - ) + mock_mgr.get_mcp_server_by_name.return_value = TestMCPDelegateAuthToUpstream._make_server( + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=False, ) with pytest.raises(HTTPException) as exc_info: await MCPRequestHandler.process_mcp_request(scope) @@ -1919,15 +1837,11 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): - mock_mgr.get_mcp_server_by_name.return_value = ( - TestMCPDelegateAuthToUpstream._make_server( - auth_type=MCPAuth.api_key, - delegate_auth_to_upstream=True, - ) + mock_mgr.get_mcp_server_by_name.return_value = TestMCPDelegateAuthToUpstream._make_server( + auth_type=MCPAuth.api_key, + delegate_auth_to_upstream=True, ) with pytest.raises(HTTPException) as exc_info: await MCPRequestHandler.process_mcp_request(scope) @@ -1971,9 +1885,7 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.side_effect = mock_lookup with pytest.raises(HTTPException) as exc_info: @@ -2003,9 +1915,7 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = None with pytest.raises(HTTPException) as exc_info: @@ -2034,15 +1944,11 @@ class TestMCPDelegateAuthToUpstream: new_callable=AsyncMock, return_value=UserAPIKeyAuth(user_id="real-user"), ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): - mock_mgr.get_mcp_server_by_name.return_value = ( - TestMCPDelegateAuthToUpstream._make_server( - auth_type=MCPAuth.oauth2, - delegate_auth_to_upstream=True, - ) + mock_mgr.get_mcp_server_by_name.return_value = TestMCPDelegateAuthToUpstream._make_server( + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, ) auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) assert isinstance(auth_result, UserAPIKeyAuth) @@ -2074,15 +1980,11 @@ class TestMCPDelegateAuthToUpstream: new_callable=AsyncMock, return_value=UserAPIKeyAuth(user_id="real-user"), ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): - mock_mgr.get_mcp_server_by_name.return_value = ( - TestMCPDelegateAuthToUpstream._make_server( - auth_type=MCPAuth.oauth2, - delegate_auth_to_upstream=True, - ) + mock_mgr.get_mcp_server_by_name.return_value = TestMCPDelegateAuthToUpstream._make_server( + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, ) ( auth_result, @@ -2135,9 +2037,7 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_auth_raises, ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = m2m_server # No delegate bypass → normal auth is attempted → 401 raised @@ -2188,9 +2088,7 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_auth_raises, ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = legacy_m2m_server with pytest.raises(HTTPException) as exc_info: @@ -2235,9 +2133,7 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_auth_raises, ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = pkce_server auth, *_rest = await MCPRequestHandler.process_mcp_request(scope) @@ -2278,9 +2174,7 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_auth_raises, ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = internal_server auth, *_rest = await MCPRequestHandler.process_mcp_request(scope) @@ -2426,6 +2320,104 @@ class TestMCPDelegateAuthToUpstream: assert "public-server" in result assert "internal-server" in result + async def test_true_passthrough_skips_litellm_auth_anonymously(self): + """auth_type=true_passthrough performs no admission auth: the caller's Authorization is an + upstream token forwarded unchanged and user_api_key_auth is never called.""" + from litellm.types.mcp import MCPAuth + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/true_passthrough_server", + "headers": [(b"authorization", b"Bearer upstream-token")], + } + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + new_callable=AsyncMock, + ) as mock_auth, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, + ): + mock_mgr.get_mcp_server_by_name.return_value = TestMCPDelegateAuthToUpstream._make_server( + auth_type=MCPAuth.true_passthrough, + ) + ( + auth_result, + _, + _, + _, + oauth2_headers, + _, + ) = await MCPRequestHandler.process_mcp_request(scope) + assert isinstance(auth_result, UserAPIKeyAuth) + assert auth_result.api_key is None + assert oauth2_headers.get("Authorization") == "Bearer upstream-token" + mock_auth.assert_not_called() + + async def test_true_passthrough_mixed_targets_fail_closed(self): + """One true_passthrough target mixed with a non-passthrough target must NOT skip admission.""" + from fastapi import HTTPException + + from litellm.types.mcp import MCPAuth + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [(b"x-mcp-servers", b"tp_server,plain_server")], + } + + async def mock_user_api_key_auth_fails(api_key, request): + raise HTTPException(status_code=401, detail="Invalid API key") + + def mock_lookup(name, client_ip=None): + if name == "tp_server": + return TestMCPDelegateAuthToUpstream._make_server( + auth_type=MCPAuth.true_passthrough, + ) + return TestMCPDelegateAuthToUpstream._make_server(auth_type=MCPAuth.api_key) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + side_effect=mock_user_api_key_auth_fails, + ), + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, + ): + mock_mgr.get_mcp_server_by_name.side_effect = mock_lookup + with pytest.raises(HTTPException) as exc_info: + await MCPRequestHandler.process_mcp_request(scope) + assert exc_info.value.status_code == 401 + + async def test_get_allowed_servers_includes_true_passthrough(self): + """Anonymous callers can reach true_passthrough servers; admission is delegated upstream.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + MCPServerManager, + ) + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + manager = MCPServerManager() + tp_server = MCPServer( + server_id="tp-server", + name="tp_server", + transport="http", + auth_type=MCPAuth.true_passthrough, + available_on_public_internet=True, + ) + manager.registry = {tp_server.server_id: tp_server} + + with patch.object( + MCPRequestHandler, + "get_allowed_mcp_servers", + new_callable=AsyncMock, + return_value=[], + ): + result = await manager.get_allowed_mcp_servers(None) + + assert "tp-server" in result + def test_extract_target_server_names_matches_routing_parser(self): """ Regression: _extract_target_server_names_from_path must match the @@ -2464,13 +2456,12 @@ class TestMCPDelegateAuthToUpstream: ("/", []), ] for path_input, expected in cases: - assert ( - MCPRequestHandler._extract_target_server_names_from_path(path_input) - == expected - ), f"path={path_input!r} → expected {expected!r}" - assert ( - _get_mcp_servers_in_path(path_input) or [] - ) == expected, f"path={path_input!r} → routing expected {expected!r}" + assert MCPRequestHandler._extract_target_server_names_from_path(path_input) == expected, ( + f"path={path_input!r} → expected {expected!r}" + ) + assert (_get_mcp_servers_in_path(path_input) or []) == expected, ( + f"path={path_input!r} → routing expected {expected!r}" + ) async def test_delegate_does_not_bypass_on_extra_path_segment(self): """ @@ -2514,9 +2505,7 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_auth_raises, ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.side_effect = lookup_by_name with pytest.raises(HTTPException) as exc_info: @@ -2579,9 +2568,7 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_auth_raises, ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.side_effect = lookup_by_name # Bypass MUST NOT fire — path-derived target is the non-delegate @@ -2601,15 +2588,12 @@ class TestMCPDelegateAuthToUpstream: empty-list case, which fails closed). """ # Path matches /mcp/... — header is ignored. - assert MCPRequestHandler._resolve_target_server_names( - path="/mcp/foo", mcp_servers_header=["evil"] - ) == ["foo"] - assert MCPRequestHandler._resolve_target_server_names( - path="/mcp/foo,bar", mcp_servers_header=["evil"] - ) == ["foo", "bar"] - assert MCPRequestHandler._resolve_target_server_names( - path="/foo/mcp", mcp_servers_header=["evil"] - ) == ["foo"] + assert MCPRequestHandler._resolve_target_server_names(path="/mcp/foo", mcp_servers_header=["evil"]) == ["foo"] + assert MCPRequestHandler._resolve_target_server_names(path="/mcp/foo,bar", mcp_servers_header=["evil"]) == [ + "foo", + "bar", + ] + assert MCPRequestHandler._resolve_target_server_names(path="/foo/mcp", mcp_servers_header=["evil"]) == ["foo"] # Path does not match — header is trusted. assert MCPRequestHandler._resolve_target_server_names( path="/.well-known/oauth-authorization-server", @@ -2653,16 +2637,12 @@ class TestMCPCustomHeaderName: (None, "", "x-mcp-auth"), ], ) - def test_get_mcp_client_side_auth_header_name( - self, env_var, general_setting, expected_header_name - ): + def test_get_mcp_client_side_auth_header_name(self, env_var, general_setting, expected_header_name): """Test that custom header name configuration works correctly""" # Mock the secret manager and general settings with patch("litellm.secret_managers.main.get_secret_str") as mock_get_secret: - with patch( - "litellm.proxy.proxy_server.general_settings" - ) as mock_general_settings: + with patch("litellm.proxy.proxy_server.general_settings") as mock_general_settings: # Configure mocks mock_get_secret.return_value = env_var mock_general_settings.get.return_value = general_setting @@ -2685,9 +2665,7 @@ class TestMCPCustomHeaderName: if env_var is None: # When env var is None, general settings should be checked (twice if not None) expected_general_calls = 2 if general_setting is not None else 1 - assert ( - mock_general_settings.get.call_count == expected_general_calls - ) + assert mock_general_settings.get.call_count == expected_general_calls for call in mock_general_settings.get.call_args_list: assert call.args == ("mcp_client_side_auth_header_name",) else: @@ -2728,9 +2706,7 @@ class TestMCPCustomHeaderName: ), ], ) - def test_get_mcp_auth_header_from_headers_with_custom_name( - self, custom_header_name, headers, expected_auth_header - ): + def test_get_mcp_auth_header_from_headers_with_custom_name(self, custom_header_name, headers, expected_auth_header): """Test that MCP auth header extraction uses custom header name""" # Mock the header name method @@ -2749,9 +2725,7 @@ class TestMCPCustomHeaderName: extracted_headers = MCPRequestHandler._safe_get_headers_from_scope(scope) # Call the method - result = MCPRequestHandler._get_mcp_auth_header_from_headers( - extracted_headers - ) + result = MCPRequestHandler._get_mcp_auth_header_from_headers(extracted_headers) # Assert the result assert result == expected_auth_header @@ -2818,9 +2792,7 @@ class TestMCPCustomHeaderName: from starlette.datastructures import Headers # Test case 1: No server-specific headers - headers = Headers( - {"x-litellm-api-key": "test-key", "content-type": "application/json"} - ) + headers = Headers({"x-litellm-api-key": "test-key", "content-type": "application/json"}) result = MCPRequestHandler._get_mcp_server_auth_headers_from_headers(headers) assert result == {} @@ -2904,17 +2876,13 @@ class TestMCPCustomHeaderName: assert result == {"github_mcp": {"Authorization": "Bearer github-mcp-token"}} # Test case 8: Edge case - empty header value - headers = Headers( - {"x-litellm-api-key": "test-key", "x-mcp-github-authorization": ""} - ) + headers = Headers({"x-litellm-api-key": "test-key", "x-mcp-github-authorization": ""}) result = MCPRequestHandler._get_mcp_server_auth_headers_from_headers(headers) assert result == {"github": {"Authorization": ""}} # Test case 9: Edge case - very long header value long_token = "Bearer " + "x" * 1000 - headers = Headers( - {"x-litellm-api-key": "test-key", "x-mcp-github-authorization": long_token} - ) + headers = Headers({"x-litellm-api-key": "test-key", "x-mcp-github-authorization": long_token}) result = MCPRequestHandler._get_mcp_server_auth_headers_from_headers(headers) assert result == {"github": {"Authorization": long_token}} @@ -2980,9 +2948,7 @@ class TestMCPAccessGroupsE2E: # Assert the results assert auth_result.api_key == "test-api-key" assert mcp_auth_header is None - assert ( - mcp_servers is None - ) # x-mcp-access-groups is not parsed as mcp_servers + assert mcp_servers is None # x-mcp-access-groups is not parsed as mcp_servers assert mcp_server_auth_headers == {} # Verify the mock was called @@ -3097,9 +3063,7 @@ def test_mcp_path_based_server_segregation(monkeypatch): # Use TestClient to make a request to /mcp/zapier,group1/tools client = TestClient(app) - response = client.get( - "/mcp/zapier,group1/tools", headers={"x-litellm-api-key": "test"} - ) + response = client.get("/mcp/zapier,group1/tools", headers={"x-litellm-api-key": "test"}) assert response.status_code == 200 assert response.json() == {"status": "ok"} @@ -3177,15 +3141,11 @@ async def test_get_team_object_permission_with_already_loaded_permission(): mock_prisma, ): with patch("litellm.proxy.auth.auth_checks.get_team_object") as mock_get_team: - with patch( - "litellm.proxy.auth.auth_checks.get_object_permission" - ) as mock_get_perm: + with patch("litellm.proxy.auth.auth_checks.get_object_permission") as mock_get_perm: mock_get_team.return_value = mock_team_obj # Call the method - result = await MCPRequestHandler._get_team_object_permission( - mock_user_auth - ) + result = await MCPRequestHandler._get_team_object_permission(mock_user_auth) # Assert we got the object permission assert result == mock_object_permission @@ -3272,9 +3232,7 @@ async def test_get_team_object_permission_ui_session_team_skips_db_lookup(): mock_prisma = MagicMock() with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): with patch("litellm.proxy.auth.auth_checks.get_team_object") as mock_get_team: - result = await MCPRequestHandler._get_team_object_permission( - mock_user_auth - ) + result = await MCPRequestHandler._get_team_object_permission(mock_user_auth) assert result is None mock_get_team.assert_not_called() @@ -3342,9 +3300,7 @@ async def test_get_allowed_tools_for_server_ui_session_team_keeps_key_restrictio detail={"error": "Team doesn't exist in db. Team=litellm-dashboard."}, ), ): - with patch.object( - MCPRequestHandler, "_get_key_object_permission", return_value=key_perm - ): + with patch.object(MCPRequestHandler, "_get_key_object_permission", return_value=key_perm): result = await MCPRequestHandler.get_allowed_tools_for_server( server_id="server_1", user_api_key_auth=user_api_key_auth, @@ -3410,9 +3366,7 @@ async def test_get_allowed_mcp_servers_for_team_uses_helper(): return_value=["group-server1", "group-server2"], ) as mock_get_access_group_servers, ): - result = await MCPRequestHandler._get_allowed_mcp_servers_for_team( - mock_user_auth - ) + result = await MCPRequestHandler._get_allowed_mcp_servers_for_team(mock_user_auth) assert set(result) == { "direct-server1", @@ -3455,9 +3409,7 @@ async def test_get_allowed_mcp_servers_for_team_with_no_object_permission(): return_value=mock_team, ), ): - result = await MCPRequestHandler._get_allowed_mcp_servers_for_team( - mock_user_auth - ) + result = await MCPRequestHandler._get_allowed_mcp_servers_for_team(mock_user_auth) assert result == [] @@ -3507,9 +3459,7 @@ async def test_get_allowed_mcp_servers_for_team_without_team_id_returns_empty(): ), ], ) -async def test_get_allowed_mcp_servers_for_key_guard_conditions( - user_api_key_auth, prisma_client_value, scenario -): +async def test_get_allowed_mcp_servers_for_key_guard_conditions(user_api_key_auth, prisma_client_value, scenario): """Ensure guard clauses return [] before hitting get_object_permission.""" with patch( @@ -3517,9 +3467,7 @@ async def test_get_allowed_mcp_servers_for_key_guard_conditions( new_callable=AsyncMock, ) as mock_get_perm: with patch("litellm.proxy.proxy_server.prisma_client", prisma_client_value): - result = await MCPRequestHandler._get_allowed_mcp_servers_for_key( - user_api_key_auth - ) + result = await MCPRequestHandler._get_allowed_mcp_servers_for_key(user_api_key_auth) assert result == [] mock_get_perm.assert_not_called() @@ -3546,9 +3494,7 @@ async def test_get_allowed_mcp_servers_for_key_returns_empty_when_db_returns_non ): mock_get_perm.return_value = None - result = await MCPRequestHandler._get_allowed_mcp_servers_for_key( - user_api_key_auth - ) + result = await MCPRequestHandler._get_allowed_mcp_servers_for_key(user_api_key_auth) assert result == [] mock_get_perm.assert_awaited_once() @@ -3591,14 +3537,10 @@ async def test_get_allowed_mcp_servers_for_key_prefers_in_memory_permission(): "litellm.proxy.auth.auth_checks.get_object_permission", new_callable=AsyncMock, ) as mock_get_perm: - with patch.object( - MCPRequestHandler, "_get_mcp_servers_from_access_groups" - ) as mock_access_groups: + with patch.object(MCPRequestHandler, "_get_mcp_servers_from_access_groups") as mock_access_groups: mock_access_groups.return_value = ["group-server"] - result = await MCPRequestHandler._get_allowed_mcp_servers_for_key( - user_api_key_auth - ) + result = await MCPRequestHandler._get_allowed_mcp_servers_for_key(user_api_key_auth) assert set(result) == {"direct-server", "group-server"} mock_get_perm.assert_not_called() @@ -3619,21 +3561,13 @@ class TestAgentMCPPermissions: team_id="test-team", agent_id="agent-123", ) - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_key" - ) as mock_key: - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_team" - ) as mock_team: - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_agent" - ) as mock_agent: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_key") as mock_key: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_team") as mock_team: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_agent") as mock_agent: mock_key.return_value = ["server_1", "server_2"] mock_team.return_value = [] mock_agent.return_value = ["server_1"] - result = await MCPRequestHandler.get_allowed_mcp_servers( - user_api_key_auth=user_api_key_auth - ) + result = await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth=user_api_key_auth) assert sorted(result) == ["server_1"] mock_agent.assert_called_once_with(user_api_key_auth) @@ -3644,21 +3578,13 @@ class TestAgentMCPPermissions: user_id="test-user", agent_id="agent-456", ) - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_key" - ) as mock_key: - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_team" - ) as mock_team: - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_agent" - ) as mock_agent: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_key") as mock_key: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_team") as mock_team: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_agent") as mock_agent: mock_key.return_value = ["server_1", "server_2"] mock_team.return_value = [] mock_agent.return_value = [] # no agent-level restriction - result = await MCPRequestHandler.get_allowed_mcp_servers( - user_api_key_auth=user_api_key_auth - ) + result = await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth=user_api_key_auth) assert sorted(result) == ["server_1", "server_2"] mock_agent.assert_called_once_with(user_api_key_auth) @@ -3669,21 +3595,13 @@ class TestAgentMCPPermissions: user_id="test-user", agent_id="agent-789", ) - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_key" - ) as mock_key: - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_team" - ) as mock_team: - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_agent" - ) as mock_agent: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_key") as mock_key: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_team") as mock_team: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_agent") as mock_agent: mock_key.return_value = ["server_1", "server_2"] mock_team.return_value = [] mock_agent.return_value = ["server_2", "server_3"] - result = await MCPRequestHandler.get_allowed_mcp_servers( - user_api_key_auth=user_api_key_auth - ) + result = await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth=user_api_key_auth) assert sorted(result) == ["server_2"] async def test_get_allowed_tools_for_server_agent_intersection(self): @@ -3696,9 +3614,7 @@ class TestAgentMCPPermissions: key_perm = MagicMock() key_perm.mcp_tool_permissions = {"server_1": ["tool_a", "tool_b"]} team_perm = None - with patch.object( - MCPRequestHandler, "_get_key_object_permission", return_value=key_perm - ): + with patch.object(MCPRequestHandler, "_get_key_object_permission", return_value=key_perm): with patch.object( MCPRequestHandler, "_get_team_object_permission", @@ -3730,9 +3646,7 @@ class TestAgentMCPPermissions: ) key_perm = MagicMock() key_perm.mcp_tool_permissions = {"server_1": ["tool_a", "tool_b"]} - with patch.object( - MCPRequestHandler, "_get_key_object_permission", return_value=key_perm - ): + with patch.object(MCPRequestHandler, "_get_key_object_permission", return_value=key_perm): with patch.object( MCPRequestHandler, "_get_team_object_permission", @@ -3762,9 +3676,7 @@ class TestAgentMCPPermissions: agent_row = MagicMock() agent_row.object_permission_id = "perm-xyz" prisma_client = MagicMock() - prisma_client.db.litellm_agentstable.find_unique = AsyncMock( - return_value=agent_row - ) + prisma_client.db.litellm_agentstable.find_unique = AsyncMock(return_value=agent_row) user_api_key_auth = UserAPIKeyAuth( api_key="test-key", user_id="test-user", @@ -3782,9 +3694,7 @@ class TestAgentMCPPermissions: return_value=expected_perm, ) as mock_get_perm, ): - result = await MCPRequestHandler._get_agent_object_permission( - user_api_key_auth - ) + result = await MCPRequestHandler._get_agent_object_permission(user_api_key_auth) assert result is expected_perm mock_get_perm.assert_awaited_once() assert mock_get_perm.await_args.kwargs["object_permission_id"] == "perm-xyz" @@ -3804,9 +3714,7 @@ class TestAgentMCPPermissions: agent_row = MagicMock() agent_row.object_permission_id = None prisma_client = MagicMock() - prisma_client.db.litellm_agentstable.find_unique = AsyncMock( - return_value=agent_row - ) + prisma_client.db.litellm_agentstable.find_unique = AsyncMock(return_value=agent_row) user_api_key_auth = UserAPIKeyAuth( api_key="test-key", user_id="test-user", @@ -3822,14 +3730,8 @@ class TestAgentMCPPermissions: new_callable=AsyncMock, ) as mock_get_perm, ): - assert ( - await MCPRequestHandler._get_agent_object_permission(user_api_key_auth) - is None - ) - assert ( - await MCPRequestHandler._get_agent_object_permission(user_api_key_auth) - is None - ) + assert await MCPRequestHandler._get_agent_object_permission(user_api_key_auth) is None + assert await MCPRequestHandler._get_agent_object_permission(user_api_key_auth) is None mock_get_perm.assert_not_awaited() prisma_client.db.litellm_agentstable.find_unique.assert_awaited_once() @@ -3870,9 +3772,7 @@ async def test_tool_permission_servers_included_in_allowed_servers(): ) with ( - patch.object( - MCPRequestHandler, "_get_key_object_permission", return_value=perm - ), + patch.object(MCPRequestHandler, "_get_key_object_permission", return_value=perm), patch.object( MCPRequestHandler, "_get_mcp_servers_from_access_groups", @@ -4105,9 +4005,7 @@ class TestOrgMCPPermissions: org_perm.mcp_tool_permissions = {"server_1": ["tool_a", "tool_b"]} with ( - patch.object( - MCPRequestHandler, "_get_key_object_permission", return_value=key_perm - ), + patch.object(MCPRequestHandler, "_get_key_object_permission", return_value=key_perm), patch.object( MCPRequestHandler, "_get_team_object_permission", @@ -4137,9 +4035,7 @@ class TestOrgMCPPermissions: org_perm.mcp_tool_permissions = {} with ( - patch.object( - MCPRequestHandler, "_get_key_object_permission", return_value=key_perm - ), + patch.object(MCPRequestHandler, "_get_key_object_permission", return_value=key_perm), patch.object( MCPRequestHandler, "_get_team_object_permission", @@ -4231,9 +4127,7 @@ async def test_mcp_key_access_group_extras_when_team_authorized(): ] _start_patches(patches) try: - result = await MCPRequestHandler._get_key_access_group_mcp_server_extras( - valid_token - ) + result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(valid_token) assert result == ["srv-stripe"] finally: _stop_patches(patches) @@ -4270,9 +4164,7 @@ async def test_mcp_key_access_group_extras_when_key_directly_authorized(): ] _start_patches(patches) try: - result = await MCPRequestHandler._get_key_access_group_mcp_server_extras( - valid_token - ) + result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(valid_token) assert result == ["srv-stripe"] finally: _stop_patches(patches) @@ -4286,9 +4178,7 @@ async def test_mcp_key_access_group_extras_when_key_has_no_groups(): access_group_ids=[], team_id="team-a", ) - result = await MCPRequestHandler._get_key_access_group_mcp_server_extras( - valid_token - ) + result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(valid_token) assert result == [] @@ -4315,9 +4205,7 @@ async def test_mcp_key_access_group_extras_when_group_has_no_servers(): ] _start_patches(patches) try: - result = await MCPRequestHandler._get_key_access_group_mcp_server_extras( - valid_token - ) + result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(valid_token) assert result == [] finally: _stop_patches(patches) @@ -4351,9 +4239,7 @@ async def test_mcp_key_access_group_extras_granted_even_when_group_authorizes_ne ] _start_patches(patches) try: - result = await MCPRequestHandler._get_key_access_group_mcp_server_extras( - valid_token - ) + result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(valid_token) assert result == ["srv-finance-only"] finally: _stop_patches(patches) @@ -4376,9 +4262,7 @@ async def test_mcp_key_access_group_extras_when_get_access_object_raises(): ] _start_patches(patches) try: - result = await MCPRequestHandler._get_key_access_group_mcp_server_extras( - valid_token - ) + result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(valid_token) assert result == [] finally: _stop_patches(patches) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py index fbe768e07c6..17960e917a4 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py @@ -23,6 +23,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( AuthorizationCodeConfig, CredError, NoneConfig, + PassthroughConfig, SharedKey, TokenExchangeConfig, ) @@ -234,6 +235,12 @@ def test_token_exchange_empty_subject_token_type_normalizes_to_default(): assert spec.config.subject_token_type == "urn:ietf:params:oauth:token-type:access_token" +@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate]) +def test_client_forwarded_modes_map_to_passthrough_config(auth_type): + spec = to_server_spec(_server(auth_type=auth_type)) + assert spec is not None and isinstance(spec.config, PassthroughConfig) + + @pytest.mark.parametrize( "server", [ diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py index f8e45b38b49..64226eea821 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py @@ -1,9 +1,9 @@ """Tests for the resolver dispatch: live arms produce auth, stubbed arms fail closed. -`none`, `api_key` (shared-key source), `authorization_code`, and `token_exchange` are implemented; -every other arm, plus the `api_key` BYOK source, returns a typed `not_implemented` error until its -mode lands. Parametrizing the stubs over one config each also guards reachability: a dropped `case` -would hit `assert_never` and raise instead of returning the stub. +`none`, `api_key` (shared-key source), `passthrough`, `authorization_code`, and `token_exchange` are +implemented; every other arm, plus the `api_key` BYOK source, returns a typed `not_implemented` error +until its mode lands. Parametrizing the stubs over one config each also guards reachability: a dropped +`case` would hit `assert_never` and raise instead of returning the stub. """ import httpx @@ -253,9 +253,24 @@ async def test_token_exchange_without_an_exchanger_fails_closed(): assert result.error.tag == "misconfigured" +@pytest.mark.asyncio +async def test_passthrough_forwards_the_inbound_token_verbatim(): + subject = Subject(tenant_id="", subject_id="", inbound_token=SecretStr("Bearer upstream-xyz")) + result = await UpstreamCredentialProvider().resolve_credentials(subject, _spec(PassthroughConfig())) + assert isinstance(result, Ok) + assert isinstance(result.ok, StaticHeaderAuth) + assert _emitted(result.ok)["Authorization"] == "Bearer upstream-xyz" + + +@pytest.mark.asyncio +async def test_passthrough_without_inbound_token_is_a_no_op(): + result = await UpstreamCredentialProvider().resolve_credentials(_SUBJECT, _spec(PassthroughConfig())) + assert isinstance(result, Ok) + assert isinstance(result.ok, NoOpAuth) + + _STUBBED = [ ("api_key_byok", ApiKeyConfig(key_source=Byok())), - ("passthrough", PassthroughConfig()), ("client_credentials", ClientCredentialsConfig()), ("aws_sigv4", AwsSigV4Config(region="us-east-1")), ] diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py index ad78609ee18..ef2a8318d7a 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py @@ -34,8 +34,7 @@ from litellm.types.mcp_server.mcp_server_manager import MCPServer def _mock_mcp_client_ip(): """Bypass IP-based access control in tests.""" with patch( - "litellm.proxy._experimental.mcp_server.discoverable_endpoints" - ".IPAddressUtils.get_mcp_client_ip", + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.IPAddressUtils.get_mcp_client_ip", return_value=None, ): yield @@ -191,9 +190,7 @@ async def test_oauth_protected_resource_passthrough_proxies_upstream_metadata(): extra_headers=["Authorization"], oauth_passthrough=True, ) - global_mcp_server_manager.registry[passthrough_server.server_id] = ( - passthrough_server - ) + global_mcp_server_manager.registry[passthrough_server.server_id] = passthrough_server upstream_payload = { "resource": "https://upstream.example.com/mcp", @@ -207,18 +204,14 @@ async def test_oauth_protected_resource_passthrough_proxies_upstream_metadata(): mock_client = MagicMock() mock_client.get = AsyncMock(return_value=mock_response) - with patch.object( - discoverable_endpoints, "get_async_httpx_client", return_value=mock_client - ): + with patch.object(discoverable_endpoints, "get_async_httpx_client", return_value=mock_client): result = await _build_oauth_protected_resource_response( request=_make_request(), mcp_server_name="sample_docs", use_standard_pattern=True, ) - assert result["authorization_servers"] == [ - "https://okta.example.com/oauth2/default" - ] + assert result["authorization_servers"] == ["https://okta.example.com/oauth2/default"] # resource is normalized to the gateway URL so bearers are sent back to us assert result["resource"].endswith("/mcp/sample_docs") assert result["scopes_supported"] == ["openid", "profile"] @@ -242,9 +235,7 @@ async def test_oauth_protected_resource_passthrough_cache_hit(): extra_headers=["Authorization"], oauth_passthrough=True, ) - global_mcp_server_manager.registry[passthrough_server.server_id] = ( - passthrough_server - ) + global_mcp_server_manager.registry[passthrough_server.server_id] = passthrough_server mock_response = MagicMock() mock_response.status_code = 200 @@ -254,9 +245,7 @@ async def test_oauth_protected_resource_passthrough_cache_hit(): mock_client = MagicMock() mock_client.get = AsyncMock(return_value=mock_response) - with patch.object( - discoverable_endpoints, "get_async_httpx_client", return_value=mock_client - ): + with patch.object(discoverable_endpoints, "get_async_httpx_client", return_value=mock_client): await _build_oauth_protected_resource_response( request=_make_request(), mcp_server_name="sample_docs", @@ -348,12 +337,8 @@ async def test_oauth_metadata_cache_expired_entry_is_refetched(): mock_client = MagicMock() mock_client.get = AsyncMock(return_value=mock_response) - with patch.object( - discoverable_endpoints, "get_async_httpx_client", return_value=mock_client - ): - result = await discoverable_endpoints.fetch_upstream_oauth_protected_resource( - passthrough_server - ) + with patch.object(discoverable_endpoints, "get_async_httpx_client", return_value=mock_client): + result = await discoverable_endpoints.fetch_upstream_oauth_protected_resource(passthrough_server) assert result == {"authorization_servers": ["https://fresh.example.com"]} assert mock_client.get.await_count == 1 @@ -377,16 +362,12 @@ async def test_oauth_protected_resource_passthrough_network_error_returns_502(): extra_headers=["Authorization"], oauth_passthrough=True, ) - global_mcp_server_manager.registry[passthrough_server.server_id] = ( - passthrough_server - ) + global_mcp_server_manager.registry[passthrough_server.server_id] = passthrough_server mock_client = MagicMock() mock_client.get = AsyncMock(side_effect=httpx.ConnectError("boom")) - with patch.object( - discoverable_endpoints, "get_async_httpx_client", return_value=mock_client - ): + with patch.object(discoverable_endpoints, "get_async_httpx_client", return_value=mock_client): with pytest.raises(HTTPException) as exc_info: await _build_oauth_protected_resource_response( request=_make_request(), @@ -414,16 +395,10 @@ async def test_fetch_upstream_metadata_returns_none_when_not_all_candidates_netw not_found_response = MagicMock() not_found_response.status_code = 404 mock_client = MagicMock() - mock_client.get = AsyncMock( - side_effect=[not_found_response, httpx.ConnectError("path fallback failed")] - ) + mock_client.get = AsyncMock(side_effect=[not_found_response, httpx.ConnectError("path fallback failed")]) - with patch.object( - discoverable_endpoints, "get_async_httpx_client", return_value=mock_client - ): - result = await discoverable_endpoints.fetch_upstream_oauth_protected_resource( - passthrough_server - ) + with patch.object(discoverable_endpoints, "get_async_httpx_client", return_value=mock_client): + result = await discoverable_endpoints.fetch_upstream_oauth_protected_resource(passthrough_server) assert result is None assert mock_client.get.await_count == 2 @@ -458,9 +433,7 @@ async def test_oauth_protected_resource_gateway_managed_unchanged(): mock_client = MagicMock() mock_client.get = AsyncMock() - with patch.object( - discoverable_endpoints, "get_async_httpx_client", return_value=mock_client - ): + with patch.object(discoverable_endpoints, "get_async_httpx_client", return_value=mock_client): result = await _build_oauth_protected_resource_response( request=_make_request(), mcp_server_name="keycloak_whoami", @@ -468,7 +441,97 @@ async def test_oauth_protected_resource_gateway_managed_unchanged(): ) mock_client.get.assert_not_awaited() - assert result["authorization_servers"] == [ - "https://gateway.example.com/keycloak_whoami" - ] + assert result["authorization_servers"] == ["https://gateway.example.com/keycloak_whoami"] assert result["scopes_supported"] == ["read"] + + +def _make_upstream_metadata_client() -> tuple[dict, MagicMock]: + upstream_payload = { + "resource": "https://upstream.example.com/mcp", + "authorization_servers": ["https://okta.example.com/oauth2/default"], + "scopes_supported": ["openid", "profile"], + "bearer_methods_supported": ["header"], + } + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = upstream_payload + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=mock_response) + return upstream_payload, mock_client + + +@pytest.mark.asyncio +async def test_oauth_protected_resource_oauth_delegate_returns_upstream_metadata_verbatim(): + """oauth_delegate discovery must return the upstream metadata verbatim, + resource included. The caller's token is forwarded to and validated by the + upstream, so its audience must be the upstream; rewriting resource to the + gateway would make a strict IdP refuse to mint it or the upstream reject it. + A regression that dropped oauth_delegate from the pass-through predicate would + fall through to the gateway-AS branch and advertise LiteLLM as the AS.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + global_mcp_server_manager.registry.clear() + delegate_server = MCPServer( + server_id="delegate-1", + name="sample_docs", + server_name="sample_docs", + alias="sample_docs", + url="https://upstream.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth_delegate, + ) + global_mcp_server_manager.registry[delegate_server.server_id] = delegate_server + + upstream_payload, mock_client = _make_upstream_metadata_client() + try: + with patch.object(discoverable_endpoints, "get_async_httpx_client", return_value=mock_client): + result = await _build_oauth_protected_resource_response( + request=_make_request(), + mcp_server_name="sample_docs", + use_standard_pattern=True, + ) + + assert result == upstream_payload + assert result["authorization_servers"] == ["https://okta.example.com/oauth2/default"] + assert result["resource"] == "https://upstream.example.com/mcp" + finally: + global_mcp_server_manager.registry.clear() + + +@pytest.mark.asyncio +async def test_oauth_protected_resource_true_passthrough_returns_upstream_metadata_verbatim(): + """true_passthrough discovery must return the upstream metadata verbatim, + resource included, so the client treats the upstream as the resource and + authorizes directly against it. A regression that rewrote resource (the + gateway-proxied behavior) would break the transparent-proxy contract.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + global_mcp_server_manager.registry.clear() + true_passthrough_server = MCPServer( + server_id="tp-1", + name="sample_docs", + server_name="sample_docs", + alias="sample_docs", + url="https://upstream.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.true_passthrough, + ) + global_mcp_server_manager.registry[true_passthrough_server.server_id] = true_passthrough_server + + upstream_payload, mock_client = _make_upstream_metadata_client() + try: + with patch.object(discoverable_endpoints, "get_async_httpx_client", return_value=mock_client): + result = await _build_oauth_protected_resource_response( + request=_make_request(), + mcp_server_name="sample_docs", + use_standard_pattern=True, + ) + + assert result == upstream_payload + assert result["resource"] == "https://upstream.example.com/mcp" + finally: + global_mcp_server_manager.registry.clear() diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index 83c19dfd7ca..e9dccdcd4ad 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -71,9 +71,7 @@ async def test_mcp_server_tool_call_body_contains_request_data(): # Mock the add_litellm_data_to_request function to capture the data captured_data = {} - async def mock_add_litellm_data_to_request( - data, request, user_api_key_dict, proxy_config - ): + async def mock_add_litellm_data_to_request(data, request, user_api_key_dict, proxy_config): captured_data.update(data) # Simulate the proxy_server_request creation captured_data["proxy_server_request"] = { @@ -357,6 +355,122 @@ def test_prepare_mcp_server_headers_m2m_skips_authorization_from_raw_extra_heade assert extra_headers.get("X-Custom") == "trace" +def _client_forwarded_mode_server(server_id: str, auth_type) -> MCPServer: + return MCPServer( + server_id=server_id, + name=server_id, + transport=MCPTransport.http, + auth_type=auth_type, + ) + + +def _prepare_headers_in_scope(server: MCPServer, scope_servers): + from litellm.proxy._experimental.mcp_server.server import ( + _prepare_mcp_server_headers, + ) + + return _prepare_mcp_server_headers( + server=server, + mcp_server_auth_headers=None, + mcp_auth_header=None, + oauth2_headers={"Authorization": "Bearer upstream-token"}, + raw_headers={ + "x-litellm-api-key": "Bearer sk-litellm-key", + "authorization": "Bearer upstream-token", + }, + user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-key"), + scope_servers=scope_servers, + ) + + +def test_prepare_mcp_server_headers_withholds_global_authorization_when_scope_fans_out(): + """One caller bearer must not be replayed against multiple upstreams (RFC 9700 + cross-resource replay): in a fan-out scope with a second Authorization-consuming + server, the client-forwarded modes get no global Authorization.""" + delegate = _client_forwarded_mode_server("od-fanout", MCPAuth.oauth_delegate) + second_consumer = _client_forwarded_mode_server("tp-fanout", MCPAuth.true_passthrough) + + _, extra_headers = _prepare_headers_in_scope(delegate, [delegate, second_consumer]) + + assert not extra_headers or "authorization" not in {k.lower() for k in extra_headers} + + +def test_prepare_mcp_server_headers_forwards_global_authorization_to_sole_consumer(): + """Non-consuming servers (static api_key) in scope do not make the forward ambiguous.""" + delegate = _client_forwarded_mode_server("od-sole", MCPAuth.oauth_delegate) + static_server = MCPServer( + server_id="static-api-key", + name="static-api-key", + transport=MCPTransport.http, + auth_type=MCPAuth.api_key, + authentication_token="static-key", + ) + + _, extra_headers = _prepare_headers_in_scope(delegate, [delegate, static_server]) + + assert extra_headers == {"Authorization": "Bearer upstream-token"} + + +def test_prepare_mcp_server_headers_scope_counts_legacy_delegate_as_consumer(): + """Legacy upstream-delegated oauth2 servers still receive the caller's Authorization on the + v1 path, so their presence in scope must suppress the new modes' forward too.""" + delegate = _client_forwarded_mode_server("od-vs-legacy", MCPAuth.oauth_delegate) + legacy_delegate = MCPServer( + server_id="legacy-delegate", + name="legacy-delegate", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, + ) + + _, extra_headers = _prepare_headers_in_scope(delegate, [delegate, legacy_delegate]) + + assert not extra_headers or "authorization" not in {k.lower() for k in extra_headers} + + +def test_prepare_mcp_server_headers_fanout_withhold_survives_extra_headers_loop(): + """Regression: when fan-out withholds the request-wide Authorization from a client-forwarded + server, the later server.extra_headers copy loop must not re-add it from raw_headers even if + the server lists Authorization in extra_headers. Otherwise one bearer is replayed across every + consuming upstream in the scope (the exact cross-resource replay the withholding prevents).""" + delegate = MCPServer( + server_id="od-extra-hdr", + name="od-extra-hdr", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth_delegate, + extra_headers=["Authorization"], + ) + second_consumer = _client_forwarded_mode_server("tp-peer", MCPAuth.true_passthrough) + + _, extra_headers = _prepare_headers_in_scope(delegate, [delegate, second_consumer]) + + assert not extra_headers or "authorization" not in {k.lower() for k in extra_headers} + + +def test_prepare_mcp_server_headers_sole_consumer_still_forwards_via_extra_headers(): + """Guard the fix does not over-withhold: with no second consumer in scope, a client-forwarded + server that lists Authorization in extra_headers still forwards the caller's bearer.""" + delegate = MCPServer( + server_id="od-extra-sole", + name="od-extra-sole", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth_delegate, + extra_headers=["Authorization"], + ) + static_server = MCPServer( + server_id="static-peer", + name="static-peer", + transport=MCPTransport.http, + auth_type=MCPAuth.api_key, + authentication_token="static-key", + ) + + _, extra_headers = _prepare_headers_in_scope(delegate, [delegate, static_server]) + + assert extra_headers is not None + assert extra_headers.get("Authorization") == "Bearer upstream-token" + + @pytest.mark.asyncio async def test_call_tool_m2m_skips_authorization_headers(): """M2M call_tool must not forward caller Authorization in oauth2/raw headers.""" @@ -384,9 +498,7 @@ async def test_call_tool_m2m_skips_authorization_headers(): mock_client = MagicMock() mock_client.call_tool = AsyncMock(return_value=MagicMock()) - with patch.object( - manager, "_create_mcp_client", new=AsyncMock(return_value=mock_client) - ) as create_client_mock: + with patch.object(manager, "_create_mcp_client", new=AsyncMock(return_value=mock_client)) as create_client_mock: await manager._call_regular_mcp_tool( mcp_server=server, original_tool_name="echo", @@ -881,9 +993,7 @@ async def test_get_tools_from_mcp_servers_continues_when_one_server_fails(): # Mock global_mcp_server_manager mock_manager = MagicMock() - mock_manager.get_allowed_mcp_servers = AsyncMock( - return_value=["working_server", "failing_server"] - ) + mock_manager.get_allowed_mcp_servers = AsyncMock(return_value=["working_server", "failing_server"]) mock_manager.get_mcp_server_by_id = lambda server_id: ( working_server if server_id == "working_server" else failing_server ) @@ -944,9 +1054,7 @@ async def test_get_tools_from_mcp_servers_continues_when_one_server_fails(): ) # Verify success logging - mock_logger.info.assert_any_call( - "Successfully fetched 1 tools total from all MCP servers" - ) + mock_logger.info.assert_any_call("Successfully fetched 1 tools total from all MCP servers") @pytest.mark.asyncio @@ -987,9 +1095,7 @@ async def test_get_tools_from_mcp_servers_handles_all_servers_failing(): # Mock global_mcp_server_manager mock_manager = MagicMock() - mock_manager.get_allowed_mcp_servers = AsyncMock( - return_value=["failing_server1", "failing_server2"] - ) + mock_manager.get_allowed_mcp_servers = AsyncMock(return_value=["failing_server1", "failing_server2"]) mock_manager.get_mcp_server_by_id = lambda server_id: ( failing_server1 if server_id == "failing_server1" else failing_server2 ) @@ -1044,9 +1150,7 @@ async def test_get_tools_from_mcp_servers_handles_all_servers_failing(): ) # Verify total logging - mock_logger.info.assert_any_call( - "Successfully fetched 0 tools total from all MCP servers" - ) + mock_logger.info.assert_any_call("Successfully fetched 0 tools total from all MCP servers") @pytest.mark.asyncio @@ -1071,9 +1175,7 @@ async def test_mcp_server_tool_call_body_with_none_arguments(): # Mock the add_litellm_data_to_request function to capture the data captured_data = {} - async def mock_add_litellm_data_to_request( - data, request, user_api_key_dict, proxy_config - ): + async def mock_add_litellm_data_to_request(data, request, user_api_key_dict, proxy_config): captured_data.update(data) captured_data["proxy_server_request"] = { "url": str(request.url), @@ -1178,31 +1280,29 @@ async def test_concurrent_initialize_session_managers(): results = await asyncio.gather(*tasks, return_exceptions=True) # All tasks should complete successfully (no exceptions) - assert all( - result == "success" for result in results - ), f"Some tasks failed: {results}" + assert all(result == "success" for result in results), f"Some tasks failed: {results}" # Each session manager.run() should only be called once due to the lock - assert ( - mock_stateless_run.call_count == 1 - ), f"Expected 1 call to session_manager_stateless.run(), got {mock_stateless_run.call_count}" - assert ( - mock_stateful_run.call_count == 1 - ), f"Expected 1 call to session_manager_stateful.run(), got {mock_stateful_run.call_count}" - assert ( - mock_sse_run.call_count == 1 - ), f"Expected 1 call to sse_session_manager.run(), got {mock_sse_run.call_count}" + assert mock_stateless_run.call_count == 1, ( + f"Expected 1 call to session_manager_stateless.run(), got {mock_stateless_run.call_count}" + ) + assert mock_stateful_run.call_count == 1, ( + f"Expected 1 call to session_manager_stateful.run(), got {mock_stateful_run.call_count}" + ) + assert mock_sse_run.call_count == 1, ( + f"Expected 1 call to sse_session_manager.run(), got {mock_sse_run.call_count}" + ) # The context managers should only be entered once each - assert ( - mock_cm_stateless.__aenter__.call_count == 1 - ), f"Expected 1 call to stateless __aenter__, got {mock_cm_stateless.__aenter__.call_count}" - assert ( - mock_cm_stateful.__aenter__.call_count == 1 - ), f"Expected 1 call to stateful __aenter__, got {mock_cm_stateful.__aenter__.call_count}" - assert ( - mock_cm_sse.__aenter__.call_count == 1 - ), f"Expected 1 call to sse __aenter__, got {mock_cm_sse.__aenter__.call_count}" + assert mock_cm_stateless.__aenter__.call_count == 1, ( + f"Expected 1 call to stateless __aenter__, got {mock_cm_stateless.__aenter__.call_count}" + ) + assert mock_cm_stateful.__aenter__.call_count == 1, ( + f"Expected 1 call to stateful __aenter__, got {mock_cm_stateful.__aenter__.call_count}" + ) + assert mock_cm_sse.__aenter__.call_count == 1, ( + f"Expected 1 call to sse __aenter__, got {mock_cm_sse.__aenter__.call_count}" + ) # State should be properly set assert mcp_server._SESSION_MANAGERS_INITIALIZED is True @@ -1345,16 +1445,12 @@ async def test_mcp_routing_initialize_to_stateful_no_session_to_stateless(): # initialize → stateful init_body = b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}' stateless_called, stateful_called = await make_request(init_body) - assert ( - stateful_called and not stateless_called - ), "initialize (no session) should route to stateful, not stateless" + assert stateful_called and not stateless_called, "initialize (no session) should route to stateful, not stateless" # tools/list → stateless tools_body = b'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' stateless_called, stateful_called = await make_request(tools_body) - assert ( - stateless_called and not stateful_called - ), "tools/list (no session) should route to stateless, not stateful" + assert stateless_called and not stateful_called, "tools/list (no session) should route to stateless, not stateful" @pytest.mark.asyncio @@ -1439,9 +1535,9 @@ async def test_mcp_routing_chunked_initialize_to_stateful(): ): await handle_streamable_http_mcp(scope, receive, send) - assert ( - stateful_called and not stateless_called - ), "chunked initialize (no session) should route to stateful, not stateless" + assert stateful_called and not stateless_called, ( + "chunked initialize (no session) should route to stateful, not stateless" + ) @pytest.mark.asyncio @@ -1470,10 +1566,7 @@ async def test_mcp_routing_caps_body_peek_for_oversized_chunked_body(): messages = [ {"type": "http.request", "body": first_chunk, "more_body": True}, - *[ - {"type": "http.request", "body": chunk, "more_body": True} - for chunk in oversized_tail - ], + *[{"type": "http.request", "body": chunk, "more_body": True} for chunk in oversized_tail], {"type": "http.request", "body": b"", "more_body": False}, ] receive_calls = {"count": 0} @@ -1524,12 +1617,8 @@ async def test_mcp_routing_caps_body_peek_for_oversized_chunked_body(): "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", True, ), - patch.object( - session_manager_stateless, "handle_request", side_effect=stateless_handle - ), - patch.object( - session_manager_stateful, "handle_request", side_effect=stateful_handle - ), + patch.object(session_manager_stateless, "handle_request", side_effect=stateless_handle), + patch.object(session_manager_stateful, "handle_request", side_effect=stateful_handle), patch.object(session_manager_stateless, "_server_instances", {}), patch.object(session_manager_stateful, "_server_instances", {}), ): @@ -1580,9 +1669,7 @@ async def test_enforce_stateful_session_cap_evicts_oldest_idle_then_rejects(): patch.object(session_manager_stateful, "_server_instances", instances), patch.object(mcp_server, "_MAX_STATEFUL_SESSIONS_PER_OWNER", 3), patch.dict(mcp_server._stateful_session_owners, owners, clear=True), - patch.dict( - mcp_server._stateful_session_auth_context_last_seen, last_seen, clear=True - ), + patch.dict(mcp_server._stateful_session_auth_context_last_seen, last_seen, clear=True), patch.dict(mcp_server._stateful_session_auth_contexts, contexts, clear=True), patch.dict(mcp_server._stateful_session_active_request_counts, {}, clear=True), ): @@ -1595,9 +1682,7 @@ async def test_enforce_stateful_session_cap_evicts_oldest_idle_then_rejects(): # A different owner at the cap is unaffected by owner-A's sessions. terminated.clear() - allowed_other = await mcp_server._enforce_stateful_session_cap_for_owner( - "owner-B" - ) + allowed_other = await mcp_server._enforce_stateful_session_cap_for_owner("owner-B") assert allowed_other is True assert terminated == [] @@ -1616,9 +1701,7 @@ async def test_enforce_stateful_session_cap_evicts_oldest_idle_then_rejects(): {f"s{i}": float(i) for i in range(3)}, clear=True, ), - patch.dict( - mcp_server._stateful_session_active_request_counts, active, clear=True - ), + patch.dict(mcp_server._stateful_session_active_request_counts, active, clear=True), ): rejected = await mcp_server._enforce_stateful_session_cap_for_owner("owner-A") assert rejected is False @@ -1664,9 +1747,7 @@ async def test_mcp_routing_initialize_rejected_when_owner_at_session_cap(): (b"authorization", b"Bearer test-key"), ], } - receive = AsyncMock( - return_value={"type": "http.request", "body": init_body, "more_body": False} - ) + receive = AsyncMock(return_value={"type": "http.request", "body": init_body, "more_body": False}) send = AsyncMock() stateful_called = [] @@ -1687,9 +1768,7 @@ async def test_mcp_routing_initialize_rejected_when_owner_at_session_cap(): ), patch.object(mcp_server, "_owner_fingerprint_for", return_value="owner-X"), patch.object(mcp_server, "_MAX_STATEFUL_SESSIONS_PER_OWNER", cap), - patch.object( - session_manager_stateful, "handle_request", side_effect=stateful_handle - ), + patch.object(session_manager_stateful, "handle_request", side_effect=stateful_handle), patch.object(session_manager_stateful, "_server_instances", instances), patch.object(session_manager_stateless, "_server_instances", {}), patch.dict(mcp_server._stateful_session_owners, owners, clear=True), @@ -1698,18 +1777,14 @@ async def test_mcp_routing_initialize_rejected_when_owner_at_session_cap(): {f"s{i}": float(i) for i in range(cap)}, clear=True, ), - patch.dict( - mcp_server._stateful_session_active_request_counts, active, clear=True - ), + patch.dict(mcp_server._stateful_session_active_request_counts, active, clear=True), patch.dict(mcp_server._stateful_session_auth_contexts, contexts, clear=True), ): await handle_streamable_http_mcp(scope, receive, send) assert not stateful_called, "initialize at session cap must not reach the manager" start_messages = [ - call.args[0] - for call in send.call_args_list - if call.args and call.args[0].get("type") == "http.response.start" + call.args[0] for call in send.call_args_list if call.args and call.args[0].get("type") == "http.response.start" ] assert start_messages, "a response should have been sent" assert start_messages[0]["status"] == 429 @@ -1745,9 +1820,7 @@ async def test_stateful_mcp_requests_refresh_session_auth_context(): None, "1.1.1.1", ) - mcp_server._stateful_session_auth_contexts[session_id] = callback_context.run( - mcp_server.auth_context_var.get - ) + mcp_server._stateful_session_auth_contexts[session_id] = callback_context.run(mcp_server.auth_context_var.get) scope = { "type": "http", @@ -2008,24 +2081,14 @@ async def test_initialize_request_with_existing_session_tracks_new_session(): ) await mcp_server._purge_expired_stateful_session_auth_contexts(now=now) assert new_session_id in mcp_server._stateful_session_auth_contexts - assert ( - mcp_server._stateful_session_auth_contexts[new_session_id] - is not existing_auth_user - ) - assert ( - mcp_server._stateful_session_auth_contexts[new_session_id].mcp_auth_header - == "new-mcp-auth" - ) + assert mcp_server._stateful_session_auth_contexts[new_session_id] is not existing_auth_user + assert mcp_server._stateful_session_auth_contexts[new_session_id].mcp_auth_header == "new-mcp-auth" async def stateless_handle(s, r, se): - raise AssertionError( - "initialize request with session should use stateful manager" - ) + raise AssertionError("initialize request with session should use stateful manager") try: - mcp_server._stateful_session_auth_contexts[existing_session_id] = ( - existing_auth_user - ) + mcp_server._stateful_session_auth_contexts[existing_session_id] = existing_auth_user mcp_server._stateful_session_auth_context_last_seen[existing_session_id] = 1.0 mcp_server._stateful_session_owners[existing_session_id] = owner_fingerprint @@ -2067,10 +2130,7 @@ async def test_initialize_request_with_existing_session_tracks_new_session(): assert stateful_called assert new_session_id not in mcp_server._stateful_session_active_request_counts assert new_session_id in mcp_server._stateful_session_auth_contexts - assert ( - mcp_server._stateful_session_auth_contexts[existing_session_id] - is existing_auth_user - ) + assert mcp_server._stateful_session_auth_contexts[existing_session_id] is existing_auth_user assert existing_auth_user.mcp_auth_header == "old-mcp-auth" assert existing_auth_user.mcp_servers == ["old-server"] finally: @@ -2193,15 +2253,11 @@ async def test_stateful_mcp_cleanup_loop_survives_purge_errors(): except ImportError: pytest.skip("MCP server not available") - purge = AsyncMock( - side_effect=[RuntimeError("terminate failed"), asyncio.CancelledError()] - ) + purge = AsyncMock(side_effect=[RuntimeError("terminate failed"), asyncio.CancelledError()]) with ( patch.object(mcp_server.asyncio, "sleep", AsyncMock(return_value=None)), - patch.object( - mcp_server, "_purge_expired_stateful_session_auth_contexts", purge - ), + patch.object(mcp_server, "_purge_expired_stateful_session_auth_contexts", purge), ): with pytest.raises(asyncio.CancelledError): await mcp_server._cleanup_expired_stateful_session_auth_contexts() @@ -2291,9 +2347,7 @@ async def test_stateful_mcp_session_owner_mismatch_returns_403(): intruder_auth = UserAPIKeyAuth(api_key="intruder-key", user_id="intruder") mcp_server._stateful_session_auth_contexts[session_id] = MagicMock() - mcp_server._stateful_session_owners[session_id] = mcp_server._owner_fingerprint_for( - owner_auth - ) + mcp_server._stateful_session_owners[session_id] = mcp_server._owner_fingerprint_for(owner_auth) scope = { "type": "http", @@ -2343,9 +2397,7 @@ async def test_stateful_mcp_session_owner_mismatch_returns_403(): await handle_streamable_http_mcp(scope, receive, capture_send) handle_request_mock.assert_not_awaited() - statuses = [ - m["status"] for m in sent_messages if m.get("type") == "http.response.start" - ] + statuses = [m["status"] for m in sent_messages if m.get("type") == "http.response.start"] assert statuses == [403] mcp_server._stateful_session_auth_contexts.pop(session_id, None) @@ -2370,12 +2422,10 @@ async def test_stateful_mcp_session_serializes_concurrent_requests(): session_id = "serialized-session-1" owner_auth = UserAPIKeyAuth(api_key="owner-key", user_id="owner") - mcp_server._stateful_session_auth_contexts[session_id] = ( - mcp_server.MCPAuthenticatedUser(user_api_key_auth=owner_auth) - ) - mcp_server._stateful_session_owners[session_id] = mcp_server._owner_fingerprint_for( - owner_auth + mcp_server._stateful_session_auth_contexts[session_id] = mcp_server.MCPAuthenticatedUser( + user_api_key_auth=owner_auth ) + mcp_server._stateful_session_owners[session_id] = mcp_server._owner_fingerprint_for(owner_auth) inside = 0 max_inside = 0 @@ -2416,9 +2466,7 @@ async def test_stateful_mcp_session_serializes_concurrent_requests(): "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", True, ), - patch.object( - session_manager_stateful, "handle_request", side_effect=slow_handle - ), + patch.object(session_manager_stateful, "handle_request", side_effect=slow_handle), patch.object( session_manager_stateful, "_server_instances", @@ -2434,9 +2482,7 @@ async def test_stateful_mcp_session_serializes_concurrent_requests(): mcp_server._stateful_session_owners.pop(session_id, None) mcp_server._stateful_session_locks.pop(session_id, None) - assert ( - max_inside == 1 - ), "concurrent requests on same stateful session must be serialized" + assert max_inside == 1, "concurrent requests on same stateful session must be serialized" @pytest.mark.asyncio @@ -2488,9 +2534,7 @@ async def test_stateful_mcp_lock_does_not_leak_when_auth_context_missing(): "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", True, ), - patch.object( - session_manager_stateful, "handle_request", side_effect=handle - ), + patch.object(session_manager_stateful, "handle_request", side_effect=handle), patch.object( session_manager_stateful, "_server_instances", @@ -2500,9 +2544,9 @@ async def test_stateful_mcp_lock_does_not_leak_when_auth_context_missing(): assert session_id not in mcp_server._stateful_session_auth_contexts await handle_streamable_http_mcp(scope, receive, AsyncMock()) - assert ( - session_id not in mcp_server._stateful_session_locks - ), "lock entry must be cleaned up for untracked stateful session" + assert session_id not in mcp_server._stateful_session_locks, ( + "lock entry must be cleaned up for untracked stateful session" + ) finally: mcp_server._stateful_session_auth_contexts.pop(session_id, None) mcp_server._stateful_session_owners.pop(session_id, None) @@ -2528,12 +2572,10 @@ async def test_stateful_mcp_get_stream_does_not_block_post(): session_id = "stream-session-1" owner_auth = UserAPIKeyAuth(api_key="owner-key", user_id="owner") - mcp_server._stateful_session_auth_contexts[session_id] = ( - mcp_server.MCPAuthenticatedUser(user_api_key_auth=owner_auth) - ) - mcp_server._stateful_session_owners[session_id] = mcp_server._owner_fingerprint_for( - owner_auth + mcp_server._stateful_session_auth_contexts[session_id] = mcp_server.MCPAuthenticatedUser( + user_api_key_auth=owner_auth ) + mcp_server._stateful_session_owners[session_id] = mcp_server._owner_fingerprint_for(owner_auth) stream_release = asyncio.Event() post_finished = asyncio.Event() @@ -2571,9 +2613,7 @@ async def test_stateful_mcp_get_stream_does_not_block_post(): "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", True, ), - patch.object( - session_manager_stateful, "handle_request", side_effect=handle - ), + patch.object(session_manager_stateful, "handle_request", side_effect=handle), patch.object( session_manager_stateful, "_server_instances", @@ -2617,10 +2657,7 @@ def test_jsonrpc_text_has_top_level_method_ignores_nested_method(): assert _jsonrpc_text_has_top_level_method(reordered) is True # response whose result nests a "method" key (and arrays of them) - response = ( - '{"jsonrpc":"2.0","id":1,"result":{"toolResult":{"method":"GET"},' - '"steps":[{"method":"x"}]}}' - ) + response = '{"jsonrpc":"2.0","id":1,"result":{"toolResult":{"method":"GET"},"steps":[{"method":"x"}]}}' assert _jsonrpc_text_has_top_level_method(response) is False # truncated response: result value never closes, no top-level method seen @@ -2645,12 +2682,10 @@ async def test_truncated_jsonrpc_response_with_nested_method_skips_lock(): session_id = "nested-method-response-session" owner_auth = UserAPIKeyAuth(api_key="owner-key", user_id="owner") - mcp_server._stateful_session_auth_contexts[session_id] = ( - mcp_server.MCPAuthenticatedUser(user_api_key_auth=owner_auth) - ) - mcp_server._stateful_session_owners[session_id] = mcp_server._owner_fingerprint_for( - owner_auth + mcp_server._stateful_session_auth_contexts[session_id] = mcp_server.MCPAuthenticatedUser( + user_api_key_auth=owner_auth ) + mcp_server._stateful_session_owners[session_id] = mcp_server._owner_fingerprint_for(owner_auth) gate = asyncio.Event() request_in_handle = asyncio.Event() @@ -2687,8 +2722,7 @@ async def test_truncated_jsonrpc_response_with_nested_method_skips_lock(): # parsed, with a nested "method" key in the first bytes to trip a flat # substring heuristic. response_body = ( - '{"jsonrpc":"2.0","id":99,"result":{"toolResult":' - '{"method":"GET","payload":"' + ("x" * 5000) + '"}}}' + '{"jsonrpc":"2.0","id":99,"result":{"toolResult":{"method":"GET","payload":"' + ("x" * 5000) + '"}}}' ).encode() try: @@ -2702,9 +2736,7 @@ async def test_truncated_jsonrpc_response_with_nested_method_skips_lock(): "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", True, ), - patch.object( - session_manager_stateful, "handle_request", side_effect=handle - ), + patch.object(session_manager_stateful, "handle_request", side_effect=handle), patch.object( session_manager_stateful, "_server_instances", @@ -2776,13 +2808,9 @@ async def test_mcp_routing_with_conflicting_alias_and_group_name(): mock_get_tools_spy = AsyncMock(return_value=[]) # Mock the function that checks DB for an access group named "custom_solutions" - mock_db_lookup = AsyncMock( - return_value=[specific_server.server_id, other_server.server_id] - ) + mock_db_lookup = AsyncMock(return_value=[specific_server.server_id, other_server.server_id]) - mock_get_allowed = AsyncMock( - return_value=[specific_server.server_id, other_server.server_id] - ) + mock_get_allowed = AsyncMock(return_value=[specific_server.server_id, other_server.server_id]) with ( patch( @@ -2807,14 +2835,12 @@ async def test_mcp_routing_with_conflicting_alias_and_group_name(): ) # Get the list of actual server objects that the orchestrator tried to contact - called_servers = [ - call.kwargs["server"] for call in mock_get_tools_spy.call_args_list - ] + called_servers = [call.kwargs["server"] for call in mock_get_tools_spy.call_args_list] assert len(called_servers) == 1, "Should have resolved to exactly one server." - assert ( - called_servers[0].server_id == specific_server.server_id - ), "Should have contacted the specific server alias, not the group." + assert called_servers[0].server_id == specific_server.server_id, ( + "Should have contacted the specific server alias, not the group." + ) @pytest.mark.asyncio @@ -2928,9 +2954,7 @@ async def test_oauth2_caller_headers_not_forwarded_for_migrated_server(): ) # Verify that _create_mcp_client was called - assert ( - mock_create_client.call_count == 1 - ), "Expected _create_mcp_client to be called once" + assert mock_create_client.call_count == 1, "Expected _create_mcp_client to be called once" # Verify the server passed to _create_mcp_client is the OAuth2 server assert captured_client_args["server"].server_id == oauth2_server.server_id @@ -2940,9 +2964,9 @@ async def test_oauth2_caller_headers_not_forwarded_for_migrated_server(): # oauth2 Authorization upstream. The v2 resolver injects the stored per-user token, # so a caller-supplied bearer cannot override another user's stored credential. extra_headers = captured_client_args["extra_headers"] - assert extra_headers is None or "Authorization" not in { - k.lower() for k in extra_headers - }, f"Caller Authorization must not be forwarded, got {extra_headers}" + assert extra_headers is None or "Authorization" not in {k.lower() for k in extra_headers}, ( + f"Caller Authorization must not be forwarded, got {extra_headers}" + ) @pytest.mark.asyncio @@ -3051,12 +3075,8 @@ async def test_list_tools_multiple_servers_prefixed_names(): # Mock manager mock_manager = MagicMock() - mock_manager.get_allowed_mcp_servers = AsyncMock( - return_value=["server1", "server2"] - ) - mock_manager.get_mcp_server_by_id = lambda server_id: ( - server1 if server_id == "server1" else server2 - ) + mock_manager.get_allowed_mcp_servers = AsyncMock(return_value=["server1", "server2"]) + mock_manager.get_mcp_server_by_id = lambda server_id: server1 if server_id == "server1" else server2 # Mock filter_server_ids_by_ip to return server_ids unchanged (no IP filtering) mock_manager.filter_server_ids_by_ip_with_info = lambda server_ids, client_ip: ( server_ids, @@ -3655,9 +3675,7 @@ async def test_list_tools_strips_prefix_when_matching_permissions(): tool1.inputSchema = {} tool2 = MagicMock() - tool2.name = ( - "GITMCP-search_litellm_documentation" # Prefixed, not in allowed list - ) + tool2.name = "GITMCP-search_litellm_documentation" # Prefixed, not in allowed list tool2.description = "Search docs" tool2.inputSchema = {} @@ -3878,17 +3896,13 @@ class TestMCPServerManagerReload: db_row = _make_db_mcp_server("server-1", timestamp) mock_prisma = MagicMock() - mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock( - return_value=[db_row] - ) + mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock(return_value=[db_row]) with ( patch( "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", return_value=mock_prisma, ), - patch.object( - manager, "build_mcp_server_from_table", AsyncMock() - ) as mock_build, + patch.object(manager, "build_mcp_server_from_table", AsyncMock()) as mock_build, ): await manager.reload_servers_from_database() @@ -3924,9 +3938,7 @@ class TestMCPServerManagerReload: ) mock_prisma = MagicMock() - mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock( - return_value=[db_row] - ) + mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock(return_value=[db_row]) with ( patch( "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", @@ -4051,9 +4063,7 @@ class TestMCPServerManagerReload: raise RuntimeError("blocked address") mock_prisma = MagicMock() - mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock( - return_value=[healthy_row, bad_openapi_row] - ) + mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock(return_value=[healthy_row, bad_openapi_row]) with ( patch( "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", @@ -4149,10 +4159,7 @@ async def test_call_mcp_tool_logs_failure_via_post_call_failure_hook(): ) proxy_logging_mock.post_call_failure_hook.assert_awaited_once() - assert ( - proxy_logging_mock.post_call_failure_hook.await_args.kwargs.get("route") - == "/mcp/call_tool" - ) + assert proxy_logging_mock.post_call_failure_hook.await_args.kwargs.get("route") == "/mcp/call_tool" @pytest.mark.asyncio @@ -4234,9 +4241,7 @@ async def test_get_tools_from_mcp_servers_logs_list_tools_to_spendlogs_when_enab assert tools == [tool_1] dummy_logging_obj.async_success_handler.assert_awaited_once() - assert dummy_logging_obj.async_success_handler.await_args.kwargs["result"] == [ - tool_1.model_dump(mode="json") - ] + assert dummy_logging_obj.async_success_handler.await_args.kwargs["result"] == [tool_1.model_dump(mode="json")] assert function_setup_kwargs["metadata"]["tags"] == ["team-a"] spend_meta = dummy_logging_obj.model_call_details["metadata"]["spend_logs_metadata"] @@ -4582,9 +4587,7 @@ async def test_get_tools_from_mcp_servers_injects_stored_oauth2_token(): oauth2_server.extra_headers = None # Simulate the DB returning a valid credential for this user+server - prefetched_creds = { - SERVER_ID: {"access_token": STORED_TOKEN, "server_id": SERVER_ID} - } + prefetched_creds = {SERVER_ID: {"access_token": STORED_TOKEN, "server_id": SERVER_ID}} tool_1 = MagicMock() tool_1.name = "atlassian_test-search" @@ -4691,16 +4694,12 @@ class TestMergeGatewayInitializeInstructions: global_mcp_server_manager, ) - global_mcp_server_manager._upstream_initialize_instructions_by_server_id[ - "s1" - ] = "upstream" + global_mcp_server_manager._upstream_initialize_instructions_by_server_id["s1"] = "upstream" try: s = _make_instruction_server(instructions="yaml wins") assert self._merge([s]) == "yaml wins" finally: - global_mcp_server_manager._upstream_initialize_instructions_by_server_id.pop( - "s1", None - ) + global_mcp_server_manager._upstream_initialize_instructions_by_server_id.pop("s1", None) def test_upstream_cache_used_when_no_yaml(self): """Upstream cached instructions are used when no YAML override is set.""" @@ -4708,16 +4707,12 @@ class TestMergeGatewayInitializeInstructions: global_mcp_server_manager, ) - global_mcp_server_manager._upstream_initialize_instructions_by_server_id[ - "s1" - ] = "from upstream" + global_mcp_server_manager._upstream_initialize_instructions_by_server_id["s1"] = "from upstream" try: s = _make_instruction_server(instructions=None) assert self._merge([s]) == "from upstream" finally: - global_mcp_server_manager._upstream_initialize_instructions_by_server_id.pop( - "s1", None - ) + global_mcp_server_manager._upstream_initialize_instructions_by_server_id.pop("s1", None) def test_spec_path_servers_skipped(self): """OpenAPI (spec_path) servers do not contribute instructions.""" @@ -4731,12 +4726,8 @@ class TestMergeGatewayInitializeInstructions: def test_multiple_servers_merged_with_labels(self): """Multiple servers get label-prefixed and separator-joined.""" - s1 = _make_instruction_server( - server_id="a", name="a", alias="Alpha", instructions="instr A" - ) - s2 = _make_instruction_server( - server_id="b", name="b", alias="Beta", instructions="instr B" - ) + s1 = _make_instruction_server(server_id="a", name="a", alias="Alpha", instructions="instr A") + s2 = _make_instruction_server(server_id="b", name="b", alias="Beta", instructions="instr B") result = self._merge([s1, s2]) assert result is not None assert "[Alpha]" in result and "[Beta]" in result @@ -4756,25 +4747,17 @@ class TestMergeGatewayInitializeInstructions: global_mcp_server_manager, ) - global_mcp_server_manager._upstream_initialize_instructions_by_server_id[ - "c" - ] = "cached C" + global_mcp_server_manager._upstream_initialize_instructions_by_server_id["c"] = "cached C" try: - s_yaml = _make_instruction_server( - server_id="a", name="a", alias="A", instructions="yaml A" - ) - s_spec = _make_instruction_server( - server_id="b", name="b", alias="B", spec_path="/spec.json", url=None - ) + s_yaml = _make_instruction_server(server_id="a", name="a", alias="A", instructions="yaml A") + s_spec = _make_instruction_server(server_id="b", name="b", alias="B", spec_path="/spec.json", url=None) s_cached = _make_instruction_server(server_id="c", name="c", alias="C") result = self._merge([s_yaml, s_spec, s_cached]) assert "yaml A" in result assert "cached C" in result assert "[B]" not in result finally: - global_mcp_server_manager._upstream_initialize_instructions_by_server_id.pop( - "c", None - ) + global_mcp_server_manager._upstream_initialize_instructions_by_server_id.pop("c", None) class TestEnsureUpstreamInitializeInstructionsCached: @@ -4786,15 +4769,9 @@ class TestEnsureUpstreamInitializeInstructionsCached: global_mcp_server_manager, ) - server = _make_instruction_server( - server_id="yaml-only", instructions="from yaml" - ) - with patch.object( - global_mcp_server_manager, "_create_mcp_client", AsyncMock() - ) as mock_create: - await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached( - server - ) + server = _make_instruction_server(server_id="yaml-only", instructions="from yaml") + with patch.object(global_mcp_server_manager, "_create_mcp_client", AsyncMock()) as mock_create: + await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached(server) mock_create.assert_not_awaited() @pytest.mark.asyncio @@ -4806,21 +4783,13 @@ class TestEnsureUpstreamInitializeInstructionsCached: ) server = _make_instruction_server(server_id="cached-only", instructions=None) - global_mcp_server_manager._upstream_initialize_instructions_by_server_id[ - "cached-only" - ] = "warm" + global_mcp_server_manager._upstream_initialize_instructions_by_server_id["cached-only"] = "warm" try: - with patch.object( - global_mcp_server_manager, "_create_mcp_client", AsyncMock() - ) as mock_create: - await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached( - server - ) + with patch.object(global_mcp_server_manager, "_create_mcp_client", AsyncMock()) as mock_create: + await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached(server) mock_create.assert_not_awaited() finally: - global_mcp_server_manager._upstream_initialize_instructions_by_server_id.pop( - "cached-only", None - ) + global_mcp_server_manager._upstream_initialize_instructions_by_server_id.pop("cached-only", None) @pytest.mark.asyncio async def test_skips_when_spec_path_set(self): @@ -4830,15 +4799,9 @@ class TestEnsureUpstreamInitializeInstructionsCached: global_mcp_server_manager, ) - server = _make_instruction_server( - server_id="openapi-spec", spec_path="/openapi.json", url=None - ) - with patch.object( - global_mcp_server_manager, "_create_mcp_client", AsyncMock() - ) as mock_create: - await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached( - server - ) + server = _make_instruction_server(server_id="openapi-spec", spec_path="/openapi.json", url=None) + with patch.object(global_mcp_server_manager, "_create_mcp_client", AsyncMock()) as mock_create: + await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached(server) mock_create.assert_not_awaited() @pytest.mark.asyncio @@ -4860,22 +4823,14 @@ class TestEnsureUpstreamInitializeInstructionsCached: AsyncMock(return_value=fake_client), ): try: - await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached( - server - ) + await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached(server) assert ( - global_mcp_server_manager._upstream_initialize_instructions_by_server_id[ - "cold-server" - ] + global_mcp_server_manager._upstream_initialize_instructions_by_server_id["cold-server"] == "upstream says hi" ) finally: - global_mcp_server_manager._upstream_initialize_instructions_by_server_id.pop( - "cold-server", None - ) - global_mcp_server_manager._upstream_initialize_instructions_probed_at.pop( - "cold-server", None - ) + global_mcp_server_manager._upstream_initialize_instructions_by_server_id.pop("cold-server", None) + global_mcp_server_manager._upstream_initialize_instructions_probed_at.pop("cold-server", None) @pytest.mark.asyncio async def test_cooldown_after_empty_upstream_response(self): @@ -4894,27 +4849,13 @@ class TestEnsureUpstreamInitializeInstructionsCached: create = AsyncMock(return_value=fake_client) with patch.object(global_mcp_server_manager, "_create_mcp_client", create): try: - await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached( - server - ) - await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached( - server - ) - assert ( - create.await_count == 1 - ), "Second probe within cooldown must not reconnect to upstream" - assert ( - "empty-server" - not in global_mcp_server_manager._upstream_initialize_instructions_by_server_id - ) - assert ( - "empty-server" - in global_mcp_server_manager._upstream_initialize_instructions_probed_at - ) + await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached(server) + await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached(server) + assert create.await_count == 1, "Second probe within cooldown must not reconnect to upstream" + assert "empty-server" not in global_mcp_server_manager._upstream_initialize_instructions_by_server_id + assert "empty-server" in global_mcp_server_manager._upstream_initialize_instructions_probed_at finally: - global_mcp_server_manager._upstream_initialize_instructions_probed_at.pop( - "empty-server", None - ) + global_mcp_server_manager._upstream_initialize_instructions_probed_at.pop("empty-server", None) @pytest.mark.asyncio async def test_cooldown_after_upstream_failure(self): @@ -4927,35 +4868,19 @@ class TestEnsureUpstreamInitializeInstructionsCached: server = _make_instruction_server(server_id="boom-server", instructions=None) fake_client = MagicMock() - fake_client.run_with_session = AsyncMock( - side_effect=RuntimeError("upstream down") - ) + fake_client.run_with_session = AsyncMock(side_effect=RuntimeError("upstream down")) fake_client._last_initialize_instructions = None create = AsyncMock(return_value=fake_client) with patch.object(global_mcp_server_manager, "_create_mcp_client", create): try: - await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached( - server - ) - await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached( - server - ) - assert ( - create.await_count == 1 - ), "Second probe within cooldown must not reconnect after failure" - assert ( - "boom-server" - not in global_mcp_server_manager._upstream_initialize_instructions_by_server_id - ) - assert ( - "boom-server" - in global_mcp_server_manager._upstream_initialize_instructions_probed_at - ) + await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached(server) + await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached(server) + assert create.await_count == 1, "Second probe within cooldown must not reconnect after failure" + assert "boom-server" not in global_mcp_server_manager._upstream_initialize_instructions_by_server_id + assert "boom-server" in global_mcp_server_manager._upstream_initialize_instructions_probed_at finally: - global_mcp_server_manager._upstream_initialize_instructions_probed_at.pop( - "boom-server", None - ) + global_mcp_server_manager._upstream_initialize_instructions_probed_at.pop("boom-server", None) @pytest.mark.asyncio async def test_reload_resets_probe_cooldown(self): @@ -4964,19 +4889,12 @@ class TestEnsureUpstreamInitializeInstructionsCached: global_mcp_server_manager, ) - global_mcp_server_manager._upstream_initialize_instructions_probed_at[ - "reload-target" - ] = 1.0 + global_mcp_server_manager._upstream_initialize_instructions_probed_at["reload-target"] = 1.0 try: await global_mcp_server_manager.load_servers_from_config({}) - assert ( - "reload-target" - not in global_mcp_server_manager._upstream_initialize_instructions_probed_at - ) + assert "reload-target" not in global_mcp_server_manager._upstream_initialize_instructions_probed_at finally: - global_mcp_server_manager._upstream_initialize_instructions_probed_at.pop( - "reload-target", None - ) + global_mcp_server_manager._upstream_initialize_instructions_probed_at.pop("reload-target", None) class TestGatewayCreateInitializationOptions: @@ -5042,9 +4960,7 @@ class TestGatewayCreateInitializationOptions: ): assert server.create_initialization_options().server_name == "grafana" - assert ( - server.create_initialization_options().server_name == "litellm-mcp-server" - ) + assert server.create_initialization_options().server_name == "litellm-mcp-server" @pytest.mark.asyncio async def test_sse_handler_scopes_server_name_from_single_server_path(self): @@ -5121,9 +5037,7 @@ class TestGatewayCreateInitializationOptions: await handle_sse_mcp(scope, AsyncMock(), AsyncMock()) assert captured["server_name"] == "grafana" - assert ( - server.create_initialization_options().server_name == "litellm-mcp-server" - ) + assert server.create_initialization_options().server_name == "litellm-mcp-server" def test_contextvar_set_injects_instructions(self): """When ContextVar has a value, it appears in InitializationOptions.""" @@ -5241,12 +5155,8 @@ async def test_list_tools_with_legacy_db_m2m_server_resolves_oauth2_flow(): ): mock_manager.get_allowed_mcp_servers = AsyncMock(return_value=["legacy-m2m-id"]) mock_manager.get_mcp_server_by_id = MagicMock(return_value=legacy_server) - mock_manager.filter_server_ids_by_ip_with_info = MagicMock( - return_value=(["legacy-m2m-id"], 0) - ) - mock_manager._get_tools_from_server = AsyncMock( - side_effect=capture_extra_headers - ) + mock_manager.filter_server_ids_by_ip_with_info = MagicMock(return_value=(["legacy-m2m-id"], 0)) + mock_manager._get_tools_from_server = AsyncMock(side_effect=capture_extra_headers) tools = await _get_tools_from_mcp_servers( user_api_key_auth=user_auth, @@ -5338,10 +5248,8 @@ async def test_call_tool_empty_extra_headers_returns_none(): pass # We only care about the captured headers # With P2 fix: extra_headers should be None (not {}) when all headers filtered - assert ( - captured_extra_headers is None - ), "P2 API consistency issue: expected None for empty extra_headers, got: " + str( - captured_extra_headers + assert captured_extra_headers is None, ( + "P2 API consistency issue: expected None for empty extra_headers, got: " + str(captured_extra_headers) ) @@ -5366,9 +5274,7 @@ async def test_probe_upstream_auth_returns_upstream_status(): "litellm.proxy._experimental.mcp_server.server.get_async_httpx_client", return_value=mock_client, ): - status, www_auth = await _probe_upstream_auth( - "http://upstream/mcp", "Bearer some-token" - ) + status, www_auth = await _probe_upstream_auth("http://upstream/mcp", "Bearer some-token") assert status == 401 assert www_auth == 'Bearer realm="test"' @@ -5395,9 +5301,7 @@ async def test_probe_upstream_auth_surfaces_httpx_status_error(): mock_response.status_code = 401 mock_response.headers = {"www-authenticate": 'Bearer realm="test"'} request = httpx.Request("POST", "http://upstream/mcp") - error = httpx.HTTPStatusError( - message="401 Unauthorized", request=request, response=mock_response - ) + error = httpx.HTTPStatusError(message="401 Unauthorized", request=request, response=mock_response) mock_client = MagicMock() mock_client.post = AsyncMock(side_effect=error) @@ -5406,9 +5310,7 @@ async def test_probe_upstream_auth_surfaces_httpx_status_error(): "litellm.proxy._experimental.mcp_server.server.get_async_httpx_client", return_value=mock_client, ): - status, www_auth = await _probe_upstream_auth( - "http://upstream/mcp", "Bearer some-token" - ) + status, www_auth = await _probe_upstream_auth("http://upstream/mcp", "Bearer some-token") assert status == 401 assert www_auth == 'Bearer realm="test"' @@ -5426,9 +5328,7 @@ async def test_probe_upstream_auth_fails_open_on_network_error(): "litellm.proxy._experimental.mcp_server.server.get_async_httpx_client", return_value=mock_client, ): - status, www_auth = await _probe_upstream_auth( - "http://upstream/mcp", "Bearer some-token" - ) + status, www_auth = await _probe_upstream_auth("http://upstream/mcp", "Bearer some-token") assert status == 200 assert www_auth is None @@ -6351,9 +6251,7 @@ class TestProxyExceptionToHttpException: from litellm.proxy._types import ProxyException http_exc = _proxy_exception_to_http_exception( - ProxyException( - message="Forbidden", type="auth_error", param="key", code=403 - ) + ProxyException(message="Forbidden", type="auth_error", param="key", code=403) ) assert http_exc.status_code == 403 @@ -6417,10 +6315,7 @@ class TestStreamableHttpAuthErrorMapping: assert exc_info.value.status_code == 401 assert exc_info.value.headers["WWW-Authenticate"] == "Bearer" # Must not have emitted a 500 body via the generic catch-all. - assert not any( - m.get("type") == "http.response.start" and m.get("status") == 500 - for m in sent - ) + assert not any(m.get("type") == "http.response.start" and m.get("status") == 500 for m in sent) @pytest.mark.asyncio async def test_sse_propagates_proxy_exception_as_401(self): @@ -6460,10 +6355,7 @@ class TestStreamableHttpAuthErrorMapping: assert exc_info.value.status_code == 401 assert exc_info.value.headers["WWW-Authenticate"] == "Bearer" - assert not any( - m.get("type") == "http.response.start" and m.get("status") == 500 - for m in sent - ) + assert not any(m.get("type") == "http.response.start" and m.get("status") == 500 for m in sent) class TestMCPMetaTraceCarrier: diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index a423f7c8b83..12e22de195b 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -1267,6 +1267,341 @@ class TestMCPServerManager: assert captured_extra_headers == {"Authorization": "Bearer upstream-oauth-bearer"} + async def _capture_call_extra_headers(self, server, oauth2_headers, raw_headers, user_api_key_auth): + manager = MCPServerManager() + mock_client = AsyncMock() + mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], isError=False)) + captured = {"extra_headers": "unset"} + + async def capture_create_mcp_client( + server, mcp_auth_header, extra_headers, stdio_env, subject_token=None, **kwargs + ): # pragma: no cover - helper + captured["extra_headers"] = extra_headers + return mock_client + + manager._create_mcp_client = AsyncMock(side_effect=capture_create_mcp_client) + await manager._call_regular_mcp_tool( + mcp_server=server, + original_tool_name="tool", + arguments={}, + tasks=[], + mcp_auth_header=None, + mcp_server_auth_headers=None, + oauth2_headers=oauth2_headers, + raw_headers=raw_headers, + proxy_logging_obj=None, + user_api_key_auth=user_api_key_auth, + ) + return captured["extra_headers"] + + @pytest.mark.asyncio + async def test_call_regular_mcp_tool_true_passthrough_forwards_authorization(self): + from litellm.proxy._types import UserAPIKeyAuth + + server = MCPServer( + server_id="server-true-passthrough", + name="tp-server", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.true_passthrough, + ) + extra_headers = await self._capture_call_extra_headers( + server, + oauth2_headers={"Authorization": "Bearer upstream-token"}, + raw_headers={"authorization": "Bearer upstream-token"}, + user_api_key_auth=UserAPIKeyAuth(api_key=None), + ) + assert extra_headers == {"Authorization": "Bearer upstream-token"} + + @pytest.mark.asyncio + async def test_call_regular_mcp_tool_oauth_delegate_forwards_separate_authorization(self): + from litellm.proxy._types import UserAPIKeyAuth + + server = MCPServer( + server_id="server-oauth-delegate", + name="od-server", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth_delegate, + ) + extra_headers = await self._capture_call_extra_headers( + server, + oauth2_headers={"Authorization": "Bearer upstream-token"}, + raw_headers={ + "x-litellm-api-key": "Bearer sk-litellm-key", + "authorization": "Bearer upstream-token", + }, + user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-key"), + ) + assert extra_headers == {"Authorization": "Bearer upstream-token"} + + @pytest.mark.asyncio + async def test_call_regular_mcp_tool_oauth_delegate_never_forwards_admission_key(self): + from litellm.proxy._types import UserAPIKeyAuth + + server = MCPServer( + server_id="server-oauth-delegate-leak", + name="od-server", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth_delegate, + ) + extra_headers = await self._capture_call_extra_headers( + server, + oauth2_headers={"Authorization": "Bearer sk-litellm-key"}, + raw_headers={"authorization": "Bearer sk-litellm-key"}, + user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-key"), + ) + assert not extra_headers or "authorization" not in {k.lower() for k in extra_headers} + + def test_should_strip_caller_authorization_new_modes(self): + from litellm.proxy._types import UserAPIKeyAuth + + true_passthrough = MCPServer( + server_id="tp", + name="tp", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.true_passthrough, + ) + assert ( + _should_strip_caller_authorization( + mcp_server=true_passthrough, + raw_headers={"authorization": "Bearer upstream"}, + user_api_key_auth=UserAPIKeyAuth(api_key=None), + ) + is False + ) + + oauth_delegate = MCPServer( + server_id="od", + name="od", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth_delegate, + ) + assert ( + _should_strip_caller_authorization( + mcp_server=oauth_delegate, + raw_headers={ + "x-litellm-api-key": "Bearer sk-litellm-key", + "authorization": "Bearer upstream", + }, + user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-key"), + ) + is False + ) + assert ( + _should_strip_caller_authorization( + mcp_server=oauth_delegate, + raw_headers={"authorization": "Bearer sk-litellm-key"}, + user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-key"), + ) + is True + ) + + def test_should_strip_authorization_for_oauth_delegate_admitted_via_jwt_without_api_key(self): + """JWT / SSO / OIDC / session admission yields a UserAPIKeyAuth with a user_id but + api_key=None; the caller's Authorization was that credential and must be stripped for + oauth_delegate when no separate x-litellm-api-key carried admission (LIT-3794-class leak).""" + from litellm.proxy._types import UserAPIKeyAuth + + oauth_delegate = MCPServer( + server_id="od-jwt", + name="od", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth_delegate, + ) + assert ( + _should_strip_caller_authorization( + mcp_server=oauth_delegate, + raw_headers={"authorization": "Bearer eyJ-idp-jwt"}, + user_api_key_auth=UserAPIKeyAuth(user_id="alice", api_key=None), + ) + is True + ) + assert ( + _should_strip_caller_authorization( + mcp_server=oauth_delegate, + raw_headers={ + "x-litellm-api-key": "Bearer sk-1234", + "authorization": "Bearer upstream", + }, + user_api_key_auth=UserAPIKeyAuth(user_id="alice", api_key=None), + ) + is False + ) + + @pytest.mark.asyncio + async def test_call_regular_mcp_tool_oauth_delegate_never_forwards_jwt_admission(self): + from litellm.proxy._types import UserAPIKeyAuth + + server = MCPServer( + server_id="od-jwt-e2e", + name="od", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth_delegate, + ) + extra_headers = await self._capture_call_extra_headers( + server, + oauth2_headers={"Authorization": "Bearer eyJ-idp-jwt"}, + raw_headers={"authorization": "Bearer eyJ-idp-jwt"}, + user_api_key_auth=UserAPIKeyAuth(user_id="alice", api_key=None), + ) + assert not extra_headers or "authorization" not in {k.lower() for k in extra_headers} + + def test_new_passthrough_modes_require_per_user_auth(self): + for auth_type in (MCPAuth.true_passthrough, MCPAuth.oauth_delegate): + server = MCPServer( + server_id="s", + name="s", + url="https://example.com", + transport=MCPTransport.http, + auth_type=auth_type, + ) + assert server.requires_per_user_auth is True + + @pytest.mark.asyncio + async def test_create_mcp_client_forwarded_modes_use_the_passthrough_arm(self): + manager = MCPServerManager() + server = MCPServer( + server_id="tp-egress", + name="tp", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.true_passthrough, + ) + with ( + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.resolve_mcp_auth", + new_callable=AsyncMock, + ) as mock_resolve, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.MCPClient") as mock_client_cls, + ): + await manager._create_mcp_client(server=server, extra_headers={"Authorization": "Bearer upstream-token"}) + mock_resolve.assert_not_awaited() + kwargs = mock_client_cls.call_args.kwargs + emitted = httpx.Request("GET", "https://example.com/mcp") + flow = kwargs["resolved_auth"].auth_flow(emitted) + next(flow) + flow.close() + assert emitted.headers["Authorization"] == "Bearer upstream-token" + assert not kwargs["extra_headers"] or "authorization" not in {k.lower() for k in kwargs["extra_headers"]} + + @staticmethod + def _emitted_authorization(mock_client_cls) -> str: + kwargs = mock_client_cls.call_args.kwargs + emitted = httpx.Request("GET", "https://example.com/mcp") + flow = kwargs["resolved_auth"].auth_flow(emitted) + next(flow) + flow.close() + return emitted.headers["Authorization"] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "per_server_header", + ["Bearer per-server-token", {"Authorization": "Bearer per-server-token"}], + ) + async def test_create_mcp_client_passthrough_prefers_per_server_token(self, per_server_header): + """A per-server x-mcp-{alias}-authorization value is the explicit one-token-one-server + binding, so it must win over the request-wide Authorization and reach the upstream + verbatim through the passthrough arm.""" + manager = MCPServerManager() + server = MCPServer( + server_id="tp-per-server", + name="tp", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.true_passthrough, + ) + with ( + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.resolve_mcp_auth", + new_callable=AsyncMock, + ) as mock_resolve, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.MCPClient") as mock_client_cls, + ): + await manager._create_mcp_client( + server=server, + mcp_auth_header=per_server_header, + extra_headers={"Authorization": "Bearer global-token"}, + ) + mock_resolve.assert_not_awaited() + assert self._emitted_authorization(mock_client_cls) == "Bearer per-server-token" + kwargs = mock_client_cls.call_args.kwargs + assert not kwargs["extra_headers"] or "authorization" not in {k.lower() for k in kwargs["extra_headers"]} + + def test_consumes_caller_authorization_per_mode(self): + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + _consumes_caller_authorization, + ) + + def build(**kwargs) -> MCPServer: + return MCPServer( + server_id="s", + name="s", + url="https://example.com", + transport=MCPTransport.http, + **kwargs, + ) + + assert _consumes_caller_authorization(build(auth_type=MCPAuth.true_passthrough)) is True + assert _consumes_caller_authorization(build(auth_type=MCPAuth.oauth_delegate)) is True + assert ( + _consumes_caller_authorization( + build(auth_type=MCPAuth.none, extra_headers=["Authorization"], oauth_passthrough=True) + ) + is True + ) + assert _consumes_caller_authorization(build(auth_type=MCPAuth.oauth2, delegate_auth_to_upstream=True)) is True + assert _consumes_caller_authorization(build(auth_type=MCPAuth.api_key, authentication_token="x")) is False + assert ( + _consumes_caller_authorization( + build( + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, + oauth2_flow="client_credentials", + token_url="https://idp/token", + ) + ) + is False + ) + + def test_caller_authorization_fans_out_only_with_second_consumer(self): + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + _caller_authorization_fans_out, + ) + + delegate = MCPServer( + server_id="od", + name="od", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth_delegate, + ) + second = MCPServer( + server_id="tp", + name="tp", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.true_passthrough, + ) + static_server = MCPServer( + server_id="static", + name="static", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.api_key, + authentication_token="x", + ) + + assert _caller_authorization_fans_out(delegate, None) is False + assert _caller_authorization_fans_out(delegate, [delegate]) is False + assert _caller_authorization_fans_out(delegate, [delegate, static_server]) is False + assert _caller_authorization_fans_out(delegate, [delegate, second]) is True + @pytest.mark.asyncio async def test_get_prompts_from_server_success(self): """Ensure prompts are fetched and prefixed when requested.""" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py index 76614a35b53..da183e8d02a 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py @@ -713,6 +713,9 @@ async def test_handle_streamable_http_mcp_delegated_server_surfaces_upstream_cha delegated_server.auth_type = MCPAuth.oauth2 delegated_server.delegate_auth_to_upstream = True delegated_server.needs_user_oauth_token = True + delegated_server.is_oauth_passthrough = False + delegated_server.is_oauth_delegate = False + delegated_server.is_true_passthrough = False delegated_server.server_id = "delegated-oauth-server" upstream_challenge = 'Bearer resource_metadata="https://upstream.example.com/.well-known/oauth-protected-resource"' @@ -1048,3 +1051,424 @@ async def test_handle_streamable_http_mcp_token_exchange_without_subject_returns assert "/.well-known/oauth-protected-resource" in challenge assert challenge.split('resource_metadata="', 1)[1].split('"', 1)[0].endswith("/mcp/obo_server") assert 'error="invalid_token"' in challenge + + +def _passthrough_mode_scope(server_name: str, extra_headers=None): + headers = [ + (b"content-type", b"application/json"), + (b"host", b"litellm.example.com"), + ] + list(extra_headers or []) + return { + "type": "http", + "method": "POST", + "path": f"/mcp/{server_name}", + "_original_path": f"/{server_name}/mcp", + "scheme": "https", + "query_string": b"", + "root_path": "", + "server": ("litellm.example.com", 443), + "headers": headers, + } + + +def _build_passthrough_mode_server(server_name: str, auth_type): + from litellm.proxy._types import MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + return MCPServer( + server_id=f"{server_name}-id", + name=server_name, + server_name=server_name, + alias=server_name, + url="https://upstream.example.com/mcp", + transport=MCPTransport.http, + auth_type=auth_type, + ) + + +@pytest.mark.asyncio +async def test_handle_streamable_http_mcp_oauth_delegate_without_token_returns_gateway_proxied_401(): + """oauth_delegate is admitted with the LiteLLM key but still owns upstream + OAuth. With no forwarded upstream token the gateway must challenge with the + proxied resource_metadata (which advertises the upstream IdP), never the + gateway authorization_uri and never a silent 200.""" + from fastapi import HTTPException + + try: + from litellm.proxy._experimental.mcp_server.server import ( + handle_streamable_http_mcp, + session_manager_stateful, + ) + except ImportError: + pytest.skip("MCP server not available") + + scope = _passthrough_mode_scope("od_server") + receive = AsyncMock( + return_value={ + "type": "http.request", + "body": b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}', + "more_body": False, + } + ) + send = AsyncMock() + user_auth = MagicMock() + user_auth.user_id = "u1" + od_server = _build_passthrough_mode_server("od_server", MCPAuth.oauth_delegate) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", + new_callable=AsyncMock, + return_value=(user_auth, None, ["od_server"], None, None, None), + ), + patch("litellm.proxy._experimental.mcp_server.server.set_auth_context"), + patch( + "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", + True, + ), + patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager.get_mcp_server_by_name", + return_value=od_server, + ), + patch.object( + session_manager_stateful, + "handle_request", + new_callable=AsyncMock, + ) as mock_handle_request, + ): + with pytest.raises(HTTPException) as exc_info: + await handle_streamable_http_mcp(scope, receive, send) + + assert mock_handle_request.await_count == 0 + assert exc_info.value.status_code == 401 + challenge = exc_info.value.headers["www-authenticate"] + assert "resource_metadata=" in challenge + assert "authorization_uri=" not in challenge + assert "/.well-known/oauth-protected-resource/od_server/mcp" in challenge + + +@pytest.mark.asyncio +async def test_handle_streamable_http_mcp_oauth_delegate_with_forwarded_token_skips_challenge(): + """When the oauth_delegate caller carries both the LiteLLM key and a separate + upstream Authorization, the gateway must forward to the session manager, not + re-challenge. Guards the ``_get_forwarded_auth_from_scope(...) is None`` + condition: dropping it would 401 even a fully-authenticated request.""" + try: + from litellm.proxy._experimental.mcp_server.server import ( + handle_streamable_http_mcp, + session_manager_stateless, + ) + except ImportError: + pytest.skip("MCP server not available") + + scope = _passthrough_mode_scope( + "od_server", + extra_headers=[ + (b"x-litellm-api-key", b"Bearer sk-1234"), + (b"authorization", b"Bearer upstream-token"), + ], + ) + receive = AsyncMock( + return_value={ + "type": "http.request", + "body": b'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}', + "more_body": False, + } + ) + send = AsyncMock() + user_auth = MagicMock() + user_auth.user_id = "u1" + od_server = _build_passthrough_mode_server("od_server", MCPAuth.oauth_delegate) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", + new_callable=AsyncMock, + return_value=(user_auth, None, ["od_server"], None, None, None), + ), + patch("litellm.proxy._experimental.mcp_server.server.set_auth_context"), + patch( + "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", + True, + ), + patch( + "litellm.proxy._experimental.mcp_server.server._handle_stale_mcp_session", + new_callable=AsyncMock, + return_value=False, + ), + patch( + "litellm.proxy._experimental.mcp_server.server._check_passthrough_upstream_auth", + new_callable=AsyncMock, + ), + patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager.get_mcp_server_by_name", + return_value=od_server, + ), + patch.object( + session_manager_stateless, + "handle_request", + new_callable=AsyncMock, + ) as mock_handle_request, + patch.object(session_manager_stateless, "_server_instances", {}), + ): + await handle_streamable_http_mcp(scope, receive, send) + + assert mock_handle_request.await_count == 1 + + +async def _run_passthrough_connect( + *, + auth_type, + server_names, + mcp_server_auth_headers, + scope_extra_headers=None, +): + """Drive handle_streamable_http_mcp through the preemptive-401 gate and report whether it + challenged (raised) or forwarded to the session manager. Returns (challenged, www_authenticate).""" + from litellm.proxy._experimental.mcp_server.server import ( + handle_streamable_http_mcp, + session_manager_stateless, + ) + + scope = _passthrough_mode_scope(server_names[0], extra_headers=scope_extra_headers) + receive = AsyncMock( + return_value={ + "type": "http.request", + "body": b'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}', + "more_body": False, + } + ) + send = AsyncMock() + user_auth = MagicMock() + user_auth.user_id = "u1" + server = _build_passthrough_mode_server(server_names[0], auth_type) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", + new_callable=AsyncMock, + return_value=(user_auth, None, server_names, mcp_server_auth_headers, None, None), + ), + patch("litellm.proxy._experimental.mcp_server.server.set_auth_context"), + patch("litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", True), + patch( + "litellm.proxy._experimental.mcp_server.server._handle_stale_mcp_session", + new_callable=AsyncMock, + return_value=False, + ), + patch( + "litellm.proxy._experimental.mcp_server.server._check_passthrough_upstream_auth", + new_callable=AsyncMock, + ), + patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager.get_mcp_server_by_name", + return_value=server, + ), + patch.object(session_manager_stateless, "handle_request", new_callable=AsyncMock) as mock_handle_request, + patch.object(session_manager_stateless, "_server_instances", {}), + ): + try: + await handle_streamable_http_mcp(scope, receive, send) + except HTTPException as exc: + return True, (exc.headers or {}).get("www-authenticate") + return mock_handle_request.await_count == 0, None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("auth_type", [MCPAuth.oauth_delegate, MCPAuth.true_passthrough]) +async def test_handle_streamable_http_mcp_per_server_header_skips_preemptive_challenge(auth_type): + """A per-server x-mcp-{alias}-authorization header binds the upstream token to one server; the + connect gate must recognize it and forward instead of spuriously 401-ing, since egress already + honors it. Without this, the mandatory multi-server binding is unusable at connect.""" + try: + from litellm.proxy._experimental.mcp_server.server import handle_streamable_http_mcp # noqa: F401 + except ImportError: + pytest.skip("MCP server not available") + + challenged, _ = await _run_passthrough_connect( + auth_type=auth_type, + server_names=["pt_server"], + mcp_server_auth_headers={"pt_server": {"Authorization": "Bearer upstream-token"}}, + ) + assert challenged is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("auth_type", [MCPAuth.oauth_delegate, MCPAuth.true_passthrough]) +async def test_handle_streamable_http_mcp_sanitized_per_server_header_skips_preemptive_challenge(auth_type): + """A dashboard client sends x-mcp-{sanitize_mcp_alias_for_header(alias)}-authorization, so the + alias 'pt-server' arrives as the header key 'pt_server'. Egress resolves that via the sanitized + alias, so the connect gate must too, or it 401s a token egress would forward.""" + try: + from litellm.proxy._experimental.mcp_server.server import handle_streamable_http_mcp # noqa: F401 + except ImportError: + pytest.skip("MCP server not available") + + challenged, _ = await _run_passthrough_connect( + auth_type=auth_type, + server_names=["pt-server"], + mcp_server_auth_headers={"pt_server": {"Authorization": "Bearer upstream-token"}}, + ) + assert challenged is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("auth_type", [MCPAuth.oauth_delegate, MCPAuth.true_passthrough]) +async def test_handle_streamable_http_mcp_aggregate_does_not_preemptively_challenge(auth_type): + """A multi-server aggregate must degrade gracefully: the preemptive 401 is single-server only, so + one server missing a token cannot 401 the whole connect (the listing absorbs per-server failures).""" + try: + from litellm.proxy._experimental.mcp_server.server import handle_streamable_http_mcp # noqa: F401 + except ImportError: + pytest.skip("MCP server not available") + + challenged, _ = await _run_passthrough_connect( + auth_type=auth_type, + server_names=["pt_server", "pt_server_2"], + mcp_server_auth_headers=None, + ) + assert challenged is False + + +@pytest.mark.asyncio +async def test_handle_streamable_http_mcp_true_passthrough_without_token_surfaces_verbatim_upstream_challenge(): + """true_passthrough is a transparent proxy: with no client Authorization the + gateway probes the upstream and surfaces its own WWW-Authenticate verbatim, + so the client discovers and authorizes against the upstream directly. Guards + against answering initialize locally with a silent 200.""" + from fastapi import HTTPException + + try: + from litellm.proxy._experimental.mcp_server.server import ( + handle_streamable_http_mcp, + session_manager_stateful, + ) + except ImportError: + pytest.skip("MCP server not available") + + upstream_challenge = 'Bearer resource_metadata="https://upstream.example.com/.well-known/oauth-protected-resource"' + probe_response = MagicMock() + probe_response.status_code = 401 + probe_response.headers = {"www-authenticate": upstream_challenge} + probe_client = MagicMock() + probe_client.post = AsyncMock(return_value=probe_response) + + scope = _passthrough_mode_scope("tp_server") + receive = AsyncMock( + return_value={ + "type": "http.request", + "body": b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}', + "more_body": False, + } + ) + send = AsyncMock() + user_auth = MagicMock() + user_auth.user_id = None + tp_server = _build_passthrough_mode_server("tp_server", MCPAuth.true_passthrough) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", + new_callable=AsyncMock, + return_value=(user_auth, None, ["tp_server"], None, None, None), + ), + patch("litellm.proxy._experimental.mcp_server.server.set_auth_context"), + patch( + "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", + True, + ), + patch( + "litellm.proxy._experimental.mcp_server.server.get_async_httpx_client", + return_value=probe_client, + ), + patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager.get_mcp_server_by_name", + return_value=tp_server, + ), + patch.object( + session_manager_stateful, + "handle_request", + new_callable=AsyncMock, + ) as mock_handle_request, + ): + with pytest.raises(HTTPException) as exc_info: + await handle_streamable_http_mcp(scope, receive, send) + + assert mock_handle_request.await_count == 0 + assert exc_info.value.status_code == 401 + assert exc_info.value.headers["www-authenticate"] == upstream_challenge + probe_client.post.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_handle_streamable_http_mcp_true_passthrough_with_token_skips_probe_and_challenge(): + """When the true_passthrough caller already carries an Authorization the + gateway must forward without probing or challenging. Guards the + ``not _scope_has_authorization_header(scope)`` condition and the no-probe + fast path.""" + try: + from litellm.proxy._experimental.mcp_server.server import ( + handle_streamable_http_mcp, + session_manager_stateless, + ) + except ImportError: + pytest.skip("MCP server not available") + + probe_client = MagicMock() + probe_client.post = AsyncMock() + + scope = _passthrough_mode_scope( + "tp_server", + extra_headers=[(b"authorization", b"Bearer upstream-token")], + ) + receive = AsyncMock( + return_value={ + "type": "http.request", + "body": b'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}', + "more_body": False, + } + ) + send = AsyncMock() + user_auth = MagicMock() + user_auth.user_id = None + tp_server = _build_passthrough_mode_server("tp_server", MCPAuth.true_passthrough) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", + new_callable=AsyncMock, + return_value=(user_auth, None, ["tp_server"], None, None, None), + ), + patch("litellm.proxy._experimental.mcp_server.server.set_auth_context"), + patch( + "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", + True, + ), + patch( + "litellm.proxy._experimental.mcp_server.server._handle_stale_mcp_session", + new_callable=AsyncMock, + return_value=False, + ), + patch( + "litellm.proxy._experimental.mcp_server.server._check_passthrough_upstream_auth", + new_callable=AsyncMock, + ), + patch( + "litellm.proxy._experimental.mcp_server.server.get_async_httpx_client", + return_value=probe_client, + ), + patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager.get_mcp_server_by_name", + return_value=tp_server, + ), + patch.object( + session_manager_stateless, + "handle_request", + new_callable=AsyncMock, + ) as mock_handle_request, + patch.object(session_manager_stateless, "_server_instances", {}), + ): + await handle_streamable_http_mcp(scope, receive, send) + + assert mock_handle_request.await_count == 1 + probe_client.post.assert_not_awaited() diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 23ada336710..852f8388ec2 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -27020,7 +27020,7 @@ export interface components { /** Alias */ alias?: string | null; /** Auth Type */ - auth_type?: ("none" | "api_key" | "bearer_token" | "basic" | "authorization" | "oauth2" | "aws_sigv4" | "token" | "oauth2_token_exchange") | null; + auth_type?: ("none" | "api_key" | "bearer_token" | "basic" | "authorization" | "oauth2" | "aws_sigv4" | "token" | "oauth2_token_exchange" | "true_passthrough" | "oauth_delegate") | null; /** Mcp Info */ mcp_info?: { [key: string]: unknown;