@@ -108,6 +108,7 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil)
108108 # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
109109 def run_client_credentials! ( as_metadata :, prm :, resource :, scope :)
110110 client_info = client_credentials_client_info
111+ ensure_client_credentials_issuer! ( client_info , as_metadata : as_metadata )
111112
112113 form = { "grant_type" => "client_credentials" }
113114 effective_scope = resolve_scope ( scope : scope , prm : prm )
@@ -134,6 +135,25 @@ def client_credentials_client_info
134135 info
135136 end
136137
138+ # Per SEP-2352, static machine-to-machine credentials are bound to their authorization
139+ # server the same way registered ones are: when the stored `client_information` records
140+ # an `issuer` that differs from the current authorization server, surface an error
141+ # instead of silently sending another server's credentials (the spec's "SHOULD surface
142+ # an error"; the TypeScript SDK raises `AuthorizationServerMismatchError` here).
143+ # Re-registration is not an option for the `client_credentials` grant - the credentials
144+ # are pre-registered, not DCR results - so the operator must update the stored credentials.
145+ # Credentials without a recorded issuer keep working unchanged.
146+ def ensure_client_credentials_issuer! ( client_info , as_metadata :)
147+ stored_issuer = client_info_required_value ( client_info , "issuer" )
148+ return if stored_issuer . nil?
149+ return if stored_issuer == as_metadata [ "issuer" ]
150+
151+ raise AuthorizationError ,
152+ "Stored client credentials are bound to a different authorization server " \
153+ "(stored issuer #{ stored_issuer . inspect } , current #{ as_metadata [ "issuer" ] . inspect } ); " \
154+ "refusing to send them to the current authorization server (SEP-2352)."
155+ end
156+
137157 # Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6).
138158 # Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security
139159 # checks before talking to it.
@@ -173,6 +193,7 @@ def refresh!(server_url:, resource_metadata_url: nil)
173193 client_info = if have_stored_client_info
174194 # Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
175195 # do not silently swap it for the CIMD URL even when the AS also advertises CIMD support.
196+ ensure_refreshable_client_information! ( stored_client_info , as_metadata : as_metadata )
176197 stored_client_info
177198 elsif as_metadata [ "client_id_metadata_document_supported" ] == true
178199 { "client_id" => @provider . client_id_metadata_document_url }
@@ -419,8 +440,8 @@ def ensure_issuer_matches!(expected:, returned:)
419440 end
420441
421442 def ensure_client_registered ( as_metadata :)
422- existing = @provider . client_information
423- return existing if existing . is_a? ( Hash ) && client_info_required_value ( existing , "client_id" )
443+ existing = stored_client_information_for ( issuer : as_metadata [ "issuer" ] )
444+ return existing if existing
424445
425446 # Per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`,
426447 # if the authorization server advertises Client ID Metadata Document support and the provider has
@@ -469,7 +490,12 @@ def ensure_client_registered(as_metadata:)
469490 "Dynamic client registration response is missing `client_id`."
470491 end
471492
472- @provider . save_client_information ( info )
493+ # Per SEP-2352, persisted client credentials are keyed by the issuer identifier of
494+ # the authorization server that minted them, so a later flow can detect an AS change and
495+ # re-register instead of replaying another server's credentials. `issuer` is not an RFC 7591 response field;
496+ # the SDK adds it to the opaque persisted hash.
497+ @provider . save_client_information ( info . merge ( "issuer" => as_metadata [ "issuer" ] ) )
498+
473499 info
474500 end
475501
@@ -487,6 +513,60 @@ def registration_client_metadata
487513 metadata . merge ( "application_type" => Discovery . infer_application_type ( redirect_uris ) )
488514 end
489515
516+ # Returns the stored `client_information` when it may be used against the authorization server
517+ # identified by `issuer`, applying SEP-2352's authorization server binding rules:
518+ #
519+ # - Credentials persisted with an `"issuer"` binding MUST NOT be reused against
520+ # a different authorization server. When the AS changed, the stale registration and its tokens
521+ # are discarded (tokens minted by the old AS are dead at the new one) and nil is returned so
522+ # the flow re-registers.
523+ # - Stored credentials without an `"issuer"` binding (data persisted by an older SDK version,
524+ # or user-supplied pre-registered credentials) are bound to the current issuer on first use.
525+ # - A CIMD `client_id` (an HTTPS URL) is portable across authorization servers, so it is reused
526+ # and re-bound instead of discarded.
527+ #
528+ # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352
529+ def stored_client_information_for ( issuer :)
530+ existing = @provider . client_information
531+ return unless existing . is_a? ( Hash ) && client_info_required_value ( existing , "client_id" )
532+
533+ stored_issuer = client_info_required_value ( existing , "issuer" )
534+ return existing if stored_issuer == issuer
535+ return rebind_client_information ( existing , issuer : issuer ) if stored_issuer . nil?
536+
537+ client_id = client_info_required_value ( existing , "client_id" )
538+ return rebind_client_information ( existing , issuer : issuer ) if Discovery . client_id_metadata_document_url? ( client_id )
539+
540+ @provider . save_client_information ( nil )
541+ @provider . clear_tokens!
542+ nil
543+ end
544+
545+ def rebind_client_information ( info , issuer :)
546+ rebound = info . merge ( "issuer" => issuer )
547+ @provider . save_client_information ( rebound )
548+ rebound
549+ end
550+
551+ # Per SEP-2352, stored client credentials are bound to the authorization server that issued them;
552+ # refuse to replay another AS's credentials at this token endpoint. `HTTP#attempt_refresh` rescues
553+ # the error and falls back to the full flow, which discards the stale registration and re-registers.
554+ # Credentials without an `"issuer"` binding predate this check and are allowed through;
555+ # CIMD `client_id`s are portable.
556+ def ensure_refreshable_client_information! ( client_info , as_metadata :)
557+ stored_issuer = client_info_required_value ( client_info , "issuer" )
558+ return if stored_issuer . nil?
559+ return if stored_issuer == as_metadata [ "issuer" ]
560+
561+ client_id = client_info_required_value ( client_info , "client_id" )
562+ return if Discovery . client_id_metadata_document_url? ( client_id )
563+
564+ raise AuthorizationError ,
565+ "Cannot refresh: stored client credentials were issued by a different authorization server " \
566+ "(stored issuer #{ stored_issuer . inspect } , current #{ as_metadata [ "issuer" ] . inspect } ); " \
567+ "re-registration is required (SEP-2352)."
568+ end
569+
490570 # Reads `key` from a `client_information` hash that may use either string or
491571 # symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built
492572 # `{ client_id:, client_secret: }` and have both work.
0 commit comments