diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMCPClient/Program.cs index 516227b3..5871284a 100644 --- a/samples/ProtectedMCPClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -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); diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs index 686316f5..cc6a8952 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs @@ -68,22 +68,12 @@ public sealed class ClientOAuthOptions public Func, Uri?>? AuthServerSelector { get; set; } /// - /// Gets or sets the client name to use during dynamic client registration. + /// Gets or sets the options to use during dynamic client registration. /// /// - /// This is a human-readable name for the client that may be displayed to users during authorization. /// Only used when a is not specified. /// - public string? ClientName { get; set; } - - /// - /// Gets or sets the client URI to use during dynamic client registration. - /// - /// - /// This should be a URL pointing to the client's home page or information page. - /// Only used when a is not specified. - /// - public Uri? ClientUri { get; set; } + public DynamicClientRegistrationOptions? DynamicClientRegistration { get; set; } /// /// Gets or sets additional parameters to include in the query string of the OAuth authorization request diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 96356028..46b5e215 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -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; @@ -27,10 +28,12 @@ internal sealed partial class ClientOAuthProvider private readonly IDictionary _additionalAuthorizationParameters; private readonly Func, 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; @@ -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; @@ -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; + } } /// @@ -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) @@ -483,6 +504,11 @@ private async Task PerformDynamicClientRegistrationAsync( } LogDynamicClientRegistrationSuccessful(_clientId!); + + if (_dynamicClientRegistrationDelegate is not null) + { + await _dynamicClientRegistrationDelegate(registrationResponse, cancellationToken).ConfigureAwait(false); + } } /// diff --git a/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationDelegate.cs b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationDelegate.cs new file mode 100644 index 00000000..a19ffb5d --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationDelegate.cs @@ -0,0 +1,13 @@ + +namespace ModelContextProtocol.Authentication; + +/// +/// Represents a method that handles the dynamic client registration response. +/// +/// The dynamic client registration response containing the client credentials. +/// The cancellation token. +/// A task that represents the asynchronous operation. +/// +/// The implementation should save the client credentials securely for future use. +/// +public delegate Task DynamicClientRegistrationDelegate(DynamicClientRegistrationResponse response, CancellationToken cancellationToken); \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationOptions.cs b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationOptions.cs new file mode 100644 index 00000000..ca687f36 --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationOptions.cs @@ -0,0 +1,49 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Provides configuration options for the related to dynamic client registration (RFC 7591). +/// +public sealed class DynamicClientRegistrationOptions +{ + /// + /// Gets or sets the client name to use during dynamic client registration. + /// + /// + /// This is a human-readable name for the client that may be displayed to users during authorization. + /// + public required string ClientName { get; set; } + + /// + /// Gets or sets the client URI to use during dynamic client registration. + /// + /// + /// This should be a URL pointing to the client's home page or information page. + /// + public Uri? ClientUri { get; set; } + + /// + /// Gets or sets the initial access token to use during dynamic client registration. + /// + /// + /// + /// This token is used to authenticate the client during the registration process. + /// + /// + /// This is required if the authorization server does not allow anonymous client registration. + /// + /// + public string? InitialAccessToken { get; set; } + + /// + /// Gets or sets the delegate used for handling the dynamic client registration response. + /// + /// + /// + /// This delegate is responsible for processing the response from the dynamic client registration endpoint. + /// + /// + /// The implementation should save the client credentials securely for future use. + /// + /// + public DynamicClientRegistrationDelegate? DynamicClientRegistrationDelegate { get; set; } +} \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationResponse.cs b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationResponse.cs index dcd51d68..1dfe1229 100644 --- a/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationResponse.cs +++ b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationResponse.cs @@ -5,7 +5,7 @@ namespace ModelContextProtocol.Authentication; /// /// Represents a client registration response for OAuth 2.0 Dynamic Client Registration (RFC 7591). /// -internal sealed class DynamicClientRegistrationResponse +public sealed class DynamicClientRegistrationResponse { /// /// Gets or sets the client identifier. diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AuthEventTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AuthEventTests.cs index 6a48c21d..782b70db 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/AuthEventTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AuthEventTests.cs @@ -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() { @@ -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, @@ -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] diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AuthTests.cs index 2252b1b7..f91904c6 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AuthTests.cs @@ -174,6 +174,8 @@ public async Task CanAuthenticate_WithDynamicClientRegistration() await app.StartAsync(TestContext.Current.CancellationToken); + DynamicClientRegistrationResponse? dcrResponse = null; + await using var transport = new SseClientTransport(new() { Endpoint = new(McpServerUrl), @@ -181,14 +183,26 @@ public async Task CanAuthenticate_WithDynamicClientRegistration() { 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]