Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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