Add support for message-level filters to McpServer (#1207)

This commit is contained in:
Stephen Halter 2026-01-30 15:19:00 -08:00 • committed by GitHub
commit 7d48d3922c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1333 additions and 105 deletions

View 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);

View 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);

View file

@ -11,6 +11,39 @@ namespace ModelContextProtocol.Server;
/// </remarks>
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>
/// Gets the filters for the list-tools handler pipeline.
/// </summary>

View file

@ -118,7 +118,17 @@ internal sealed partial class McpServerImpl : McpServer
}
// 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/>
@ -875,6 +885,39 @@ internal sealed partial class McpServerImpl : McpServer
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()
{
if (ClientInfo is null)

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

View file

@ -1,4 +1,3 @@
using System.Security.Claims;
using ModelContextProtocol.Protocol;
namespace ModelContextProtocol.Server;
@ -12,11 +11,8 @@ namespace ModelContextProtocol.Server;
/// 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.
/// </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>
/// Initializes a new instance of the <see cref="RequestContext{TParams}"/> class with the specified server and JSON-RPC request.
/// </summary>
@ -24,48 +20,10 @@ public sealed class RequestContext<TParams>
/// <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>
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>
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,
/// including the method name, parameters, request ID, and associated transport and user information.
/// </remarks>
public JsonRpcRequest JsonRpcRequest { get; }
public JsonRpcRequest JsonRpcRequest
{
get => (JsonRpcRequest)JsonRpcMessage;
set => JsonRpcMessage = value;
}
/// <summary>
/// Ends the current response and enables polling for updates from the server.

View file

@ -41,9 +41,12 @@ internal sealed partial class StreamableHttpPostTransport(
{
Debug.Assert(_pendingRequest.Id is null);
message.Context ??= new JsonRpcMessageContext();
if (message is JsonRpcRequest request)
{
_pendingRequest = request.Id;
message.Context.RelatedTransport = this;
// Invoke the initialize request handler if applicable.
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)
{
message.Context.ExecutionContext = ExecutionContext.Capture();