From cd9ba60c89c5df53b9713a4481311b74e31daa3f Mon Sep 17 00:00:00 2001 From: Jayaraman Venkatesan <112980436+jayaraman-venkatesan@users.noreply.github.com> Date: Fri, 29 May 2026 17:13:45 -0400 Subject: [PATCH] feature/sep-837-application-type: infer DCR application_type from redirect URI --- .../Authentication/ClientOAuthProvider.cs | 33 ++++++++- .../DynamicClientRegistrationOptions.cs | 18 +++++ .../DynamicClientRegistrationRequest.cs | 6 ++ ...ClientOAuthProviderApplicationTypeTests.cs | 71 +++++++++++++++++++ 4 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 tests/ModelContextProtocol.Tests/Authentication/ClientOAuthProviderApplicationTypeTests.cs diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 662e436eb..7d2e5de5a 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); @@ -711,6 +714,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..e53231cde 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 + /// 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 . + /// + /// + 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.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); + } +}