Skip to content

ClientOAuthProvider throws "Client ID is not available" on cold start when a refresh token is persisted but client ID is not #1658

@KirillOsenkov

Description

@KirillOsenkov

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

  1. A durable ITokenCache is configured (not the default InMemoryTokenCache), so a refresh token survives a process restart.
  2. Process restarts. _clientId is null (DCR / metadata-document assignment has not run in this process yet).
  3. A request is made; the server returns 401 Unauthorized.
  4. SendAsync → HandleUnauthorizedResponseAsync → GetAccessTokenAsync.
  5. 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.
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions