Improvements to dynamic client registration (#609)

* Add a delegate for handling the dynamic client registration response.
* Support passing an initial access token for dynamic client registration.
This commit is contained in:
S-Luiten 2025-08-26 23:24:00 +02:00 • committed by GitHub
commit f9129f5b28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 102 additions and 28 deletions

View file

@ -31,9 +31,12 @@ var transport = new SseClientTransport(new()
Name = "Secure Weather Client",
OAuth = new()
{
ClientName = "ProtectedMcpClient",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
DynamicClientRegistration = new()
{
ClientName = "ProtectedMcpClient",
},
}
}, httpClient, consoleLoggerFactory);

View file

@ -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

View file

@ -1,7 +1,7 @@
using Microsoft.Extensions.Logging;
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;
@ -28,9 +28,11 @@ internal sealed partial class ClientOAuthProvider
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
// _clientName and _client URI is used for dynamic client registration (RFC 7591)
private readonly string? _clientName;
private readonly Uri? _clientUri;
// _dcrClientName, _dcrClientUri, _dcrInitialAccessToken and _dcrResponseDelegate are used for dynamic client registration (RFC 7591)
private readonly string? _dcrClientName;
private readonly Uri? _dcrClientUri;
private readonly string? _dcrInitialAccessToken;
private readonly Func<DynamicClientRegistrationResponse, CancellationToken, Task>? _dcrResponseDelegate;
private readonly HttpClient _httpClient;
private readonly ILogger _logger;
@ -66,9 +68,7 @@ internal sealed partial class 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;
@ -77,6 +77,11 @@ internal sealed partial class ClientOAuthProvider
// Set up authorization URL handler (use default if not provided)
_authorizationRedirectDelegate = options.AuthorizationRedirectDelegate ?? DefaultAuthorizationUrlHandler;
_dcrClientName = options.DynamicClientRegistration?.ClientName;
_dcrClientUri = options.DynamicClientRegistration?.ClientUri;
_dcrInitialAccessToken = options.DynamicClientRegistration?.InitialAccessToken;
_dcrResponseDelegate = options.DynamicClientRegistration?.ResponseDelegate;
}
/// <summary>
@ -447,8 +452,8 @@ internal sealed partial class ClientOAuthProvider
GrantTypes = ["authorization_code", "refresh_token"],
ResponseTypes = ["code"],
TokenEndpointAuthMethod = "client_secret_post",
ClientName = _clientName,
ClientUri = _clientUri?.ToString(),
ClientName = _dcrClientName,
ClientUri = _dcrClientUri?.ToString(),
Scope = _scopes is not null ? string.Join(" ", _scopes) : null
};
@ -460,6 +465,11 @@ internal sealed partial class ClientOAuthProvider
Content = requestContent
};
if (!string.IsNullOrEmpty(_dcrInitialAccessToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue(BearerScheme, _dcrInitialAccessToken);
}
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!httpResponse.IsSuccessStatusCode)
@ -487,6 +497,11 @@ internal sealed partial class ClientOAuthProvider
}
LogDynamicClientRegistrationSuccessful(_clientId!);
if (_dcrResponseDelegate is not null)
{
await _dcrResponseDelegate(registrationResponse, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>

View file

@ -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 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 Func<DynamicClientRegistrationResponse, CancellationToken, Task>? ResponseDelegate { get; set; }
}

View file

@ -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.

View file

@ -140,6 +140,8 @@ public class AuthEventTests : KestrelInMemoryTest, IAsyncDisposable
await app.StartAsync(TestContext.Current.CancellationToken);
DynamicClientRegistrationResponse? dcrResponse = null;
await using var transport = new SseClientTransport(
new()
{
@ -148,9 +150,17 @@ public class AuthEventTests : KestrelInMemoryTest, IAsyncDisposable
{
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"),
ResponseDelegate = (response, cancellationToken) =>
{
dcrResponse = response;
return Task.CompletedTask;
},
},
},
},
HttpClient,
@ -162,6 +172,10 @@ public class AuthEventTests : KestrelInMemoryTest, IAsyncDisposable
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken
);
Assert.NotNull(dcrResponse);
Assert.False(string.IsNullOrEmpty(dcrResponse.ClientId));
Assert.False(string.IsNullOrEmpty(dcrResponse.ClientSecret));
}
[Fact]

View file

@ -181,9 +181,12 @@ public class AuthTests : KestrelInMemoryTest, IAsyncDisposable
{
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"),
},
},
}, HttpClient, LoggerFactory);