diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
index 483e3643e..0bfb19a59 100644
--- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
+++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
@@ -35,19 +35,40 @@ public sealed class ClientOAuthOptions
public Uri? ClientMetadataDocumentUri { get; set; }
///
- /// Gets or sets the OAuth scopes to request.
+ /// Gets or sets the OAuth scopes to request as a fallback.
///
///
///
- /// 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 (scopes_supported). This
+ /// matches the MCP scope selection strategy: WWW-Authenticate scope → PRM scopes_supported →
+ /// client-configured scopes → omit scope parameter.
///
///
- /// Common OAuth scopes include "openid", "profile", and "email".
+ /// To filter or customize scopes when the server does provide scope information,
+ /// use instead.
///
///
public IEnumerable? Scopes { get; set; }
+ ///
+ /// Gets or sets a delegate that selects or filters the OAuth scopes to request.
+ ///
+ ///
+ ///
+ /// When set, this delegate is called after the MCP scope selection strategy has determined the
+ /// candidate scopes (WWW-Authenticate → PRM scopes_supported → fallback)
+ /// and after offline_access has been automatically appended when advertised by the
+ /// authorization server. The return value replaces the candidate scopes in the authorization request.
+ ///
+ ///
+ /// 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 or an empty
+ /// enumerable to omit the scope parameter entirely.
+ ///
+ ///
+ public ScopeSelectorDelegate? ScopeSelector { get; set; }
+
///
/// Gets or sets the authorization redirect delegate for handling the OAuth authorization flow.
///
diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
index c376f932c..662e436eb 100644
--- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
+++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
@@ -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 _additionalAuthorizationParameters;
private readonly Func, Uri?> _authServerSelector;
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
@@ -76,6 +77,7 @@ public ClientOAuthProvider(
_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 @@ private Uri BuildAuthorizationUrl(
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 @@ private async Task PerformDynamicClientRegistrationAsync(
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 @@ private async Task PerformDynamicClientRegistrationAsync(
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))
diff --git a/src/ModelContextProtocol.Core/Authentication/ScopeSelectorDelegate.cs b/src/ModelContextProtocol.Core/Authentication/ScopeSelectorDelegate.cs
new file mode 100644
index 000000000..5fe688ede
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/ScopeSelectorDelegate.cs
@@ -0,0 +1,37 @@
+
+namespace ModelContextProtocol.Authentication;
+
+///
+/// Represents a method that selects or filters the OAuth scopes to request during authorization.
+///
+///
+/// The scopes determined by the MCP scope selection strategy (WWW-Authenticate header scope →
+/// scopes_supported from Protected Resource Metadata →
+/// fallback), with offline_access appended when advertised by the authorization server. May be
+/// if the server provided no scope information and no fallback scopes are configured.
+///
+///
+/// The scopes to include in the authorization and Dynamic Client Registration requests. Return
+/// or an empty enumerable to omit the scope parameter entirely.
+///
+///
+///
+/// Use this delegate to filter or customize the proposed scopes before the authorization request is made.
+/// Common scenarios include:
+///
+///
+/// - Requesting only a subset of the scopes offered by the server.
+/// - Appending a custom scope not advertised in the server metadata.
+///
+///
+/// The MCP specification defines the following scope selection priority (highest to lowest):
+/// WWW-Authenticate header scope → PRM scopes_supported → omit scope parameter. The
+/// parameter already reflects this priority. The delegate runs after
+/// offline_access has been auto-appended, so it can also remove that scope if desired.
+///
+///
+/// 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.
+///
+///
+public delegate IEnumerable? ScopeSelectorDelegate(IReadOnlyCollection? scope);
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
index 4f6e0ce94..1ec6fddc6 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
@@ -1365,4 +1365,196 @@ public async Task AuthorizationFlow_DoesNotDuplicateOfflineAccess_WhenAlreadyPre
var scopeTokens = requestedScope!.Split(' ');
Assert.Single(scopeTokens, t => t == "offline_access");
}
+
+ [Fact]
+ public async Task AuthorizationFlow_ScopeSelector_CanFilterServerProposedScopes()
+ {
+ Builder.Services.Configure(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(McpAuthenticationDefaults.AuthenticationScheme, options =>
+ {
+ options.ResourceMetadata!.ScopesSupported = [];
+ });
+
+ await using var app = await StartMcpServerAsync();
+
+ IEnumerable? 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(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);
+ }
}
diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs
index a65c5e4ab..68600f81d 100644
--- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs
+++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs
@@ -91,6 +91,9 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor
public HashSet DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyCollection MetadataRequests => _metadataRequests.ToArray();
+ /// Gets the scope field from the most recent Dynamic Client Registration request.
+ public string? LastRegistrationScope { get; private set; }
+
///
/// Entry point for the application.
///
@@ -513,6 +516,8 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null)
});
}
+ LastRegistrationScope = registrationRequest.Scope;
+
// Validate redirect URIs are provided
if (registrationRequest.RedirectUris.Count == 0)
{