Add ScopeSelectorDelegate to enhance OAuth options for scope filtering (#1596)

This commit is contained in:
Manuel Naujoks 2026-05-27 00:23:52 +02:00 • committed by GitHub
commit fd1ac08925
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 277 additions and 7 deletions

View file

@ -35,19 +35,40 @@ public sealed class ClientOAuthOptions
public Uri? ClientMetadataDocumentUri { get; set; }
/// <summary>
/// Gets or sets the OAuth scopes to request.
/// Gets or sets the OAuth scopes to request as a fallback.
/// </summary>
/// <remarks>
/// <para>
/// When specified, these scopes will be used instead of the scopes advertised by the protected resource.
/// If not specified, the provider will use the scopes from the protected resource metadata.
/// These scopes are used only when the server does not provide scope information via the
/// WWW-Authenticate header or Protected Resource Metadata (<c>scopes_supported</c>). This
/// matches the MCP scope selection strategy: WWW-Authenticate scope → PRM scopes_supported →
/// client-configured scopes → omit scope parameter.
/// </para>
/// <para>
/// Common OAuth scopes include "openid", "profile", and "email".
/// To filter or customize scopes when the server <em>does</em> provide scope information,
/// use <see cref="ScopeSelector"/> instead.
/// </para>
/// </remarks>
public IEnumerable<string>? Scopes { get; set; }
/// <summary>
/// Gets or sets a delegate that selects or filters the OAuth scopes to request.
/// </summary>
/// <remarks>
/// <para>
/// When set, this delegate is called after the MCP scope selection strategy has determined the
/// candidate scopes (WWW-Authenticate → PRM <c>scopes_supported</c> → <see cref="Scopes"/> fallback)
/// and after <c>offline_access</c> has been automatically appended when advertised by the
/// authorization server. The return value replaces the candidate scopes in the authorization request.
/// </para>
/// <para>
/// Use this to request only a subset of the scopes offered by the server, or to append a custom
/// scope that is not advertised in the server metadata. Return <see langword="null"/> or an empty
/// enumerable to omit the <c>scope</c> parameter entirely.
/// </para>
/// </remarks>
public ScopeSelectorDelegate? ScopeSelector { get; set; }
/// <summary>
/// Gets or sets the authorization redirect delegate for handling the OAuth authorization flow.
/// </summary>

View file

@ -28,6 +28,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
private readonly Uri _serverUrl;
private readonly Uri _redirectUri;
private readonly string? _configuredScopes;
private readonly ScopeSelectorDelegate? _scopeSelector;
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
@ -76,6 +77,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
_clientSecret = options.ClientSecret;
_redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options));
_configuredScopes = options.Scopes is null ? null : string.Join(" ", options.Scopes);
_scopeSelector = options.ScopeSelector;
_additionalAuthorizationParameters = options.AdditionalAuthorizationParameters;
_clientMetadataDocumentUri = options.ClientMetadataDocumentUri;
@ -491,8 +493,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
queryParamsDictionary["resource"] = resourceUri;
}
var scope = GetScopeParameter(protectedResourceMetadata);
scope = AugmentScopeWithOfflineAccess(scope, authServerMetadata);
var scope = ComputeEffectiveScope(protectedResourceMetadata, authServerMetadata);
if (!string.IsNullOrEmpty(scope))
{
queryParamsDictionary["scope"] = scope!;
@ -654,7 +655,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
TokenEndpointAuthMethod = "client_secret_post",
ClientName = _dcrClientName,
ClientUri = _dcrClientUri?.ToString(),
Scope = GetScopeParameter(protectedResourceMetadata),
Scope = ComputeEffectiveScope(protectedResourceMetadata, authServerMetadata),
};
var requestBytes = JsonSerializer.SerializeToUtf8Bytes(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest);
@ -713,6 +714,20 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
private static string? GetResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
=> protectedResourceMetadata.Resource;
private string? ComputeEffectiveScope(
ProtectedResourceMetadata protectedResourceMetadata,
AuthorizationServerMetadata authServerMetadata)
{
var scope = GetScopeParameter(protectedResourceMetadata);
scope = AugmentScopeWithOfflineAccess(scope, authServerMetadata);
if (_scopeSelector is not null)
{
var selected = _scopeSelector(scope?.Split(' '));
scope = selected is not null ? string.Join(" ", selected) : null;
}
return scope;
}
private string? GetScopeParameter(ProtectedResourceMetadata protectedResourceMetadata)
{
if (!string.IsNullOrEmpty(protectedResourceMetadata.WwwAuthenticateScope))

View file

@ -0,0 +1,37 @@
namespace ModelContextProtocol.Authentication;
/// <summary>
/// Represents a method that selects or filters the OAuth scopes to request during authorization.
/// </summary>
/// <param name="scope">
/// The scopes determined by the MCP scope selection strategy (WWW-Authenticate header scope →
/// <c>scopes_supported</c> from Protected Resource Metadata → <see cref="ClientOAuthOptions.Scopes"/>
/// fallback), with <c>offline_access</c> appended when advertised by the authorization server. May be
/// <see langword="null"/> if the server provided no scope information and no fallback scopes are configured.
/// </param>
/// <returns>
/// The scopes to include in the authorization and Dynamic Client Registration requests. Return
/// <see langword="null"/> or an empty enumerable to omit the <c>scope</c> parameter entirely.
/// </returns>
/// <remarks>
/// <para>
/// Use this delegate to filter or customize the proposed scopes before the authorization request is made.
/// Common scenarios include:
/// </para>
/// <list type="bullet">
/// <item><description>Requesting only a subset of the scopes offered by the server.</description></item>
/// <item><description>Appending a custom scope not advertised in the server metadata.</description></item>
/// </list>
/// <para>
/// The MCP specification defines the following scope selection priority (highest to lowest):
/// WWW-Authenticate header scope → PRM <c>scopes_supported</c> → omit scope parameter. The
/// <paramref name="scope"/> parameter already reflects this priority. The delegate runs after
/// <c>offline_access</c> has been auto-appended, so it can also remove that scope if desired.
/// </para>
/// <para>
/// The resolved scope is applied consistently to both the authorization URL and the Dynamic Client
/// Registration (DCR) request, so the registered client scope matches what is actually requested.
/// </para>
/// </remarks>
public delegate IEnumerable<string>? ScopeSelectorDelegate(IReadOnlyCollection<string>? scope);