Summary
When an MCP client persists the client_id / client_secret returned from Dynamic Client Registration and then provides them via ClientOAuthOptions.ClientId / ClientSecret on a subsequent session (to skip DCR), the token exchange step fails with 401 Unauthorized against authorization servers whose token_endpoint_auth_methods_supported does not list "client_secret_post" first.
ClientOAuthOptions has no TokenEndpointAuthMethod setting, so there's no supported way to tell the SDK which method the client was registered with — the SDK falls back to the AS metadata's first supported method, which may not match how the client was registered.
Prior discussion
This failure mode was originally flagged as conformance test auth/pre-registration in #1177, which was closed by #1254 ("Bring up to 0.1.13 conformance"). PR #1254 introduced ClientOAuthOptions.ClientId / ClientSecret, but the matching TokenEndpointAuthMethod was not exposed — so reusing DCR-acquired credentials still fails at token exchange on ASes that don't list client_secret_post first in their metadata. Reproducible on 1.3.0.
Reproduction
-
Configure a client against an AS (e.g. WorkOS AuthKit) whose token_endpoint_auth_methods_supported lists none before client_secret_post:
"token_endpoint_auth_methods_supported": ["none", "client_secret_post", "client_secret_basic"]
-
First run — let the SDK do DCR:
OAuth = new ClientOAuthOptions
{
RedirectUri = new Uri("http://localhost:5556/callback"),
AuthorizationRedirectDelegate = ...,
DynamicClientRegistration = new DynamicClientRegistrationOptions
{
ClientName = "Sample MCP Client",
ResponseDelegate = (response, ct) =>
{
// persist response.ClientId and response.ClientSecret to disk
return Task.CompletedTask;
},
},
}
DCR succeeds, MCP initialize succeeds, tool calls succeed. ✅
-
Second run — read cached credentials from disk and pass them back:
OAuth = new ClientOAuthOptions
{
ClientId = cachedClientId,
ClientSecret = cachedClientSecret,
RedirectUri = new Uri("http://localhost:5556/callback"),
AuthorizationRedirectDelegate = ...,
// No DynamicClientRegistration — we want to skip DCR
}
Token exchange fails:
System.Net.Http.HttpRequestException: Response status code does not indicate success: 401 (Unauthorized).
Response body: {"error":"unauthorized"}
Root cause
In ClientOAuthProvider.cs:655, PerformDynamicClientRegistrationAsync hardcodes the DCR request's TokenEndpointAuthMethod:
var registrationRequest = new DynamicClientRegistrationRequest
{
// ...
TokenEndpointAuthMethod = "client_secret_post",
// ...
};
So every DCR'd client is registered as client_secret_post (confidential). The provider stores this on its private _tokenEndpointAuthMethod field when DCR completes — but only in memory for the current session.
On a subsequent session where ClientOAuthOptions.ClientId is set (skipping DCR), the provider's _tokenEndpointAuthMethod starts null and falls back via:
_tokenEndpointAuthMethod ??= authServerMetadata.TokenEndpointAuthMethodsSupported?.FirstOrDefault();
For an AS that lists none first (e.g. WorkOS AuthKit), the provider selects "none". CreateTokenRequest then takes the none branch:
else if (string.Equals(_tokenEndpointAuthMethod, "none", StringComparison.Ordinal))
{
// Public client: include client_id in the body but no secret.
formFields["client_id"] = clientId;
}
…and sends client_id only — no client_secret. The AS rejects with 401 because the registered client expects client_secret_post. The cached ClientSecret we worked to capture is never used.
Expected behavior
Reusing a DCR'd client via cached credentials should work without falling out of the supported configuration surface. Either:
- The SDK persists / accepts the auth method alongside
ClientId / ClientSecret, or
- The DCR step doesn't hardcode
client_secret_post (e.g., negotiates or defaults to none for PKCE-only flows).
Suggested fix
Add TokenEndpointAuthMethod to ClientOAuthOptions:
public sealed class ClientOAuthOptions
{
// ...existing properties...
/// <summary>
/// Gets or sets the token endpoint authentication method to use when <see cref="ClientId"/>
/// is supplied externally (e.g. from a persisted DCR registration). When omitted, the SDK
/// falls back to the authorization server's metadata.
/// </summary>
public string? TokenEndpointAuthMethod { get; set; }
}
Initialize ClientOAuthProvider._tokenEndpointAuthMethod from this option in the constructor. The existing fallback to AS metadata stays as a default.
Environment
- SDK:
ModelContextProtocol / ModelContextProtocol.AspNetCore 1.3.0
- Authorization server: WorkOS AuthKit
- .NET 10.0
Summary
When an MCP client persists the
client_id/client_secretreturned from Dynamic Client Registration and then provides them viaClientOAuthOptions.ClientId/ClientSecreton a subsequent session (to skip DCR), the token exchange step fails with401 Unauthorizedagainst authorization servers whosetoken_endpoint_auth_methods_supporteddoes not list"client_secret_post"first.ClientOAuthOptionshas noTokenEndpointAuthMethodsetting, so there's no supported way to tell the SDK which method the client was registered with — the SDK falls back to the AS metadata's first supported method, which may not match how the client was registered.Prior discussion
This failure mode was originally flagged as conformance test
auth/pre-registrationin #1177, which was closed by #1254 ("Bring up to 0.1.13 conformance"). PR #1254 introducedClientOAuthOptions.ClientId/ClientSecret, but the matchingTokenEndpointAuthMethodwas not exposed — so reusing DCR-acquired credentials still fails at token exchange on ASes that don't listclient_secret_postfirst in their metadata. Reproducible on 1.3.0.Reproduction
Configure a client against an AS (e.g. WorkOS AuthKit) whose
token_endpoint_auth_methods_supportedlistsnonebeforeclient_secret_post:First run — let the SDK do DCR:
DCR succeeds, MCP
initializesucceeds, tool calls succeed. ✅Second run — read cached credentials from disk and pass them back:
Token exchange fails:
Root cause
In
ClientOAuthProvider.cs:655,PerformDynamicClientRegistrationAsynchardcodes the DCR request'sTokenEndpointAuthMethod:So every DCR'd client is registered as
client_secret_post(confidential). The provider stores this on its private_tokenEndpointAuthMethodfield when DCR completes — but only in memory for the current session.On a subsequent session where
ClientOAuthOptions.ClientIdis set (skipping DCR), the provider's_tokenEndpointAuthMethodstarts null and falls back via:For an AS that lists
nonefirst (e.g. WorkOS AuthKit), the provider selects"none".CreateTokenRequestthen takes thenonebranch:…and sends
client_idonly — noclient_secret. The AS rejects with 401 because the registered client expectsclient_secret_post. The cachedClientSecretwe worked to capture is never used.Expected behavior
Reusing a DCR'd client via cached credentials should work without falling out of the supported configuration surface. Either:
ClientId/ClientSecret, orclient_secret_post(e.g., negotiates or defaults tononefor PKCE-only flows).Suggested fix
Add
TokenEndpointAuthMethodtoClientOAuthOptions:Initialize
ClientOAuthProvider._tokenEndpointAuthMethodfrom this option in the constructor. The existing fallback to AS metadata stays as a default.Environment
ModelContextProtocol/ModelContextProtocol.AspNetCore1.3.0