From 8af770ca833ab6428ce445657d0877eef32bd5f1 Mon Sep 17 00:00:00 2001 From: yucheng Date: Fri, 11 Sep 2026 22:35:05 +0000 Subject: [PATCH] fix(mcp): point the Agent 365 sign-in challenge at the metadata for the route the client used The connect-time 401 always named /.well-known/oauth-protected-resource/mcp/{server}, so a client on the /{server}/mcp URL fetched a document whose resource was a different URL and RFC 9728 strict clients (newer Claude Code) stopped before opening the browser. Reuse get_passthrough_resource_metadata_url so the challenge is absolute and matches the inbound path, on both the connect challenge and OBO preflight Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../mcp_server/mcp_server_manager.py | 6 ++- .../outbound_credentials/adapter.py | 8 +++- .../proxy/_experimental/mcp_server/server.py | 8 +++- .../outbound_credentials/test_adapter.py | 16 +++++++ .../mcp_server/test_mcp_server.py | 42 ++++++++++++++++--- 5 files changed, 72 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 7ee4daed477..7ccd6307373 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -3946,6 +3946,7 @@ class MCPServerManager: oauth2_headers: dict[str, str] | None, user_api_key_auth: UserAPIKeyAuth | None, raw_headers: Mapping[str, str] | None = None, + resource_metadata_url: str | None = None, ) -> None: """Mint an exchange-backed server's upstream credential at the transport edge. @@ -3983,7 +3984,9 @@ class MCPServerManager: if spec is None or not isinstance(spec.config, (TokenExchangeConfig, IdJagConfig)): return if subject_token is None and isinstance(spec.config, TokenExchangeConfig): - raise_token_exchange_challenge(resolved_server, root_path=get_request_root_path()) + raise_token_exchange_challenge( + resolved_server, root_path=get_request_root_path(), resource_metadata_url=resource_metadata_url + ) match await self._cred_provider.resolve_credentials(to_subject(user_api_key_auth, subject_token), spec): case Ok(_): return @@ -3993,6 +3996,7 @@ class MCPServerManager: resolved_server, root_path=get_request_root_path(), claims=err.unauthorized.claims, + resource_metadata_url=resource_metadata_url, ) raise_public(err) diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py index 77979a15199..2401ad4a776 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py @@ -358,6 +358,7 @@ def raise_token_exchange_challenge( *, root_path: str, claims: str | None = None, + resource_metadata_url: str | None = None, ) -> NoReturn: """Raise the RFC 9728 / RFC 6750 challenge an OBO (``token_exchange``) server returns when the caller's subject token is missing or the IdP rejected it. @@ -375,8 +376,13 @@ def raise_token_exchange_challenge( ``error="invalid_token"`` and is byte-identical to the static one. Both the error value (one of two literals) and the base64 claims draw from a fixed alphabet, so nothing from the IdP body reaches the header unescaped. + + ``resource_metadata_url`` overrides the derived path with the absolute metadata URL matching the + route spelling the request arrived on: RFC 9728 ยง3.3 clients reject a ``resource`` that differs + from the URL they connected to, and a ``/{server}/mcp`` connect must not be sent to the + ``/mcp/{server}`` document. """ - resource_metadata: Final = oauth_protected_resource_path(root_path, server) + resource_metadata: Final = resource_metadata_url or oauth_protected_resource_path(root_path, server) encoded_claims: Final = base64.b64encode(claims.encode()).decode() if claims else None error: Final = "insufficient_claims" if encoded_claims else "invalid_token" error_description: Final = ( diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 887a4aec033..bf9e1f1567f 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -56,6 +56,7 @@ from litellm.proxy._experimental.mcp_server.mcp_debug import ( ) from litellm.proxy._experimental.mcp_server.oauth_utils import ( _redact_mcp_resource_url, + get_passthrough_resource_metadata_url, get_passthrough_www_authenticate, get_route_relative_request_path, well_known_root_suffix, @@ -4155,7 +4156,11 @@ if MCP_AVAILABLE: get_request_root_path, ) - raise_token_exchange_challenge(server, root_path=get_request_root_path()) + raise_token_exchange_challenge( + server, + root_path=get_request_root_path(), + resource_metadata_url=get_passthrough_resource_metadata_url(scope=scope, server_name=server_name), + ) # Exchange-backed modes (token_exchange's OBO mint, id_jag's stored-assertion mint): run # the exchange here at the transport edge, so a rejected subject raises the RFC 9728 @@ -4180,6 +4185,7 @@ if MCP_AVAILABLE: oauth2_headers=oauth2_headers, user_api_key_auth=user_api_key_auth, raw_headers=raw_headers, + resource_metadata_url=get_passthrough_resource_metadata_url(scope=scope, server_name=server_name), ) # Pass-through OAuth: when the admin has opted a server into 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 1b003e11993..d650c5ea6c1 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 @@ -584,6 +584,22 @@ def test_raise_token_exchange_challenge_is_rfc9728_invalid_token(): assert "error_description=" in www +def test_raise_token_exchange_challenge_explicit_resource_metadata_url_wins(): + from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import ( + raise_token_exchange_challenge, + ) + + with pytest.raises(HTTPException) as exc_info: + raise_token_exchange_challenge( + _server(alias="obo-srv"), + root_path="/", + resource_metadata_url="https://gw.example.com/.well-known/oauth-protected-resource/obo-srv/mcp", + ) + www = exc_info.value.headers["WWW-Authenticate"] + assert 'resource_metadata="https://gw.example.com/.well-known/oauth-protected-resource/obo-srv/mcp"' in www + assert "/mcp/obo-srv" not in www + + def test_raise_token_exchange_challenge_includes_server_root_path(monkeypatch): from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import ( raise_token_exchange_challenge, 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 b599fbadb50..95103a3ed71 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 @@ -8962,7 +8962,9 @@ class TestAgent365ChallengeAtConnect: litellm.callbacks, guardrail, require_self=False ) - async def _connect(self, server: MCPServer, oauth2_headers: dict[str, str] | None) -> HTTPException | None: + async def _connect( + self, server: MCPServer, oauth2_headers: dict[str, str] | None, path: str = "/mcp/tools" + ) -> HTTPException | None: from litellm.proxy._experimental.mcp_server import server as server_module with ( @@ -8975,7 +8977,14 @@ class TestAgent365ChallengeAtConnect: ): try: await server_module._raise_preemptive_401_for_unauthenticated_servers( - scope={"type": "http", "method": "POST", "path": "/mcp/tools", "headers": []}, + scope={ + "type": "http", + "method": "POST", + "path": path, + "scheme": "https", + "server": ("gw.example.com", 443), + "headers": [], + }, mcp_servers=["tools"], oauth2_headers=oauth2_headers, mcp_server_auth_headers=None, @@ -8993,7 +9002,23 @@ class TestAgent365ChallengeAtConnect: assert challenge is not None and challenge.status_code == 401 www_authenticate = (challenge.headers or {}).get("WWW-Authenticate", "") assert 'error="invalid_token"' in www_authenticate - assert 'resource_metadata="/.well-known/oauth-protected-resource/mcp/tools"' in www_authenticate + assert ( + 'resource_metadata="https://gw.example.com/.well-known/oauth-protected-resource/mcp/tools"' + in www_authenticate + ) + + @pytest.mark.asyncio + async def test_legacy_route_challenge_points_at_its_own_metadata(self, agent_365_guardrail): + """RFC 9728 3.3: the metadata's ``resource`` must equal the URL the client connected to, so a + ``/{server}/mcp`` connect is sent to the ``/{server}/mcp`` document, not the ``/mcp/{server}`` one.""" + challenge = await self._connect(self._server([self.GATEWAY_SCOPE]), None, path="/tools/mcp") + + assert challenge is not None and challenge.status_code == 401 + www_authenticate = (challenge.headers or {}).get("WWW-Authenticate", "") + assert ( + 'resource_metadata="https://gw.example.com/.well-known/oauth-protected-resource/tools/mcp"' + in www_authenticate + ) @pytest.mark.asyncio async def test_entra_assertion_present_connects(self, agent_365_guardrail): @@ -9011,7 +9036,10 @@ class TestAgent365ChallengeAtConnect: assert challenge is not None and challenge.status_code == 401 www_authenticate = (challenge.headers or {}).get("WWW-Authenticate", "") assert 'error="invalid_token"' in www_authenticate - assert 'resource_metadata="/.well-known/oauth-protected-resource/mcp/tools"' in www_authenticate + assert ( + 'resource_metadata="https://gw.example.com/.well-known/oauth-protected-resource/mcp/tools"' + in www_authenticate + ) @pytest.mark.asyncio async def test_scopeless_server_is_still_challenged(self, agent_365_guardrail): @@ -9096,7 +9124,11 @@ class TestOboPreflightScopedToAllowedServers: _, preflight = await self._run(requested, allowed=[requested], user_api_key_auth=key) preflight.assert_awaited_once_with( - server=requested, oauth2_headers=self.SUBJECT_HEADERS, user_api_key_auth=key, raw_headers=None + server=requested, + oauth2_headers=self.SUBJECT_HEADERS, + user_api_key_auth=key, + raw_headers=None, + resource_metadata_url="/.well-known/oauth-protected-resource/mcp/obo_tools", )