-
Notifications
You must be signed in to change notification settings - Fork 670
Built-in server-side incremental scope consent (SEP-835) #1483
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -64,6 +64,10 @@ public static IMcpServerBuilder AddAuthorizationFilters(this IMcpServerBuilder b | |
| // Allow the authorization filters to get added multiple times in case other middleware changes the matched primitive. | ||
| builder.Services.AddTransient<IConfigureOptions<McpServerOptions>, AuthorizationFilterSetup>(); | ||
|
|
||
| // 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? |
||
|
|
||
| return builder; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -1,6 +1,8 @@ | ||||||||
| using Microsoft.AspNetCore.Http; | ||||||||
| using Microsoft.AspNetCore.Authorization; | ||||||||
| using Microsoft.AspNetCore.Http; | ||||||||
| using Microsoft.AspNetCore.Http.Features; | ||||||||
| using Microsoft.AspNetCore.WebUtilities; | ||||||||
| using Microsoft.Extensions.DependencyInjection; | ||||||||
| using Microsoft.Extensions.Hosting; | ||||||||
| using Microsoft.Extensions.Logging; | ||||||||
| using Microsoft.Extensions.Options; | ||||||||
|
|
@@ -41,6 +43,9 @@ internal sealed class StreamableHttpHandler( | |||||||
|
|
||||||||
| private static readonly JsonTypeInfo<JsonRpcMessage> s_messageTypeInfo = GetRequiredJsonTypeInfo<JsonRpcMessage>(); | ||||||||
| private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>(); | ||||||||
| private static readonly JsonTypeInfo<CallToolRequestParams> s_callToolParamsTypeInfo = GetRequiredJsonTypeInfo<CallToolRequestParams>(); | ||||||||
| private static readonly JsonTypeInfo<GetPromptRequestParams> s_getPromptParamsTypeInfo = GetRequiredJsonTypeInfo<GetPromptRequestParams>(); | ||||||||
| private static readonly JsonTypeInfo<ReadResourceRequestParams> s_readResourceParamsTypeInfo = GetRequiredJsonTypeInfo<ReadResourceRequestParams>(); | ||||||||
|
|
||||||||
| private static bool AllowNewSessionForNonInitializeRequests { get; } = | ||||||||
| AppContext.TryGetSwitch("ModelContextProtocol.AspNetCore.AllowNewSessionForNonInitializeRequests", out var enabled) && enabled; | ||||||||
|
|
@@ -87,6 +92,11 @@ await WriteJsonRpcErrorAsync(context, | |||||||
|
|
||||||||
| await using var _ = await session.AcquireReferenceAsync(context.RequestAborted); | ||||||||
|
|
||||||||
| if (await TryHandleInsufficientScopeAsync(context, session, message)) | ||||||||
| { | ||||||||
| return; | ||||||||
| } | ||||||||
|
|
||||||||
| InitializeSseResponse(context); | ||||||||
| var wroteResponse = await session.Transport.HandlePostRequestAsync(message, context.Response.Body, context.RequestAborted); | ||||||||
| if (!wroteResponse) | ||||||||
|
|
@@ -463,6 +473,127 @@ private static Task WriteJsonRpcErrorAsync(HttpContext context, string errorMess | |||||||
| return Results.Json(jsonRpcError, s_errorTypeInfo, statusCode: statusCode).ExecuteAsync(context); | ||||||||
| } | ||||||||
|
|
||||||||
| /// <summary> | ||||||||
| /// Performs a pre-flight authorization check for invocation requests (tools/call, prompts/get, resources/read) | ||||||||
| /// when <see cref="HttpMcpServerBuilderExtensions.AddAuthorizationFilters"/> has not been called. | ||||||||
| /// If the request targets a primitive with <see cref="Microsoft.AspNetCore.Authorization.AuthorizeAttribute"/> | ||||||||
| /// metadata and the caller is not authorized, writes an HTTP 403 response with a | ||||||||
| /// <c>WWW-Authenticate: Bearer error="insufficient_scope"</c> header to trigger incremental scope consent (SEP-835). | ||||||||
| /// </summary> | ||||||||
| /// <returns><see langword="true"/> if a 403 response was written and request processing should stop; otherwise <see langword="false"/>.</returns> | ||||||||
| private async ValueTask<bool> TryHandleInsufficientScopeAsync(HttpContext context, StreamableHttpSession session, JsonRpcMessage message) | ||||||||
| { | ||||||||
| // Only applicable when AddAuthorizationFilters has NOT been called. | ||||||||
| // If it was called, the MCP filter pipeline handles authorization (hiding + MCP errors). | ||||||||
| if (httpServerTransportOptions.Value.AuthorizationFiltersRegistered) | ||||||||
| { | ||||||||
| return false; | ||||||||
| } | ||||||||
|
|
||||||||
| // Only handle invocation requests that target a specific primitive. | ||||||||
| if (message is not JsonRpcRequest request) | ||||||||
| { | ||||||||
| return false; | ||||||||
| } | ||||||||
|
|
||||||||
| var serverOptions = session.Server.ServerOptions; | ||||||||
| IMcpServerPrimitive? primitive = null; | ||||||||
|
|
||||||||
| switch (request.Method) | ||||||||
| { | ||||||||
| case RequestMethods.ToolsCall: | ||||||||
| { | ||||||||
| var toolParams = request.Params is { } p ? System.Text.Json.JsonSerializer.Deserialize(p, s_callToolParamsTypeInfo) : null; | ||||||||
| if (toolParams?.Name is { } toolName && serverOptions.ToolCollection is { } tools | ||||||||
| && tools.TryGetPrimitive(toolName, out var tool)) | ||||||||
| { | ||||||||
| primitive = tool; | ||||||||
| } | ||||||||
| break; | ||||||||
| } | ||||||||
| case RequestMethods.PromptsGet: | ||||||||
| { | ||||||||
| var promptParams = request.Params is { } p ? System.Text.Json.JsonSerializer.Deserialize(p, s_getPromptParamsTypeInfo) : null; | ||||||||
| if (promptParams?.Name is { } promptName && serverOptions.PromptCollection is { } prompts | ||||||||
| && prompts.TryGetPrimitive(promptName, out var prompt)) | ||||||||
| { | ||||||||
| primitive = prompt; | ||||||||
| } | ||||||||
| break; | ||||||||
| } | ||||||||
| case RequestMethods.ResourcesRead: | ||||||||
| { | ||||||||
| var resourceParams = request.Params is { } p ? System.Text.Json.JsonSerializer.Deserialize(p, s_readResourceParamsTypeInfo) : null; | ||||||||
| if (resourceParams?.Uri is { } resourceUri && serverOptions.ResourceCollection is { } resources) | ||||||||
| { | ||||||||
| // First try an exact match, then fall back to URI template matching. | ||||||||
| if (resources.TryGetPrimitive(resourceUri, out var resource) && !resource.IsTemplated) | ||||||||
| { | ||||||||
| primitive = resource; | ||||||||
| } | ||||||||
| else | ||||||||
| { | ||||||||
| foreach (var resourceTemplate in resources) | ||||||||
| { | ||||||||
| if (resourceTemplate.IsMatch(resourceUri)) | ||||||||
| { | ||||||||
| primitive = resourceTemplate; | ||||||||
| break; | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
| break; | ||||||||
| } | ||||||||
| default: | ||||||||
| return false; | ||||||||
| } | ||||||||
|
|
||||||||
| if (!AuthorizationFilterSetup.HasAuthorizationMetadata(primitive)) | ||||||||
| { | ||||||||
| return false; | ||||||||
| } | ||||||||
|
|
||||||||
| // Evaluate the authorization policy for this primitive. | ||||||||
| var policyProvider = context.RequestServices.GetService<IAuthorizationPolicyProvider>(); | ||||||||
| if (policyProvider is null) | ||||||||
| { | ||||||||
| // No authorization infrastructure configured; skip the pre-flight check. | ||||||||
| return false; | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. csharp-sdk/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs Lines 330 to 332 in 1492a5d
|
||||||||
| } | ||||||||
|
|
||||||||
| var policy = await AuthorizationFilterSetup.CombineAsync(policyProvider, primitive.Metadata); | ||||||||
| if (policy is null) | ||||||||
| { | ||||||||
| return false; | ||||||||
| } | ||||||||
|
|
||||||||
| var authService = context.RequestServices.GetRequiredService<IAuthorizationService>(); | ||||||||
| var authResult = await authService.AuthorizeAsync(context.User ?? new ClaimsPrincipal(new ClaimsIdentity()), context, policy); | ||||||||
| if (authResult.Succeeded) | ||||||||
| { | ||||||||
| return false; | ||||||||
| } | ||||||||
|
|
||||||||
| // Authorization failed. Build a WWW-Authenticate header with error="insufficient_scope". | ||||||||
| // Extract the scope from IAuthorizeData.Roles (the standard pattern for incremental scope consent). | ||||||||
| var scope = primitive.Metadata | ||||||||
| .OfType<IAuthorizeData>() | ||||||||
| .Select(static a => a.Roles) | ||||||||
| .FirstOrDefault(static r => !string.IsNullOrEmpty(r)); | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||
|
|
||||||||
| // Build the resource_metadata URL using the default well-known path for this endpoint. | ||||||||
| var resourceMetadataUri = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.PathBase}/.well-known/oauth-protected-resource{context.Request.Path}"; | ||||||||
|
|
||||||||
| var wwwAuthenticate = string.IsNullOrEmpty(scope) | ||||||||
| ? $"Bearer error=\"insufficient_scope\", resource_metadata=\"{resourceMetadataUri}\"" | ||||||||
| : $"Bearer error=\"insufficient_scope\", scope=\"{scope}\", resource_metadata=\"{resourceMetadataUri}\""; | ||||||||
|
|
||||||||
| context.Response.Headers[HeaderNames.WWWAuthenticate] = wwwAuthenticate; | ||||||||
| await WriteJsonRpcErrorAsync(context, "Forbidden: Insufficient scope.", StatusCodes.Status403Forbidden, (int)McpErrorCode.InvalidRequest); | ||||||||
| return true; | ||||||||
| } | ||||||||
|
|
||||||||
| internal static void InitializeSseResponse(HttpContext context) | ||||||||
| { | ||||||||
| context.Response.Headers.ContentType = "text/event-stream"; | ||||||||
|
|
||||||||
There was a problem hiding this comment.
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.