Skip to content

Implement authorization server binding and credential isolation (MCP SEP-2352)#1474

Draft
Copilot wants to merge 3 commits intomainfrom
copilot/update-client-oauth-provider
Draft

Implement authorization server binding and credential isolation (MCP SEP-2352)#1474
Copilot wants to merge 3 commits intomainfrom
copilot/update-client-oauth-provider

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 29, 2026

MCP SEP-2352 adds MUST-level requirements for clients to maintain per-AS credential state and detect AS changes, preventing accidental reuse of credentials issued by one authorization server with a different one.

Core changes — ClientOAuthProvider

AS binding tracking

  • _boundAuthServerIssuer — records the issuer URI of the AS that issued current credentials
  • _credentialsArePreRegistered — set at construction if options.ClientId is provided
  • _isCimdClientId — marks client IDs from CIMD (self-hosted HTTPS URLs, portable across ASes)

HandleAuthorizationServerChange(Uri currentIssuer) — called after fetching AS metadata, before any token refresh attempt:

Credential type AS changed → behavior
Pre-registered ClientId Throw McpException (credentials are AS-specific)
DCR-registered Invalidate token cache, clear _clientId/_clientSecret/auth method → triggers re-registration
CIMD Invalidate token cache only; client ID is portable, no re-registration needed

InvalidatableTokenCache — internal wrapper around ITokenCache that adds Invalidate(). Returns null from GetTokensAsync when invalidated (reset on next StoreTokensAsync). Keeps ITokenCache interface stable.

_boundAuthServerIssuer is updated only after a successful token acquisition (refresh or auth code flow).

Tests — AuthServerBindingTests

Three integration tests using McpAuthenticationOptions.Events.OnResourceMetadataRequest to dynamically swap the reported AS URL, plus middleware to force re-authentication:

  • AuthServerChange_WithPreRegisteredCredentials_ThrowsMcpException
  • AuthServerChange_WithDcrCredentials_TriggersReregistration — asserts dcrCallCount == 2
  • AuthServerChange_WithCimdCredentials_ClearsTokensButKeepsPortableClientId — asserts CIMD URL appears as client_id in the second auth request
Original prompt

Summary

The MCP specification PR modelcontextprotocol/modelcontextprotocol#2352 ("SEP-2352: Clarify authorization server binding and migration") has been merged. It adds new MUST/MUST NOT requirements for client OAuth implementations around authorization server binding and credential isolation. The C# SDK's ClientOAuthProvider needs to be updated to comply.

Spec Requirements (from the merged spec change)

1. Per-AS credential isolation (MUST)

"When multiple authorization servers are listed in authorization_servers, each is an independent OAuth 2.0 authorization server. Consistent with RFC 6749 Section 2.2, client identifiers are unique to the authorization server that issued them. Clients MUST maintain separate registration state (client credentials, tokens) per authorization server and MUST NOT assume that credentials valid for one authorization server will be accepted by another."

2. Authorization Server Binding (MUST)

"Clients that use pre-registered credentials, or persist client credentials obtained via Dynamic Client Registration, MUST associate those credentials with the specific authorization server that issued them, keyed by the authorization server's issuer identifier. When the authorization server changes (detected via updated protected resource metadata), clients MUST NOT reuse client credentials from a different authorization server and MUST re-register with the new authorization server."

3. Pre-registered credential mismatch (SHOULD)

"Pre-registered credentials are inherently specific to a particular authorization server. If the authorization server indicated by protected resource metadata no longer matches the one the credentials were registered with, clients SHOULD surface an error rather than silently attempting to use mismatched credentials."

4. CIMD portability

"Client IDs based on Client ID Metadata Documents are portable across authorization servers, since they are self-hosted HTTPS URLs resolved by the authorization server on demand. No re-registration is needed when the authorization server changes."

Current Gaps in the C# SDK

All relevant code is in src/ModelContextProtocol.Core/Authentication/.

Gap 1: ClientOAuthProvider stores credentials as single instance fields

The fields _clientId, _clientSecret, _tokenEndpointAuthMethod, and _authServerMetadata are single values with no association to a specific authorization server issuer. When a different AS is selected (or the AS changes), credentials from the old AS could be incorrectly reused.

Fix: Track the issuer URI that credentials were obtained from. Before reusing _clientId/_clientSecret, verify the current AS issuer matches the one that issued those credentials. If it doesn't match:

  • If using CIMD (_clientMetadataDocumentUri is set and was used), the client ID is portable — keep it, but discard tokens.
  • If using DCR (credentials were dynamically registered), clear _clientId, _clientSecret, and _tokenEndpointAuthMethod so re-registration happens with the new AS.
  • If using pre-registered credentials (user-provided ClientId in ClientOAuthOptions), surface an error (throw McpException) indicating the AS has changed and the pre-registered credentials are no longer valid for the new AS.

Gap 2: ITokenCache and token storage are not keyed by AS issuer

ITokenCache.StoreTokensAsync/GetTokensAsync have no concept of which authorization server the tokens belong to. This means tokens from one AS could be returned when a different AS is now in use.

Fix: Since ITokenCache is a public interface and changing its signature would be a breaking change, the preferred approach is to have ClientOAuthProvider internally invalidate/clear the token cache when an AS change is detected (before attempting to use cached tokens). This way the ITokenCache interface stays stable, but the provider ensures tokens from a stale AS are not reused.

Gap 3: No AS change detection

ClientOAuthProvider.GetAccessTokenAsync does not compare the currently-selected authorization server against a previously-used one. There is no mechanism to detect that the AS has changed between calls.

Fix: Store the issuer URI of the AS that was used for the most recent successful authentication. On subsequent calls to GetAccessTokenAsync, after selecting the AS and fetching its metadata, compare the new AS issuer against the stored one. If they differ, trigger the credential invalidation logic described in Gap 1.

Implementation Guidance

  1. Add a field like private Uri? _boundAuthServerIssuer to ClientOAuthProvider to track which AS issuer the current credentials are bound to.
  2. Add a field like private bool _credentialsFromPreRegistration to distinguish pre-registered credentials from DCR-obtained ones. This can be determined at construction time: if `options....

This pull request was created from Copilot chat.


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Copilot AI changed the title [WIP] Update ClientOAuthProvider to comply with SEP-2352 requirements Implement authorization server binding and credential isolation (MCP SEP-2352) Mar 29, 2026
Copilot AI requested a review from stephentoub March 29, 2026 01:04
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.

2 participants