Skip to content

Commit 8a63ffb

Browse files
author
maxpetrusenkoagent
committed
Add OIDC fallback for legacy OAuth discovery
1 parent cf110e3 commit 8a63ffb

3 files changed

Lines changed: 96 additions & 8 deletions

File tree

src/mcp/client/auth/utils.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,11 @@ def build_oauth_authorization_server_metadata_discovery_urls(auth_server_url: st
143143
# Legacy path using the 2025-03-26 spec:
144144
# link: https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization
145145
parsed = urlparse(server_url)
146-
return [f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-authorization-server"]
146+
base_url = f"{parsed.scheme}://{parsed.netloc}"
147+
return [
148+
urljoin(base_url, "/.well-known/oauth-authorization-server"),
149+
urljoin(base_url, "/.well-known/openid-configuration"),
150+
]
147151

148152
urls: list[str] = []
149153
parsed = urlparse(auth_server_url)

tests/client/test_auth.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,7 @@ async def test_oauth_discovery_legacy_fallback_when_no_prm(self):
329329
# Should only try the root URL (legacy behavior)
330330
assert discovery_urls == [
331331
"https://mcp.linear.app/.well-known/oauth-authorization-server",
332+
"https://mcp.linear.app/.well-known/openid-configuration",
332333
]
333334

334335
@pytest.mark.anyio
@@ -1046,6 +1047,87 @@ def test_falls_back_when_metadata_has_no_registration_endpoint(self):
10461047
assert request.method == "POST"
10471048

10481049

1050+
@pytest.mark.anyio
1051+
async def test_oauth_flow_discovers_oidc_metadata_when_prm_is_absent(
1052+
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
1053+
):
1054+
"""Test OIDC metadata discovery after PRM and OAuth metadata are absent."""
1055+
captured_auth_url: str | None = None
1056+
captured_state: str | None = None
1057+
1058+
async def redirect_handler(url: str) -> None:
1059+
nonlocal captured_auth_url, captured_state
1060+
captured_auth_url = url
1061+
parsed = urlparse(url)
1062+
params = parse_qs(parsed.query)
1063+
captured_state = params.get("state", [None])[0]
1064+
1065+
async def callback_handler() -> tuple[str, str | None]:
1066+
return "test_auth_code", captured_state
1067+
1068+
provider = OAuthClientProvider(
1069+
server_url="https://auth.example.com/v1/mcp",
1070+
client_metadata=client_metadata,
1071+
storage=mock_storage,
1072+
redirect_handler=redirect_handler,
1073+
callback_handler=callback_handler,
1074+
)
1075+
provider.context.current_tokens = None
1076+
provider.context.token_expiry_time = None
1077+
provider.context.client_info = OAuthClientInformationFull(
1078+
client_id="test_client",
1079+
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
1080+
)
1081+
provider._initialized = True
1082+
1083+
test_request = httpx.Request("GET", "https://auth.example.com/v1/mcp")
1084+
auth_flow = provider.async_auth_flow(test_request)
1085+
1086+
await auth_flow.__anext__()
1087+
response = httpx.Response(401, headers={}, request=test_request)
1088+
1089+
prm_request_1 = await auth_flow.asend(response)
1090+
assert str(prm_request_1.url) == "https://auth.example.com/.well-known/oauth-protected-resource/v1/mcp"
1091+
1092+
prm_request_2 = await auth_flow.asend(httpx.Response(404, request=prm_request_1))
1093+
assert str(prm_request_2.url) == "https://auth.example.com/.well-known/oauth-protected-resource"
1094+
1095+
oauth_metadata_request = await auth_flow.asend(httpx.Response(404, request=prm_request_2))
1096+
assert str(oauth_metadata_request.url) == "https://auth.example.com/.well-known/oauth-authorization-server"
1097+
1098+
oidc_metadata_request = await auth_flow.asend(httpx.Response(404, request=oauth_metadata_request))
1099+
assert str(oidc_metadata_request.url) == "https://auth.example.com/.well-known/openid-configuration"
1100+
1101+
oidc_metadata_response = httpx.Response(
1102+
200,
1103+
content=(
1104+
b'{"issuer": "https://auth.example.com",'
1105+
b' "authorization_endpoint": "https://auth.example.com/authorize",'
1106+
b' "token_endpoint": "https://auth.example.com/token"}'
1107+
),
1108+
request=oidc_metadata_request,
1109+
)
1110+
1111+
token_request = await auth_flow.asend(oidc_metadata_response)
1112+
assert captured_auth_url is not None
1113+
assert captured_auth_url.startswith("https://auth.example.com/authorize?")
1114+
assert str(token_request.url) == "https://auth.example.com/token"
1115+
1116+
token_response = httpx.Response(
1117+
200,
1118+
content=b'{"access_token": "new_access_token", "token_type": "Bearer", "expires_in": 3600}',
1119+
request=token_request,
1120+
)
1121+
final_request = await auth_flow.asend(token_response)
1122+
assert final_request.headers["Authorization"] == "Bearer new_access_token"
1123+
1124+
final_response = httpx.Response(200, request=final_request)
1125+
try:
1126+
await auth_flow.asend(final_response)
1127+
except StopAsyncIteration:
1128+
pass
1129+
1130+
10491131
class TestAuthFlow:
10501132
"""Test the auth flow in httpx."""
10511133

tests/interaction/auth/test_discovery.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -123,17 +123,19 @@ async def test_prm_discovery_falls_back_from_path_well_known_to_root_on_404() ->
123123

124124
@requirement("client-auth:prm-discovery:no-prm-fallback")
125125
async def test_when_every_prm_probe_fails_the_client_discovers_as_metadata_at_the_server_origin() -> None:
126-
"""When every protected-resource metadata probe 404s, the client falls back to the legacy path.
126+
"""When every protected-resource metadata probe 404s, the client falls back to the server origin.
127127
128-
The legacy 2025-03-26 behaviour: with no PRM document available, treat the MCP server's
129-
origin as the authorization server and fetch its `/.well-known/oauth-authorization-server`
130-
directly. The real co-hosted ASM endpoint is at exactly that location, so the flow completes.
131-
The recorded sequence shows both PRM well-known paths probed (and failed) before ASM_ROOT.
128+
With no PRM document available, treat the MCP server's origin as the authorization server.
129+
OAuth metadata is tried first, then OIDC discovery. This pins the fallback for OIDC-only
130+
authorization servers that don't expose `/.well-known/oauth-authorization-server`.
132131
"""
133132
recorded, on_request = record_requests()
134133
provider = InMemoryAuthorizationServerProvider()
135134
server = Server("guarded", on_list_tools=list_tools)
136-
app_shim = shim(not_found=frozenset({PRM_PATH_SUFFIXED, PRM_ROOT}))
135+
app_shim = shim(
136+
not_found=frozenset({PRM_PATH_SUFFIXED, PRM_ROOT, ASM_ROOT}),
137+
serve={OIDC_ROOT: metadata_body(real_asm())},
138+
)
137139

138140
with anyio.fail_after(5):
139141
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
145147
well_known = discovery_gets(recorded)
146148
assert PRM_PATH_SUFFIXED in well_known
147149
assert PRM_ROOT in well_known
148-
assert well_known[-1] == ASM_ROOT
150+
assert well_known[-2:] == [ASM_ROOT, OIDC_ROOT]
149151
assert all(well_known.index(prm) < well_known.index(ASM_ROOT) for prm in (PRM_PATH_SUFFIXED, PRM_ROOT))
150152
assert result.tools[0].name == "probe"
151153

0 commit comments

Comments
 (0)