diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 8dbd3c394..c823524f7 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, _dcrApplicationType 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? _dcrApplicationType; private readonly Func? _dcrResponseDelegate; private readonly HttpClient _httpClient; @@ -91,6 +92,7 @@ public ClientOAuthProvider( _dcrClientUri = options.DynamicClientRegistration?.ClientUri; _dcrInitialAccessToken = options.DynamicClientRegistration?.InitialAccessToken; _dcrResponseDelegate = options.DynamicClientRegistration?.ResponseDelegate; + _dcrApplicationType = ResolveApplicationType(options, _redirectUri); _tokenCache = options.TokenCache ?? new InMemoryTokenCache(); } @@ -656,6 +658,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); @@ -677,7 +680,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); @@ -711,6 +716,34 @@ private async Task PerformDynamicClientRegistrationAsync( } } + private static string? ResolveApplicationType(ClientOAuthOptions options, Uri redirectUri) + { + var explicitType = options.DynamicClientRegistration?.ApplicationType; + var inferredType = InferApplicationType(redirectUri); + + if (explicitType is not null && + inferredType is not null && + !string.Equals(explicitType, inferredType, StringComparison.Ordinal)) + { + throw new ArgumentException( + $"DynamicClientRegistrationOptions.ApplicationType \"{explicitType}\" conflicts with the type inferred from the redirect URI (\"{inferredType}\").", + nameof(options)); + } + + var resolved = explicitType ?? inferredType; + options.DynamicClientRegistration?.ApplicationType = resolved; + return resolved; + } + + 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..e62c10cef 100644 --- a/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationOptions.cs @@ -34,6 +34,31 @@ 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 + /// https:// URIs map to "web". + /// + /// + /// When set explicitly, the value is validated against the inferred type. A conflicting + /// explicit value (for example "web" with a localhost redirect URI) causes the + /// constructor to throw . + /// + /// + /// This validation mirrors the OpenID Connect Dynamic Client Registration coupling between + /// application_type and redirect_uris: "web" clients must use remote + /// https redirect URIs, while "native" clients must use loopback or custom-scheme + /// URIs. A conflicting combination is therefore rejected rather than sent, since a conformant + /// authorization server would reject the registration anyway. + /// + /// + 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/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..aa81b3dc0 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Authentication/ClientOAuthProviderApplicationTypeTests.cs @@ -0,0 +1,71 @@ +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Client; + +namespace ModelContextProtocol.Tests.Authentication; + +// ClientOAuthProvider is internal; construct it indirectly via HttpClientTransport +// so we can observe the application_type value mutated back onto the options. +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", "native")] + [InlineData("http://127.0.0.1:8080/callback", "native")] + [InlineData("http://[::1]:8080/callback", "native")] + [InlineData("myapp://callback", "native")] + [InlineData("https://example.com/callback", "web")] + public void Constructor_Infers_ApplicationType_From_RedirectUri(string redirectUri, string expected) + { + var options = BuildOptions(redirectUri); + + using var httpClient = new HttpClient(); + _ = new HttpClientTransport(options, httpClient); + + Assert.Equal(expected, options.OAuth!.DynamicClientRegistration!.ApplicationType); + } + + [Theory] + [InlineData("http://localhost:8080/callback", "native")] + [InlineData("https://example.com/callback", "web")] + public void Constructor_Preserves_Explicit_ApplicationType_When_It_Matches_Inferred(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); + } + + [Theory] + [InlineData("http://localhost:8080/callback", "web")] + [InlineData("https://example.com/callback", "native")] + public void Constructor_Throws_When_Explicit_ApplicationType_Conflicts_With_Inferred( + string redirectUri, string explicitType) + { + var options = BuildOptions(redirectUri, explicitType); + + using var httpClient = new HttpClient(); + var ex = Assert.Throws(() => new HttpClientTransport(options, httpClient)); + + Assert.Contains("conflicts with the type inferred from the redirect URI", ex.Message); + Assert.Equal("options", ex.ParamName); + } +}