Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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<DynamicClientRegistrationResponse, CancellationToken, Task>? _dcrResponseDelegate;

private readonly HttpClient _httpClient;
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ public sealed class DynamicClientRegistrationOptions
/// </remarks>
public string? InitialAccessToken { get; set; }

/// <summary>
/// Gets or sets the OIDC <c>application_type</c> sent during dynamic client registration.
/// </summary>
/// <remarks>
/// <para>
/// When <see langword="null"/>, the SDK infers the value from the configured
/// <see cref="ClientOAuthOptions.RedirectUri"/>: loopback hosts (<c>localhost</c>,
/// <c>127.0.0.1</c>, <c>[::1]</c>) and custom-scheme URIs map to <c>"native"</c>; remote
/// <c>https://</c> URIs map to <c>"web"</c>.
/// </para>
/// <para>
/// When set explicitly, the value is validated against the inferred type. A conflicting
/// explicit value (for example <c>"web"</c> with a localhost redirect URI) causes the
/// <see cref="ClientOAuthProvider"/> constructor to throw <see cref="ArgumentException"/>.
/// </para>
/// </remarks>
public string? ApplicationType { get; set; }

/// <summary>
/// Gets or sets the delegate used for handling the dynamic client registration response.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,10 @@ internal sealed class DynamicClientRegistrationRequest
/// </summary>
[JsonPropertyName("scope")]
public string? Scope { get; init; }

/// <summary>
/// Gets or sets the OIDC application type ("native" or "web") for the client.
/// </summary>
[JsonPropertyName("application_type")]
public string? ApplicationType { get; init; }
}
Original file line number Diff line number Diff line change
@@ -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<ArgumentException>(() => new HttpClientTransport(options, httpClient));

Assert.Contains("conflicts with the type inferred from the redirect URI", ex.Message);
Assert.Equal("options", ex.ParamName);
}
}
Loading