diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 693e3f8e47d..eb66b63457d 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -2088,13 +2088,15 @@ async def _build_oauth_protected_resource_response( 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. + discover the **upstream** IdP instead of the gateway. ``resource`` is + rewritten to the gateway URL the client dialed, because a document served + from the gateway's own well-known path that names a different resource is + rejected outright by RFC 9728 clients (they compare it to the MCP URL they + connected to) before any sign-in can start. Only ``true_passthrough``, whose + contract is that the gateway is invisible and the client transacts with the + upstream directly, keeps the upstream metadata verbatim. A strict IdP that + refuses to mint a token for the gateway resource needs ``dcr_bridge``, where + the gateway holds the upstream token instead of the client. An explicitly named gateway-managed oauth2 server (interactive with gateway-vaulted per-user tokens, or M2M) advertises the gateway's own @@ -2171,7 +2173,7 @@ async def _build_oauth_protected_resource_response( ) if upstream_metadata is not None: - if mcp_server.is_true_passthrough or mcp_server.is_oauth_delegate: + if mcp_server.is_true_passthrough: return upstream_metadata return {**upstream_metadata, "resource": resource_url} 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 fe583ace897..56b60dfeb53 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 @@ -500,13 +500,12 @@ def _make_upstream_metadata_client() -> tuple[dict, MagicMock]: @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.""" +@pytest.mark.parametrize("use_standard_pattern", [True, False]) +async def test_oauth_protected_resource_oauth_delegate_resource_is_the_gateway_url(use_standard_pattern): + """A non-bridge oauth_delegate server keeps the upstream authorization server but must + advertise the gateway URL the client dialed as ``resource``: an RFC 9728 client compares + ``resource`` against the MCP URL it connected to and refuses to start the upstream sign-in + when they differ (#36803). Returning the upstream document verbatim regresses that.""" from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( global_mcp_server_manager, ) @@ -520,6 +519,7 @@ async def test_oauth_protected_resource_oauth_delegate_returns_upstream_metadata url="https://upstream.example.com/mcp", transport=MCPTransport.http, auth_type=MCPAuth.oauth_delegate, + dcr_bridge=False, ) global_mcp_server_manager.registry[delegate_server.server_id] = delegate_server @@ -529,15 +529,18 @@ async def test_oauth_protected_resource_oauth_delegate_returns_upstream_metadata result = await _build_oauth_protected_resource_response( request=_make_request(), mcp_server_name="sample_docs", - use_standard_pattern=True, + use_standard_pattern=use_standard_pattern, ) - - 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() + expected_resource = ( + "https://gateway.example.com/mcp/sample_docs" + if use_standard_pattern + else "https://gateway.example.com/sample_docs/mcp" + ) + assert result == {**upstream_payload, "resource": expected_resource} + @pytest.mark.asyncio async def test_oauth_protected_resource_true_passthrough_returns_upstream_metadata_verbatim():