diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index c3b1af736..305e8b5f7 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -34,10 +34,11 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate; private readonly Uri? _clientMetadataDocumentUri; - // _dcrClientName, _dcrClientUri, _dcrInitialAccessToken and _dcrResponseDelegate are used for dynamic client registration (RFC 7591) + // _dcrClientName, _dcrClientUri, _dcrInitialAccessToken, _dcrConfiguredApplicationType and _dcrResponseDelegate are used for dynamic client registration (RFC 7591) private readonly string? _dcrClientName; private readonly Uri? _dcrClientUri; private readonly string? _dcrInitialAccessToken; + private readonly string? _dcrConfiguredApplicationType; private readonly Func? _dcrResponseDelegate; private readonly HttpClient _httpClient; @@ -99,6 +100,7 @@ public ClientOAuthProvider( _dcrClientUri = options.DynamicClientRegistration?.ClientUri; _dcrInitialAccessToken = options.DynamicClientRegistration?.InitialAccessToken; _dcrResponseDelegate = options.DynamicClientRegistration?.ResponseDelegate; + _dcrConfiguredApplicationType = options.DynamicClientRegistration?.ApplicationType; _tokenCache = options.TokenCache ?? new InMemoryTokenCache(); } @@ -677,6 +679,7 @@ private async Task PerformDynamicClientRegistrationAsync( LogPerformingDynamicClientRegistration(authServerMetadata.RegistrationEndpoint); + var dcrApplicationType = ResolveApplicationType(_dcrConfiguredApplicationType, _redirectUri); var registrationRequest = new DynamicClientRegistrationRequest { RedirectUris = [_redirectUri.ToString()], @@ -686,6 +689,7 @@ private async Task PerformDynamicClientRegistrationAsync( ClientName = _dcrClientName, ClientUri = _dcrClientUri?.ToString(), Scope = ComputeEffectiveScope(protectedResourceMetadata, authServerMetadata), + ApplicationType = dcrApplicationType, }; var requestBytes = JsonSerializer.SerializeToUtf8Bytes(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest); @@ -707,7 +711,9 @@ private async Task PerformDynamicClientRegistrationAsync( if (!httpResponse.IsSuccessStatusCode) { var errorContent = await httpResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - ThrowFailedToHandleUnauthorizedResponse($"Dynamic client registration failed with status {httpResponse.StatusCode}: {errorContent}"); + ThrowFailedToHandleUnauthorizedResponse( + $"Dynamic client registration failed with status {httpResponse.StatusCode}: {errorContent} " + + $"(application_type: '{dcrApplicationType}', redirect_uri: '{_redirectUri}')."); } using var responseStream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); @@ -741,6 +747,18 @@ private async Task PerformDynamicClientRegistrationAsync( } } + private static string ResolveApplicationType(string? configuredApplicationType, Uri redirectUri) + => configuredApplicationType ?? InferApplicationType(redirectUri); + + private static string InferApplicationType(Uri redirectUri) + { + if (redirectUri.Scheme is "http" or "https") + { + return redirectUri.IsLoopback ? "native" : "web"; + } + return "native"; + } + private static string? GetResourceUri(ProtectedResourceMetadata protectedResourceMetadata) => protectedResourceMetadata.Resource; diff --git a/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationOptions.cs b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationOptions.cs index 5d145a568..88db6bd45 100644 --- a/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationOptions.cs @@ -34,6 +34,24 @@ public sealed class DynamicClientRegistrationOptions /// public string? InitialAccessToken { get; set; } + /// + /// Gets or sets the OIDC application_type sent during dynamic client registration. + /// + /// + /// + /// When , the SDK infers the value from the configured + /// : loopback hosts (localhost, + /// 127.0.0.1, [::1]) and custom-scheme URIs map to "native"; remote + /// http:// and https:// URIs map to "web". + /// + /// + /// When set explicitly, the value is sent without modification. Use this to account for + /// authorization-server-specific requirements or to retry a registration with an adjusted + /// application type. + /// + /// + public string? ApplicationType { get; set; } + /// /// Gets or sets the delegate used for handling the dynamic client registration response. /// diff --git a/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationRequest.cs b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationRequest.cs index 8496610e7..6ad8bf2c1 100644 --- a/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationRequest.cs +++ b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationRequest.cs @@ -48,4 +48,10 @@ internal sealed class DynamicClientRegistrationRequest /// [JsonPropertyName("scope")] public string? Scope { get; init; } + + /// + /// Gets or sets the OIDC application type ("native" or "web") for the client. + /// + [JsonPropertyName("application_type")] + public string? ApplicationType { get; init; } } \ No newline at end of file diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index e49cb5bf5..c4264d29e 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -109,6 +109,33 @@ public async Task CanAuthenticate_WithDynamicClientRegistration() await using var client = await McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("native", TestOAuthServer.LastApplicationType); + } + + [Fact] + public async Task DynamicClientRegistration_UsesExplicitApplicationType() + { + await using var app = await StartMcpServerAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new ClientOAuthOptions() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + DynamicClientRegistration = new() + { + ApplicationType = "web", + }, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("web", TestOAuthServer.LastApplicationType); } [Fact] @@ -123,7 +150,11 @@ public async Task CanAuthenticate_WithClientMetadataDocument() { RedirectUri = new Uri("http://localhost:1179/callback"), AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, - ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl) + ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl), + DynamicClientRegistration = new() + { + ApplicationType = "web", + }, }, }, HttpClient, LoggerFactory); @@ -178,6 +209,10 @@ public async Task DoesNotUseClientMetadataDocument_WhenClientIdIsSpecified() RedirectUri = new Uri("http://localhost:1179/callback"), AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"), + DynamicClientRegistration = new() + { + ApplicationType = "web", + }, }, }, HttpClient, LoggerFactory); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/DcrFailureTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/DcrFailureTests.cs new file mode 100644 index 000000000..9a4bd0aa9 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/DcrFailureTests.cs @@ -0,0 +1,92 @@ +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Client; + +namespace ModelContextProtocol.AspNetCore.Tests.OAuth; + +// SEP-837: the SDK doesn't surface or retry DCR failures itself, but a consumer built on the SDK +// must be able to. These tests prove that surface: a rejected registration propagates with enough +// context to build a meaningful error, and a consumer can retry with an adjusted redirect URI. +public class DcrFailureTests : OAuthTestBase +{ + public DcrFailureTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + [Fact] + public async Task DcrRejection_PropagatesToConsumer_WithStatusBodyAndSentParameters() + { + await using var app = await StartMcpServerAsync(); + + // A custom-scheme redirect URI infers application_type "native"; the OIDC AS rejects it + // with 400 invalid_redirect_uri because it only registers http/https redirect URIs. + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new ClientOAuthOptions() + { + RedirectUri = new Uri("myapp://callback"), + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + DynamicClientRegistration = new() { ClientName = "Test MCP Client" }, + }, + }, HttpClient, LoggerFactory); + + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + // The consumer needs enough to produce a meaningful error: the HTTP status, the AS error + // body (which echoes the redirect URI), and the application_type the SDK actually sent. + Assert.Contains("BadRequest", ex.Message); + Assert.Contains("invalid_redirect_uri", ex.Message); + Assert.Contains("native", ex.Message); + } + + [Fact] + public async Task ConsumerCanRetryRegistration_WithAdjustedRedirectUri_AfterRejection() + { + await using var app = await StartMcpServerAsync(); + + // First attempt: a custom-scheme redirect (native) is rejected by the AS. ApplicationType + // is held constant at "native" so only the redirect URI changes between the two attempts. + await using var firstTransport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new ClientOAuthOptions() + { + RedirectUri = new Uri("myapp://callback"), + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + DynamicClientRegistration = new() + { + ClientName = "Test MCP Client", + ApplicationType = "native", + }, + }, + }, HttpClient, LoggerFactory); + + await Assert.ThrowsAsync(() => McpClient.CreateAsync( + firstTransport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + // Second attempt: a new provider on the SAME HttpClient with an adjusted (loopback) redirect + // URI that the AS accepts. The retry must succeed, proving the SEP-837 MAY-retry surface works + // and that the rejected attempt left no client state behind. + await using var secondTransport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new ClientOAuthOptions() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + DynamicClientRegistration = new() + { + ClientName = "Test MCP Client", + ApplicationType = "native", + }, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + secondTransport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("native", TestOAuthServer.LastApplicationType); + } +} diff --git a/tests/ModelContextProtocol.TestOAuthServer/ClientRegistrationRequest.cs b/tests/ModelContextProtocol.TestOAuthServer/ClientRegistrationRequest.cs index 50592bbea..1e9c9fc07 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/ClientRegistrationRequest.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/ClientRegistrationRequest.cs @@ -55,6 +55,12 @@ internal sealed class ClientRegistrationRequest [JsonPropertyName("scope")] public string? Scope { get; init; } + /// + /// Gets or sets the OIDC application type ("web" or "native"). + /// + [JsonPropertyName("application_type")] + public string? ApplicationType { get; init; } + /// /// Gets or sets the contacts for the client. /// diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index 69eb60683..29da976ee 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -106,6 +106,9 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor /// Gets the scope field from the most recent Dynamic Client Registration request. public string? LastRegistrationScope { get; private set; } + /// Gets the application_type field from the most recent Dynamic Client Registration request. + public string? LastApplicationType { get; private set; } + /// /// Entry point for the application. /// @@ -665,6 +668,7 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) } LastRegistrationScope = registrationRequest.Scope; + LastApplicationType = registrationRequest.ApplicationType; // Validate redirect URIs are provided if (registrationRequest.RedirectUris.Count == 0) diff --git a/tests/ModelContextProtocol.Tests/Authentication/ClientOAuthProviderApplicationTypeTests.cs b/tests/ModelContextProtocol.Tests/Authentication/ClientOAuthProviderApplicationTypeTests.cs new file mode 100644 index 000000000..9c38963eb --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Authentication/ClientOAuthProviderApplicationTypeTests.cs @@ -0,0 +1,41 @@ +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Client; + +namespace ModelContextProtocol.Tests.Authentication; + +// ClientOAuthProvider is internal; construct it indirectly via HttpClientTransport +// to verify DCR application_type validation is deferred until DCR is selected. +public class ClientOAuthProviderApplicationTypeTests +{ + private static readonly Uri ServerEndpoint = new("https://server.example.com/mcp"); + + private static HttpClientTransportOptions BuildOptions(string redirectUri, string? explicitApplicationType = null) + { + return new HttpClientTransportOptions + { + Endpoint = ServerEndpoint, + OAuth = new ClientOAuthOptions + { + RedirectUri = new Uri(redirectUri), + DynamicClientRegistration = new DynamicClientRegistrationOptions + { + ApplicationType = explicitApplicationType, + }, + }, + }; + } + + [Theory] + [InlineData("http://localhost:8080/callback", "web")] + [InlineData("https://example.com/callback", "native")] + public void Constructor_Defers_ApplicationTypeValidation_UntilDynamicRegistration( + string redirectUri, string explicitType) + { + var options = BuildOptions(redirectUri, explicitType); + + using var httpClient = new HttpClient(); + _ = new HttpClientTransport(options, httpClient); + + Assert.Equal(explicitType, options.OAuth!.DynamicClientRegistration!.ApplicationType); + } +}