Skip to content
Open
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, _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<DynamicClientRegistrationResponse, CancellationToken, Task>? _dcrResponseDelegate;

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

Expand Down Expand Up @@ -677,6 +679,7 @@ private async Task PerformDynamicClientRegistrationAsync(

LogPerformingDynamicClientRegistration(authServerMetadata.RegistrationEndpoint);

var dcrApplicationType = ResolveApplicationType(_dcrConfiguredApplicationType, _redirectUri);
var registrationRequest = new DynamicClientRegistrationRequest
{
RedirectUris = [_redirectUri.ToString()],
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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;

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>http://</c> and <c>https://</c> URIs map to <c>"web"</c>.
/// </para>
/// <para>
/// 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.
/// </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; }
}
37 changes: 36 additions & 1 deletion tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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);

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

Expand Down
Original file line number Diff line number Diff line change
@@ -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<McpException>(() => 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<McpException>(() => 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ internal sealed class ClientRegistrationRequest
[JsonPropertyName("scope")]
public string? Scope { get; init; }

/// <summary>
/// Gets or sets the OIDC application type ("web" or "native").
/// </summary>
[JsonPropertyName("application_type")]
public string? ApplicationType { get; init; }

/// <summary>
/// Gets or sets the contacts for the client.
/// </summary>
Expand Down
4 changes: 4 additions & 0 deletions tests/ModelContextProtocol.TestOAuthServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor
/// <summary>Gets the <c>scope</c> field from the most recent Dynamic Client Registration request.</summary>
public string? LastRegistrationScope { get; private set; }

/// <summary>Gets the <c>application_type</c> field from the most recent Dynamic Client Registration request.</summary>
public string? LastApplicationType { get; private set; }

/// <summary>
/// Entry point for the application.
/// </summary>
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}