using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.ComponentModel;
using System.Security.Claims;
namespace ModelContextProtocol.AspNetCore.Tests;
///
/// Tests for MCP authorization functionality with [Authorize], [AllowAnonymous] and role-based authorization.
///
public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper)
{
private async Task ConnectAsync()
{
await using var transport = new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new("http://localhost:5000"),
}, HttpClient, LoggerFactory);
return await McpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken, loggerFactory: LoggerFactory);
}
[Fact]
public async Task Authorize_Tool_RequiresAuthentication()
{
await using var app = await StartServerWithAuth(builder => builder.WithTools());
var client = await ConnectAsync();
var exception = await Assert.ThrowsAsync(async () =>
await client.CallToolAsync(
"authorized_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 ClassLevelAuthorize_Tool_RequiresAuthentication()
{
await using var app = await StartServerWithAuth(builder => builder.WithTools());
var client = await ConnectAsync();
var result = await client.CallToolAsync(
"anonymous_tool",
new Dictionary { ["message"] = "test" },
cancellationToken: TestContext.Current.CancellationToken);
Assert.False(result.IsError ?? false);
var content = Assert.Single(result.Content.OfType());
Assert.Equal("Anonymous: test", content.Text);
}
[Fact]
public async Task AllowAnonymous_Tool_AllowsAnonymousAccess()
{
await using var app = await StartServerWithAuth(builder => builder.WithTools());
var client = await ConnectAsync();
var result = await client.CallToolAsync(
"anonymous_tool",
new Dictionary { ["message"] = "test" },
cancellationToken: TestContext.Current.CancellationToken);
Assert.False(result.IsError ?? false);
var content = Assert.Single(result.Content.OfType());
Assert.Equal("Anonymous: test", content.Text);
}
[Fact]
public async Task Authorize_Tool_AllowsAuthenticatedUser()
{
await using var app = await StartServerWithAuth(builder => builder.WithTools(), "TestUser");
var client = await ConnectAsync();
var result = await client.CallToolAsync(
"authorized_tool",
new Dictionary { ["message"] = "test" },
cancellationToken: TestContext.Current.CancellationToken);
Assert.False(result.IsError ?? false);
var content = Assert.Single(result.Content.OfType());
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 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()
{
await using var app = await StartServerWithAuth(builder => builder.WithTools(), "TestUser", "User");
var client = await ConnectAsync();
var exception = await Assert.ThrowsAsync(async () =>
await client.CallToolAsync(
"admin_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_AllowsAdminUser()
{
await using var app = await StartServerWithAuth(builder => builder.WithTools(), "AdminUser", "Admin");
var client = await ConnectAsync();
var result = await client.CallToolAsync(
"admin_tool",
new Dictionary { ["message"] = "test" },
cancellationToken: TestContext.Current.CancellationToken);
Assert.False(result.IsError ?? false);
var content = Assert.Single(result.Content.OfType());
Assert.Equal("Admin: test", content.Text);
}
[Fact]
public async Task ListTools_Anonymous_OnlyReturnsAnonymousTools()
{
await using var app = await StartServerWithAuth(builder => builder.WithTools());
var client = await ConnectAsync();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Single(tools);
Assert.Equal("anonymous_tool", tools[0].Name);
}
[Fact]
public async Task ListTools_AuthenticatedUser_ReturnsAuthorizedTools()
{
await using var app = await StartServerWithAuth(builder => builder.WithTools(), "TestUser");
var client = await ConnectAsync();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
// Authenticated user should see anonymous and basic authorized tools, but not admin-only tools
Assert.Equal(2, tools.Count);
var toolNames = tools.Select(t => t.Name).OrderBy(n => n).ToList();
Assert.Equal(["anonymous_tool", "authorized_tool"], toolNames);
}
[Fact]
public async Task ListTools_AdminUser_ReturnsAllTools()
{
await using var app = await StartServerWithAuth(builder => builder.WithTools(), "AdminUser", "Admin");
var client = await ConnectAsync();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
// Admin user should see all tools
Assert.Equal(3, tools.Count);
var toolNames = tools.Select(t => t.Name).OrderBy(n => n).ToList();
Assert.Equal(["admin_tool", "anonymous_tool", "authorized_tool"], toolNames);
}
[Fact]
public async Task ListTools_UserRole_DoesNotReturnAdminTools()
{
await using var app = await StartServerWithAuth(builder => builder.WithTools(), "TestUser", "User");
var client = await ConnectAsync();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
// User with User role should not see admin-only tools
Assert.Equal(2, tools.Count);
var toolNames = tools.Select(t => t.Name).OrderBy(n => n).ToList();
Assert.Equal(["anonymous_tool", "authorized_tool"], toolNames);
}
[Fact]
public async Task Authorize_Prompt_RequiresAuthentication()
{
await using var app = await StartServerWithAuth(builder => builder.WithPrompts());
var client = await ConnectAsync();
var exception = await Assert.ThrowsAsync(async () =>
await client.GetPromptAsync(
"authorized_prompt",
new Dictionary { ["message"] = "test" },
cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal("Request failed (remote): Access forbidden: This prompt requires authorization.", exception.Message);
Assert.Equal(McpErrorCode.InvalidRequest, exception.ErrorCode);
}
[Fact]
public async Task Authorize_Prompt_AllowsAuthenticatedUser()
{
await using var app = await StartServerWithAuth(builder => builder.WithPrompts(), "TestUser");
var client = await ConnectAsync();
var result = await client.GetPromptAsync(
"authorized_prompt",
new Dictionary { ["message"] = "test" },
cancellationToken: TestContext.Current.CancellationToken);
var message = Assert.Single(result.Messages);
Assert.Equal(Role.User, message.Role);
var content = Assert.IsType(message.Content);
Assert.Equal("Authorized prompt: test", content.Text);
}
[Fact]
public async Task ListPrompts_Anonymous_OnlyReturnsAnonymousPrompts()
{
await using var app = await StartServerWithAuth(builder => builder.WithPrompts());
var client = await ConnectAsync();
var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
// Anonymous user should only see prompts marked with [AllowAnonymous]
Assert.Single(prompts);
Assert.Equal("anonymous_prompt", prompts[0].Name);
}
[Fact]
public async Task Authorize_Resource_RequiresAuthentication()
{
await using var app = await StartServerWithAuth(builder => builder.WithResources());
var client = await ConnectAsync();
var exception = await Assert.ThrowsAsync(async () =>
await client.ReadResourceAsync(
"resource://authorized",
cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal("Request failed (remote): Access forbidden: This resource requires authorization.", exception.Message);
Assert.Equal(McpErrorCode.InvalidRequest, exception.ErrorCode);
}
[Fact]
public async Task Authorize_Resource_AllowsAuthenticatedUser()
{
await using var app = await StartServerWithAuth(builder => builder.WithResources(), "TestUser");
var client = await ConnectAsync();
var result = await client.ReadResourceAsync(
"resource://authorized",
cancellationToken: TestContext.Current.CancellationToken);
var content = Assert.Single(result.Contents.OfType());
Assert.Equal("Authorized resource content", content.Text);
}
[Fact]
public async Task ListResources_Anonymous_OnlyReturnsAnonymousResources()
{
await using var app = await StartServerWithAuth(builder => builder.WithResources());
var client = await ConnectAsync();
var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Single(resources);
Assert.Equal("resource://anonymous", resources[0].Uri);
}
[Fact]
public async Task ListTools_WithoutAuthFilters_ThrowsInvalidOperationException()
{
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithTools());
var client = await ConnectAsync();
var exception = await Assert.ThrowsAsync(async () =>
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
Assert.Contains(MockLoggerProvider.LogMessages, log =>
log.LogLevel == LogLevel.Warning &&
log.Exception is InvalidOperationException &&
log.Exception.Message.Contains("Authorization filter was not invoked for tools/list operation") &&
log.Exception.Message.Contains("Ensure that AddAuthorizationFilters() is called"));
}
[Fact]
public async Task CallTool_WithoutAuthFilters_ReturnsError()
{
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithTools());
var client = await ConnectAsync();
var toolResult = await client.CallToolAsync(
"authorized_tool",
new Dictionary { ["message"] = "test" },
cancellationToken: TestContext.Current.CancellationToken);
Assert.True(toolResult.IsError);
var errorContent = Assert.IsType(Assert.Single(toolResult.Content));
Assert.Equal("An error occurred invoking 'authorized_tool'.", errorContent.Text);
Assert.Contains(MockLoggerProvider.LogMessages, log =>
log.LogLevel == LogLevel.Error &&
log.Exception is InvalidOperationException &&
log.Exception.Message.Contains("Authorization filter was not invoked for tools/call operation") &&
log.Exception.Message.Contains("Ensure that AddAuthorizationFilters() is called"));
}
[Fact]
public async Task ListPrompts_WithoutAuthFilters_ThrowsInvalidOperationException()
{
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithPrompts());
var client = await ConnectAsync();
var exception = await Assert.ThrowsAsync(async () =>
await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
Assert.Contains(MockLoggerProvider.LogMessages, log =>
log.LogLevel == LogLevel.Warning &&
log.Exception is InvalidOperationException &&
log.Exception.Message.Contains("Authorization filter was not invoked for prompts/list operation") &&
log.Exception.Message.Contains("Ensure that AddAuthorizationFilters() is called"));
}
[Fact]
public async Task GetPrompt_WithoutAuthFilters_ThrowsInvalidOperationException()
{
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithPrompts());
var client = await ConnectAsync();
var exception = await Assert.ThrowsAsync(async () =>
await client.GetPromptAsync(
"authorized_prompt",
new Dictionary { ["message"] = "test" },
cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
Assert.Contains(MockLoggerProvider.LogMessages, log =>
log.LogLevel == LogLevel.Warning &&
log.Exception is InvalidOperationException &&
log.Exception.Message.Contains("Authorization filter was not invoked for prompts/get operation") &&
log.Exception.Message.Contains("Ensure that AddAuthorizationFilters() is called"));
}
[Fact]
public async Task ListResources_WithoutAuthFilters_ThrowsInvalidOperationException()
{
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithResources());
var client = await ConnectAsync();
var exception = await Assert.ThrowsAsync(async () =>
await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
Assert.Contains(MockLoggerProvider.LogMessages, log =>
log.LogLevel == LogLevel.Warning &&
log.Exception is InvalidOperationException &&
log.Exception.Message.Contains("Authorization filter was not invoked for resources/list operation") &&
log.Exception.Message.Contains("Ensure that AddAuthorizationFilters() is called"));
}
[Fact]
public async Task ReadResource_WithoutAuthFilters_ThrowsInvalidOperationException()
{
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithResources());
var client = await ConnectAsync();
var exception = await Assert.ThrowsAsync(async () =>
await client.ReadResourceAsync(
"resource://authorized",
cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
Assert.Contains(MockLoggerProvider.LogMessages, log =>
log.LogLevel == LogLevel.Warning &&
log.Exception is InvalidOperationException &&
log.Exception.Message.Contains("Authorization filter was not invoked for resources/read operation") &&
log.Exception.Message.Contains("Ensure that AddAuthorizationFilters() is called"));
}
[Fact]
public async Task ListResourceTemplates_WithoutAuthFilters_ThrowsInvalidOperationException()
{
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithResources());
var client = await ConnectAsync();
var exception = await Assert.ThrowsAsync(async () =>
await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
Assert.Contains(MockLoggerProvider.LogMessages, log =>
log.LogLevel == LogLevel.Warning &&
log.Exception is InvalidOperationException &&
log.Exception.Message.Contains("Authorization filter was not invoked for resources/templates/list operation") &&
log.Exception.Message.Contains("Ensure that AddAuthorizationFilters() is called"));
}
[Fact]
public async Task ListTools_WithHandlerAndNullCollection_AllToolsVisible()
{
// When ToolCollection is null (custom handler only), the auth filter can't look up
// primitives in the collection and should not filter any tools.
await using var app = await StartServerWithAuth(builder =>
builder.WithListToolsHandler(static (_, _) => ValueTask.FromResult(new ListToolsResult
{
Tools = [new Tool { Name = "custom_tool" }]
})));
var client = await ConnectAsync();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
// Tool from custom handler (not in ToolCollection) should be visible even to anonymous users
Assert.Single(tools);
Assert.Equal("custom_tool", tools[0].Name);
}
[Fact]
public async Task ListTools_WithMixedCollectionAndHandler_HandlerToolsNotFiltered()
{
// Tools in the ToolCollection are filtered based on auth metadata.
// Tools returned only from a custom handler (not in ToolCollection) are not filtered.
await using var app = await StartServerWithAuth(builder =>
{
builder.WithTools();
builder.WithListToolsHandler(static (_, _) => ValueTask.FromResult(new ListToolsResult
{
Tools = [new Tool { Name = "handler_tool" }]
}));
});
var client = await ConnectAsync();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
// Anonymous user: anonymous_tool from collection + handler_tool (not in collection, so not filtered)
Assert.Equal(2, tools.Count);
var toolNames = tools.Select(t => t.Name).OrderBy(n => n).ToList();
Assert.Equal(["anonymous_tool", "handler_tool"], toolNames);
}
[Fact]
public async Task ListPrompts_WithHandlerAndNullCollection_AllPromptsVisible()
{
// When PromptCollection is null (custom handler only), the auth filter can't look up
// primitives in the collection and should not filter any prompts.
await using var app = await StartServerWithAuth(builder =>
builder.WithListPromptsHandler(static (_, _) => ValueTask.FromResult(new ListPromptsResult
{
Prompts = [new Prompt { Name = "custom_prompt" }]
})));
var client = await ConnectAsync();
var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
// Prompt from custom handler (not in PromptCollection) should be visible even to anonymous users
Assert.Single(prompts);
Assert.Equal("custom_prompt", prompts[0].Name);
}
[Fact]
public async Task ListResources_WithHandlerAndNullCollection_AllResourcesVisible()
{
// When ResourceCollection is null (custom handler only), the auth filter can't look up
// primitives in the collection and should not filter any resources.
await using var app = await StartServerWithAuth(builder =>
builder.WithListResourcesHandler(static (_, _) => ValueTask.FromResult(new ListResourcesResult
{
Resources = [new Resource { Name = "custom_resource", Uri = "resource://custom" }]
})));
var client = await ConnectAsync();
var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
// Resource from custom handler (not in ResourceCollection) should be visible even to anonymous users
Assert.Single(resources);
Assert.Equal("resource://custom", resources[0].Uri);
}
[Fact]
public async Task ListResourceTemplates_WithHandlerAndNullCollection_AllResourceTemplatesVisible()
{
// When ResourceCollection is null (custom handler only), the auth filter can't look up
// primitives in the collection and should not filter any resource templates.
await using var app = await StartServerWithAuth(builder =>
builder.WithListResourceTemplatesHandler(static (_, _) => ValueTask.FromResult(new ListResourceTemplatesResult
{
ResourceTemplates = [new ResourceTemplate { Name = "custom_template", UriTemplate = "resource://custom/{id}" }]
})));
var client = await ConnectAsync();
var templates = await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken);
// Template from custom handler (not in ResourceCollection) should be visible even to anonymous users
Assert.Single(templates);
Assert.Equal("resource://custom/{id}", templates[0].UriTemplate);
}
private async Task StartServerWithAuth(Action configure, string? userName = null, params string[] roles)
{
var mcpServerBuilder = Builder.Services.AddMcpServer().WithHttpTransport().AddAuthorizationFilters();
configure(mcpServerBuilder);
Builder.Services.AddAuthorization();
var app = Builder.Build();
if (userName is not null)
{
app.Use(next =>
{
return async context =>
{
context.User = CreateUser(userName, roles);
await next(context);
};
});
}
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
return app;
}
private async Task StartServerWithoutAuthFilters(Action configure)
{
var mcpServerBuilder = Builder.Services.AddMcpServer().WithHttpTransport(); // No AddAuthorizationFilters() call
configure(mcpServerBuilder);
Builder.Services.AddAuthorization();
var app = Builder.Build();
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
return app;
}
private ClaimsPrincipal CreateUser(string name, params string[] roles)
=> new(new ClaimsIdentity(
[new Claim("name", name), new Claim(ClaimTypes.NameIdentifier, name), .. roles.Select(role => new Claim("role", role))],
"TestAuthType", "name", "role"));
[McpServerToolType]
private class AuthorizationTestTools
{
[McpServerTool, Description("A tool that allows anonymous access.")]
public static string AnonymousTool(string message)
{
return $"Anonymous: {message}";
}
[McpServerTool, Description("A tool that requires authorization.")]
[Authorize]
public static string AuthorizedTool(string message)
{
return $"Authorized: {message}";
}
[McpServerTool, Description("A tool that requires Admin role.")]
[Authorize(Roles = "Admin")]
public static string AdminTool(string message)
{
return $"Admin: {message}";
}
}
[McpServerToolType]
[Authorize]
private class AllowAnonymousTestTools
{
[McpServerTool, Description("A tool that allows anonymous access.")]
[AllowAnonymous]
public static string AnonymousTool(string message)
{
return $"Anonymous: {message}";
}
[McpServerTool, Description("A tool that requires authorization.")]
public static string AuthorizedTool(string message)
{
return $"Authorized: {message}";
}
}
[McpServerPromptType]
private class AuthorizationTestPrompts
{
[McpServerPrompt, Description("A prompt that allows anonymous access.")]
public static string AnonymousPrompt(string message)
{
return $"Anonymous prompt: {message}";
}
[McpServerPrompt, Description("A prompt that requires authorization.")]
[Authorize]
public static string AuthorizedPrompt(string message)
{
return $"Authorized prompt: {message}";
}
}
[McpServerResourceType]
private class AuthorizationTestResources
{
[McpServerResource(UriTemplate = "resource://anonymous"), Description("A resource that allows anonymous access.")]
public static string AnonymousResource()
{
return "Anonymous resource content";
}
[McpServerResource(UriTemplate = "resource://authorized"), Description("A resource that requires authorization.")]
[Authorize]
public static string AuthorizedResource()
{
return "Authorized resource content";
}
[McpServerResource(UriTemplate = "resource://authorized/{id}"), Description("A resource template that requires authorization.")]
[Authorize]
public static string AuthorizedResourceWithTemplate(string id)
{
return "Authorized resource content";
}
}
}