Skip to content

Commit d747e72

Browse files
committed
Add Dynamic Client Registration support following RFC 7591
1 parent 5b20710 commit d747e72

13 files changed

Lines changed: 632 additions & 35 deletions

File tree

samples/ProtectedMCPServer/Properties/launchSettings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"environmentVariables": {
77
"ASPNETCORE_ENVIRONMENT": "Development"
88
},
9-
"applicationUrl": "http://localhost:7029"
9+
"applicationUrl": "http://localhost:7071"
1010
}
1111
}
1212
}

src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ namespace ModelContextProtocol.Authentication;
66
public sealed class ClientOAuthOptions
77
{
88
/// <summary>
9-
/// Gets or sets the OAuth client ID.
9+
/// Gets or sets the OAuth redirect URI.
1010
/// </summary>
11-
public required string ClientId { get; set; }
11+
public required Uri RedirectUri { get; set; }
1212

1313
/// <summary>
14-
/// Gets or sets the OAuth redirect URI.
14+
/// Gets or sets the OAuth client ID. If not provided, the client will attempt to register dynamically.
1515
/// </summary>
16-
public required Uri RedirectUri { get; set; }
16+
public string? ClientId { get; set; }
1717

1818
/// <summary>
1919
/// Gets or sets the OAuth client secret.
@@ -66,4 +66,22 @@ public sealed class ClientOAuthOptions
6666
/// </para>
6767
/// </remarks>
6868
public Func<IReadOnlyList<Uri>, Uri?>? AuthServerSelector { get; set; }
69+
70+
/// <summary>
71+
/// Gets or sets the client name to use during dynamic client registration.
72+
/// </summary>
73+
/// <remarks>
74+
/// This is a human-readable name for the client that may be displayed to users during authorization.
75+
/// Only used when a <see cref="ClientId"/> is not specified.
76+
/// </remarks>
77+
public string? ClientName { get; set; }
78+
79+
/// <summary>
80+
/// Gets or sets the client URI to use during dynamic client registration.
81+
/// </summary>
82+
/// <remarks>
83+
/// This should be a URL pointing to the client's home page or information page.
84+
/// Only used when a <see cref="Client"/> is not specified.
85+
/// </remarks>
86+
public Uri? ClientUri { get; set; }
6987
}

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 106 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ internal sealed class ClientOAuthProvider
2424
private readonly Uri _serverUrl;
2525
private readonly Uri _redirectUri;
2626
private readonly string[]? _scopes;
27-
private readonly string _clientId;
28-
private readonly string? _clientSecret;
27+
private string? _clientId;
28+
private string? _clientSecret;
2929
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
3030
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
3131

@@ -34,6 +34,7 @@ internal sealed class ClientOAuthProvider
3434

3535
private TokenContainer? _token;
3636
private AuthorizationServerMetadata? _authServerMetadata;
37+
private readonly ClientOAuthOptions _options;
3738

3839
/// <summary>
3940
/// Initializes a new instance of the <see cref="ClientOAuthProvider"/> class using the specified options.
@@ -49,20 +50,18 @@ public ClientOAuthProvider(
4950
HttpClient? httpClient = null,
5051
ILoggerFactory? loggerFactory = null)
5152
{
52-
_serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl));
53+
_serverUrl = serverUrl;
5354
_httpClient = httpClient ?? new HttpClient();
5455
_logger = (ILogger?)loggerFactory?.CreateLogger<ClientOAuthProvider>() ?? NullLogger.Instance;
5556

56-
if (options is null)
57-
{
58-
throw new ArgumentNullException(nameof(options));
59-
}
60-
6157
_clientId = options.ClientId;
6258
_redirectUri = options.RedirectUri;
6359
_clientSecret = options.ClientSecret;
6460
_scopes = options.Scopes?.ToArray();
6561

62+
// Store options for potential dynamic registration
63+
_options = options;
64+
6665
// Set up authorization server selection strategy
6766
_authServerSelector = options.AuthServerSelector ?? DefaultAuthServerSelector;
6867

@@ -94,10 +93,29 @@ public ClientOAuthProvider(
9493
return Task.FromResult<string?>(authorizationCode);
9594
}
9695

97-
/// <inheritdoc />
96+
/// <summary>
97+
/// Gets the collection of authentication schemes supported by this provider.
98+
/// </summary>
99+
/// <remarks>
100+
/// <para>
101+
/// This property returns all authentication schemes that this provider can handle,
102+
/// allowing clients to select the appropriate scheme based on server capabilities.
103+
/// </para>
104+
/// <para>
105+
/// Common values include "Bearer" for JWT tokens, "Basic" for username/password authentication,
106+
/// and "Negotiate" for integrated Windows authentication.
107+
/// </para>
108+
/// </remarks>
98109
public IEnumerable<string> SupportedSchemes => [BearerScheme];
99110

100-
/// <inheritdoc />
111+
/// <summary>
112+
/// Gets an authentication token or credential for authenticating requests to a resource
113+
/// using the specified authentication scheme.
114+
/// </summary>
115+
/// <param name="scheme">The authentication scheme to use.</param>
116+
/// <param name="resourceUri">The URI of the resource requiring authentication.</param>
117+
/// <param name="cancellationToken">A token to cancel the operation.</param>
118+
/// <returns>An authentication token string or null if no token could be obtained for the specified scheme.</returns>
101119
public async Task<string?> GetCredentialAsync(string scheme, Uri resourceUri, CancellationToken cancellationToken = default)
102120
{
103121
ThrowIfNotBearerScheme(scheme);
@@ -125,7 +143,16 @@ public ClientOAuthProvider(
125143
return null;
126144
}
127145

128-
/// <inheritdoc />
146+
/// <summary>
147+
/// Handles a 401 Unauthorized response from a resource.
148+
/// </summary>
149+
/// <param name="scheme">The authentication scheme that was used when the unauthorized response was received.</param>
150+
/// <param name="response">The HTTP response that contained the 401 status code.</param>
151+
/// <param name="cancellationToken">A token to cancel the operation.</param>
152+
/// <returns>
153+
/// A result object indicating if the provider was able to handle the unauthorized response,
154+
/// and the authentication scheme that should be used for the next attempt, if any.
155+
/// </returns>
129156
public async Task HandleUnauthorizedResponseAsync(
130157
string scheme,
131158
HttpResponseMessage response,
@@ -185,6 +212,12 @@ private async Task PerformOAuthAuthorizationAsync(
185212
// Store auth server metadata for future refresh operations
186213
_authServerMetadata = authServerMetadata;
187214

215+
// Perform dynamic client registration if needed
216+
if (string.IsNullOrEmpty(_clientId))
217+
{
218+
await PerformDynamicClientRegistrationAsync(authServerMetadata, cancellationToken).ConfigureAwait(false);
219+
}
220+
188221
// Perform the OAuth flow
189222
var token = await InitiateAuthorizationCodeFlowAsync(protectedResourceMetadata, authServerMetadata, cancellationToken).ConfigureAwait(false);
190223

@@ -242,7 +275,7 @@ private async Task<TokenContainer> RefreshTokenAsync(string refreshToken, Author
242275
{
243276
["grant_type"] = "refresh_token",
244277
["refresh_token"] = refreshToken,
245-
["client_id"] = _clientId
278+
["client_id"] = GetClientIdOrThrow(),
246279
});
247280

248281
using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.TokenEndpoint)
@@ -322,8 +355,8 @@ private async Task<TokenContainer> ExchangeCodeForTokenAsync(
322355
["grant_type"] = "authorization_code",
323356
["code"] = authorizationCode,
324357
["redirect_uri"] = _redirectUri.ToString(),
325-
["client_id"] = _clientId,
326-
["code_verifier"] = codeVerifier
358+
["client_id"] = GetClientIdOrThrow(),
359+
["code_verifier"] = codeVerifier,
327360
});
328361

329362
using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.TokenEndpoint)
@@ -372,6 +405,60 @@ private async Task<TokenContainer> FetchTokenAsync(HttpRequestMessage request, C
372405
return await JsonSerializer.DeserializeAsync(stream, McpJsonUtilities.JsonContext.Default.ProtectedResourceMetadata, cancellationToken).ConfigureAwait(false);
373406
}
374407

408+
private async Task PerformDynamicClientRegistrationAsync(
409+
AuthorizationServerMetadata authServerMetadata,
410+
CancellationToken cancellationToken)
411+
{
412+
if (authServerMetadata.RegistrationEndpoint is null)
413+
{
414+
ThrowFailedToHandleUnauthorizedResponse("Authorization server does not support dynamic client registration");
415+
}
416+
417+
_logger.LogInformation("Performing dynamic client registration with {RegistrationEndpoint}", authServerMetadata.RegistrationEndpoint);
418+
419+
var registrationRequest = new DynamicClientRegistrationRequest
420+
{
421+
RedirectUris = [_redirectUri.ToString()],
422+
GrantTypes = ["authorization_code", "refresh_token"],
423+
ResponseTypes = ["code"],
424+
TokenEndpointAuthMethod = "client_secret_basic",
425+
ClientName = _options.ClientName,
426+
ClientUri = _options.ClientUri?.ToString(),
427+
Scope = _scopes != null ? string.Join(" ", _scopes) : null
428+
};
429+
430+
var requestJson = JsonSerializer.Serialize(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest);
431+
var requestContent = new StringContent(requestJson, Encoding.UTF8, "application/json");
432+
433+
using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.RegistrationEndpoint)
434+
{
435+
Content = requestContent
436+
};
437+
438+
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
439+
httpResponse.EnsureSuccessStatusCode();
440+
441+
using var responseStream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
442+
var registrationResponse = await JsonSerializer.DeserializeAsync(
443+
responseStream,
444+
McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationResponse,
445+
cancellationToken).ConfigureAwait(false);
446+
447+
if (registrationResponse is null)
448+
{
449+
ThrowFailedToHandleUnauthorizedResponse("Dynamic client registration returned empty response");
450+
}
451+
452+
// Update client credentials
453+
_clientId = registrationResponse.ClientId;
454+
if (!string.IsNullOrEmpty(registrationResponse.ClientSecret))
455+
{
456+
_clientSecret = registrationResponse.ClientSecret;
457+
}
458+
459+
_logger.LogInformation("Dynamic client registration successful. Client ID: {ClientId}", _clientId);
460+
}
461+
375462
/// <summary>
376463
/// Verifies that the resource URI in the metadata exactly matches the original request URL as required by the RFC.
377464
/// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server.
@@ -540,15 +627,17 @@ private static string GenerateCodeChallenge(string codeVerifier)
540627
.Replace('/', '_');
541628
}
542629

630+
private string GetClientIdOrThrow() => _clientId ?? throw new InvalidOperationException($"_clientId is uninitialized! This should be unreachable from public API.");
631+
543632
private static void ThrowIfNotBearerScheme(string scheme)
544633
{
545634
if (!string.Equals(scheme, BearerScheme, StringComparison.OrdinalIgnoreCase))
546635
{
547-
throw new InvalidOperationException($"The '{scheme}' is not supported. This credential provider only supports the '{BearerScheme}' scheme");
636+
throw new InvalidOperationException($"The '{scheme}' is not supported. This credential provider only supports the '{BearerScheme}' scheme.");
548637
}
549638
}
550639

551640
[DoesNotReturn]
552-
private static void ThrowFailedToHandleUnauthorizedResponse(string message) =>
553-
throw new McpException($"Failed to handle unauthorized response with 'Bearer' scheme. {message}");
641+
private static void ThrowFailedToHandleUnauthorizedResponse(string message, Exception? innerException = null) =>
642+
throw new McpException($"Failed to handle unauthorized response with 'Bearer' scheme. {message}", innerException);
554643
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace ModelContextProtocol.Authentication;
4+
5+
/// <summary>
6+
/// Represents a client registration request for OAuth 2.0 Dynamic Client Registration (RFC 7591).
7+
/// </summary>
8+
internal sealed class DynamicClientRegistrationRequest
9+
{
10+
/// <summary>
11+
/// Gets or sets the redirect URIs for the client.
12+
/// </summary>
13+
[JsonPropertyName("redirect_uris")]
14+
public required string[] RedirectUris { get; init; }
15+
16+
/// <summary>
17+
/// Gets or sets the token endpoint authentication method.
18+
/// </summary>
19+
[JsonPropertyName("token_endpoint_auth_method")]
20+
public string? TokenEndpointAuthMethod { get; init; }
21+
22+
/// <summary>
23+
/// Gets or sets the grant types that the client will use.
24+
/// </summary>
25+
[JsonPropertyName("grant_types")]
26+
public string[]? GrantTypes { get; init; }
27+
28+
/// <summary>
29+
/// Gets or sets the response types that the client will use.
30+
/// </summary>
31+
[JsonPropertyName("response_types")]
32+
public string[]? ResponseTypes { get; init; }
33+
34+
/// <summary>
35+
/// Gets or sets the human-readable name of the client.
36+
/// </summary>
37+
[JsonPropertyName("client_name")]
38+
public string? ClientName { get; init; }
39+
40+
/// <summary>
41+
/// Gets or sets the URL of the client's home page.
42+
/// </summary>
43+
[JsonPropertyName("client_uri")]
44+
public string? ClientUri { get; init; }
45+
46+
/// <summary>
47+
/// Gets or sets the scope values that the client will use.
48+
/// </summary>
49+
[JsonPropertyName("scope")]
50+
public string? Scope { get; init; }
51+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace ModelContextProtocol.Authentication;
4+
5+
/// <summary>
6+
/// Represents a client registration response for OAuth 2.0 Dynamic Client Registration (RFC 7591).
7+
/// </summary>
8+
internal sealed class DynamicClientRegistrationResponse
9+
{
10+
/// <summary>
11+
/// Gets or sets the client identifier.
12+
/// </summary>
13+
[JsonPropertyName("client_id")]
14+
public required string ClientId { get; init; }
15+
16+
/// <summary>
17+
/// Gets or sets the client secret.
18+
/// </summary>
19+
[JsonPropertyName("client_secret")]
20+
public string? ClientSecret { get; init; }
21+
22+
/// <summary>
23+
/// Gets or sets the redirect URIs for the client.
24+
/// </summary>
25+
[JsonPropertyName("redirect_uris")]
26+
public string[]? RedirectUris { get; init; }
27+
28+
/// <summary>
29+
/// Gets or sets the token endpoint authentication method.
30+
/// </summary>
31+
[JsonPropertyName("token_endpoint_auth_method")]
32+
public string? TokenEndpointAuthMethod { get; init; }
33+
34+
/// <summary>
35+
/// Gets or sets the grant types that the client will use.
36+
/// </summary>
37+
[JsonPropertyName("grant_types")]
38+
public string[]? GrantTypes { get; init; }
39+
40+
/// <summary>
41+
/// Gets or sets the response types that the client will use.
42+
/// </summary>
43+
[JsonPropertyName("response_types")]
44+
public string[]? ResponseTypes { get; init; }
45+
46+
/// <summary>
47+
/// Gets or sets the client ID issued timestamp.
48+
/// </summary>
49+
[JsonPropertyName("client_id_issued_at")]
50+
public long? ClientIdIssuedAt { get; init; }
51+
52+
/// <summary>
53+
/// Gets or sets the client secret expiration time.
54+
/// </summary>
55+
[JsonPropertyName("client_secret_expires_at")]
56+
public long? ClientSecretExpiresAt { get; init; }
57+
}

src/ModelContextProtocol.Core/McpJsonUtilities.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ internal static bool IsValidMcpToolSchema(JsonElement element)
158158
[JsonSerializable(typeof(ProtectedResourceMetadata))]
159159
[JsonSerializable(typeof(AuthorizationServerMetadata))]
160160
[JsonSerializable(typeof(TokenContainer))]
161+
[JsonSerializable(typeof(DynamicClientRegistrationRequest))]
162+
[JsonSerializable(typeof(DynamicClientRegistrationResponse))]
161163

162164
// Primitive types for use in consuming AIFunctions
163165
[JsonSerializable(typeof(string))]

0 commit comments

Comments
 (0)