@@ -5374,6 +5374,57 @@ def __post_init__(self) -> None:
53745374 transports = ("streamable-http" ,),
53755375 note = "OAuth is HTTP-only." ,
53765376 ),
5377+ "client-auth:stepup:retry-cap" : Requirement (
5378+ source = f"{ SPEC_BASE_URL } /basic/authorization#step-up-authorization-flow" ,
5379+ behavior = (
5380+ "Step-up re-authorization is bounded per request send: one re-authorization and one "
5381+ "retry, after which a further insufficient_scope 403 on the retried request "
5382+ "surfaces to the caller as an error without another authorization attempt."
5383+ ),
5384+ transports = ("streamable-http" ,),
5385+ note = (
5386+ "OAuth is HTTP-only. The bound is structural -- the auth flow performs at most one "
5387+ "step-up before its generator ends -- not a configurable retry count; the surfaced "
5388+ "error is the transport's INTERNAL_ERROR stand-in for a non-2xx final response. "
5389+ "Cross-request attempt tracking is the separate deferred "
5390+ "client-auth:stepup:attempt-tracking."
5391+ ),
5392+ ),
5393+ "client-auth:stepup:get-stream-403" : Requirement (
5394+ source = f"{ SPEC_BASE_URL } /basic/authorization#step-up-authorization-flow" ,
5395+ behavior = (
5396+ "A 403 insufficient_scope challenge on the standalone GET stream open receives the "
5397+ "same step-up handling as the POST path: the scope union is re-authorized once and "
5398+ "the stream is established on the retried GET with the upgraded token."
5399+ ),
5400+ transports = ("streamable-http" ,),
5401+ removed_in = "2026-07-28" ,
5402+ note = (
5403+ "OAuth is HTTP-only. The standalone GET stream is a 2025-11-25 transport mechanism "
5404+ "removed at 2026-07-28; the auth suite's legacy-mode connect is its natural home. "
5405+ "The uniformity is structural (the OAuth provider wraps every request the transport "
5406+ "issues), but the GET leg's choreography is pinned because a failed step-up there "
5407+ "would otherwise vanish into the stream's silent reconnect loop."
5408+ ),
5409+ ),
5410+ "client-auth:stepup:attempt-tracking" : Requirement (
5411+ source = f"{ SPEC_BASE_URL } /basic/authorization#step-up-authorization-flow" ,
5412+ behavior = (
5413+ "The client tracks scope-upgrade attempts across request sends to avoid repeated "
5414+ "failures for the same resource and operation combination."
5415+ ),
5416+ transports = ("streamable-http" ,),
5417+ note = "OAuth is HTTP-only. The per-send bound is client-auth:stepup:retry-cap." ,
5418+ deferred = (
5419+ "Not implemented in the SDK: the client OAuth provider keeps no cross-request memory "
5420+ "of scope-upgrade attempts. The 403 insufficient_scope branch "
5421+ "(src/mcp/client/auth/oauth2.py:704-734) performs one inline step-up per send with no "
5422+ "attempt counter and no (resource, operation) key, and OAuthContext (oauth2.py:98) "
5423+ "carries no field recording prior step-up failures, so a second send for the same "
5424+ "resource and operation re-attempts the upgrade unconditionally. The per-send "
5425+ '"repeated 403s do not loop" half of this spec line is client-auth:403-scope-upgrade.'
5426+ ),
5427+ ),
53775428 "client-auth:as-metadata-discovery:priority-order" : Requirement (
53785429 source = f"{ SPEC_BASE_URL } /basic/authorization#authorization-server-metadata-discovery" ,
53795430 behavior = (
@@ -5473,12 +5524,68 @@ def __post_init__(self) -> None:
54735524 transports = ("streamable-http" ,),
54745525 note = "OAuth is HTTP-only." ,
54755526 ),
5476- "client-auth:as-binding" : Requirement (
5527+ "client-auth:dcr:app-type-heuristic" : Requirement (
5528+ source = (
5529+ f"{ SPEC_2026_BASE_URL } "
5530+ "/basic/authorization/client-registration#application-type-and-redirect-uri-constraints"
5531+ ),
5532+ behavior = (
5533+ "When the client metadata does not set application_type, dynamic client "
5534+ "registration derives it from the redirect URIs: a loopback host or custom URI "
5535+ "scheme yields 'native', otherwise 'web' (SEP-837)."
5536+ ),
5537+ added_in = "2026-07-28" ,
5538+ transports = ("streamable-http" ,),
5539+ note = (
5540+ "OAuth is HTTP-only. The spec MUST (always send an application_type) IS satisfied "
5541+ "at this pin: OAuthClientMetadata defaults the field to 'native' and every "
5542+ "registration body carries it, pinned incidentally by the "
5543+ "client-auth:dcr:grant-types-default body snapshot. Only the derive-from-redirect-"
5544+ "URIs strategy for the 'web' SHOULD is unimplemented; a web-app consumer sets "
5545+ "application_type='web' explicitly and it is transmitted verbatim."
5546+ ),
5547+ deferred = (
5548+ "Not implemented in the SDK: application_type is a static model default ('native') "
5549+ "on OAuthClientMetadata (src/mcp/shared/auth.py); no code path inspects the "
5550+ "redirect URIs to choose between 'native' and 'web'."
5551+ ),
5552+ ),
5553+ "client-auth:dcr:grant-types-default" : Requirement (
5554+ source = f"{ SPEC_2026_BASE_URL } /basic/authorization#refresh-tokens" ,
5555+ behavior = (
5556+ "When the client metadata does not set grant_types, the dynamic-registration "
5557+ "request carries ['authorization_code', 'refresh_token'] so the authorization "
5558+ "server may issue refresh tokens (SEP-2207); a consumer-set grant_types is sent "
5559+ "verbatim, never rewritten."
5560+ ),
5561+ added_in = "2026-07-28" ,
5562+ transports = ("streamable-http" ,),
5563+ note = (
5564+ "OAuth is HTTP-only. A SHOULD. Python implements the default on the "
5565+ "OAuthClientMetadata model (a field default), not in registration code, so it is "
5566+ "present from construction -- wire-observably identical to injecting it at "
5567+ "registration time, which is what the registration body pins."
5568+ ),
5569+ ),
5570+ "client-auth:as-binding:reregister" : Requirement (
5571+ source = f"{ SPEC_2026_BASE_URL } /basic/authorization/client-registration#authorization-server-binding" ,
5572+ behavior = (
5573+ "Stored client credentials are bound to the issuer that registered them; when the "
5574+ "authorization server changes, the client discards them and re-registers with the "
5575+ "new authorization server (SEP-2352)."
5576+ ),
5577+ added_in = "2026-07-28" ,
5578+ transports = ("streamable-http" ,),
5579+ note = "OAuth is HTTP-only." ,
5580+ ),
5581+ "client-auth:as-binding:no-cred-reuse" : Requirement (
54775582 source = f"{ SPEC_2026_BASE_URL } /basic/authorization/client-registration#authorization-server-binding" ,
54785583 behavior = (
5479- "Stored client credentials are bound to the issuer that registered them; when the authorization "
5480- "server changes, the client discards them and re-registers rather than reusing them (SEP-2352)."
5584+ "When the authorization server changes, the client never reuses credentials from "
5585+ "the previous authorization server: the stale client_id reaches neither the "
5586+ "authorize nor the token endpoint (SEP-2352)."
54815587 ),
5588+ added_in = "2026-07-28" ,
54825589 transports = ("streamable-http" ,),
54835590 note = "OAuth is HTTP-only." ,
54845591 ),
@@ -5573,6 +5680,29 @@ def __post_init__(self) -> None:
55735680 transports = ("streamable-http" ,),
55745681 note = "OAuth is HTTP-only." ,
55755682 ),
5683+ "client-auth:refresh:rotation-handling" : Requirement (
5684+ source = f"{ SPEC_2026_BASE_URL } /basic/authorization#refresh-tokens" ,
5685+ behavior = (
5686+ "On a refresh-token exchange, a new refresh_token in the response replaces the "
5687+ "stored one, and a response that omits refresh_token leaves the stored one in "
5688+ "place -- the client never assumes a refresh token will be issued "
5689+ "(RFC 6749 section 6 / SEP-2207)."
5690+ ),
5691+ transports = ("streamable-http" ,),
5692+ note = (
5693+ "OAuth is HTTP-only. No added_in: the replace/preserve mechanics are RFC 6749 "
5694+ "section 6 client behaviour that predates the 2026 Refresh Tokens section restating "
5695+ "them (the add plan classifies this entry era PRE-EXISTING), and the auth tests "
5696+ "bypass the connect fixture so era fields drive no cells. The follow-on claim -- "
5697+ "the NEXT refresh presents the rotated token -- is real-time-bound at this pin: a "
5698+ "token that is already expired when its refresh response arrives is not refreshed "
5699+ "again on the same request; the request goes out unauthenticated and 401s into a "
5700+ "full re-authorization (oauth2.py sends at most one refresh per request and only "
5701+ "attaches a bearer it considers valid), so a second same-connection refresh cannot "
5702+ "be driven without wall-clock waits. The tests therefore pin replacement and "
5703+ "preservation at the storage/wire seam of a single refresh."
5704+ ),
5705+ ),
55765706 "client-auth:refresh:transparent" : Requirement (
55775707 source = "sdk" ,
55785708 behavior = (
@@ -5627,12 +5757,127 @@ def __post_init__(self) -> None:
56275757 transports = ("streamable-http" ,),
56285758 note = "OAuth is HTTP-only." ,
56295759 ),
5760+ "client-auth:iss:match" : Requirement (
5761+ source = f"{ SPEC_2026_BASE_URL } /basic/authorization#authorization-response-validation" ,
5762+ behavior = (
5763+ "When the authorization server's metadata advertises "
5764+ "authorization_response_iss_parameter_supported and the callback's iss equals the "
5765+ "recorded metadata issuer, the client proceeds to redeem the authorization code "
5766+ "(RFC 9207 validation table row 1)."
5767+ ),
5768+ added_in = "2026-07-28" ,
5769+ transports = ("streamable-http" ,),
5770+ note = "OAuth is HTTP-only." ,
5771+ ),
5772+ "client-auth:iss:no-normalize" : Requirement (
5773+ source = f"{ SPEC_2026_BASE_URL } /basic/authorization#authorization-response-validation" ,
5774+ behavior = (
5775+ "The iss comparison is simple string comparison (RFC 3986 section 6.2.1): a value "
5776+ "differing from the recorded issuer only by a trailing slash is rejected as a "
5777+ "mismatch -- no scheme or host case folding, default-port elision, trailing-slash, "
5778+ "or percent-encoding normalization is applied before comparison."
5779+ ),
5780+ added_in = "2026-07-28" ,
5781+ transports = ("streamable-http" ,),
5782+ note = (
5783+ "OAuth is HTTP-only. The comparison is a single string inequality; the test pins the "
5784+ "trailing-slash arm as the representative normalization class."
5785+ ),
5786+ ),
5787+ "client-auth:iss:supported-missing-reject" : Requirement (
5788+ source = f"{ SPEC_2026_BASE_URL } /basic/authorization#authorization-response-validation" ,
5789+ behavior = (
5790+ "When the authorization server's metadata advertises "
5791+ "authorization_response_iss_parameter_supported: true and the callback carries no "
5792+ "iss, the client rejects the authorization response before redeeming the code "
5793+ "(RFC 9207 validation table row 2)."
5794+ ),
5795+ added_in = "2026-07-28" ,
5796+ transports = ("streamable-http" ,),
5797+ note = "OAuth is HTTP-only." ,
5798+ ),
5799+ "client-auth:iss:unadvertised-proceed" : Requirement (
5800+ source = f"{ SPEC_2026_BASE_URL } /basic/authorization#authorization-response-validation" ,
5801+ behavior = (
5802+ "When the authorization server's metadata does not advertise "
5803+ "authorization_response_iss_parameter_supported and the callback carries no iss, "
5804+ "the client proceeds with the code exchange (RFC 9207 validation table row 4)."
5805+ ),
5806+ added_in = "2026-07-28" ,
5807+ transports = ("streamable-http" ,),
5808+ note = "OAuth is HTTP-only." ,
5809+ ),
5810+ "client-auth:iss:unadvertised-present-validated" : Requirement (
5811+ source = f"{ SPEC_2026_BASE_URL } /basic/authorization#authorization-response-validation" ,
5812+ behavior = (
5813+ "A present iss is validated against the recorded issuer regardless of metadata "
5814+ "advertisement (RFC 9207 validation table row 3, where this specification "
5815+ "deliberately exceeds RFC 9207's local-policy provision): a matching iss proceeds "
5816+ "and a mismatching iss is rejected."
5817+ ),
5818+ added_in = "2026-07-28" ,
5819+ transports = ("streamable-http" ,),
5820+ note = (
5821+ "OAuth is HTTP-only. Covered by two tests: the match half directly, and the "
5822+ "mismatch half by the client-auth:iss:mismatch-reject test, which drives a "
5823+ "mismatched iss against the suite's unadvertising authorization server."
5824+ ),
5825+ ),
5826+ "client-auth:iss:error-response-validated" : Requirement (
5827+ source = f"{ SPEC_2026_BASE_URL } /basic/authorization#authorization-response-validation" ,
5828+ behavior = (
5829+ "iss validation applies equally to error responses: a mismatched iss on an error "
5830+ "callback is rejected before the flow acts on the response, and on mismatch the "
5831+ "client must not act on or display error, error_description, or error_uri."
5832+ ),
5833+ added_in = "2026-07-28" ,
5834+ transports = ("streamable-http" ,),
5835+ note = (
5836+ "OAuth is HTTP-only. The non-surfacing half holds by construction: the callback "
5837+ "contract (AuthorizationCodeResult) carries no error fields, so those values never "
5838+ "enter the SDK; the test pins the observable half -- the iss mismatch is raised in "
5839+ "preference to the missing-authorization-code failure."
5840+ ),
5841+ ),
56305842 "client-auth:token-endpoint-auth-method" : Requirement (
56315843 source = "sdk" ,
56325844 behavior = "The client authenticates to the token endpoint using the auth method established at registration." ,
56335845 transports = ("streamable-http" ,),
56345846 note = "OAuth is HTTP-only." ,
56355847 ),
5848+ "client-auth:token-error:machine-readable-code" : Requirement (
5849+ source = "sdk" ,
5850+ behavior = (
5851+ "An RFC 6749 error response from the token endpoint (e.g. invalid_grant, "
5852+ "invalid_client, on either the authorization-code exchange or a refresh) surfaces "
5853+ "to the caller as a typed OAuth error carrying the wire error code as a "
5854+ "machine-readable field, not only embedded in the message text."
5855+ ),
5856+ transports = ("streamable-http" ,),
5857+ note = "OAuth is HTTP-only. The weak testable sibling is client-auth:token:error-surfaces." ,
5858+ deferred = (
5859+ "Not implemented in the SDK: OAuthTokenError (src/mcp/client/auth/exceptions.py) "
5860+ "carries only a message string; the token-response handler embeds the RFC 6749 "
5861+ "error body in an f-string and the refresh-response handler clears tokens without "
5862+ "reading the body (src/mcp/client/auth/oauth2.py), so there is no machine-readable "
5863+ "error code for a caller to branch on."
5864+ ),
5865+ ),
5866+ "client-auth:token:error-surfaces" : Requirement (
5867+ source = "sdk" ,
5868+ behavior = (
5869+ "A non-2xx response from the token endpoint on the authorization-code exchange "
5870+ "aborts the flow and surfaces to the caller as an error naming the HTTP status; "
5871+ "the flow does not loop, and no request is ever sent with a bearer token."
5872+ ),
5873+ transports = ("streamable-http" ,),
5874+ note = (
5875+ "OAuth is HTTP-only. Completes the endpoint error-surfaces family alongside "
5876+ "client-auth:authorize:error-surfaces and "
5877+ "client-auth:dcr:registration-rejected-error; the machine-readable half is "
5878+ "client-auth:token-error:machine-readable-code (deferred)."
5879+ ),
5880+ ),
56365881 "client-auth:token-provenance" : Requirement (
56375882 source = f"{ SPEC_BASE_URL } /basic/authorization#token-handling" ,
56385883 behavior = (
0 commit comments