Skip to content
Draft
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
140 changes: 3 additions & 137 deletions src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,6 @@ public void Configure(McpServerOptions options)

public void PostConfigure(string? name, McpServerOptions options)
{
CheckListToolsFilter(options);
CheckCallToolFilter(options);

CheckListResourcesFilter(options);
CheckListResourceTemplatesFilter(options);
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.

}

private void ConfigureListToolsFilter(McpServerOptions options)
Expand All @@ -59,26 +50,6 @@ await FilterAuthorizedItemsAsync(
});
}

private static void CheckListToolsFilter(McpServerOptions options)
{
options.Filters.Request.ListToolsFilters.Add(next =>
{
var toolCollection = options.ToolCollection;
return async (context, cancellationToken) =>
{
var result = await next(context, cancellationToken);

if (HasAuthorizationMetadata(result.Tools.Select(tool => toolCollection is not null && toolCollection.TryGetPrimitive(tool.Name, out var serverTool) ? serverTool : null))
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
{
throw new InvalidOperationException("Authorization filter was not invoked for tools/list operation, but authorization metadata was found on the tools. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
}

return result;
};
});
}

private void ConfigureCallToolFilter(McpServerOptions options)
{
options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) =>
Expand All @@ -95,20 +66,6 @@ private void ConfigureCallToolFilter(McpServerOptions options)
});
}

private static void CheckCallToolFilter(McpServerOptions options)
{
options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) =>
{
if (HasAuthorizationMetadata(context.MatchedPrimitive)
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
{
throw new InvalidOperationException("Authorization filter was not invoked for tools/call operation, but authorization metadata was found on the tool. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
}

return await next(context, cancellationToken);
});
}

private void ConfigureListResourcesFilter(McpServerOptions options)
{
options.Filters.Request.ListResourcesFilters.Add(next =>
Expand All @@ -127,26 +84,6 @@ await FilterAuthorizedItemsAsync(
});
}

private static void CheckListResourcesFilter(McpServerOptions options)
{
options.Filters.Request.ListResourcesFilters.Add(next =>
{
var resourceCollection = options.ResourceCollection;
return async (context, cancellationToken) =>
{
var result = await next(context, cancellationToken);

if (HasAuthorizationMetadata(result.Resources.Select(resource => resourceCollection is not null && resourceCollection.TryGetPrimitive(resource.Uri, out var serverResource) ? serverResource : null))
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
{
throw new InvalidOperationException("Authorization filter was not invoked for resources/list operation, but authorization metadata was found on the resources. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
}

return result;
};
});
}

private void ConfigureListResourceTemplatesFilter(McpServerOptions options)
{
options.Filters.Request.ListResourceTemplatesFilters.Add(next =>
Expand All @@ -165,26 +102,6 @@ await FilterAuthorizedItemsAsync(
});
}

private static void CheckListResourceTemplatesFilter(McpServerOptions options)
{
options.Filters.Request.ListResourceTemplatesFilters.Add(next =>
{
var resourceCollection = options.ResourceCollection;
return async (context, cancellationToken) =>
{
var result = await next(context, cancellationToken);

if (HasAuthorizationMetadata(result.ResourceTemplates.Select(resourceTemplate => resourceCollection is not null && resourceCollection.TryGetPrimitive(resourceTemplate.UriTemplate, out var serverResource) ? serverResource : null))
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
{
throw new InvalidOperationException("Authorization filter was not invoked for resources/templates/list operation, but authorization metadata was found on the resource templates. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
}

return result;
};
});
}

private void ConfigureReadResourceFilter(McpServerOptions options)
{
options.Filters.Request.ReadResourceFilters.Add(next => async (context, cancellationToken) =>
Expand All @@ -201,20 +118,6 @@ private void ConfigureReadResourceFilter(McpServerOptions options)
});
}

private static void CheckReadResourceFilter(McpServerOptions options)
{
options.Filters.Request.ReadResourceFilters.Add(next => async (context, cancellationToken) =>
{
if (HasAuthorizationMetadata(context.MatchedPrimitive)
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
{
throw new InvalidOperationException("Authorization filter was not invoked for resources/read operation, but authorization metadata was found on the resource. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
}

return await next(context, cancellationToken);
});
}

private void ConfigureListPromptsFilter(McpServerOptions options)
{
options.Filters.Request.ListPromptsFilters.Add(next =>
Expand All @@ -233,26 +136,6 @@ await FilterAuthorizedItemsAsync(
});
}

private static void CheckListPromptsFilter(McpServerOptions options)
{
options.Filters.Request.ListPromptsFilters.Add(next =>
{
var promptCollection = options.PromptCollection;
return async (context, cancellationToken) =>
{
var result = await next(context, cancellationToken);

if (HasAuthorizationMetadata(result.Prompts.Select(prompt => promptCollection is not null && promptCollection.TryGetPrimitive(prompt.Name, out var serverPrompt) ? serverPrompt : null))
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
{
throw new InvalidOperationException("Authorization filter was not invoked for prompts/list operation, but authorization metadata was found on the prompts. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
}

return result;
};
});
}

private void ConfigureGetPromptFilter(McpServerOptions options)
{
options.Filters.Request.GetPromptFilters.Add(next => async (context, cancellationToken) =>
Expand All @@ -269,20 +152,6 @@ private void ConfigureGetPromptFilter(McpServerOptions options)
});
}

private static void CheckGetPromptFilter(McpServerOptions options)
{
options.Filters.Request.GetPromptFilters.Add(next => async (context, cancellationToken) =>
{
if (HasAuthorizationMetadata(context.MatchedPrimitive)
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
{
throw new InvalidOperationException("Authorization filter was not invoked for prompts/get operation, but authorization metadata was found on the prompt. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
}

return await next(context, cancellationToken);
});
}

/// <summary>
/// Filters a collection of items based on authorization policies in their metadata.
/// For list operations where we need to filter results by authorization.
Expand Down Expand Up @@ -338,7 +207,7 @@ private async ValueTask<AuthorizationResult> GetAuthorizationResultAsync(
/// <param name="policyProvider">The authorization policy provider.</param>
/// <param name="endpointMetadata">The endpoint metadata collection.</param>
/// <returns>The combined authorization policy, or null if no authorization is required.</returns>
private static async ValueTask<AuthorizationPolicy?> CombineAsync(IAuthorizationPolicyProvider policyProvider, IReadOnlyList<object> endpointMetadata)
internal static async ValueTask<AuthorizationPolicy?> CombineAsync(IAuthorizationPolicyProvider policyProvider, IReadOnlyList<object> endpointMetadata)
{
// https://github.com/dotnet/aspnetcore/issues/63365 tracks adding this as public API to AuthorizationPolicy itself.
// Copied from https://github.com/dotnet/aspnetcore/blob/9f2977bf9cfb539820983bda3bedf81c8cda9f20/src/Security/Authorization/Policy/src/AuthorizationMiddleware.cs#L116-L138
Expand Down Expand Up @@ -374,7 +243,7 @@ private async ValueTask<AuthorizationResult> GetAuthorizationResultAsync(
: AuthorizationPolicy.Combine(policy, reqPolicyBuilder.Build());
}

private static bool HasAuthorizationMetadata([NotNullWhen(true)] IMcpServerPrimitive? primitive)
internal static bool HasAuthorizationMetadata([NotNullWhen(true)] IMcpServerPrimitive? primitive)
{
// If no primitive was found for this request or there is IAllowAnonymous metadata anywhere on the class or method,
// the request should go through as normal.
Expand All @@ -385,7 +254,4 @@ private static bool HasAuthorizationMetadata([NotNullWhen(true)] IMcpServerPrimi

return primitive.Metadata.Any(static m => m is IAuthorizeData or AuthorizationPolicy or IAuthorizationRequirementData);
}

private static bool HasAuthorizationMetadata(IEnumerable<IMcpServerPrimitive?> primitives)
=> primitives.Any(HasAuthorizationMetadata);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
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?


return builder;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,4 +188,13 @@ public class HttpServerTransportOptions
/// Gets or sets the time provider that's used for testing the <see cref="IdleTimeout"/>.
/// </summary>
public TimeProvider TimeProvider { get; set; } = TimeProvider.System;

/// <summary>
/// Gets a value indicating whether authorization filters have been registered via
/// <c>AddAuthorizationFilters</c>.
/// When <see langword="true"/>, the MCP filter pipeline handles authorization (hiding unauthorized primitives and returning MCP errors).
/// When <see langword="false"/> (the default), the HTTP transport performs a pre-flight authorization check that returns
/// HTTP 403 with <c>WWW-Authenticate: Bearer error="insufficient_scope"</c> for incremental scope consent (SEP-835).
/// </summary>
internal bool AuthorizationFiltersRegistered { get; set; }
}
133 changes: 132 additions & 1 deletion src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
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);

}

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));
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.


// 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";
Expand Down
Loading
Loading