Skip to content
Open
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
17 changes: 17 additions & 0 deletions src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ internal sealed class AuthorizationFilterSetup(
public void Configure(McpServerOptions options)
{
ConfigureListToolsFilter(options);
ConfigureOrdinaryCallToolFilter(options);

ConfigureListResourcesFilter(options);
ConfigureListResourceTemplatesFilter(options);
Expand Down Expand Up @@ -85,6 +86,22 @@ private static void CheckListToolsFilter(McpServerOptions options)
});
}

private void ConfigureOrdinaryCallToolFilter(McpServerOptions options)
{
options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) =>
Comment thread
PranavSenthilnathan marked this conversation as resolved.
{
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 void ConfigureCallToolFilter(McpServerOptions options)
{
#pragma warning disable MCPEXP002 // Authorization must run in the alternate-result pipeline before task dispatch.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder
/// authorization attributes such as <see cref="AuthorizeAttribute"/>
/// and <see cref="AllowAnonymousAttribute"/>. 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.
/// </remarks>
public static IMcpServerBuilder AddAuthorizationFilters(this IMcpServerBuilder builder)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </remarks>
/// <param name="builder">The server builder.</param>
/// <param name="store">The task store.</param>
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,41 @@ public async Task Authorize_Tool_AllowsAuthenticatedUser()
Assert.Equal("Authorized: test", content.Text);
}

[Fact]
public async Task Authorize_Tool_ReauthorizesPrimitiveChangedByInterveningFilter()
Comment thread
PranavSenthilnathan marked this conversation as resolved.
{
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 AuthorizeWithRoles_Tool_RequiresAdminRole()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -44,23 +43,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<TestTools>();

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 = 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);
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]
Expand Down Expand Up @@ -165,6 +182,54 @@ public async Task WithTasks_UnauthorizedTool_DoesNotCreateTask(bool registerTask
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
{
Expand Down