Skip to content

Commit 7d48d39

Browse files
authored
Add support for message-level filters to McpServer (#1207)
1 parent 6224f30 commit 7d48d39

File tree

16 files changed

+1333
-105
lines changed

16 files changed

+1333
-105
lines changed

docs/concepts/filters.md

Lines changed: 213 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@ uid: filters
77

88
# MCP Server Handler Filters
99

10-
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.
10+
The MCP Server provides two levels of filters for intercepting and modifying request processing:
1111

12-
## Available Filter Methods
12+
1. **Message Filters** - Low-level filters (`AddIncomingMessageFilter`, `AddOutgoingMessageFilter`) that intercept all JSON-RPC messages before routing
13+
2. **Request-Specific Filters** - Handler-level filters (e.g., `AddListToolsFilter`, `AddCallToolFilter`) that target specific MCP operations
14+
15+
The filters are stored in `McpServerOptions.Filters` and applied during server configuration.
16+
17+
## Available Request-Specific Filter Methods
1318

1419
The following filter methods are available:
1520

@@ -25,6 +30,212 @@ The following filter methods are available:
2530
- `AddUnsubscribeFromResourcesFilter` - Filter for resource unsubscription handlers
2631
- `AddSetLoggingLevelFilter` - Filter for logging level handlers
2732

33+
## Message Filters
34+
35+
In addition to the request-specific filters above, there are low-level message filters that intercept all JSON-RPC messages before they are routed to specific handlers:
36+
37+
- `AddIncomingMessageFilter` - Filter for all incoming JSON-RPC messages (requests and notifications)
38+
- `AddOutgoingMessageFilter` - Filter for all outgoing JSON-RPC messages (responses and notifications)
39+
40+
### When to Use Message Filters
41+
42+
Message filters operate at a lower level than request-specific filters and are useful when you need to:
43+
44+
- Intercept all messages regardless of type
45+
- Implement custom protocol extensions or handle custom JSON-RPC methods
46+
- Log or monitor all traffic between client and server
47+
- Modify or skip messages before they reach handlers
48+
- Send additional messages in response to specific events
49+
50+
### Incoming Message Filter
51+
52+
`AddIncomingMessageFilter` intercepts all incoming JSON-RPC messages before they are dispatched to request-specific handlers:
53+
54+
```csharp
55+
services.AddMcpServer()
56+
.AddIncomingMessageFilter(next => async (context, cancellationToken) =>
57+
{
58+
var logger = context.Services?.GetService<ILogger<Program>>();
59+
60+
// Access the raw JSON-RPC message
61+
if (context.JsonRpcMessage is JsonRpcRequest request)
62+
{
63+
logger?.LogInformation($"Incoming request: {request.Method}");
64+
}
65+
66+
// Call next to continue processing
67+
await next(context, cancellationToken);
68+
})
69+
.WithTools<MyTools>();
70+
```
71+
72+
#### MessageContext Properties
73+
74+
Inside an incoming message filter, you have access to:
75+
76+
- `context.JsonRpcMessage` - The incoming `JsonRpcMessage` (can be `JsonRpcRequest` or `JsonRpcNotification`)
77+
- `context.Server` - The `McpServer` instance for sending responses or notifications
78+
- `context.Services` - The request's service provider
79+
- `context.Items` - A dictionary for passing data between filters
80+
81+
#### Skipping Default Handlers
82+
83+
You can skip the default handler by not calling `next`. This is useful for implementing custom protocol methods:
84+
85+
```csharp
86+
.AddIncomingMessageFilter(next => async (context, cancellationToken) =>
87+
{
88+
if (context.JsonRpcMessage is JsonRpcRequest request && request.Method == "custom/myMethod")
89+
{
90+
// Handle the custom method directly
91+
var response = new JsonRpcResponse
92+
{
93+
Id = request.Id,
94+
Result = JsonSerializer.SerializeToNode(new { message = "Custom response" })
95+
};
96+
await context.Server.SendMessageAsync(response, cancellationToken);
97+
return; // Don't call next - we handled it
98+
}
99+
100+
await next(context, cancellationToken);
101+
})
102+
```
103+
104+
### Outgoing Message Filter
105+
106+
`AddOutgoingMessageFilter` intercepts all outgoing JSON-RPC messages before they are sent to the client:
107+
108+
```csharp
109+
services.AddMcpServer()
110+
.AddOutgoingMessageFilter(next => async (context, cancellationToken) =>
111+
{
112+
var logger = context.Services?.GetService<ILogger<Program>>();
113+
114+
// Inspect outgoing messages
115+
switch (context.JsonRpcMessage)
116+
{
117+
case JsonRpcResponse response:
118+
logger?.LogInformation($"Sending response for request {response.Id}");
119+
break;
120+
case JsonRpcNotification notification:
121+
logger?.LogInformation($"Sending notification: {notification.Method}");
122+
break;
123+
}
124+
125+
await next(context, cancellationToken);
126+
})
127+
.WithTools<MyTools>();
128+
```
129+
130+
#### Skipping Outgoing Messages
131+
132+
You can suppress outgoing messages by not calling `next`:
133+
134+
```csharp
135+
.AddOutgoingMessageFilter(next => async (context, cancellationToken) =>
136+
{
137+
// Suppress specific notifications
138+
if (context.JsonRpcMessage is JsonRpcNotification notification &&
139+
notification.Method == "notifications/progress")
140+
{
141+
return; // Don't send this notification
142+
}
143+
144+
await next(context, cancellationToken);
145+
})
146+
```
147+
148+
#### Sending Additional Messages
149+
150+
Outgoing message filters can send additional messages by calling `next` with a new `MessageContext`:
151+
152+
```csharp
153+
.AddOutgoingMessageFilter(next => async (context, cancellationToken) =>
154+
{
155+
// Send an extra notification before certain responses
156+
if (context.JsonRpcMessage is JsonRpcResponse response &&
157+
response.Result is JsonObject result &&
158+
result.ContainsKey("tools"))
159+
{
160+
var notification = new JsonRpcNotification
161+
{
162+
Method = "custom/toolsListed",
163+
Params = new JsonObject { ["timestamp"] = DateTime.UtcNow.ToString("O") },
164+
Context = new JsonRpcMessageContext
165+
{
166+
RelatedTransport = context.JsonRpcMessage.Context?.RelatedTransport
167+
}
168+
};
169+
await next(new MessageContext(context.Server, notification), cancellationToken);
170+
}
171+
172+
await next(context, cancellationToken);
173+
})
174+
```
175+
176+
### Message Filter Execution Order
177+
178+
Message filters execute in registration order, with the first registered filter being the outermost:
179+
180+
```csharp
181+
services.AddMcpServer()
182+
.AddIncomingMessageFilter(incomingFilter1) // Incoming: executes first (outermost)
183+
.AddIncomingMessageFilter(incomingFilter2) // Incoming: executes second
184+
.AddOutgoingMessageFilter(outgoingFilter1) // Outgoing: executes first (outermost)
185+
.AddOutgoingMessageFilter(outgoingFilter2) // Outgoing: executes second
186+
.AddListToolsFilter(toolsFilter) // Request-specific filter
187+
.WithTools<MyTools>();
188+
```
189+
190+
**Important**: Incoming message filters always run before request-specific filters, and outgoing message filters run when responses or notifications are sent. The complete execution flow for a request/response cycle is:
191+
192+
```
193+
Request arrives
194+
195+
IncomingFilter1 (before next)
196+
197+
IncomingFilter2 (before next)
198+
199+
Request Routing → ListToolsFilter → Handler
200+
201+
IncomingFilter2 (after next)
202+
203+
IncomingFilter1 (after next)
204+
205+
Response sent via OutgoingFilter1 (before next)
206+
207+
OutgoingFilter2 (before next)
208+
209+
Transport sends message
210+
211+
OutgoingFilter2 (after next)
212+
213+
OutgoingFilter1 (after next)
214+
```
215+
216+
### Passing Data Between Filters
217+
218+
The `Items` dictionary allows you to pass data between filters processing the same message:
219+
220+
```csharp
221+
.AddIncomingMessageFilter(next => async (context, cancellationToken) =>
222+
{
223+
context.Items["requestStartTime"] = DateTime.UtcNow;
224+
await next(context, cancellationToken);
225+
})
226+
.AddIncomingMessageFilter(next => async (context, cancellationToken) =>
227+
{
228+
await next(context, cancellationToken);
229+
230+
if (context.Items.TryGetValue("requestStartTime", out var startTime))
231+
{
232+
var elapsed = DateTime.UtcNow - (DateTime)startTime;
233+
var logger = context.Services?.GetService<ILogger<Program>>();
234+
logger?.LogInformation($"Request processed in {elapsed.TotalMilliseconds}ms");
235+
}
236+
})
237+
```
238+
28239
## Usage
29240

30241
Filters are functions that take a handler and return a new handler, allowing you to wrap the original handler with additional functionality:

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -260,9 +260,6 @@ private async Task<string> GetAccessTokenAsync(HttpResponseMessage response, boo
260260
// Get auth server metadata
261261
var authServerMetadata = await GetAuthServerMetadataAsync(selectedAuthServer, cancellationToken).ConfigureAwait(false);
262262

263-
// Store auth server metadata for future refresh operations
264-
_authServerMetadata = authServerMetadata;
265-
266263
// The existing access token must be invalid to have resulted in a 401 response, but refresh might still work.
267264
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
268265

@@ -296,6 +293,9 @@ await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { R
296293
}
297294
}
298295

296+
// Store auth server metadata for future refresh operations
297+
_authServerMetadata = authServerMetadata;
298+
299299
// Perform the OAuth flow
300300
return await InitiateAuthorizationCodeFlowAsync(protectedResourceMetadata, authServerMetadata, cancellationToken).ConfigureAwait(false);
301301
}

src/ModelContextProtocol.Core/Client/McpClientImpl.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,15 @@ internal McpClientImpl(ITransport transport, string endpointName, McpClientOptio
5858

5959
RegisterHandlers(options, notificationHandlers, requestHandlers);
6060

61-
_sessionHandler = new McpSessionHandler(isServer: false, transport, endpointName, requestHandlers, notificationHandlers, _logger);
61+
_sessionHandler = new McpSessionHandler(
62+
isServer: false,
63+
transport,
64+
endpointName,
65+
requestHandlers,
66+
notificationHandlers,
67+
incomingMessageFilter: null,
68+
outgoingMessageFilter: null,
69+
_logger);
6270
}
6371

6472
private void RegisterHandlers(McpClientOptions options, NotificationHandlers notificationHandlers, RequestHandlers requestHandlers)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using ModelContextProtocol.Protocol;
2+
3+
namespace ModelContextProtocol;
4+
5+
/// <summary>
6+
/// Represents a filter that wraps the processing of incoming JSON-RPC messages.
7+
/// </summary>
8+
/// <param name="next">The next handler in the pipeline.</param>
9+
/// <returns>A wrapped handler that processes messages and optionally delegates to the next handler.</returns>
10+
internal delegate Func<JsonRpcMessage, CancellationToken, Task> JsonRpcMessageFilter(Func<JsonRpcMessage, CancellationToken, Task> next);

0 commit comments

Comments
 (0)