Skip to content
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