@@ -5144,6 +5144,57 @@ def __post_init__(self) -> None:
51445144 transports = ("streamable-http" ,),
51455145 note = "OAuth is HTTP-only." ,
51465146 ),
5147+ "client-auth:stepup:retry-cap" : Requirement (
5148+ source = f"{ SPEC_BASE_URL } /basic/authorization#step-up-authorization-flow" ,
5149+ behavior = (
5150+ "Step-up re-authorization is bounded per request send: one re-authorization and one "
5151+ "retry, after which a further insufficient_scope 403 on the retried request "
5152+ "surfaces to the caller as an error without another authorization attempt."
5153+ ),
5154+ transports = ("streamable-http" ,),
5155+ note = (
5156+ "OAuth is HTTP-only. The bound is structural -- the auth flow performs at most one "
5157+ "step-up before its generator ends -- not a configurable retry count; the surfaced "
5158+ "error is the transport's INTERNAL_ERROR stand-in for a non-2xx final response. "
5159+ "Cross-request attempt tracking is the separate deferred "
5160+ "client-auth:stepup:attempt-tracking."
5161+ ),
5162+ ),
5163+ "client-auth:stepup:get-stream-403" : Requirement (
5164+ source = f"{ SPEC_BASE_URL } /basic/authorization#step-up-authorization-flow" ,
5165+ behavior = (
5166+ "A 403 insufficient_scope challenge on the standalone GET stream open receives the "
5167+ "same step-up handling as the POST path: the scope union is re-authorized once and "
5168+ "the stream is established on the retried GET with the upgraded token."
5169+ ),
5170+ transports = ("streamable-http" ,),
5171+ removed_in = "2026-07-28" ,
5172+ note = (
5173+ "OAuth is HTTP-only. The standalone GET stream is a 2025-11-25 transport mechanism "
5174+ "removed at 2026-07-28; the auth suite's legacy-mode connect is its natural home. "
5175+ "The uniformity is structural (the OAuth provider wraps every request the transport "
5176+ "issues), but the GET leg's choreography is pinned because a failed step-up there "
5177+ "would otherwise vanish into the stream's silent reconnect loop."
5178+ ),
5179+ ),
5180+ "client-auth:stepup:attempt-tracking" : Requirement (
5181+ source = f"{ SPEC_BASE_URL } /basic/authorization#step-up-authorization-flow" ,
5182+ behavior = (
5183+ "The client tracks scope-upgrade attempts across request sends to avoid repeated "
5184+ "failures for the same resource and operation combination."
5185+ ),
5186+ transports = ("streamable-http" ,),
5187+ note = "OAuth is HTTP-only. The per-send bound is client-auth:stepup:retry-cap." ,
5188+ deferred = (
5189+ "Not implemented in the SDK: the client OAuth provider keeps no cross-request memory "
5190+ "of scope-upgrade attempts. The 403 insufficient_scope branch "
5191+ "(src/mcp/client/auth/oauth2.py:704-734) performs one inline step-up per send with no "
5192+ "attempt counter and no (resource, operation) key, and OAuthContext (oauth2.py:98) "
5193+ "carries no field recording prior step-up failures, so a second send for the same "
5194+ "resource and operation re-attempts the upgrade unconditionally. The per-send "
5195+ '"repeated 403s do not loop" half of this spec line is client-auth:403-scope-upgrade.'
5196+ ),
5197+ ),
51475198 "client-auth:as-metadata-discovery:priority-order" : Requirement (
51485199 source = f"{ SPEC_BASE_URL } /basic/authorization#authorization-server-metadata-discovery" ,
51495200 behavior = (
@@ -5230,12 +5281,68 @@ def __post_init__(self) -> None:
52305281 transports = ("streamable-http" ,),
52315282 note = "OAuth is HTTP-only." ,
52325283 ),
5233- "client-auth:as-binding" : Requirement (
5284+ "client-auth:dcr:app-type-heuristic" : Requirement (
5285+ source = (
5286+ f"{ SPEC_2026_BASE_URL } "
5287+ "/basic/authorization/client-registration#application-type-and-redirect-uri-constraints"
5288+ ),
5289+ behavior = (
5290+ "When the client metadata does not set application_type, dynamic client "
5291+ "registration derives it from the redirect URIs: a loopback host or custom URI "
5292+ "scheme yields 'native', otherwise 'web' (SEP-837)."
5293+ ),
5294+ added_in = "2026-07-28" ,
5295+ transports = ("streamable-http" ,),
5296+ note = (
5297+ "OAuth is HTTP-only. The spec MUST (always send an application_type) IS satisfied "
5298+ "at this pin: OAuthClientMetadata defaults the field to 'native' and every "
5299+ "registration body carries it, pinned incidentally by the "
5300+ "client-auth:dcr:grant-types-default body snapshot. Only the derive-from-redirect-"
5301+ "URIs strategy for the 'web' SHOULD is unimplemented; a web-app consumer sets "
5302+ "application_type='web' explicitly and it is transmitted verbatim."
5303+ ),
5304+ deferred = (
5305+ "Not implemented in the SDK: application_type is a static model default ('native') "
5306+ "on OAuthClientMetadata (src/mcp/shared/auth.py); no code path inspects the "
5307+ "redirect URIs to choose between 'native' and 'web'."
5308+ ),
5309+ ),
5310+ "client-auth:dcr:grant-types-default" : Requirement (
5311+ source = f"{ SPEC_2026_BASE_URL } /basic/authorization#refresh-tokens" ,
5312+ behavior = (
5313+ "When the client metadata does not set grant_types, the dynamic-registration "
5314+ "request carries ['authorization_code', 'refresh_token'] so the authorization "
5315+ "server may issue refresh tokens (SEP-2207); a consumer-set grant_types is sent "
5316+ "verbatim, never rewritten."
5317+ ),
5318+ added_in = "2026-07-28" ,
5319+ transports = ("streamable-http" ,),
5320+ note = (
5321+ "OAuth is HTTP-only. A SHOULD. Python implements the default on the "
5322+ "OAuthClientMetadata model (a field default), not in registration code, so it is "
5323+ "present from construction -- wire-observably identical to injecting it at "
5324+ "registration time, which is what the registration body pins."
5325+ ),
5326+ ),
5327+ "client-auth:as-binding:reregister" : Requirement (
5328+ source = f"{ SPEC_2026_BASE_URL } /basic/authorization/client-registration#authorization-server-binding" ,
5329+ behavior = (
5330+ "Stored client credentials are bound to the issuer that registered them; when the "
5331+ "authorization server changes, the client discards them and re-registers with the "
5332+ "new authorization server (SEP-2352)."
5333+ ),
5334+ added_in = "2026-07-28" ,
5335+ transports = ("streamable-http" ,),
5336+ note = "OAuth is HTTP-only." ,
5337+ ),
5338+ "client-auth:as-binding:no-cred-reuse" : Requirement (
52345339 source = f"{ SPEC_2026_BASE_URL } /basic/authorization/client-registration#authorization-server-binding" ,
52355340 behavior = (
5236- "Stored client credentials are bound to the issuer that registered them; when the authorization "
5237- "server changes, the client discards them and re-registers rather than reusing them (SEP-2352)."
5341+ "When the authorization server changes, the client never reuses credentials from "
5342+ "the previous authorization server: the stale client_id reaches neither the "
5343+ "authorize nor the token endpoint (SEP-2352)."
52385344 ),
5345+ added_in = "2026-07-28" ,
52395346 transports = ("streamable-http" ,),
52405347 note = "OAuth is HTTP-only." ,
52415348 ),
@@ -5330,6 +5437,29 @@ def __post_init__(self) -> None:
53305437 transports = ("streamable-http" ,),
53315438 note = "OAuth is HTTP-only." ,
53325439 ),
5440+ "client-auth:refresh:rotation-handling" : Requirement (
5441+ source = f"{ SPEC_2026_BASE_URL } /basic/authorization#refresh-tokens" ,
5442+ behavior = (
5443+ "On a refresh-token exchange, a new refresh_token in the response replaces the "
5444+ "stored one, and a response that omits refresh_token leaves the stored one in "
5445+ "place -- the client never assumes a refresh token will be issued "
5446+ "(RFC 6749 section 6 / SEP-2207)."
5447+ ),
5448+ transports = ("streamable-http" ,),
5449+ note = (
5450+ "OAuth is HTTP-only. No added_in: the replace/preserve mechanics are RFC 6749 "
5451+ "section 6 client behaviour that predates the 2026 Refresh Tokens section restating "
5452+ "them (the add plan classifies this entry era PRE-EXISTING), and the auth tests "
5453+ "bypass the connect fixture so era fields drive no cells. The follow-on claim -- "
5454+ "the NEXT refresh presents the rotated token -- is real-time-bound at this pin: a "
5455+ "token that is already expired when its refresh response arrives is not refreshed "
5456+ "again on the same request; the request goes out unauthenticated and 401s into a "
5457+ "full re-authorization (oauth2.py sends at most one refresh per request and only "
5458+ "attaches a bearer it considers valid), so a second same-connection refresh cannot "
5459+ "be driven without wall-clock waits. The tests therefore pin replacement and "
5460+ "preservation at the storage/wire seam of a single refresh."
5461+ ),
5462+ ),
53335463 "client-auth:refresh:transparent" : Requirement (
53345464 source = "sdk" ,
53355465 behavior = (
@@ -5384,12 +5514,127 @@ def __post_init__(self) -> None:
53845514 transports = ("streamable-http" ,),
53855515 note = "OAuth is HTTP-only." ,
53865516 ),
5517+ "client-auth:iss:match" : Requirement (
5518+ source = f"{ SPEC_2026_BASE_URL } /basic/authorization#authorization-response-validation" ,
5519+ behavior = (
5520+ "When the authorization server's metadata advertises "
5521+ "authorization_response_iss_parameter_supported and the callback's iss equals the "
5522+ "recorded metadata issuer, the client proceeds to redeem the authorization code "
5523+ "(RFC 9207 validation table row 1)."
5524+ ),
5525+ added_in = "2026-07-28" ,
5526+ transports = ("streamable-http" ,),
5527+ note = "OAuth is HTTP-only." ,
5528+ ),
5529+ "client-auth:iss:no-normalize" : Requirement (
5530+ source = f"{ SPEC_2026_BASE_URL } /basic/authorization#authorization-response-validation" ,
5531+ behavior = (
5532+ "The iss comparison is simple string comparison (RFC 3986 section 6.2.1): a value "
5533+ "differing from the recorded issuer only by a trailing slash is rejected as a "
5534+ "mismatch -- no scheme or host case folding, default-port elision, trailing-slash, "
5535+ "or percent-encoding normalization is applied before comparison."
5536+ ),
5537+ added_in = "2026-07-28" ,
5538+ transports = ("streamable-http" ,),
5539+ note = (
5540+ "OAuth is HTTP-only. The comparison is a single string inequality; the test pins the "
5541+ "trailing-slash arm as the representative normalization class."
5542+ ),
5543+ ),
5544+ "client-auth:iss:supported-missing-reject" : Requirement (
5545+ source = f"{ SPEC_2026_BASE_URL } /basic/authorization#authorization-response-validation" ,
5546+ behavior = (
5547+ "When the authorization server's metadata advertises "
5548+ "authorization_response_iss_parameter_supported: true and the callback carries no "
5549+ "iss, the client rejects the authorization response before redeeming the code "
5550+ "(RFC 9207 validation table row 2)."
5551+ ),
5552+ added_in = "2026-07-28" ,
5553+ transports = ("streamable-http" ,),
5554+ note = "OAuth is HTTP-only." ,
5555+ ),
5556+ "client-auth:iss:unadvertised-proceed" : Requirement (
5557+ source = f"{ SPEC_2026_BASE_URL } /basic/authorization#authorization-response-validation" ,
5558+ behavior = (
5559+ "When the authorization server's metadata does not advertise "
5560+ "authorization_response_iss_parameter_supported and the callback carries no iss, "
5561+ "the client proceeds with the code exchange (RFC 9207 validation table row 4)."
5562+ ),
5563+ added_in = "2026-07-28" ,
5564+ transports = ("streamable-http" ,),
5565+ note = "OAuth is HTTP-only." ,
5566+ ),
5567+ "client-auth:iss:unadvertised-present-validated" : Requirement (
5568+ source = f"{ SPEC_2026_BASE_URL } /basic/authorization#authorization-response-validation" ,
5569+ behavior = (
5570+ "A present iss is validated against the recorded issuer regardless of metadata "
5571+ "advertisement (RFC 9207 validation table row 3, where this specification "
5572+ "deliberately exceeds RFC 9207's local-policy provision): a matching iss proceeds "
5573+ "and a mismatching iss is rejected."
5574+ ),
5575+ added_in = "2026-07-28" ,
5576+ transports = ("streamable-http" ,),
5577+ note = (
5578+ "OAuth is HTTP-only. Covered by two tests: the match half directly, and the "
5579+ "mismatch half by the client-auth:iss:mismatch-reject test, which drives a "
5580+ "mismatched iss against the suite's unadvertising authorization server."
5581+ ),
5582+ ),
5583+ "client-auth:iss:error-response-validated" : Requirement (
5584+ source = f"{ SPEC_2026_BASE_URL } /basic/authorization#authorization-response-validation" ,
5585+ behavior = (
5586+ "iss validation applies equally to error responses: a mismatched iss on an error "
5587+ "callback is rejected before the flow acts on the response, and on mismatch the "
5588+ "client must not act on or display error, error_description, or error_uri."
5589+ ),
5590+ added_in = "2026-07-28" ,
5591+ transports = ("streamable-http" ,),
5592+ note = (
5593+ "OAuth is HTTP-only. The non-surfacing half holds by construction: the callback "
5594+ "contract (AuthorizationCodeResult) carries no error fields, so those values never "
5595+ "enter the SDK; the test pins the observable half -- the iss mismatch is raised in "
5596+ "preference to the missing-authorization-code failure."
5597+ ),
5598+ ),
53875599 "client-auth:token-endpoint-auth-method" : Requirement (
53885600 source = "sdk" ,
53895601 behavior = "The client authenticates to the token endpoint using the auth method established at registration." ,
53905602 transports = ("streamable-http" ,),
53915603 note = "OAuth is HTTP-only." ,
53925604 ),
5605+ "client-auth:token-error:machine-readable-code" : Requirement (
5606+ source = "sdk" ,
5607+ behavior = (
5608+ "An RFC 6749 error response from the token endpoint (e.g. invalid_grant, "
5609+ "invalid_client, on either the authorization-code exchange or a refresh) surfaces "
5610+ "to the caller as a typed OAuth error carrying the wire error code as a "
5611+ "machine-readable field, not only embedded in the message text."
5612+ ),
5613+ transports = ("streamable-http" ,),
5614+ note = "OAuth is HTTP-only. The weak testable sibling is client-auth:token:error-surfaces." ,
5615+ deferred = (
5616+ "Not implemented in the SDK: OAuthTokenError (src/mcp/client/auth/exceptions.py) "
5617+ "carries only a message string; the token-response handler embeds the RFC 6749 "
5618+ "error body in an f-string and the refresh-response handler clears tokens without "
5619+ "reading the body (src/mcp/client/auth/oauth2.py), so there is no machine-readable "
5620+ "error code for a caller to branch on."
5621+ ),
5622+ ),
5623+ "client-auth:token:error-surfaces" : Requirement (
5624+ source = "sdk" ,
5625+ behavior = (
5626+ "A non-2xx response from the token endpoint on the authorization-code exchange "
5627+ "aborts the flow and surfaces to the caller as an error naming the HTTP status; "
5628+ "the flow does not loop, and no request is ever sent with a bearer token."
5629+ ),
5630+ transports = ("streamable-http" ,),
5631+ note = (
5632+ "OAuth is HTTP-only. Completes the endpoint error-surfaces family alongside "
5633+ "client-auth:authorize:error-surfaces and "
5634+ "client-auth:dcr:registration-rejected-error; the machine-readable half is "
5635+ "client-auth:token-error:machine-readable-code (deferred)."
5636+ ),
5637+ ),
53935638 "client-auth:token-provenance" : Requirement (
53945639 source = f"{ SPEC_BASE_URL } /basic/authorization#token-handling" ,
53955640 behavior = (
0 commit comments