From b54f6ddfb14f2e210a2ad3dab863d16fe40169e8 Mon Sep 17 00:00:00 2001 From: Manuel Naujoks Date: Fri, 22 May 2026 09:57:17 +0200 Subject: [PATCH 1/3] Add ScopeSelectorDelegate to enhance OAuth options for scope filtering --- .../Authentication/ClientOAuthOptions.cs | 29 +++- .../Authentication/ClientOAuthProvider.cs | 7 + .../Authentication/ScopeSelectorDelegate.cs | 33 ++++ .../OAuth/AuthTests.cs | 164 ++++++++++++++++++ 4 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 src/ModelContextProtocol.Core/Authentication/ScopeSelectorDelegate.cs 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..88a0c360c 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; @@ -493,6 +495,11 @@ private Uri BuildAuthorizationUrl( var scope = GetScopeParameter(protectedResourceMetadata); scope = AugmentScopeWithOfflineAccess(scope, authServerMetadata); + if (_scopeSelector is not null) + { + var selectedScope = _scopeSelector(scope?.Split(" ")); + scope = selectedScope is not null ? string.Join(" ", selectedScope) : null; + } if (!string.IsNullOrEmpty(scope)) { queryParamsDictionary["scope"] = scope!; diff --git a/src/ModelContextProtocol.Core/Authentication/ScopeSelectorDelegate.cs b/src/ModelContextProtocol.Core/Authentication/ScopeSelectorDelegate.cs new file mode 100644 index 000000000..49e705688 --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/ScopeSelectorDelegate.cs @@ -0,0 +1,33 @@ + +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 request. 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. +/// +/// +public delegate IEnumerable? ScopeSelectorDelegate(IEnumerable? scope); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index 4f6e0ce94..011f2f1cf 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -1365,4 +1365,168 @@ 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); + } } From 3187540d6d3f71c010a7a50b0cf4e0274806e282 Mon Sep 17 00:00:00 2001 From: Manuel Naujoks Date: Fri, 22 May 2026 10:16:36 +0200 Subject: [PATCH 2/3] Fix scope splitting to use single quotes for consistency --- .../Authentication/ClientOAuthProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 88a0c360c..55bb1259f 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -497,7 +497,7 @@ private Uri BuildAuthorizationUrl( scope = AugmentScopeWithOfflineAccess(scope, authServerMetadata); if (_scopeSelector is not null) { - var selectedScope = _scopeSelector(scope?.Split(" ")); + var selectedScope = _scopeSelector(scope?.Split(' ')); scope = selectedScope is not null ? string.Join(" ", selectedScope) : null; } if (!string.IsNullOrEmpty(scope)) From 38eef68f7c2a06bd8698694424063472041816f0 Mon Sep 17 00:00:00 2001 From: Manuel Naujoks Date: Fri, 22 May 2026 21:35:36 +0200 Subject: [PATCH 3/3] Refactor scope handling to compute effective scope for authorization and DCR requests --- .../Authentication/ClientOAuthProvider.cs | 24 ++++++++++------ .../Authentication/ScopeSelectorDelegate.cs | 10 +++++-- .../OAuth/AuthTests.cs | 28 +++++++++++++++++++ .../Program.cs | 5 ++++ 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 55bb1259f..662e436eb 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -493,13 +493,7 @@ private Uri BuildAuthorizationUrl( queryParamsDictionary["resource"] = resourceUri; } - var scope = GetScopeParameter(protectedResourceMetadata); - scope = AugmentScopeWithOfflineAccess(scope, authServerMetadata); - if (_scopeSelector is not null) - { - var selectedScope = _scopeSelector(scope?.Split(' ')); - scope = selectedScope is not null ? string.Join(" ", selectedScope) : null; - } + var scope = ComputeEffectiveScope(protectedResourceMetadata, authServerMetadata); if (!string.IsNullOrEmpty(scope)) { queryParamsDictionary["scope"] = scope!; @@ -661,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); @@ -720,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 index 49e705688..5fe688ede 100644 --- a/src/ModelContextProtocol.Core/Authentication/ScopeSelectorDelegate.cs +++ b/src/ModelContextProtocol.Core/Authentication/ScopeSelectorDelegate.cs @@ -11,8 +11,8 @@ namespace ModelContextProtocol.Authentication; /// if the server provided no scope information and no fallback scopes are configured. /// /// -/// The scopes to include in the authorization request. Return or an empty -/// enumerable to omit the scope parameter entirely. +/// The scopes to include in the authorization and Dynamic Client Registration requests. Return +/// or an empty enumerable to omit the scope parameter entirely. /// /// /// @@ -29,5 +29,9 @@ namespace ModelContextProtocol.Authentication; /// 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(IEnumerable? scope); +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 011f2f1cf..1ec6fddc6 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -1529,4 +1529,32 @@ public async Task AuthorizationFlow_ScopeSelector_ReturningEmpty_OmitsScopeParam 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) {