Split the registration request model from the registered-client record - #3181
Split the registration request model from the registered-client record#3181maxisbey wants to merge 3 commits into
Conversation
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".
📚 Documentation preview
|
There was a problem hiding this comment.
All reported issues were addressed across 11 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
There was a problem hiding this comment.
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.
…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.
There was a problem hiding this comment.
All reported issues were addressed across 10 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
|
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 Reported: the SDK-internal Also folded in from the inline threads: |
There was a problem hiding this comment.
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.
An SDK-internal field must never be sourced from the wire, not sourced then cleared: the registration response body is now parsed with issuer dropped before validation, so an echoed member neither seeds the SEP-2352 binding nor fails the parse when malformed. The parse goes through pydantic's JSON layer so malformed bytes still surface as OAuthRegistrationError. Both placeholder styles servers use for unset members - null, and "" - now read as omitted keys across the whole record, closing the gap for list fields and retiring the field-by-field empty-string coercions. An empty client_id is accordingly no client_id, so the unrepresentable empty-identifier record and its dead guards in prepare_token_auth go. check_registration_usable now also rejects a secret-based method for which the server issued no client_secret, before persistence and before any interactive authorization, instead of letting the token request go out unauthenticated after the browser round-trip. The bundled registration endpoint refuses private_key_jwt with 400 invalid_client_metadata: it authenticates token requests with the secret it mints and holds no client key to verify an assertion, so confirming the method would register a client it could never authenticate - which this SDK's own client would then immediately declare unusable, orphaning the record. Correct prose that overstated the private_key_jwt refresh path (the tolerance keeps prepare_token_auth from raising so a rejected refresh can fall back to a fresh, signed client-credentials exchange), the stale Raises text in prepare_token_auth, and the client failure-model page, which described OAuthRegistrationError as only a refusal.
There was a problem hiding this comment.
No new issues found in this pass over 884bf91 — all items from the previous review rounds are addressed — but this is a breaking change to the public auth models and a rewrite of the parse boundary for untrusted registration responses, so it warrants a maintainer's review rather than an auto-approval.
What was reviewed this pass:
- the pre-validation
issuerdrop inhandle_registration_response— theexcept ValueErrorcovers bothfrom_jsonandValidationErrorlayers without over-catching - the widened placeholder stripper against non-string members —
client_secret_expires_at: 0and other falsy-but-meaningful values survive (0 != "") - removal of the
client_idtruthiness guards inprepare_token_auth— safe now that the record requiresclient_idand the placeholder rule rejects an empty one - the new secret-not-issued gate — a
""client_secret reads as absent via the placeholder rule, so it correctly trips the gate before the browser round-trip
Extended reasoning...
Overview
PR #3181 splits OAuthClientInformationFull (the registered-client record parsed from an untrusted DCR response) from OAuthClientMetadata (the strict registration request), making them siblings over OAuthClientMetadataBase. It touches src/mcp/shared/auth.py, the client auth flow (oauth2.py, utils.py), the bundled server registration handler, docs (migration.md, oauth-clients.md), and adds substantial model/interaction test coverage. Head commit 884bf91 is the third revision, addressing two prior review rounds from me and one finding from cubic.
Security risks
This is squarely security-sensitive territory: it changes how the client parses an untrusted authorization-server response, how the SEP-2352 issuer trust binding is populated (now sealed — dropped from the wire body before validation rather than parsed-then-cleared), which token-endpoint auth methods the flow will act on, and the bundled AS's registration echo. The direction of each change is defensive — parse-tolerant on cosmetic metadata, but client_id is now required, an empty client_id is rejected, unusable credential assignments fail closed before persistence or any browser round-trip, and the server refuses private_key_jwt registrations it could never authenticate. I found no injection, bypass, or exposure vector in this pass; the earlier issuer-seeding concern is resolved at the correct layer.
Level of scrutiny
High — production client-auth code plus an intentional breaking API change (documented in docs/migration.md per repo policy). The design decisions here (sibling split, parse-tolerant record, registration-time usability gate, required client_id, the deliberately-deferred server-record/client-response separation named in the PR description) are exactly the kind of API-shape calls the guidelines say a human maintainer should own, regardless of correctness. That, not any outstanding defect, is why this defers rather than approves.
Other factors
All prior review threads are resolved with code changes rather than pushback (one item — defaulting an omitted auth method to client_secret_basic — was reasonably declined as an out-of-scope semantic change). Test coverage is strong: model-level parametrization over placeholder styles, real handle_registration_response paths including malformed/non-object/non-UTF-8 bodies, end-to-end interaction tests asserting no authorize/token traffic and nothing persisted on the unusable paths, and server-side 201-body assertions where the earlier omission actually lived. The repo requires 100% coverage in CI, which backstops the branch-level claims.
|
Second review round addressed in 884bf91; the four new inline threads are replied to and resolved individually. Three further reported items had no thread, so recording them here: Reported (again): required Reported: stale prose about auth-method tolerance. Both bits valid and fixed. The comment, test docstring, and migration line no longer claim the Reported: bundled AS confirms Two more from the inline threads worth naming in one place: an SDK-internal One item remains deliberately out of scope — treating an omitted |
OAuthClientInformationFull— the client's parse of the authorization server's Dynamic Client Registration response — inherited fromOAuthClientMetadata, 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 aValidationErroron 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_typevalue outside{"web", "native"}. The server answered 201 and provisioned the client; the SDK then raisedInvalid registration response: … Input should be 'web' or 'native' [type=literal_error]out of the auth flow, discarding a registration whoseclient_idhad already been minted (and orphaning it server-side on every retry).application_typeis 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
*_supportedmetadata,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 onlynative/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_typepermissively — the TypeScript SDK types itz.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) andOAuthClientInformationFull(registered-client record) are now siblings over a sharedOAuthClientMetadataBaseholding only the fields that are genuinely identical between them:application_type="browser"; the MCPMUSTon the wire is unchanged and enforced by the type.application_type/token_endpoint_auth_methodarestr | None(an echoed""reads as absent, matching the existing URL-field coercion),grant_typesislist[str],redirect_urismay be absent or empty.client_idis 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).OAuthRegistrationErrornaming 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_authreports the same for a stored/pre-registered record. The recognized set is derived from oneTokenEndpointAuthMethodtype viaget_args, so send-side and recognize-side cannot drift (private_key_jwtremains recognized —PrivateKeyJWTOAuthProvider's refresh path is unaffected).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'smodel_dump(), and a test pins that every request field exists on the record — a field can no longer be silently omitted.except ValidationErrorinhandle_registration_responsecarried# 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?
null/emptyapplication_type, an unimplementedtoken_endpoint_auth_method, extragrant_types, emptyredirect_uris; the request still rejects an off-setapplication_typeand requiresredirect_uris.handle_registration_responsepath (the layer the failure occurred in) with a substituted body, and with a body that isn't client information at all./registerreturning a substituted 201 completes the full flow (tool call succeeds); a 201 assigning an unusable auth method surfacesOAuthRegistrationErrorwith no/authorizeor/tokenrequest and nothing persisted.application_typefor bothwebandnative."confidential",nullredirect URIs, extra grants) now completes with HTTP 200 wheremaindies with the exactliteral_errorabove; a""auth method reads as absent; aclient_secret_jwtassignment fails at registration before any browser round-trip.strict-no-coverclean.Breaking Changes
Yes — documented in
docs/migration.mdalongside the existing SEP-837 entry.OAuthClientInformationFullis no longer a subclass ofOAuthClientMetadata. Code relying onisinstance(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 onOAuthClientMetadata.application_type/token_endpoint_auth_method→str | None,grant_types→list[str],redirect_urisoptional,client_idrequired. Reads are unaffected.ValidationErrorduring 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
Checklist
Additional context
Two things I considered and want to name:
invalid_client.OAuthAuthorizationServerProvider.get_client,authorize). The split stops one class short of separating those, which is whyredirect_uriselements stayAnyUrl(the AS compares them) and why hand-built server records lose a couple of constructor invariants they previously borrowed from the request. Requiringclient_idrestores the one that matters; a fuller server-record / client-response separation is the next seam, deliberately left out of this change.AI Disclaimer