Add middleware and authz support for server-side handlers (#733)

This commit is contained in:
Stephen Halter 2025-09-10 10:07:45 -07:00 • committed by GitHub
commit d344c65120
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 2871 additions and 435 deletions

317
docs/concepts/filters.md Normal file
View file

@ -0,0 +1,317 @@
---
title: Filters
author: halter73
description: MCP Server Handler Filters
uid: filters
---
# MCP Server Handler Filters
For each handler type in the MCP Server, there are corresponding `AddXXXFilter` methods in `McpServerBuilderExtensions.cs` that allow you to add filters to the handler pipeline. The filters are stored in `McpServerOptions.Filters` and applied during server configuration.
## Available Filter Methods
The following filter methods are available:
- `AddListResourceTemplatesFilter` - Filter for list resource templates handlers
- `AddListToolsFilter` - Filter for list tools handlers
- `AddCallToolFilter` - Filter for call tool handlers
- `AddListPromptsFilter` - Filter for list prompts handlers
- `AddGetPromptFilter` - Filter for get prompt handlers
- `AddListResourcesFilter` - Filter for list resources handlers
- `AddReadResourceFilter` - Filter for read resource handlers
- `AddCompleteFilter` - Filter for completion handlers
- `AddSubscribeToResourcesFilter` - Filter for resource subscription handlers
- `AddUnsubscribeFromResourcesFilter` - Filter for resource unsubscription handlers
- `AddSetLoggingLevelFilter` - Filter for logging level handlers
## Usage
Filters are functions that take a handler and return a new handler, allowing you to wrap the original handler with additional functionality:
```csharp
services.AddMcpServer()
.WithListToolsHandler(async (context, cancellationToken) =>
{
// Your base handler logic
return new ListToolsResult { Tools = GetTools() };
})
.AddListToolsFilter(next => async (context, cancellationToken) =>
{
var logger = context.Services?.GetService<ILogger<Program>>();
// Pre-processing logic
logger?.LogInformation("Before handler execution");
var result = await next(context, cancellationToken);
// Post-processing logic
logger?.LogInformation("After handler execution");
return result;
});
```
## Filter Execution Order
```csharp
services.AddMcpServer()
.WithListToolsHandler(baseHandler)
.AddListToolsFilter(filter1) // Executes first (outermost)
.AddListToolsFilter(filter2) // Executes second
.AddListToolsFilter(filter3); // Executes third (closest to handler)
```
Execution flow: `filter1 -> filter2 -> filter3 -> baseHandler -> filter3 -> filter2 -> filter1`
## Common Use Cases
### Logging
```csharp
.AddListToolsFilter(next => async (context, cancellationToken) =>
{
var logger = context.Services?.GetService<ILogger<Program>>();
logger?.LogInformation($"Processing request from {context.Meta.ProgressToken}");
var result = await next(context, cancellationToken);
logger?.LogInformation($"Returning {result.Tools?.Count ?? 0} tools");
return result;
});
```
### Error Handling
```csharp
.AddCallToolFilter(next => async (context, cancellationToken) =>
{
try
{
return await next(context, cancellationToken);
}
catch (Exception ex)
{
return new CallToolResult
{
Content = new[] { new TextContent { Type = "text", Text = $"Error: {ex.Message}" } },
IsError = true
};
}
});
```
### Performance Monitoring
```csharp
.AddListToolsFilter(next => async (context, cancellationToken) =>
{
var logger = context.Services?.GetService<ILogger<Program>>();
var stopwatch = Stopwatch.StartNew();
var result = await next(context, cancellationToken);
stopwatch.Stop();
logger?.LogInformation($"Handler took {stopwatch.ElapsedMilliseconds}ms");
return result;
});
```
### Caching
```csharp
.AddListResourcesFilter(next => async (context, cancellationToken) =>
{
var cache = context.Services!.GetRequiredService<IMemoryCache>();
var cacheKey = $"resources:{context.Params.Cursor}";
if (cache.TryGetValue(cacheKey, out var cached))
{
return (ListResourcesResult)cached;
}
var result = await next(context, cancellationToken);
cache.Set(cacheKey, result, TimeSpan.FromMinutes(5));
return result;
});
```
## Built-in Authorization Filters
When using the ASP.NET Core integration (`ModelContextProtocol.AspNetCore`), you can add authorization filters to support `[Authorize]` and `[AllowAnonymous]` attributes on MCP server tools, prompts, and resources by calling `AddAuthorizationFilters()` on your MCP server builder.
### Enabling Authorization Filters
To enable authorization support, call `AddAuthorizationFilters()` when configuring your MCP server:
```csharp
services.AddMcpServer()
.WithHttpTransport()
.AddAuthorizationFilters() // Enable authorization filter support
.WithTools<WeatherTools>();
```
**Important**: You should always call `AddAuthorizationFilters()` when using ASP.NET Core integration if you want to use authorization attributes like `[Authorize]` on your MCP server tools, prompts, or resources.
### Authorization Attributes Support
The MCP server automatically respects the following authorization attributes:
- **`[Authorize]`** - Requires authentication for access
- **`[Authorize(Roles = "RoleName")]`** - Requires specific roles
- **`[Authorize(Policy = "PolicyName")]`** - Requires specific authorization policies
- **`[AllowAnonymous]`** - Explicitly allows anonymous access (overrides `[Authorize]`)
### Tool Authorization
Tools can be decorated with authorization attributes to control access:
```csharp
[McpServerToolType]
public class WeatherTools
{
[McpServerTool, Description("Gets public weather data")]
public static string GetWeather(string location)
{
return $"Weather for {location}: Sunny, 25°C";
}
[McpServerTool, Description("Gets detailed weather forecast")]
[Authorize] // Requires authentication
public static string GetDetailedForecast(string location)
{
return $"Detailed forecast for {location}: ...";
}
[McpServerTool, Description("Manages weather alerts")]
[Authorize(Roles = "Admin")] // Requires Admin role
public static string ManageWeatherAlerts(string alertType)
{
return $"Managing alert: {alertType}";
}
}
```
### Class-Level Authorization
You can apply authorization at the class level, which affects all tools in the class:
```csharp
[McpServerToolType]
[Authorize] // All tools require authentication
public class RestrictedTools
{
[McpServerTool, Description("Restricted tool accessible to authenticated users")]
public static string RestrictedOperation()
{
return "Restricted operation completed";
}
[McpServerTool, Description("Public tool accessible to anonymous users")]
[AllowAnonymous] // Overrides class-level [Authorize]
public static string PublicOperation()
{
return "Public operation completed";
}
}
```
### How Authorization Filters Work
The authorization filters work differently for list operations versus individual operations:
#### List Operations (ListTools, ListPrompts, ListResources)
For list operations, the filters automatically remove unauthorized items from the results. Users only see tools, prompts, or resources they have permission to access.
#### Individual Operations (CallTool, GetPrompt, ReadResource)
For individual operations, the filters throw an `McpException` with "Access forbidden" message. These get turned into JSON-RPC errors if uncaught by middleware.
### Filter Execution Order and Authorization
Authorization filters are applied automatically when you call `AddAuthorizationFilters()`. These filters run at a specific point in the filter pipeline, which means:
**Filters added before authorization filters** can see:
- Unauthorized requests for operations before they are rejected by the authorization filters
- Complete listings for unauthorized primitives before they are filtered out by the authorization filters
**Filters added after authorization filters** will only see:
- Authorized requests that passed authorization checks
- Filtered listings containing only authorized primitives
This allows you to implement logging, metrics, or other cross-cutting concerns that need to see all requests, while still maintaining proper authorization:
```csharp
services.AddMcpServer()
.WithHttpTransport()
.AddListToolsFilter(next => async (context, cancellationToken) =>
{
var logger = context.Services?.GetService<ILogger<Program>>();
// This filter runs BEFORE authorization - sees all tools
logger?.LogInformation("Request for tools list - will see all tools");
var result = await next(context, cancellationToken);
logger?.LogInformation($"Returning {result.Tools?.Count ?? 0} tools after authorization");
return result;
})
.AddAuthorizationFilters() // Authorization filtering happens here
.AddListToolsFilter(next => async (context, cancellationToken) =>
{
var logger = context.Services?.GetService<ILogger<Program>>();
// This filter runs AFTER authorization - only sees authorized tools
var result = await next(context, cancellationToken);
logger?.LogInformation($"Post-auth filter sees {result.Tools?.Count ?? 0} authorized tools");
return result;
})
.WithTools<WeatherTools>();
```
### Setup Requirements
To use authorization features, you must configure authentication and authorization in your ASP.NET Core application and call `AddAuthorizationFilters()`:
```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer(options => { /* JWT configuration */ })
.AddMcp(options => { /* Resource metadata configuration */ });
builder.Services.AddAuthorization();
builder.Services.AddMcpServer()
.WithHttpTransport()
.AddAuthorizationFilters() // Required for authorization support
.WithTools<WeatherTools>()
.AddCallToolFilter(next => async (context, cancellationToken) =>
{
// Custom call tool logic
return await next(context, cancellationToken);
});
var app = builder.Build();
app.MapMcp();
app.Run();
```
### Custom Authorization Filters
You can also create custom authorization filters using the filter methods:
```csharp
.AddCallToolFilter(next => async (context, cancellationToken) =>
{
// Custom authorization logic
if (context.User?.Identity?.IsAuthenticated != true)
{
return new CallToolResult
{
Content = [new TextContent { Text = "Custom: Authentication required" }],
IsError = true
};
}
return await next(context, cancellationToken);
});
```
### RequestContext
Within filters, you have access to:
- `context.User` - The current user's `ClaimsPrincipal`
- `context.Services` - The request's service provider for resolving authorization services
- `context.MatchedPrimitive` - The matched tool/prompt/resource with its metadata including authorization attributes via `context.MatchedPrimitive.Metadata`

View file

@ -13,3 +13,7 @@ items:
items:
- name: Logging
uid: logging
- name: Server Features
items:
- name: Filters
uid: filters

View file

@ -7,7 +7,7 @@
"applicationUrl": "http://localhost:3001",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"OTEL_SERVICE_NAME": "aspnetcore-mcp-server",
"OTEL_SERVICE_NAME": "aspnetcore-mcp-server"
}
},
"https": {
@ -16,7 +16,7 @@
"applicationUrl": "https://localhost:7133;http://localhost:3001",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"OTEL_SERVICE_NAME": "aspnetcore-mcp-server",
"OTEL_SERVICE_NAME": "aspnetcore-mcp-server"
}
}
}

View file

@ -0,0 +1,359 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace ModelContextProtocol.AspNetCore;
/// <summary>
/// Evaluates authorization policies from endpoint metadata.
/// </summary>
internal sealed class AuthorizationFilterSetup(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);
ConfigureReadResourceFilter(options);
ConfigureListPromptsFilter(options);
ConfigureGetPromptFilter(options);
}
public void PostConfigure(string? name, McpServerOptions options)
{
CheckListToolsFilter(options);
CheckCallToolFilter(options);
CheckListResourcesFilter(options);
CheckListResourceTemplatesFilter(options);
CheckReadResourceFilter(options);
CheckListPromptsFilter(options);
CheckGetPromptFilter(options);
}
private void ConfigureListToolsFilter(McpServerOptions options)
{
options.Filters.ListToolsFilters.Add(next => async (context, cancellationToken) =>
{
context.Items[AuthorizationFilterInvokedKey] = true;
var result = await next(context, cancellationToken);
await FilterAuthorizedItemsAsync(
result.Tools, static tool => tool.McpServerTool,
context.User, context.Services, context);
return result;
});
}
private void CheckListToolsFilter(McpServerOptions options)
{
options.Filters.ListToolsFilters.Add(next => async (context, cancellationToken) =>
{
var result = await next(context, cancellationToken);
if (HasAuthorizationMetadata(result.Tools.Select(static tool => tool.McpServerTool))
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
{
throw new InvalidOperationException("Authorization filter was not invoked for tools/list operation, but authorization metadata was found on the tools. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
}
return result;
});
}
private void ConfigureCallToolFilter(McpServerOptions options)
{
options.Filters.CallToolFilters.Add(next => async (context, cancellationToken) =>
{
var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context);
if (!authResult.Succeeded)
{
throw new McpException("Access forbidden: This tool requires authorization.", McpErrorCode.InvalidRequest);
}
context.Items[AuthorizationFilterInvokedKey] = true;
return await next(context, cancellationToken);
});
}
private void CheckCallToolFilter(McpServerOptions options)
{
options.Filters.CallToolFilters.Add(next => async (context, cancellationToken) =>
{
if (HasAuthorizationMetadata(context.MatchedPrimitive)
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
{
throw new InvalidOperationException("Authorization filter was not invoked for tools/call operation, but authorization metadata was found on the tool. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
}
return await next(context, cancellationToken);
});
}
private void ConfigureListResourcesFilter(McpServerOptions options)
{
options.Filters.ListResourcesFilters.Add(next => async (context, cancellationToken) =>
{
context.Items[AuthorizationFilterInvokedKey] = true;
var result = await next(context, cancellationToken);
await FilterAuthorizedItemsAsync(
result.Resources, static resource => resource.McpServerResource,
context.User, context.Services, context);
return result;
});
}
private void CheckListResourcesFilter(McpServerOptions options)
{
options.Filters.ListResourcesFilters.Add(next => async (context, cancellationToken) =>
{
var result = await next(context, cancellationToken);
if (HasAuthorizationMetadata(result.Resources.Select(static resource => resource.McpServerResource))
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
{
throw new InvalidOperationException("Authorization filter was not invoked for resources/list operation, but authorization metadata was found on the resources. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
}
return result;
});
}
private void ConfigureListResourceTemplatesFilter(McpServerOptions options)
{
options.Filters.ListResourceTemplatesFilters.Add(next => async (context, cancellationToken) =>
{
context.Items[AuthorizationFilterInvokedKey] = true;
var result = await next(context, cancellationToken);
await FilterAuthorizedItemsAsync(
result.ResourceTemplates, static resourceTemplate => resourceTemplate.McpServerResource,
context.User, context.Services, context);
return result;
});
}
private void CheckListResourceTemplatesFilter(McpServerOptions options)
{
options.Filters.ListResourceTemplatesFilters.Add(next => async (context, cancellationToken) =>
{
var result = await next(context, cancellationToken);
if (HasAuthorizationMetadata(result.ResourceTemplates.Select(static resourceTemplate => resourceTemplate.McpServerResource))
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
{
throw new InvalidOperationException("Authorization filter was not invoked for resources/templates/list operation, but authorization metadata was found on the resource templates. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
}
return result;
});
}
private void ConfigureReadResourceFilter(McpServerOptions options)
{
options.Filters.ReadResourceFilters.Add(next => async (context, cancellationToken) =>
{
context.Items[AuthorizationFilterInvokedKey] = true;
var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context);
if (!authResult.Succeeded)
{
throw new McpException("Access forbidden: This resource requires authorization.", McpErrorCode.InvalidRequest);
}
return await next(context, cancellationToken);
});
}
private void CheckReadResourceFilter(McpServerOptions options)
{
options.Filters.ReadResourceFilters.Add(next => async (context, cancellationToken) =>
{
if (HasAuthorizationMetadata(context.MatchedPrimitive)
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
{
throw new InvalidOperationException("Authorization filter was not invoked for resources/read operation, but authorization metadata was found on the resource. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
}
return await next(context, cancellationToken);
});
}
private void ConfigureListPromptsFilter(McpServerOptions options)
{
options.Filters.ListPromptsFilters.Add(next => async (context, cancellationToken) =>
{
context.Items[AuthorizationFilterInvokedKey] = true;
var result = await next(context, cancellationToken);
await FilterAuthorizedItemsAsync(
result.Prompts, static prompt => prompt.McpServerPrompt,
context.User, context.Services, context);
return result;
});
}
private void CheckListPromptsFilter(McpServerOptions options)
{
options.Filters.ListPromptsFilters.Add(next => async (context, cancellationToken) =>
{
var result = await next(context, cancellationToken);
if (HasAuthorizationMetadata(result.Prompts.Select(static prompt => prompt.McpServerPrompt))
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
{
throw new InvalidOperationException("Authorization filter was not invoked for prompts/list operation, but authorization metadata was found on the prompts. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
}
return result;
});
}
private void ConfigureGetPromptFilter(McpServerOptions options)
{
options.Filters.GetPromptFilters.Add(next => async (context, cancellationToken) =>
{
context.Items[AuthorizationFilterInvokedKey] = true;
var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context);
if (!authResult.Succeeded)
{
throw new McpException("Access forbidden: This prompt requires authorization.", McpErrorCode.InvalidRequest);
}
return await next(context, cancellationToken);
});
}
private void CheckGetPromptFilter(McpServerOptions options)
{
options.Filters.GetPromptFilters.Add(next => async (context, cancellationToken) =>
{
if (HasAuthorizationMetadata(context.MatchedPrimitive)
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
{
throw new InvalidOperationException("Authorization filter was not invoked for prompts/get operation, but authorization metadata was found on the prompt. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
}
return await next(context, cancellationToken);
});
}
/// <summary>
/// Filters a collection of items based on authorization policies in their metadata.
/// For list operations where we need to filter results by authorization.
/// </summary>
private async ValueTask FilterAuthorizedItemsAsync<T>(IList<T> items, Func<T, IMcpServerPrimitive?> primitiveSelector,
ClaimsPrincipal? user, IServiceProvider? requestServices, object context)
{
for (int i = items.Count - 1; i >= 0; i--)
{
var authorizationResult = await GetAuthorizationResultAsync(
user, primitiveSelector(items[i]), requestServices, context);
if (!authorizationResult.Succeeded)
{
items.RemoveAt(i);
}
}
}
private async ValueTask<AuthorizationResult> GetAuthorizationResultAsync(
ClaimsPrincipal? user, IMcpServerPrimitive? primitive, IServiceProvider? requestServices, object context)
{
if (!HasAuthorizationMetadata(primitive))
{
return AuthorizationResult.Success();
}
if (policyProvider is null)
{
throw new InvalidOperationException($"You must call AddAuthorization() because an authorization related attribute was found on {primitive.Id}");
}
var policy = await CombineAsync(policyProvider, primitive.Metadata);
if (policy is null)
{
return AuthorizationResult.Success();
}
if (requestServices is null)
{
// The IAuthorizationPolicyProvider service must be non-null to get to this line, so it's very unexpected for RequestContext.Services to not be set.
throw new InvalidOperationException("RequestContext.Services is not set! The IMcpServer must be initialized with a non-null IServiceProvider.");
}
// ASP.NET Core's AuthorizationMiddleware resolves the IAuthorizationService from scoped request services, so we do the same.
var authService = requestServices.GetRequiredService<IAuthorizationService>();
return await authService.AuthorizeAsync(user ?? new ClaimsPrincipal(new ClaimsIdentity()), context, policy);
}
/// <summary>
/// Combines authorization policies and requirements from endpoint metadata without considering <see cref="IAllowAnonymous"/>.
/// </summary>
/// <param name="policyProvider">The authorization policy provider.</param>
/// <param name="endpointMetadata">The endpoint metadata collection.</param>
/// <returns>The combined authorization policy, or null if no authorization is required.</returns>
private static async ValueTask<AuthorizationPolicy?> CombineAsync(IAuthorizationPolicyProvider policyProvider, IReadOnlyList<object> endpointMetadata)
{
// https://github.com/dotnet/aspnetcore/issues/63365 tracks adding this as public API to AuthorizationPolicy itself.
// Copied from https://github.com/dotnet/aspnetcore/blob/9f2977bf9cfb539820983bda3bedf81c8cda9f20/src/Security/Authorization/Policy/src/AuthorizationMiddleware.cs#L116-L138
var authorizeData = endpointMetadata.OfType<IAuthorizeData>();
var policies = endpointMetadata.OfType<AuthorizationPolicy>();
var policy = await AuthorizationPolicy.CombineAsync(policyProvider, authorizeData, policies);
AuthorizationPolicyBuilder? reqPolicyBuilder = null;
foreach (var m in endpointMetadata)
{
if (m is not IAuthorizationRequirementData requirementData)
{
continue;
}
reqPolicyBuilder ??= new AuthorizationPolicyBuilder();
foreach (var requirement in requirementData.GetRequirements())
{
reqPolicyBuilder.AddRequirements(requirement);
}
}
if (reqPolicyBuilder is null)
{
return policy;
}
// Combine policy with requirements or just use requirements if no policy
return (policy is null)
? reqPolicyBuilder.Build()
: AuthorizationPolicy.Combine(policy, reqPolicyBuilder.Build());
}
private static bool HasAuthorizationMetadata([NotNullWhen(true)] IMcpServerPrimitive? primitive)
{
// If no primitive was found for this request or there is IAllowAnonymous metadata anywhere on the class or method,
// the request should go through as normal.
if (primitive is null || primitive.Metadata.Any(static m => m is IAllowAnonymous))
{
return false;
}
return primitive.Metadata.Any(static m => m is IAuthorizeData or AuthorizationPolicy or IAuthorizationRequirementData);
}
private static bool HasAuthorizationMetadata(IEnumerable<IMcpServerPrimitive?> primitives)
=> primitives.Any(HasAuthorizationMetadata);
}

View file

@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ModelContextProtocol.AspNetCore;
using ModelContextProtocol.Server;
@ -29,6 +31,8 @@ public static class HttpMcpServerBuilderExtensions
builder.Services.AddHostedService<IdleTrackingBackgroundService>();
builder.Services.AddDataProtection();
builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IPostConfigureOptions<McpServerOptions>, AuthorizationFilterSetup>());
if (configureOptions is not null)
{
builder.Services.Configure(configureOptions);
@ -36,4 +40,27 @@ public static class HttpMcpServerBuilderExtensions
return builder;
}
/// <summary>
/// Adds authorization filters to support <see cref="AuthorizeAttribute"/>
/// on MCP server tools, prompts, and resources. This method should always be called when using
/// ASP.NET Core integration to ensure proper authorization support.
/// </summary>
/// <param name="builder">The builder instance.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <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"/>.
/// </remarks>
public static IMcpServerBuilder AddAuthorizationFilters(this IMcpServerBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
// Allow the authorization filters to get added multiple times in case other middleware changes the matched primitive.
builder.Services.AddTransient<IConfigureOptions<McpServerOptions>, AuthorizationFilterSetup>();
return builder;
}
}

View file

@ -2,7 +2,6 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Collections.Concurrent;
using System.Diagnostics;
@ -97,7 +96,7 @@ internal sealed class SseHandler(
return;
}
var message = (JsonRpcMessage?)await context.Request.ReadFromJsonAsync(McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)), context.RequestAborted);
var message = await StreamableHttpHandler.ReadJsonRpcMessageAsync(context);
if (message is null)
{
await Results.BadRequest("No message in request body.").ExecuteAsync(context);

View file

@ -8,7 +8,6 @@ using Microsoft.Net.Http.Headers;
using ModelContextProtocol.AspNetCore.Stateless;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.IO.Pipelines;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
@ -26,6 +25,8 @@ internal sealed class StreamableHttpHandler(
IServiceProvider applicationServices)
{
private const string McpSessionIdHeaderName = "Mcp-Session-Id";
private static readonly JsonTypeInfo<JsonRpcMessage> s_messageTypeInfo = GetRequiredJsonTypeInfo<JsonRpcMessage>();
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value;
@ -55,8 +56,17 @@ internal sealed class StreamableHttpHandler(
await using var _ = await session.AcquireReferenceAsync(context.RequestAborted);
var message = await ReadJsonRpcMessageAsync(context);
if (message is null)
{
await WriteJsonRpcErrorAsync(context,
"Bad Request: The POST body did not contain a valid JSON-RPC message.",
StatusCodes.Status400BadRequest);
return;
}
InitializeSseResponse(context);
var wroteResponse = await session.Transport.HandlePostRequest(new HttpDuplexPipe(context), context.RequestAborted);
var wroteResponse = await session.Transport.HandlePostRequest(message, context.Response.Body, context.RequestAborted);
if (!wroteResponse)
{
// We wound up writing nothing, so there should be no Content-Type response header.
@ -264,6 +274,22 @@ internal sealed class StreamableHttpHandler(
return WebEncoders.Base64UrlEncode(buffer);
}
internal static async Task<JsonRpcMessage?> ReadJsonRpcMessageAsync(HttpContext context)
{
// Implementation for reading a JSON-RPC message from the request body
var message = await context.Request.ReadFromJsonAsync(s_messageTypeInfo, context.RequestAborted);
if (context.User?.Identity?.IsAuthenticated == true && message is not null)
{
message.Context = new()
{
User = context.User,
};
}
return message;
}
private void ScheduleStatelessSessionIdWrite(HttpContext context, StreamableHttpServerTransport transport)
{
transport.OnInitRequestReceived = initRequestParams =>
@ -304,17 +330,11 @@ internal sealed class StreamableHttpHandler(
return null;
}
private static JsonTypeInfo<T> GetRequiredJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
internal static JsonTypeInfo<T> GetRequiredJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
private static bool MatchesApplicationJsonMediaType(MediaTypeHeaderValue acceptHeaderValue)
=> acceptHeaderValue.MatchesMediaType("application/json");
private static bool MatchesTextEventStreamMediaType(MediaTypeHeaderValue acceptHeaderValue)
=> acceptHeaderValue.MatchesMediaType("text/event-stream");
private sealed class HttpDuplexPipe(HttpContext context) : IDuplexPipe
{
public PipeReader Input => context.Request.BodyReader;
public PipeWriter Output => context.Response.BodyWriter;
}
}

View file

@ -100,15 +100,17 @@ internal sealed class StreamableHttpSession(
try
{
await _disposeCts.CancelAsync();
try
{
// Dispose transport first to complete the incoming MessageReader gracefully and avoid a potentially unnecessary OCE.
await transport.DisposeAsync();
await _disposeCts.CancelAsync();
await ServerRunTask;
}
finally
{
await DisposeServerThenTransportAsync();
await server.DisposeAsync();
}
}
catch (OperationCanceledException)
@ -124,18 +126,6 @@ internal sealed class StreamableHttpSession(
}
}
private async ValueTask DisposeServerThenTransportAsync()
{
try
{
await server.DisposeAsync();
}
finally
{
await transport.DisposeAsync();
}
}
private sealed class UnreferenceDisposable(StreamableHttpSession session) : IAsyncDisposable
{
public ValueTask DisposeAsync()

View file

@ -116,14 +116,14 @@ internal sealed partial class McpSession : IDisposable
LogMessageRead(EndpointName, message.GetType().Name);
// Fire and forget the message handling to avoid blocking the transport.
if (message.ExecutionContext is null)
if (message.Context?.ExecutionContext is null)
{
_ = ProcessMessageAsync();
}
else
{
// Flow the execution context from the HTTP request corresponding to this message if provided.
ExecutionContext.Run(message.ExecutionContext, _ => _ = ProcessMessageAsync(), null);
ExecutionContext.Run(message.Context.ExecutionContext, _ => _ = ProcessMessageAsync(), null);
}
async Task ProcessMessageAsync()
@ -176,13 +176,15 @@ internal sealed partial class McpSession : IDisposable
Message = "An error occurred.",
};
await SendMessageAsync(new JsonRpcError
var errorMessage = new JsonRpcError
{
Id = request.Id,
JsonRpc = "2.0",
Error = detail,
RelatedTransport = request.RelatedTransport,
}, cancellationToken).ConfigureAwait(false);
Context = new JsonRpcMessageContext { RelatedTransport = request.Context?.RelatedTransport },
};
await SendMessageAsync(errorMessage, cancellationToken).ConfigureAwait(false);
}
else if (ex is not OperationCanceledException)
{
@ -329,7 +331,7 @@ internal sealed partial class McpSession : IDisposable
{
Id = request.Id,
Result = result,
RelatedTransport = request.RelatedTransport,
Context = request.Context,
}, cancellationToken).ConfigureAwait(false);
return result;
@ -349,7 +351,7 @@ internal sealed partial class McpSession : IDisposable
{
Method = NotificationMethods.CancelledNotification,
Params = JsonSerializer.SerializeToNode(new CancelledNotificationParams { RequestId = state.Item2.Id }, McpJsonUtilities.JsonContext.Default.CancelledNotificationParams),
RelatedTransport = state.Item2.RelatedTransport,
Context = new JsonRpcMessageContext { RelatedTransport = state.Item2.Context?.RelatedTransport },
});
}, Tuple.Create(this, request));
}
@ -527,7 +529,7 @@ internal sealed partial class McpSession : IDisposable
// Streamable HTTP transport where the specification states that the server SHOULD include JSON-RPC responses in
// the HTTP response body for the POST request containing the corresponding JSON-RPC request.
private Task SendToRelatedTransportAsync(JsonRpcMessage message, CancellationToken cancellationToken)
=> (message.RelatedTransport ?? _transport).SendMessageAsync(message, cancellationToken);
=> (message.Context?.RelatedTransport ?? _transport).SendMessageAsync(message, cancellationToken);
private static CancelledNotificationParams? GetCancelledNotificationParams(JsonNode? notificationParams)
{

View file

@ -9,7 +9,7 @@ namespace ModelContextProtocol.Protocol;
/// </summary>
/// <remarks>
/// <para>
/// When enabled, this capability allows a Model Context Protocol server to provide
/// When enabled, this capability allows a Model Context Protocol server to provide
/// auto-completion suggestions. This capability is advertised to clients during the initialize handshake.
/// </para>
/// <para>
@ -33,5 +33,5 @@ public sealed class CompletionsCapability
/// and should return appropriate completion suggestions.
/// </remarks>
[JsonIgnore]
public Func<RequestContext<CompleteRequestParams>, CancellationToken, ValueTask<CompleteResult>>? CompleteHandler { get; set; }
public McpRequestHandler<CompleteRequestParams, CompleteResult>? CompleteHandler { get; set; }
}

View file

@ -1,5 +1,6 @@
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization;
@ -29,28 +30,21 @@ public abstract class JsonRpcMessage
public string JsonRpc { get; init; } = "2.0";
/// <summary>
/// Gets or sets the transport the <see cref="JsonRpcMessage"/> was received on or should be sent over.
/// Gets or sets the contextual information for this JSON-RPC message.
/// </summary>
/// <remarks>
/// This is used to support the Streamable HTTP transport where the specification states that the server
/// SHOULD include JSON-RPC responses in the HTTP response body for the POST request containing
/// the corresponding JSON-RPC request. It may be <see langword="null"/> for other transports.
/// This property contains transport-specific and runtime context information that accompanies
/// JSON-RPC messages but is not serialized as part of the JSON-RPC payload. This includes
/// transport references, execution context, and authenticated user information.
/// </remarks>
[JsonIgnore]
public ITransport? RelatedTransport { get; set; }
/// <summary>
/// Gets or sets the <see cref="ExecutionContext"/> that should be used to run any handlers
/// </summary>
/// <remarks>
/// This is used to support the Streamable HTTP transport in its default stateful mode. In this mode,
/// the <see cref="IMcpServer"/> outlives the initial HTTP request context it was created on, and new
/// JSON-RPC messages can originate from future HTTP requests. This allows the transport to flow the
/// context with the JSON-RPC message. This is particularly useful for enabling IHttpContextAccessor
/// in tool calls.
/// This property should only be set when implementing a custom <see cref="ITransport"/>
/// that needs to pass additional per-message context or to pass a <see cref="JsonRpcMessageContext.User"/>
/// to <see cref="StreamableHttpServerTransport.HandlePostRequest(JsonRpcMessage, Stream, CancellationToken)"/>
/// or <see cref="SseResponseStreamTransport.OnMessageReceivedAsync(JsonRpcMessage, CancellationToken)"/> .
/// </remarks>
[JsonIgnore]
public ExecutionContext? ExecutionContext { get; set; }
public JsonRpcMessageContext? Context { get; set; }
/// <summary>
/// Provides a <see cref="JsonConverter"/> for <see cref="JsonRpcMessage"/> messages,

View file

@ -0,0 +1,61 @@
using ModelContextProtocol.Server;
using System.Security.Claims;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol;
/// <summary>
/// Contains contextual information for JSON-RPC messages that is not part of the JSON-RPC protocol specification.
/// </summary>
/// <remarks>
/// This class holds transport-specific and runtime context information that accompanies JSON-RPC messages
/// but is not serialized as part of the JSON-RPC payload. This includes transport references, execution context,
/// and authenticated user information.
/// </remarks>
public class JsonRpcMessageContext
{
/// <summary>
/// Gets or sets the transport the <see cref="JsonRpcMessage"/> was received on or should be sent over.
/// </summary>
/// <remarks>
/// This is used to support the Streamable HTTP transport where the specification states that the server
/// SHOULD include JSON-RPC responses in the HTTP response body for the POST request containing
/// the corresponding JSON-RPC request. It may be <see langword="null"/> for other transports.
/// </remarks>
public ITransport? RelatedTransport { get; set; }
/// <summary>
/// Gets or sets the <see cref="ExecutionContext"/> that should be used to run any handlers
/// </summary>
/// <remarks>
/// This is used to support the Streamable HTTP transport in its default stateful mode. In this mode,
/// the <see cref="IMcpServer"/> outlives the initial HTTP request context it was created on, and new
/// JSON-RPC messages can originate from future HTTP requests. This allows the transport to flow the
/// context with the JSON-RPC message. This is particularly useful for enabling IHttpContextAccessor
/// in tool calls.
/// </remarks>
public ExecutionContext? ExecutionContext { get; set; }
/// <summary>
/// Gets or sets the authenticated user associated with this JSON-RPC message.
/// </summary>
/// <remarks>
/// <para>
/// This property contains the <see cref="ClaimsPrincipal"/> representing the authenticated user
/// who initiated this JSON-RPC message. This enables request handlers to access user identity
/// and authorization information without requiring dependency on HTTP context accessors
/// or other HTTP-specific abstractions.
/// </para>
/// <para>
/// The user information is automatically populated by the transport layer when processing
/// incoming HTTP requests in ASP.NET Core scenarios. For other transport types or scenarios
/// where user authentication is not applicable, this property may be <see langword="null"/>.
/// </para>
/// <para>
/// This property is particularly useful in the Streamable HTTP transport where JSON-RPC messages
/// may outlive the original HTTP request context, allowing user identity to be preserved
/// throughout the message processing pipeline.
/// </para>
/// </remarks>
public ClaimsPrincipal? User { get; set; }
}

View file

@ -9,7 +9,7 @@ namespace ModelContextProtocol.Protocol;
/// <remarks>
/// Requests are messages that require a response from the receiver. Each request includes a unique ID
/// that will be included in the corresponding response message (either a success response or an error).
///
///
/// The receiver of a request message is expected to execute the specified method with the provided parameters
/// and return either a <see cref="JsonRpcResponse"/> with the result, or a <see cref="JsonRpcError"/>
/// if the method execution fails.
@ -36,7 +36,7 @@ public sealed class JsonRpcRequest : JsonRpcMessageWithId
Id = id,
Method = Method,
Params = Params,
RelatedTransport = RelatedTransport,
Context = Context,
};
}
}

View file

@ -18,5 +18,5 @@ public sealed class LoggingCapability
/// Gets or sets the handler for set logging level requests from clients.
/// </summary>
[JsonIgnore]
public Func<RequestContext<SetLevelRequestParams>, CancellationToken, ValueTask<EmptyResult>>? SetLoggingLevelHandler { get; set; }
public McpRequestHandler<SetLevelRequestParams, EmptyResult>? SetLoggingLevelHandler { get; set; }
}

View file

@ -1,5 +1,6 @@
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using ModelContextProtocol.Server;
namespace ModelContextProtocol.Protocol;
@ -59,4 +60,10 @@ public sealed class Prompt : IBaseMetadata
/// </remarks>
[JsonPropertyName("_meta")]
public JsonObject? Meta { get; set; }
/// <summary>
/// Gets or sets the callable server prompt corresponding to this metadata if any.
/// </summary>
[JsonIgnore]
public McpServerPrompt? McpServerPrompt { get; set; }
}

View file

@ -22,10 +22,10 @@ public sealed class PromptsCapability
/// Gets or sets whether this server supports notifications for changes to the prompt list.
/// </summary>
/// <remarks>
/// When set to <see langword="true"/>, the server will send notifications using
/// <see cref="NotificationMethods.PromptListChangedNotification"/> when prompts are added,
/// When set to <see langword="true"/>, the server will send notifications using
/// <see cref="NotificationMethods.PromptListChangedNotification"/> when prompts are added,
/// removed, or modified. Clients can register handlers for these notifications to
/// refresh their prompt cache. This capability enables clients to stay synchronized with server-side changes
/// refresh their prompt cache. This capability enables clients to stay synchronized with server-side changes
/// to available prompts.
/// </remarks>
[JsonPropertyName("listChanged")]
@ -40,15 +40,15 @@ public sealed class PromptsCapability
/// along with any prompts defined in <see cref="PromptCollection"/>.
/// </remarks>
[JsonIgnore]
public Func<RequestContext<ListPromptsRequestParams>, CancellationToken, ValueTask<ListPromptsResult>>? ListPromptsHandler { get; set; }
public McpRequestHandler<ListPromptsRequestParams, ListPromptsResult>? ListPromptsHandler { get; set; }
/// <summary>
/// Gets or sets the handler for <see cref="RequestMethods.PromptsGet"/> requests.
/// </summary>
/// <remarks>
/// <para>
/// This handler is invoked when a client requests details for a specific prompt by name and provides arguments
/// for the prompt if needed. The handler receives the request context containing the prompt name and any arguments,
/// This handler is invoked when a client requests details for a specific prompt by name and provides arguments
/// for the prompt if needed. The handler receives the request context containing the prompt name and any arguments,
/// and should return a <see cref="GetPromptResult"/> with the prompt messages and other details.
/// </para>
/// <para>
@ -57,7 +57,7 @@ public sealed class PromptsCapability
/// </para>
/// </remarks>
[JsonIgnore]
public Func<RequestContext<GetPromptRequestParams>, CancellationToken, ValueTask<GetPromptResult>>? GetPromptHandler { get; set; }
public McpRequestHandler<GetPromptRequestParams, GetPromptResult>? GetPromptHandler { get; set; }
/// <summary>
/// Gets or sets a collection of prompts that will be served by the server.
@ -69,7 +69,7 @@ public sealed class PromptsCapability
/// when those are provided:
/// </para>
/// <para>
/// - For <see cref="RequestMethods.PromptsList"/> requests: The server returns all prompts from this collection
/// - For <see cref="RequestMethods.PromptsList"/> requests: The server returns all prompts from this collection
/// plus any additional prompts provided by the <see cref="ListPromptsHandler"/> if it's set.
/// </para>
/// <para>

View file

@ -1,5 +1,6 @@
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using ModelContextProtocol.Server;
namespace ModelContextProtocol.Protocol;
@ -87,4 +88,10 @@ public sealed class Resource : IBaseMetadata
/// </remarks>
[JsonPropertyName("_meta")]
public JsonObject? Meta { get; init; }
/// <summary>
/// Gets or sets the callable server resource corresponding to this metadata if any.
/// </summary>
[JsonIgnore]
public McpServerResource? McpServerResource { get; set; }
}

View file

@ -1,5 +1,6 @@
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using ModelContextProtocol.Server;
namespace ModelContextProtocol.Protocol;
@ -84,6 +85,12 @@ public sealed class ResourceTemplate : IBaseMetadata
[JsonIgnore]
public bool IsTemplated => UriTemplate.Contains('{');
/// <summary>
/// Gets or sets the callable server resource corresponding to this metadata if any.
/// </summary>
[JsonIgnore]
public McpServerResource? McpServerResource { get; set; }
/// <summary>Converts the <see cref="ResourceTemplate"/> into a <see cref="Resource"/>.</summary>
/// <returns>A <see cref="Resource"/> if <see cref="IsTemplated"/> is <see langword="false"/>; otherwise, <see langword="null"/>.</returns>
public Resource? AsResource()
@ -102,6 +109,7 @@ public sealed class ResourceTemplate : IBaseMetadata
MimeType = MimeType,
Annotations = Annotations,
Meta = Meta,
McpServerResource = McpServerResource,
};
}
}

View file

@ -21,8 +21,8 @@ public sealed class ResourcesCapability
/// Gets or sets whether this server supports notifications for changes to the resource list.
/// </summary>
/// <remarks>
/// When set to <see langword="true"/>, the server will send notifications using
/// <see cref="NotificationMethods.ResourceListChangedNotification"/> when resources are added,
/// When set to <see langword="true"/>, the server will send notifications using
/// <see cref="NotificationMethods.ResourceListChangedNotification"/> when resources are added,
/// removed, or modified. Clients can register handlers for these notifications to
/// refresh their resource cache.
/// </remarks>
@ -39,7 +39,7 @@ public sealed class ResourcesCapability
/// allowing clients to discover available resource types and their access patterns.
/// </remarks>
[JsonIgnore]
public Func<RequestContext<ListResourceTemplatesRequestParams>, CancellationToken, ValueTask<ListResourceTemplatesResult>>? ListResourceTemplatesHandler { get; set; }
public McpRequestHandler<ListResourceTemplatesRequestParams, ListResourceTemplatesResult>? ListResourceTemplatesHandler { get; set; }
/// <summary>
/// Gets or sets the handler for <see cref="RequestMethods.ResourcesList"/> requests.
@ -49,7 +49,7 @@ public sealed class ResourcesCapability
/// The implementation should return a <see cref="ListResourcesResult"/> with the matching resources.
/// </remarks>
[JsonIgnore]
public Func<RequestContext<ListResourcesRequestParams>, CancellationToken, ValueTask<ListResourcesResult>>? ListResourcesHandler { get; set; }
public McpRequestHandler<ListResourcesRequestParams, ListResourcesResult>? ListResourcesHandler { get; set; }
/// <summary>
/// Gets or sets the handler for <see cref="RequestMethods.ResourcesRead"/> requests.
@ -61,7 +61,7 @@ public sealed class ResourcesCapability
/// its contents in a ReadResourceResult object.
/// </remarks>
[JsonIgnore]
public Func<RequestContext<ReadResourceRequestParams>, CancellationToken, ValueTask<ReadResourceResult>>? ReadResourceHandler { get; set; }
public McpRequestHandler<ReadResourceRequestParams, ReadResourceResult>? ReadResourceHandler { get; set; }
/// <summary>
/// Gets or sets the handler for <see cref="RequestMethods.ResourcesSubscribe"/> requests.
@ -74,7 +74,7 @@ public sealed class ResourcesCapability
/// requiring polling.
/// </remarks>
[JsonIgnore]
public Func<RequestContext<SubscribeRequestParams>, CancellationToken, ValueTask<EmptyResult>>? SubscribeToResourcesHandler { get; set; }
public McpRequestHandler<SubscribeRequestParams, EmptyResult>? SubscribeToResourcesHandler { get; set; }
/// <summary>
/// Gets or sets the handler for <see cref="RequestMethods.ResourcesUnsubscribe"/> requests.
@ -85,7 +85,7 @@ public sealed class ResourcesCapability
/// about the specified resource.
/// </remarks>
[JsonIgnore]
public Func<RequestContext<UnsubscribeRequestParams>, CancellationToken, ValueTask<EmptyResult>>? UnsubscribeFromResourcesHandler { get; set; }
public McpRequestHandler<UnsubscribeRequestParams, EmptyResult>? UnsubscribeFromResourcesHandler { get; set; }
/// <summary>
/// Gets or sets a collection of resources served by the server.

View file

@ -1,6 +1,7 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using ModelContextProtocol.Server;
namespace ModelContextProtocol.Protocol;
@ -43,7 +44,7 @@ public sealed class Tool : IBaseMetadata
/// if an invalid schema is provided.
/// </para>
/// <para>
/// The schema typically defines the properties (parameters) that the tool accepts,
/// The schema typically defines the properties (parameters) that the tool accepts,
/// their types, and which ones are required. This helps AI models understand
/// how to structure their calls to the tool.
/// </para>
@ -52,9 +53,9 @@ public sealed class Tool : IBaseMetadata
/// </para>
/// </remarks>
[JsonPropertyName("inputSchema")]
public JsonElement InputSchema
{
get => field;
public JsonElement InputSchema
{
get => field;
set
{
if (!McpJsonUtilities.IsValidMcpToolSchema(value))
@ -114,4 +115,10 @@ public sealed class Tool : IBaseMetadata
/// </remarks>
[JsonPropertyName("_meta")]
public JsonObject? Meta { get; set; }
/// <summary>
/// Gets or sets the callable server tool corresponding to this metadata if any.
/// </summary>
[JsonIgnore]
public McpServerTool? McpServerTool { get; set; }
}

Some files were not shown because too many files have changed in this diff Show more