From 8a63ffbb55d4efebfa5e139d4d7f15709a9ae7a9 Mon Sep 17 00:00:00 2001 From: maxpetrusenkoagent Date: Sat, 13 Jun 2026 22:56:37 -0400 Subject: [PATCH] Add OIDC fallback for legacy OAuth discovery --- src/mcp/client/auth/utils.py | 6 +- tests/client/test_auth.py | 82 ++++++++++++++++++++++++ tests/interaction/auth/test_discovery.py | 16 +++-- 3 files changed, 96 insertions(+), 8 deletions(-) diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index 780a24e859..eb20b99e94 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -143,7 +143,11 @@ def build_oauth_authorization_server_metadata_discovery_urls(auth_server_url: st # Legacy path using the 2025-03-26 spec: # link: https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization parsed = urlparse(server_url) - return [f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-authorization-server"] + base_url = f"{parsed.scheme}://{parsed.netloc}" + return [ + urljoin(base_url, "/.well-known/oauth-authorization-server"), + urljoin(base_url, "/.well-known/openid-configuration"), + ] urls: list[str] = [] parsed = urlparse(auth_server_url) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index ca7a495e6c..496ea90ea9 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -329,6 +329,7 @@ async def test_oauth_discovery_legacy_fallback_when_no_prm(self): # Should only try the root URL (legacy behavior) assert discovery_urls == [ "https://mcp.linear.app/.well-known/oauth-authorization-server", + "https://mcp.linear.app/.well-known/openid-configuration", ] @pytest.mark.anyio @@ -1046,6 +1047,87 @@ def test_falls_back_when_metadata_has_no_registration_endpoint(self): assert request.method == "POST" +@pytest.mark.anyio +async def test_oauth_flow_discovers_oidc_metadata_when_prm_is_absent( + client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage +): + """Test OIDC metadata discovery after PRM and OAuth metadata are absent.""" + captured_auth_url: str | None = None + captured_state: str | None = None + + async def redirect_handler(url: str) -> None: + nonlocal captured_auth_url, captured_state + captured_auth_url = url + parsed = urlparse(url) + params = parse_qs(parsed.query) + captured_state = params.get("state", [None])[0] + + async def callback_handler() -> tuple[str, str | None]: + return "test_auth_code", captured_state + + provider = OAuthClientProvider( + server_url="https://auth.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + ) + provider.context.current_tokens = None + provider.context.token_expiry_time = None + provider.context.client_info = OAuthClientInformationFull( + client_id="test_client", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + ) + provider._initialized = True + + test_request = httpx.Request("GET", "https://auth.example.com/v1/mcp") + auth_flow = provider.async_auth_flow(test_request) + + await auth_flow.__anext__() + response = httpx.Response(401, headers={}, request=test_request) + + prm_request_1 = await auth_flow.asend(response) + assert str(prm_request_1.url) == "https://auth.example.com/.well-known/oauth-protected-resource/v1/mcp" + + prm_request_2 = await auth_flow.asend(httpx.Response(404, request=prm_request_1)) + assert str(prm_request_2.url) == "https://auth.example.com/.well-known/oauth-protected-resource" + + oauth_metadata_request = await auth_flow.asend(httpx.Response(404, request=prm_request_2)) + assert str(oauth_metadata_request.url) == "https://auth.example.com/.well-known/oauth-authorization-server" + + oidc_metadata_request = await auth_flow.asend(httpx.Response(404, request=oauth_metadata_request)) + assert str(oidc_metadata_request.url) == "https://auth.example.com/.well-known/openid-configuration" + + oidc_metadata_response = httpx.Response( + 200, + content=( + b'{"issuer": "https://auth.example.com",' + b' "authorization_endpoint": "https://auth.example.com/authorize",' + b' "token_endpoint": "https://auth.example.com/token"}' + ), + request=oidc_metadata_request, + ) + + token_request = await auth_flow.asend(oidc_metadata_response) + assert captured_auth_url is not None + assert captured_auth_url.startswith("https://auth.example.com/authorize?") + assert str(token_request.url) == "https://auth.example.com/token" + + token_response = httpx.Response( + 200, + content=b'{"access_token": "new_access_token", "token_type": "Bearer", "expires_in": 3600}', + request=token_request, + ) + final_request = await auth_flow.asend(token_response) + assert final_request.headers["Authorization"] == "Bearer new_access_token" + + final_response = httpx.Response(200, request=final_request) + try: + await auth_flow.asend(final_response) + except StopAsyncIteration: + pass + + class TestAuthFlow: """Test the auth flow in httpx.""" diff --git a/tests/interaction/auth/test_discovery.py b/tests/interaction/auth/test_discovery.py index 68c33c8a2d..9fa32e580c 100644 --- a/tests/interaction/auth/test_discovery.py +++ b/tests/interaction/auth/test_discovery.py @@ -123,17 +123,19 @@ async def test_prm_discovery_falls_back_from_path_well_known_to_root_on_404() -> @requirement("client-auth:prm-discovery:no-prm-fallback") async def test_when_every_prm_probe_fails_the_client_discovers_as_metadata_at_the_server_origin() -> None: - """When every protected-resource metadata probe 404s, the client falls back to the legacy path. + """When every protected-resource metadata probe 404s, the client falls back to the server origin. - The legacy 2025-03-26 behaviour: with no PRM document available, treat the MCP server's - origin as the authorization server and fetch its `/.well-known/oauth-authorization-server` - directly. The real co-hosted ASM endpoint is at exactly that location, so the flow completes. - The recorded sequence shows both PRM well-known paths probed (and failed) before ASM_ROOT. + With no PRM document available, treat the MCP server's origin as the authorization server. + OAuth metadata is tried first, then OIDC discovery. This pins the fallback for OIDC-only + authorization servers that don't expose `/.well-known/oauth-authorization-server`. """ recorded, on_request = record_requests() provider = InMemoryAuthorizationServerProvider() server = Server("guarded", on_list_tools=list_tools) - app_shim = shim(not_found=frozenset({PRM_PATH_SUFFIXED, PRM_ROOT})) + app_shim = shim( + not_found=frozenset({PRM_PATH_SUFFIXED, PRM_ROOT, ASM_ROOT}), + serve={OIDC_ROOT: metadata_body(real_asm())}, + ) with anyio.fail_after(5): async with connect_with_oauth(server, provider=provider, app_shim=app_shim, on_request=on_request) as ( @@ -145,7 +147,7 @@ async def test_when_every_prm_probe_fails_the_client_discovers_as_metadata_at_th well_known = discovery_gets(recorded) assert PRM_PATH_SUFFIXED in well_known assert PRM_ROOT in well_known - assert well_known[-1] == ASM_ROOT + assert well_known[-2:] == [ASM_ROOT, OIDC_ROOT] assert all(well_known.index(prm) < well_known.index(ASM_ROOT) for prm in (PRM_PATH_SUFFIXED, PRM_ROOT)) assert result.tools[0].name == "probe"