Add support for message-level filters to McpServer (#1207)
This commit is contained in:
parent
6224f30a33
commit
7d48d3922c
16 changed files with 1333 additions and 105 deletions
|
|
@ -7,9 +7,14 @@ uid: filters
|
||||||
|
|
||||||
# MCP Server Handler 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.
|
The MCP Server provides two levels of filters for intercepting and modifying request processing:
|
||||||
|
|
||||||
## Available Filter Methods
|
1. **Message Filters** - Low-level filters (`AddIncomingMessageFilter`, `AddOutgoingMessageFilter`) that intercept all JSON-RPC messages before routing
|
||||||
|
2. **Request-Specific Filters** - Handler-level filters (e.g., `AddListToolsFilter`, `AddCallToolFilter`) that target specific MCP operations
|
||||||
|
|
||||||
|
The filters are stored in `McpServerOptions.Filters` and applied during server configuration.
|
||||||
|
|
||||||
|
## Available Request-Specific Filter Methods
|
||||||
|
|
||||||
The following filter methods are available:
|
The following filter methods are available:
|
||||||
|
|
||||||
|
|
@ -25,6 +30,212 @@ The following filter methods are available:
|
||||||
- `AddUnsubscribeFromResourcesFilter` - Filter for resource unsubscription handlers
|
- `AddUnsubscribeFromResourcesFilter` - Filter for resource unsubscription handlers
|
||||||
- `AddSetLoggingLevelFilter` - Filter for logging level handlers
|
- `AddSetLoggingLevelFilter` - Filter for logging level handlers
|
||||||
|
|
||||||
|
## Message Filters
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
- `AddIncomingMessageFilter` - Filter for all incoming JSON-RPC messages (requests and notifications)
|
||||||
|
- `AddOutgoingMessageFilter` - Filter for all outgoing JSON-RPC messages (responses and notifications)
|
||||||
|
|
||||||
|
### When to Use Message Filters
|
||||||
|
|
||||||
|
Message filters operate at a lower level than request-specific filters and are useful when you need to:
|
||||||
|
|
||||||
|
- Intercept all messages regardless of type
|
||||||
|
- Implement custom protocol extensions or handle custom JSON-RPC methods
|
||||||
|
- Log or monitor all traffic between client and server
|
||||||
|
- Modify or skip messages before they reach handlers
|
||||||
|
- Send additional messages in response to specific events
|
||||||
|
|
||||||
|
### Incoming Message Filter
|
||||||
|
|
||||||
|
`AddIncomingMessageFilter` intercepts all incoming JSON-RPC messages before they are dispatched to request-specific handlers:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
services.AddMcpServer()
|
||||||
|
.AddIncomingMessageFilter(next => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
var logger = context.Services?.GetService<ILogger<Program>>();
|
||||||
|
|
||||||
|
// Access the raw JSON-RPC message
|
||||||
|
if (context.JsonRpcMessage is JsonRpcRequest request)
|
||||||
|
{
|
||||||
|
logger?.LogInformation($"Incoming request: {request.Method}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call next to continue processing
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
.WithTools<MyTools>();
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MessageContext Properties
|
||||||
|
|
||||||
|
Inside an incoming message filter, you have access to:
|
||||||
|
|
||||||
|
- `context.JsonRpcMessage` - The incoming `JsonRpcMessage` (can be `JsonRpcRequest` or `JsonRpcNotification`)
|
||||||
|
- `context.Server` - The `McpServer` instance for sending responses or notifications
|
||||||
|
- `context.Services` - The request's service provider
|
||||||
|
- `context.Items` - A dictionary for passing data between filters
|
||||||
|
|
||||||
|
#### Skipping Default Handlers
|
||||||
|
|
||||||
|
You can skip the default handler by not calling `next`. This is useful for implementing custom protocol methods:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
.AddIncomingMessageFilter(next => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
if (context.JsonRpcMessage is JsonRpcRequest request && request.Method == "custom/myMethod")
|
||||||
|
{
|
||||||
|
// Handle the custom method directly
|
||||||
|
var response = new JsonRpcResponse
|
||||||
|
{
|
||||||
|
Id = request.Id,
|
||||||
|
Result = JsonSerializer.SerializeToNode(new { message = "Custom response" })
|
||||||
|
};
|
||||||
|
await context.Server.SendMessageAsync(response, cancellationToken);
|
||||||
|
return; // Don't call next - we handled it
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Outgoing Message Filter
|
||||||
|
|
||||||
|
`AddOutgoingMessageFilter` intercepts all outgoing JSON-RPC messages before they are sent to the client:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
services.AddMcpServer()
|
||||||
|
.AddOutgoingMessageFilter(next => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
var logger = context.Services?.GetService<ILogger<Program>>();
|
||||||
|
|
||||||
|
// Inspect outgoing messages
|
||||||
|
switch (context.JsonRpcMessage)
|
||||||
|
{
|
||||||
|
case JsonRpcResponse response:
|
||||||
|
logger?.LogInformation($"Sending response for request {response.Id}");
|
||||||
|
break;
|
||||||
|
case JsonRpcNotification notification:
|
||||||
|
logger?.LogInformation($"Sending notification: {notification.Method}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
.WithTools<MyTools>();
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Skipping Outgoing Messages
|
||||||
|
|
||||||
|
You can suppress outgoing messages by not calling `next`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
.AddOutgoingMessageFilter(next => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
// Suppress specific notifications
|
||||||
|
if (context.JsonRpcMessage is JsonRpcNotification notification &&
|
||||||
|
notification.Method == "notifications/progress")
|
||||||
|
{
|
||||||
|
return; // Don't send this notification
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sending Additional Messages
|
||||||
|
|
||||||
|
Outgoing message filters can send additional messages by calling `next` with a new `MessageContext`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
.AddOutgoingMessageFilter(next => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
// Send an extra notification before certain responses
|
||||||
|
if (context.JsonRpcMessage is JsonRpcResponse response &&
|
||||||
|
response.Result is JsonObject result &&
|
||||||
|
result.ContainsKey("tools"))
|
||||||
|
{
|
||||||
|
var notification = new JsonRpcNotification
|
||||||
|
{
|
||||||
|
Method = "custom/toolsListed",
|
||||||
|
Params = new JsonObject { ["timestamp"] = DateTime.UtcNow.ToString("O") },
|
||||||
|
Context = new JsonRpcMessageContext
|
||||||
|
{
|
||||||
|
RelatedTransport = context.JsonRpcMessage.Context?.RelatedTransport
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await next(new MessageContext(context.Server, notification), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Message Filter Execution Order
|
||||||
|
|
||||||
|
Message filters execute in registration order, with the first registered filter being the outermost:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
services.AddMcpServer()
|
||||||
|
.AddIncomingMessageFilter(incomingFilter1) // Incoming: executes first (outermost)
|
||||||
|
.AddIncomingMessageFilter(incomingFilter2) // Incoming: executes second
|
||||||
|
.AddOutgoingMessageFilter(outgoingFilter1) // Outgoing: executes first (outermost)
|
||||||
|
.AddOutgoingMessageFilter(outgoingFilter2) // Outgoing: executes second
|
||||||
|
.AddListToolsFilter(toolsFilter) // Request-specific filter
|
||||||
|
.WithTools<MyTools>();
|
||||||
|
```
|
||||||
|
|
||||||
|
**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:
|
||||||
|
|
||||||
|
```
|
||||||
|
Request arrives
|
||||||
|
↓
|
||||||
|
IncomingFilter1 (before next)
|
||||||
|
↓
|
||||||
|
IncomingFilter2 (before next)
|
||||||
|
↓
|
||||||
|
Request Routing → ListToolsFilter → Handler
|
||||||
|
↓
|
||||||
|
IncomingFilter2 (after next)
|
||||||
|
↓
|
||||||
|
IncomingFilter1 (after next)
|
||||||
|
↓
|
||||||
|
Response sent via OutgoingFilter1 (before next)
|
||||||
|
↓
|
||||||
|
OutgoingFilter2 (before next)
|
||||||
|
↓
|
||||||
|
Transport sends message
|
||||||
|
↓
|
||||||
|
OutgoingFilter2 (after next)
|
||||||
|
↓
|
||||||
|
OutgoingFilter1 (after next)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Passing Data Between Filters
|
||||||
|
|
||||||
|
The `Items` dictionary allows you to pass data between filters processing the same message:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
.AddIncomingMessageFilter(next => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
context.Items["requestStartTime"] = DateTime.UtcNow;
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
.AddIncomingMessageFilter(next => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
|
||||||
|
if (context.Items.TryGetValue("requestStartTime", out var startTime))
|
||||||
|
{
|
||||||
|
var elapsed = DateTime.UtcNow - (DateTime)startTime;
|
||||||
|
var logger = context.Services?.GetService<ILogger<Program>>();
|
||||||
|
logger?.LogInformation($"Request processed in {elapsed.TotalMilliseconds}ms");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Filters are functions that take a handler and return a new handler, allowing you to wrap the original handler with additional functionality:
|
Filters are functions that take a handler and return a new handler, allowing you to wrap the original handler with additional functionality:
|
||||||
|
|
|
||||||
|
|
@ -260,9 +260,6 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
// Get auth server metadata
|
// Get auth server metadata
|
||||||
var authServerMetadata = await GetAuthServerMetadataAsync(selectedAuthServer, cancellationToken).ConfigureAwait(false);
|
var authServerMetadata = await GetAuthServerMetadataAsync(selectedAuthServer, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
// Store auth server metadata for future refresh operations
|
|
||||||
_authServerMetadata = authServerMetadata;
|
|
||||||
|
|
||||||
// The existing access token must be invalid to have resulted in a 401 response, but refresh might still work.
|
// The existing access token must be invalid to have resulted in a 401 response, but refresh might still work.
|
||||||
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
|
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
|
||||||
|
|
||||||
|
|
@ -296,6 +293,9 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store auth server metadata for future refresh operations
|
||||||
|
_authServerMetadata = authServerMetadata;
|
||||||
|
|
||||||
// Perform the OAuth flow
|
// Perform the OAuth flow
|
||||||
return await InitiateAuthorizationCodeFlowAsync(protectedResourceMetadata, authServerMetadata, cancellationToken).ConfigureAwait(false);
|
return await InitiateAuthorizationCodeFlowAsync(protectedResourceMetadata, authServerMetadata, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,15 @@ internal sealed partial class McpClientImpl : McpClient
|
||||||
|
|
||||||
RegisterHandlers(options, notificationHandlers, requestHandlers);
|
RegisterHandlers(options, notificationHandlers, requestHandlers);
|
||||||
|
|
||||||
_sessionHandler = new McpSessionHandler(isServer: false, transport, endpointName, requestHandlers, notificationHandlers, _logger);
|
_sessionHandler = new McpSessionHandler(
|
||||||
|
isServer: false,
|
||||||
|
transport,
|
||||||
|
endpointName,
|
||||||
|
requestHandlers,
|
||||||
|
notificationHandlers,
|
||||||
|
incomingMessageFilter: null,
|
||||||
|
outgoingMessageFilter: null,
|
||||||
|
_logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RegisterHandlers(McpClientOptions options, NotificationHandlers notificationHandlers, RequestHandlers requestHandlers)
|
private void RegisterHandlers(McpClientOptions options, NotificationHandlers notificationHandlers, RequestHandlers requestHandlers)
|
||||||
|
|
|
||||||
10
src/ModelContextProtocol.Core/JsonRpcMessageFilter.cs
Normal file
10
src/ModelContextProtocol.Core/JsonRpcMessageFilter.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a filter that wraps the processing of incoming JSON-RPC messages.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="next">The next handler in the pipeline.</param>
|
||||||
|
/// <returns>A wrapped handler that processes messages and optionally delegates to the next handler.</returns>
|
||||||
|
internal delegate Func<JsonRpcMessage, CancellationToken, Task> JsonRpcMessageFilter(Func<JsonRpcMessage, CancellationToken, Task> next);
|
||||||
|
|
@ -66,6 +66,8 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
||||||
private readonly ITransport _transport;
|
private readonly ITransport _transport;
|
||||||
private readonly RequestHandlers _requestHandlers;
|
private readonly RequestHandlers _requestHandlers;
|
||||||
private readonly NotificationHandlers _notificationHandlers;
|
private readonly NotificationHandlers _notificationHandlers;
|
||||||
|
private readonly JsonRpcMessageFilter _incomingMessageFilter;
|
||||||
|
private readonly JsonRpcMessageFilter _outgoingMessageFilter;
|
||||||
private readonly long _sessionStartingTimestamp = Stopwatch.GetTimestamp();
|
private readonly long _sessionStartingTimestamp = Stopwatch.GetTimestamp();
|
||||||
|
|
||||||
private readonly DistributedContextPropagator _propagator = DistributedContextPropagator.Current;
|
private readonly DistributedContextPropagator _propagator = DistributedContextPropagator.Current;
|
||||||
|
|
@ -95,6 +97,8 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
||||||
/// <param name="endpointName">The name of the endpoint for logging and debug purposes.</param>
|
/// <param name="endpointName">The name of the endpoint for logging and debug purposes.</param>
|
||||||
/// <param name="requestHandlers">A collection of request handlers.</param>
|
/// <param name="requestHandlers">A collection of request handlers.</param>
|
||||||
/// <param name="notificationHandlers">A collection of notification handlers.</param>
|
/// <param name="notificationHandlers">A collection of notification handlers.</param>
|
||||||
|
/// <param name="incomingMessageFilter">A filter that wraps incoming message processing. Takes the next handler and returns a wrapped handler. If null, a passthrough filter is used.</param>
|
||||||
|
/// <param name="outgoingMessageFilter">A filter that wraps outgoing message processing. Takes the next handler and returns a wrapped handler. If null, a passthrough filter is used.</param>
|
||||||
/// <param name="logger">The logger.</param>
|
/// <param name="logger">The logger.</param>
|
||||||
public McpSessionHandler(
|
public McpSessionHandler(
|
||||||
bool isServer,
|
bool isServer,
|
||||||
|
|
@ -102,6 +106,8 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
||||||
string endpointName,
|
string endpointName,
|
||||||
RequestHandlers requestHandlers,
|
RequestHandlers requestHandlers,
|
||||||
NotificationHandlers notificationHandlers,
|
NotificationHandlers notificationHandlers,
|
||||||
|
JsonRpcMessageFilter? incomingMessageFilter,
|
||||||
|
JsonRpcMessageFilter? outgoingMessageFilter,
|
||||||
ILogger logger)
|
ILogger logger)
|
||||||
{
|
{
|
||||||
Throw.IfNull(transport);
|
Throw.IfNull(transport);
|
||||||
|
|
@ -120,7 +126,9 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
||||||
EndpointName = endpointName;
|
EndpointName = endpointName;
|
||||||
_requestHandlers = requestHandlers;
|
_requestHandlers = requestHandlers;
|
||||||
_notificationHandlers = notificationHandlers;
|
_notificationHandlers = notificationHandlers;
|
||||||
_logger = logger ?? NullLogger.Instance;
|
_incomingMessageFilter = incomingMessageFilter ?? (next => next);
|
||||||
|
_outgoingMessageFilter = outgoingMessageFilter ?? (next => next);
|
||||||
|
_logger = logger;
|
||||||
LogSessionCreated(EndpointName, _sessionId, _transportKind);
|
LogSessionCreated(EndpointName, _sessionId, _transportKind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -309,36 +317,14 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
||||||
AddTags(ref tags, activity, message, method, target);
|
AddTags(ref tags, activity, message, method, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (message)
|
await _incomingMessageFilter(async (msg, ct) =>
|
||||||
{
|
{
|
||||||
case JsonRpcRequest request:
|
var result = await HandleMessageCoreAsync(msg, ct).ConfigureAwait(false);
|
||||||
LogRequestHandlerCalled(EndpointName, request.Method);
|
if (addTags && result is not null)
|
||||||
long requestStartingTimestamp = Stopwatch.GetTimestamp();
|
{
|
||||||
try
|
AddResponseTags(ref tags, activity, result, method);
|
||||||
{
|
}
|
||||||
var result = await HandleRequest(request, cancellationToken).ConfigureAwait(false);
|
})(message, cancellationToken).ConfigureAwait(false);
|
||||||
LogRequestHandlerCompleted(EndpointName, request.Method, GetElapsed(requestStartingTimestamp).TotalMilliseconds);
|
|
||||||
AddResponseTags(ref tags, activity, result, method);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
LogRequestHandlerException(EndpointName, request.Method, GetElapsed(requestStartingTimestamp).TotalMilliseconds, ex);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case JsonRpcNotification notification:
|
|
||||||
await HandleNotification(notification, cancellationToken).ConfigureAwait(false);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case JsonRpcMessageWithId messageWithId:
|
|
||||||
HandleMessageWithId(message, messageWithId);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
LogEndpointHandlerUnexpectedMessageType(EndpointName, message.GetType().Name);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception e) when (addTags)
|
catch (Exception e) when (addTags)
|
||||||
{
|
{
|
||||||
|
|
@ -351,7 +337,40 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleNotification(JsonRpcNotification notification, CancellationToken cancellationToken)
|
private async Task<JsonNode?> HandleMessageCoreAsync(JsonRpcMessage message, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
switch (message)
|
||||||
|
{
|
||||||
|
case JsonRpcRequest request:
|
||||||
|
LogRequestHandlerCalled(EndpointName, request.Method);
|
||||||
|
long requestStartingTimestamp = Stopwatch.GetTimestamp();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await HandleRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
LogRequestHandlerCompleted(EndpointName, request.Method, GetElapsed(requestStartingTimestamp).TotalMilliseconds);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogRequestHandlerException(EndpointName, request.Method, GetElapsed(requestStartingTimestamp).TotalMilliseconds, ex);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
case JsonRpcNotification notification:
|
||||||
|
await HandleNotificationAsync(notification, cancellationToken).ConfigureAwait(false);
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case JsonRpcMessageWithId messageWithId:
|
||||||
|
HandleMessageWithId(message, messageWithId);
|
||||||
|
return null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
LogEndpointHandlerUnexpectedMessageType(EndpointName, message.GetType().Name);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleNotificationAsync(JsonRpcNotification notification, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Special-case cancellation to cancel a pending operation. (We'll still subsequently invoke a user-specified handler if one exists.)
|
// Special-case cancellation to cancel a pending operation. (We'll still subsequently invoke a user-specified handler if one exists.)
|
||||||
if (notification.Method == NotificationMethods.CancelledNotification)
|
if (notification.Method == NotificationMethods.CancelledNotification)
|
||||||
|
|
@ -387,7 +406,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<JsonNode?> HandleRequest(JsonRpcRequest request, CancellationToken cancellationToken)
|
private async Task<JsonNode?> HandleRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!_requestHandlers.TryGetValue(request.Method, out var handler))
|
if (!_requestHandlers.TryGetValue(request.Method, out var handler))
|
||||||
{
|
{
|
||||||
|
|
@ -586,26 +605,29 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
||||||
AddTags(ref tags, activity, message, method, target);
|
AddTags(ref tags, activity, message, method, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_logger.IsEnabled(LogLevel.Trace))
|
await _outgoingMessageFilter(async (msg, ct) =>
|
||||||
{
|
{
|
||||||
LogSendingMessageSensitive(EndpointName, JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage));
|
if (_logger.IsEnabled(LogLevel.Trace))
|
||||||
}
|
{
|
||||||
else
|
LogSendingMessageSensitive(EndpointName, JsonSerializer.Serialize(msg, McpJsonUtilities.JsonContext.Default.JsonRpcMessage));
|
||||||
{
|
}
|
||||||
LogSendingMessage(EndpointName);
|
else
|
||||||
}
|
{
|
||||||
|
LogSendingMessage(EndpointName);
|
||||||
|
}
|
||||||
|
|
||||||
await SendToRelatedTransportAsync(message, cancellationToken).ConfigureAwait(false);
|
await SendToRelatedTransportAsync(msg, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
// If the sent notification was a cancellation notification, cancel the pending request's await, as either the
|
// If the sent notification was a cancellation notification, cancel the pending request's await, as either the
|
||||||
// server won't be sending a response, or per the specification, the response should be ignored. There are inherent
|
// server won't be sending a response, or per the specification, the response should be ignored. There are inherent
|
||||||
// race conditions here, so it's possible and allowed for the operation to complete before we get to this point.
|
// race conditions here, so it's possible and allowed for the operation to complete before we get to this point.
|
||||||
if (message is JsonRpcNotification { Method: NotificationMethods.CancelledNotification } notification &&
|
if (msg is JsonRpcNotification { Method: NotificationMethods.CancelledNotification } notification &&
|
||||||
GetCancelledNotificationParams(notification.Params) is CancelledNotificationParams cn &&
|
GetCancelledNotificationParams(notification.Params) is CancelledNotificationParams cn &&
|
||||||
_pendingRequests.TryRemove(cn.RequestId, out var tcs))
|
_pendingRequests.TryRemove(cn.RequestId, out var tcs))
|
||||||
{
|
{
|
||||||
tcs.TrySetCanceled(default);
|
tcs.TrySetCanceled(default);
|
||||||
}
|
}
|
||||||
|
})(message, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (addTags)
|
catch (Exception ex) when (addTags)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -58,4 +58,20 @@ public class JsonRpcMessageContext
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public ClaimsPrincipal? User { get; set; }
|
public ClaimsPrincipal? User { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a key/value collection that can be used to share data within the scope of this message.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This property allows data to be flowed throughout the message processing pipeline,
|
||||||
|
/// including from incoming message filters to request-specific filters and handlers.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// When creating a <see cref="MessageContext"/> or <see cref="RequestContext{TParams}"/> for server-side
|
||||||
|
/// processing, the Items dictionary from this context will be used, ensuring data set in message filters
|
||||||
|
/// is available in request filters and handlers.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public IDictionary<string, object?>? Items { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
src/ModelContextProtocol.Core/Server/McpMessageFilter.cs
Normal file
30
src/ModelContextProtocol.Core/Server/McpMessageFilter.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
namespace ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delegate type for applying filters to JSON-RPC messages.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="next">The next message handler in the pipeline.</param>
|
||||||
|
/// <returns>The next message handler wrapped with the filter.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Message filters allow you to intercept and process JSON-RPC messages before they reach
|
||||||
|
/// their respective handlers (incoming) or before they are sent (outgoing). This is useful for implementing
|
||||||
|
/// cross-cutting concerns that need to apply to all message types, such as logging, authentication, rate limiting,
|
||||||
|
/// redaction, or request tracing.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Filters are applied in the order they are registered, with the first registered filter being the outermost.
|
||||||
|
/// Each filter receives the next handler in the pipeline and can choose to:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>Call the next handler to continue processing (await next(context, cancellationToken))</description></item>
|
||||||
|
/// <item><description>Skip the default handlers entirely by not calling next</description></item>
|
||||||
|
/// <item><description>Perform operations before and/or after calling next</description></item>
|
||||||
|
/// <item><description>Catch and handle exceptions from inner handlers</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// For request-specific filters, use <see cref="McpRequestFilter{TParams, TResult}"/> instead.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public delegate McpMessageHandler McpMessageFilter(
|
||||||
|
McpMessageHandler next);
|
||||||
21
src/ModelContextProtocol.Core/Server/McpMessageHandler.cs
Normal file
21
src/ModelContextProtocol.Core/Server/McpMessageHandler.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
namespace ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delegate type for handling incoming JSON-RPC messages.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The message context containing the JSON-RPC message and other metadata.</param>
|
||||||
|
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This delegate can handle any type of JSON-RPC message, including requests, notifications, responses, and errors.
|
||||||
|
/// Use this for implementing cross-cutting concerns that need to intercept all message types,
|
||||||
|
/// such as logging, authentication, rate limiting, or request tracing.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// For request-specific handling, use <see cref="McpRequestHandler{TParams, TResult}"/> instead.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public delegate Task McpMessageHandler(
|
||||||
|
MessageContext context,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
@ -11,6 +11,39 @@ namespace ModelContextProtocol.Server;
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class McpServerFilters
|
public sealed class McpServerFilters
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the filters for all incoming JSON-RPC messages.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// These filters intercept all incoming JSON-RPC messages before they are processed by the server,
|
||||||
|
/// including requests, notifications, responses, and errors. The filters can perform logging,
|
||||||
|
/// authentication, rate limiting, or other cross-cutting concerns that apply to all message types.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Message filters are applied before request-specific filters. If a message filter does not call
|
||||||
|
/// the next handler in the pipeline, the default handlers will not be executed.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public List<McpMessageFilter> IncomingMessageFilters { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the filters for all outgoing JSON-RPC messages.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// These filters intercept all outgoing JSON-RPC messages before they are sent to the client,
|
||||||
|
/// including responses, notifications, and errors. The filters can perform logging,
|
||||||
|
/// redaction, auditing, or other cross-cutting concerns that apply to all message types.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// If a message filter does not call the next handler in the pipeline, the message will not be sent.
|
||||||
|
/// Filters may also call the next handler multiple times with different messages to emit additional
|
||||||
|
/// server-to-client messages.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public List<McpMessageFilter> OutgoingMessageFilters { get; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the filters for the list-tools handler pipeline.
|
/// Gets the filters for the list-tools handler pipeline.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,17 @@ internal sealed partial class McpServerImpl : McpServer
|
||||||
}
|
}
|
||||||
|
|
||||||
// And initialize the session.
|
// And initialize the session.
|
||||||
_sessionHandler = new McpSessionHandler(isServer: true, _sessionTransport, _endpointName!, _requestHandlers, _notificationHandlers, _logger);
|
var incomingMessageFilter = BuildMessageFilterPipeline(options.Filters.IncomingMessageFilters);
|
||||||
|
var outgoingMessageFilter = BuildMessageFilterPipeline(options.Filters.OutgoingMessageFilters);
|
||||||
|
_sessionHandler = new McpSessionHandler(
|
||||||
|
isServer: true,
|
||||||
|
_sessionTransport,
|
||||||
|
_endpointName!,
|
||||||
|
_requestHandlers,
|
||||||
|
_notificationHandlers,
|
||||||
|
incomingMessageFilter,
|
||||||
|
outgoingMessageFilter,
|
||||||
|
_logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
|
|
@ -875,6 +885,39 @@ internal sealed partial class McpServerImpl : McpServer
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private JsonRpcMessageFilter BuildMessageFilterPipeline(List<McpMessageFilter> filters)
|
||||||
|
{
|
||||||
|
if (filters.Count == 0)
|
||||||
|
{
|
||||||
|
return next => next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return next =>
|
||||||
|
{
|
||||||
|
// Build the handler chain from the filters.
|
||||||
|
// The innermost handler calls the provided 'next' delegate with the message from the context.
|
||||||
|
McpMessageHandler baseHandler = async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
await next(context.JsonRpcMessage, cancellationToken).ConfigureAwait(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
var current = baseHandler;
|
||||||
|
for (int i = filters.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
current = filters[i](current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the handler that creates a MessageContext and invokes the pipeline.
|
||||||
|
return async (message, cancellationToken) =>
|
||||||
|
{
|
||||||
|
// Ensure message has a Context so Items can be shared through the pipeline
|
||||||
|
message.Context ??= new();
|
||||||
|
var context = new MessageContext(new DestinationBoundMcpServer(this, message.Context.RelatedTransport), message);
|
||||||
|
await current(context, cancellationToken).ConfigureAwait(false);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private void UpdateEndpointNameWithClientInfo()
|
private void UpdateEndpointNameWithClientInfo()
|
||||||
{
|
{
|
||||||
if (ClientInfo is null)
|
if (ClientInfo is null)
|
||||||
|
|
|
||||||
108
src/ModelContextProtocol.Core/Server/MessageContext.cs
Normal file
108
src/ModelContextProtocol.Core/Server/MessageContext.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides a context container that provides access to the server and resources for processing a JSON-RPC message.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The <see cref="MessageContext"/> encapsulates contextual information for handling any JSON-RPC message,
|
||||||
|
/// including requests, responses, notifications, and errors. This is the base class for
|
||||||
|
/// <see cref="RequestContext{TParams}"/>, which adds request-specific properties.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// This type is typically received as a parameter in message filter delegates registered via
|
||||||
|
/// <see cref="McpServerFilters.IncomingMessageFilters"/> or <see cref="McpServerFilters.OutgoingMessageFilters"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public class MessageContext
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="MessageContext"/> class with the specified server and JSON-RPC message.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="server">The server with which this instance is associated.</param>
|
||||||
|
/// <param name="jsonRpcMessage">The JSON-RPC message associated with this context.</param>
|
||||||
|
public MessageContext(McpServer server, JsonRpcMessage jsonRpcMessage)
|
||||||
|
{
|
||||||
|
Throw.IfNull(server);
|
||||||
|
Throw.IfNull(jsonRpcMessage);
|
||||||
|
|
||||||
|
Server = server;
|
||||||
|
JsonRpcMessage = jsonRpcMessage;
|
||||||
|
Services = server.Services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets or sets the server with which this instance is associated.</summary>
|
||||||
|
public McpServer Server
|
||||||
|
{
|
||||||
|
get => field;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
Throw.IfNull(value);
|
||||||
|
field = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a key/value collection that can be used to share data within the scope of this message.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This dictionary is shared with the <see cref="Protocol.JsonRpcMessageContext.Items"/> property
|
||||||
|
/// on the underlying <see cref="JsonRpcMessage"/>, ensuring that data set in message filters
|
||||||
|
/// flows through to request-specific filters and handlers.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public IDictionary<string, object?> Items
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
JsonRpcMessage.Context ??= new();
|
||||||
|
return JsonRpcMessage.Context.Items ??= new Dictionary<string, object?>();
|
||||||
|
}
|
||||||
|
set
|
||||||
|
{
|
||||||
|
JsonRpcMessage.Context ??= new();
|
||||||
|
JsonRpcMessage.Context.Items = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets or sets the services associated with this message.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This provider might not be the same instance stored in <see cref="McpServer.Services"/>
|
||||||
|
/// if <see cref="McpServerOptions.ScopeRequests"/> was true, in which case this
|
||||||
|
/// might be a scoped <see cref="IServiceProvider"/> derived from the server's
|
||||||
|
/// <see cref="McpServer.Services"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public IServiceProvider? Services { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gets or sets the user associated with this message.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This property is backed by the <see cref="Protocol.JsonRpcMessageContext.User"/> property
|
||||||
|
/// on the underlying <see cref="JsonRpcMessage"/>, ensuring that user information set in message filters
|
||||||
|
/// flows through to request-specific filters and handlers.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public ClaimsPrincipal? User
|
||||||
|
{
|
||||||
|
get => JsonRpcMessage.Context?.User;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
JsonRpcMessage.Context ??= new();
|
||||||
|
JsonRpcMessage.Context.User = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the JSON-RPC message associated with this context.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This property provides access to the complete JSON-RPC message,
|
||||||
|
/// including the method name (for requests/notifications), request ID (for requests/responses),
|
||||||
|
/// and associated transport and user information.
|
||||||
|
/// </remarks>
|
||||||
|
public JsonRpcMessage JsonRpcMessage { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
using System.Security.Claims;
|
|
||||||
using ModelContextProtocol.Protocol;
|
using ModelContextProtocol.Protocol;
|
||||||
|
|
||||||
namespace ModelContextProtocol.Server;
|
namespace ModelContextProtocol.Server;
|
||||||
|
|
@ -12,11 +11,8 @@ namespace ModelContextProtocol.Server;
|
||||||
/// This type is typically received as a parameter in handler delegates registered with IMcpServerBuilder,
|
/// This type is typically received as a parameter in handler delegates registered with IMcpServerBuilder,
|
||||||
/// and can be injected as parameters into <see cref="McpServerTool"/>s.
|
/// and can be injected as parameters into <see cref="McpServerTool"/>s.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class RequestContext<TParams>
|
public sealed class RequestContext<TParams> : MessageContext
|
||||||
{
|
{
|
||||||
/// <summary>The server with which this instance is associated.</summary>
|
|
||||||
private McpServer _server;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="RequestContext{TParams}"/> class with the specified server and JSON-RPC request.
|
/// Initializes a new instance of the <see cref="RequestContext{TParams}"/> class with the specified server and JSON-RPC request.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -24,48 +20,10 @@ public sealed class RequestContext<TParams>
|
||||||
/// <param name="jsonRpcRequest">The JSON-RPC request associated with this context.</param>
|
/// <param name="jsonRpcRequest">The JSON-RPC request associated with this context.</param>
|
||||||
/// <exception cref="ArgumentNullException"><paramref name="server"/> or <paramref name="jsonRpcRequest"/> is <see langword="null"/>.</exception>
|
/// <exception cref="ArgumentNullException"><paramref name="server"/> or <paramref name="jsonRpcRequest"/> is <see langword="null"/>.</exception>
|
||||||
public RequestContext(McpServer server, JsonRpcRequest jsonRpcRequest)
|
public RequestContext(McpServer server, JsonRpcRequest jsonRpcRequest)
|
||||||
|
: base(server, jsonRpcRequest)
|
||||||
{
|
{
|
||||||
Throw.IfNull(server);
|
|
||||||
Throw.IfNull(jsonRpcRequest);
|
|
||||||
|
|
||||||
_server = server;
|
|
||||||
JsonRpcRequest = jsonRpcRequest;
|
|
||||||
Services = server.Services;
|
|
||||||
User = jsonRpcRequest.Context?.User;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Gets or sets the server with which this instance is associated.</summary>
|
|
||||||
public McpServer Server
|
|
||||||
{
|
|
||||||
get => _server;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
Throw.IfNull(value);
|
|
||||||
_server = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a key/value collection that can be used to share data within the scope of this request.
|
|
||||||
/// </summary>
|
|
||||||
public IDictionary<string, object?> Items
|
|
||||||
{
|
|
||||||
get => field ??= new Dictionary<string, object?>();
|
|
||||||
set => field = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Gets or sets the services associated with this request.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// This provider might not be the same instance stored in <see cref="McpServer.Services"/>
|
|
||||||
/// if <see cref="McpServerOptions.ScopeRequests"/> was true, in which case this
|
|
||||||
/// might be a scoped <see cref="IServiceProvider"/> derived from the server's
|
|
||||||
/// <see cref="McpServer.Services"/>.
|
|
||||||
/// </remarks>
|
|
||||||
public IServiceProvider? Services { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Gets or sets the user associated with this request.</summary>
|
|
||||||
public ClaimsPrincipal? User { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Gets or sets the parameters associated with this request.</summary>
|
/// <summary>Gets or sets the parameters associated with this request.</summary>
|
||||||
public TParams? Params { get; set; }
|
public TParams? Params { get; set; }
|
||||||
|
|
||||||
|
|
@ -81,7 +39,11 @@ public sealed class RequestContext<TParams>
|
||||||
/// This property provides access to the complete JSON-RPC request that initiated this handler invocation,
|
/// This property provides access to the complete JSON-RPC request that initiated this handler invocation,
|
||||||
/// including the method name, parameters, request ID, and associated transport and user information.
|
/// including the method name, parameters, request ID, and associated transport and user information.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public JsonRpcRequest JsonRpcRequest { get; }
|
public JsonRpcRequest JsonRpcRequest
|
||||||
|
{
|
||||||
|
get => (JsonRpcRequest)JsonRpcMessage;
|
||||||
|
set => JsonRpcMessage = value;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ends the current response and enables polling for updates from the server.
|
/// Ends the current response and enables polling for updates from the server.
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,12 @@ internal sealed partial class StreamableHttpPostTransport(
|
||||||
{
|
{
|
||||||
Debug.Assert(_pendingRequest.Id is null);
|
Debug.Assert(_pendingRequest.Id is null);
|
||||||
|
|
||||||
|
message.Context ??= new JsonRpcMessageContext();
|
||||||
|
|
||||||
if (message is JsonRpcRequest request)
|
if (message is JsonRpcRequest request)
|
||||||
{
|
{
|
||||||
_pendingRequest = request.Id;
|
_pendingRequest = request.Id;
|
||||||
|
message.Context.RelatedTransport = this;
|
||||||
|
|
||||||
// Invoke the initialize request handler if applicable.
|
// Invoke the initialize request handler if applicable.
|
||||||
if (request.Method == RequestMethods.Initialize)
|
if (request.Method == RequestMethods.Initialize)
|
||||||
|
|
@ -53,9 +56,6 @@ internal sealed partial class StreamableHttpPostTransport(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
message.Context ??= new JsonRpcMessageContext();
|
|
||||||
message.Context.RelatedTransport = this;
|
|
||||||
|
|
||||||
if (parentTransport.FlowExecutionContextFromRequests)
|
if (parentTransport.FlowExecutionContextFromRequests)
|
||||||
{
|
{
|
||||||
message.Context.ExecutionContext = ExecutionContext.Capture();
|
message.Context.ExecutionContext = ExecutionContext.Capture();
|
||||||
|
|
|
||||||
|
|
@ -1105,6 +1105,70 @@ public static partial class McpServerBuilderExtensions
|
||||||
builder.Services.Configure<McpServerOptions>(options => options.Filters.SetLoggingLevelFilters.Add(filter));
|
builder.Services.Configure<McpServerOptions>(options => options.Filters.SetLoggingLevelFilters.Add(filter));
|
||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a filter to intercept all incoming JSON-RPC messages.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="builder">The builder instance.</param>
|
||||||
|
/// <param name="filter">The filter function that wraps the message handler.</param>
|
||||||
|
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This filter intercepts all incoming JSON-RPC messages before they are processed by the server,
|
||||||
|
/// including requests, notifications, responses, and errors. The filter can perform logging,
|
||||||
|
/// authentication, rate limiting, or other cross-cutting concerns that apply to all message types.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Message filters are applied before request-specific filters. If a message filter does not call
|
||||||
|
/// the next handler in the pipeline, the default handlers will not be executed.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Filters are applied in the order they are registered, with the first registered filter being the outermost.
|
||||||
|
/// Each filter receives the next handler in the pipeline and can choose to:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>Call the next handler to continue processing: <c>await next(context, cancellationToken)</c></description></item>
|
||||||
|
/// <item><description>Skip the default handlers entirely by not calling next</description></item>
|
||||||
|
/// <item><description>Perform operations before and/or after calling next</description></item>
|
||||||
|
/// <item><description>Catch and handle exceptions from inner handlers</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static IMcpServerBuilder AddIncomingMessageFilter(this IMcpServerBuilder builder, McpMessageFilter filter)
|
||||||
|
{
|
||||||
|
Throw.IfNull(builder);
|
||||||
|
Throw.IfNull(filter);
|
||||||
|
|
||||||
|
builder.Services.Configure<McpServerOptions>(options => options.Filters.IncomingMessageFilters.Add(filter));
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a filter to intercept all outgoing JSON-RPC messages.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="builder">The builder instance.</param>
|
||||||
|
/// <param name="filter">The filter function that wraps the message handler.</param>
|
||||||
|
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This filter intercepts all outgoing JSON-RPC messages before they are sent to the client,
|
||||||
|
/// including responses, notifications, and errors. The filter can perform logging, redaction,
|
||||||
|
/// auditing, or other cross-cutting concerns that apply to all message types.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// If a message filter does not call the next handler in the pipeline, the message will not be sent.
|
||||||
|
/// Filters may also call the next handler multiple times with different messages to emit additional
|
||||||
|
/// server-to-client messages.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static IMcpServerBuilder AddOutgoingMessageFilter(this IMcpServerBuilder builder, McpMessageFilter filter)
|
||||||
|
{
|
||||||
|
Throw.IfNull(builder);
|
||||||
|
|
||||||
|
builder.Services.Configure<McpServerOptions>(options => options.Filters.OutgoingMessageFilters.Add(filter));
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Transports
|
#region Transports
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ using ModelContextProtocol.Tests.Utils;
|
||||||
|
|
||||||
namespace ModelContextProtocol.AspNetCore.Tests.Utils;
|
namespace ModelContextProtocol.AspNetCore.Tests.Utils;
|
||||||
|
|
||||||
public class KestrelInMemoryTest : LoggedTest
|
public abstract class KestrelInMemoryTest : LoggedTest
|
||||||
{
|
{
|
||||||
public KestrelInMemoryTest(ITestOutputHelper testOutputHelper)
|
public KestrelInMemoryTest(ITestOutputHelper testOutputHelper)
|
||||||
: base(testOutputHelper)
|
: base(testOutputHelper)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,700 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using ModelContextProtocol.Tests.Utils;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.Tests.Configuration;
|
||||||
|
|
||||||
|
public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper testOutputHelper) : ClientServerTestBase(testOutputHelper, startServer: false)
|
||||||
|
{
|
||||||
|
private static ILogger GetLogger(IServiceProvider? services, string categoryName)
|
||||||
|
{
|
||||||
|
var loggerFactory = services?.GetRequiredService<ILoggerFactory>() ?? throw new InvalidOperationException("LoggerFactory not available");
|
||||||
|
return loggerFactory.CreateLogger(categoryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddIncomingMessageFilter_Logs_For_Request()
|
||||||
|
{
|
||||||
|
List<string> messageTypes = [];
|
||||||
|
|
||||||
|
McpServerBuilder
|
||||||
|
.AddIncomingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
var logger = GetLogger(context.Services, "MessageFilter1");
|
||||||
|
logger.LogInformation("MessageFilter1 before");
|
||||||
|
|
||||||
|
var messageTypeName = context.JsonRpcMessage.GetType().Name;
|
||||||
|
messageTypes.Add(messageTypeName);
|
||||||
|
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("MessageFilter1 after");
|
||||||
|
})
|
||||||
|
.AddIncomingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
var logger = GetLogger(context.Services, "MessageFilter2");
|
||||||
|
logger.LogInformation("MessageFilter2 before");
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
logger.LogInformation("MessageFilter2 after");
|
||||||
|
})
|
||||||
|
.WithTools<TestTool>()
|
||||||
|
.WithPrompts<TestPrompt>()
|
||||||
|
.WithResources<TestResource>();
|
||||||
|
|
||||||
|
StartServer();
|
||||||
|
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var beforeMessages = MockLoggerProvider.LogMessages.Where(m => m.Message == "MessageFilter1 before").ToList();
|
||||||
|
Assert.True(beforeMessages.Count > 0);
|
||||||
|
Assert.Equal(LogLevel.Information, beforeMessages[0].LogLevel);
|
||||||
|
Assert.Equal("MessageFilter1", beforeMessages[0].Category);
|
||||||
|
|
||||||
|
var afterMessages = MockLoggerProvider.LogMessages.Where(m => m.Message == "MessageFilter1 after").ToList();
|
||||||
|
Assert.True(afterMessages.Count > 0);
|
||||||
|
Assert.Equal(LogLevel.Information, afterMessages[0].LogLevel);
|
||||||
|
Assert.Equal("MessageFilter1", afterMessages[0].Category);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddIncomingMessageFilter_Intercepts_Request_Messages()
|
||||||
|
{
|
||||||
|
List<string> messageTypes = [];
|
||||||
|
|
||||||
|
McpServerBuilder
|
||||||
|
.AddIncomingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
var messageTypeName = context.JsonRpcMessage.GetType().Name;
|
||||||
|
messageTypes.Add(messageTypeName);
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
.WithTools<TestTool>();
|
||||||
|
|
||||||
|
StartServer();
|
||||||
|
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// The message filter should intercept JsonRpcRequest messages
|
||||||
|
Assert.Contains("JsonRpcRequest", messageTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddIncomingMessageFilter_Multiple_Filters_Execute_In_Order()
|
||||||
|
{
|
||||||
|
McpServerBuilder
|
||||||
|
.AddIncomingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
var logger = GetLogger(context.Services, "MessageFilter1");
|
||||||
|
logger.LogInformation("MessageFilter1 before");
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
logger.LogInformation("MessageFilter1 after");
|
||||||
|
})
|
||||||
|
.AddIncomingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
var logger = GetLogger(context.Services, "MessageFilter2");
|
||||||
|
logger.LogInformation("MessageFilter2 before");
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
logger.LogInformation("MessageFilter2 after");
|
||||||
|
})
|
||||||
|
.WithTools<TestTool>();
|
||||||
|
|
||||||
|
StartServer();
|
||||||
|
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var logMessages = MockLoggerProvider.LogMessages
|
||||||
|
.Where(m => m.Category.StartsWith("MessageFilter"))
|
||||||
|
.Select(m => m.Message)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// First filter registered is outermost
|
||||||
|
// We should see this pattern for each message: MessageFilter1 before -> MessageFilter2 before -> MessageFilter2 after -> MessageFilter1 after
|
||||||
|
int idx1Before = logMessages.IndexOf("MessageFilter1 before");
|
||||||
|
int idx2Before = logMessages.IndexOf("MessageFilter2 before");
|
||||||
|
int idx2After = logMessages.IndexOf("MessageFilter2 after");
|
||||||
|
int idx1After = logMessages.IndexOf("MessageFilter1 after");
|
||||||
|
|
||||||
|
Assert.True(idx1Before >= 0);
|
||||||
|
Assert.True(idx2Before >= 0);
|
||||||
|
Assert.True(idx2After >= 0);
|
||||||
|
Assert.True(idx1After >= 0);
|
||||||
|
|
||||||
|
// Verify ordering within a single request
|
||||||
|
Assert.True(idx1Before < idx2Before);
|
||||||
|
Assert.True(idx2Before < idx2After);
|
||||||
|
Assert.True(idx2After < idx1After);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddIncomingMessageFilter_Has_Access_To_Server()
|
||||||
|
{
|
||||||
|
McpServer? capturedServer = null;
|
||||||
|
|
||||||
|
McpServerBuilder
|
||||||
|
.AddIncomingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
capturedServer = context.Server;
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
.WithTools<TestTool>();
|
||||||
|
|
||||||
|
StartServer();
|
||||||
|
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// The captured server is a per-destination wrapper that provides the same functionality
|
||||||
|
Assert.NotNull(capturedServer);
|
||||||
|
Assert.NotNull(capturedServer.ServerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddIncomingMessageFilter_Items_Dictionary_Can_Be_Used()
|
||||||
|
{
|
||||||
|
string? capturedValue = null;
|
||||||
|
|
||||||
|
McpServerBuilder
|
||||||
|
.AddIncomingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
context.Items["testKey"] = "testValue";
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
.AddIncomingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
if (context.Items.TryGetValue("testKey", out var value))
|
||||||
|
{
|
||||||
|
capturedValue = value as string;
|
||||||
|
}
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
.WithTools<TestTool>();
|
||||||
|
|
||||||
|
StartServer();
|
||||||
|
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal("testValue", capturedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddIncomingMessageFilter_Can_Access_JsonRpcMessage_Details()
|
||||||
|
{
|
||||||
|
string? capturedMethod = null;
|
||||||
|
|
||||||
|
McpServerBuilder
|
||||||
|
.AddIncomingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
if (context.JsonRpcMessage is JsonRpcRequest request && request.Method == RequestMethods.ToolsList)
|
||||||
|
{
|
||||||
|
capturedMethod = request.Method;
|
||||||
|
}
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
.WithTools<TestTool>();
|
||||||
|
|
||||||
|
StartServer();
|
||||||
|
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(RequestMethods.ToolsList, capturedMethod);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddIncomingMessageFilter_Exception_Propagates_Properly()
|
||||||
|
{
|
||||||
|
McpServerBuilder
|
||||||
|
.AddIncomingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
// Only throw for tools/list, not for initialize/initialized
|
||||||
|
if (context.JsonRpcMessage is JsonRpcRequest request && request.Method == RequestMethods.ToolsList)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Filter exception");
|
||||||
|
}
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
.WithTools<TestTool>();
|
||||||
|
|
||||||
|
StartServer();
|
||||||
|
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
|
||||||
|
{
|
||||||
|
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Contains("error", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddIncomingMessageFilter_Runs_Before_Request_Specific_Filters()
|
||||||
|
{
|
||||||
|
var executionOrder = new List<string>();
|
||||||
|
|
||||||
|
McpServerBuilder
|
||||||
|
.AddIncomingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
if (context.JsonRpcMessage is JsonRpcRequest request && request.Method == RequestMethods.ToolsList)
|
||||||
|
{
|
||||||
|
executionOrder.Add("MessageFilter");
|
||||||
|
}
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
.AddListToolsFilter((next) => async (request, cancellationToken) =>
|
||||||
|
{
|
||||||
|
executionOrder.Add("ListToolsFilter");
|
||||||
|
return await next(request, cancellationToken);
|
||||||
|
})
|
||||||
|
.WithTools<TestTool>();
|
||||||
|
|
||||||
|
StartServer();
|
||||||
|
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Message filter should run before the request-specific filter
|
||||||
|
Assert.Equal(2, executionOrder.Count);
|
||||||
|
Assert.Equal("MessageFilter", executionOrder[0]);
|
||||||
|
Assert.Equal("ListToolsFilter", executionOrder[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddIncomingMessageFilter_Can_Skip_Default_Handlers()
|
||||||
|
{
|
||||||
|
McpServerBuilder
|
||||||
|
.AddIncomingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
// Skip calling next for tools/list
|
||||||
|
if (context.JsonRpcMessage is JsonRpcRequest request && request.Method == RequestMethods.ToolsList)
|
||||||
|
{
|
||||||
|
// Don't call next - this will skip the default handler
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
.WithTools<TestTool>();
|
||||||
|
|
||||||
|
StartServer();
|
||||||
|
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
// When default handlers are skipped, the request should time out
|
||||||
|
// because no response will be sent
|
||||||
|
using var requestCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||||
|
{
|
||||||
|
await client.ListToolsAsync(cancellationToken: requestCts.Token);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddOutgoingMessageFilter_Sees_Initialize_Progress_And_Response()
|
||||||
|
{
|
||||||
|
var observedMessages = new List<string>();
|
||||||
|
|
||||||
|
McpServerBuilder
|
||||||
|
.AddOutgoingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
switch (context.JsonRpcMessage)
|
||||||
|
{
|
||||||
|
case JsonRpcResponse response when response.Result is JsonObject result:
|
||||||
|
if (result.ContainsKey("protocolVersion"))
|
||||||
|
{
|
||||||
|
observedMessages.Add("initialize");
|
||||||
|
}
|
||||||
|
else if (result.ContainsKey("content"))
|
||||||
|
{
|
||||||
|
observedMessages.Add("response");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case JsonRpcNotification notification when notification.Method == NotificationMethods.ProgressNotification:
|
||||||
|
observedMessages.Add("progress");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
.WithTools<ProgressTool>();
|
||||||
|
|
||||||
|
StartServer();
|
||||||
|
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
IProgress<ProgressNotificationValue> progress = new Progress<ProgressNotificationValue>(_ => { });
|
||||||
|
await client.CallToolAsync("progress-tool", progress: progress, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
int initializeIndex = observedMessages.IndexOf("initialize");
|
||||||
|
int progressIndex = observedMessages.IndexOf("progress");
|
||||||
|
int responseIndex = observedMessages.LastIndexOf("response");
|
||||||
|
|
||||||
|
Assert.True(initializeIndex >= 0);
|
||||||
|
Assert.True(progressIndex > initializeIndex);
|
||||||
|
Assert.True(responseIndex > progressIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddOutgoingMessageFilter_Can_Skip_Sending_Messages()
|
||||||
|
{
|
||||||
|
McpServerBuilder
|
||||||
|
.AddOutgoingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
if (context.JsonRpcMessage is JsonRpcResponse response && response.Result is JsonObject result && result.ContainsKey("tools"))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
.WithTools<TestTool>();
|
||||||
|
|
||||||
|
StartServer();
|
||||||
|
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
using var requestCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||||
|
{
|
||||||
|
await client.ListToolsAsync(cancellationToken: requestCts.Token);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddOutgoingMessageFilter_Can_Send_Additional_Messages()
|
||||||
|
{
|
||||||
|
McpServerBuilder
|
||||||
|
.AddOutgoingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
if (context.JsonRpcMessage is JsonRpcResponse response && response.Result is JsonObject result && result.ContainsKey("tools"))
|
||||||
|
{
|
||||||
|
var extraNotification = new JsonRpcNotification
|
||||||
|
{
|
||||||
|
Method = "test/extra",
|
||||||
|
Params = new JsonObject { ["message"] = "extra" },
|
||||||
|
Context = new JsonRpcMessageContext { RelatedTransport = context.JsonRpcMessage.Context?.RelatedTransport },
|
||||||
|
};
|
||||||
|
|
||||||
|
await next(new MessageContext(context.Server, extraNotification), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
.WithTools<TestTool>();
|
||||||
|
|
||||||
|
StartServer();
|
||||||
|
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
var extraNotificationReceived = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
await using var registration = client.RegisterNotificationHandler("test/extra", (notification, _) =>
|
||||||
|
{
|
||||||
|
extraNotificationReceived.TrySetResult(notification.Params?["message"]?.GetValue<string>());
|
||||||
|
return default;
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var extraMessage = await extraNotificationReceived.Task.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal("extra", extraMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddIncomingMessageFilter_Items_Flow_To_Request_Filters()
|
||||||
|
{
|
||||||
|
string? capturedValue = null;
|
||||||
|
|
||||||
|
McpServerBuilder
|
||||||
|
.AddIncomingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
// Set an item in the message filter
|
||||||
|
if (context.JsonRpcMessage is JsonRpcRequest request && request.Method == RequestMethods.ToolsList)
|
||||||
|
{
|
||||||
|
context.Items["messageFilterKey"] = "messageFilterValue";
|
||||||
|
}
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
.AddListToolsFilter((next) => async (request, cancellationToken) =>
|
||||||
|
{
|
||||||
|
// Read the item in the request-specific filter
|
||||||
|
if (request.Items.TryGetValue("messageFilterKey", out var value))
|
||||||
|
{
|
||||||
|
capturedValue = value as string;
|
||||||
|
}
|
||||||
|
return await next(request, cancellationToken);
|
||||||
|
})
|
||||||
|
.WithTools<TestTool>();
|
||||||
|
|
||||||
|
StartServer();
|
||||||
|
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal("messageFilterValue", capturedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddIncomingMessageFilter_Items_Flow_To_CallTool_Handler()
|
||||||
|
{
|
||||||
|
object? capturedValue = null;
|
||||||
|
|
||||||
|
McpServerBuilder
|
||||||
|
.AddIncomingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
// Set an item in the message filter for CallTool requests
|
||||||
|
if (context.JsonRpcMessage is JsonRpcRequest request && request.Method == RequestMethods.ToolsCall)
|
||||||
|
{
|
||||||
|
context.Items["toolContextKey"] = 42;
|
||||||
|
}
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
.AddCallToolFilter((next) => async (request, cancellationToken) =>
|
||||||
|
{
|
||||||
|
// Read the item in the call tool filter
|
||||||
|
if (request.Items.TryGetValue("toolContextKey", out var value))
|
||||||
|
{
|
||||||
|
capturedValue = value;
|
||||||
|
}
|
||||||
|
return await next(request, cancellationToken);
|
||||||
|
})
|
||||||
|
.WithTools<SimpleTool>();
|
||||||
|
|
||||||
|
StartServer();
|
||||||
|
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
await client.CallToolAsync("simple-tool", cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(42, capturedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddIncomingMessageFilter_User_Flows_To_CallTool_Handler()
|
||||||
|
{
|
||||||
|
ClaimsPrincipal? capturedUser = null;
|
||||||
|
|
||||||
|
McpServerBuilder
|
||||||
|
.AddIncomingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
// Set a custom user in the message filter for CallTool requests
|
||||||
|
if (context.JsonRpcMessage is JsonRpcRequest request && request.Method == RequestMethods.ToolsCall)
|
||||||
|
{
|
||||||
|
var claims = new[] { new Claim(ClaimTypes.Name, "TestUser"), new Claim(ClaimTypes.Role, "Admin") };
|
||||||
|
var identity = new ClaimsIdentity(claims, "TestAuth");
|
||||||
|
context.User = new ClaimsPrincipal(identity);
|
||||||
|
}
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
.AddCallToolFilter((next) => async (request, cancellationToken) =>
|
||||||
|
{
|
||||||
|
// Read the user in the call tool filter
|
||||||
|
capturedUser = request.User;
|
||||||
|
return await next(request, cancellationToken);
|
||||||
|
})
|
||||||
|
.WithTools<SimpleTool>();
|
||||||
|
|
||||||
|
StartServer();
|
||||||
|
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
await client.CallToolAsync("simple-tool", cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.NotNull(capturedUser);
|
||||||
|
Assert.Equal("TestUser", capturedUser.Identity?.Name);
|
||||||
|
Assert.True(capturedUser.IsInRole("Admin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddIncomingMessageFilter_Items_Preserved_When_Context_Replaced()
|
||||||
|
{
|
||||||
|
object? firstFilterValue = null;
|
||||||
|
object? secondFilterValue = null;
|
||||||
|
|
||||||
|
McpServerBuilder
|
||||||
|
.AddIncomingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
// First filter sets an item
|
||||||
|
if (context.JsonRpcMessage is JsonRpcRequest request && request.Method == RequestMethods.ToolsList)
|
||||||
|
{
|
||||||
|
context.Items["firstFilterKey"] = "firstFilterValue";
|
||||||
|
}
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
.AddIncomingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
// Second filter creates a new context with a new JsonRpcRequest and adds an item
|
||||||
|
if (context.JsonRpcMessage is JsonRpcRequest request && request.Method == RequestMethods.ToolsList)
|
||||||
|
{
|
||||||
|
var newRequest = new JsonRpcRequest
|
||||||
|
{
|
||||||
|
Id = request.Id,
|
||||||
|
Method = RequestMethods.ToolsList,
|
||||||
|
Params = request.Params,
|
||||||
|
Context = new JsonRpcMessageContext { RelatedTransport = request.Context?.RelatedTransport },
|
||||||
|
};
|
||||||
|
|
||||||
|
var newContext = new MessageContext(context.Server, newRequest);
|
||||||
|
newContext.Items["secondFilterKey"] = "secondFilterValue";
|
||||||
|
|
||||||
|
await next(newContext, cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
.AddListToolsFilter((next) => async (request, cancellationToken) =>
|
||||||
|
{
|
||||||
|
// Request filter should see items from message filters
|
||||||
|
request.Items.TryGetValue("firstFilterKey", out firstFilterValue);
|
||||||
|
request.Items.TryGetValue("secondFilterKey", out secondFilterValue);
|
||||||
|
return await next(request, cancellationToken);
|
||||||
|
})
|
||||||
|
.WithTools<TestTool>();
|
||||||
|
|
||||||
|
StartServer();
|
||||||
|
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Null(firstFilterValue);
|
||||||
|
Assert.Equal("secondFilterValue", secondFilterValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddIncomingMessageFilter_Items_Flow_Through_Multiple_Request_Filters()
|
||||||
|
{
|
||||||
|
var observedValues = new List<string>();
|
||||||
|
|
||||||
|
McpServerBuilder
|
||||||
|
.AddIncomingMessageFilter((next) => async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
if (context.JsonRpcMessage is JsonRpcRequest request && request.Method == RequestMethods.ToolsList)
|
||||||
|
{
|
||||||
|
context.Items["sharedKey"] = "fromMessageFilter";
|
||||||
|
}
|
||||||
|
await next(context, cancellationToken);
|
||||||
|
})
|
||||||
|
.AddListToolsFilter((next) => async (request, cancellationToken) =>
|
||||||
|
{
|
||||||
|
// First request filter reads and modifies
|
||||||
|
if (request.Items.TryGetValue("sharedKey", out var value))
|
||||||
|
{
|
||||||
|
observedValues.Add((string)value!);
|
||||||
|
request.Items["sharedKey"] = "modifiedByFilter1";
|
||||||
|
}
|
||||||
|
return await next(request, cancellationToken);
|
||||||
|
})
|
||||||
|
.AddListToolsFilter((next) => async (request, cancellationToken) =>
|
||||||
|
{
|
||||||
|
// Second request filter should see modified value
|
||||||
|
if (request.Items.TryGetValue("sharedKey", out var value))
|
||||||
|
{
|
||||||
|
observedValues.Add((string)value!);
|
||||||
|
}
|
||||||
|
return await next(request, cancellationToken);
|
||||||
|
})
|
||||||
|
.WithTools<TestTool>();
|
||||||
|
|
||||||
|
StartServer();
|
||||||
|
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(2, observedValues.Count);
|
||||||
|
Assert.Equal("fromMessageFilter", observedValues[0]);
|
||||||
|
Assert.Equal("modifiedByFilter1", observedValues[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerToolType]
|
||||||
|
public sealed class TestTool
|
||||||
|
{
|
||||||
|
[McpServerTool]
|
||||||
|
public static string TestToolMethod()
|
||||||
|
{
|
||||||
|
return "test result";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerPromptType]
|
||||||
|
public sealed class TestPrompt
|
||||||
|
{
|
||||||
|
[McpServerPrompt]
|
||||||
|
public static Task<GetPromptResult> TestPromptMethod()
|
||||||
|
{
|
||||||
|
return Task.FromResult(new GetPromptResult
|
||||||
|
{
|
||||||
|
Description = "Test prompt",
|
||||||
|
Messages = [new() { Role = Role.User, Content = new TextContentBlock { Text = "Test" } }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerResourceType]
|
||||||
|
public sealed class TestResource
|
||||||
|
{
|
||||||
|
[McpServerResource(UriTemplate = "test://resource/{id}")]
|
||||||
|
public static string TestResourceMethod(string id)
|
||||||
|
{
|
||||||
|
return $"Test resource for ID: {id}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerToolType]
|
||||||
|
public sealed class ProgressTool
|
||||||
|
{
|
||||||
|
[McpServerTool(Name = "progress-tool")]
|
||||||
|
public static async Task<string> ReportProgress(
|
||||||
|
McpServer server,
|
||||||
|
RequestContext<CallToolRequestParams> context,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (context.Params?.ProgressToken is { } token)
|
||||||
|
{
|
||||||
|
await server.NotifyProgressAsync(token, new ProgressNotificationValue
|
||||||
|
{
|
||||||
|
Progress = 0,
|
||||||
|
Total = 2,
|
||||||
|
Message = "starting",
|
||||||
|
}, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
await server.NotifyProgressAsync(token, new ProgressNotificationValue
|
||||||
|
{
|
||||||
|
Progress = 1,
|
||||||
|
Total = 2,
|
||||||
|
Message = "running",
|
||||||
|
}, cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "done";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerToolType]
|
||||||
|
public sealed class SimpleTool
|
||||||
|
{
|
||||||
|
[McpServerTool(Name = "simple-tool")]
|
||||||
|
public static string Execute()
|
||||||
|
{
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue