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 All @@ -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 ?? "<null>"}', redirect_uri: '{_redirectUri}').");
}

using var responseStream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,31 @@ 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>
/// <para>
/// This validation mirrors the OpenID Connect Dynamic Client Registration coupling between
/// <c>application_type</c> and <c>redirect_uris</c>: <c>"web"</c> clients must use remote
/// <c>https</c> redirect URIs, while <c>"native"</c> 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.
/// </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,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,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