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"; } } }