Skip to content

Commit c7e4b82

Browse files
Reauthorize tools changed by call-tool filters (#1737)
Copilot-Session: 5feb0906-73fc-4e1a-8cf2-c9a99c588487
1 parent be55536 commit c7e4b82

5 files changed

Lines changed: 177 additions & 15 deletions

File tree

src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ internal sealed class AuthorizationFilterSetup(
2020
public void Configure(McpServerOptions options)
2121
{
2222
ConfigureListToolsFilter(options);
23+
ConfigureOrdinaryCallToolFilter(options);
2324

2425
ConfigureListResourcesFilter(options);
2526
ConfigureListResourceTemplatesFilter(options);
@@ -85,6 +86,22 @@ private static void CheckListToolsFilter(McpServerOptions options)
8586
});
8687
}
8788

89+
private void ConfigureOrdinaryCallToolFilter(McpServerOptions options)
90+
{
91+
options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) =>
92+
{
93+
var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context);
94+
if (!authResult.Succeeded)
95+
{
96+
throw new McpProtocolException("Access forbidden: This tool requires authorization.", McpErrorCode.InvalidRequest);
97+
}
98+
99+
context.Items[AuthorizationFilterInvokedKey] = true;
100+
101+
return await next(context, cancellationToken);
102+
});
103+
}
104+
88105
private void ConfigureCallToolFilter(McpServerOptions options)
89106
{
90107
#pragma warning disable MCPEXP002 // Authorization must run in the alternate-result pipeline before task dispatch.

src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder
5858
/// authorization attributes such as <see cref="AuthorizeAttribute"/>
5959
/// and <see cref="AllowAnonymousAttribute"/>. Tool authorization runs in the alternate-result pipeline before
6060
/// the Tasks extension dispatches background execution, so an unauthorized tool call does not create a task.
61+
/// Each call to this method also adds an ordinary call-tool authorization checkpoint at that point in the filter
62+
/// pipeline. Call this method again after any call-tool filter that changes the matched tool or user to authorize
63+
/// the replacement using the updated context.
6164
/// </remarks>
6265
public static IMcpServerBuilder AddAuthorizationFilters(this IMcpServerBuilder builder)
6366
{

src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ public static class McpTasksBuilderExtensions
2323
/// Tasks are implemented as an alternate-result call-tool filter. Alternate-result filters registered before
2424
/// the Tasks filter run before task creation. Filters registered after it, along with all ordinary call-tool
2525
/// filters, run in the background before the tool.
26-
/// Register Tasks before configuring ordinary call-tool filters.
2726
/// </remarks>
2827
/// <param name="builder">The server builder.</param>
2928
/// <param name="store">The task store.</param>
@@ -120,13 +119,6 @@ public void Configure(McpServerOptions options)
120119
Handler = HandleCancelTask,
121120
});
122121

123-
if (options.Filters.Request.CallToolFilters.Count > 0)
124-
{
125-
throw new InvalidOperationException(
126-
$"{nameof(WithTasks)} must be configured before ordinary call-tool filters because " +
127-
"the Tasks filter must execute outside the ordinary call-tool pipeline.");
128-
}
129-
130122
// Use a filter rather than a handler so it wraps around Core's tool dispatch.
131123
// This ensures it intercepts tool calls BEFORE the tool is invoked, allowing
132124
// it to spawn background execution and return the task alternate immediately.

tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,91 @@ public async Task Authorize_Tool_AllowsAuthenticatedUser()
9393
Assert.Equal("Authorized: test", content.Text);
9494
}
9595

96+
[Fact]
97+
public async Task Authorize_Tool_ReauthorizesPrimitiveChangedByInterveningFilter()
98+
{
99+
await using var app = await StartServerWithAuth(builder =>
100+
{
101+
builder.WithTools<AuthorizationTestTools>();
102+
builder.Services.Configure<McpServerOptions>(options =>
103+
{
104+
if (options.ToolCollection is null ||
105+
!options.ToolCollection.TryGetPrimitive("authorized_tool", out var authorizedTool))
106+
{
107+
throw new InvalidOperationException("The replacement tool was not registered.");
108+
}
109+
110+
options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) =>
111+
{
112+
context.MatchedPrimitive = authorizedTool;
113+
return await next(context, cancellationToken);
114+
});
115+
});
116+
builder.AddAuthorizationFilters();
117+
});
118+
119+
var client = await ConnectAsync();
120+
121+
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
122+
await client.CallToolAsync(
123+
"anonymous_tool",
124+
new Dictionary<string, object?> { ["message"] = "test" },
125+
cancellationToken: TestContext.Current.CancellationToken));
126+
127+
Assert.Equal("Request failed (remote): Access forbidden: This tool requires authorization.", exception.Message);
128+
Assert.Equal(McpErrorCode.InvalidRequest, exception.ErrorCode);
129+
}
130+
131+
[Fact]
132+
public async Task Authorize_Tool_ReauthorizesAfterEachInterveningFilter()
133+
{
134+
await using var app = await StartServerWithAuth(builder =>
135+
{
136+
builder.WithTools<AuthorizationTestTools>();
137+
builder.Services.Configure<McpServerOptions>(options =>
138+
{
139+
if (options.ToolCollection is null ||
140+
!options.ToolCollection.TryGetPrimitive("authorized_tool", out var authorizedTool))
141+
{
142+
throw new InvalidOperationException("The first replacement tool was not registered.");
143+
}
144+
145+
options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) =>
146+
{
147+
context.MatchedPrimitive = authorizedTool;
148+
return await next(context, cancellationToken);
149+
});
150+
});
151+
builder.AddAuthorizationFilters();
152+
builder.Services.Configure<McpServerOptions>(options =>
153+
{
154+
if (options.ToolCollection is null ||
155+
!options.ToolCollection.TryGetPrimitive("admin_tool", out var adminTool))
156+
{
157+
throw new InvalidOperationException("The second replacement tool was not registered.");
158+
}
159+
160+
options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) =>
161+
{
162+
context.MatchedPrimitive = adminTool;
163+
return await next(context, cancellationToken);
164+
});
165+
});
166+
builder.AddAuthorizationFilters();
167+
}, "TestUser");
168+
169+
var client = await ConnectAsync();
170+
171+
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
172+
await client.CallToolAsync(
173+
"anonymous_tool",
174+
new Dictionary<string, object?> { ["message"] = "test" },
175+
cancellationToken: TestContext.Current.CancellationToken));
176+
177+
Assert.Equal("Request failed (remote): Access forbidden: This tool requires authorization.", exception.Message);
178+
Assert.Equal(McpErrorCode.InvalidRequest, exception.ErrorCode);
179+
}
180+
96181
[Fact]
97182
public async Task AuthorizeWithRoles_Tool_RequiresAdminRole()
98183
{

tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
using Microsoft.AspNetCore.Builder;
22
using Microsoft.AspNetCore.Authorization;
33
using Microsoft.Extensions.DependencyInjection;
4-
using Microsoft.Extensions.Options;
54
using ModelContextProtocol.AspNetCore.Tests.Utils;
65
using ModelContextProtocol.Client;
76
using ModelContextProtocol.Extensions.Tasks;
@@ -44,23 +43,41 @@ public async Task WithTasks_CanCallToolOverHttp()
4443
}
4544

4645
[Fact]
47-
public async Task WithTasks_AfterOrdinaryFilter_ThrowsActionableError()
46+
public async Task WithTasks_AfterOrdinaryFilter_RunsFilter()
4847
{
48+
var filterInvocationCount = 0;
4949
Builder.Services
5050
.AddMcpServer(options =>
5151
{
52-
options.Filters.Request.CallToolFilters.Add(next => next);
52+
options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) =>
53+
{
54+
Interlocked.Increment(ref filterInvocationCount);
55+
return await next(context, cancellationToken);
56+
});
5357
})
5458
.WithHttpTransport()
5559
.WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 })
5660
.WithTools<TestTools>();
5761

5862
await using var app = Builder.Build();
63+
app.MapMcp();
64+
await app.StartAsync(TestContext.Current.CancellationToken);
65+
66+
await using var transport = new HttpClientTransport(
67+
new HttpClientTransportOptions { Endpoint = new("http://localhost:5000") },
68+
HttpClient,
69+
LoggerFactory);
70+
await using var client = await McpClient.CreateAsync(
71+
transport,
72+
loggerFactory: LoggerFactory,
73+
cancellationToken: TestContext.Current.CancellationToken);
5974

60-
var exception = Assert.Throws<InvalidOperationException>(
61-
() => app.Services.GetRequiredService<IOptions<McpServerOptions>>().Value);
62-
Assert.Contains(nameof(McpTasksBuilderExtensions.WithTasks), exception.Message);
63-
Assert.Contains("before ordinary call-tool filters", exception.Message);
75+
var result = await client.CallToolWithPollingAsync(
76+
new CallToolRequestParams { Name = "test" },
77+
cancellationToken: TestContext.Current.CancellationToken);
78+
79+
Assert.Equal("Hello World!", Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text);
80+
Assert.Equal(1, filterInvocationCount);
6481
}
6582

6683
[Theory]
@@ -165,6 +182,54 @@ public async Task WithTasks_UnauthorizedTool_DoesNotCreateTask(bool registerTask
165182
Times.Never);
166183
}
167184

185+
[Fact]
186+
public async Task WithTasks_ReauthorizesToolChangedByOrdinaryFilter()
187+
{
188+
var serverBuilder = Builder.Services
189+
.AddMcpServer()
190+
.WithHttpTransport()
191+
.WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 })
192+
.WithTools<TestTools>()
193+
.AddAuthorizationFilters();
194+
195+
serverBuilder.Services.Configure<McpServerOptions>(options =>
196+
{
197+
if (options.ToolCollection is null ||
198+
!options.ToolCollection.TryGetPrimitive("authorized-test", out var authorizedTool))
199+
{
200+
throw new InvalidOperationException("The replacement tool was not registered.");
201+
}
202+
203+
options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) =>
204+
{
205+
context.MatchedPrimitive = authorizedTool;
206+
return await next(context, cancellationToken);
207+
});
208+
});
209+
serverBuilder.AddAuthorizationFilters();
210+
Builder.Services.AddAuthorization();
211+
212+
await using var app = Builder.Build();
213+
app.MapMcp();
214+
await app.StartAsync(TestContext.Current.CancellationToken);
215+
216+
await using var transport = new HttpClientTransport(
217+
new HttpClientTransportOptions { Endpoint = new("http://localhost:5000") },
218+
HttpClient,
219+
LoggerFactory);
220+
await using var client = await McpClient.CreateAsync(
221+
transport,
222+
loggerFactory: LoggerFactory,
223+
cancellationToken: TestContext.Current.CancellationToken);
224+
225+
var exception = await Assert.ThrowsAsync<McpException>(() =>
226+
client.CallToolWithPollingAsync(
227+
new CallToolRequestParams { Name = "test" },
228+
cancellationToken: TestContext.Current.CancellationToken).AsTask());
229+
230+
Assert.Contains("Access forbidden: This tool requires authorization.", exception.Message);
231+
}
232+
168233
[McpServerToolType]
169234
private sealed class TestTools
170235
{

0 commit comments

Comments
 (0)