From 8ce299f2b0b7a8f0dc7d4a3400ae2f0ba8f01a6a Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Fri, 24 Jul 2026 17:03:54 -0700 Subject: [PATCH 1/4] Reauthorize tools changed by filters Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5feb0906-73fc-4e1a-8cf2-c9a99c588487 --- .../AuthorizationFilterSetup.cs | 90 +++++++++++++++++-- .../HttpMcpServerBuilderExtensions.cs | 4 + .../AuthorizeAttributeTests.cs | 35 ++++++++ 3 files changed, 120 insertions(+), 9 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs b/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs index 5b371d9a1..539f60995 100644 --- a/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs +++ b/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs @@ -13,9 +13,12 @@ namespace ModelContextProtocol.AspNetCore; /// internal sealed class AuthorizationFilterSetup( IAuthorizationPolicyProvider? policyProvider = null, - AuthorizationFiltersMarker? marker = null) : IConfigureOptions, IPostConfigureOptions + AuthorizationFiltersMarker? marker = null, + IEnumerable? callToolFilterCheckpoints = null) : + IConfigureOptions, IPostConfigureOptions { private static readonly string AuthorizationFilterInvokedKey = "ModelContextProtocol.AspNetCore.AuthorizationFilter.Invoked"; + private static readonly string LastAuthorizedToolKey = "ModelContextProtocol.AspNetCore.AuthorizationFilter.LastAuthorizedTool"; public void Configure(McpServerOptions options) { @@ -31,10 +34,12 @@ public void Configure(McpServerOptions options) public void PostConfigure(string? name, McpServerOptions options) { + ConfigureCallToolFilters(options); + // Add tool authorization after all regular configuration so it always wraps Tasks. if (marker is not null) { - ConfigureCallToolFilter(options); + ConfigureAlternateCallToolFilter(options); } CheckListToolsFilter(options); @@ -85,24 +90,63 @@ private static void CheckListToolsFilter(McpServerOptions options) }); } - private void ConfigureCallToolFilter(McpServerOptions options) + private void ConfigureCallToolFilters(McpServerOptions options) { -#pragma warning disable MCPEXP002 // Authorization must run in the alternate-result pipeline before task dispatch. - options.Filters.Request.CallToolWithAlternateFilters.Insert(0, async (context, next, cancellationToken) => + if (callToolFilterCheckpoints is null) { - var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context); - if (!authResult.Succeeded) + return; + } + + int insertedFilters = 0; + foreach (var checkpoint in callToolFilterCheckpoints) + { + if (!checkpoint.TryTakeIndex(options, out var index)) { - throw new McpProtocolException("Access forbidden: This tool requires authorization.", McpErrorCode.InvalidRequest); + continue; } - context.Items[AuthorizationFilterInvokedKey] = true; + options.Filters.Request.CallToolFilters.Insert( + index + insertedFilters++, + CreateCallToolFilter()); + } + } + private McpRequestFilter CreateCallToolFilter() + => next => async (context, cancellationToken) => + { + await AuthorizeToolAsync(context); + return await next(context, cancellationToken); + }; + + private void ConfigureAlternateCallToolFilter(McpServerOptions options) + { +#pragma warning disable MCPEXP002 // Authorization must run in the alternate-result pipeline before task dispatch. + options.Filters.Request.CallToolWithAlternateFilters.Insert(0, async (context, next, cancellationToken) => + { + await AuthorizeToolAsync(context); return await next(context, cancellationToken); }); #pragma warning restore MCPEXP002 } + private async ValueTask AuthorizeToolAsync(RequestContext context) + { + if (context.Items.TryGetValue(LastAuthorizedToolKey, out var lastAuthorizedTool) && + ReferenceEquals(lastAuthorizedTool, context.MatchedPrimitive)) + { + return; + } + + var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context); + if (!authResult.Succeeded) + { + throw new McpProtocolException("Access forbidden: This tool requires authorization.", McpErrorCode.InvalidRequest); + } + + context.Items[AuthorizationFilterInvokedKey] = true; + context.Items[LastAuthorizedToolKey] = context.MatchedPrimitive; + } + private void ConfigureListResourcesFilter(McpServerOptions options) { options.Filters.Request.ListResourcesFilters.Add(next => @@ -382,4 +426,32 @@ internal static bool HasAuthorizationMetadata([NotNullWhen(true)] IMcpServerPrim private static bool HasAuthorizationMetadata(IEnumerable primitives) => primitives.Any(HasAuthorizationMetadata); +} + +internal sealed class AuthorizationCallToolFilterCheckpoint : IConfigureOptions +{ + private readonly Dictionary _indices = new(ReferenceEqualityComparer.Instance); + + public void Configure(McpServerOptions options) + { + lock (_indices) + { + _indices.Add(options, options.Filters.Request.CallToolFilters.Count); + } + } + + public bool TryTakeIndex(McpServerOptions options, out int index) + { + lock (_indices) + { + if (_indices.TryGetValue(options, out index)) + { + _indices.Remove(options); + return true; + } + } + + index = 0; + return false; + } } \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index 801da5f6e..05750872d 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -58,6 +58,7 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder /// authorization attributes such as /// and . Tool authorization runs in the alternate-result pipeline before /// the Tasks extension dispatches background execution, so an unauthorized tool call does not create a task. + /// Call this method again after any call-tool filter that changes the matched tool to authorize the replacement. /// public static IMcpServerBuilder AddAuthorizationFilters(this IMcpServerBuilder builder) { @@ -67,6 +68,9 @@ public static IMcpServerBuilder AddAuthorizationFilters(this IMcpServerBuilder b builder.Services.TryAddSingleton(); builder.Services.AddTransient, AuthorizationFilterSetup>(); builder.Services.TryAddEnumerable(ServiceDescriptor.Transient, AuthorizationFilterSetup>()); + var callToolFilterCheckpoint = new AuthorizationCallToolFilterCheckpoint(); + builder.Services.AddSingleton(callToolFilterCheckpoint); + builder.Services.AddSingleton>(callToolFilterCheckpoint); return builder; } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs index 76a7201d8..3facea431 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs @@ -93,6 +93,41 @@ public async Task Authorize_Tool_AllowsAuthenticatedUser() Assert.Equal("Authorized: test", content.Text); } + [Fact] + public async Task Authorize_Tool_ReauthorizesPrimitiveChangedByInterveningFilter() + { + await using var app = await StartServerWithAuth(builder => + { + builder.WithTools(); + builder.Services.Configure(options => + { + if (options.ToolCollection is null || + !options.ToolCollection.TryGetPrimitive("authorized_tool", out var authorizedTool)) + { + throw new InvalidOperationException("The replacement tool was not registered."); + } + + options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => + { + context.MatchedPrimitive = authorizedTool; + return await next(context, cancellationToken); + }); + }); + builder.AddAuthorizationFilters(); + }); + + var client = await ConnectAsync(); + + var exception = await Assert.ThrowsAsync(async () => + await client.CallToolAsync( + "anonymous_tool", + new Dictionary { ["message"] = "test" }, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal("Request failed (remote): Access forbidden: This tool requires authorization.", exception.Message); + Assert.Equal(McpErrorCode.InvalidRequest, exception.ErrorCode); + } + [Fact] public async Task AuthorizeWithRoles_Tool_RequiresAdminRole() { From 52df41dbf68dbfbc546df83fb2bea519ed70d07d Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Fri, 24 Jul 2026 19:00:42 -0700 Subject: [PATCH 2/4] Simplify task authorization filters Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5feb0906-73fc-4e1a-8cf2-c9a99c588487 --- .../AuthorizationFilterSetup.cs | 83 +------------------ .../HttpMcpServerBuilderExtensions.cs | 6 +- .../Server/McpTasksBuilderExtensions.cs | 8 -- .../HttpTaskIntegrationTests.cs | 52 +++++++----- 4 files changed, 37 insertions(+), 112 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs b/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs index 539f60995..85ed38e28 100644 --- a/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs +++ b/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs @@ -12,17 +12,15 @@ namespace ModelContextProtocol.AspNetCore; /// Evaluates authorization policies from endpoint metadata. /// internal sealed class AuthorizationFilterSetup( - IAuthorizationPolicyProvider? policyProvider = null, - AuthorizationFiltersMarker? marker = null, - IEnumerable? callToolFilterCheckpoints = null) : + IAuthorizationPolicyProvider? policyProvider = null) : IConfigureOptions, IPostConfigureOptions { private static readonly string AuthorizationFilterInvokedKey = "ModelContextProtocol.AspNetCore.AuthorizationFilter.Invoked"; - private static readonly string LastAuthorizedToolKey = "ModelContextProtocol.AspNetCore.AuthorizationFilter.LastAuthorizedTool"; public void Configure(McpServerOptions options) { ConfigureListToolsFilter(options); + ConfigureCallToolFilter(options); ConfigureListResourcesFilter(options); ConfigureListResourceTemplatesFilter(options); @@ -34,14 +32,6 @@ public void Configure(McpServerOptions options) public void PostConfigure(string? name, McpServerOptions options) { - ConfigureCallToolFilters(options); - - // Add tool authorization after all regular configuration so it always wraps Tasks. - if (marker is not null) - { - ConfigureAlternateCallToolFilter(options); - } - CheckListToolsFilter(options); CheckListResourcesFilter(options); @@ -90,53 +80,17 @@ private static void CheckListToolsFilter(McpServerOptions options) }); } - private void ConfigureCallToolFilters(McpServerOptions options) - { - if (callToolFilterCheckpoints is null) - { - return; - } - - int insertedFilters = 0; - foreach (var checkpoint in callToolFilterCheckpoints) - { - if (!checkpoint.TryTakeIndex(options, out var index)) - { - continue; - } - - options.Filters.Request.CallToolFilters.Insert( - index + insertedFilters++, - CreateCallToolFilter()); - } - } - - private McpRequestFilter CreateCallToolFilter() - => next => async (context, cancellationToken) => - { - await AuthorizeToolAsync(context); - return await next(context, cancellationToken); - }; - - private void ConfigureAlternateCallToolFilter(McpServerOptions options) + private void ConfigureCallToolFilter(McpServerOptions options) { -#pragma warning disable MCPEXP002 // Authorization must run in the alternate-result pipeline before task dispatch. - options.Filters.Request.CallToolWithAlternateFilters.Insert(0, async (context, next, cancellationToken) => + options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => { await AuthorizeToolAsync(context); return await next(context, cancellationToken); }); -#pragma warning restore MCPEXP002 } private async ValueTask AuthorizeToolAsync(RequestContext context) { - if (context.Items.TryGetValue(LastAuthorizedToolKey, out var lastAuthorizedTool) && - ReferenceEquals(lastAuthorizedTool, context.MatchedPrimitive)) - { - return; - } - var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context); if (!authResult.Succeeded) { @@ -144,7 +98,6 @@ private async ValueTask AuthorizeToolAsync(RequestContext } context.Items[AuthorizationFilterInvokedKey] = true; - context.Items[LastAuthorizedToolKey] = context.MatchedPrimitive; } private void ConfigureListResourcesFilter(McpServerOptions options) @@ -426,32 +379,4 @@ internal static bool HasAuthorizationMetadata([NotNullWhen(true)] IMcpServerPrim private static bool HasAuthorizationMetadata(IEnumerable primitives) => primitives.Any(HasAuthorizationMetadata); -} - -internal sealed class AuthorizationCallToolFilterCheckpoint : IConfigureOptions -{ - private readonly Dictionary _indices = new(ReferenceEqualityComparer.Instance); - - public void Configure(McpServerOptions options) - { - lock (_indices) - { - _indices.Add(options, options.Filters.Request.CallToolFilters.Count); - } - } - - public bool TryTakeIndex(McpServerOptions options, out int index) - { - lock (_indices) - { - if (_indices.TryGetValue(options, out index)) - { - _indices.Remove(options); - return true; - } - } - - index = 0; - return false; - } } \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index 05750872d..2c8fd3fe7 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -56,8 +56,7 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder /// /// This method automatically configures authorization filters for all MCP server handlers. These filters respect /// authorization attributes such as - /// and . Tool authorization runs in the alternate-result pipeline before - /// the Tasks extension dispatches background execution, so an unauthorized tool call does not create a task. + /// and . /// Call this method again after any call-tool filter that changes the matched tool to authorize the replacement. /// public static IMcpServerBuilder AddAuthorizationFilters(this IMcpServerBuilder builder) @@ -68,9 +67,6 @@ public static IMcpServerBuilder AddAuthorizationFilters(this IMcpServerBuilder b builder.Services.TryAddSingleton(); builder.Services.AddTransient, AuthorizationFilterSetup>(); builder.Services.TryAddEnumerable(ServiceDescriptor.Transient, AuthorizationFilterSetup>()); - var callToolFilterCheckpoint = new AuthorizationCallToolFilterCheckpoint(); - builder.Services.AddSingleton(callToolFilterCheckpoint); - builder.Services.AddSingleton>(callToolFilterCheckpoint); return builder; } diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs index 9c46b0a2c..207ffebd7 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs @@ -23,7 +23,6 @@ public static class McpTasksBuilderExtensions /// Tasks are implemented as an alternate-result call-tool filter. Alternate-result filters registered before /// the Tasks filter run before task creation. Filters registered after it, along with all ordinary call-tool /// filters, run in the background before the tool. - /// Register Tasks before configuring ordinary call-tool filters. /// /// The server builder. /// The task store. @@ -79,13 +78,6 @@ public void Configure(McpServerOptions options) options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksUpdate, Handler = HandleUpdateTask }); options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksCancel, Handler = HandleCancelTask }); - if (options.Filters.Request.CallToolFilters.Count > 0) - { - throw new InvalidOperationException( - $"{nameof(WithTasks)} must be configured before ordinary call-tool filters because " + - "the Tasks filter must execute outside the ordinary call-tool pipeline."); - } - // Use a filter rather than a handler so it wraps around Core's tool dispatch. // This ensures it intercepts tool calls BEFORE the tool is invoked, allowing // it to spawn background execution and return the task alternate immediately. diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs index e7b2a5578..36db8af3e 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs @@ -1,13 +1,11 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Client; using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; -using Moq; using System.Security.Claims; namespace ModelContextProtocol.AspNetCore.Tests; @@ -44,23 +42,41 @@ public async Task WithTasks_CanCallToolOverHttp() } [Fact] - public async Task WithTasks_AfterOrdinaryFilter_ThrowsActionableError() + public async Task WithTasks_AfterOrdinaryFilter_RunsFilter() { + var filterInvocationCount = 0; Builder.Services .AddMcpServer(options => { - options.Filters.Request.CallToolFilters.Add(next => next); + options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => + { + Interlocked.Increment(ref filterInvocationCount); + return await next(context, cancellationToken); + }); }) .WithHttpTransport() .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 }) .WithTools(); await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); - var exception = Assert.Throws( - () => app.Services.GetRequiredService>().Value); - Assert.Contains(nameof(McpTasksBuilderExtensions.WithTasks), exception.Message); - Assert.Contains("before ordinary call-tool filters", exception.Message); + await using var transport = new HttpClientTransport( + new HttpClientTransportOptions { Endpoint = new("http://localhost:5000") }, + HttpClient, + LoggerFactory); + await using var client = await McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "test" }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("Hello World!", Assert.IsType(Assert.Single(result.Content)).Text); + Assert.Equal(1, filterInvocationCount); } [Theory] @@ -118,9 +134,9 @@ [new Claim(ClaimTypes.NameIdentifier, "test-user")], [Theory] [InlineData(false)] [InlineData(true)] - public async Task WithTasks_UnauthorizedTool_DoesNotCreateTask(bool registerTasksBeforeAuthorization) + public async Task WithTasks_UnauthorizedTool_CreatesTaskBeforeAuthorization(bool registerTasksBeforeAuthorization) { - var taskStore = new Mock(MockBehavior.Strict); + var taskStore = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 }; var serverBuilder = Builder.Services .AddMcpServer() .WithHttpTransport(); @@ -128,14 +144,14 @@ public async Task WithTasks_UnauthorizedTool_DoesNotCreateTask(bool registerTask if (registerTasksBeforeAuthorization) { serverBuilder - .WithTasks(taskStore.Object) + .WithTasks(taskStore) .AddAuthorizationFilters(); } else { serverBuilder .AddAuthorizationFilters() - .WithTasks(taskStore.Object); + .WithTasks(taskStore); } serverBuilder.WithTools(); @@ -154,15 +170,11 @@ public async Task WithTasks_UnauthorizedTool_DoesNotCreateTask(bool registerTask loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); - var exception = await Assert.ThrowsAsync(() => - client.CallToolAsTaskAsync( - new CallToolRequestParams { Name = "authorized-test" }, - TestContext.Current.CancellationToken).AsTask()); + var result = await client.CallToolAsTaskAsync( + new CallToolRequestParams { Name = "authorized-test" }, + TestContext.Current.CancellationToken); - Assert.Equal(McpErrorCode.InvalidRequest, exception.ErrorCode); - taskStore.Verify( - store => store.CreateTaskAsync(It.IsAny()), - Times.Never); + Assert.True(result.IsTask); } [McpServerToolType] From 15cd94b08f104947e83997275c54b4412f41475b Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Mon, 27 Jul 2026 13:27:39 -0700 Subject: [PATCH 3/4] Preserve pre-task authorization Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5feb0906-73fc-4e1a-8cf2-c9a99c588487 --- .../AuthorizationFilterSetup.cs | 42 ++++++++--- .../HttpMcpServerBuilderExtensions.cs | 3 +- .../HttpTaskIntegrationTests.cs | 69 ++++++++++++++++--- 3 files changed, 94 insertions(+), 20 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs b/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs index 85ed38e28..6290dd5c6 100644 --- a/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs +++ b/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs @@ -12,15 +12,15 @@ namespace ModelContextProtocol.AspNetCore; /// Evaluates authorization policies from endpoint metadata. /// internal sealed class AuthorizationFilterSetup( - IAuthorizationPolicyProvider? policyProvider = null) : - IConfigureOptions, IPostConfigureOptions + IAuthorizationPolicyProvider? policyProvider = null, + AuthorizationFiltersMarker? marker = null) : IConfigureOptions, IPostConfigureOptions { private static readonly string AuthorizationFilterInvokedKey = "ModelContextProtocol.AspNetCore.AuthorizationFilter.Invoked"; public void Configure(McpServerOptions options) { ConfigureListToolsFilter(options); - ConfigureCallToolFilter(options); + ConfigureOrdinaryCallToolFilter(options); ConfigureListResourcesFilter(options); ConfigureListResourceTemplatesFilter(options); @@ -32,6 +32,12 @@ public void Configure(McpServerOptions options) public void PostConfigure(string? name, McpServerOptions options) { + // Add tool authorization after all regular configuration so it always wraps Tasks. + if (marker is not null) + { + ConfigureCallToolFilter(options); + } + CheckListToolsFilter(options); CheckListResourcesFilter(options); @@ -80,24 +86,38 @@ private static void CheckListToolsFilter(McpServerOptions options) }); } - private void ConfigureCallToolFilter(McpServerOptions options) + private void ConfigureOrdinaryCallToolFilter(McpServerOptions options) { options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => { - await AuthorizeToolAsync(context); + var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context); + if (!authResult.Succeeded) + { + throw new McpProtocolException("Access forbidden: This tool requires authorization.", McpErrorCode.InvalidRequest); + } + + context.Items[AuthorizationFilterInvokedKey] = true; + return await next(context, cancellationToken); }); } - private async ValueTask AuthorizeToolAsync(RequestContext context) + private void ConfigureCallToolFilter(McpServerOptions options) { - var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context); - if (!authResult.Succeeded) +#pragma warning disable MCPEXP002 // Authorization must run in the alternate-result pipeline before task dispatch. + options.Filters.Request.CallToolWithAlternateFilters.Insert(0, async (context, next, cancellationToken) => { - throw new McpProtocolException("Access forbidden: This tool requires authorization.", McpErrorCode.InvalidRequest); - } + var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context); + if (!authResult.Succeeded) + { + throw new McpProtocolException("Access forbidden: This tool requires authorization.", McpErrorCode.InvalidRequest); + } + + context.Items[AuthorizationFilterInvokedKey] = true; - context.Items[AuthorizationFilterInvokedKey] = true; + return await next(context, cancellationToken); + }); +#pragma warning restore MCPEXP002 } private void ConfigureListResourcesFilter(McpServerOptions options) diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index 2c8fd3fe7..4aa9d1327 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -56,7 +56,8 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder /// /// This method automatically configures authorization filters for all MCP server handlers. These filters respect /// authorization attributes such as - /// and . + /// and . Tool authorization runs in the alternate-result pipeline before + /// the Tasks extension dispatches background execution, so an unauthorized tool call does not create a task. /// Call this method again after any call-tool filter that changes the matched tool to authorize the replacement. /// public static IMcpServerBuilder AddAuthorizationFilters(this IMcpServerBuilder builder) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs index 36db8af3e..d64a55987 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs @@ -6,6 +6,7 @@ using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using Moq; using System.Security.Claims; namespace ModelContextProtocol.AspNetCore.Tests; @@ -134,9 +135,9 @@ [new Claim(ClaimTypes.NameIdentifier, "test-user")], [Theory] [InlineData(false)] [InlineData(true)] - public async Task WithTasks_UnauthorizedTool_CreatesTaskBeforeAuthorization(bool registerTasksBeforeAuthorization) + public async Task WithTasks_UnauthorizedTool_DoesNotCreateTask(bool registerTasksBeforeAuthorization) { - var taskStore = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 }; + var taskStore = new Mock(MockBehavior.Strict); var serverBuilder = Builder.Services .AddMcpServer() .WithHttpTransport(); @@ -144,14 +145,14 @@ public async Task WithTasks_UnauthorizedTool_CreatesTaskBeforeAuthorization(bool if (registerTasksBeforeAuthorization) { serverBuilder - .WithTasks(taskStore) + .WithTasks(taskStore.Object) .AddAuthorizationFilters(); } else { serverBuilder .AddAuthorizationFilters() - .WithTasks(taskStore); + .WithTasks(taskStore.Object); } serverBuilder.WithTools(); @@ -170,11 +171,63 @@ public async Task WithTasks_UnauthorizedTool_CreatesTaskBeforeAuthorization(bool loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); - var result = await client.CallToolAsTaskAsync( - new CallToolRequestParams { Name = "authorized-test" }, - TestContext.Current.CancellationToken); + var exception = await Assert.ThrowsAsync(() => + client.CallToolAsTaskAsync( + new CallToolRequestParams { Name = "authorized-test" }, + TestContext.Current.CancellationToken).AsTask()); + + Assert.Equal(McpErrorCode.InvalidRequest, exception.ErrorCode); + taskStore.Verify( + store => store.CreateTaskAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task WithTasks_ReauthorizesToolChangedByOrdinaryFilter() + { + var serverBuilder = Builder.Services + .AddMcpServer() + .WithHttpTransport() + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 }) + .WithTools() + .AddAuthorizationFilters(); + + serverBuilder.Services.Configure(options => + { + if (options.ToolCollection is null || + !options.ToolCollection.TryGetPrimitive("authorized-test", out var authorizedTool)) + { + throw new InvalidOperationException("The replacement tool was not registered."); + } + + options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => + { + context.MatchedPrimitive = authorizedTool; + return await next(context, cancellationToken); + }); + }); + serverBuilder.AddAuthorizationFilters(); + Builder.Services.AddAuthorization(); + + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var transport = new HttpClientTransport( + new HttpClientTransportOptions { Endpoint = new("http://localhost:5000") }, + HttpClient, + LoggerFactory); + await using var client = await McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync(() => + client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "test" }, + cancellationToken: TestContext.Current.CancellationToken).AsTask()); - Assert.True(result.IsTask); + Assert.Contains("Access forbidden: This tool requires authorization.", exception.Message); } [McpServerToolType] From 2c3863700e71cda941a44aa43e7e3930e1d9dfda Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Mon, 27 Jul 2026 16:24:07 -0700 Subject: [PATCH 4/4] Document ordered authorization checkpoints Add coverage for repeated authorization across multiple tool replacements. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5feb0906-73fc-4e1a-8cf2-c9a99c588487 --- .../HttpMcpServerBuilderExtensions.cs | 4 +- .../AuthorizeAttributeTests.cs | 50 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index 4aa9d1327..a52268341 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -58,7 +58,9 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder /// authorization attributes such as /// and . Tool authorization runs in the alternate-result pipeline before /// the Tasks extension dispatches background execution, so an unauthorized tool call does not create a task. - /// Call this method again after any call-tool filter that changes the matched tool to authorize the replacement. + /// Each call to this method also adds an ordinary call-tool authorization checkpoint at that point in the filter + /// pipeline. Call this method again after any call-tool filter that changes the matched tool or user to authorize + /// the replacement using the updated context. /// public static IMcpServerBuilder AddAuthorizationFilters(this IMcpServerBuilder builder) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs index 3facea431..6ad643ef5 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs @@ -128,6 +128,56 @@ await client.CallToolAsync( Assert.Equal(McpErrorCode.InvalidRequest, exception.ErrorCode); } + [Fact] + public async Task Authorize_Tool_ReauthorizesAfterEachInterveningFilter() + { + await using var app = await StartServerWithAuth(builder => + { + builder.WithTools(); + builder.Services.Configure(options => + { + if (options.ToolCollection is null || + !options.ToolCollection.TryGetPrimitive("authorized_tool", out var authorizedTool)) + { + throw new InvalidOperationException("The first replacement tool was not registered."); + } + + options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => + { + context.MatchedPrimitive = authorizedTool; + return await next(context, cancellationToken); + }); + }); + builder.AddAuthorizationFilters(); + builder.Services.Configure(options => + { + if (options.ToolCollection is null || + !options.ToolCollection.TryGetPrimitive("admin_tool", out var adminTool)) + { + throw new InvalidOperationException("The second replacement tool was not registered."); + } + + options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => + { + context.MatchedPrimitive = adminTool; + return await next(context, cancellationToken); + }); + }); + builder.AddAuthorizationFilters(); + }, "TestUser"); + + var client = await ConnectAsync(); + + var exception = await Assert.ThrowsAsync(async () => + await client.CallToolAsync( + "anonymous_tool", + new Dictionary { ["message"] = "test" }, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal("Request failed (remote): Access forbidden: This tool requires authorization.", exception.Message); + Assert.Equal(McpErrorCode.InvalidRequest, exception.ErrorCode); + } + [Fact] public async Task AuthorizeWithRoles_Tool_RequiresAdminRole() {