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
5 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);

View file

@ -1365,4 +1365,196 @@ public class AuthTests : OAuthTestBase
var scopeTokens = requestedScope!.Split(' ');
Assert.Single(scopeTokens, t => t == "offline_access");
}
[Fact]
public async Task AuthorizationFlow_ScopeSelector_CanFilterServerProposedScopes()
{
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
options.ResourceMetadata!.ScopesSupported = ["mcp:tools", "files:read"];
});
await using var app = await StartMcpServerAsync();
string? requestedScope = null;
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
requestedScope = query["scope"].ToString();
return HandleAuthorizationUrlAsync(uri, redirect, ct);
},
ScopeSelector = scopes => scopes?.Where(s => s == "mcp:tools"),
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("mcp:tools", requestedScope);
}
[Fact]
public async Task AuthorizationFlow_ScopeSelector_CanAddCustomScope()
{
await using var app = await StartMcpServerAsync();
string? requestedScope = null;
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
requestedScope = query["scope"].ToString();
return HandleAuthorizationUrlAsync(uri, redirect, ct);
},
ScopeSelector = scopes => scopes?.Append("custom:scope") ?? ["custom:scope"],
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(requestedScope);
Assert.Contains("custom:scope", requestedScope!.Split(' '));
}
[Fact]
public async Task AuthorizationFlow_ScopeSelector_ReceivesNull_WhenServerProvidesNoScopes()
{
// No ScopesSupported on PRM, no Scopes fallback on client, no offline_access on AS (default).
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
options.ResourceMetadata!.ScopesSupported = [];
});
await using var app = await StartMcpServerAsync();
IEnumerable<string>? capturedInput = ["sentinel"];
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
ScopeSelector = scopes =>
{
capturedInput = scopes;
return scopes;
},
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.Null(capturedInput);
}
[Fact]
public async Task AuthorizationFlow_ScopeSelector_ReturningNull_OmitsScopeParameter()
{
await using var app = await StartMcpServerAsync();
bool? scopePresent = null;
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
{
scopePresent = QueryHelpers.ParseQuery(uri.Query).ContainsKey("scope");
return HandleAuthorizationUrlAsync(uri, redirect, ct);
},
ScopeSelector = _ => null,
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.False(scopePresent);
}
[Fact]
public async Task AuthorizationFlow_ScopeSelector_ReturningEmpty_OmitsScopeParameter()
{
await using var app = await StartMcpServerAsync();
bool? scopePresent = null;
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
{
scopePresent = QueryHelpers.ParseQuery(uri.Query).ContainsKey("scope");
return HandleAuthorizationUrlAsync(uri, redirect, ct);
},
ScopeSelector = _ => [],
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.False(scopePresent);
}
[Fact]
public async Task DynamicClientRegistration_ScopeSelector_AppliesToDcrScope()
{
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
options.ResourceMetadata!.ScopesSupported = ["mcp:tools", "files:read"];
});
await using var app = await StartMcpServerAsync();
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
DynamicClientRegistration = new() { ClientName = "Test MCP Client" },
ScopeSelector = scopes => scopes?.Where(s => s == "mcp:tools"),
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("mcp:tools", TestOAuthServer.LastRegistrationScope);
}
}

View file

@ -91,6 +91,9 @@ public sealed class Program
public HashSet<string> DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyCollection<string> MetadataRequests => _metadataRequests.ToArray();
/// <summary>Gets the <c>scope</c> field from the most recent Dynamic Client Registration request.</summary>
public string? LastRegistrationScope { get; private set; }
/// <summary>
/// Entry point for the application.
/// </summary>
@ -513,6 +516,8 @@ public sealed class Program
});
}
LastRegistrationScope = registrationRequest.Scope;
// Validate redirect URIs are provided
if (registrationRequest.RedirectUris.Count == 0)
{