Skip to content

Commit 43deac5

Browse files
committed
Add middleware and authz support for server-side handlers
1 parent b067261 commit 43deac5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+2227
-362
lines changed

Directory.Packages.props

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@
6666
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.12.0" />
6767
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
6868
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
69-
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
70-
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
69+
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
70+
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
7171
<PackageVersion Include="Serilog.Extensions.Hosting" Version="9.0.0" />
7272
<PackageVersion Include="Serilog.Extensions.Logging" Version="9.0.1" />
7373
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />

docs/FILTERS.md

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# MCP Server Handler Filters
2+
3+
This document describes the filter functionality in the MCP Server, which allows you to add middleware-style filters to handler pipelines.
4+
5+
## Overview
6+
7+
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.
8+
9+
## Available Filter Methods
10+
11+
The following filter methods are available:
12+
13+
- `AddListResourceTemplatesFilter` - Filter for list resource templates handlers
14+
- `AddListToolsFilter` - Filter for list tools handlers
15+
- `AddCallToolFilter` - Filter for call tool handlers
16+
- `AddListPromptsFilter` - Filter for list prompts handlers
17+
- `AddGetPromptFilter` - Filter for get prompt handlers
18+
- `AddListResourcesFilter` - Filter for list resources handlers
19+
- `AddReadResourceFilter` - Filter for read resource handlers
20+
- `AddCompleteFilter` - Filter for completion handlers
21+
- `AddSubscribeToResourcesFilter` - Filter for resource subscription handlers
22+
- `AddUnsubscribeFromResourcesFilter` - Filter for resource unsubscription handlers
23+
- `AddSetLoggingLevelFilter` - Filter for logging level handlers
24+
25+
## Usage
26+
27+
Filters are functions that take a handler and return a new handler, allowing you to wrap the original handler with additional functionality:
28+
29+
```csharp
30+
services.AddMcpServer()
31+
.WithListToolsHandler(async (context, cancellationToken) =>
32+
{
33+
// Your base handler logic
34+
return new ListToolsResult { Tools = GetTools() };
35+
})
36+
.AddListToolsFilter(next => async (context, cancellationToken) =>
37+
{
38+
// Pre-processing logic
39+
Console.WriteLine("Before handler execution");
40+
41+
var result = await next(context, cancellationToken);
42+
43+
// Post-processing logic
44+
Console.WriteLine("After handler execution");
45+
return result;
46+
});
47+
```
48+
49+
## Filter Execution Order
50+
51+
```csharp
52+
services.AddMcpServer()
53+
.WithListToolsHandler(baseHandler)
54+
.AddListToolsFilter(filter1) // Executes first (outermost)
55+
.AddListToolsFilter(filter2) // Executes second
56+
.AddListToolsFilter(filter3); // Executes third (closest to handler)
57+
```
58+
59+
Execution flow: `filter1 -> filter2 -> filter3 -> baseHandler -> filter3 -> filter2 -> filter1`
60+
61+
## Common Use Cases
62+
63+
### Logging
64+
```csharp
65+
.AddListToolsFilter(next => async (context, cancellationToken) =>
66+
{
67+
Console.WriteLine($"Processing request from {context.Meta.ProgressToken}");
68+
var result = await next(context, cancellationToken);
69+
Console.WriteLine($"Returning {result.Tools?.Count ?? 0} tools");
70+
return result;
71+
});
72+
```
73+
74+
### Error Handling
75+
```csharp
76+
.AddCallToolFilter(next => async (context, cancellationToken) =>
77+
{
78+
try
79+
{
80+
return await next(context, cancellationToken);
81+
}
82+
catch (Exception ex)
83+
{
84+
return new CallToolResult
85+
{
86+
Content = new[] { new TextContent { Type = "text", Text = $"Error: {ex.Message}" } },
87+
IsError = true
88+
};
89+
}
90+
});
91+
```
92+
93+
### Performance Monitoring
94+
```csharp
95+
.AddListToolsFilter(next => async (context, cancellationToken) =>
96+
{
97+
var stopwatch = Stopwatch.StartNew();
98+
var result = await next(context, cancellationToken);
99+
stopwatch.Stop();
100+
Console.WriteLine($"Handler took {stopwatch.ElapsedMilliseconds}ms");
101+
return result;
102+
});
103+
```
104+
105+
### Caching
106+
```csharp
107+
.AddListResourcesFilter(next => async (context, cancellationToken) =>
108+
{
109+
var cacheKey = $"resources:{context.Params.Cursor}";
110+
if (cache.TryGetValue(cacheKey, out var cached))
111+
return cached;
112+
113+
var result = await next(context, cancellationToken);
114+
cache.Set(cacheKey, result, TimeSpan.FromMinutes(5));
115+
return result;
116+
});
117+
```

samples/AspNetCoreMcpServer/Properties/launchSettings.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"applicationUrl": "http://localhost:3001",
88
"environmentVariables": {
99
"ASPNETCORE_ENVIRONMENT": "Development",
10-
"OTEL_SERVICE_NAME": "aspnetcore-mcp-server",
10+
"OTEL_SERVICE_NAME": "aspnetcore-mcp-server"
1111
}
1212
},
1313
"https": {
@@ -16,7 +16,7 @@
1616
"applicationUrl": "https://localhost:7133;http://localhost:3001",
1717
"environmentVariables": {
1818
"ASPNETCORE_ENVIRONMENT": "Development",
19-
"OTEL_SERVICE_NAME": "aspnetcore-mcp-server",
19+
"OTEL_SERVICE_NAME": "aspnetcore-mcp-server"
2020
}
2121
}
2222
}
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
using System.Security.Claims;
2+
using Microsoft.AspNetCore.Authorization;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using Microsoft.Extensions.Options;
5+
using ModelContextProtocol.Protocol;
6+
using ModelContextProtocol.Server;
7+
8+
namespace ModelContextProtocol.AspNetCore;
9+
10+
/// <summary>
11+
/// Evaluates authorization policies from endpoint metadata.
12+
/// </summary>
13+
internal sealed class AuthorizationFilterSetup(IAuthorizationPolicyProvider? policyProvider = null) : IConfigureOptions<McpServerOptions>
14+
{
15+
public void Configure(McpServerOptions options)
16+
{
17+
ConfigureListToolsFilter(options);
18+
ConfigureCallToolFilter(options);
19+
20+
ConfigureListResourcesFilter(options);
21+
ConfigureListResourceTemplatesFilter(options);
22+
ConfigureReadResourceFilter(options);
23+
24+
ConfigureListPromptsFilter(options);
25+
ConfigureGetPromptFilter(options);
26+
}
27+
28+
private void ConfigureListToolsFilter(McpServerOptions options)
29+
{
30+
options.Filters.ListToolsFilters.Add(next => async (context, cancellationToken) =>
31+
{
32+
var result = await next(context, cancellationToken);
33+
await FilterAuthorizedItemsAsync(
34+
result.Tools, static tool => tool.McpServerTool,
35+
context.User, context.Services, context);
36+
return result;
37+
});
38+
}
39+
40+
private void ConfigureCallToolFilter(McpServerOptions options)
41+
{
42+
options.Filters.CallToolFilters.Add(next => async (context, cancellationToken) =>
43+
{
44+
var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context);
45+
if (!authResult.Succeeded)
46+
{
47+
return new CallToolResult
48+
{
49+
Content = [new TextContentBlock { Text = "Access forbidden: This tool requires authorization." }],
50+
IsError = true
51+
};
52+
}
53+
54+
return await next(context, cancellationToken);
55+
});
56+
}
57+
58+
private void ConfigureListResourcesFilter(McpServerOptions options)
59+
{
60+
options.Filters.ListResourcesFilters.Add(next => async (context, cancellationToken) =>
61+
{
62+
var result = await next(context, cancellationToken);
63+
await FilterAuthorizedItemsAsync(
64+
result.Resources, static resource => resource.McpServerResource,
65+
context.User, context.Services, context);
66+
return result;
67+
});
68+
}
69+
70+
private void ConfigureListResourceTemplatesFilter(McpServerOptions options)
71+
{
72+
options.Filters.ListResourceTemplatesFilters.Add(next => async (context, cancellationToken) =>
73+
{
74+
var result = await next(context, cancellationToken);
75+
await FilterAuthorizedItemsAsync(
76+
result.ResourceTemplates, static resourceTemplate => resourceTemplate.McpServerResource,
77+
context.User, context.Services, context);
78+
return result;
79+
});
80+
}
81+
82+
private void ConfigureReadResourceFilter(McpServerOptions options)
83+
{
84+
options.Filters.ReadResourceFilters.Add(next => async (context, cancellationToken) =>
85+
{
86+
var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context);
87+
if (!authResult.Succeeded)
88+
{
89+
throw new McpException("Access forbidden: This resource requires authorization.", McpErrorCode.InvalidRequest);
90+
}
91+
92+
return await next(context, cancellationToken);
93+
});
94+
}
95+
96+
private void ConfigureListPromptsFilter(McpServerOptions options)
97+
{
98+
options.Filters.ListPromptsFilters.Add(next => async (context, cancellationToken) =>
99+
{
100+
var result = await next(context, cancellationToken);
101+
await FilterAuthorizedItemsAsync(
102+
result.Prompts, static prompt => prompt.McpServerPrompt,
103+
context.User, context.Services, context);
104+
return result;
105+
});
106+
}
107+
108+
private void ConfigureGetPromptFilter(McpServerOptions options)
109+
{
110+
options.Filters.GetPromptFilters.Add(next => async (context, cancellationToken) =>
111+
{
112+
var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context);
113+
if (!authResult.Succeeded)
114+
{
115+
throw new McpException("Access forbidden: This prompt requires authorization.", McpErrorCode.InvalidRequest);
116+
}
117+
118+
return await next(context, cancellationToken);
119+
});
120+
}
121+
122+
/// <summary>
123+
/// Filters a collection of items based on authorization policies in their metadata.
124+
/// For list operations where we need to filter results by authorization.
125+
/// </summary>
126+
private async ValueTask FilterAuthorizedItemsAsync<T>(IList<T> items, Func<T, IMcpServerPrimitive?> primitiveSelector,
127+
ClaimsPrincipal? user, IServiceProvider? requestServices, object context)
128+
{
129+
for (int i = items.Count - 1; i >= 0; i--)
130+
{
131+
var authorizationResult = await GetAuthorizationResultAsync(
132+
user, primitiveSelector(items[i]), requestServices, context);
133+
134+
if (!authorizationResult.Succeeded)
135+
{
136+
items.RemoveAt(i);
137+
}
138+
}
139+
}
140+
141+
private async ValueTask<AuthorizationResult> GetAuthorizationResultAsync(
142+
ClaimsPrincipal? user, IMcpServerPrimitive? primitive, IServiceProvider? requestServices, object context)
143+
{
144+
// If no primitive was found for this request or there is IAllowAnonymous metadata anywhere on the class or method,
145+
// the request should go through as normal.
146+
if (primitive is null || primitive.Metadata.Any(static m => m is IAllowAnonymous))
147+
{
148+
return AuthorizationResult.Success();
149+
}
150+
151+
// There are no [Authorize] style attributes applied to the method or containing class. Any fallback policies
152+
// have already been enforced at the HTTP request level by the ASP.NET Core authorization middleware.
153+
if (!primitive.Metadata.Any(static m => m is IAuthorizeData or AuthorizationPolicy or IAuthorizationRequirementData))
154+
{
155+
return AuthorizationResult.Success();
156+
}
157+
158+
if (policyProvider is null)
159+
{
160+
throw new InvalidOperationException($"You must call AddAuthorization() because an authorization related attribute was found on {primitive.Id}");
161+
}
162+
163+
// TODO: Cache policy lookup. We would probably use a singleton (not-static) ConditionalWeakTable<IMcpServerPrimitive, AuthorizationPolicy?>.
164+
var policy = await CombineAsync(policyProvider, primitive.Metadata);
165+
if (policy is null)
166+
{
167+
return AuthorizationResult.Success();
168+
}
169+
170+
if (requestServices is null)
171+
{
172+
// The IAuthorizationPolicyProvider service must be non-null to get to this line, so it's very unexpected for RequestContext.Services to not be set.
173+
throw new InvalidOperationException("RequestContext.Services is not set! The IMcpServer must be initialized with a non-null IServiceProvider.");
174+
}
175+
176+
// ASP.NET Core's AuthorizationMiddleware resolves the IAuthorizationService from scoped request services, so we do the same.
177+
var authService = requestServices.GetRequiredService<IAuthorizationService>();
178+
return await authService.AuthorizeAsync(user ?? new ClaimsPrincipal(new ClaimsIdentity()), context, policy);
179+
}
180+
181+
/// <summary>
182+
/// Combines authorization policies and requirements from endpoint metadata without considering <see cref="IAllowAnonymous"/>.
183+
/// </summary>
184+
/// <param name="policyProvider">The authorization policy provider.</param>
185+
/// <param name="endpointMetadata">The endpoint metadata collection.</param>
186+
/// <returns>The combined authorization policy, or null if no authorization is required.</returns>
187+
private static async ValueTask<AuthorizationPolicy?> CombineAsync(IAuthorizationPolicyProvider policyProvider, IReadOnlyList<object> endpointMetadata)
188+
{
189+
// https://github.com/dotnet/aspnetcore/issues/63365 tracks adding this as public API to AuthorizationPolicy itself.
190+
// Copied from https://github.com/dotnet/aspnetcore/blob/9f2977bf9cfb539820983bda3bedf81c8cda9f20/src/Security/Authorization/Policy/src/AuthorizationMiddleware.cs#L116-L138
191+
var authorizeData = endpointMetadata.OfType<IAuthorizeData>();
192+
var policies = endpointMetadata.OfType<AuthorizationPolicy>();
193+
194+
var policy = await AuthorizationPolicy.CombineAsync(policyProvider, authorizeData, policies);
195+
196+
AuthorizationPolicyBuilder? reqPolicyBuilder = null;
197+
198+
foreach (var m in endpointMetadata)
199+
{
200+
if (m is not IAuthorizationRequirementData requirementData)
201+
{
202+
continue;
203+
}
204+
205+
reqPolicyBuilder ??= new AuthorizationPolicyBuilder();
206+
foreach (var requirement in requirementData.GetRequirements())
207+
{
208+
reqPolicyBuilder.AddRequirements(requirement);
209+
}
210+
}
211+
212+
if (reqPolicyBuilder is null)
213+
{
214+
return policy;
215+
}
216+
217+
// Combine policy with requirements or just use requirements if no policy
218+
return (policy is null)
219+
? reqPolicyBuilder.Build()
220+
: AuthorizationPolicy.Combine(policy, reqPolicyBuilder.Build());
221+
}
222+
}

src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Microsoft.Extensions.DependencyInjection.Extensions;
2+
using Microsoft.Extensions.Options;
23
using ModelContextProtocol.AspNetCore;
34
using ModelContextProtocol.Server;
45

@@ -29,6 +30,9 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder
2930
builder.Services.AddHostedService<IdleTrackingBackgroundService>();
3031
builder.Services.AddDataProtection();
3132

33+
// Register authorization filter setup for automatic filter configuration
34+
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<McpServerOptions>, AuthorizationFilterSetup>());
35+
3236
if (configureOptions is not null)
3337
{
3438
builder.Services.Configure(configureOptions);

0 commit comments

Comments
 (0)