Skip to content

Commit 83a6ef4

Browse files
Copilotmikekistler
andcommitted
Add built-in server-side support for incremental scope consent (SEP-835)
Agent-Logs-Url: https://github.com/modelcontextprotocol/csharp-sdk/sessions/5671ae62-779a-42f5-b84c-77d470131732 Co-authored-by: mikekistler <85643503+mikekistler@users.noreply.github.com>
1 parent c437463 commit 83a6ef4

6 files changed

Lines changed: 568 additions & 203 deletions

File tree

src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs

Lines changed: 3 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,6 @@ public void Configure(McpServerOptions options)
3030

3131
public void PostConfigure(string? name, McpServerOptions options)
3232
{
33-
CheckListToolsFilter(options);
34-
CheckCallToolFilter(options);
35-
36-
CheckListResourcesFilter(options);
37-
CheckListResourceTemplatesFilter(options);
38-
CheckReadResourceFilter(options);
39-
40-
CheckListPromptsFilter(options);
41-
CheckGetPromptFilter(options);
4233
}
4334

4435
private void ConfigureListToolsFilter(McpServerOptions options)
@@ -59,26 +50,6 @@ await FilterAuthorizedItemsAsync(
5950
});
6051
}
6152

62-
private static void CheckListToolsFilter(McpServerOptions options)
63-
{
64-
options.Filters.Request.ListToolsFilters.Add(next =>
65-
{
66-
var toolCollection = options.ToolCollection;
67-
return async (context, cancellationToken) =>
68-
{
69-
var result = await next(context, cancellationToken);
70-
71-
if (HasAuthorizationMetadata(result.Tools.Select(tool => toolCollection is not null && toolCollection.TryGetPrimitive(tool.Name, out var serverTool) ? serverTool : null))
72-
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
73-
{
74-
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.");
75-
}
76-
77-
return result;
78-
};
79-
});
80-
}
81-
8253
private void ConfigureCallToolFilter(McpServerOptions options)
8354
{
8455
options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) =>
@@ -95,20 +66,6 @@ private void ConfigureCallToolFilter(McpServerOptions options)
9566
});
9667
}
9768

98-
private static void CheckCallToolFilter(McpServerOptions options)
99-
{
100-
options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) =>
101-
{
102-
if (HasAuthorizationMetadata(context.MatchedPrimitive)
103-
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
104-
{
105-
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.");
106-
}
107-
108-
return await next(context, cancellationToken);
109-
});
110-
}
111-
11269
private void ConfigureListResourcesFilter(McpServerOptions options)
11370
{
11471
options.Filters.Request.ListResourcesFilters.Add(next =>
@@ -127,26 +84,6 @@ await FilterAuthorizedItemsAsync(
12784
});
12885
}
12986

130-
private static void CheckListResourcesFilter(McpServerOptions options)
131-
{
132-
options.Filters.Request.ListResourcesFilters.Add(next =>
133-
{
134-
var resourceCollection = options.ResourceCollection;
135-
return async (context, cancellationToken) =>
136-
{
137-
var result = await next(context, cancellationToken);
138-
139-
if (HasAuthorizationMetadata(result.Resources.Select(resource => resourceCollection is not null && resourceCollection.TryGetPrimitive(resource.Uri, out var serverResource) ? serverResource : null))
140-
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
141-
{
142-
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.");
143-
}
144-
145-
return result;
146-
};
147-
});
148-
}
149-
15087
private void ConfigureListResourceTemplatesFilter(McpServerOptions options)
15188
{
15289
options.Filters.Request.ListResourceTemplatesFilters.Add(next =>
@@ -165,26 +102,6 @@ await FilterAuthorizedItemsAsync(
165102
});
166103
}
167104

168-
private static void CheckListResourceTemplatesFilter(McpServerOptions options)
169-
{
170-
options.Filters.Request.ListResourceTemplatesFilters.Add(next =>
171-
{
172-
var resourceCollection = options.ResourceCollection;
173-
return async (context, cancellationToken) =>
174-
{
175-
var result = await next(context, cancellationToken);
176-
177-
if (HasAuthorizationMetadata(result.ResourceTemplates.Select(resourceTemplate => resourceCollection is not null && resourceCollection.TryGetPrimitive(resourceTemplate.UriTemplate, out var serverResource) ? serverResource : null))
178-
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
179-
{
180-
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.");
181-
}
182-
183-
return result;
184-
};
185-
});
186-
}
187-
188105
private void ConfigureReadResourceFilter(McpServerOptions options)
189106
{
190107
options.Filters.Request.ReadResourceFilters.Add(next => async (context, cancellationToken) =>
@@ -201,20 +118,6 @@ private void ConfigureReadResourceFilter(McpServerOptions options)
201118
});
202119
}
203120

204-
private static void CheckReadResourceFilter(McpServerOptions options)
205-
{
206-
options.Filters.Request.ReadResourceFilters.Add(next => async (context, cancellationToken) =>
207-
{
208-
if (HasAuthorizationMetadata(context.MatchedPrimitive)
209-
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
210-
{
211-
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.");
212-
}
213-
214-
return await next(context, cancellationToken);
215-
});
216-
}
217-
218121
private void ConfigureListPromptsFilter(McpServerOptions options)
219122
{
220123
options.Filters.Request.ListPromptsFilters.Add(next =>
@@ -233,26 +136,6 @@ await FilterAuthorizedItemsAsync(
233136
});
234137
}
235138

236-
private static void CheckListPromptsFilter(McpServerOptions options)
237-
{
238-
options.Filters.Request.ListPromptsFilters.Add(next =>
239-
{
240-
var promptCollection = options.PromptCollection;
241-
return async (context, cancellationToken) =>
242-
{
243-
var result = await next(context, cancellationToken);
244-
245-
if (HasAuthorizationMetadata(result.Prompts.Select(prompt => promptCollection is not null && promptCollection.TryGetPrimitive(prompt.Name, out var serverPrompt) ? serverPrompt : null))
246-
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
247-
{
248-
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.");
249-
}
250-
251-
return result;
252-
};
253-
});
254-
}
255-
256139
private void ConfigureGetPromptFilter(McpServerOptions options)
257140
{
258141
options.Filters.Request.GetPromptFilters.Add(next => async (context, cancellationToken) =>
@@ -269,20 +152,6 @@ private void ConfigureGetPromptFilter(McpServerOptions options)
269152
});
270153
}
271154

272-
private static void CheckGetPromptFilter(McpServerOptions options)
273-
{
274-
options.Filters.Request.GetPromptFilters.Add(next => async (context, cancellationToken) =>
275-
{
276-
if (HasAuthorizationMetadata(context.MatchedPrimitive)
277-
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
278-
{
279-
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.");
280-
}
281-
282-
return await next(context, cancellationToken);
283-
});
284-
}
285-
286155
/// <summary>
287156
/// Filters a collection of items based on authorization policies in their metadata.
288157
/// For list operations where we need to filter results by authorization.
@@ -338,7 +207,7 @@ private async ValueTask<AuthorizationResult> GetAuthorizationResultAsync(
338207
/// <param name="policyProvider">The authorization policy provider.</param>
339208
/// <param name="endpointMetadata">The endpoint metadata collection.</param>
340209
/// <returns>The combined authorization policy, or null if no authorization is required.</returns>
341-
private static async ValueTask<AuthorizationPolicy?> CombineAsync(IAuthorizationPolicyProvider policyProvider, IReadOnlyList<object> endpointMetadata)
210+
internal static async ValueTask<AuthorizationPolicy?> CombineAsync(IAuthorizationPolicyProvider policyProvider, IReadOnlyList<object> endpointMetadata)
342211
{
343212
// https://github.com/dotnet/aspnetcore/issues/63365 tracks adding this as public API to AuthorizationPolicy itself.
344213
// Copied from https://github.com/dotnet/aspnetcore/blob/9f2977bf9cfb539820983bda3bedf81c8cda9f20/src/Security/Authorization/Policy/src/AuthorizationMiddleware.cs#L116-L138
@@ -374,7 +243,7 @@ private async ValueTask<AuthorizationResult> GetAuthorizationResultAsync(
374243
: AuthorizationPolicy.Combine(policy, reqPolicyBuilder.Build());
375244
}
376245

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

386255
return primitive.Metadata.Any(static m => m is IAuthorizeData or AuthorizationPolicy or IAuthorizationRequirementData);
387256
}
388-
389-
private static bool HasAuthorizationMetadata(IEnumerable<IMcpServerPrimitive?> primitives)
390-
=> primitives.Any(HasAuthorizationMetadata);
391-
}
257+
}

src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ public static IMcpServerBuilder AddAuthorizationFilters(this IMcpServerBuilder b
6464
// Allow the authorization filters to get added multiple times in case other middleware changes the matched primitive.
6565
builder.Services.AddTransient<IConfigureOptions<McpServerOptions>, AuthorizationFilterSetup>();
6666

67+
// Signal to the HTTP transport that authorization filters are handling access control,
68+
// so the pre-flight incremental scope consent check (SEP-835) should be skipped.
69+
builder.Services.Configure<HttpServerTransportOptions>(static o => o.AuthorizationFiltersRegistered = true);
70+
6771
return builder;
6872
}
6973

src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,4 +188,13 @@ public class HttpServerTransportOptions
188188
/// Gets or sets the time provider that's used for testing the <see cref="IdleTimeout"/>.
189189
/// </summary>
190190
public TimeProvider TimeProvider { get; set; } = TimeProvider.System;
191+
192+
/// <summary>
193+
/// Gets a value indicating whether authorization filters have been registered via
194+
/// <c>AddAuthorizationFilters</c>.
195+
/// When <see langword="true"/>, the MCP filter pipeline handles authorization (hiding unauthorized primitives and returning MCP errors).
196+
/// When <see langword="false"/> (the default), the HTTP transport performs a pre-flight authorization check that returns
197+
/// HTTP 403 with <c>WWW-Authenticate: Bearer error="insufficient_scope"</c> for incremental scope consent (SEP-835).
198+
/// </summary>
199+
internal bool AuthorizationFiltersRegistered { get; set; }
191200
}

src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
using Microsoft.AspNetCore.Http;
1+
using Microsoft.AspNetCore.Authorization;
2+
using Microsoft.AspNetCore.Http;
23
using Microsoft.AspNetCore.Http.Features;
34
using Microsoft.AspNetCore.WebUtilities;
5+
using Microsoft.Extensions.DependencyInjection;
46
using Microsoft.Extensions.Hosting;
57
using Microsoft.Extensions.Logging;
68
using Microsoft.Extensions.Options;
@@ -41,6 +43,9 @@ internal sealed class StreamableHttpHandler(
4143

4244
private static readonly JsonTypeInfo<JsonRpcMessage> s_messageTypeInfo = GetRequiredJsonTypeInfo<JsonRpcMessage>();
4345
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
46+
private static readonly JsonTypeInfo<CallToolRequestParams> s_callToolParamsTypeInfo = GetRequiredJsonTypeInfo<CallToolRequestParams>();
47+
private static readonly JsonTypeInfo<GetPromptRequestParams> s_getPromptParamsTypeInfo = GetRequiredJsonTypeInfo<GetPromptRequestParams>();
48+
private static readonly JsonTypeInfo<ReadResourceRequestParams> s_readResourceParamsTypeInfo = GetRequiredJsonTypeInfo<ReadResourceRequestParams>();
4449

4550
private static bool AllowNewSessionForNonInitializeRequests { get; } =
4651
AppContext.TryGetSwitch("ModelContextProtocol.AspNetCore.AllowNewSessionForNonInitializeRequests", out var enabled) && enabled;
@@ -87,6 +92,11 @@ await WriteJsonRpcErrorAsync(context,
8792

8893
await using var _ = await session.AcquireReferenceAsync(context.RequestAborted);
8994

95+
if (await TryHandleInsufficientScopeAsync(context, session, message))
96+
{
97+
return;
98+
}
99+
90100
InitializeSseResponse(context);
91101
var wroteResponse = await session.Transport.HandlePostRequestAsync(message, context.Response.Body, context.RequestAborted);
92102
if (!wroteResponse)
@@ -463,6 +473,127 @@ private static Task WriteJsonRpcErrorAsync(HttpContext context, string errorMess
463473
return Results.Json(jsonRpcError, s_errorTypeInfo, statusCode: statusCode).ExecuteAsync(context);
464474
}
465475

476+
/// <summary>
477+
/// Performs a pre-flight authorization check for invocation requests (tools/call, prompts/get, resources/read)
478+
/// when <see cref="HttpMcpServerBuilderExtensions.AddAuthorizationFilters"/> has not been called.
479+
/// If the request targets a primitive with <see cref="Microsoft.AspNetCore.Authorization.AuthorizeAttribute"/>
480+
/// metadata and the caller is not authorized, writes an HTTP 403 response with a
481+
/// <c>WWW-Authenticate: Bearer error="insufficient_scope"</c> header to trigger incremental scope consent (SEP-835).
482+
/// </summary>
483+
/// <returns><see langword="true"/> if a 403 response was written and request processing should stop; otherwise <see langword="false"/>.</returns>
484+
private async ValueTask<bool> TryHandleInsufficientScopeAsync(HttpContext context, StreamableHttpSession session, JsonRpcMessage message)
485+
{
486+
// Only applicable when AddAuthorizationFilters has NOT been called.
487+
// If it was called, the MCP filter pipeline handles authorization (hiding + MCP errors).
488+
if (httpServerTransportOptions.Value.AuthorizationFiltersRegistered)
489+
{
490+
return false;
491+
}
492+
493+
// Only handle invocation requests that target a specific primitive.
494+
if (message is not JsonRpcRequest request)
495+
{
496+
return false;
497+
}
498+
499+
var serverOptions = session.Server.ServerOptions;
500+
IMcpServerPrimitive? primitive = null;
501+
502+
switch (request.Method)
503+
{
504+
case RequestMethods.ToolsCall:
505+
{
506+
var toolParams = request.Params is { } p ? System.Text.Json.JsonSerializer.Deserialize(p, s_callToolParamsTypeInfo) : null;
507+
if (toolParams?.Name is { } toolName && serverOptions.ToolCollection is { } tools
508+
&& tools.TryGetPrimitive(toolName, out var tool))
509+
{
510+
primitive = tool;
511+
}
512+
break;
513+
}
514+
case RequestMethods.PromptsGet:
515+
{
516+
var promptParams = request.Params is { } p ? System.Text.Json.JsonSerializer.Deserialize(p, s_getPromptParamsTypeInfo) : null;
517+
if (promptParams?.Name is { } promptName && serverOptions.PromptCollection is { } prompts
518+
&& prompts.TryGetPrimitive(promptName, out var prompt))
519+
{
520+
primitive = prompt;
521+
}
522+
break;
523+
}
524+
case RequestMethods.ResourcesRead:
525+
{
526+
var resourceParams = request.Params is { } p ? System.Text.Json.JsonSerializer.Deserialize(p, s_readResourceParamsTypeInfo) : null;
527+
if (resourceParams?.Uri is { } resourceUri && serverOptions.ResourceCollection is { } resources)
528+
{
529+
// First try an exact match, then fall back to URI template matching.
530+
if (resources.TryGetPrimitive(resourceUri, out var resource) && !resource.IsTemplated)
531+
{
532+
primitive = resource;
533+
}
534+
else
535+
{
536+
foreach (var resourceTemplate in resources)
537+
{
538+
if (resourceTemplate.IsMatch(resourceUri))
539+
{
540+
primitive = resourceTemplate;
541+
break;
542+
}
543+
}
544+
}
545+
}
546+
break;
547+
}
548+
default:
549+
return false;
550+
}
551+
552+
if (!AuthorizationFilterSetup.HasAuthorizationMetadata(primitive))
553+
{
554+
return false;
555+
}
556+
557+
// Evaluate the authorization policy for this primitive.
558+
var policyProvider = context.RequestServices.GetService<IAuthorizationPolicyProvider>();
559+
if (policyProvider is null)
560+
{
561+
// No authorization infrastructure configured; skip the pre-flight check.
562+
return false;
563+
}
564+
565+
var policy = await AuthorizationFilterSetup.CombineAsync(policyProvider, primitive.Metadata);
566+
if (policy is null)
567+
{
568+
return false;
569+
}
570+
571+
var authService = context.RequestServices.GetRequiredService<IAuthorizationService>();
572+
var authResult = await authService.AuthorizeAsync(context.User ?? new ClaimsPrincipal(new ClaimsIdentity()), context, policy);
573+
if (authResult.Succeeded)
574+
{
575+
return false;
576+
}
577+
578+
// Authorization failed. Build a WWW-Authenticate header with error="insufficient_scope".
579+
// Extract the scope from IAuthorizeData.Roles (the standard pattern for incremental scope consent).
580+
var scope = primitive.Metadata
581+
.OfType<IAuthorizeData>()
582+
.Select(static a => a.Roles)
583+
.FirstOrDefault(static r => !string.IsNullOrEmpty(r));
584+
585+
// Build the resource_metadata URL using the default well-known path for this endpoint.
586+
var resourceMetadataUri = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.PathBase}/.well-known/oauth-protected-resource{context.Request.Path}";
587+
588+
var wwwAuthenticate = string.IsNullOrEmpty(scope)
589+
? $"Bearer error=\"insufficient_scope\", resource_metadata=\"{resourceMetadataUri}\""
590+
: $"Bearer error=\"insufficient_scope\", scope=\"{scope}\", resource_metadata=\"{resourceMetadataUri}\"";
591+
592+
context.Response.Headers[HeaderNames.WWWAuthenticate] = wwwAuthenticate;
593+
await WriteJsonRpcErrorAsync(context, "Forbidden: Insufficient scope.", StatusCodes.Status403Forbidden, (int)McpErrorCode.InvalidRequest);
594+
return true;
595+
}
596+
466597
internal static void InitializeSseResponse(HttpContext context)
467598
{
468599
context.Response.Headers.ContentType = "text/event-stream";

0 commit comments

Comments
 (0)