Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
35 changes: 16 additions & 19 deletions src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ namespace ModelContextProtocol.AspNetCore;
/// Evaluates authorization policies from endpoint metadata.
/// </summary>
internal sealed class AuthorizationFilterSetup(
IAuthorizationPolicyProvider? policyProvider = null,
AuthorizationFiltersMarker? marker = null) : IConfigureOptions<McpServerOptions>, IPostConfigureOptions<McpServerOptions>
IAuthorizationPolicyProvider? policyProvider = null) :
IConfigureOptions<McpServerOptions>, IPostConfigureOptions<McpServerOptions>
{
private static readonly string AuthorizationFilterInvokedKey = "ModelContextProtocol.AspNetCore.AuthorizationFilter.Invoked";

public void Configure(McpServerOptions options)
{
ConfigureListToolsFilter(options);
ConfigureCallToolFilter(options);

ConfigureListResourcesFilter(options);
ConfigureListResourceTemplatesFilter(options);
Expand All @@ -31,12 +32,6 @@ 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);
Expand Down Expand Up @@ -87,20 +82,22 @@ private static void CheckListToolsFilter(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) =>
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;

await AuthorizeToolAsync(context);
return await next(context, cancellationToken);
});
#pragma warning restore MCPEXP002
}

private async ValueTask AuthorizeToolAsync(RequestContext<CallToolRequestParams> 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;
}

private void ConfigureListResourcesFilter(McpServerOptions options)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder
/// <remarks>
/// This method automatically configures authorization filters for all MCP server handlers. These filters respect
/// 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.
/// and <see cref="AllowAnonymousAttribute"/>.
/// 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,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;
Comment thread
jeffhandley marked this conversation as resolved.
using System.Security.Claims;

namespace ModelContextProtocol.AspNetCore.Tests;
Expand Down Expand Up @@ -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<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]
Expand Down Expand Up @@ -118,24 +134,24 @@ [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<IMcpTaskStore>(MockBehavior.Strict);
var taskStore = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 };
var serverBuilder = Builder.Services
.AddMcpServer()
.WithHttpTransport();

if (registerTasksBeforeAuthorization)
{
serverBuilder
.WithTasks(taskStore.Object)
.WithTasks(taskStore)
.AddAuthorizationFilters();
}
else
{
serverBuilder
.AddAuthorizationFilters()
.WithTasks(taskStore.Object);
.WithTasks(taskStore);
}

serverBuilder.WithTools<TestTools>();
Expand All @@ -154,15 +170,11 @@ public async Task WithTasks_UnauthorizedTool_DoesNotCreateTask(bool registerTask
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);

var exception = await Assert.ThrowsAsync<McpProtocolException>(() =>
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<CancellationToken>()),
Times.Never);
Assert.True(result.IsTask);
}

[McpServerToolType]
Expand Down