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 a27d6b92843e..df810d9193ec 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 @@ -11,6 +11,7 @@ import litellm from litellm._logging import verbose_logger from litellm.proxy._experimental.mcp_server.oauth_utils import ( + get_passthrough_resource_metadata_url, get_request_base_url, well_known_root_suffix, ) @@ -137,52 +138,83 @@ def _is_mcp_admitted_user_subject(user_api_key_auth: UserAPIKeyAuth | None) -> b return user_api_key_auth is not None and user_api_key_auth.mcp_admitted_user_subject is True -def _is_aggregate_mcp_scope(route: str, mcp_servers: list[str] | None) -> bool: - """True when a request targets the aggregate ``/mcp`` endpoint rather than any named - server. Named targets arrive either through ``x-mcp-servers`` (``mcp_servers``) or a - path segment (``/mcp/{server}`` / ``/{server}/mcp``); the aggregate scope has neither. - The gateway-DCR session arm and challenge fire only here, so a per-server flow is never - affected.""" - if mcp_servers: - return False - return len(MCPRequestHandler._extract_target_server_names_from_path(route)) == 0 +def _gateway_dcr_challenge_target( + route: str, + mcp_servers: list[str] | None, + client_ip: str | None, +) -> str | None: + """The single path-named server this request targets, iff it resolves to a + gateway-managed oauth2 server — the one per-server shape the gateway's own keyless + DCR flow serves end to end, so the 401 challenge may advertise the per-server + protected-resource metadata (whose ``authorization_servers`` names the gateway). + + Multi-server CSV paths, header/path mismatches, unknown names, and every + client-forwarded or delegated mode return ``None``: those cells keep their existing + challenge (or absence of one), and a challenge is never emitted for a name the + public discovery routes would 404, so this reveals exactly the server set the + per-server protected-resource metadata already reveals.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + targets = _parse_mcp_server_names_from_path(route, mcp_servers) + if targets is None: + return None + server = global_mcp_server_manager.get_mcp_server_by_name(targets[0], client_ip=client_ip) + if server is None or not server.is_gateway_managed_oauth2: + return None + return targets[0] -def _is_aggregate_gateway_dcr_challenge_scope( +def _is_gateway_dcr_challenge_scope( route: str, mcp_servers: list[str] | None, mcp_auth_header: str | None, mcp_server_auth_headers: dict[str, dict[str, str]] | None, exc: Exception, + client_ip: str | None, ) -> bool: - """True when an unauthenticated request to the aggregate ``/mcp`` endpoint - should receive the RFC 9728 401 challenge that advertises the gateway as - the authorization server. - - Fires only for a genuine 401 on the aggregate scope: any named target - (path or ``x-mcp-servers``) belongs to the per-server challenge paths, and - client-supplied MCP auth headers mean the caller is not a cold-start DCR - client. Fails closed to the original admission error otherwise.""" + """True when an unauthenticated MCP request should receive the RFC 9728 401 + challenge that advertises the gateway as the authorization server. + + Fires only for a genuine 401 with no client-supplied MCP auth headers (those mean + the caller is not a cold-start DCR client), on the scopes the gateway's keyless + flow serves: the aggregate ``/mcp`` endpoint, an ``x-mcp-servers``-scoped request + (the resource the client configured is still ``/mcp``), or a per-server path whose + single target is a gateway-managed oauth2 server. Every other named target keeps + its existing behavior, failing closed to the original admission error.""" if not _is_litellm_auth_admission_error(exc): return False if _has_client_supplied_mcp_auth(mcp_auth_header, mcp_server_auth_headers): return False - return _is_aggregate_mcp_scope(route, mcp_servers) + if len(MCPRequestHandler._extract_target_server_names_from_path(route)) == 0: + return True + return _gateway_dcr_challenge_target(route, mcp_servers, client_ip) is not None -def _aggregate_gateway_dcr_challenge(request: Request, invalid_token: bool) -> HTTPException: - """The RFC 9728 challenge for the aggregate endpoint: points the client at - the gateway's own protected-resource metadata so a DCR client discovers - the gateway as its authorization server and starts the sign-in flow. +def _gateway_dcr_challenge( + request: Request, + route: str, + mcp_servers: list[str] | None, + invalid_token: bool, +) -> HTTPException: + """The RFC 9728 challenge pointing the client at the protected-resource metadata + matching the scope it requested: the per-server document (same URL spelling the + request arrived on) when the single target is a gateway-managed oauth2 server, + else the gateway's aggregate document. Either way the client discovers the gateway + as its authorization server and starts the same sign-in flow. ``invalid_token`` adds the RFC 6750 error code for a request that DID present a bearer that failed admission (expired or revoked), telling spec-compliant clients to re-authorize rather than retry; a request with no credentials at all gets the bare challenge per RFC 6750 section 3.1.""" - error_attr = 'error="invalid_token", ' if invalid_token else "" + target = _gateway_dcr_challenge_target(route, mcp_servers, IPAddressUtils.get_mcp_client_ip(request)) resource_metadata_url = ( - f"{get_request_base_url(request)}/.well-known/oauth-protected-resource{well_known_root_suffix()}/mcp" + get_passthrough_resource_metadata_url(request.scope, target) + if target is not None + else f"{get_request_base_url(request)}/.well-known/oauth-protected-resource{well_known_root_suffix()}/mcp" ) + error_attr = 'error="invalid_token", ' if invalid_token else "" return HTTPException( status_code=401, detail={ @@ -225,14 +257,15 @@ def _admission_failure_fallback( ): verbose_logger.debug("MCP pass-through cold start: deferring admission to route 401 emitter") return UserAPIKeyAuth() - if _is_aggregate_gateway_dcr_challenge_scope( + if _is_gateway_dcr_challenge_scope( route=request_route, mcp_servers=mcp_servers, mcp_auth_header=mcp_auth_header, mcp_server_auth_headers=mcp_server_auth_headers, exc=exc, + client_ip=IPAddressUtils.get_mcp_client_ip(request), ): - raise _aggregate_gateway_dcr_challenge(request, invalid_token=bearer_presented) from exc + raise _gateway_dcr_challenge(request, request_route, mcp_servers, invalid_token=bearer_presented) from exc raise exc @@ -384,18 +417,18 @@ async def mock_body(): request=request, route=request_route, ) - elif ( - _is_aggregate_mcp_scope(request_route, mcp_servers) - and oauth2_headers - and is_session_bearer_shaped(oauth2_headers["Authorization"]) - ): - # A gateway DCR session bearer at the aggregate /mcp scope: open the identity-only session - # token and admit under the live litellm user. One that does not open fails closed with the - # aggregate invalid_token challenge; a non-session bearer falls through to the oauth2 arm. + elif oauth2_headers and is_session_bearer_shaped(oauth2_headers["Authorization"]): + # A gateway DCR session bearer at any MCP scope: open the identity-only session + # token and admit under the live litellm user; downstream grant resolution + # intersects the admitted subject's servers with any path or header target, so a + # per-server scope narrows and never broadens. One that does not open fails + # closed with the scope's invalid_token challenge; a non-session bearer falls + # through to the oauth2 arm. validated_user_api_key_auth = await MCPRequestHandler._admit_gateway_session( authorization_value=oauth2_headers["Authorization"], request=request, route=request_route, + mcp_servers=mcp_servers, ) elif oauth2_headers: # Authorization on a non-delegated server: the bearer must be a real @@ -731,6 +764,7 @@ async def _admit_gateway_session( authorization_value: str, request: Request, route: str, + mcp_servers: list[str] | None, ) -> UserAPIKeyAuth: """Open a gateway DCR session bearer and admit the live litellm user it references. @@ -738,8 +772,8 @@ async def _admit_gateway_session( upstream credential (those are vaulted per user, resolved at egress), so authorization is resolved fresh via :meth:`_reload_admitted_user` + the centralized policy gate rather than a mint-time snapshot. Pre-DB gates (size, IP, route allowlist) run first, mirroring the standard - pipeline. Fails closed with the aggregate ``invalid_token`` challenge on an expired, tampered, - foreign, or refresh token, or a missing/deactivated/policy-rejected user.""" + pipeline. Fails closed with the requested scope's ``invalid_token`` challenge on an expired, + tampered, foreign, or refresh token, or a missing/deactivated/policy-rejected user.""" from litellm.proxy._experimental.mcp_server.outbound_credentials.session_credentials import ( NotSessionBearer, SessionBearerAdmitted, @@ -765,20 +799,20 @@ async def _admit_gateway_session( ) except HTTPException as exc: # A cryptographically valid bearer whose referenced user is now missing or - # SCIM-deactivated is an invalid_token at the aggregate scope: relay the RFC 9728 + # SCIM-deactivated is an invalid_token at the requested scope: relay the RFC 9728 # challenge so the DCR client re-authorizes, matching the SessionBearerInvalid # arm, instead of a bare 401 with no WWW-Authenticate. A 503 (DB outage) is a # transient availability failure, not an auth failure, so it passes through. if exc.status_code == 401: - raise _aggregate_gateway_dcr_challenge(request, invalid_token=True) from exc + raise _gateway_dcr_challenge(request, route, mcp_servers, invalid_token=True) from exc raise return admitted case SessionBearerInvalid(): - raise _aggregate_gateway_dcr_challenge(request, invalid_token=True) + raise _gateway_dcr_challenge(request, route, mcp_servers, invalid_token=True) case NotSessionBearer(): # Unreachable: the arm is entered only for an is_session_bearer_shaped # value. Kept for match exhaustiveness and fails closed regardless. - raise _aggregate_gateway_dcr_challenge(request, invalid_token=True) + raise _gateway_dcr_challenge(request, route, mcp_servers, invalid_token=True) case _: assert_never(result) diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index caa5c65894c9..6fb2903acb8e 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -2094,6 +2094,15 @@ async def _build_oauth_protected_resource_response( 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. + An explicitly named gateway-managed oauth2 server (interactive with + gateway-vaulted per-user tokens, or M2M) advertises the gateway's own + authorization server (``{base}/mcp``): a keyless DCR client that configured the + per-server URL completes the same sign-in flow the aggregate ``/mcp`` endpoint + supports and is admitted with a gateway session bearer. The per-server relay + authorize/token endpoints stay registered for the keyed interactive flow (which + is challenged with an explicit ``authorization_uri``), and the root-resolved + (unnamed) legacy shape keeps the relay authorization server. + Args: request: FastAPI Request object mcp_server_name: Name of the MCP server @@ -2109,6 +2118,7 @@ async def _build_oauth_protected_resource_response( request_base_url = get_request_base_url(request) client_ip = IPAddressUtils.get_mcp_client_ip(request) + explicitly_named = mcp_server_name is not None # When no server name provided, try to resolve the single OAuth2 server if mcp_server_name is None: @@ -2183,6 +2193,13 @@ async def _build_oauth_protected_resource_response( if mcp_server is None or mcp_server.auth_type != MCPAuth.oauth2_token_exchange: _raise_unless_oauth2_discovery_server(mcp_server, mcp_server_name, "not an OAuth-protected resource") + if explicitly_named and mcp_server is not None and mcp_server.is_gateway_managed_oauth2: + return { + "authorization_servers": [f"{request_base_url}/mcp"], + "resource": resource_url, + "scopes_supported": (mcp_server.scopes if mcp_server.scopes else []), + } + return { "authorization_servers": [ (f"{request_base_url}/{mcp_server_name}" if mcp_server_name else f"{request_base_url}") diff --git a/litellm/proxy/_experimental/mcp_server/exceptions.py b/litellm/proxy/_experimental/mcp_server/exceptions.py index 74752809e865..ca2261139c9e 100644 --- a/litellm/proxy/_experimental/mcp_server/exceptions.py +++ b/litellm/proxy/_experimental/mcp_server/exceptions.py @@ -57,7 +57,7 @@ def to_http_exception( ``/.well-known/oauth-protected-resource/mcp/{server_name}``. This keeps the ``resource_metadata`` URI aligned with the resource pattern the client originally targeted, matching the path-aware behaviour of - ``_get_passthrough_resource_metadata_url`` in ``server.py``. + ``get_passthrough_resource_metadata_url`` in ``oauth_utils.py``. """ challenge: Optional[str] = self.www_authenticate if challenge is None and self.status_code == 401 and base_url: diff --git a/litellm/proxy/_experimental/mcp_server/oauth_utils.py b/litellm/proxy/_experimental/mcp_server/oauth_utils.py index 5daec9f97bea..8f47aa7344de 100644 --- a/litellm/proxy/_experimental/mcp_server/oauth_utils.py +++ b/litellm/proxy/_experimental/mcp_server/oauth_utils.py @@ -7,6 +7,7 @@ from urllib.parse import ParseResult, urlparse, urlsplit, urlunparse, urlunsplit from fastapi import HTTPException, Request +from starlette.types import Scope from litellm._logging import verbose_logger from litellm.proxy._experimental.mcp_server.auth.token_endpoint_auth import ( @@ -179,6 +180,37 @@ def well_known_root_suffix() -> str: return "" if root == "/" else root +def get_passthrough_resource_metadata_url(scope: Scope, server_name: str) -> str: + """The per-server protected-resource metadata URL matching the spelling the request + arrived on, so a strict RFC 9728 client resolves the same route the proxy registered. + ``_original_path`` preserves the ``/{server}/mcp`` spelling through the + ``dynamic_mcp_route`` rewrite; the ``SERVER_ROOT_PATH`` segment is inserted exactly as + the route decorators insert it (see :func:`well_known_root_suffix`).""" + request = Request(scope) + base_url = get_request_base_url(request) + _path = scope.get("_original_path") or scope.get("path", "") or "" + + if _path.startswith(f"/{server_name}/mcp"): + return f"{base_url}/.well-known/oauth-protected-resource{well_known_root_suffix()}/{server_name}/mcp" + return f"{base_url}/.well-known/oauth-protected-resource{well_known_root_suffix()}/mcp/{server_name}" + + +def get_passthrough_www_authenticate( + scope: Scope, + server_name: str, + invalid_token: bool = False, +) -> str: + """The RFC 9728 ``WWW-Authenticate`` value advertising the per-server + protected-resource metadata, with the RFC 6750 ``invalid_token`` error code when the + caller presented a bearer that failed rather than no credential at all.""" + resource_metadata_url = get_passthrough_resource_metadata_url( + scope=scope, + server_name=server_name, + ) + error_attr = 'error="invalid_token", ' if invalid_token else "" + return f'Bearer {error_attr}resource_metadata="{resource_metadata_url}"' + + def validate_loopback_redirect_uri(redirect_uri: str) -> None: """Require a loopback ``redirect_uri`` (OAuth 2.1 §4.1.2.1 + RFC 8252 §7.3 native-app pattern). MCP clients are native apps that listen on diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 14673cf12c13..1ff60b41e19e 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -37,6 +37,7 @@ ) from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( MCPRequestHandler, + _is_mcp_admitted_user_subject, ) from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( get_request_base_url, @@ -53,6 +54,7 @@ from litellm.proxy._experimental.mcp_server.mcp_debug import MCPDebug from litellm.proxy._experimental.mcp_server.oauth_utils import ( _redact_mcp_resource_url, + get_passthrough_www_authenticate, ) from litellm.proxy._experimental.mcp_server.utils import ( LITELLM_MCP_SERVER_DESCRIPTION, @@ -3611,30 +3613,6 @@ async def _apply_toolset_scope( ) return user_api_key_auth.model_copy(update={"object_permission": updated_op}) - def _get_passthrough_resource_metadata_url(scope: Scope, server_name: str) -> str: - request = StarletteRequest(scope) - base_url = get_request_base_url(request) - _path = scope.get("_original_path") or scope.get("path", "") or "" - - if _path.startswith(f"/{server_name}/mcp"): - return f"{base_url}/.well-known/oauth-protected-resource/{server_name}/mcp" - return f"{base_url}/.well-known/oauth-protected-resource/mcp/{server_name}" - - def _get_passthrough_www_authenticate( - scope: Scope, - server_name: str, - invalid_token: bool = False, - ) -> str: - resource_metadata_url = _get_passthrough_resource_metadata_url( - scope=scope, - server_name=server_name, - ) - params = [] - if invalid_token: - params.append('error="invalid_token"') - params.append(f'resource_metadata="{resource_metadata_url}"') - return "Bearer " + ", ".join(params) - async def _raise_preemptive_401_for_unauthenticated_servers( scope: Scope, mcp_servers: list[str] | None, @@ -3684,10 +3662,26 @@ async def _raise_preemptive_401_for_unauthenticated_servers( # challenge whenever one is absent, regardless of any bearer. # The v2 resolver owns the existence check, so every # authorization_code resolution (egress and this discovery - # challenge) runs through it. + # challenge) runs through it. A keyless admitted subject is + # challenged with the per-server resource_metadata (whose + # authorization server is the gateway itself, vaulting via the + # authorize interlude); the per-server relay advertised below + # cannot vault without a litellm key on its token request. if await global_mcp_server_manager.has_user_oauth_token(server, user_api_key_auth): continue + if _is_mcp_admitted_user_subject(user_api_key_auth): + raise HTTPException( + status_code=401, + detail="Unauthorized", + headers={ + "www-authenticate": get_passthrough_www_authenticate( + scope=scope, + server_name=server_name, + ) + }, + ) + request = StarletteRequest(scope) base_url = get_request_base_url(request) _path = scope.get("_original_path") or scope.get("path", "") or "" @@ -3712,7 +3706,7 @@ async def _raise_preemptive_401_for_unauthenticated_servers( # the proxied resource_metadata (RFC 9728), not the gateway # authorization_uri above which would authorize against the # gateway instead of the upstream IdP. - www_authenticate = _get_passthrough_www_authenticate( + www_authenticate = get_passthrough_www_authenticate( scope=scope, server_name=server_name, ) @@ -3768,7 +3762,7 @@ async def _raise_preemptive_401_for_unauthenticated_servers( and server.is_oauth_passthrough and not _client_has_passthrough_authorization(server, oauth2_headers, mcp_server_auth_headers) ): - www_authenticate = _get_passthrough_www_authenticate( + www_authenticate = get_passthrough_www_authenticate( scope=scope, server_name=server_name, ) @@ -3785,7 +3779,7 @@ async def _raise_preemptive_401_for_unauthenticated_servers( 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( + www_authenticate = get_passthrough_www_authenticate( scope=scope, server_name=server_name, ) @@ -3807,7 +3801,7 @@ async def _raise_preemptive_401_for_unauthenticated_servers( status_code=401, detail="Unauthorized", headers={ - "www-authenticate": _get_passthrough_www_authenticate( + "www-authenticate": get_passthrough_www_authenticate( scope=scope, server_name=server_name, ) @@ -4014,7 +4008,7 @@ async def _check_passthrough_upstream_auth( # Token is missing or expired: keep pass-through clients on the # protected-resource discovery flow so they re-authorize against # the upstream IdP metadata proxied by LiteLLM. - www_authenticate = _get_passthrough_www_authenticate( + www_authenticate = get_passthrough_www_authenticate( scope=scope, server_name=challenge_server_name, invalid_token=True, diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index a127e8dad118..d02778e9eac0 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -194,6 +194,18 @@ def needs_user_oauth_token(self) -> bool: """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_gateway_managed_oauth2(self) -> bool: + """True when the gateway itself owns this server's OAuth custody: an ``oauth2`` server + (interactive authorization_code with gateway-vaulted per-user tokens, or M2M + client_credentials minted at egress) that has NOT opted into upstream-delegated auth. + These are the servers the keyless gateway-DCR flow can serve end to end, so the + per-server 401 challenge and protected-resource metadata advertise the gateway as the + authorization server for exactly this set. ``true_passthrough``, ``oauth_delegate``, + DCR-bridge, and token-exchange servers are their own auth types and client-forwarded, + so they are excluded by construction.""" + return self.auth_type == MCPAuth.oauth2 and not self.delegate_auth_to_upstream + @property def is_true_passthrough(self) -> bool: """True for the transparent-proxy mode: LiteLLM performs no admission auth and forwards the 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 b3c0dcd16813..d2acca47be24 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 @@ -1154,21 +1154,27 @@ async def mock_user_api_key_auth_server_error(api_key, request): await MCPRequestHandler.process_mcp_request(scope) assert exc_info.value.status_code == 500 - async def test_proxy_exception_non_delegate_oauth2_propagates(self): + async def test_proxy_exception_non_delegate_oauth2_challenges_with_per_server_metadata(self): """ Production raises ProxyException (not HTTPException) on auth failure. For - a non-delegate oauth2 server the bearer is treated as a LiteLLM credential - and a 401 must propagate as a real auth error, not be exchanged for an - anonymous upstream-passthrough session. + a gateway-managed oauth2 server the bearer is treated as a LiteLLM + credential and its failure stays a 401, never an anonymous + upstream-passthrough session. The 401 now carries the RFC 9728 + invalid_token challenge with the per-server resource metadata (LIT-4864): + a keyless client holding a stale upstream token (the relayed gho_ shape) + re-discovers the gateway as this resource's authorization server instead + of dead-ending on a bare 401. """ from litellm.proxy._types import ProxyException from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer scope = { "type": "http", "method": "POST", "path": "/mcp/atlassian_mcp", "headers": [ + (b"host", b"testserver"), (b"authorization", b"Bearer atlassian-oauth2-access-token-xyz"), ], } @@ -1181,10 +1187,14 @@ async def mock_user_api_key_auth_proxy_exception(api_key, request): code=401, ) - oauth2_server = MagicMock() - oauth2_server.auth_type = MCPAuth.oauth2 - oauth2_server.delegate_auth_to_upstream = False - oauth2_server.is_oauth_passthrough = False + oauth2_server = MCPServer( + server_id="atlassian-id", + name="atlassian_mcp", + server_name="atlassian_mcp", + url="https://upstream.example/mcp", + transport="http", + auth_type=MCPAuth.oauth2, + ) with ( patch( @@ -1194,9 +1204,14 @@ async def mock_user_api_key_auth_proxy_exception(api_key, request): 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: + with pytest.raises(HTTPException) as exc_info: await MCPRequestHandler.process_mcp_request(scope) - assert str(exc_info.value.code) == "401" + assert exc_info.value.status_code == 401 + www_authenticate = (exc_info.value.headers or {})["WWW-Authenticate"] + assert www_authenticate == ( + 'Bearer error="invalid_token", ' + 'resource_metadata="http://testserver/.well-known/oauth-protected-resource/mcp/atlassian_mcp"' + ) async def test_proxy_exception_non_auth_still_raises(self): """ @@ -6250,14 +6265,133 @@ async def test_no_challenge_for_explicit_litellm_key(self): self._scope(extra_headers=((b"x-litellm-api-key", b"sk-typo"),)) ) - async def test_no_challenge_for_named_servers_header(self): - """x-mcp-servers names explicit targets; the per-server challenge paths - own those, so the aggregate challenge must not fire.""" + async def test_challenge_for_named_servers_header(self): + """x-mcp-servers scopes the fan-out but the resource the client configured is still + the aggregate /mcp URL, so an unauthenticated request gets the aggregate challenge + and completes the same keyless flow; the header names then narrow (never broaden) + the admitted subject's servers downstream (LIT-4864).""" with ( patch(self._AUTH_PATCH_TARGET, side_effect=self._auth_401()), ): - with pytest.raises(ProxyException): + with pytest.raises(HTTPException) as exc_info: await MCPRequestHandler.process_mcp_request(self._scope(extra_headers=((b"x-mcp-servers", b"github"),))) + assert exc_info.value.status_code == 401 + www_authenticate = (exc_info.value.headers or {})["WWW-Authenticate"] + assert www_authenticate == f"Bearer {self._EXPECTED_RESOURCE_METADATA}" + + async def test_per_server_challenge_for_gateway_managed_oauth2(self): + """Anonymous request to a per-server path whose single target is a gateway-managed + oauth2 server: 401 plus the RFC 9728 challenge advertising the PER-SERVER + protected-resource metadata in the same URL spelling the request used, so a keyless + DCR client configured with either per-server spelling discovers the gateway as the + authorization server (LIT-4864). Covers interactive and M2M, which the gateway can + both serve end to end.""" + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server = MCPServer( + server_id="gh-id", + name="github", + server_name="github", + url="https://upstream.example/mcp", + transport="http", + auth_type=MCPAuth.oauth2, + ) + for path, expected_metadata_path in ( + ("/mcp/github", "/.well-known/oauth-protected-resource/mcp/github"), + ("/github/mcp", "/.well-known/oauth-protected-resource/github/mcp"), + ): + with ( + patch(self._AUTH_PATCH_TARGET, side_effect=self._auth_401()), + 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 = server + with pytest.raises(HTTPException) as exc_info: + await MCPRequestHandler.process_mcp_request(self._scope(path=path)) + assert exc_info.value.status_code == 401 + www_authenticate = (exc_info.value.headers or {})["WWW-Authenticate"] + assert www_authenticate == f'Bearer resource_metadata="http://testserver{expected_metadata_path}"' + + async def test_no_per_server_challenge_for_non_gateway_managed_targets(self): + """The per-server challenge fires only for the server set the gateway's keyless flow + serves: an OBO server and a multi-server CSV path keep the original admission error + through the full pipeline, so no client-forwarded mode is redirected into the gateway + sign-in flow and no cell broadens (LIT-4864).""" + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + obo_server = MCPServer( + server_id="o-id", + name="obo", + server_name="obo", + url="https://upstream.example/mcp", + transport="http", + auth_type=MCPAuth.oauth2_token_exchange, + ) + for path, resolved in ( + ("/mcp/obo", obo_server), + ("/mcp/github,linear", None), + ): + with ( + patch(self._AUTH_PATCH_TARGET, side_effect=self._auth_401()), + 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 = resolved + with pytest.raises(ProxyException): + await MCPRequestHandler.process_mcp_request( + self._scope(path=path, extra_headers=((b"authorization", b"Bearer not-a-key"),)) + ) + + def test_challenge_target_excludes_every_non_gateway_managed_mode(self): + """Unit pin of the challenge-target owner: only a resolved gateway-managed oauth2 + target (interactive or M2M) yields a per-server challenge; delegate-auth oauth2 + (whose keyless flow is upstream PKCE via the relay), every client-forwarded auth + type, OBO, api_key, unknown names, and CSV paths yield None (LIT-4864).""" + from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( + _gateway_dcr_challenge_target, + ) + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + def _server(auth_type, **kw): + return MCPServer( + server_id="s-id", + name="srv", + server_name="srv", + url="https://upstream.example/mcp", + transport="http", + auth_type=auth_type, + **kw, + ) + + cases = [ + (_server(MCPAuth.oauth2), "srv"), + (_server(MCPAuth.oauth2, oauth2_flow="client_credentials"), "srv"), + (_server(MCPAuth.oauth2, delegate_auth_to_upstream=True), None), + (_server(MCPAuth.oauth2_token_exchange), None), + (_server(MCPAuth.true_passthrough), None), + (_server(MCPAuth.oauth_delegate), None), + (_server(MCPAuth.oauth_delegate, dcr_bridge=True), None), + (_server(MCPAuth.api_key), None), + (None, None), + ] + for resolved, expected in cases: + with 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 = resolved + assert _gateway_dcr_challenge_target("/mcp/srv", None, None) == expected, resolved + assert _gateway_dcr_challenge_target("/mcp/a,b", None, None) is None + assert _gateway_dcr_challenge_target("/mcp", None, None) is None + with 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 = _server(MCPAuth.oauth2) + assert _gateway_dcr_challenge_target("/mcp/srv", ["other"], None) is None async def test_no_challenge_for_path_named_server(self): """/mcp/{server} targets one server; the aggregate challenge must not @@ -6295,10 +6429,11 @@ async def _raise_500(api_key, request): @pytest.mark.asyncio class TestGatewaySessionAdmission: - """The aggregate /mcp session-bearer admission arm (mcp_gateway_dcr). A valid session - token admits under the LIVE litellm user it references; an invalid/expired/refresh/foreign - token fails closed with the aggregate invalid_token challenge; the arm fires ONLY at the - aggregate scope, never for named servers or per-server flows.""" + """The session-bearer admission arm (mcp_gateway_dcr). A valid session token admits under + the LIVE litellm user it references at any MCP scope (aggregate, per-server path, or + x-mcp-servers scoped; LIT-4864) with downstream grant resolution narrowing to the + requested servers; an invalid/expired/refresh/foreign token fails closed with the + requested scope's invalid_token challenge.""" _MASTER_KEY = "sk-gateway-session-admission-master-key" @@ -6471,21 +6606,89 @@ async def test_session_bearer_scrubbed_from_egress_header_contexts(self): assert oauth2_headers is None assert not any(k.lower() == "authorization" for k in (raw_headers or {})) - async def test_arm_does_not_fire_for_named_server(self): - """A session-shaped bearer aimed at a named server (path scope) does not enter the - aggregate arm; it is treated as an ordinary bearer on that server.""" - token = self._access_token() + @pytest.mark.parametrize( + "path, original_path, extra_headers", + [ + ("/mcp/github", None, ()), + ("/mcp/github", "/github/mcp", ()), + ("/mcp", None, ((b"x-mcp-servers", b"github"),)), + ], + ) + async def test_arm_admits_session_bearer_on_per_server_scopes(self, path, original_path, extra_headers): + """A valid session bearer admits the live user on per-server paths (the standard + spelling and the legacy /{server}/mcp spelling as dynamic_mcp_route rewrites it) and + x-mcp-servers scoped requests, never touching user_api_key_auth; downstream grant + resolution then intersects the named servers against the admitted subject's grants, + so the narrower scope can never broaden access (LIT-4864).""" + token = self._access_token(user_id="sso-user-42") + scope = self._scope(token, path=path, extra_headers=extra_headers) + if original_path is not None: + scope["_original_path"] = original_path with ( patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY), patch( "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", new_callable=AsyncMock, - side_effect=ProxyException(message="bad key", type="auth_error", param="api_key", code=401), ) as mock_auth, + self._patch_user_reload(user_id="sso-user-42"), ): - with pytest.raises((HTTPException, ProxyException)): - await MCPRequestHandler.process_mcp_request(self._scope(token, path="/mcp/github")) - mock_auth.assert_called_once() + auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) + assert auth_result.user_id == "sso-user-42" + assert auth_result.mcp_admitted_user_subject is True + mock_auth.assert_not_called() + + async def test_expired_session_bearer_on_per_server_path_gets_per_server_challenge(self): + """An expired session bearer on a per-server path targeting a gateway-managed oauth2 + server re-challenges with the PER-SERVER resource metadata (matching the resource the + client configured), so a spec client re-authorizes against the right document instead + of a bare 401 or the aggregate metadata (LIT-4864).""" + from datetime import datetime, timezone + + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + mint, _refresh, principal, keys = self._session_bearer() + bearer = mint(principal, keys, datetime(2020, 1, 1, tzinfo=timezone.utc)).token.get_secret_value() + server = MCPServer( + server_id="gh-id", + name="github", + server_name="github", + url="https://upstream.example/mcp", + transport="http", + auth_type=MCPAuth.oauth2, + ) + with ( + patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY), + 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 = server + with pytest.raises(HTTPException) as exc_info: + await MCPRequestHandler.process_mcp_request(self._scope(bearer, path="/mcp/github")) + assert exc_info.value.status_code == 401 + www_authenticate = (exc_info.value.headers or {})["WWW-Authenticate"] + assert www_authenticate == ( + 'Bearer error="invalid_token", ' + 'resource_metadata="http://testserver/.well-known/oauth-protected-resource/mcp/github"' + ) + + async def test_session_bearer_scrubbed_from_egress_on_per_server_path(self): + """After a per-server keyless admission the session bearer must be scrubbed from every + egress header context exactly as at the aggregate scope, so no per-server passthrough + egress can forward it upstream for replay (LIT-4864).""" + token = self._access_token(user_id="sso-user-42") + with ( + patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY), + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + new_callable=AsyncMock, + ), + self._patch_user_reload(user_id="sso-user-42"), + ): + _auth, _h, _servers, _msah, oauth2_headers, raw_headers = await MCPRequestHandler.process_mcp_request( + self._scope(token, path="/mcp/github") + ) + assert oauth2_headers is None + assert not any(k.lower() == "authorization" for k in (raw_headers or {})) def _make_team(team_id, mcp_servers, *, org_id=None, tool_perms=None, members=("sso-user",)): diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index 694583dde881..9bc84b43fc50 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -2976,6 +2976,125 @@ async def test_oauth_protected_resource_returns_empty_scopes_when_none(): global_mcp_server_manager.registry.clear() +@pytest.mark.asyncio +async def test_oauth_protected_resource_gateway_managed_oauth2_advertises_gateway_as(): + """LIT-4864: an explicitly named gateway-managed oauth2 server (interactive or M2M) + advertises the gateway's own authorization server, so a keyless DCR client that + configured the per-server URL completes the same sign-in flow the aggregate /mcp + endpoint supports and returns with a gateway session bearer; the resource stays the + per-server URL in the requested spelling (RFC 9728 resource match). A delegate-auth + oauth2 server keeps the per-server relay authorization server (its keyless flow is + upstream PKCE via the relay), and the root-resolved unnamed legacy shape is unchanged.""" + try: + from fastapi import Request + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + _build_oauth_protected_resource_response, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.proxy._types import MCPTransport + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + except ImportError: + pytest.skip("MCP discoverable endpoints not available") + + def _oauth2_server(name, **kw): + return MCPServer( + server_id=name, + name=name, + server_name=name, + alias=name, + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + authorization_url="https://idp.example.com/authorize", + token_url="https://idp.example.com/oauth/token", + scopes=["read"], + **kw, + ) + + mock_request = MagicMock(spec=Request) + mock_request.base_url = "https://litellm.example.com/" + mock_request.headers = {} + + interactive = _oauth2_server("github_mcp") + m2m = _oauth2_server("m2m_mcp", oauth2_flow="client_credentials", client_id="cid", client_secret="cs") + delegated = _oauth2_server("delegated_mcp", delegate_auth_to_upstream=True) + + global_mcp_server_manager.registry.clear() + try: + for server in (interactive, m2m, delegated): + global_mcp_server_manager.registry[server.server_id] = server + + for name in ("github_mcp", "m2m_mcp"): + standard = await _build_oauth_protected_resource_response( + request=mock_request, mcp_server_name=name, use_standard_pattern=True + ) + assert standard["authorization_servers"] == ["https://litellm.example.com/mcp"], name + assert standard["resource"] == f"https://litellm.example.com/mcp/{name}" + assert standard["scopes_supported"] == ["read"] + legacy = await _build_oauth_protected_resource_response( + request=mock_request, mcp_server_name=name, use_standard_pattern=False + ) + assert legacy["authorization_servers"] == ["https://litellm.example.com/mcp"], name + assert legacy["resource"] == f"https://litellm.example.com/{name}/mcp" + + delegated_response = await _build_oauth_protected_resource_response( + request=mock_request, mcp_server_name="delegated_mcp", use_standard_pattern=True + ) + assert delegated_response["authorization_servers"] == ["https://litellm.example.com/delegated_mcp"] + finally: + global_mcp_server_manager.registry.clear() + + +@pytest.mark.asyncio +async def test_oauth_protected_resource_root_resolved_single_server_keeps_relay_as(): + """The unnamed (bare-root) legacy shape resolves the single configured oauth2 server and + must keep advertising the per-server relay authorization server: only an EXPLICITLY + named request opts into the gateway-as-AS flow (LIT-4864), so pre-existing single-server + deployments discovering through the root document are byte-identical.""" + try: + from fastapi import Request + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + _build_oauth_protected_resource_response, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.proxy._types import MCPTransport + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + except ImportError: + pytest.skip("MCP discoverable endpoints not available") + + only_server = MCPServer( + server_id="solo_mcp", + name="solo_mcp", + server_name="solo_mcp", + alias="solo_mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + authorization_url="https://idp.example.com/authorize", + token_url="https://idp.example.com/oauth/token", + ) + + mock_request = MagicMock(spec=Request) + mock_request.base_url = "https://litellm.example.com/" + mock_request.headers = {} + + global_mcp_server_manager.registry.clear() + try: + global_mcp_server_manager.registry[only_server.server_id] = only_server + response = await _build_oauth_protected_resource_response( + request=mock_request, mcp_server_name=None, use_standard_pattern=False + ) + assert response["authorization_servers"] == ["https://litellm.example.com/solo_mcp"] + finally: + global_mcp_server_manager.registry.clear() + + @pytest.mark.asyncio async def test_oauth_authorization_server_returns_empty_scopes_when_none(): """ 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 ec285f8eba0e..fe583ace8978 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 @@ -442,7 +442,10 @@ async def test_fetch_upstream_metadata_returns_none_when_not_all_candidates_netw @pytest.mark.asyncio async def test_oauth_protected_resource_gateway_managed_unchanged(): - """Regression guard: OAuth2 servers still advertise the gateway as AS.""" + """Regression guard: gateway-managed OAuth2 servers advertise the gateway as AS and + never fetch upstream metadata. Since LIT-4864 the advertised document is the gateway's + own aggregate authorization server ({base}/mcp), which serves the keyless DCR flow for + per-server URLs; the per-server relay endpoints remain for the keyed flow.""" from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( global_mcp_server_manager, ) @@ -477,7 +480,7 @@ 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/mcp"] assert result["scopes_supported"] == ["read"] 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 e5173be45b99..7bdd3b367634 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 @@ -664,6 +664,97 @@ async def test_per_user_oauth_missing_stored_token_returns_preemptive_401(): assert "Bearer authorization_uri=" in exc_info.value.headers["www-authenticate"] +@pytest.mark.asyncio +async def test_admitted_subject_missing_stored_token_challenged_with_resource_metadata(): + """ + LIT-4864: a keyless gateway-session subject (mcp_admitted_user_subject) with no stored + per-user token must be challenged with the per-server resource_metadata, whose + authorization server is the gateway itself, so the client re-runs the gateway sign-in + flow and vaults the upstream token through the authorize interlude. The keyed + authorization_uri challenge points at the per-server relay, which cannot vault a token + for a keyless client (its token request carries no litellm credential), so sending an + admitted subject there would dead-end the flow on a raw upstream token. + """ + from fastapi import HTTPException + + 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 = { + "type": "http", + "method": "POST", + "path": "/mcp/repro_oauth_server", + "scheme": "http", + "query_string": b"", + "root_path": "", + "server": ("localhost", 8000), + "headers": [ + (b"content-type", b"application/json"), + (b"host", b"localhost:8000"), + ], + } + receive = AsyncMock() + send = AsyncMock() + user_auth = MagicMock() + user_auth.user_id = "sso-user-42" + user_auth.mcp_admitted_user_subject = True + oauth_server = MagicMock() + oauth_server.auth_type = MCPAuth.oauth2 + oauth_server.needs_user_oauth_token = True + oauth_server.delegate_auth_to_upstream = False + + with ( + patch( + "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", + new_callable=AsyncMock, + return_value=(user_auth, None, ["repro_oauth_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.global_mcp_server_manager.has_user_oauth_token", + new_callable=AsyncMock, + return_value=False, + ) as mock_has_token, + patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager.get_mcp_server_by_name", + return_value=oauth_server, + ), + patch.object( + session_manager_stateless, + "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_has_token.await_count == 1 + assert mock_handle_request.await_count == 0 + assert exc_info.value.status_code == 401 + challenge = exc_info.value.headers["www-authenticate"] + assert "authorization_uri=" not in challenge + assert challenge == ( + 'Bearer resource_metadata="http://localhost:8000' + '/.well-known/oauth-protected-resource/mcp/repro_oauth_server"' + ) + + @pytest.mark.asyncio @pytest.mark.parametrize( "m2m_fields",