Preserve empty URL paths on OAuth metadata models#2925
Conversation
Set url_preserve_empty_path=True (Pydantic 2.12+) on OAuthMetadata, ProtectedResourceMetadata, and OAuthClientMetadata so a path-less URL parsed from the wire keeps its empty path instead of acquiring a trailing slash. RFC 9207 / RFC 8414 issuer comparisons require simple string comparison (RFC 3986 6.2.1), which a spurious trailing slash defeats: an issuer of https://as.example.com now round-trips unchanged rather than as https://as.example.com/. Only values parsed from strings/JSON change; URLs built from an already normalized AnyHttpUrl object are unaffected.
With url_preserve_empty_path on the OAuth metadata models (#2925), the issuer parsed from the wire keeps its empty path, so str(oauth_metadata.issuer) is already the byte-exact value the authorization server transmitted. Remove _strip_authority_trailing_slash / raw_issuer and compare directly. This also fixes the false rejection the heuristic introduced for an issuer that genuinely ends in a trailing slash (e.g. Auth0's https://tenant.auth0.com/): its redirect iss now matches its advertised issuer instead of being stripped.
| # Preserve empty URL paths so identifiers are compared as transmitted (RFC 3986 6.2.1) | ||
| # instead of acquiring a trailing slash; defaults in Pydantic v3. | ||
| model_config = ConfigDict(url_preserve_empty_path=True) |
There was a problem hiding this comment.
🔴 Adding url_preserve_empty_path=True to OAuthClientMetadata changes the wire serialization of redirect_uris, not just metadata parsing: a path-less redirect URI passed as a string (e.g. redirect_uris=['http://localhost:8080']) previously serialized as http://localhost:8080/ but now keeps the empty path, and the client sends this value verbatim in the /authorize and token-exchange requests (oauth2.py:337, :393). A client that registered with the old SDK (registration persisted in TokenStorage, so no re-registration) will now send a redirect_uri that no longer exact-string-matches the one recorded at an RFC 6749-compliant authorization server, breaking a previously working flow. Consider scoping the flag to OAuthMetadata/ProtectedResourceMetadata (which is all the issuer-comparison goal needs) or calling out the redirect_uri implication in the migration note.
Extended reasoning...
What the bug is. The PR's stated goal is to make issuer/resource/authorization_servers round-trip as transmitted for RFC 9207 / RFC 8414 issuer comparison. But the flag is also applied to OAuthClientMetadata, where the comparison-sensitive field on the wire is redirect_uris — and that field's serialized form is exact-string-matched by an external party (the authorization server), persistently, across SDK upgrades.
The PR's scope claim doesn't fully hold. The description says "URLs constructed in Python from an already-built AnyHttpUrl object are unchanged ... only values parsed from strings/JSON change." That is true for pre-built AnyHttpUrl objects, but the common way client metadata is constructed is with plain strings: OAuthClientMetadata(redirect_uris=['http://localhost:8080']). Verified on pydantic 2.12.5 (the repo's installed floor): with url_preserve_empty_path=True, python-mode validation of that string now yields 'http://localhost:8080' (and model_dump_json emits it without the slash), whereas before this PR it normalized to 'http://localhost:8080/'. So user code that hasn't changed at all produces a different wire value after upgrading.
The code path that puts it on the wire. src/mcp/client/auth/oauth2.py sends str(self.context.client_metadata.redirect_uris[0]) directly in both the authorization request (oauth2.py:337) and the token-exchange request (oauth2.py:393), and the DCR registration body is serialized from this same model. Nothing re-normalizes the URI before transmission.
Step-by-step regression scenario.
- With the previous SDK release, an app constructs
OAuthClientMetadata(redirect_uris=['http://localhost:8080'])and performs dynamic client registration. The registration request carries"redirect_uris": ["http://localhost:8080/"](old normalization), so the AS records exactly that string. The resultingclient_id/client info is persisted inTokenStorage— the SDK's normal mode of operation — so no re-registration happens later. - The app upgrades to an SDK containing this PR. Same user code, but
redirect_uris[0]now serializes ashttp://localhost:8080(no trailing slash). - The next OAuth flow sends
redirect_uri=http://localhost:8080in the /authorize request (oauth2.py:337). RFC 6749 §3.1.2.3 requires the AS to compare redirect URIs using simple string comparison, so a spec-compliant external AS rejects the request withinvalid_redirect_uri— a hard, confusing auth failure for a flow that worked before the upgrade, until the user clears stored client info and re-registers.
Why nothing else prevents it. The SDK's own server-side AuthorizationHandler is not affected, because pydantic AnyUrl equality treats http://localhost:8080 and http://localhost:8080/ as equal (verified), so validate_redirect_uri still matches. The breakage is specifically against third-party authorization servers doing the RFC-mandated literal string match on the transmitted value, where pydantic equality is irrelevant. Neither the migration note nor the PR description mentions that OAuthClientMetadata.redirect_uris output changes on the wire.
Mitigating factors and how to fix. The trigger is fairly narrow: it requires a path-less redirect URI (uncommon — the SDK examples use /callback), a DCR registration persisted across the upgrade, and an AS that does strict string matching; recovery is re-registering. Still, the fix is cheap: scope url_preserve_empty_path=True to OAuthMetadata and ProtectedResourceMetadata only (which is everything the issuer-comparison goal and #2921 need), or — if the new behavior on OAuthClientMetadata is intentional — explicitly document the redirect_uri wire-format change in the migration note so users with persisted registrations know to re-register.
Sets
url_preserve_empty_path=True(Pydantic 2.12+) onOAuthMetadata,ProtectedResourceMetadata, andOAuthClientMetadata.Why
A path-less URL parsed from the wire previously acquired a trailing slash (
https://as.example.com→https://as.example.com/). RFC 9207 / RFC 8414 issuer comparisons require simple string comparison (RFC 3986 §6.2.1), which a spurious trailing slash defeats. With this flag,issuer/resource/authorization_serversround-trip as transmitted.This is a prerequisite for #2921 (validate the
issauthorization-response parameter): once this lands, that PR can drop its_strip_authority_trailing_slashworkaround and compare issuers directly.Scope
AnyHttpUrlobject are unchanged (they were normalized at construction), so model-constructed test fixtures and existing served-metadata output are unaffected — only one test assertion needed updating.>=2.12.0; no dependency change.AI Disclaimer
This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.