Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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!;
Comment thread
halllo marked this conversation as resolved.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
192 changes: 192 additions & 0 deletions tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<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);
}
}
5 changes: 5 additions & 0 deletions tests/ModelContextProtocol.TestOAuthServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor
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>
Expand Down Expand Up @@ -513,6 +516,8 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null)
});
}

LastRegistrationScope = registrationRequest.Scope;

// Validate redirect URIs are provided
if (registrationRequest.RedirectUris.Count == 0)
{
Expand Down
Loading