diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
index a20ef9f..785e3cc 100644
--- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
+++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
@@ -35,6 +35,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
private readonly bool _validateAuthorizationResponseState;
private readonly bool _validateAuthorizationResponseIssuer;
private readonly Uri? _clientMetadataDocumentUri;
+ private readonly string? _configuredClientId;
// _dcrClientName, _dcrClientUri, _dcrInitialAccessToken, _dcrConfiguredApplicationType and _dcrResponseDelegate are used for dynamic client registration (RFC 7591)
private readonly string? _dcrClientName;
@@ -49,6 +50,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
private string? _clientId;
private string? _clientSecret;
private string? _tokenEndpointAuthMethod;
+ private string? _clientCredentialsAuthorizationServer;
private ITokenCache _tokenCache;
private AuthorizationServerMetadata? _authServerMetadata;
@@ -96,6 +98,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
}
_clientId = options.ClientId;
+ _configuredClientId = options.ClientId;
_clientSecret = options.ClientSecret;
_redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options));
_configuredScopes = options.Scopes is null ? null : string.Join(" ", options.Scopes);
@@ -244,7 +247,9 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
return (current.AccessToken, true);
}
- if (_authServerMetadata is not null && current?.RefreshToken is { Length: > 0 } refreshToken)
+ if (_authServerMetadata is not null &&
+ current?.RefreshToken is { Length: > 0 } refreshToken &&
+ CachedTokensMatchClientCredentials(current, _clientCredentialsAuthorizationServer))
{
var accessToken = await RefreshTokensAsync(refreshToken, resourceUri.ToString(), _authServerMetadata, cancellationToken).ConfigureAwait(false);
return (accessToken, true);
@@ -427,7 +432,10 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
// provider has not assigned a client ID yet. Restoring it here makes the refresh below possible
// and avoids a redundant dynamic client registration in the assignment block.
var cachedTokens = await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false);
- RestoreCachedClientCredentials(cachedTokens);
+ RestoreCachedClientCredentials(cachedTokens, selectedAuthServer);
+ BindClientCredentialsToAuthorizationServer(selectedAuthServer);
+ var cachedTokensMatchClientCredentials =
+ CachedTokensMatchClientCredentials(cachedTokens, selectedAuthServer.OriginalString);
// 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
@@ -439,6 +447,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
if (!attemptedRefresh &&
response.StatusCode == System.Net.HttpStatusCode.Unauthorized &&
!string.IsNullOrEmpty(_clientId) &&
+ cachedTokensMatchClientCredentials &&
cachedTokens is { RefreshToken: { Length: > 0 } refreshToken })
{
var accessToken = await RefreshTokensAsync(refreshToken, resourceUri, authServerMetadata, cancellationToken).ConfigureAwait(false);
@@ -463,6 +472,8 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
}
}
+ _clientCredentialsAuthorizationServer = selectedAuthServer.OriginalString;
+
// Determine the token endpoint auth method from server metadata if not already set by DCR.
_tokenEndpointAuthMethod ??= authServerMetadata.TokenEndpointAuthMethodsSupported?.FirstOrDefault();
@@ -875,6 +886,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
ClientId = _clientId,
ClientSecret = _clientSecret,
TokenEndpointAuthMethod = _tokenEndpointAuthMethod,
+ AuthorizationServer = _clientCredentialsAuthorizationServer,
};
await _tokenCache.StoreTokensAsync(tokens, cancellationToken).ConfigureAwait(false);
@@ -1458,33 +1470,93 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
///
/// Restores the client registration persisted alongside cached tokens when this provider has not been
/// assigned a client ID yet. This allows a durable to use a refresh token that
- /// survived a process restart without re-running dynamic client registration. An explicitly configured
+ /// survived a process restart without re-running dynamic client registration. Credentials are restored
+ /// only when the cache binds them to the currently selected authorization server. An explicitly configured
/// client ID always takes precedence, so nothing is restored when one is already available.
///
///
/// Callers must hold _tokenAcquisitionLock: this writes the shared _clientId,
/// _clientSecret, and _tokenEndpointAuthMethod fields, which the lock serializes.
- /// Like the persisted refresh token, the restored client ID assumes the durable cache belongs to the
- /// current authorization server; a single cache shared across authorization servers is not supported
- /// (an inherent property of the single-container design).
+ /// A single cache still stores only one registration, but the persisted authorization-server issuer
+ /// prevents that registration from being reused with a different server.
///
- private void RestoreCachedClientCredentials(TokenContainer? tokens)
+ private void RestoreCachedClientCredentials(TokenContainer? tokens, Uri selectedAuthServer)
{
- if (!string.IsNullOrEmpty(_clientId) || string.IsNullOrEmpty(tokens?.ClientId))
+ if (tokens is null)
{
return;
}
- // The guard above guarantees a non-null container, but the older nullable flow analysis on
- // netstandard2.0/net472 doesn't infer that from the null-conditional check, so capture a
- // non-null local and use it for every assignment.
+ // The guard above guarantees a non-null container, but older nullable flow analysis on
+ // netstandard2.0/net472 may not preserve that narrowing, so capture a non-null local.
var cached = tokens!;
+ if (_configuredClientId is not null)
+ {
+ if (!string.Equals(cached.ClientId, _configuredClientId, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ // Restore the issuer binding independently from the secret. A rotated configured secret
+ // must not make credentials previously bound to another issuer appear portable.
+ _clientCredentialsAuthorizationServer = cached.AuthorizationServer;
+
+ if (string.Equals(cached.AuthorizationServer, selectedAuthServer.OriginalString, StringComparison.Ordinal) &&
+ string.Equals(cached.ClientSecret, _clientSecret, StringComparison.Ordinal))
+ {
+ _tokenEndpointAuthMethod ??= cached.TokenEndpointAuthMethod;
+ }
+
+ return;
+ }
+
+ if (!string.IsNullOrEmpty(_clientId))
+ {
+ return;
+ }
+
+ if (string.IsNullOrEmpty(cached.ClientId) ||
+ !string.Equals(cached.AuthorizationServer, selectedAuthServer.OriginalString, StringComparison.Ordinal))
+ {
+ return;
+ }
+
// Assign _clientId last. Callers treat a non-empty _clientId as "registration complete", so the
// secret and auth method must already be in place before _clientId becomes observable.
_clientSecret ??= cached.ClientSecret;
_tokenEndpointAuthMethod ??= cached.TokenEndpointAuthMethod;
_clientId = cached.ClientId;
+ _clientCredentialsAuthorizationServer = cached.AuthorizationServer;
+ }
+
+ private bool CachedTokensMatchClientCredentials(TokenContainer? tokens, string? authorizationServer) =>
+ tokens is not null &&
+ string.Equals(tokens.AuthorizationServer, authorizationServer, StringComparison.Ordinal) &&
+ string.Equals(tokens.ClientId, _clientId, StringComparison.Ordinal) &&
+ string.Equals(tokens.ClientSecret, _clientSecret, StringComparison.Ordinal) &&
+ string.Equals(tokens.TokenEndpointAuthMethod, _tokenEndpointAuthMethod, StringComparison.Ordinal);
+
+ private void BindClientCredentialsToAuthorizationServer(Uri selectedAuthServer)
+ {
+ if (_clientCredentialsAuthorizationServer is null ||
+ string.Equals(_clientCredentialsAuthorizationServer, selectedAuthServer.OriginalString, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ if (_configuredClientId is not null)
+ {
+ ThrowFailedToHandleUnauthorizedResponse(
+ $"The authorization server changed from '{_clientCredentialsAuthorizationServer}' to '{selectedAuthServer.OriginalString}', " +
+ "but explicitly configured client credentials cannot be assumed to be valid for the new authorization server.");
+ }
+
+ _clientId = null;
+ _clientSecret = null;
+ _tokenEndpointAuthMethod = null;
+ _authServerMetadata = null;
+ _clientCredentialsAuthorizationServer = null;
}
[DoesNotReturn]
diff --git a/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs b/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs
index 9dd440b..e1a1df3 100644
--- a/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs
+++ b/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs
@@ -25,6 +25,11 @@ internal sealed class ExchangeJwtBearerGrantOptions
///
public string? ClientSecret { get; set; }
+ ///
+ /// Gets or sets the token endpoint authentication method.
+ ///
+ public string? TokenEndpointAuthMethod { get; set; }
+
///
/// Gets or sets the scopes to request (space-separated). Optional.
///
diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs
index c949037..ecc5eb3 100644
--- a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs
+++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs
@@ -164,7 +164,18 @@ internal static class IdentityAssertionGrant
["client_id"] = options.ClientId,
};
- if (!string.IsNullOrEmpty(options.ClientSecret))
+ using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint);
+
+ if (string.Equals(options.TokenEndpointAuthMethod, "client_secret_basic", StringComparison.Ordinal))
+ {
+ formData.Remove("client_id");
+ var credentials = $"{Uri.EscapeDataString(options.ClientId)}:{Uri.EscapeDataString(options.ClientSecret ?? string.Empty)}";
+ httpRequest.Headers.Authorization = new AuthenticationHeaderValue(
+ "Basic",
+ Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(credentials)));
+ }
+ else if (string.Equals(options.TokenEndpointAuthMethod, "client_secret_post", StringComparison.Ordinal) &&
+ !string.IsNullOrEmpty(options.ClientSecret))
{
formData["client_secret"] = options.ClientSecret!;
}
@@ -174,12 +185,7 @@ internal static class IdentityAssertionGrant
formData["scope"] = options.Scope!;
}
- using var requestContent = new FormUrlEncodedContent(formData);
- using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint)
- {
- Content = requestContent
- };
-
+ httpRequest.Content = new FormUrlEncodedContent(formData);
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs
index f881b5f..41d2ea3 100644
--- a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs
+++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs
@@ -102,6 +102,22 @@ public sealed class IdentityAssertionGrantProvider
throw new ArgumentNullException($"{nameof(options)}.{nameof(options.IdTokenCallback)}");
}
+ if (options.TokenEndpointAuthMethod is not null &&
+ options.TokenEndpointAuthMethod is not ("client_secret_basic" or "client_secret_post" or "none"))
+ {
+ throw new ArgumentException(
+ $"{nameof(options.TokenEndpointAuthMethod)} must be 'client_secret_basic', 'client_secret_post', or 'none'.",
+ $"{nameof(options)}.{nameof(options.TokenEndpointAuthMethod)}");
+ }
+
+ if (options.TokenEndpointAuthMethod is "client_secret_basic" or "client_secret_post" &&
+ string.IsNullOrEmpty(options.ClientSecret))
+ {
+ throw new ArgumentException(
+ $"{nameof(options.ClientSecret)} is required when {nameof(options.TokenEndpointAuthMethod)} is '{options.TokenEndpointAuthMethod}'.",
+ $"{nameof(options)}.{nameof(options.ClientSecret)}");
+ }
+
_options = options;
_httpClient = httpClient;
_logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance;
@@ -197,6 +213,7 @@ public sealed class IdentityAssertionGrantProvider
Assertion = jag,
ClientId = _options.ClientId,
ClientSecret = _options.ClientSecret,
+ TokenEndpointAuthMethod = SelectTokenEndpointAuthMethod(mcpAuthMetadata),
Scope = _options.Scope,
}, _httpClient, cancellationToken).ConfigureAwait(false);
@@ -206,6 +223,50 @@ public sealed class IdentityAssertionGrantProvider
return tokens;
}
+ private string SelectTokenEndpointAuthMethod(AuthorizationServerMetadata metadata)
+ {
+ var supportedMethods = metadata.TokenEndpointAuthMethodsSupported;
+ if (_options.TokenEndpointAuthMethod is { } configuredMethod)
+ {
+ if (supportedMethods is { Count: > 0 } &&
+ !supportedMethods.Contains(configuredMethod, StringComparer.Ordinal))
+ {
+ throw new IdentityAssertionGrantException(
+ $"The configured token endpoint authentication method '{configuredMethod}' is not advertised by the MCP authorization server.");
+ }
+
+ return configuredMethod;
+ }
+
+ if (string.IsNullOrEmpty(_options.ClientSecret))
+ {
+ if (supportedMethods is null or { Count: 0 } ||
+ supportedMethods.Contains("none", StringComparer.Ordinal))
+ {
+ return "none";
+ }
+
+ throw new IdentityAssertionGrantException(
+ "The MCP authorization server does not advertise a token endpoint authentication method usable without a client secret.");
+ }
+
+ // Preserve the provider's existing client_secret_post behavior when it is available.
+ // RFC 8414 defines this metadata as a list of supported methods, not a preference order.
+ if (supportedMethods is null or { Count: 0 } ||
+ supportedMethods.Contains("client_secret_post", StringComparer.Ordinal))
+ {
+ return "client_secret_post";
+ }
+
+ if (supportedMethods.Contains("client_secret_basic", StringComparer.Ordinal))
+ {
+ return "client_secret_basic";
+ }
+
+ throw new IdentityAssertionGrantException(
+ "The MCP authorization server does not advertise a supported token endpoint authentication method.");
+ }
+
///
/// Clears any cached tokens, forcing a fresh token exchange on the next call to .
///
diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs
index c6cc7f8..ecbd055 100644
--- a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs
+++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs
@@ -16,6 +16,17 @@ public sealed class IdentityAssertionGrantProviderOptions
///
public string? ClientSecret { get; set; }
+ ///
+ /// Gets or sets the authentication method used at the MCP authorization server's token endpoint.
+ ///
+ ///
+ /// Supported values are client_secret_basic, client_secret_post, and none.
+ /// Set this to the method assigned to the pre-registered MCP client. When omitted, the provider
+ /// preserves its existing client_secret_post behavior when supported, then falls back to
+ /// another compatible method advertised by the authorization server.
+ ///
+ public string? TokenEndpointAuthMethod { get; set; }
+
///
/// Gets or sets the scopes to request from the MCP authorization server (space-separated). Optional.
///
diff --git a/src/ModelContextProtocol.Core/Authentication/TokenContainer.cs b/src/ModelContextProtocol.Core/Authentication/TokenContainer.cs
index 6f6a542..68d4fb8 100644
--- a/src/ModelContextProtocol.Core/Authentication/TokenContainer.cs
+++ b/src/ModelContextProtocol.Core/Authentication/TokenContainer.cs
@@ -76,5 +76,15 @@ public sealed class TokenContainer
///
public string? TokenEndpointAuthMethod { get; set; }
+ ///
+ /// Gets or sets the authorization server issuer that issued the client credentials and tokens.
+ ///
+ ///
+ /// OAuth client credentials are bound to an authorization server and must not be reused with a
+ /// different issuer. Durable cache implementations should persist this value alongside the token
+ /// and client registration so restored credentials can be validated before use.
+ ///
+ public string? AuthorizationServer { get; set; }
+
internal bool IsExpired => ExpiresIn is not null && DateTimeOffset.UtcNow >= ObtainedAt.AddSeconds(ExpiresIn.Value);
}
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs
index 8e982e9..1b8be84 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs
@@ -63,9 +63,12 @@ public class ClientConformanceTests
[InlineData("auth/2025-03-26-oauth-metadata-backcompat")]
[InlineData("auth/2025-03-26-oauth-endpoint-fallback")]
- // Extensions: Require ES256 JWT signing (private_key_jwt) and client_credentials grant support.
- // [InlineData("auth/client-credentials-jwt")]
- // [InlineData("auth/client-credentials-basic")]
+ [InlineData("auth/authorization-server-migration")]
+ [InlineData("auth/client-credentials-jwt")]
+ [InlineData("auth/client-credentials-basic")]
+ [InlineData("auth/enterprise-managed-authorization")]
+ [InlineData("sep-2322-client-request-state")]
+ [InlineData("json-schema-ref-no-deref")]
public async Task RunConformanceTest(string scenario)
{
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs
index cdd7475..5b6f054 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs
@@ -1,5 +1,6 @@
using ModelContextProtocol.Authentication;
using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
@@ -187,6 +188,182 @@ public class TokenCacheTests : OAuthTestBase
Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken);
}
+ [Fact]
+ public async Task GetTokenAsync_ExplicitClientDoesNotRefreshTokenFromDifferentAuthorizationServer()
+ {
+ await using var app = await StartMcpServerAsync();
+
+ var tokenCache = new TestTokenCache();
+ await using var setupTransport = new HttpClientTransport(new()
+ {
+ Endpoint = new(McpServerUrl),
+ OAuth = new()
+ {
+ ClientId = "demo-client",
+ ClientSecret = "demo-secret",
+ RedirectUri = new Uri("http://localhost:1179/callback"),
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
+ TokenCache = tokenCache,
+ },
+ }, HttpClient, LoggerFactory);
+
+ await using (var setupClient = await McpClient.CreateAsync(
+ setupTransport,
+ loggerFactory: LoggerFactory,
+ cancellationToken: TestContext.Current.CancellationToken))
+ {
+ }
+
+ Assert.NotNull(tokenCache.LastStoredToken);
+ tokenCache.LastStoredToken.AccessToken = "invalid-token";
+ tokenCache.LastStoredToken.AuthorizationServer = "https://different-authorization-server.example.com";
+ var authorizationCallbackCalled = false;
+
+ await using var transport = new HttpClientTransport(new()
+ {
+ Endpoint = new(McpServerUrl),
+ OAuth = new()
+ {
+ ClientId = "demo-client",
+ ClientSecret = "demo-secret",
+ RedirectUri = new Uri("http://localhost:1179/callback"),
+ AuthorizationCallbackHandler = (context, ct) =>
+ {
+ authorizationCallbackCalled = true;
+ return HandleAuthorizationUrlAsync(context, ct);
+ },
+ TokenCache = tokenCache,
+ },
+ }, HttpClient, LoggerFactory);
+
+ var exception = await Assert.ThrowsAsync(() => McpClient.CreateAsync(
+ transport,
+ loggerFactory: LoggerFactory,
+ cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Contains("explicitly configured client credentials", exception.Message);
+ Assert.False(authorizationCallbackCalled);
+ Assert.False(TestOAuthServer.HasRefreshedToken);
+ Assert.Equal("https://different-authorization-server.example.com", tokenCache.LastStoredToken.AuthorizationServer);
+ }
+
+ [Fact]
+ public async Task GetTokenAsync_ExplicitClientDoesNotRefreshTokenIssuedToDifferentClient()
+ {
+ await using var app = await StartMcpServerAsync();
+
+ var tokenCache = new TestTokenCache();
+ await using var setupTransport = new HttpClientTransport(new()
+ {
+ Endpoint = new(McpServerUrl),
+ OAuth = new()
+ {
+ ClientId = "demo-client",
+ ClientSecret = "demo-secret",
+ RedirectUri = new Uri("http://localhost:1179/callback"),
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
+ TokenCache = tokenCache,
+ },
+ }, HttpClient, LoggerFactory);
+
+ await using (var setupClient = await McpClient.CreateAsync(
+ setupTransport,
+ loggerFactory: LoggerFactory,
+ cancellationToken: TestContext.Current.CancellationToken))
+ {
+ }
+
+ Assert.NotNull(tokenCache.LastStoredToken);
+ tokenCache.LastStoredToken.AccessToken = "invalid-token";
+ tokenCache.LastStoredToken.ClientId = "different-client";
+ var authorizationCallbackCalled = false;
+
+ await using var transport = new HttpClientTransport(new()
+ {
+ Endpoint = new(McpServerUrl),
+ OAuth = new()
+ {
+ ClientId = "demo-client",
+ ClientSecret = "demo-secret",
+ RedirectUri = new Uri("http://localhost:1179/callback"),
+ AuthorizationCallbackHandler = (context, ct) =>
+ {
+ authorizationCallbackCalled = true;
+ return HandleAuthorizationUrlAsync(context, ct);
+ },
+ TokenCache = tokenCache,
+ },
+ }, HttpClient, LoggerFactory);
+
+ await using var client = await McpClient.CreateAsync(
+ transport,
+ loggerFactory: LoggerFactory,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.True(authorizationCallbackCalled);
+ Assert.False(TestOAuthServer.HasRefreshedToken);
+ Assert.Equal("demo-client", tokenCache.LastStoredToken.ClientId);
+ Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer);
+ }
+
+ [Fact]
+ public async Task GetTokenAsync_ExplicitClientWithLegacyUnboundCache_Reauthorizes()
+ {
+ await using var app = await StartMcpServerAsync();
+
+ var tokenCache = new TestTokenCache();
+ await using var setupTransport = new HttpClientTransport(new()
+ {
+ Endpoint = new(McpServerUrl),
+ OAuth = new()
+ {
+ ClientId = "demo-client",
+ ClientSecret = "demo-secret",
+ RedirectUri = new Uri("http://localhost:1179/callback"),
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
+ TokenCache = tokenCache,
+ },
+ }, HttpClient, LoggerFactory);
+
+ await using (var setupClient = await McpClient.CreateAsync(
+ setupTransport,
+ loggerFactory: LoggerFactory,
+ cancellationToken: TestContext.Current.CancellationToken))
+ {
+ }
+
+ Assert.NotNull(tokenCache.LastStoredToken);
+ tokenCache.LastStoredToken.AccessToken = "invalid-token";
+ tokenCache.LastStoredToken.AuthorizationServer = null;
+ var authorizationCallbackCalled = false;
+
+ await using var transport = new HttpClientTransport(new()
+ {
+ Endpoint = new(McpServerUrl),
+ OAuth = new()
+ {
+ ClientId = "demo-client",
+ ClientSecret = "demo-secret",
+ RedirectUri = new Uri("http://localhost:1179/callback"),
+ AuthorizationCallbackHandler = (context, ct) =>
+ {
+ authorizationCallbackCalled = true;
+ return HandleAuthorizationUrlAsync(context, ct);
+ },
+ TokenCache = tokenCache,
+ },
+ }, HttpClient, LoggerFactory);
+
+ await using var client = await McpClient.CreateAsync(
+ transport,
+ loggerFactory: LoggerFactory,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.True(authorizationCallbackCalled);
+ Assert.False(TestOAuthServer.HasRefreshedToken);
+ Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer);
+ }
+
[Fact]
public async Task GetTokenAsync_ColdStartWithDynamicRegistration_RefreshesUsingPersistedCredentials()
{
@@ -227,6 +404,7 @@ public class TokenCacheTests : OAuthTestBase
Assert.False(
string.IsNullOrEmpty(tokenCache.LastStoredToken.ClientId),
"The dynamically registered client ID should be persisted alongside the tokens");
+ Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer);
// Simulate a cold start: the access token is no longer valid, but the refresh token persists.
// The new provider has no client ID configured and must restore it from the cache to refresh
@@ -261,6 +439,119 @@ public class TokenCacheTests : OAuthTestBase
Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken);
}
+ [Fact]
+ public async Task GetTokenAsync_ColdStartWithCredentialsFromDifferentAuthorizationServer_Reregisters()
+ {
+ await using var app = await StartMcpServerAsync();
+
+ var tokenCache = new TestTokenCache();
+ await using var setupTransport = new HttpClientTransport(new()
+ {
+ Endpoint = new(McpServerUrl),
+ OAuth = new()
+ {
+ RedirectUri = new Uri("http://localhost:1179/callback"),
+ DynamicClientRegistration = new() { ClientName = "Test MCP Client" },
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
+ TokenCache = tokenCache,
+ },
+ }, HttpClient, LoggerFactory);
+
+ await using (var setupClient = await McpClient.CreateAsync(
+ setupTransport,
+ loggerFactory: LoggerFactory,
+ cancellationToken: TestContext.Current.CancellationToken))
+ {
+ }
+
+ Assert.NotNull(tokenCache.LastStoredToken);
+ tokenCache.LastStoredToken.AccessToken = "invalid-token";
+ tokenCache.LastStoredToken.AuthorizationServer = "https://different-authorization-server.example.com";
+ var authorizationCallbackCalled = false;
+
+ await using var transport = new HttpClientTransport(new()
+ {
+ Endpoint = new(McpServerUrl),
+ OAuth = new()
+ {
+ RedirectUri = new Uri("http://localhost:1179/callback"),
+ DynamicClientRegistration = new() { ClientName = "Test MCP Client" },
+ AuthorizationCallbackHandler = (context, ct) =>
+ {
+ authorizationCallbackCalled = true;
+ return HandleAuthorizationUrlAsync(context, ct);
+ },
+ TokenCache = tokenCache,
+ },
+ }, HttpClient, LoggerFactory);
+
+ await using var client = await McpClient.CreateAsync(
+ transport,
+ loggerFactory: LoggerFactory,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.True(authorizationCallbackCalled);
+ Assert.False(TestOAuthServer.HasRefreshedToken);
+ Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer);
+ }
+
+ [Fact]
+ public async Task GetTokenAsync_ColdStartWithLegacyUnboundCredentials_Reregisters()
+ {
+ await using var app = await StartMcpServerAsync();
+
+ var tokenCache = new TestTokenCache();
+ await using var setupTransport = new HttpClientTransport(new()
+ {
+ Endpoint = new(McpServerUrl),
+ OAuth = new()
+ {
+ RedirectUri = new Uri("http://localhost:1179/callback"),
+ DynamicClientRegistration = new() { ClientName = "Test MCP Client" },
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
+ TokenCache = tokenCache,
+ },
+ }, HttpClient, LoggerFactory);
+
+ await using (var setupClient = await McpClient.CreateAsync(
+ setupTransport,
+ loggerFactory: LoggerFactory,
+ cancellationToken: TestContext.Current.CancellationToken))
+ {
+ }
+
+ Assert.NotNull(tokenCache.LastStoredToken);
+ Assert.False(string.IsNullOrEmpty(tokenCache.LastStoredToken.ClientId));
+ tokenCache.LastStoredToken.AccessToken = "invalid-token";
+ tokenCache.LastStoredToken.AuthorizationServer = null;
+ var authorizationCallbackCalled = false;
+
+ await using var transport = new HttpClientTransport(new()
+ {
+ Endpoint = new(McpServerUrl),
+ OAuth = new()
+ {
+ RedirectUri = new Uri("http://localhost:1179/callback"),
+ DynamicClientRegistration = new() { ClientName = "Test MCP Client" },
+ AuthorizationCallbackHandler = (context, ct) =>
+ {
+ authorizationCallbackCalled = true;
+ return HandleAuthorizationUrlAsync(context, ct);
+ },
+ TokenCache = tokenCache,
+ },
+ }, HttpClient, LoggerFactory);
+
+ await using var client = await McpClient.CreateAsync(
+ transport,
+ loggerFactory: LoggerFactory,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.True(authorizationCallbackCalled);
+ Assert.False(TestOAuthServer.HasRefreshedToken);
+ Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer);
+ }
+
[Fact]
public async Task GetTokenAsync_ColdStartWithoutPersistedClientId_FallsBackToReauthorization()
{
diff --git a/tests/ModelContextProtocol.ConformanceClient/ConformanceOAuthHelpers.cs b/tests/ModelContextProtocol.ConformanceClient/ConformanceOAuthHelpers.cs
new file mode 100644
index 0000000..d9c148d
--- /dev/null
+++ b/tests/ModelContextProtocol.ConformanceClient/ConformanceOAuthHelpers.cs
@@ -0,0 +1,298 @@
+using System.Net.Http.Headers;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.Authentication;
+
+internal sealed class ConformanceContext
+{
+ private readonly JsonElement _root;
+
+ private ConformanceContext(JsonElement root)
+ {
+ _root = root;
+ }
+
+ public static ConformanceContext? Parse(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return null;
+ }
+
+ using var document = JsonDocument.Parse(json);
+ return new ConformanceContext(document.RootElement.Clone());
+ }
+
+ public string GetRequiredString(string propertyName)
+ {
+ if (!_root.TryGetProperty(propertyName, out var value) ||
+ value.ValueKind != JsonValueKind.String ||
+ string.IsNullOrEmpty(value.GetString()))
+ {
+ throw new InvalidOperationException(
+ $"MCP_CONFORMANCE_CONTEXT is missing required string property '{propertyName}'.");
+ }
+
+ return value.GetString()!;
+ }
+
+ public string? GetString(string propertyName) =>
+ _root.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String
+ ? value.GetString()
+ : null;
+}
+
+internal static class ConformanceOAuthHelpers
+{
+ private const string ClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
+
+ public static async Task AcquireClientCredentialsTokenAsync(
+ Uri resourceUri,
+ ConformanceContext context,
+ bool usePrivateKeyJwt,
+ CancellationToken cancellationToken)
+ {
+ using var httpClient = new HttpClient();
+ var discovery = await DiscoverAsync(httpClient, resourceUri, cancellationToken).ConfigureAwait(false);
+ var clientId = context.GetRequiredString("client_id");
+
+ Dictionary formFields = new()
+ {
+ ["grant_type"] = "client_credentials",
+ ["resource"] = discovery.Resource,
+ };
+
+ if (discovery.Scopes.Count > 0)
+ {
+ formFields["scope"] = string.Join(" ", discovery.Scopes);
+ }
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, discovery.TokenEndpoint);
+ if (usePrivateKeyJwt)
+ {
+ var algorithm = context.GetString("signing_algorithm") ?? "ES256";
+ if (!string.Equals(algorithm, "ES256", StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException($"Unsupported client assertion signing algorithm '{algorithm}'.");
+ }
+
+ formFields["client_assertion_type"] = ClientAssertionType;
+ formFields["client_assertion"] = CreateEs256ClientAssertion(
+ clientId,
+ discovery.AuthorizationServer,
+ context.GetRequiredString("private_key_pem"));
+ }
+ else
+ {
+ var credentials = $"{Uri.EscapeDataString(clientId)}:{Uri.EscapeDataString(context.GetRequiredString("client_secret"))}";
+ request.Headers.Authorization = new AuthenticationHeaderValue(
+ "Basic",
+ Convert.ToBase64String(Encoding.UTF8.GetBytes(credentials)));
+ }
+
+ request.Content = new FormUrlEncodedContent(formFields);
+ using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ return await ReadAccessTokenAsync(response, cancellationToken).ConfigureAwait(false);
+ }
+
+ public static async Task AcquireEnterpriseTokenAsync(
+ Uri resourceUri,
+ ConformanceContext context,
+ ILoggerFactory loggerFactory,
+ CancellationToken cancellationToken)
+ {
+ using var httpClient = new HttpClient();
+ var discovery = await DiscoverAsync(httpClient, resourceUri, cancellationToken).ConfigureAwait(false);
+ var provider = new IdentityAssertionGrantProvider(
+ new IdentityAssertionGrantProviderOptions
+ {
+ ClientId = context.GetRequiredString("client_id"),
+ ClientSecret = context.GetRequiredString("client_secret"),
+ TokenEndpointAuthMethod = "client_secret_basic",
+ IdpClientId = context.GetRequiredString("idp_client_id"),
+ IdpTokenEndpoint = context.GetRequiredString("idp_token_endpoint"),
+ IdTokenCallback = (_, _) => Task.FromResult(context.GetRequiredString("idp_id_token")),
+ },
+ httpClient,
+ loggerFactory);
+
+ var tokens = await provider.GetAccessTokenAsync(
+ resourceUri,
+ new Uri(discovery.AuthorizationServer),
+ cancellationToken).ConfigureAwait(false);
+ return tokens.AccessToken;
+ }
+
+ public static HttpClient CreateBearerHttpClient(string accessToken)
+ {
+ var httpClient = new HttpClient();
+ httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+ return httpClient;
+ }
+
+ private static async Task DiscoverAsync(
+ HttpClient httpClient,
+ Uri resourceUri,
+ CancellationToken cancellationToken)
+ {
+ JsonElement? protectedResourceMetadata = null;
+ foreach (var metadataUri in GetProtectedResourceMetadataUris(resourceUri))
+ {
+ using var response = await httpClient.GetAsync(metadataUri, cancellationToken).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode)
+ {
+ continue;
+ }
+
+ protectedResourceMetadata = await ReadJsonAsync(response, cancellationToken).ConfigureAwait(false);
+ break;
+ }
+
+ if (protectedResourceMetadata is not { } prm ||
+ !prm.TryGetProperty("authorization_servers", out var authorizationServers) ||
+ authorizationServers.ValueKind != JsonValueKind.Array ||
+ authorizationServers.GetArrayLength() == 0)
+ {
+ throw new InvalidOperationException($"No authorization server was discovered for MCP resource '{resourceUri}'.");
+ }
+
+ var authorizationServer = authorizationServers[0].GetString()
+ ?? throw new InvalidOperationException("The discovered authorization server URI was null.");
+ var resource = prm.TryGetProperty("resource", out var resourceProperty)
+ ? resourceProperty.GetString() ?? resourceUri.ToString()
+ : resourceUri.ToString();
+ List scopes = [];
+ if (prm.TryGetProperty("scopes_supported", out var scopesProperty) &&
+ scopesProperty.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var scope in scopesProperty.EnumerateArray())
+ {
+ if (scope.GetString() is { Length: > 0 } value)
+ {
+ scopes.Add(value);
+ }
+ }
+ }
+
+ var authorizationServerUri = new Uri(authorizationServer);
+ foreach (var metadataUri in GetAuthorizationServerMetadataUris(authorizationServerUri))
+ {
+ using var response = await httpClient.GetAsync(metadataUri, cancellationToken).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode)
+ {
+ continue;
+ }
+
+ var metadata = await ReadJsonAsync(response, cancellationToken).ConfigureAwait(false);
+ if (metadata.TryGetProperty("token_endpoint", out var tokenEndpointProperty) &&
+ tokenEndpointProperty.GetString() is { Length: > 0 } tokenEndpoint)
+ {
+ var issuer = metadata.TryGetProperty("issuer", out var issuerProperty)
+ ? issuerProperty.GetString() ?? authorizationServer
+ : authorizationServer;
+ return new OAuthDiscovery(resource, issuer, new Uri(tokenEndpoint), scopes);
+ }
+ }
+
+ throw new InvalidOperationException(
+ $"No authorization server metadata with a token endpoint was discovered for '{authorizationServer}'.");
+ }
+
+ private static IEnumerable GetProtectedResourceMetadataUris(Uri resourceUri)
+ {
+ var authority = resourceUri.GetLeftPart(UriPartial.Authority);
+ var path = resourceUri.AbsolutePath.Trim('/');
+ if (path.Length > 0)
+ {
+ yield return new Uri($"{authority}/.well-known/oauth-protected-resource/{path}");
+ }
+ yield return new Uri($"{authority}/.well-known/oauth-protected-resource");
+ }
+
+ private static IEnumerable GetAuthorizationServerMetadataUris(Uri authorizationServer)
+ {
+ var authority = authorizationServer.GetLeftPart(UriPartial.Authority);
+ var path = authorizationServer.AbsolutePath.Trim('/');
+ if (path.Length == 0)
+ {
+ yield return new Uri($"{authority}/.well-known/oauth-authorization-server");
+ yield return new Uri($"{authority}/.well-known/openid-configuration");
+ }
+ else
+ {
+ yield return new Uri($"{authority}/.well-known/oauth-authorization-server/{path}");
+ yield return new Uri($"{authority}/.well-known/openid-configuration/{path}");
+ yield return new Uri($"{authority}/{path}/.well-known/openid-configuration");
+ }
+ }
+
+ private static string CreateEs256ClientAssertion(string clientId, string audience, string privateKeyPem)
+ {
+ var header = Base64UrlEncode(Encoding.UTF8.GetBytes("""{"alg":"ES256","typ":"JWT"}"""));
+ var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+
+ using var payloadStream = new MemoryStream();
+ using (var writer = new Utf8JsonWriter(payloadStream))
+ {
+ writer.WriteStartObject();
+ writer.WriteString("iss", clientId);
+ writer.WriteString("sub", clientId);
+ writer.WriteString("aud", audience);
+ writer.WriteNumber("iat", now);
+ writer.WriteNumber("exp", now + 300);
+ writer.WriteString("jti", Guid.NewGuid());
+ writer.WriteEndObject();
+ }
+
+ var payload = Base64UrlEncode(payloadStream.ToArray());
+ var signingInput = Encoding.ASCII.GetBytes($"{header}.{payload}");
+ using var key = ECDsa.Create();
+ key.ImportFromPem(privateKeyPem);
+ var signature = key.SignData(
+ signingInput,
+ HashAlgorithmName.SHA256,
+ DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
+ return $"{header}.{payload}.{Base64UrlEncode(signature)}";
+ }
+
+ private static async Task ReadJsonAsync(
+ HttpResponseMessage response,
+ CancellationToken cancellationToken)
+ {
+ using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
+ return document.RootElement.Clone();
+ }
+
+ private static async Task ReadAccessTokenAsync(
+ HttpResponseMessage response,
+ CancellationToken cancellationToken)
+ {
+ var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new HttpRequestException(
+ $"Client credentials token request failed with status {(int)response.StatusCode}: {body}");
+ }
+
+ using var document = JsonDocument.Parse(body);
+ if (!document.RootElement.TryGetProperty("access_token", out var accessToken) ||
+ accessToken.GetString() is not { Length: > 0 } value)
+ {
+ throw new InvalidOperationException("The token response did not contain an access_token.");
+ }
+
+ return value;
+ }
+
+ private static string Base64UrlEncode(byte[] bytes) =>
+ Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+
+ private sealed record OAuthDiscovery(
+ string Resource,
+ string AuthorizationServer,
+ Uri TokenEndpoint,
+ IReadOnlyList Scopes);
+}
diff --git a/tests/ModelContextProtocol.ConformanceClient/Program.cs b/tests/ModelContextProtocol.ConformanceClient/Program.cs
index 82d720a..718c782 100644
--- a/tests/ModelContextProtocol.ConformanceClient/Program.cs
+++ b/tests/ModelContextProtocol.ConformanceClient/Program.cs
@@ -40,10 +40,10 @@ McpClientOptions options = new()
// The default client now prefers the 2026-07-28 protocol (probing with server/discover and
// falling back to an initialize handshake). The "initialize" and "sse-retry" scenarios
// specifically exercise the initialize handshake and SSE resumability (removed in the
-// 2026-07-28 protocol) and strictly expect initialize as the first message, so pin them to the
-// latest stable version. Other scenarios run on the 2026-07-28 default and exercise the
-// server/discover probe plus the transparent initialize-handshake fallback.
-if (scenario is "initialize" or "sse-retry")
+// 2026-07-28 protocol) and strictly expect initialize as the first message. The alpha.9
+// json-schema-ref-no-deref server also uses a bundled Node transport that rejects the draft
+// protocol header before tools/list. Pin these scenarios to the latest stable version.
+if (scenario is "initialize" or "sse-retry" or "json-schema-ref-no-deref")
{
options.ProtocolVersion = "2025-11-25";
}
@@ -72,21 +72,10 @@ if (callbackPort == 0)
var clientRedirectUri = new Uri($"http://localhost:{callbackPort}/callback");
// Read conformance context for scenarios that provide additional data (e.g., pre-registered credentials).
-string? preRegisteredClientId = null;
-string? preRegisteredClientSecret = null;
var conformanceContext = Environment.GetEnvironmentVariable("MCP_CONFORMANCE_CONTEXT");
-if (!string.IsNullOrEmpty(conformanceContext))
-{
- using var doc = JsonDocument.Parse(conformanceContext);
- if (doc.RootElement.TryGetProperty("client_id", out var clientIdEl))
- {
- preRegisteredClientId = clientIdEl.GetString();
- }
- if (doc.RootElement.TryGetProperty("client_secret", out var clientSecretEl))
- {
- preRegisteredClientSecret = clientSecretEl.GetString();
- }
-}
+var parsedConformanceContext = ConformanceContext.Parse(conformanceContext);
+var preRegisteredClientId = parsedConformanceContext?.GetString("client_id");
+var preRegisteredClientSecret = parsedConformanceContext?.GetString("client_secret");
var oauthOptions = new ModelContextProtocol.Authentication.ClientOAuthOptions
{
@@ -110,12 +99,55 @@ else
};
}
-var clientTransport = new HttpClientTransport(new()
+var endpointUri = new Uri(endpoint);
+HttpClientTransport clientTransport;
+if (scenario is "auth/client-credentials-basic" or "auth/client-credentials-jwt")
{
- Endpoint = new Uri(endpoint),
- TransportMode = HttpTransportMode.StreamableHttp,
- OAuth = oauthOptions,
-}, loggerFactory: consoleLoggerFactory);
+ var context = parsedConformanceContext
+ ?? throw new InvalidOperationException($"Scenario '{scenario}' requires MCP_CONFORMANCE_CONTEXT.");
+ var accessToken = await ConformanceOAuthHelpers.AcquireClientCredentialsTokenAsync(
+ endpointUri,
+ context,
+ usePrivateKeyJwt: scenario == "auth/client-credentials-jwt",
+ CancellationToken.None);
+ clientTransport = new HttpClientTransport(
+ new()
+ {
+ Endpoint = endpointUri,
+ TransportMode = HttpTransportMode.StreamableHttp,
+ },
+ ConformanceOAuthHelpers.CreateBearerHttpClient(accessToken),
+ consoleLoggerFactory,
+ ownsHttpClient: true);
+}
+else if (scenario == "auth/enterprise-managed-authorization")
+{
+ var context = parsedConformanceContext
+ ?? throw new InvalidOperationException($"Scenario '{scenario}' requires MCP_CONFORMANCE_CONTEXT.");
+ var accessToken = await ConformanceOAuthHelpers.AcquireEnterpriseTokenAsync(
+ endpointUri,
+ context,
+ consoleLoggerFactory,
+ CancellationToken.None);
+ clientTransport = new HttpClientTransport(
+ new()
+ {
+ Endpoint = endpointUri,
+ TransportMode = HttpTransportMode.StreamableHttp,
+ },
+ ConformanceOAuthHelpers.CreateBearerHttpClient(accessToken),
+ consoleLoggerFactory,
+ ownsHttpClient: true);
+}
+else
+{
+ clientTransport = new HttpClientTransport(new()
+ {
+ Endpoint = endpointUri,
+ TransportMode = HttpTransportMode.StreamableHttp,
+ OAuth = oauthOptions,
+ }, loggerFactory: consoleLoggerFactory);
+}
try
{
@@ -191,6 +223,19 @@ try
}
break;
}
+ case "auth/authorization-server-migration":
+ {
+ await mcpClient.ListToolsAsync();
+ await mcpClient.ListToolsAsync();
+ break;
+ }
+ case "auth/client-credentials-basic":
+ case "auth/client-credentials-jwt":
+ case "auth/enterprise-managed-authorization":
+ {
+ await mcpClient.ListToolsAsync();
+ break;
+ }
case "http-standard-headers":
{
// List and call tools to test Mcp-Method and Mcp-Name headers
diff --git a/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs b/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs
index cfff605..92e81c9 100644
--- a/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs
+++ b/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs
@@ -37,6 +37,7 @@ public sealed class IdentityAssertionGrantTests : IDisposable
["issuer"] = "https://auth.mcp-server.example.com",
["authorization_endpoint"] = "https://auth.mcp-server.example.com/authorize",
["token_endpoint"] = "https://auth.mcp-server.example.com/token",
+ ["token_endpoint_auth_methods_supported"] = new JsonArray("client_secret_basic"),
});
}
@@ -52,6 +53,10 @@ public sealed class IdentityAssertionGrantTests : IDisposable
if (url.Contains("auth.mcp-server.example.com/token"))
{
+ Assert.Equal("Basic", request.Headers.Authorization?.Scheme);
+ Assert.Equal(
+ Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("mcp-client-id:mcp-client-secret")),
+ request.Headers.Authorization?.Parameter);
return JsonResponse(HttpStatusCode.OK, new JsonObject
{
["access_token"] = "final-access-token",
@@ -67,6 +72,8 @@ public sealed class IdentityAssertionGrantTests : IDisposable
new IdentityAssertionGrantProviderOptions
{
ClientId = "mcp-client-id",
+ ClientSecret = "mcp-client-secret",
+ TokenEndpointAuthMethod = "client_secret_basic",
IdpTokenEndpoint = "https://idp.example.com/token",
IdpClientId = "idp-client-id",
IdTokenCallback = (context, ct) =>
@@ -88,6 +95,30 @@ public sealed class IdentityAssertionGrantTests : IDisposable
Assert.Equal(3600, tokens.ExpiresIn);
}
+ [Fact]
+ public Task IdentityAssertionGrantProvider_DefaultsToPostRegardlessOfMetadataOrder() =>
+ AssertMcpTokenEndpointAuthenticationAsync(
+ new JsonArray("client_secret_basic", "client_secret_post"),
+ configuredMethod: null,
+ expectedAuthorizationScheme: null,
+ expectSecretInBody: true);
+
+ [Fact]
+ public Task IdentityAssertionGrantProvider_FallsBackToBasicWhenPostIsUnavailable() =>
+ AssertMcpTokenEndpointAuthenticationAsync(
+ new JsonArray("client_secret_basic"),
+ configuredMethod: null,
+ expectedAuthorizationScheme: "Basic",
+ expectSecretInBody: false);
+
+ [Fact]
+ public Task IdentityAssertionGrantProvider_NoneDoesNotSendClientSecret() =>
+ AssertMcpTokenEndpointAuthenticationAsync(
+ new JsonArray("none"),
+ configuredMethod: "none",
+ expectedAuthorizationScheme: null,
+ expectSecretInBody: false);
+
[Fact]
public async Task IdentityAssertionGrantProvider_CachesTokens()
{
@@ -313,6 +344,39 @@ public sealed class IdentityAssertionGrantTests : IDisposable
_httpClient));
}
+ [Theory]
+ [InlineData("client_secret_basic")]
+ [InlineData("client_secret_post")]
+ public void IdentityAssertionGrantProvider_TokenEndpointAuthMethodRequiresSecret_MissingClientSecret_ThrowsArgumentException(string tokenEndpointAuthMethod)
+ {
+ Assert.Throws(() => new IdentityAssertionGrantProvider(
+ new IdentityAssertionGrantProviderOptions
+ {
+ ClientId = "client-id",
+ IdpTokenEndpoint = "https://idp.example.com/token",
+ IdpClientId = "idp-client-id",
+ IdTokenCallback = (_, _) => Task.FromResult("test"),
+ TokenEndpointAuthMethod = tokenEndpointAuthMethod,
+ ClientSecret = null,
+ },
+ _httpClient));
+ }
+
+ [Fact]
+ public void IdentityAssertionGrantProvider_UnsupportedTokenEndpointAuthMethod_ThrowsArgumentException()
+ {
+ Assert.Throws(() => new IdentityAssertionGrantProvider(
+ new IdentityAssertionGrantProviderOptions
+ {
+ ClientId = "client-id",
+ IdpTokenEndpoint = "https://idp.example.com/token",
+ IdpClientId = "idp-client-id",
+ IdTokenCallback = (_, _) => Task.FromResult("test"),
+ TokenEndpointAuthMethod = "private_key_jwt",
+ },
+ _httpClient));
+ }
+
[Fact]
public async Task IdentityAssertionGrantProvider_ConcurrentCallers_RunExchangeOnce()
{
@@ -434,6 +498,67 @@ public sealed class IdentityAssertionGrantTests : IDisposable
#region Helpers
+ private async Task AssertMcpTokenEndpointAuthenticationAsync(
+ JsonArray supportedMethods,
+ string? configuredMethod,
+ string? expectedAuthorizationScheme,
+ bool expectSecretInBody)
+ {
+ _mockHandler.AsyncHandler = async request =>
+ {
+ var url = request.RequestUri!.ToString();
+ if (url.Contains(".well-known"))
+ {
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["issuer"] = "https://auth.example.com",
+ ["authorization_endpoint"] = "https://auth.example.com/authorize",
+ ["token_endpoint"] = "https://auth.example.com/token",
+ ["token_endpoint_auth_methods_supported"] = supportedMethods,
+ });
+ }
+
+ if (url.Contains("idp.example.com"))
+ {
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = "mock-jag",
+ ["issued_token_type"] = "urn:ietf:params:oauth:token-type:id-jag",
+ ["token_type"] = "N_A",
+ });
+ }
+
+ Assert.Equal(expectedAuthorizationScheme, request.Headers.Authorization?.Scheme);
+ var body = await request.Content!.ReadAsStringAsync(TestContext.Current.CancellationToken);
+ Assert.Equal(expectSecretInBody, body.Contains("client_secret=mcp-client-secret", StringComparison.Ordinal));
+
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = "final-access-token",
+ ["token_type"] = "Bearer",
+ });
+ };
+
+ var provider = new IdentityAssertionGrantProvider(
+ new IdentityAssertionGrantProviderOptions
+ {
+ ClientId = "mcp-client-id",
+ ClientSecret = "mcp-client-secret",
+ TokenEndpointAuthMethod = configuredMethod,
+ IdpTokenEndpoint = "https://idp.example.com/token",
+ IdpClientId = "idp-client-id",
+ IdTokenCallback = (_, _) => Task.FromResult("mock-id-token"),
+ },
+ _httpClient);
+
+ var tokens = await provider.GetAccessTokenAsync(
+ new Uri("https://resource.example.com"),
+ new Uri("https://auth.example.com"),
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal("final-access-token", tokens.AccessToken);
+ }
+
private static HttpResponseMessage JsonResponse(HttpStatusCode statusCode, JsonObject payload)
{
return new HttpResponseMessage(statusCode)