Skip to content

Split the registration request model from the registered-client record#3181

Open
maxisbey wants to merge 2 commits into
mainfrom
dcr-record-request-split
Open

Split the registration request model from the registered-client record#3181
maxisbey wants to merge 2 commits into
mainfrom
dcr-record-request-split

Conversation

@maxisbey

Copy link
Copy Markdown
Contributor

OAuthClientInformationFull — the client's parse of the authorization server's Dynamic Client Registration response — inherited from OAuthClientMetadata, the request the client sends. That typed the response as though it had to be a request this SDK would send, so any registered value the server substituted became a ValidationError on a 2xx. This splits the two into siblings so the record parses what a server may legitimately echo.

Motivation and Context

We received a report of an authorization server whose registration response returned an application_type value outside {"web", "native"}. The server answered 201 and provisioned the client; the SDK then raised Invalid registration response: … Input should be 'web' or 'native' [type=literal_error] out of the auth flow, discarding a registration whose client_id had already been minted (and orphaning it server-side on every retry). application_type is never read again anywhere in the SDK — it's a purely cosmetic field killing an otherwise-valid registration.

This isn't specific to that field. The response model is-a request model, so every tight type on the request re-arms the same failure on the response. The same closed-typing had already been widened piecemeal for *_supported metadata, grant_types, response_types, and empty-string URLs — this is the fifth instance of one class, and the class is the inheritance.

Spec. RFC 7591 §3.2.1: the server "MAY reject or replace any of the client's requested metadata values submitted during the registration and substitute them with suitable values", and "The client or developer can check the values in the response to determine if the registration is sufficient for use" — a substituted value is a judgment call for the client, not a parse failure. The MCP spec (SEP-837) constrains only what the client sends (MUST specify an appropriate application_type); it says nothing about the response. The server here is also off-spec — OIDC Registration §2 defines only native/web — but that doesn't oblige the client to abort a successful registration over a field it doesn't use. Both are true.

Cross-SDK. Every other MCP SDK constrains what it sends and parses application_type permissively — the TypeScript SDK types it z.string().optional() with a comment giving exactly this reasoning, go/rust/csharp/ruby likewise. This SDK was the only one that fails on it.

What changed

OAuthClientMetadata (request) and OAuthClientInformationFull (registered-client record) are now siblings over a shared OAuthClientMetadataBase holding only the fields that are genuinely identical between them:

class OAuthClientMetadataBase(BaseModel): ...          # verbatim-shared metadata
class OAuthClientMetadata(OAuthClientMetadataBase): ...          # request: strict (what we send)
class OAuthClientInformationFull(OAuthClientMetadataBase): ...   # record: tolerant (what a server echoes)
  • The request keeps every strict type — you still cannot construct or send application_type="browser"; the MCP MUST on the wire is unchanged and enforced by the type.
  • The record accepts what a server may echo: application_type/token_endpoint_auth_method are str | None (an echoed "" reads as absent, matching the existing URL-field coercion), grant_types is list[str], redirect_uris may be absent or empty. client_id is now required — RFC 7591 §3.2.1 makes it mandatory in the response, and a "registered client" record without one was never meaningful (a body without it should not parse as success).
  • Usability is judged where it matters, not at parse. The one substitution that makes minted credentials unusable — a token-endpoint auth method this client cannot apply — is reported as an OAuthRegistrationError naming the method when the registration completes, before the record is persisted or any interactive authorization begins (per §3.2.1's "sufficient for use" model). prepare_token_auth reports the same for a stored/pre-registered record. The recognized set is derived from one TokenEndpointAuthMethod type via get_args, so send-side and recognize-side cannot drift (private_key_jwt remains recognized — PrivateKeyJWTOAuthProvider's refresh path is unaffected).
  • Server side (bonus fix of the same mechanism): the bundled registration endpoint previously hand-copied ~20 fields into its 201 echo and dropped application_type, so the SDK's own AS reported the default in place of a client's "web". The echo is now built from the validated request's model_dump(), and a test pins that every request field exists on the record — a field can no longer be silently omitted.
  • The except ValidationError in handle_registration_response carried # pragma: no cover — the invalid-response branch was asserted unreachable, which is why this shipped untested. It's now covered and chains the cause (raise … from e).

How Has This Been Tested?

  • Model-level: the record parses off-set/null/empty application_type, an unimplemented token_endpoint_auth_method, extra grant_types, empty redirect_uris; the request still rejects an off-set application_type and requires redirect_uris.
  • Real handle_registration_response path (the layer the failure occurred in) with a substituted body, and with a body that isn't client information at all.
  • End-to-end interaction tests: a shimmed /register returning a substituted 201 completes the full flow (tool call succeeds); a 201 assigning an unusable auth method surfaces OAuthRegistrationError with no /authorize or /token request and nothing persisted.
  • Server: the 201 echoes the client's application_type for both web and native.
  • Driven manually against a live hostile authorization server over a socket: the production-shape body ("confidential", null redirect URIs, extra grants) now completes with HTTP 200 where main dies with the exact literal_error above; a "" auth method reads as absent; a client_secret_jwt assignment fails at registration before any browser round-trip.
  • Full suite: 100% coverage, strict-no-cover clean.

Breaking Changes

Yes — documented in docs/migration.md alongside the existing SEP-837 entry.

  • OAuthClientInformationFull is no longer a subclass of OAuthClientMetadata. Code relying on isinstance(info, OAuthClientMetadata), or passing a record where a request is annotated, must reference the record type directly. validate_scope()/validate_redirect_uri() moved with the record (they're what server code calls) and are no longer on OAuthClientMetadata.
  • On the record: application_type/token_endpoint_auth_methodstr | None, grant_typeslist[str], redirect_uris optional, client_id required. Reads are unaffected.
  • An unimplementable auth method now fails legibly (at registration / token exchange) instead of an opaque ValidationError during parsing.

Conformance: no scenario puts these values in a 201 body, and SEP-837 defines no response-side requirement, so a send-strict / parse-tolerant client passes every existing check unchanged.

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 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

Two things I considered and want to name:

  • A stored record with an unusable method now errors on refresh instead of falling through. The old fall-through wasn't a working fallback — it looped into a full re-auth against the same record and failed at the token exchange anyway. Naming the permanent condition once is the intent, and such a record could never have been produced by registration before this change, so it only affects hand-supplied pre-registered credentials, where a clear error beats an endless silent invalid_client.
  • The record still serves two seats: the client's parse of an untrusted response and the SDK's own AS's stored-client type (OAuthAuthorizationServerProvider.get_client, authorize). The split stops one class short of separating those, which is why redirect_uris elements stay AnyUrl (the AS compares them) and why hand-built server records lose a couple of constructor invariants they previously borrowed from the request. Requiring client_id restores the one that matters; a fuller server-record / client-response separation is the next seam, deliberately left out of this change.

AI Disclaimer

OAuthClientInformationFull, the client's parse of the authorization server's
Dynamic Client Registration response, inherited from OAuthClientMetadata, the
request the client sends. That typed the response as though it had to be a
request this SDK would send. RFC 7591 3.2.1 says otherwise: the server may
reject or replace any requested metadata value, and real servers echo an
application_type outside OIDC Registration's web/native, an explicit null,
an auth method the SDK does not implement, or an empty redirect_uris. Each
raised ValidationError on a 2xx response, after the server had already
provisioned the client, so the registration was discarded and orphaned.

Make the two models siblings over a shared OAuthClientMetadataBase. The
request keeps its strict types, so the SDK still refuses to send an
unregistered application_type. The record accepts what a server may echo:
application_type and token_endpoint_auth_method are str | None (with an
echoed "" read as absent, as the optional URL fields already were),
grant_types is list[str], and redirect_uris may be absent or empty.
client_id is now required, as RFC 7591 3.2.1 makes it in the response.

Whether a substituted value is usable is judged where it matters, not at
parse: an auth method the client cannot apply is reported as an
OAuthRegistrationError when the registration completes, before the record
is stored or any interactive authorization begins, and prepare_token_auth
reports the same for a stored record. The recognized set is derived from
the one TokenEndpointAuthMethod type so the two cannot drift.

The bundled registration endpoint now returns all registered metadata in
its 201 response, building the record from the validated request's dump so
a field can no longer be silently dropped from the echo; it previously
omitted application_type, reporting the default in place of a client's
"web".
@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3181.mcp-python-docs.pages.dev
Deployment https://62b1dbf0.mcp-python-docs.pages.dev
Commit 29a3942
Triggered by @maxisbey
Updated 2026-07-27 15:49:48 UTC

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 11 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/mcp/client/auth/oauth2.py Outdated
Comment thread src/mcp/client/auth/oauth2.py Outdated
Comment thread docs/migration.md Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline nits, two candidate concerns were examined and ruled out: check_registration_usable accepting private_key_jwt (correct — PrivateKeyJWTOAuthProvider supplies the assertion, and the recognized set is derived from the same TokenEndpointAuthMethod literal the SDK sends, so the refresh path a private-key-JWT client inherits keeps working), and an empty-string client_id bypassing the new client_id-REQUIRED invariant on the record (not a practical issue — "" still parses but downstream guards treat a falsy client_id as absent, and no observed server mints an empty id).

Extended reasoning...

Bugs were found (three nits, posted inline), so the review body is limited to recording what else was examined this run. The two refuted candidates both target the new tolerance/usability mechanism in src/mcp/client/auth/oauth2.py and src/mcp/shared/auth.py; verifiers concluded neither is a real defect. This note is informational only — it is not an approval, and the PR (a breaking change across OAuth client/server auth code) should still get human review.

Comment thread src/mcp/shared/auth.py
Comment thread src/mcp/server/auth/handlers/register.py
Comment thread docs/migration.md Outdated
…boundary

Two conflated questions shared one recognized-method set: whether the
authorization-code registration flow can act on an assigned method, and
whether a stored record may carry a method without the token step
erroring. The flow authenticates with the minted secret and holds no key
to sign a private_key_jwt assertion, so it now rejects that method at
registration too, while prepare_token_auth still tolerates it for
PrivateKeyJWTOAuthProvider's inherited refresh path.

An explicit JSON null in the registration response is now read as an
omitted key across the whole record, so grant_types and response_types
(which pydantic rejects null for) parse like the other fields. The
SDK-internal issuer binding is cleared after parsing, so a body echoing
an "issuer" member cannot seed it. The bundled registration endpoint
sets client_secret_expires_at to 0 rather than omitting it whenever a
client_secret is issued with no expiry configured, as RFC 7591 3.2.1
requires. The migration entry now names both exception surfaces.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 10 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/mcp/client/auth/utils.py">

<violation number="1" location="src/mcp/client/auth/utils.py:308">
P2: Registration still fails when a server includes a non-string `issuer` extension, because validation consumes it before this reset. Strip `issuer` from the decoded response before validating so this SDK-internal field cannot affect response parsing.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

# `issuer` is the SDK's own binding of these credentials to the server they were
# registered with (SEP-2352), stamped by the auth flow - never taken from the wire.
# A body echoing an "issuer" member must not seed the trust binding.
client_info.issuer = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Registration still fails when a server includes a non-string issuer extension, because validation consumes it before this reset. Strip issuer from the decoded response before validating so this SDK-internal field cannot affect response parsing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/auth/utils.py, line 308:

<comment>Registration still fails when a server includes a non-string `issuer` extension, because validation consumes it before this reset. Strip `issuer` from the decoded response before validating so this SDK-internal field cannot affect response parsing.</comment>

<file context>
@@ -300,9 +300,13 @@ async def handle_registration_response(response: Response) -> OAuthClientInforma
+    # `issuer` is the SDK's own binding of these credentials to the server they were
+    # registered with (SEP-2352), stamped by the auth flow - never taken from the wire.
+    # A body echoing an "issuer" member must not seed the trust binding.
+    client_info.issuer = None
+    return client_info
 
</file context>

@maxisbey

Copy link
Copy Markdown
Contributor Author

Follow-up in 29a3942 addressing the review round; the six inline threads are replied to and resolved individually. Two further items came in without a thread, so recording them here:

Reported: required client_id makes pyright fail with ~20 always-true is not None errors. This does not reproduce — uv run --frozen pyright over the whole repo on this branch reports 0 errors, 0 warnings, and the pre-commit pyright hook passes on both commits. The 22 assert … client_id is not None sites do exist and are now dead narrowing, but strict pyright here does not flag them, so no check fails. Cleaning them would touch roughly eight unrelated test/example files for no behaviour change, so they are left as-is; happy to trim them in a follow-up if wanted.

Reported: the SDK-internal issuer binding (SEP-2352) was populatable from the untrusted response body. Confirmed — handle_registration_response parsed the wire body straight into the record, so a server-echoed "issuer" member could populate the field, and on the fallback branch where the flow does not overwrite it, that value would be persisted as the trust binding (after which every 401 mismatches and re-registers). Pre-existing rather than introduced here, but this PR is the redesign of exactly that parse boundary, so it is now sealed: issuer is cleared right after the parse — the binding is stamped by the flow and never taken from the wire — with a test that an echoed "issuer" does not seed it. Verified against a server echoing a bogus issuer: the stored binding is the flow’s own.

Also folded in from the inline threads: private_key_jwt is now rejected at registration by the authorization-code flow (it holds no key to sign the assertion) while stored records still tolerate it; an explicit null in a registration response is read as an omitted key across the record, closing the grant_types/response_types gap; the bundled server emits client_secret_expires_at: 0 per RFC 7591 §3.2.1 instead of omitting it; and the migration entry now names both exception surfaces. One item was declined as out of scope (defaulting an omitted auth method to client_secret_basic — pre-existing and unchanged here, and a semantic change that wants its own discussion).

AI Disclaimer

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond this run's inline notes, two other candidates were examined and ruled out: (1) widening the record's token_endpoint_auth_method to str does not break client_auth.py — its unsupported-method branch raises a handled AuthenticationError (client_auth.py:94-97), not an asserted-unreachable, and SDK-registered records are still built from the strict request model; (2) the record's now-required client_id accepting "" is not a regression — pre-PR the field was optional and an absent client_id parsed as success, so requiring it strictly narrows what passes.

Extended reasoning...

Bugs were found and posted inline, so no approval or full deferral. This note records two candidate issues that were examined and refuted this run, so later review passes don't re-explore them from scratch: the widened auth-method type on the record versus the bundled AS middleware's unsupported-method branch (verified to be an explicit, handled error path), and the empty-string client_id parse (verified to be a strict improvement over the previous optional field). Informational only — not a guarantee of correctness.

Comment on lines +79 to 99
"""Confirm a registration this flow completed is one it can act on.

RFC 7591 §3.2.1 lets the authorization server replace requested metadata and leaves it to
the client to "check the values in the response to determine if the registration is
sufficient for use". The one substitution that makes the minted credentials unusable is a
token-endpoint auth method the authorization-code flow cannot apply - one it does not
implement, or `private_key_jwt`, whose assertion this flow has no key to sign - so it is
judged here, before the record is persisted or any interactive authorization begins,
rather than surfacing later as an opaque failure at the token endpoint.

Raises:
OAuthRegistrationError: The server registered the client with a
`token_endpoint_auth_method` this flow cannot apply.
"""
if client_info.token_endpoint_auth_method not in _REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS:
raise OAuthRegistrationError(
"Authorization server registered the client with unsupported token_endpoint_auth_method "
f"{client_info.token_endpoint_auth_method!r}"
)


Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 docs/client/oauth-clients.md:134 still says "OAuthRegistrationError means the authorization server refused to register you", but this PR's new check_registration_usable() also raises OAuthRegistrationError after a successful 201 whose assigned token_endpoint_auth_method the flow cannot apply — the opposite of a refusal, and a condition where each retry orphans another minted registration server-side. Amend the "When it fails" sentence (and, if desired, the Recap bullet at line 145) so the exception is described as covering both a refused registration and a completed registration the flow cannot use.

Extended reasoning...

What the page says vs. what the code now does. docs/client/oauth-clients.md is the Clients-nav page documenting the client-side failure model. Its "When it fails" section (line 134) states: "OAuthRegistrationError means the authorization server refused to register you. OAuthTokenError means the token endpoint said no." This PR introduces check_registration_usable() (src/mcp/client/auth/oauth2.py:79-99), invoked from async_auth_flow immediately after handle_registration_response (oauth2.py:714), which raises OAuthRegistrationError when the server successfully registered the client — HTTP 201, client_id and client_secret minted — but assigned a token_endpoint_auth_method the authorization-code flow cannot apply (an unimplemented method such as client_secret_jwt, or private_key_jwt, whose assertion this flow has no key to sign). That is the opposite of "refused to register you": the server accepted; the SDK judged the registration unusable.\n\nThe code path. (1) Client POSTs its DCR request; server answers 201 with e.g. {"client_id": "unusable", "client_secret": "s3cr3t", "token_endpoint_auth_method": "client_secret_jwt"}. (2) handle_registration_response parses the now-tolerant OAuthClientInformationFull successfully. (3) check_registration_usable finds client_secret_jwt outside _REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS and raises OAuthRegistrationError naming the method — before the record is persisted and before any /authorize or /token request. This is pinned by the PR's own interaction test, test_a_registration_assigning_an_unusable_auth_method_surfaces_as_a_registration_error (tests/interaction/auth/test_discovery.py), which asserts storage.client_info is None and no authorize/token traffic.\n\nWhy it matters beyond wording. The two conditions the exception now covers call for opposite handling. A genuine refusal (a 400 from /register) is something a user might address by adjusting OAuthClientMetadata and retrying. The new condition is a server-policy outcome where every retry mints and orphans another registration server-side — the exact orphaning problem the PR description highlights. A user who follows this page's guidance and models every OAuthRegistrationError as "the server refused; retry with different metadata" will handle the new failure mode incorrectly.\n\nWhy nothing else covers it. AGENTS.md's Documentation section requires: "When a change affects public API or user-visible behaviour, update the relevant page(s) under docs/ in the same PR ... Find the page covering the feature you touched in mkdocs.yml." The PR updates only docs/migration.md — the upgrade guide — while oauth-clients.md is the page the Clients nav points users at for the failure model, and it now contradicts shipped behavior introduced by this very PR. (The secondary condition — a 2xx body that is not valid client information → OAuthRegistrationError — existed pre-PR behind a # pragma: no cover, so the page was already slightly imprecise there; the usability gate is the genuinely new, tested semantic.)\n\nWhy this is a nit, not blocking. Nothing breaks at runtime: the exception type is unchanged, except OAuthFlowError: still covers everything as the page promises, and the fix is a one-sentence amendment. It does not justify blocking merge, but it is a repo-mandated doc update that should land.\n\nFix. Amend line 134 to something like: "OAuthRegistrationError means registration did not yield a usable client — the authorization server refused to register you, or it completed the registration with a token_endpoint_auth_method this flow cannot apply." The Recap bullet at line 145 only lists the subclass names, so it can stay, though a parallel touch-up would not hurt.

Comment thread src/mcp/shared/auth.py
Comment on lines +159 to +175
client_id_issued_at: int | None = None
client_secret_expires_at: int | None = None
# SEP-2352: the issuer these credentials were registered with, recorded by the SDK (not an
# RFC 7591 field) to detect authorization-server migration and avoid cross-AS credential reuse.
issuer: str | None = None

@model_validator(mode="before")
@classmethod
def _explicit_null_reads_as_omitted(cls, data: object) -> object:
# Servers that serialize their client record dump unset members as null instead of
# omitting the keys; a null for a list field would otherwise fail the parse (and
# discard an already-provisioned registration). null and absent mean the same thing.
if isinstance(data, dict):
members = cast(dict[str, Any], data)
return {key: value for key, value in members.items() if value is not None}
return data

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The record's placeholder tolerance is asymmetric between the two server dump styles it documents: _explicit_null_reads_as_omitted strips only None values and the ""-coercions cover only scalar fields, so an echoed "" for a list-typed member (redirect_uris, grant_types, response_types, contacts) still raises a list_type ValidationError — turning a 2xx into OAuthRegistrationError and discarding a registration whose client_id the server already minted, this PR's motivating failure mode. A one-line extension — treating a top-level "" like null in _explicit_null_reads_as_omitted — closes the gap; not a regression (main rejected these too), so nice-to-fix rather than blocking.

Extended reasoning...

The gap. OAuthClientInformationFull tolerates two documented classes of placeholder echoes from real authorization servers: null-dumping ("Servers that serialize their client record dump unset members as null", handled uniformly for every field by _explicit_null_reads_as_omitted at src/mcp/shared/auth.py:159-168) and empty-string-dumping ("Some authorization servers echo omitted metadata back as "" instead of dropping the key", src/mcp/shared/auth.py:17-20 — handled only for hand-picked scalar fields: the base's five URL fields via _empty_string_optional_url_to_none, plus token_endpoint_auth_method/application_type via _empty_string_optional_metadata_to_none at auth.py:170-175). No coercion covers a top-level "" on a list-typed member.\n\nStep-by-step proof (verified against a faithful replica of the record's validator structure, pydantic 2.13.4):\n\n1. A ""-placeholder server answers a DCR request with 201 and body {"client_id": "issued-id", "client_secret": "s", "redirect_uris": ""} (or "grant_types": "", "response_types": "", "contacts": "").\n2. handle_registration_response (src/mcp/client/auth/utils.py:296-306) sees the 2xx and calls OAuthClientInformationFull.model_validate_json(content).\n3. _explicit_null_reads_as_omitted runs first but its filter is value is not None"" passes through untouched. No field validator applies (""-coercion is scalar-field-specific), so the value reaches the list[...] annotation and pydantic deterministically raises ValidationError (list_type: "Input should be a valid list"). Verified: all four list fields fail on "" while their null variants all parse.\n4. The except ValidationError branch converts it to OAuthRegistrationError("Invalid registration response: ...") — after the server has already minted the client_id, so the registration is discarded and orphaned server-side on every retry. That is the precise failure mode the PR was written to eliminate.\n\nWhy existing safeguards miss it. The null-stripper checks is not None only; the ""-coercion validators name scalar fields only; and the tolerance tests in tests/shared/test_auth.py cover "" just for token_endpoint_auth_method/application_type and null (not "") for the list fields — so the remaining strictness is silent.\n\nOn the objection that this is an implausible hypothetical / deliberate scope. It is true that the ""-placeholder servers actually observed emitted "" for scalar string/URL fields, and that no server has been seen sending "" in place of an array — that is why this is a nit and not blocking. But the same was true of the null variant: the motivating hostile body nulled only redirect_uris, yet when the null-for-list-fields gap was flagged mid-review the author generalized the fix uniformly across all fields via _explicit_null_reads_as_omitted rather than patching just the observed one. The "" handling stops at scalars, leaving the mechanism asymmetric between the two dump styles the record's own docstring and comments present as tolerated. Nor does the slippery-slope concern ({}, 0, false) apply: "" is the one placeholder the codebase itself documents as a real server behavior; the others are not. The tolerance boundary may be defensible, but nothing in the code or docstring records ""-on-a-list as deliberately out of scope — the docstring says broadly that "a member the server serializes as an explicit null is read as omitted" and "" "reads as absent", without the scalar-only caveat.\n\nImpact and severity. Not a regression — main rejected "" for these fields identically — and the trigger is a plausible-but-unobserved extension of a documented server class, so nothing that worked before breaks on merge. It is an incompleteness in the tolerance mechanism this PR builds, on lines this PR wrote; the same shape as the null-variant finding the author already accepted.\n\nFix. One line: extend _explicit_null_reads_as_omitted to also drop members whose value is "" (i.e. if value is not None and value != ""), or add ""-to-default mode="before" validators for the four list fields. The model-validator route keeps the two placeholder styles handled in one place; the existing scalar ""-coercions then become redundant but harmless.

Comment on lines +78 to +97
def check_registration_usable(client_info: OAuthClientInformationFull) -> None:
"""Confirm a registration this flow completed is one it can act on.

RFC 7591 §3.2.1 lets the authorization server replace requested metadata and leaves it to
the client to "check the values in the response to determine if the registration is
sufficient for use". The one substitution that makes the minted credentials unusable is a
token-endpoint auth method the authorization-code flow cannot apply - one it does not
implement, or `private_key_jwt`, whose assertion this flow has no key to sign - so it is
judged here, before the record is persisted or any interactive authorization begins,
rather than surfacing later as an opaque failure at the token endpoint.

Raises:
OAuthRegistrationError: The server registered the client with a
`token_endpoint_auth_method` this flow cannot apply.
"""
if client_info.token_endpoint_auth_method not in _REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS:
raise OAuthRegistrationError(
"Authorization server registered the client with unsupported token_endpoint_auth_method "
f"{client_info.token_endpoint_auth_method!r}"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 check_registration_usable judges only the method name, so a 201 that assigns client_secret_basic or client_secret_post while issuing no client_secret (or an empty-string one — client_secret has no ""-coercion) passes the gate, is persisted, and the user completes the full interactive authorization before prepare_token_auth silently skips both secret branches and sends the token request unauthenticated, producing the exact authorize→token→401 invalid_client loop this gate's docstring says it prevents. Consider raising OAuthRegistrationError in check_registration_usable when the assigned method is client_secret_basic/client_secret_post and client_info.client_secret is falsy, so the failure surfaces before the record is persisted or the browser round-trip begins.

Extended reasoning...

The gap. check_registration_usable (src/mcp/client/auth/oauth2.py:78-97) tests only client_info.token_endpoint_auth_method not in _REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS — the method name, not whether the credentials that method needs were actually issued. A secret-based method without a minted secret is a second RFC 7591 §3.2.1 substitution that makes the registration unusable, and it is judged nowhere: not at registration (the name is in the usable set) and not at prepare_token_auth (the method is known, so the falsy-secret fall-through is silent by design).

Step-by-step proof.

  1. The SDK registers; the server answers 201 with {"client_id": "abc", "token_endpoint_auth_method": "client_secret_basic"} and no client_secret member — or, matching the motivating hostile-server class that echoes unset members as "", with "client_secret": "". The record parses fine: unlike token_endpoint_auth_method/application_type and the URL fields, client_secret has no empty-string-to-None coercion, and "" survives the null-stripping model validator.
  2. check_registration_usable (oauth2.py:93): "client_secret_basic" is in the usable set → the gate passes and the record is persisted via storage.set_client_info.
  3. The full interactive authorization runs — browser redirect, user consent, callback.
  4. _exchange_token_authorization_codeprepare_token_auth (oauth2.py:246-262): the basic branch requires self.client_info.client_id and self.client_info.client_secret — the secret is falsy, so it is skipped. The post branch does not match. The new elif auth_method not in _KNOWN_TOKEN_ENDPOINT_AUTH_METHODS (oauth2.py:259) does not fire either — client_secret_basic is a known method. The token request goes out with no Authorization header and no client_secret.
  5. The AS, which registered the client for basic auth, rejects with 401 invalid_clientOAuthTokenError("Token exchange failed (401): …"). Because the record is already persisted, subsequent requests skip re-registration and loop authorize→token→fail against the same record.

Why the existing safeguards miss it. The gate's own docstring claims "the one substitution that makes the minted credentials unusable is a token-endpoint auth method the authorization-code flow cannot apply … judged here, before the record is persisted or any interactive authorization begins" — but this is a second such substitution, and it produces exactly the opaque post-authorization pathology the PR description says this mechanism eliminates ("looped into a full re-auth against the same record and failed at the token exchange anyway"). The new tests cover an unusable method name and a usable name with a secret; none covers a usable name without one.

Not a duplicate of cubic's P1. That comment concerns an omitted token_endpoint_auth_method being treated as no-auth instead of RFC 7591's client_secret_basic default. Its fix (normalize absent→basic) does nothing here — the method is explicitly present and recognized — and would wrongly reject public-client registrations that omit both method and secret. The gap here is that the usability judgment ignores whether the secret the method requires was minted.

Impact and severity. This is not a regression — v1's prepare_token_auth fell through identically — and the trigger requires a server that assigns a secret-based method while minting no secret, more off-spec than even the motivating hostile server (which did issue a secret). The failure is an explicit OAuthTokenError, not silent corruption. But the gate is new code in this PR, the incompleteness is in the contract those lines state for themselves, and the fix is small and local.

Fix. In check_registration_usable, additionally raise OAuthRegistrationError when the assigned method is client_secret_basic or client_secret_post and client_info.client_secret is falsy — before the record is persisted and before the user is sent through the browser.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant