Skip to content

Commit fd1ac08

Browse files
authored
Add ScopeSelectorDelegate to enhance OAuth options for scope filtering (#1596)
1 parent 157f855 commit fd1ac08

5 files changed

Lines changed: 277 additions & 7 deletions

File tree

src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,40 @@ public sealed class ClientOAuthOptions
3535
public Uri? ClientMetadataDocumentUri { get; set; }
3636

3737
/// <summary>
38-
/// Gets or sets the OAuth scopes to request.
38+
/// Gets or sets the OAuth scopes to request as a fallback.
3939
/// </summary>
4040
/// <remarks>
4141
/// <para>
42-
/// When specified, these scopes will be used instead of the scopes advertised by the protected resource.
43-
/// If not specified, the provider will use the scopes from the protected resource metadata.
42+
/// These scopes are used only when the server does not provide scope information via the
43+
/// WWW-Authenticate header or Protected Resource Metadata (<c>scopes_supported</c>). This
44+
/// matches the MCP scope selection strategy: WWW-Authenticate scope → PRM scopes_supported →
45+
/// client-configured scopes → omit scope parameter.
4446
/// </para>
4547
/// <para>
46-
/// Common OAuth scopes include "openid", "profile", and "email".
48+
/// To filter or customize scopes when the server <em>does</em> provide scope information,
49+
/// use <see cref="ScopeSelector"/> instead.
4750
/// </para>
4851
/// </remarks>
4952
public IEnumerable<string>? Scopes { get; set; }
5053

54+
/// <summary>
55+
/// Gets or sets a delegate that selects or filters the OAuth scopes to request.
56+
/// </summary>
57+
/// <remarks>
58+
/// <para>
59+
/// When set, this delegate is called after the MCP scope selection strategy has determined the
60+
/// candidate scopes (WWW-Authenticate → PRM <c>scopes_supported</c> → <see cref="Scopes"/> fallback)
61+
/// and after <c>offline_access</c> has been automatically appended when advertised by the
62+
/// authorization server. The return value replaces the candidate scopes in the authorization request.
63+
/// </para>
64+
/// <para>
65+
/// Use this to request only a subset of the scopes offered by the server, or to append a custom
66+
/// scope that is not advertised in the server metadata. Return <see langword="null"/> or an empty
67+
/// enumerable to omit the <c>scope</c> parameter entirely.
68+
/// </para>
69+
/// </remarks>
70+
public ScopeSelectorDelegate? ScopeSelector { get; set; }
71+
5172
/// <summary>
5273
/// Gets or sets the authorization redirect delegate for handling the OAuth authorization flow.
5374
/// </summary>

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
2828
private readonly Uri _serverUrl;
2929
private readonly Uri _redirectUri;
3030
private readonly string? _configuredScopes;
31+
private readonly ScopeSelectorDelegate? _scopeSelector;
3132
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
3233
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
3334
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
@@ -76,6 +77,7 @@ public ClientOAuthProvider(
7677
_clientSecret = options.ClientSecret;
7778
_redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options));
7879
_configuredScopes = options.Scopes is null ? null : string.Join(" ", options.Scopes);
80+
_scopeSelector = options.ScopeSelector;
7981
_additionalAuthorizationParameters = options.AdditionalAuthorizationParameters;
8082
_clientMetadataDocumentUri = options.ClientMetadataDocumentUri;
8183

@@ -491,8 +493,7 @@ private Uri BuildAuthorizationUrl(
491493
queryParamsDictionary["resource"] = resourceUri;
492494
}
493495

494-
var scope = GetScopeParameter(protectedResourceMetadata);
495-
scope = AugmentScopeWithOfflineAccess(scope, authServerMetadata);
496+
var scope = ComputeEffectiveScope(protectedResourceMetadata, authServerMetadata);
496497
if (!string.IsNullOrEmpty(scope))
497498
{
498499
queryParamsDictionary["scope"] = scope!;
@@ -654,7 +655,7 @@ private async Task PerformDynamicClientRegistrationAsync(
654655
TokenEndpointAuthMethod = "client_secret_post",
655656
ClientName = _dcrClientName,
656657
ClientUri = _dcrClientUri?.ToString(),
657-
Scope = GetScopeParameter(protectedResourceMetadata),
658+
Scope = ComputeEffectiveScope(protectedResourceMetadata, authServerMetadata),
658659
};
659660

660661
var requestBytes = JsonSerializer.SerializeToUtf8Bytes(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest);
@@ -713,6 +714,20 @@ private async Task PerformDynamicClientRegistrationAsync(
713714
private static string? GetResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
714715
=> protectedResourceMetadata.Resource;
715716

717+
private string? ComputeEffectiveScope(
718+
ProtectedResourceMetadata protectedResourceMetadata,
719+
AuthorizationServerMetadata authServerMetadata)
720+
{
721+
var scope = GetScopeParameter(protectedResourceMetadata);
722+
scope = AugmentScopeWithOfflineAccess(scope, authServerMetadata);
723+
if (_scopeSelector is not null)
724+
{
725+
var selected = _scopeSelector(scope?.Split(' '));
726+
scope = selected is not null ? string.Join(" ", selected) : null;
727+
}
728+
return scope;
729+
}
730+
716731
private string? GetScopeParameter(ProtectedResourceMetadata protectedResourceMetadata)
717732
{
718733
if (!string.IsNullOrEmpty(protectedResourceMetadata.WwwAuthenticateScope))
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
2+
namespace ModelContextProtocol.Authentication;
3+
4+
/// <summary>
5+
/// Represents a method that selects or filters the OAuth scopes to request during authorization.
6+
/// </summary>
7+
/// <param name="scope">
8+
/// The scopes determined by the MCP scope selection strategy (WWW-Authenticate header scope →
9+
/// <c>scopes_supported</c> from Protected Resource Metadata → <see cref="ClientOAuthOptions.Scopes"/>
10+
/// fallback), with <c>offline_access</c> appended when advertised by the authorization server. May be
11+
/// <see langword="null"/> if the server provided no scope information and no fallback scopes are configured.
12+
/// </param>
13+
/// <returns>
14+
/// The scopes to include in the authorization and Dynamic Client Registration requests. Return
15+
/// <see langword="null"/> or an empty enumerable to omit the <c>scope</c> parameter entirely.
16+
/// </returns>
17+
/// <remarks>
18+
/// <para>
19+
/// Use this delegate to filter or customize the proposed scopes before the authorization request is made.
20+
/// Common scenarios include:
21+
/// </para>
22+
/// <list type="bullet">
23+
/// <item><description>Requesting only a subset of the scopes offered by the server.</description></item>
24+
/// <item><description>Appending a custom scope not advertised in the server metadata.</description></item>
25+
/// </list>
26+
/// <para>
27+
/// The MCP specification defines the following scope selection priority (highest to lowest):
28+
/// WWW-Authenticate header scope → PRM <c>scopes_supported</c> → omit scope parameter. The
29+
/// <paramref name="scope"/> parameter already reflects this priority. The delegate runs after
30+
/// <c>offline_access</c> has been auto-appended, so it can also remove that scope if desired.
31+
/// </para>
32+
/// <para>
33+
/// The resolved scope is applied consistently to both the authorization URL and the Dynamic Client
34+
/// Registration (DCR) request, so the registered client scope matches what is actually requested.
35+
/// </para>
36+
/// </remarks>
37+
public delegate IEnumerable<string>? ScopeSelectorDelegate(IReadOnlyCollection<string>? scope);

tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1365,4 +1365,196 @@ public async Task AuthorizationFlow_DoesNotDuplicateOfflineAccess_WhenAlreadyPre
13651365
var scopeTokens = requestedScope!.Split(' ');
13661366
Assert.Single(scopeTokens, t => t == "offline_access");
13671367
}
1368+
1369+
[Fact]
1370+
public async Task AuthorizationFlow_ScopeSelector_CanFilterServerProposedScopes()
1371+
{
1372+
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
1373+
{
1374+
options.ResourceMetadata!.ScopesSupported = ["mcp:tools", "files:read"];
1375+
});
1376+
1377+
await using var app = await StartMcpServerAsync();
1378+
1379+
string? requestedScope = null;
1380+
1381+
await using var transport = new HttpClientTransport(new()
1382+
{
1383+
Endpoint = new(McpServerUrl),
1384+
OAuth = new()
1385+
{
1386+
ClientId = "demo-client",
1387+
ClientSecret = "demo-secret",
1388+
RedirectUri = new Uri("http://localhost:1179/callback"),
1389+
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
1390+
{
1391+
var query = QueryHelpers.ParseQuery(uri.Query);
1392+
requestedScope = query["scope"].ToString();
1393+
return HandleAuthorizationUrlAsync(uri, redirect, ct);
1394+
},
1395+
ScopeSelector = scopes => scopes?.Where(s => s == "mcp:tools"),
1396+
},
1397+
}, HttpClient, LoggerFactory);
1398+
1399+
await using var client = await McpClient.CreateAsync(
1400+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
1401+
1402+
Assert.Equal("mcp:tools", requestedScope);
1403+
}
1404+
1405+
[Fact]
1406+
public async Task AuthorizationFlow_ScopeSelector_CanAddCustomScope()
1407+
{
1408+
await using var app = await StartMcpServerAsync();
1409+
1410+
string? requestedScope = null;
1411+
1412+
await using var transport = new HttpClientTransport(new()
1413+
{
1414+
Endpoint = new(McpServerUrl),
1415+
OAuth = new()
1416+
{
1417+
ClientId = "demo-client",
1418+
ClientSecret = "demo-secret",
1419+
RedirectUri = new Uri("http://localhost:1179/callback"),
1420+
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
1421+
{
1422+
var query = QueryHelpers.ParseQuery(uri.Query);
1423+
requestedScope = query["scope"].ToString();
1424+
return HandleAuthorizationUrlAsync(uri, redirect, ct);
1425+
},
1426+
ScopeSelector = scopes => scopes?.Append("custom:scope") ?? ["custom:scope"],
1427+
},
1428+
}, HttpClient, LoggerFactory);
1429+
1430+
await using var client = await McpClient.CreateAsync(
1431+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
1432+
1433+
Assert.NotNull(requestedScope);
1434+
Assert.Contains("custom:scope", requestedScope!.Split(' '));
1435+
}
1436+
1437+
[Fact]
1438+
public async Task AuthorizationFlow_ScopeSelector_ReceivesNull_WhenServerProvidesNoScopes()
1439+
{
1440+
// No ScopesSupported on PRM, no Scopes fallback on client, no offline_access on AS (default).
1441+
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
1442+
{
1443+
options.ResourceMetadata!.ScopesSupported = [];
1444+
});
1445+
1446+
await using var app = await StartMcpServerAsync();
1447+
1448+
IEnumerable<string>? capturedInput = ["sentinel"];
1449+
1450+
await using var transport = new HttpClientTransport(new()
1451+
{
1452+
Endpoint = new(McpServerUrl),
1453+
OAuth = new()
1454+
{
1455+
ClientId = "demo-client",
1456+
ClientSecret = "demo-secret",
1457+
RedirectUri = new Uri("http://localhost:1179/callback"),
1458+
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
1459+
ScopeSelector = scopes =>
1460+
{
1461+
capturedInput = scopes;
1462+
return scopes;
1463+
},
1464+
},
1465+
}, HttpClient, LoggerFactory);
1466+
1467+
await using var client = await McpClient.CreateAsync(
1468+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
1469+
1470+
Assert.Null(capturedInput);
1471+
}
1472+
1473+
[Fact]
1474+
public async Task AuthorizationFlow_ScopeSelector_ReturningNull_OmitsScopeParameter()
1475+
{
1476+
await using var app = await StartMcpServerAsync();
1477+
1478+
bool? scopePresent = null;
1479+
1480+
await using var transport = new HttpClientTransport(new()
1481+
{
1482+
Endpoint = new(McpServerUrl),
1483+
OAuth = new()
1484+
{
1485+
ClientId = "demo-client",
1486+
ClientSecret = "demo-secret",
1487+
RedirectUri = new Uri("http://localhost:1179/callback"),
1488+
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
1489+
{
1490+
scopePresent = QueryHelpers.ParseQuery(uri.Query).ContainsKey("scope");
1491+
return HandleAuthorizationUrlAsync(uri, redirect, ct);
1492+
},
1493+
ScopeSelector = _ => null,
1494+
},
1495+
}, HttpClient, LoggerFactory);
1496+
1497+
await using var client = await McpClient.CreateAsync(
1498+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
1499+
1500+
Assert.False(scopePresent);
1501+
}
1502+
1503+
[Fact]
1504+
public async Task AuthorizationFlow_ScopeSelector_ReturningEmpty_OmitsScopeParameter()
1505+
{
1506+
await using var app = await StartMcpServerAsync();
1507+
1508+
bool? scopePresent = null;
1509+
1510+
await using var transport = new HttpClientTransport(new()
1511+
{
1512+
Endpoint = new(McpServerUrl),
1513+
OAuth = new()
1514+
{
1515+
ClientId = "demo-client",
1516+
ClientSecret = "demo-secret",
1517+
RedirectUri = new Uri("http://localhost:1179/callback"),
1518+
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
1519+
{
1520+
scopePresent = QueryHelpers.ParseQuery(uri.Query).ContainsKey("scope");
1521+
return HandleAuthorizationUrlAsync(uri, redirect, ct);
1522+
},
1523+
ScopeSelector = _ => [],
1524+
},
1525+
}, HttpClient, LoggerFactory);
1526+
1527+
await using var client = await McpClient.CreateAsync(
1528+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
1529+
1530+
Assert.False(scopePresent);
1531+
}
1532+
1533+
[Fact]
1534+
public async Task DynamicClientRegistration_ScopeSelector_AppliesToDcrScope()
1535+
{
1536+
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
1537+
{
1538+
options.ResourceMetadata!.ScopesSupported = ["mcp:tools", "files:read"];
1539+
});
1540+
1541+
await using var app = await StartMcpServerAsync();
1542+
1543+
await using var transport = new HttpClientTransport(new()
1544+
{
1545+
Endpoint = new(McpServerUrl),
1546+
OAuth = new ClientOAuthOptions()
1547+
{
1548+
RedirectUri = new Uri("http://localhost:1179/callback"),
1549+
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
1550+
DynamicClientRegistration = new() { ClientName = "Test MCP Client" },
1551+
ScopeSelector = scopes => scopes?.Where(s => s == "mcp:tools"),
1552+
},
1553+
}, HttpClient, LoggerFactory);
1554+
1555+
await using var client = await McpClient.CreateAsync(
1556+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
1557+
1558+
Assert.Equal("mcp:tools", TestOAuthServer.LastRegistrationScope);
1559+
}
13681560
}

tests/ModelContextProtocol.TestOAuthServer/Program.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor
9191
public HashSet<string> DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
9292
public IReadOnlyCollection<string> MetadataRequests => _metadataRequests.ToArray();
9393

94+
/// <summary>Gets the <c>scope</c> field from the most recent Dynamic Client Registration request.</summary>
95+
public string? LastRegistrationScope { get; private set; }
96+
9497
/// <summary>
9598
/// Entry point for the application.
9699
/// </summary>
@@ -513,6 +516,8 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null)
513516
});
514517
}
515518

519+
LastRegistrationScope = registrationRequest.Scope;
520+
516521
// Validate redirect URIs are provided
517522
if (registrationRequest.RedirectUris.Count == 0)
518523
{

0 commit comments

Comments
 (0)