Skip to content

Built-in server-side incremental scope consent (SEP-835)#1483

Draft
Copilot wants to merge 2 commits intomainfrom
copilot/add-incremental-scope-consent-support
Draft

Built-in server-side incremental scope consent (SEP-835)#1483
Copilot wants to merge 2 commits intomainfrom
copilot/add-incremental-scope-consent-support

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 31, 2026

Adds server-side support for incremental scope consent so MCP servers can decorate primitives with [Authorize] without calling AddAuthorizationFilters(), and the SDK automatically returns HTTP 403 with proper WWW-Authenticate headers to trigger client re-authentication with broader scopes. The existing ClientOAuthProvider already handles these 403 responses client-side.

Behavior

AddAuthorizationFilters() called Not called (new incremental consent path)
Listings Filters out unauthorized primitives All primitives visible
Invocation (unauthorized) JSON-RPC error in HTTP 200 HTTP 403 + WWW-Authenticate

Usage

builder.Services.AddMcpServer()
    .WithHttpTransport()         // no AddAuthorizationFilters()
    .WithTools<MyTools>();

[McpServerToolType]
public class MyTools
{
    [McpServerTool, Description("Reads a file")]
    [Authorize(Roles = "read_files")]   // scope = Roles value
    public static Task<string> ReadFile(string path, CancellationToken ct) { ... }
}

Unauthorized call returns:

HTTP 403 Forbidden
WWW-Authenticate: ******"insufficient_scope", scope="read_files", resource_metadata="https://..."

Changes

  • AuthorizationFilterSetup — Removed PostConfigure check filters that threw InvalidOperationException when [Authorize] was found without AddAuthorizationFilters(); those checks blocked the new incremental consent path. Made HasAuthorizationMetadata and CombineAsync internal for reuse. AddAuthorizationFilters() behavior is unchanged.

  • HttpServerTransportOptions — Added internal AuthorizationFiltersRegistered flag.

  • HttpMcpServerBuilderExtensions.AddAuthorizationFilters() — Sets AuthorizationFiltersRegistered = true so the new pre-flight check skips when the existing filter pipeline is in use.

  • StreamableHttpHandler — Added TryHandleInsufficientScopeAsync() called before InitializeSseResponse(). For tools/call, prompts/get, and resources/read targeting a primitive with auth metadata, evaluates the policy and writes HTTP 403 + WWW-Authenticate: ******"insufficient_scope", scope="<roles>", resource_metadata="..." if unauthorized. The check must run before SSE headers are committed (HTTP 200).

  • AuthorizeAttributeTests — Updated 7 existing WithoutAuthFilters tests to reflect the new behavior (lists succeed and return all primitives; invocations return HTTP 403).

  • IncrementalConsentTests (new) — Tests for: all primitives visible in listings, HTTP 403 + correct WWW-Authenticate header for unauthorized invocations, successful invocation for authorized users, and that AddAuthorizationFilters() behavior is unchanged.

Copilot AI changed the title [WIP] Add server-side support for incremental scope consent Built-in server-side incremental scope consent (SEP-835) Mar 31, 2026
Copilot AI requested a review from mikekistler March 31, 2026 20:38
var scope = primitive.Metadata
.OfType<IAuthorizeData>()
.Select(static a => a.Roles)
.FirstOrDefault(static r => !string.IsNullOrEmpty(r));
Copy link
Copy Markdown
Contributor

@halter73 halter73 Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ASP.NET Core roles and OAuth scopes are related but distinct concepts. They can map 1:1, but they don't necessarily. A lot of apps would use something like [RequiredScope("scope")] from Microsoft.Identity.Web instead.

CheckReadResourceFilter(options);

CheckListPromptsFilter(options);
CheckGetPromptFilter(options);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do see the appeal of doing authz before reaching the filter stack, considering that we flush the headers before any filters/handlers run making impossible to change the status code at that point.

However, I don't know removing these checks is the right move. If we were going to do something like that, we would probably just want to remove the authz filters altogether and move it entirely to the Streamable HTTP handler. Of course this would make it impossible to run filters before authz.

Right now, it looks like we're forced to deserialize the payload twice for Stremable HTTP. Of course, you also cannot really remove the authz filters without moving the auth checks into the SseHandler too.

I think incremental step up is niche enough it should be opt-in. And I think you should be required to add the authz filters regardless if you have any handlers attributed with [Authorize], even if we do have a way to short-circuit for incremental consent in the StreamableHttpHandler for some possible subset of. If we were sure that all the attributed handlers all relied exclusively on incremental consent, that might be different.


// Signal to the HTTP transport that authorization filters are handling access control,
// so the pre-flight incremental scope consent check (SEP-835) should be skipped.
builder.Services.Configure<HttpServerTransportOptions>(static o => o.AuthorizationFiltersRegistered = true);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it make sense to allow a subset of handlers require incremental concent instead of being all or nothing?

if (policyProvider is null)
{
// No authorization infrastructure configured; skip the pre-flight check.
return false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're not requiring the filters to be registered, this probably needs to throw like the filters do.

// ASP.NET Core's AuthorizationMiddleware resolves the IAuthorizationService from scoped request services, so we do the same.
var authService = requestServices.GetRequiredService<IAuthorizationService>();
return await authService.AuthorizeAsync(user ?? new ClaimsPrincipal(new ClaimsIdentity()), context, policy);

/// authorization check that returns HTTP 403 with <c>WWW-Authenticate: Bearer error="insufficient_scope"</c>
/// to trigger client re-authentication with broader scopes.
/// </summary>
public class IncrementalConsentTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figured this would derive from OAuthTestBase, so we could test the entire process. Even if we had that though, I would be hesitant to ship advanced server-side OAuth features without verifying compatiblity with a decent number of OAuth providers.

In my experience, they tend to be pretty incompatible. I know the MCP spec requires support for this flow from the client at least, but Entra doesn't really support MCP-style resource-parameter-based OAuth yet. I'd want to verify that works before doing more.

@mikekistler
Copy link
Copy Markdown
Contributor

I'm unhappy with this first pass from copilot and working on revising it with the local agent. Hoping to push up a revision shortly.

@halter73
Copy link
Copy Markdown
Contributor

halter73 commented Apr 2, 2026

Do you have any real OAuth servers you're manually testing with? I know it'd be hard to automate anything relying on a cloud-based OAuth server. I've been there with Entra, but I definitely want to know which popular servers this works with out of the box. We can always wait for wider support if it isn't there yet just to make sure what we do is widely compatible.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Built-in server-side support for incremental scope consent (SEP-835)

3 participants