On a cold start where an ITokenCache returns a persisted refresh token but _clientId has not yet been assigned, ClientOAuthProvider.GetAccessTokenAsync attempts a token refresh before it assigns a client ID (via the client-id metadata document path or dynamic client registration). RefreshTokensAsync calls CreateTokenRequest, which calls GetClientIdOrThrow(), which throws:
System.InvalidOperationException: Client ID is not available. This may indicate an issue with dynamic client registration.
The DCR message is misleading: DCR has not failed — it simply has not been given the chance to run yet, because the refresh attempt is sequenced ahead of the client-ID assignment block.
|
// Only attempt a token refresh if we haven't attempted to already for this request. |
|
// Also only attempt a token refresh for a 401 Unauthorized responses. Other response status codes |
|
// should not be used for expired access tokens. This is important because 403 forbiden responses can |
|
// be used for incremental consent which cannot be acheived with a simple refresh. |
|
if (!attemptedRefresh && |
|
response.StatusCode == System.Net.HttpStatusCode.Unauthorized && |
|
await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { RefreshToken: { Length: > 0 } refreshToken }) |
|
{ |
|
var accessToken = await RefreshTokensAsync(refreshToken, resourceUri, authServerMetadata, cancellationToken).ConfigureAwait(false); |
|
if (accessToken is not null) |
|
{ |
|
// A non-null result indicates the refresh succeeded and the new tokens have been stored. |
|
return accessToken; |
|
} |
|
} |
|
|
|
// Assign a client ID if necessary |
|
if (string.IsNullOrEmpty(_clientId)) |
|
{ |
|
// Try using a client metadata document before falling back to dynamic client registration |
|
if (authServerMetadata.ClientIdMetadataDocumentSupported && _clientMetadataDocumentUri is not null) |
|
{ |
|
ApplyClientIdMetadataDocument(_clientMetadataDocumentUri); |
|
} |
|
else |
|
{ |
|
await PerformDynamicClientRegistrationAsync(protectedResourceMetadata, authServerMetadata, cancellationToken).ConfigureAwait(false); |
|
} |
|
} |
• The refresh block (attempts RefreshTokensAsync when a cached refresh token exists) runs first.
• The client-ID assignment block (ApplyClientIdMetadataDocument / PerformDynamicClientRegistrationAsync) runs after it.
So on a cold start with _clientId == null, the refresh fires against the token endpoint with no client ID.
Note: the sibling path GetAccessTokenSilentAsync is already guarded against an analogous case — it only refreshes when _authServerMetadata is not null. GetAccessTokenAsync has no equivalent guard for a missing client ID.
Sequence of events
- A durable ITokenCache is configured (not the default InMemoryTokenCache), so a refresh token survives a process restart.
- Process restarts. _clientId is null (DCR / metadata-document assignment has not run in this process yet).
- A request is made; the server returns 401 Unauthorized.
- SendAsync → HandleUnauthorizedResponseAsync → GetAccessTokenAsync.
- GetAccessTokenAsync fetches AS metadata, then enters the refresh block: it sees attemptedRefresh == false, status Unauthorized, and a cached RefreshToken of length > 0 — so it calls RefreshTokensAsync before assigning a client ID.
- RefreshTokensAsync → CreateTokenRequest → GetClientIdOrThrow() throws because _clientId is still null.
Stack
System.InvalidOperationException: Client ID is not available. This may indicate an issue with dynamic client registration.
at ClientOAuthProvider.GetClientIdOrThrow()
at ClientOAuthProvider.CreateTokenRequest(Uri tokenEndpoint, Dictionary<string, string> formFields)
at ClientOAuthProvider.RefreshTokensAsync(string refreshToken, string resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
at ClientOAuthProvider.GetAccessTokenAsync(HttpResponseMessage response, bool attemptedRefresh, CancellationToken cancellationToken)
at ClientOAuthProvider.HandleUnauthorizedResponseAsync(HttpRequestMessage originalRequest, JsonRpcMessage originalJsonRpcMessage, HttpResponseMessage response, bool attemptedRefresh, CancellationToken cancellationToken)
at ClientOAuthProvider.SendAsync(HttpRequestMessage request, JsonRpcMessage message, CancellationToken cancellationToken)
at StreamableHttpClientSessionTransport.SendHttpRequestAsync(JsonRpcMessage message, CancellationToken cancellationToken)
at AutoDetectingClientSessionTransport.InitializeAsync(JsonRpcMessage message, CancellationToken cancellationToken)
at McpSessionHandler.SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken)
at McpSession.SendRequestAsync<TParameters, TResult>(...)
at McpClientImpl.ConnectAsync(CancellationToken cancellationToken)
at McpClient.CreateAsync(IClientTransport clientTransport, McpClientOptions clientOptions, ILoggerFactory loggerFactory, CancellationToken cancellationToken)
Suggested fix
Either:
• Option A (minimal): add !string.IsNullOrEmpty(_clientId) to the refresh-block condition in GetAccessTokenAsync so a refresh is only attempted when a client ID already exists; otherwise fall through to client-ID assignment and the normal authorization-code flow. Or
• Option B: move the client-ID assignment block above the refresh block so a client ID always exists before any token-endpoint call (costs a DCR/metadata round-trip on every cold-start refresh).
Related issues / PRs
• PR #1474 — "Implement authorization server binding and credential isolation (MCP SEP-2352)" (open). Rewrites the credential/refresh sequencing in ClientOAuthProvider, adding a HandleAuthorizationServerChange step that runs after AS-metadata fetch and before any token refresh, plus an InvalidatableTokenCache that can return null from GetTokensAsync. It targets spec compliance rather than this specific throw, but it touches the exact sequencing involved and may already mitigate or overlap with a fix here.
• PR #377 (merged) — original "Authorization Support" PR that introduced this OAuth client surface. Background.
• #1446 (open) — "OAuth proxy / DCR facade for non-DCR providers"; same DCR-vs-pre-registered-credentials theme, different failure mode. References a resource= parameter bug fixed in #648 / PR #1402.
• #681 (open) — "Not able to authenticate MCP Servers" (Entra ID OpenID config download failure); different root cause.
On a cold start where an ITokenCache returns a persisted refresh token but _clientId has not yet been assigned, ClientOAuthProvider.GetAccessTokenAsync attempts a token refresh before it assigns a client ID (via the client-id metadata document path or dynamic client registration). RefreshTokensAsync calls CreateTokenRequest, which calls GetClientIdOrThrow(), which throws:
The DCR message is misleading: DCR has not failed — it simply has not been given the chance to run yet, because the refresh attempt is sequenced ahead of the client-ID assignment block.
csharp-sdk/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
Lines 280 to 308 in 0663b7c
• The refresh block (attempts RefreshTokensAsync when a cached refresh token exists) runs first.
• The client-ID assignment block (ApplyClientIdMetadataDocument / PerformDynamicClientRegistrationAsync) runs after it.
So on a cold start with _clientId == null, the refresh fires against the token endpoint with no client ID.
Note: the sibling path GetAccessTokenSilentAsync is already guarded against an analogous case — it only refreshes when _authServerMetadata is not null. GetAccessTokenAsync has no equivalent guard for a missing client ID.
Sequence of events
Stack
Suggested fix
Either:
• Option A (minimal): add !string.IsNullOrEmpty(_clientId) to the refresh-block condition in GetAccessTokenAsync so a refresh is only attempted when a client ID already exists; otherwise fall through to client-ID assignment and the normal authorization-code flow. Or
• Option B: move the client-ID assignment block above the refresh block so a client ID always exists before any token-endpoint call (costs a DCR/metadata round-trip on every cold-start refresh).
Related issues / PRs
• PR #1474 — "Implement authorization server binding and credential isolation (MCP SEP-2352)" (open). Rewrites the credential/refresh sequencing in ClientOAuthProvider, adding a HandleAuthorizationServerChange step that runs after AS-metadata fetch and before any token refresh, plus an InvalidatableTokenCache that can return null from GetTokensAsync. It targets spec compliance rather than this specific throw, but it touches the exact sequencing involved and may already mitigate or overlap with a fix here.
• PR #377 (merged) — original "Authorization Support" PR that introduced this OAuth client surface. Background.
• #1446 (open) — "OAuth proxy / DCR facade for non-DCR providers"; same DCR-vs-pre-registered-credentials theme, different failure mode. References a resource= parameter bug fixed in #648 / PR #1402.
• #681 (open) — "Not able to authenticate MCP Servers" (Entra ID OpenID config download failure); different root cause.