Enable additional client conformance scenarios (#1763)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Stephen Halter 2026-07-28 13:39:26 -07:00 • committed by GitHub
commit e8b80f0e6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 971 additions and 44 deletions

View file

@ -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)
{

View file

@ -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<McpException>(() => 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()
{

View file

@ -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<string> 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<string, string> 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<string> 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<OAuthDiscovery> 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<string> 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<Uri> 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<Uri> 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<JsonElement> 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<string> 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<string> Scopes);
}

View file

@ -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

View file

@ -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<ArgumentException>(() => 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<ArgumentException>(() => 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)