GeezerVison is showing github.com — [v1.x] Validate the authorization server metadata issuer on every discovery path by maxisbey · Pull Request #3431 · modelcontextprotocol/python-sdk
Conversation
maxisbey
commented
Sep 2, 2026
•
edited
Loading
Uh oh!
There was an error while loading. Please reload this page.
v1.x backport of #3398 and #3435.
The OAuth client on 1.x now decides which issuer it expects before it fetches any authorization server metadata and checks what follows against that one value: the metadata's issuer (RFC 8414 section 3.3), which 1.x did not check on any path; the binding of stored client credentials to the authorization server they were registered with, which 1.x did not have; and a 403 scope step-up that goes through the same discovery when no metadata is held. The two machine-to-machine providers gain the optional issuer= keyword.
Motivation and Context
Same as #3398. On 1.x the client accepted authorization server metadata without comparing its issuer to the URL the well-known was built from, and stored client_id/client_secret carried no record of which server issued them, so they were presented to whichever authorization server the resource advertised next (or, on the 2025-03-26 fallback, to the resource server's own origin). This brings the 1.x client to the behaviour main has: the expected issuer is the PRM-advertised server, or on the no-PRM path the resource server's origin; metadata that names anything else is refused (a root issuer with and without its trailing slash is the same server); a registration bound to a different issuer is dropped, together with its tokens, before any metadata is fetched, and the client re-registers; newly registered clients are bound to the expected issuer; a 403 insufficient_scope with no metadata held discovers first; a 5xx/429 on the resource metadata request with no location answering stops the flow; and ClientCredentialsOAuthProvider / PrivateKeyJWTOAuthProvider accept issuer= so pre-provisioned credentials only go to that server.
Because 1.x had no credential binding, this also carries the parts of #2921, #2933, #2936, #2946 and #3181 that the change relies on: OAuthClientInformationFull.issuer (recorded by the SDK, never read from a registration response), validate_metadata_issuer, credentials_match_issuer and issuers_match in mcp.client.auth.utils, and the registration step recording the issuer.
Differences from #3398:
- RFC 9207
iss validation is not included; the callback contract on 1.x stays tuple[str, str | None].
- 1.x has no SEP-2350 scope union, so a step-up asks for the challenged scope as before.
- Every parsed issuer renders with a trailing slash on 1.x, so the root-slash allowance lives in
validate_metadata_issuer itself (new here) rather than only on the legacy path, and recorded issuers carry the slash.
- The
issuer= keyword follows scopes (the 1.x name); there is no docs page for these providers on 1.x, so the docstrings carry the description.
- Deprecate constructing the pre-provisioned OAuth clients without an issuer #3435's deprecation of constructing the two providers without
issuer= is included as a third commit; 1.x has no MCPDeprecationWarning, so it warns with DeprecationWarning like the other deprecations on this branch, and the note lives in the docstrings (1.x has no deprecations docs page).
- The conformance harness pinned on 1.x (0.1.13) serves, in
auth/metadata-var2 and auth/metadata-var3, authorization server metadata whose issuer omits the tenant path its resource metadata advertises, so a client that checks RFC 8414 section 3.3 refuses it. Those two scenarios are listed in expected-failures.yml with that note; the mock includes the path from conformance 0.1.15 (fix: include tenant path in auth server issuer claim conformance#152), so the entries go when the pin moves past it. (Bumping the pin here instead would trade them for two unrelated baseline entries, so that is left for its own change.)
How Has This Been Tested?
New tests in tests/client/test_auth.py and tests/client/auth/extensions/test_client_credentials.py, ported from #3398 and from main's credential-binding suite: issuer check on the PRM and legacy paths, root-slash tolerance and its limits, binding kept/discarded/stamped/not-stamped cases, registration responses cannot seed the binding, legacy path with no metadata (from a 401 and from a 403), step-up with and without metadata held, non-challenge 403, PRM 5xx/429, issuer= on both providers. The flow tests were checked to fail against v1.x's oauth2.py with the new helpers in place (they import functions 1.x did not have, so the plain "restore src/" recipe cannot collect them). I also drove the 1.x provider through httpx.AsyncClient(auth=...) and streamablehttp_client against local test servers for the PRM and legacy paths, 401-first and 403-first, and client-credentials with and without issuer=. Full suite, coverage, pyright and ruff pass locally.
Breaking Changes
No API removals or signature changes; additions are the optional issuer= keyword, the optional OAuthClientInformationFull.issuer field (round-trips through the unchanged TokenStorage methods; the server side does not emit it), and three functions in mcp.client.auth.utils. Observable differences:
- Authorization server metadata whose
issuer is not the advertised server (or, without protected resource metadata, the resource server's origin) now gets OAuthFlowError: Authorization server metadata issuer mismatch. No client-side override, as on main.
- Stored credentials the SDK registers from now on carry an issuer; when the resource later names a different authorization server they are discarded and the client re-registers (one extra
POST /register). Records stored by earlier 1.x releases carry no issuer and are left as they are.
- A scope step-up performs discovery first when no metadata is held; a 403 without
insufficient_scope is no longer retried.
- When no protected resource metadata location answers and one returned a 5xx or 429, the flow raises instead of taking the legacy path.
- An
issuer member in a dynamic client registration response is ignored.
Types of changes
- Bug fix (non-breaking change which fixes an issue)
- New feature (non-breaking change which adds functionality)
- Breaking change (fix or feature that would cause existing functionality to change)
- Documentation update
Checklist
- I am assigned to the linked issue (or it is labeled
help wanted, or I'm a maintainer)
- I have disclosed any AI assistance and can explain the change in my own words
- I have read the MCP Documentation
- My code follows the repository's style guidelines
- New and existing tests pass locally
- I have added appropriate error handling
- I have added or updated documentation as needed
Additional context
Three commits: the discovery change (with the binding layer it needs on 1.x), the issuer= keyword, then its deprecation-when-omitted from #3435. The discovery block in async_auth_flow is re-indented under one new condition; the whitespace-insensitive view is the easier read.
AI Disclaimer
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
…r metadata
Backport of the discovery half of #3398, together with the SEP-2352
credential binding that main has had since 2.0 and that change relies on
(the parts of #2921, #2933, #2936, #2946 and #3181 it needs).
The discovery step now knows which issuer the authorization server
metadata must carry before fetching it: the PRM-advertised server, or on
the 2025-03-26 no-PRM fallback the resource server's origin, which is
what that well-known URL is built from (RFC 8414 section 3.3). On this
line there was no issuer check at all; it now runs on both paths
(`validate_metadata_issuer`, with `issuers_match`: exact, except that a
root issuer with and without its trailing slash is the same server,
which matters here because every parsed issuer renders with the slash).
Stored client credentials gain the binding main has: the SDK records the
issuer a registration was made with (`OAuthClientInformationFull.issuer`,
never read from the registration response), and before any metadata is
fetched a registration bound to a different issuer is discarded together
with its tokens so the flow re-registers instead of presenting another
server's credentials (`credentials_match_issuer`); newly registered
clients are bound to the expected issuer when metadata for it was found and
the registration actually went to that server.
When no protected resource metadata location answers and one of them
failed with a server error (or 429), the flow stops rather than reading
that as "no such metadata".
A 403 insufficient_scope step-up takes the same path as a 401: it
discovers first when no metadata is held (extract_resource_metadata_from_www_auth
also reads the `resource_metadata` hint from a 403 challenge) and then
re-authorizes with the challenged scope; metadata already discovered in
this process is reused as before. A 403 that is not a scope challenge is
handed back to the caller instead of being retried unchanged.
The conformance harness pinned on this line (0.1.13) still serves, in its
two path-based authorization server scenarios, metadata whose `issuer` omits
the advertised tenant path; those two scenarios are listed as expected
failures until the pin moves past conformance 0.1.15, where the mock was
corrected.
Differences from the main change:
- RFC 9207 `iss` validation is not included; the callback contract on
1.x stays `tuple[str, str | None]`.
- 1.x has no SEP-2350 scope union, so a step-up asks for the challenged
scope as it did before.
- The root-slash allowance lives in `validate_metadata_issuer` itself
(new on this line) rather than only on the legacy path.
Backport of the second half of #3398.
ClientCredentialsOAuthProvider and PrivateKeyJWTOAuthProvider take an
optional issuer keyword: the issuer identifier of the authorization
server the fixed client_id (and secret) were issued by. When set, token
requests are only built from discovered authorization server metadata
whose issuer matches it; if discovery yields metadata for another
server, or none at all, the flow stops with OAuthFlowError before the
secret is attached or an assertion is minted, and the metadata and
tokens held are dropped so the next request starts discovery again.
When the resource advertises several authorization servers the one
matching the configured issuer is used; the comparison is issuers_match
(exact, root slash aside); a value that is not an http(s) URL is a
ValueError. Omitting it keeps the current behaviour.
Differences from the main change: the keyword follows `scopes` (the 1.x
name); there is no docs page for these providers on 1.x, so the
docstrings carry the description.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This pull request has been reviewed before and this review found new issues. Where they share a root cause, one fix may close them together.
Still open from earlier reviews (4):
- Unresolved: 4 minor or pre-existing.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
cubic-dev-ai
Bot
left a comment
•
edited
Loading
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Review completed against the latest diff
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…ut an issuer
Backport of #3435. Omitting `issuer=` on ClientCredentialsOAuthProvider and
PrivateKeyJWTOAuthProvider keeps working and now warns at construction that
it will be required in 3.0, saying why (without it the MCP server decides
which authorization server receives the credentials) and what to pass.
Difference from main: 1.x has no MCPDeprecationWarning, so the category is
DeprecationWarning like the other deprecations on this branch, and there is
no deprecations docs page to list it on; the docstrings carry the note and
the module's examples pass `issuer=`.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Findings marked 🟡 are optional suggestions and need no follow-up push.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Assignees
No one assigned
Milestone
No milestone
Development
Successfully merging this pull request may close these issues.
Uh oh!
There was an error while loading. Please reload this page.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
|