Reauthorize tools changed by call-tool filters (#1737)

Copilot-Session: 5feb0906-73fc-4e1a-8cf2-c9a99c588487
This commit is contained in:
Pranav Senthilnathan 2026-07-27 16:47:05 -07:00 • committed by GitHub
commit c7e4b820fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 177 additions and 15 deletions

View file

@ -93,6 +93,91 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
Assert.Equal("Authorized: test", content.Text);
}
[Fact]
public async Task Authorize_Tool_ReauthorizesPrimitiveChangedByInterveningFilter()
{
await using var app = await StartServerWithAuth(builder =>
{
builder.WithTools<AuthorizationTestTools>();
builder.Services.Configure<McpServerOptions>(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<McpProtocolException>(async () =>
await client.CallToolAsync(
"anonymous_tool",
new Dictionary<string, object?> { ["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 Authorize_Tool_ReauthorizesAfterEachInterveningFilter()
{
await using var app = await StartServerWithAuth(builder =>
{
builder.WithTools<AuthorizationTestTools>();
builder.Services.Configure<McpServerOptions>(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<McpServerOptions>(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<McpProtocolException>(async () =>
await client.CallToolAsync(
"anonymous_tool",
new Dictionary<string, object?> { ["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()
{

View file

@ -1,7 +1,6 @@
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;
@ -44,23 +43,41 @@ public class HttpTaskIntegrationTests(ITestOutputHelper testOutputHelper) : Kest
}
[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<TestTools>();
await using var app = Builder.Build();
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
var exception = Assert.Throws<InvalidOperationException>(
() => app.Services.GetRequiredService<IOptions<McpServerOptions>>().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<TextContentBlock>(Assert.Single(result.Content)).Text);
Assert.Equal(1, filterInvocationCount);
}
[Theory]
@ -165,6 +182,54 @@ public class HttpTaskIntegrationTests(ITestOutputHelper testOutputHelper) : Kest
Times.Never);
}
[Fact]
public async Task WithTasks_ReauthorizesToolChangedByOrdinaryFilter()
{
var serverBuilder = Builder.Services
.AddMcpServer()
.WithHttpTransport()
.WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 })
.WithTools<TestTools>()
.AddAuthorizationFilters();
serverBuilder.Services.Configure<McpServerOptions>(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<McpException>(() =>
client.CallToolWithPollingAsync(
new CallToolRequestParams { Name = "test" },
cancellationToken: TestContext.Current.CancellationToken).AsTask());
Assert.Contains("Access forbidden: This tool requires authorization.", exception.Message);
}
[McpServerToolType]
private sealed class TestTools
{