Skip to content

Improvements to dynamic client registration #609

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
5 changes: 4 additions & 1 deletion samples/ProtectedMCPClient/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@
Name = "Secure Weather Client",
OAuth = new()
{
ClientName = "ProtectedMcpClient",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
DynamicClientRegistration = new()
{
ClientName = "ProtectedMcpClient",
},
}
}, httpClient, consoleLoggerFactory);

Expand Down
14 changes: 2 additions & 12 deletions src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,22 +68,12 @@ public sealed class ClientOAuthOptions
public Func<IReadOnlyList<Uri>, Uri?>? AuthServerSelector { get; set; }

/// <summary>
/// Gets or sets the client name to use during dynamic client registration.
/// Gets or sets the options to use during dynamic client registration.
/// </summary>
/// <remarks>
/// This is a human-readable name for the client that may be displayed to users during authorization.
/// Only used when a <see cref="ClientId"/> is not specified.
/// </remarks>
public string? ClientName { get; set; }

/// <summary>
/// Gets or sets the client URI to use during dynamic client registration.
/// </summary>
/// <remarks>
/// This should be a URL pointing to the client's home page or information page.
/// Only used when a <see cref="ClientId"/> is not specified.
/// </remarks>
public Uri? ClientUri { get; set; }
public DynamicClientRegistrationOptions? DynamicClientRegistration { get; set; }

/// <summary>
/// Gets or sets additional parameters to include in the query string of the OAuth authorization request
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Microsoft.Extensions.Logging.Abstractions;
using System.Collections.Specialized;
using System.Diagnostics.CodeAnalysis;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
Expand All @@ -27,10 +28,12 @@ internal sealed partial class ClientOAuthProvider
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
private readonly DynamicClientRegistrationDelegate? _dynamicClientRegistrationDelegate;

// _clientName and _client URI is used for dynamic client registration (RFC 7591)
// _clientName, _clientUri, and _initialAccessToken is used for dynamic client registration (RFC 7591)
private readonly string? _clientName;
private readonly Uri? _clientUri;
private readonly string? _initialAccessToken;

private readonly HttpClient _httpClient;
private readonly ILogger _logger;
Expand Down Expand Up @@ -66,9 +69,7 @@ public ClientOAuthProvider(

_clientId = options.ClientId;
_clientSecret = options.ClientSecret;
_redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.");
_clientName = options.ClientName;
_clientUri = options.ClientUri;
_redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options));
_scopes = options.Scopes?.ToArray();
_additionalAuthorizationParameters = options.AdditionalAuthorizationParameters;

Expand All @@ -77,6 +78,21 @@ public ClientOAuthProvider(

// Set up authorization URL handler (use default if not provided)
_authorizationRedirectDelegate = options.AuthorizationRedirectDelegate ?? DefaultAuthorizationUrlHandler;

if (string.IsNullOrEmpty(_clientId))
{
if (options.DynamicClientRegistration is null)
{
throw new ArgumentException("ClientOAuthOptions.DynamicClientRegistration must be configured when ClientId is not set.", nameof(options));
}

_clientName = options.DynamicClientRegistration.ClientName;
_clientUri = options.DynamicClientRegistration.ClientUri;
_initialAccessToken = options.DynamicClientRegistration.InitialAccessToken;

// Set up dynamic client registration delegate
_dynamicClientRegistrationDelegate = options.DynamicClientRegistration.DynamicClientRegistrationDelegate;
}
}

/// <summary>
Expand Down Expand Up @@ -456,6 +472,11 @@ private async Task PerformDynamicClientRegistrationAsync(
Content = requestContent
};

if (!string.IsNullOrEmpty(_initialAccessToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue(BearerScheme, _initialAccessToken);
}

using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);

if (!httpResponse.IsSuccessStatusCode)
Expand Down Expand Up @@ -483,6 +504,11 @@ private async Task PerformDynamicClientRegistrationAsync(
}

LogDynamicClientRegistrationSuccessful(_clientId!);

if (_dynamicClientRegistrationDelegate is not null)
{
await _dynamicClientRegistrationDelegate(registrationResponse, cancellationToken).ConfigureAwait(false);
}
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@

namespace ModelContextProtocol.Authentication;

/// <summary>
/// Represents a method that handles the dynamic client registration response.
/// </summary>
/// <param name="response">The dynamic client registration response containing the client credentials.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <remarks>
/// The implementation should save the client credentials securely for future use.
/// </remarks>
public delegate Task DynamicClientRegistrationDelegate(DynamicClientRegistrationResponse response, CancellationToken cancellationToken);
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
namespace ModelContextProtocol.Authentication;

/// <summary>
/// Provides configuration options for the <see cref="ClientOAuthProvider"/> related to dynamic client registration (RFC 7591).
/// </summary>
public sealed class DynamicClientRegistrationOptions
{
/// <summary>
/// Gets or sets the client name to use during dynamic client registration.
/// </summary>
/// <remarks>
/// This is a human-readable name for the client that may be displayed to users during authorization.
/// </remarks>
public required string ClientName { get; set; }

/// <summary>
/// Gets or sets the client URI to use during dynamic client registration.
/// </summary>
/// <remarks>
/// This should be a URL pointing to the client's home page or information page.
/// </remarks>
public Uri? ClientUri { get; set; }

/// <summary>
/// Gets or sets the initial access token to use during dynamic client registration.
/// </summary>
/// <remarks>
/// <para>
/// This token is used to authenticate the client during the registration process.
/// </para>
/// <para>
/// This is required if the authorization server does not allow anonymous client registration.
/// </para>
/// </remarks>
public string? InitialAccessToken { get; set; }

/// <summary>
/// Gets or sets the delegate used for handling the dynamic client registration response.
/// </summary>
/// <remarks>
/// <para>
/// This delegate is responsible for processing the response from the dynamic client registration endpoint.
/// </para>
/// <para>
/// The implementation should save the client credentials securely for future use.
/// </para>
/// </remarks>
public DynamicClientRegistrationDelegate? DynamicClientRegistrationDelegate { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace ModelContextProtocol.Authentication;
/// <summary>
/// Represents a client registration response for OAuth 2.0 Dynamic Client Registration (RFC 7591).
/// </summary>
internal sealed class DynamicClientRegistrationResponse
public sealed class DynamicClientRegistrationResponse
{
/// <summary>
/// Gets or sets the client identifier.
Expand Down
18 changes: 16 additions & 2 deletions tests/ModelContextProtocol.AspNetCore.Tests/AuthEventTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ public async Task CanAuthenticate_WithDynamicClientRegistration_FromEvent()

await app.StartAsync(TestContext.Current.CancellationToken);

DynamicClientRegistrationResponse? dcrResponse = null;

await using var transport = new SseClientTransport(
new()
{
Expand All @@ -148,9 +150,17 @@ public async Task CanAuthenticate_WithDynamicClientRegistration_FromEvent()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
ClientName = "Test MCP Client",
ClientUri = new Uri("https://example.com"),
Scopes = ["mcp:tools"],
DynamicClientRegistration = new()
{
ClientName = "Test MCP Client",
ClientUri = new Uri("https://example.com"),
DynamicClientRegistrationDelegate = (response, cancellationToken) =>
{
dcrResponse = response;
return Task.CompletedTask;
},
},
},
},
HttpClient,
Expand All @@ -162,6 +172,10 @@ public async Task CanAuthenticate_WithDynamicClientRegistration_FromEvent()
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken
);

Assert.NotNull(dcrResponse);
Assert.False(string.IsNullOrEmpty(dcrResponse.ClientId));
Assert.False(string.IsNullOrEmpty(dcrResponse.ClientSecret));
}

[Fact]
Expand Down
20 changes: 17 additions & 3 deletions tests/ModelContextProtocol.AspNetCore.Tests/AuthTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,21 +174,35 @@ public async Task CanAuthenticate_WithDynamicClientRegistration()

await app.StartAsync(TestContext.Current.CancellationToken);

DynamicClientRegistrationResponse? dcrResponse = null;

await using var transport = new SseClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
ClientName = "Test MCP Client",
ClientUri = new Uri("https://example.com"),
Scopes = ["mcp:tools"]
Scopes = ["mcp:tools"],
DynamicClientRegistration = new()
{
ClientName = "Test MCP Client",
ClientUri = new Uri("https://example.com"),
DynamicClientRegistrationDelegate = (response, cancellationToken) =>
{
dcrResponse = response;
return Task.CompletedTask;
},
},
},
}, HttpClient, LoggerFactory);

await using var client = await McpClientFactory.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);

Assert.NotNull(dcrResponse);
Assert.False(string.IsNullOrEmpty(dcrResponse.ClientId));
Assert.False(string.IsNullOrEmpty(dcrResponse.ClientSecret));
}

[Fact]
Expand Down
Loading