C# Implementation (#2)

C# implementation of SEP-1763

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Peder Holdgaard Pedersen 2026-01-28 00:53:44 +01:00 • committed by GitHub
commit 45f9e0ef27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 13862 additions and 21 deletions

View file

@ -0,0 +1,66 @@
using ModelContextProtocol.Client;
namespace ModelContextProtocol.Interceptors.Client;
/// <summary>
/// Provides a context container for client-side interceptor invocations.
/// </summary>
/// <typeparam name="TParams">Type of the request parameters.</typeparam>
/// <remarks>
/// <para>
/// The <see cref="ClientInterceptorContext{TParams}"/> encapsulates all contextual information for
/// invoking a client-side interceptor. Unlike server-side <c>RequestContext</c>, this context is
/// designed for intercepting outgoing requests and incoming responses at the client level.
/// </para>
/// <para>
/// Client interceptors operate at trust boundaries when:
/// <list type="bullet">
/// <item>Sending requests to servers (request phase)</item>
/// <item>Receiving responses from servers (response phase)</item>
/// </list>
/// </para>
/// </remarks>
public sealed class ClientInterceptorContext<TParams>
{
/// <summary>
/// Initializes a new instance of the <see cref="ClientInterceptorContext{TParams}"/> class.
/// </summary>
/// <param name="client">The MCP client associated with this context, if any.</param>
public ClientInterceptorContext(McpClient? client = null)
{
Client = client;
}
/// <summary>
/// Gets or sets the MCP client associated with this context.
/// </summary>
/// <remarks>
/// May be null for interceptors that operate independently of a specific client session.
/// </remarks>
public McpClient? Client { get; set; }
/// <summary>
/// Gets or sets the services associated with this invocation.
/// </summary>
public IServiceProvider? Services { get; set; }
/// <summary>
/// Gets or sets the parameters for this interceptor invocation.
/// </summary>
public TParams? Params { get; set; }
/// <summary>
/// Gets or sets a key/value collection for sharing data within the scope of this invocation.
/// </summary>
public IDictionary<string, object?> Items
{
get => _items ??= new Dictionary<string, object?>();
set => _items = value;
}
private IDictionary<string, object?>? _items;
/// <summary>
/// Gets or sets the matched interceptor primitive, if any.
/// </summary>
public McpClientInterceptor? MatchedInterceptor { get; set; }
}

View file

@ -0,0 +1,516 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using ModelContextProtocol.Client;
using ModelContextProtocol.Interceptors.Client;
using ModelContextProtocol.Protocol;
namespace ModelContextProtocol.Interceptors;
/// <summary>
/// Wraps an <see cref="McpClient"/> and automatically executes interceptor chains for tool operations.
/// </summary>
/// <remarks>
/// <para>
/// The <see cref="InterceptingMcpClient"/> provides a decorator pattern implementation that wraps an
/// existing <see cref="McpClient"/> and intercepts tool-related operations (CallToolAsync
/// and <see cref="ListToolsAsync"/>) to execute validation, mutation, and observability interceptors
/// according to the SEP-1763 specification.
/// </para>
/// <para>
/// <b>Execution Model (SEP-1763):</b>
/// </para>
/// <list type="bullet">
/// <item><description><b>Sending (outgoing request):</b> Mutations execute sequentially by priority, then validations and observability execute in parallel.</description></item>
/// <item><description><b>Receiving (incoming response):</b> Validations and observability execute in parallel, then mutations execute sequentially by priority.</description></item>
/// </list>
/// <para>
/// Only validation interceptors with <see cref="ValidationSeverity.Error"/> severity can block execution.
/// Info and warning severities are recorded but do not prevent the operation from proceeding.
/// </para>
/// </remarks>
/// <example>
/// Using InterceptingMcpClient with interceptors:
/// <code>
/// // Create MCP client normally
/// await using var client = await McpClient.CreateAsync(transport);
///
/// // Wrap with interceptors using extension method
/// var interceptedClient = client.WithInterceptors(new InterceptingMcpClientOptions
/// {
/// Interceptors =
/// [
/// McpClientInterceptor.Create(
/// name: "pii-validator",
/// events: [InterceptorEvents.ToolsCall],
/// type: InterceptorType.Validation,
/// handler: (ctx, ct) =&gt;
/// {
/// // Validate no PII in arguments
/// return ValueTask.FromResult(ValidationInterceptorResult.Success());
/// })
/// ]
/// });
///
/// // Use intercepted client - interceptors run automatically
/// try
/// {
/// var result = await interceptedClient.CallToolAsync("my-tool", args);
/// }
/// catch (McpInterceptorValidationException ex)
/// {
/// Console.WriteLine($"Blocked by: {ex.AbortedAt?.Interceptor}");
/// }
/// </code>
/// </example>
public sealed class InterceptingMcpClient : IAsyncDisposable
{
private readonly McpClient _inner;
private readonly InterceptorChainExecutor _executor;
private readonly InterceptingMcpClientOptions _options;
/// <summary>
/// Initializes a new instance of the <see cref="InterceptingMcpClient"/> class.
/// </summary>
/// <param name="inner">The underlying <see cref="McpClient"/> to wrap.</param>
/// <param name="options">Configuration options including interceptors and settings.</param>
/// <exception cref="ArgumentNullException"><paramref name="inner"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
public InterceptingMcpClient(McpClient inner, InterceptingMcpClientOptions options)
{
Throw.IfNull(inner);
Throw.IfNull(options);
_inner = inner;
_options = options;
_executor = new InterceptorChainExecutor(
options.Interceptors,
options.Services);
}
/// <summary>
/// Gets the underlying <see cref="McpClient"/> instance.
/// </summary>
/// <remarks>
/// Use this property to access non-intercepted operations or properties from the underlying client,
/// such as prompts, resources, or other MCP features not currently supported by interception.
/// </remarks>
public McpClient Inner => _inner;
/// <summary>
/// Gets the capabilities supported by the connected server.
/// </summary>
public ServerCapabilities ServerCapabilities => _inner.ServerCapabilities;
/// <summary>
/// Gets the implementation information of the connected server.
/// </summary>
public Implementation ServerInfo => _inner.ServerInfo;
/// <summary>
/// Gets any instructions describing how to use the connected server and its features.
/// </summary>
public string? ServerInstructions => _inner.ServerInstructions;
/// <summary>
/// Gets the configuration options for this intercepting client.
/// </summary>
public InterceptingMcpClientOptions Options => _options;
#region CallToolAsync
/// <summary>
/// Invokes a tool on the server with interceptor chain execution.
/// </summary>
/// <param name="toolName">The name of the tool to call on the server.</param>
/// <param name="arguments">An optional dictionary of arguments to pass to the tool.</param>
/// <param name="progress">An optional progress reporter for server notifications.</param>
/// <param name="options">Optional request options including metadata, serialization settings, and progress tracking.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>The <see cref="CallToolResult"/> from the tool execution.</returns>
/// <exception cref="ArgumentNullException"><paramref name="toolName"/> is <see langword="null"/>.</exception>
/// <exception cref="McpInterceptorValidationException">A validation interceptor returned error severity and <see cref="InterceptingMcpClientOptions.ThrowOnValidationError"/> is <see langword="true"/>.</exception>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
/// <remarks>
/// <para>
/// This method executes the interceptor chain in two phases:
/// </para>
/// <list type="number">
/// <item><description><b>Request interception:</b> Before sending the request to the server, interceptors run according to the sending order (mutations → validations/observability in parallel).</description></item>
/// <item><description><b>Response interception:</b> After receiving the response, interceptors run according to the receiving order (validations/observability in parallel → mutations).</description></item>
/// </list>
/// <para>
/// If a validation interceptor fails with error severity during request interception, the request
/// is not sent to the server and <see cref="McpInterceptorValidationException"/> is thrown.
/// </para>
/// </remarks>
public async ValueTask<CallToolResult> CallToolAsync(
string toolName,
IReadOnlyDictionary<string, object?>? arguments = null,
IProgress<ProgressNotificationValue>? progress = null,
RequestOptions? options = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(toolName);
// Phase 1: Intercept outgoing request
var requestPayload = PayloadConverter.ToCallToolRequestPayload(toolName, arguments);
var sendingResult = await _executor.ExecuteForSendingAsync(
InterceptorEvents.ToolsCall,
requestPayload,
_options.DefaultConfig,
_options.DefaultTimeoutMs,
cancellationToken).ConfigureAwait(false);
// Check if validation failed
if (sendingResult.Status == InterceptorChainStatus.ValidationFailed)
{
if (_options.ThrowOnValidationError)
{
throw new McpInterceptorValidationException(
$"Interceptor validation failed for tools/call '{toolName}': {sendingResult.AbortedAt?.Reason ?? "Unknown reason"}",
sendingResult);
}
// Return an error result if not throwing
return new CallToolResult
{
IsError = true,
Content = [new TextContentBlock { Text = sendingResult.AbortedAt?.Reason ?? "Validation failed" }]
};
}
// Check for timeout or mutation failure
if (sendingResult.Status == InterceptorChainStatus.Timeout)
{
throw new McpInterceptorValidationException(
$"Interceptor chain timed out for tools/call '{toolName}'",
sendingResult);
}
if (sendingResult.Status == InterceptorChainStatus.MutationFailed)
{
throw new McpInterceptorValidationException(
$"Interceptor mutation failed for tools/call '{toolName}': {sendingResult.AbortedAt?.Reason ?? "Unknown reason"}",
sendingResult);
}
// Extract potentially mutated request parameters
var (mutatedToolName, mutatedArguments) = PayloadConverter.FromCallToolRequestPayload(sendingResult.FinalPayload);
// Phase 2: Call the underlying client
CallToolResult result;
if (progress is not null)
{
// Use the high-level overload which handles progress registration
result = await _inner.CallToolAsync(
mutatedToolName,
ConvertToObjectDictionary(mutatedArguments),
progress,
options,
cancellationToken).ConfigureAwait(false);
}
else
{
// Use the low-level overload for better performance
var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions;
result = await _inner.CallToolAsync(
new CallToolRequestParams
{
Name = mutatedToolName,
Arguments = mutatedArguments,
Meta = options?.GetMetaForRequest(),
},
cancellationToken).ConfigureAwait(false);
}
// Phase 3: Intercept incoming response (if enabled)
if (_options.InterceptResponses)
{
var responsePayload = PayloadConverter.ToCallToolResultPayload(result);
var receivingResult = await _executor.ExecuteForReceivingAsync(
InterceptorEvents.ToolsCall,
responsePayload,
_options.DefaultConfig,
_options.DefaultTimeoutMs,
cancellationToken).ConfigureAwait(false);
// Check if validation failed on response
if (receivingResult.Status == InterceptorChainStatus.ValidationFailed)
{
if (_options.ThrowOnValidationError)
{
throw new McpInterceptorValidationException(
$"Interceptor validation failed for tools/call response from '{toolName}': {receivingResult.AbortedAt?.Reason ?? "Unknown reason"}",
receivingResult);
}
// Return an error result if not throwing
return new CallToolResult
{
IsError = true,
Content = [new TextContentBlock { Text = receivingResult.AbortedAt?.Reason ?? "Response validation failed" }]
};
}
// Extract potentially mutated response
var mutatedResult = PayloadConverter.FromCallToolResultPayload(receivingResult.FinalPayload);
if (mutatedResult is not null)
{
result = mutatedResult;
}
}
return result;
}
/// <summary>
/// Invokes a tool on the server with interceptor chain execution.
/// </summary>
/// <param name="requestParams">The request parameters to send in the request.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>The result of the request.</returns>
/// <exception cref="ArgumentNullException"><paramref name="requestParams"/> is <see langword="null"/>.</exception>
/// <exception cref="McpInterceptorValidationException">A validation interceptor returned error severity.</exception>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
public async ValueTask<CallToolResult> CallToolAsync(
CallToolRequestParams requestParams,
CancellationToken cancellationToken = default)
{
Throw.IfNull(requestParams);
// Phase 1: Intercept outgoing request
var requestPayload = PayloadConverter.ToCallToolRequestParamsPayload(requestParams);
var sendingResult = await _executor.ExecuteForSendingAsync(
InterceptorEvents.ToolsCall,
requestPayload,
_options.DefaultConfig,
_options.DefaultTimeoutMs,
cancellationToken).ConfigureAwait(false);
// Check if validation failed
if (sendingResult.Status == InterceptorChainStatus.ValidationFailed)
{
if (_options.ThrowOnValidationError)
{
throw new McpInterceptorValidationException(
$"Interceptor validation failed for tools/call '{requestParams.Name}': {sendingResult.AbortedAt?.Reason ?? "Unknown reason"}",
sendingResult);
}
return new CallToolResult
{
IsError = true,
Content = [new TextContentBlock { Text = sendingResult.AbortedAt?.Reason ?? "Validation failed" }]
};
}
if (sendingResult.Status != InterceptorChainStatus.Success)
{
throw new McpInterceptorValidationException(
$"Interceptor chain failed for tools/call '{requestParams.Name}': {sendingResult.AbortedAt?.Reason ?? sendingResult.Status.ToString()}",
sendingResult);
}
// Extract potentially mutated request parameters
var mutatedParams = PayloadConverter.FromCallToolRequestParamsPayload(sendingResult.FinalPayload)
?? requestParams;
// Phase 2: Call the underlying client
var result = await _inner.CallToolAsync(mutatedParams, cancellationToken).ConfigureAwait(false);
// Phase 3: Intercept incoming response (if enabled)
if (_options.InterceptResponses)
{
var responsePayload = PayloadConverter.ToCallToolResultPayload(result);
var receivingResult = await _executor.ExecuteForReceivingAsync(
InterceptorEvents.ToolsCall,
responsePayload,
_options.DefaultConfig,
_options.DefaultTimeoutMs,
cancellationToken).ConfigureAwait(false);
if (receivingResult.Status == InterceptorChainStatus.ValidationFailed)
{
if (_options.ThrowOnValidationError)
{
throw new McpInterceptorValidationException(
$"Interceptor validation failed for tools/call response: {receivingResult.AbortedAt?.Reason ?? "Unknown reason"}",
receivingResult);
}
return new CallToolResult
{
IsError = true,
Content = [new TextContentBlock { Text = receivingResult.AbortedAt?.Reason ?? "Response validation failed" }]
};
}
var mutatedResult = PayloadConverter.FromCallToolResultPayload(receivingResult.FinalPayload);
if (mutatedResult is not null)
{
result = mutatedResult;
}
}
return result;
}
#endregion
#region ListToolsAsync
/// <summary>
/// Retrieves a list of available tools from the server with interceptor chain execution.
/// </summary>
/// <param name="options">Optional request options including metadata, serialization settings, and progress tracking.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A list of all available tools as <see cref="McpClientTool"/> instances.</returns>
/// <exception cref="McpInterceptorValidationException">A validation interceptor returned error severity.</exception>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
/// <remarks>
/// <para>
/// This method handles pagination automatically, executing interceptors for each page of results.
/// The returned tools are associated with this <see cref="InterceptingMcpClient"/> instance (via the inner client),
/// so invoking them through their InvokeAsync method will NOT execute interceptors.
/// </para>
/// <para>
/// To ensure interceptors are executed for tool calls, use <see cref="CallToolAsync(string, IReadOnlyDictionary{string, object?}?, IProgress{ProgressNotificationValue}?, RequestOptions?, CancellationToken)"/>
/// directly on this <see cref="InterceptingMcpClient"/> instance instead of calling tools through <see cref="McpClientTool"/>.
/// </para>
/// </remarks>
public async ValueTask<IList<McpClientTool>> ListToolsAsync(
RequestOptions? options = null,
CancellationToken cancellationToken = default)
{
List<McpClientTool>? tools = null;
string? cursor = null;
do
{
// Phase 1: Intercept outgoing request
var requestPayload = PayloadConverter.ToListToolsRequestPayload(cursor);
var sendingResult = await _executor.ExecuteForSendingAsync(
InterceptorEvents.ToolsList,
requestPayload,
_options.DefaultConfig,
_options.DefaultTimeoutMs,
cancellationToken).ConfigureAwait(false);
if (sendingResult.Status == InterceptorChainStatus.ValidationFailed)
{
if (_options.ThrowOnValidationError)
{
throw new McpInterceptorValidationException(
$"Interceptor validation failed for tools/list: {sendingResult.AbortedAt?.Reason ?? "Unknown reason"}",
sendingResult);
}
// Return empty list if not throwing
return [];
}
if (sendingResult.Status != InterceptorChainStatus.Success)
{
throw new McpInterceptorValidationException(
$"Interceptor chain failed for tools/list: {sendingResult.AbortedAt?.Reason ?? sendingResult.Status.ToString()}",
sendingResult);
}
// Extract potentially mutated cursor
var mutatedCursor = PayloadConverter.FromListToolsRequestPayload(sendingResult.FinalPayload);
// Phase 2: Call the underlying client
var requestParams = new ListToolsRequestParams
{
Cursor = mutatedCursor,
Meta = options?.GetMetaForRequest()
};
var toolResults = await _inner.ListToolsAsync(requestParams, cancellationToken).ConfigureAwait(false);
// Phase 3: Intercept incoming response (if enabled)
if (_options.InterceptResponses)
{
var responsePayload = PayloadConverter.ToListToolsResultPayload(toolResults);
var receivingResult = await _executor.ExecuteForReceivingAsync(
InterceptorEvents.ToolsList,
responsePayload,
_options.DefaultConfig,
_options.DefaultTimeoutMs,
cancellationToken).ConfigureAwait(false);
if (receivingResult.Status == InterceptorChainStatus.ValidationFailed)
{
if (_options.ThrowOnValidationError)
{
throw new McpInterceptorValidationException(
$"Interceptor validation failed for tools/list response: {receivingResult.AbortedAt?.Reason ?? "Unknown reason"}",
receivingResult);
}
return tools ?? [];
}
var mutatedResults = PayloadConverter.FromListToolsResultPayload(receivingResult.FinalPayload);
if (mutatedResults is not null)
{
toolResults = mutatedResults;
}
}
// Add tools to the result list
tools ??= new(toolResults.Tools.Count);
foreach (var tool in toolResults.Tools)
{
// Note: Tools are associated with the inner client, not this intercepting wrapper
// This means calling tool.InvokeAsync() will bypass interceptors
tools.Add(new McpClientTool(_inner, tool, options?.JsonSerializerOptions));
}
cursor = toolResults.NextCursor;
}
while (cursor is not null);
return tools ?? [];
}
#endregion
#region IAsyncDisposable
/// <summary>
/// Disposes the underlying <see cref="McpClient"/> instance.
/// </summary>
/// <returns>A task that represents the asynchronous dispose operation.</returns>
public ValueTask DisposeAsync() => _inner.DisposeAsync();
#endregion
#region Helper Methods
/// <summary>
/// Converts a dictionary of JsonElement values to object values for compatibility with the high-level CallToolAsync overload.
/// </summary>
private static IReadOnlyDictionary<string, object?>? ConvertToObjectDictionary(Dictionary<string, JsonElement>? arguments)
{
if (arguments is null || arguments.Count == 0)
{
return null;
}
var result = new Dictionary<string, object?>(arguments.Count);
foreach (var kvp in arguments)
{
result[kvp.Key] = kvp.Value;
}
return result;
}
#endregion
}

View file

@ -0,0 +1,128 @@
using ModelContextProtocol.Client;
using ModelContextProtocol.Interceptors.Client;
namespace ModelContextProtocol.Interceptors;
/// <summary>
/// Provides extension methods for wrapping <see cref="McpClient"/> with interceptor support.
/// </summary>
public static class InterceptingMcpClientExtensions
{
/// <summary>
/// Wraps an <see cref="McpClient"/> with interceptor chain execution for tool operations.
/// </summary>
/// <param name="client">The <see cref="McpClient"/> to wrap.</param>
/// <param name="options">Configuration options including interceptors and settings.</param>
/// <returns>An <see cref="InterceptingMcpClient"/> that executes interceptor chains for tool operations.</returns>
/// <exception cref="ArgumentNullException"><paramref name="client"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// This extension method creates an <see cref="InterceptingMcpClient"/> that wraps the provided
/// <see cref="McpClient"/> and automatically executes interceptor chains for <c>tools/call</c>
/// and <c>tools/list</c> operations according to SEP-1763.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// await using var client = await McpClient.CreateAsync(transport);
///
/// var interceptedClient = client.WithInterceptors(new InterceptingMcpClientOptions
/// {
/// Interceptors =
/// [
/// McpClientInterceptor.Create(
/// name: "audit-logger",
/// events: [InterceptorEvents.ToolsCall],
/// type: InterceptorType.Observability,
/// handler: async (ctx, ct) =&gt;
/// {
/// await LogToolCallAsync(ctx.Params);
/// return ObservabilityInterceptorResult.Success();
/// })
/// ],
/// DefaultTimeoutMs = 5000
/// });
///
/// var result = await interceptedClient.CallToolAsync("my-tool", args);
/// </code>
/// </example>
public static InterceptingMcpClient WithInterceptors(
this McpClient client,
InterceptingMcpClientOptions options)
{
Throw.IfNull(client);
Throw.IfNull(options);
return new InterceptingMcpClient(client, options);
}
/// <summary>
/// Wraps an <see cref="McpClient"/> with interceptor chain execution using the specified interceptors.
/// </summary>
/// <param name="client">The <see cref="McpClient"/> to wrap.</param>
/// <param name="interceptors">The interceptors to register.</param>
/// <returns>An <see cref="InterceptingMcpClient"/> that executes interceptor chains for tool operations.</returns>
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// This is a convenience overload that creates an <see cref="InterceptingMcpClient"/> with the
/// specified interceptors using default options (no timeout, throw on validation error, intercept responses).
/// </para>
/// </remarks>
/// <example>
/// <code>
/// await using var client = await McpClient.CreateAsync(transport);
///
/// var interceptedClient = client.WithInterceptors(
/// McpClientInterceptor.Create(
/// name: "rate-limiter",
/// events: [InterceptorEvents.ToolsCall],
/// type: InterceptorType.Validation,
/// handler: (ctx, ct) =&gt; ValueTask.FromResult(ValidationInterceptorResult.Success())),
/// McpClientInterceptor.Create(
/// name: "pii-filter",
/// events: [InterceptorEvents.ToolsCall],
/// type: InterceptorType.Validation,
/// handler: (ctx, ct) =&gt; ValueTask.FromResult(ValidatePii(ctx))));
/// </code>
/// </example>
public static InterceptingMcpClient WithInterceptors(
this McpClient client,
params McpClientInterceptor[] interceptors)
{
Throw.IfNull(client);
return new InterceptingMcpClient(client, new InterceptingMcpClientOptions
{
Interceptors = interceptors ?? []
});
}
/// <summary>
/// Wraps an <see cref="McpClient"/> with interceptor chain execution using the specified interceptors and service provider.
/// </summary>
/// <param name="client">The <see cref="McpClient"/> to wrap.</param>
/// <param name="interceptors">The interceptors to register.</param>
/// <param name="services">The service provider for dependency injection in interceptors.</param>
/// <returns>An <see cref="InterceptingMcpClient"/> that executes interceptor chains for tool operations.</returns>
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// This overload allows you to provide a service provider for dependency injection support
/// in interceptors that need to resolve services.
/// </para>
/// </remarks>
public static InterceptingMcpClient WithInterceptors(
this McpClient client,
IEnumerable<McpClientInterceptor> interceptors,
IServiceProvider? services = null)
{
Throw.IfNull(client);
return new InterceptingMcpClient(client, new InterceptingMcpClientOptions
{
Interceptors = interceptors?.ToList() ?? [],
Services = services
});
}
}

View file

@ -0,0 +1,138 @@
using System.Text.Json.Nodes;
using ModelContextProtocol.Interceptors.Client;
namespace ModelContextProtocol.Interceptors;
/// <summary>
/// Configuration options for <see cref="InterceptingMcpClient"/>.
/// </summary>
/// <remarks>
/// <para>
/// Use these options to configure how the <see cref="InterceptingMcpClient"/> executes interceptor
/// chains for MCP tool operations. You can register interceptors, configure timeouts, and control
/// error handling behavior.
/// </para>
/// </remarks>
/// <example>
/// Creating options with interceptors:
/// <code>
/// var options = new InterceptingMcpClientOptions
/// {
/// Interceptors =
/// [
/// McpClientInterceptor.Create(
/// name: "logging",
/// events: [InterceptorEvents.ToolsCall],
/// type: InterceptorType.Observability,
/// handler: (ctx, ct) => { /* log */ })
/// ],
/// DefaultTimeoutMs = 5000,
/// ThrowOnValidationError = true
/// };
/// </code>
/// </example>
public sealed class InterceptingMcpClientOptions
{
/// <summary>
/// Gets or sets the collection of interceptors to execute for MCP operations.
/// </summary>
/// <remarks>
/// <para>
/// Interceptors are executed according to SEP-1763 ordering rules:
/// </para>
/// <list type="bullet">
/// <item><description><b>Sending (outgoing):</b> Mutations run sequentially by priority, then validations and observability run in parallel.</description></item>
/// <item><description><b>Receiving (incoming):</b> Validations and observability run in parallel, then mutations run sequentially by priority.</description></item>
/// </list>
/// <para>
/// Only interceptors whose <see cref="Interceptor.Events"/> match the current operation
/// (e.g., "tools/call") will be executed.
/// </para>
/// </remarks>
public IList<McpClientInterceptor> Interceptors { get; set; } = [];
/// <summary>
/// Gets or sets the service provider for dependency injection.
/// </summary>
/// <remarks>
/// If provided, this service provider will be passed to interceptors via the
/// <see cref="ClientInterceptorContext{TParams}.Services"/> property, allowing
/// interceptors to resolve dependencies.
/// </remarks>
public IServiceProvider? Services { get; set; }
/// <summary>
/// Gets or sets the default timeout in milliseconds for interceptor chain execution.
/// </summary>
/// <remarks>
/// <para>
/// If the interceptor chain takes longer than this timeout, execution will be aborted
/// and the chain result will have status <see cref="InterceptorChainStatus.Timeout"/>.
/// </para>
/// <para>
/// Set to <c>null</c> for no timeout (default). A reasonable production value might be
/// 5000-30000ms depending on interceptor complexity.
/// </para>
/// </remarks>
public int? DefaultTimeoutMs { get; set; }
/// <summary>
/// Gets or sets the default configuration passed to interceptors.
/// </summary>
/// <remarks>
/// <para>
/// This configuration dictionary is merged with any per-call configuration and passed
/// to interceptors. Keys should match interceptor names, and values should be JSON
/// objects containing interceptor-specific configuration.
/// </para>
/// </remarks>
/// <example>
/// Setting default interceptor configuration:
/// <code>
/// options.DefaultConfig = new Dictionary&lt;string, JsonNode&gt;
/// {
/// ["pii-filter"] = JsonNode.Parse("""{"sensitivity": "high", "regions": ["US", "EU"]}"""),
/// ["rate-limiter"] = JsonNode.Parse("""{"maxRequestsPerMinute": 100}""")
/// };
/// </code>
/// </example>
public IDictionary<string, JsonNode>? DefaultConfig { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to throw <see cref="McpInterceptorValidationException"/>
/// when a validation interceptor fails with error severity.
/// </summary>
/// <remarks>
/// <para>
/// When <c>true</c> (default), the <see cref="InterceptingMcpClient"/> will throw
/// <see cref="McpInterceptorValidationException"/> if any validation interceptor returns
/// <see cref="ValidationSeverity.Error"/>. The exception contains the full
/// <see cref="InterceptorChainResult"/> for inspection.
/// </para>
/// <para>
/// When <c>false</c>, validation errors will not throw exceptions. Instead, the operation
/// will proceed without calling the underlying MCP client, and the caller is responsible
/// for checking results. This mode is primarily useful for testing or scenarios where
/// you want to handle validation failures differently.
/// </para>
/// </remarks>
/// <value><c>true</c> to throw on validation errors; <c>false</c> to continue silently. Default is <c>true</c>.</value>
public bool ThrowOnValidationError { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether to execute interceptors for response/result payloads.
/// </summary>
/// <remarks>
/// <para>
/// When <c>true</c> (default), interceptors will be executed both when sending requests
/// (via <see cref="InterceptorChainExecutor.ExecuteForSendingAsync"/>) and when receiving
/// responses (via <see cref="InterceptorChainExecutor.ExecuteForReceivingAsync"/>).
/// </para>
/// <para>
/// When <c>false</c>, only request interception is performed. This can be useful when you
/// only need to validate/transform outgoing requests and want to minimize overhead on responses.
/// </para>
/// </remarks>
/// <value><c>true</c> to intercept responses; <c>false</c> to only intercept requests. Default is <c>true</c>.</value>
public bool InterceptResponses { get; set; } = true;
}

View file

@ -0,0 +1,397 @@
using System.Diagnostics;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.Interceptors.Client;
/// <summary>
/// Executes interceptor chains following the SEP-1763 execution model.
/// </summary>
/// <remarks>
/// <para>
/// The chain executor handles the ordering and execution of interceptors based on their type:
/// <list type="bullet">
/// <item><b>Mutations</b>: Executed sequentially by priority (lower first), alphabetically for ties</item>
/// <item><b>Validations</b>: Executed in parallel, errors block execution</item>
/// <item><b>Observability</b>: Fire-and-forget, executed in parallel, never block</item>
/// </list>
/// </para>
/// <para>
/// Execution order depends on data flow direction:
/// <list type="bullet">
/// <item><b>Sending</b>: Mutate → Validate &amp; Observe → Send</item>
/// <item><b>Receiving</b>: Receive → Validate &amp; Observe → Mutate</item>
/// </list>
/// </para>
/// </remarks>
public class InterceptorChainExecutor
{
private readonly IReadOnlyList<McpClientInterceptor> _interceptors;
private readonly IServiceProvider? _services;
/// <summary>
/// Initializes a new instance of the <see cref="InterceptorChainExecutor"/> class.
/// </summary>
/// <param name="interceptors">The interceptors to execute.</param>
/// <param name="services">Optional service provider for dependency injection.</param>
public InterceptorChainExecutor(IEnumerable<McpClientInterceptor> interceptors, IServiceProvider? services = null)
{
Throw.IfNull(interceptors);
_interceptors = interceptors.ToList();
_services = services;
}
/// <summary>
/// Executes the interceptor chain for outgoing data (sending across trust boundary).
/// </summary>
/// <param name="event">The event type being intercepted.</param>
/// <param name="payload">The payload to process.</param>
/// <param name="config">Optional per-interceptor configuration.</param>
/// <param name="timeoutMs">Optional timeout for the entire chain.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The chain execution result.</returns>
/// <remarks>
/// Execution order for sending: Mutate (sequential) → Validate &amp; Observe (parallel) → Return
/// </remarks>
public Task<InterceptorChainResult> ExecuteForSendingAsync(
string @event,
JsonNode? payload,
IDictionary<string, JsonNode>? config = null,
int? timeoutMs = null,
CancellationToken cancellationToken = default)
{
return ExecuteChainAsync(@event, InterceptorPhase.Request, payload, config, timeoutMs, isSending: true, cancellationToken);
}
/// <summary>
/// Executes the interceptor chain for incoming data (receiving from trust boundary).
/// </summary>
/// <param name="event">The event type being intercepted.</param>
/// <param name="payload">The payload to process.</param>
/// <param name="config">Optional per-interceptor configuration.</param>
/// <param name="timeoutMs">Optional timeout for the entire chain.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The chain execution result.</returns>
/// <remarks>
/// Execution order for receiving: Validate &amp; Observe (parallel) → Mutate (sequential) → Return
/// </remarks>
public Task<InterceptorChainResult> ExecuteForReceivingAsync(
string @event,
JsonNode? payload,
IDictionary<string, JsonNode>? config = null,
int? timeoutMs = null,
CancellationToken cancellationToken = default)
{
return ExecuteChainAsync(@event, InterceptorPhase.Response, payload, config, timeoutMs, isSending: false, cancellationToken);
}
private async Task<InterceptorChainResult> ExecuteChainAsync(
string @event,
InterceptorPhase phase,
JsonNode? payload,
IDictionary<string, JsonNode>? config,
int? timeoutMs,
bool isSending,
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
var result = new InterceptorChainResult
{
Event = @event,
Phase = phase,
Status = InterceptorChainStatus.Success
};
using var timeoutCts = timeoutMs.HasValue
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
: null;
if (timeoutCts is not null)
{
timeoutCts.CancelAfter(timeoutMs!.Value);
}
var effectiveCt = timeoutCts?.Token ?? cancellationToken;
try
{
// Get interceptors that handle this event and phase
var applicableInterceptors = GetApplicableInterceptors(@event, phase);
// Separate by type
var mutations = applicableInterceptors
.Where(i => i.ProtocolInterceptor.Type == InterceptorType.Mutation)
.OrderBy(i => GetPriority(i, phase))
.ThenBy(i => i.ProtocolInterceptor.Name)
.ToList();
var validations = applicableInterceptors
.Where(i => i.ProtocolInterceptor.Type == InterceptorType.Validation)
.ToList();
var observability = applicableInterceptors
.Where(i => i.ProtocolInterceptor.Type == InterceptorType.Observability)
.ToList();
JsonNode? currentPayload = payload;
if (isSending)
{
// Sending: Mutate → Validate & Observe
currentPayload = await ExecuteMutationsAsync(mutations, @event, phase, currentPayload, config, result, effectiveCt);
if (result.Status != InterceptorChainStatus.Success)
{
result.TotalDurationMs = stopwatch.ElapsedMilliseconds;
return result;
}
await ExecuteValidationsAndObservabilityAsync(validations, observability, @event, phase, currentPayload, config, result, effectiveCt);
}
else
{
// Receiving: Validate & Observe → Mutate
await ExecuteValidationsAndObservabilityAsync(validations, observability, @event, phase, currentPayload, config, result, effectiveCt);
if (result.Status != InterceptorChainStatus.Success)
{
result.TotalDurationMs = stopwatch.ElapsedMilliseconds;
return result;
}
currentPayload = await ExecuteMutationsAsync(mutations, @event, phase, currentPayload, config, result, effectiveCt);
}
result.FinalPayload = currentPayload;
}
catch (OperationCanceledException) when (timeoutCts?.IsCancellationRequested == true)
{
result.Status = InterceptorChainStatus.Timeout;
result.AbortedAt = new ChainAbortInfo
{
Interceptor = "chain",
Reason = "Chain execution timed out",
Type = "timeout"
};
}
result.TotalDurationMs = stopwatch.ElapsedMilliseconds;
return result;
}
private IEnumerable<McpClientInterceptor> GetApplicableInterceptors(string @event, InterceptorPhase phase)
{
return _interceptors.Where(i =>
{
var proto = i.ProtocolInterceptor;
// Check phase
if (proto.Phase != InterceptorPhase.Both && proto.Phase != phase)
{
return false;
}
// Check event
if (proto.Events.Count == 0)
{
return true; // No events specified means all events
}
return proto.Events.Any(e =>
e == @event ||
e == "*" ||
(e == "*/request" && phase == InterceptorPhase.Request) ||
(e == "*/response" && phase == InterceptorPhase.Response));
});
}
private static int GetPriority(McpClientInterceptor interceptor, InterceptorPhase phase)
{
var hint = interceptor.ProtocolInterceptor.PriorityHint;
if (hint is null)
{
return 0;
}
return hint.Value.GetPriorityForPhase(phase);
}
private async Task<JsonNode?> ExecuteMutationsAsync(
List<McpClientInterceptor> mutations,
string @event,
InterceptorPhase phase,
JsonNode? payload,
IDictionary<string, JsonNode>? config,
InterceptorChainResult chainResult,
CancellationToken cancellationToken)
{
var currentPayload = payload;
foreach (var interceptor in mutations)
{
cancellationToken.ThrowIfCancellationRequested();
var context = CreateContext(interceptor, @event, phase, currentPayload, config);
try
{
var result = await interceptor.InvokeAsync(context, cancellationToken);
chainResult.Results.Add(result);
if (result is MutationInterceptorResult mutationResult)
{
if (mutationResult.Modified)
{
currentPayload = mutationResult.Payload;
}
}
}
catch (Exception ex)
{
chainResult.Status = InterceptorChainStatus.MutationFailed;
chainResult.AbortedAt = new ChainAbortInfo
{
Interceptor = interceptor.ProtocolInterceptor.Name,
Reason = ex.Message,
Type = "mutation"
};
chainResult.FinalPayload = currentPayload; // Return last valid state
return currentPayload;
}
}
return currentPayload;
}
private async Task ExecuteValidationsAndObservabilityAsync(
List<McpClientInterceptor> validations,
List<McpClientInterceptor> observability,
string @event,
InterceptorPhase phase,
JsonNode? payload,
IDictionary<string, JsonNode>? config,
InterceptorChainResult chainResult,
CancellationToken cancellationToken)
{
// Execute validations and observability in parallel
var allTasks = new List<Task<(McpClientInterceptor Interceptor, InterceptorResult Result, bool IsObservability)>>();
foreach (var interceptor in validations)
{
allTasks.Add(ExecuteInterceptorAsync(interceptor, @event, phase, payload, config, isObservability: false, cancellationToken));
}
foreach (var interceptor in observability)
{
allTasks.Add(ExecuteInterceptorAsync(interceptor, @event, phase, payload, config, isObservability: true, cancellationToken));
}
var results = await Task.WhenAll(allTasks);
foreach (var (interceptor, result, isObservability) in results)
{
chainResult.Results.Add(result);
if (result is ValidationInterceptorResult validationResult)
{
// Update validation summary
if (validationResult.Messages is not null)
{
foreach (var msg in validationResult.Messages)
{
switch (msg.Severity)
{
case ValidationSeverity.Error:
chainResult.ValidationSummary.Errors++;
break;
case ValidationSeverity.Warn:
chainResult.ValidationSummary.Warnings++;
break;
case ValidationSeverity.Info:
chainResult.ValidationSummary.Infos++;
break;
}
}
}
// Check for blocking errors
if (!validationResult.Valid && validationResult.Severity == ValidationSeverity.Error)
{
chainResult.Status = InterceptorChainStatus.ValidationFailed;
chainResult.AbortedAt = new ChainAbortInfo
{
Interceptor = interceptor.ProtocolInterceptor.Name,
Reason = validationResult.Messages?.FirstOrDefault()?.Message ?? "Validation failed",
Type = "validation"
};
}
}
// Observability failures are logged but never block (fire-and-forget behavior)
}
}
private async Task<(McpClientInterceptor Interceptor, InterceptorResult Result, bool IsObservability)> ExecuteInterceptorAsync(
McpClientInterceptor interceptor,
string @event,
InterceptorPhase phase,
JsonNode? payload,
IDictionary<string, JsonNode>? config,
bool isObservability,
CancellationToken cancellationToken)
{
var context = CreateContext(interceptor, @event, phase, payload, config);
try
{
var result = await interceptor.InvokeAsync(context, cancellationToken);
return (interceptor, result, isObservability);
}
catch (Exception ex)
{
// For observability, failures are logged but don't affect the result
if (isObservability)
{
return (interceptor, new ObservabilityInterceptorResult
{
Interceptor = interceptor.ProtocolInterceptor.Name,
Phase = phase,
Observed = false,
Info = new JsonObject { ["error"] = ex.Message }
}, true);
}
// For validations, return an error result
return (interceptor, new ValidationInterceptorResult
{
Interceptor = interceptor.ProtocolInterceptor.Name,
Phase = phase,
Valid = false,
Severity = ValidationSeverity.Error,
Messages = [new() { Message = ex.Message, Severity = ValidationSeverity.Error }]
}, false);
}
}
private ClientInterceptorContext<InvokeInterceptorRequestParams> CreateContext(
McpClientInterceptor interceptor,
string @event,
InterceptorPhase phase,
JsonNode? payload,
IDictionary<string, JsonNode>? config)
{
return new ClientInterceptorContext<InvokeInterceptorRequestParams>
{
Services = _services,
MatchedInterceptor = interceptor,
Params = new InvokeInterceptorRequestParams
{
Name = interceptor.ProtocolInterceptor.Name,
Event = @event,
Phase = phase,
Payload = payload!,
Config = config?.TryGetValue(interceptor.ProtocolInterceptor.Name, out var interceptorConfig) == true
? interceptorConfig
: null
}
};
}
}

View file

@ -0,0 +1,27 @@
namespace ModelContextProtocol.Interceptors.Client;
/// <summary>
/// Contains filter delegates for client-side interceptor operations.
/// </summary>
/// <remarks>
/// <para>
/// Filters provide a middleware-like mechanism to wrap interceptor handler invocations,
/// allowing for cross-cutting concerns like logging, timing, or additional validation.
/// </para>
/// <para>
/// Filters are applied in the order they are added, with each filter wrapping the next
/// handler in the chain.
/// </para>
/// </remarks>
public class InterceptorClientFilters
{
/// <summary>
/// Gets the list of filters for the list interceptors handler.
/// </summary>
public List<Func<ListInterceptorsRequestParams?, Func<ListInterceptorsRequestParams?, CancellationToken, ValueTask<ListInterceptorsResult>>, CancellationToken, ValueTask<ListInterceptorsResult>>> ListInterceptorsFilters { get; } = [];
/// <summary>
/// Gets the list of filters for the invoke interceptor handler.
/// </summary>
public List<Func<InvokeInterceptorRequestParams, Func<InvokeInterceptorRequestParams, CancellationToken, ValueTask<InterceptorResult>>, CancellationToken, ValueTask<InterceptorResult>>> InvokeInterceptorFilters { get; } = [];
}

View file

@ -0,0 +1,27 @@
namespace ModelContextProtocol.Interceptors.Client;
/// <summary>
/// Contains handler delegates for client-side interceptor operations.
/// </summary>
/// <remarks>
/// <para>
/// This class stores the handler functions that are invoked when clients need to
/// list available interceptors or invoke specific interceptors.
/// </para>
/// <para>
/// Handlers can be configured through the McpClientInterceptorExtensions
/// extension methods in the <c>Microsoft.Extensions.DependencyInjection</c> namespace.
/// </para>
/// </remarks>
public class InterceptorClientHandlers
{
/// <summary>
/// Gets or sets the handler for listing available interceptors.
/// </summary>
public Func<ListInterceptorsRequestParams?, CancellationToken, ValueTask<ListInterceptorsResult>>? ListInterceptorsHandler { get; set; }
/// <summary>
/// Gets or sets the handler for invoking an interceptor.
/// </summary>
public Func<InvokeInterceptorRequestParams, CancellationToken, ValueTask<InterceptorResult>>? InvokeInterceptorHandler { get; set; }
}

View file

@ -0,0 +1,135 @@
using System.Reflection;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.Interceptors.Client;
/// <summary>
/// Represents an invocable interceptor used by Model Context Protocol clients.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="McpClientInterceptor"/> is an abstract base class that represents an MCP interceptor for use in the client
/// (as opposed to <see cref="Interceptor"/>, which provides the protocol representation of an interceptor).
/// Client interceptors are invoked when processing outgoing requests or incoming responses.
/// </para>
/// <para>
/// Most commonly, <see cref="McpClientInterceptor"/> instances are created using the static <see cref="M:McpClientInterceptor.Create"/> methods.
/// These methods enable creating an <see cref="McpClientInterceptor"/> for a method, specified via a <see cref="Delegate"/> or
/// <see cref="MethodInfo"/>.
/// </para>
/// <para>
/// By default, parameters are bound from the <see cref="InvokeInterceptorRequestParams"/>:
/// <list type="bullet">
/// <item>
/// <description>
/// <see cref="JsonNode"/> parameters named "payload" are bound to <see cref="InvokeInterceptorRequestParams.Payload"/>.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="InvokeInterceptorContext"/> parameters are bound to <see cref="InvokeInterceptorRequestParams.Context"/>.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="JsonNode"/> parameters named "config" are bound to <see cref="InvokeInterceptorRequestParams.Config"/>.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="CancellationToken"/> parameters are automatically bound to a <see cref="CancellationToken"/> provided by the caller.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="IServiceProvider"/> parameters are bound from the <see cref="ClientInterceptorContext{InvokeInterceptorRequestParams}"/> for this request.
/// </description>
/// </item>
/// </list>
/// </para>
/// <para>
/// Return values from a method should be an interceptor result type appropriate for the interceptor's type:
/// <list type="bullet">
/// <item><see cref="ValidationInterceptorResult"/> for validation interceptors</item>
/// <item><see cref="MutationInterceptorResult"/> for mutation interceptors</item>
/// <item><see cref="ObservabilityInterceptorResult"/> for observability interceptors</item>
/// </list>
/// </para>
/// </remarks>
public abstract class McpClientInterceptor
{
/// <summary>Initializes a new instance of the <see cref="McpClientInterceptor"/> class.</summary>
protected McpClientInterceptor()
{
}
/// <summary>Gets the protocol <see cref="Interceptor"/> type for this instance.</summary>
public abstract Interceptor ProtocolInterceptor { get; }
/// <summary>
/// Gets the metadata for this interceptor instance.
/// </summary>
/// <remarks>
/// Contains attributes from the associated MethodInfo and declaring class (if any),
/// with class-level attributes appearing before method-level attributes.
/// </remarks>
public abstract IReadOnlyList<object> Metadata { get; }
/// <summary>Invokes the <see cref="McpClientInterceptor"/>.</summary>
/// <param name="context">The context information for this interceptor invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The result from invoking the interceptor.</returns>
/// <exception cref="ArgumentNullException"><paramref name="context"/> is <see langword="null"/>.</exception>
public abstract ValueTask<InterceptorResult> InvokeAsync(
ClientInterceptorContext<InvokeInterceptorRequestParams> context,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates an <see cref="McpClientInterceptor"/> instance for a method, specified via a <see cref="Delegate"/> instance.
/// </summary>
/// <param name="method">The method to be represented via the created <see cref="McpClientInterceptor"/>.</param>
/// <param name="options">Optional options used in the creation of the <see cref="McpClientInterceptor"/> to control its behavior.</param>
/// <returns>The created <see cref="McpClientInterceptor"/> for invoking <paramref name="method"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="method"/> is <see langword="null"/>.</exception>
public static McpClientInterceptor Create(
Delegate method,
McpClientInterceptorCreateOptions? options = null) =>
ReflectionMcpClientInterceptor.Create(method, options);
/// <summary>
/// Creates an <see cref="McpClientInterceptor"/> instance for a method, specified via a <see cref="MethodInfo"/> instance.
/// </summary>
/// <param name="method">The method to be represented via the created <see cref="McpClientInterceptor"/>.</param>
/// <param name="target">The instance if <paramref name="method"/> is an instance method; otherwise, <see langword="null"/>.</param>
/// <param name="options">Optional options used in the creation of the <see cref="McpClientInterceptor"/> to control its behavior.</param>
/// <returns>The created <see cref="McpClientInterceptor"/> for invoking <paramref name="method"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="method"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="method"/> is an instance method but <paramref name="target"/> is <see langword="null"/>.</exception>
public static McpClientInterceptor Create(
MethodInfo method,
object? target = null,
McpClientInterceptorCreateOptions? options = null) =>
ReflectionMcpClientInterceptor.Create(method, target, options);
/// <summary>
/// Creates an <see cref="McpClientInterceptor"/> instance for a method, specified via an <see cref="MethodInfo"/> for
/// an instance method, along with a factory function to create the target object.
/// </summary>
/// <param name="method">The instance method to be represented via the created <see cref="McpClientInterceptor"/>.</param>
/// <param name="createTargetFunc">
/// Callback used on each invocation to create an instance of the type on which the instance method <paramref name="method"/>
/// will be invoked. If the returned instance is <see cref="IAsyncDisposable"/> or <see cref="IDisposable"/>, it will
/// be disposed of after the method completes its invocation.
/// </param>
/// <param name="options">Optional options used in the creation of the <see cref="McpClientInterceptor"/> to control its behavior.</param>
/// <returns>The created <see cref="McpClientInterceptor"/> for invoking <paramref name="method"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="method"/> or <paramref name="createTargetFunc"/> is <see langword="null"/>.</exception>
public static McpClientInterceptor Create(
MethodInfo method,
Func<ClientInterceptorContext<InvokeInterceptorRequestParams>, object> createTargetFunc,
McpClientInterceptorCreateOptions? options = null) =>
ReflectionMcpClientInterceptor.Create(method, createTargetFunc, options);
/// <inheritdoc />
public override string ToString() => ProtocolInterceptor.Name;
}

View file

@ -0,0 +1,63 @@
namespace ModelContextProtocol.Interceptors.Client;
/// <summary>
/// Attribute used to mark a method as an MCP client interceptor.
/// </summary>
/// <remarks>
/// <para>
/// When applied to a method, this attribute indicates that the method should be exposed as an
/// MCP interceptor that can validate, mutate, or observe messages on the client side.
/// </para>
/// <para>
/// Client interceptors are invoked when the client sends requests to a server or receives responses,
/// enabling validation and transformation at trust boundaries.
/// </para>
/// </remarks>
[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
public sealed class McpClientInterceptorAttribute : Attribute
{
/// <summary>
/// Gets or sets the name of the interceptor.
/// </summary>
/// <remarks>
/// If not specified, a name will be derived from the method name.
/// </remarks>
public string? Name { get; set; }
/// <summary>
/// Gets or sets the version of the interceptor.
/// </summary>
public string? Version { get; set; }
/// <summary>
/// Gets or sets the description of the interceptor.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Gets or sets the events this interceptor handles.
/// </summary>
/// <remarks>
/// Use constants from <see cref="InterceptorEvents"/> for event names.
/// This is a required property when using the attribute.
/// </remarks>
public string[] Events { get; set; } = [];
/// <summary>
/// Gets or sets the interceptor type.
/// </summary>
public InterceptorType Type { get; set; } = InterceptorType.Validation;
/// <summary>
/// Gets or sets the execution phase for this interceptor.
/// </summary>
public InterceptorPhase Phase { get; set; } = InterceptorPhase.Request;
/// <summary>
/// Gets or sets the priority hint for mutation interceptor ordering.
/// </summary>
/// <remarks>
/// Lower values execute first. Default is 0 if not specified.
/// </remarks>
public int PriorityHint { get; set; }
}

View file

@ -0,0 +1,78 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.Interceptors.Client;
/// <summary>
/// Options for creating an <see cref="McpClientInterceptor"/>.
/// </summary>
public sealed class McpClientInterceptorCreateOptions
{
/// <summary>
/// Gets or sets the name of the interceptor.
/// </summary>
/// <remarks>
/// If not provided, the name will be derived from the method name.
/// </remarks>
public string? Name { get; set; }
/// <summary>
/// Gets or sets the version of the interceptor.
/// </summary>
public string? Version { get; set; }
/// <summary>
/// Gets or sets the description of the interceptor.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Gets or sets the events this interceptor handles.
/// </summary>
public string[]? Events { get; set; }
/// <summary>
/// Gets or sets the interceptor type.
/// </summary>
public InterceptorType? Type { get; set; }
/// <summary>
/// Gets or sets the execution phase.
/// </summary>
public InterceptorPhase? Phase { get; set; }
/// <summary>
/// Gets or sets the priority hint for mutation ordering.
/// </summary>
public int? PriorityHint { get; set; }
/// <summary>
/// Gets or sets the JSON schema for the interceptor's configuration.
/// </summary>
public JsonElement? ConfigSchema { get; set; }
/// <summary>
/// Gets or sets protocol-level metadata.
/// </summary>
public JsonObject? Meta { get; set; }
/// <summary>
/// Gets or sets the service provider for dependency injection.
/// </summary>
public IServiceProvider? Services { get; set; }
/// <summary>
/// Gets or sets the JSON serializer options.
/// </summary>
public JsonSerializerOptions? SerializerOptions { get; set; }
/// <summary>
/// Gets or sets the metadata for the interceptor.
/// </summary>
public IReadOnlyList<object>? Metadata { get; set; }
/// <summary>
/// Creates a shallow copy of this options instance.
/// </summary>
public McpClientInterceptorCreateOptions Clone() => (McpClientInterceptorCreateOptions)MemberwiseClone();
}

View file

@ -0,0 +1,184 @@
using ModelContextProtocol.Client;
using ModelContextProtocol.Interceptors;
using ModelContextProtocol.Interceptors.Client;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;
namespace Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Provides extension methods for configuring MCP client interceptors.
/// </summary>
public static class McpClientInterceptorExtensions
{
private const string WithInterceptorsRequiresUnreferencedCodeMessage =
$"The non-generic {nameof(WithInterceptors)} and {nameof(WithInterceptorsFromAssembly)} methods require dynamic lookup of method metadata" +
$"and might not work in Native AOT. Use the generic {nameof(WithInterceptors)} method instead.";
/// <summary>
/// Creates <see cref="McpClientInterceptor"/> instances from a type.
/// </summary>
/// <typeparam name="TInterceptorType">The interceptor type.</typeparam>
/// <param name="services">Optional service provider for dependency injection.</param>
/// <param name="serializerOptions">The serializer options governing interceptor parameter marshalling.</param>
/// <returns>A collection of <see cref="McpClientInterceptor"/> instances.</returns>
/// <remarks>
/// This method discovers all instance and static methods (public and non-public) on the specified <typeparamref name="TInterceptorType"/>
/// type, where the methods are attributed as <see cref="McpClientInterceptorAttribute"/>, and creates an <see cref="McpClientInterceptor"/>
/// instance for each.
/// </remarks>
public static IEnumerable<McpClientInterceptor> WithInterceptors<[DynamicallyAccessedMembers(
DynamicallyAccessedMemberTypes.PublicMethods |
DynamicallyAccessedMemberTypes.NonPublicMethods |
DynamicallyAccessedMemberTypes.PublicConstructors)] TInterceptorType>(
IServiceProvider? services = null,
JsonSerializerOptions? serializerOptions = null)
{
foreach (var interceptorMethod in typeof(TInterceptorType).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
{
if (interceptorMethod.GetCustomAttribute<McpClientInterceptorAttribute>() is not null)
{
yield return interceptorMethod.IsStatic
? McpClientInterceptor.Create(interceptorMethod, options: new() { Services = services, SerializerOptions = serializerOptions })
: McpClientInterceptor.Create(interceptorMethod, ctx => CreateTarget(ctx.Services, typeof(TInterceptorType)), new() { Services = services, SerializerOptions = serializerOptions });
}
}
}
/// <summary>
/// Creates <see cref="McpClientInterceptor"/> instances from a target instance.
/// </summary>
/// <typeparam name="TInterceptorType">The interceptor type.</typeparam>
/// <param name="target">The target instance from which the interceptors should be sourced.</param>
/// <param name="serializerOptions">The serializer options governing interceptor parameter marshalling.</param>
/// <returns>A collection of <see cref="McpClientInterceptor"/> instances.</returns>
/// <exception cref="ArgumentNullException"><paramref name="target"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// This method discovers all methods (public and non-public) on the specified <typeparamref name="TInterceptorType"/>
/// type, where the methods are attributed as <see cref="McpClientInterceptorAttribute"/>, and creates an <see cref="McpClientInterceptor"/>
/// instance for each, using <paramref name="target"/> as the associated instance for instance methods.
/// </para>
/// <para>
/// If <typeparamref name="TInterceptorType"/> is itself an <see cref="IEnumerable{T}"/> of <see cref="McpClientInterceptor"/>,
/// this method returns those interceptors directly without scanning for methods on <typeparamref name="TInterceptorType"/>.
/// </para>
/// </remarks>
public static IEnumerable<McpClientInterceptor> WithInterceptors<[DynamicallyAccessedMembers(
DynamicallyAccessedMemberTypes.PublicMethods |
DynamicallyAccessedMemberTypes.NonPublicMethods)] TInterceptorType>(
TInterceptorType target,
JsonSerializerOptions? serializerOptions = null)
{
Throw.IfNull(target);
if (target is IEnumerable<McpClientInterceptor> interceptors)
{
return interceptors;
}
return GetInterceptorsFromTarget(target, serializerOptions);
}
private static IEnumerable<McpClientInterceptor> GetInterceptorsFromTarget<[DynamicallyAccessedMembers(
DynamicallyAccessedMemberTypes.PublicMethods |
DynamicallyAccessedMemberTypes.NonPublicMethods)] TInterceptorType>(
TInterceptorType target,
JsonSerializerOptions? serializerOptions)
{
foreach (var interceptorMethod in typeof(TInterceptorType).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
{
if (interceptorMethod.GetCustomAttribute<McpClientInterceptorAttribute>() is not null)
{
yield return McpClientInterceptor.Create(
interceptorMethod,
interceptorMethod.IsStatic ? null : target,
new() { SerializerOptions = serializerOptions });
}
}
}
/// <summary>
/// Creates <see cref="McpClientInterceptor"/> instances from types.
/// </summary>
/// <param name="interceptorTypes">Types with <see cref="McpClientInterceptorAttribute"/>-attributed methods to add as interceptors.</param>
/// <param name="services">Optional service provider for dependency injection.</param>
/// <param name="serializerOptions">The serializer options governing interceptor parameter marshalling.</param>
/// <returns>A collection of <see cref="McpClientInterceptor"/> instances.</returns>
/// <exception cref="ArgumentNullException"><paramref name="interceptorTypes"/> is <see langword="null"/>.</exception>
/// <remarks>
/// This method discovers all instance and static methods (public and non-public) on the specified <paramref name="interceptorTypes"/>
/// types, where the methods are attributed as <see cref="McpClientInterceptorAttribute"/>, and creates an <see cref="McpClientInterceptor"/>
/// instance for each.
/// </remarks>
[RequiresUnreferencedCode(WithInterceptorsRequiresUnreferencedCodeMessage)]
public static IEnumerable<McpClientInterceptor> WithInterceptors(
IEnumerable<Type> interceptorTypes,
IServiceProvider? services = null,
JsonSerializerOptions? serializerOptions = null)
{
Throw.IfNull(interceptorTypes);
foreach (var interceptorType in interceptorTypes)
{
if (interceptorType is null) continue;
foreach (var interceptorMethod in interceptorType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
{
if (interceptorMethod.GetCustomAttribute<McpClientInterceptorAttribute>() is not null)
{
yield return interceptorMethod.IsStatic
? McpClientInterceptor.Create(interceptorMethod, options: new() { Services = services, SerializerOptions = serializerOptions })
: McpClientInterceptor.Create(interceptorMethod, ctx => CreateTarget(ctx.Services, interceptorType), new() { Services = services, SerializerOptions = serializerOptions });
}
}
}
}
/// <summary>
/// Creates <see cref="McpClientInterceptor"/> instances from types marked with <see cref="McpClientInterceptorTypeAttribute"/> in an assembly.
/// </summary>
/// <param name="interceptorAssembly">The assembly to load the types from. If <see langword="null"/>, the calling assembly is used.</param>
/// <param name="services">Optional service provider for dependency injection.</param>
/// <param name="serializerOptions">The serializer options governing interceptor parameter marshalling.</param>
/// <returns>A collection of <see cref="McpClientInterceptor"/> instances.</returns>
/// <remarks>
/// <para>
/// This method scans the specified assembly (or the calling assembly if none is provided) for classes
/// marked with the <see cref="McpClientInterceptorTypeAttribute"/>. It then discovers all methods within those
/// classes that are marked with the <see cref="McpClientInterceptorAttribute"/> and creates <see cref="McpClientInterceptor"/>s.
/// </para>
/// <para>
/// Note that this method performs reflection at runtime and might not work in Native AOT scenarios. For
/// Native AOT compatibility, consider using the generic <see cref="M:WithInterceptors"/> method instead.
/// </para>
/// </remarks>
[RequiresUnreferencedCode(WithInterceptorsRequiresUnreferencedCodeMessage)]
public static IEnumerable<McpClientInterceptor> WithInterceptorsFromAssembly(
Assembly? interceptorAssembly = null,
IServiceProvider? services = null,
JsonSerializerOptions? serializerOptions = null)
{
interceptorAssembly ??= Assembly.GetCallingAssembly();
var interceptorTypes = from t in interceptorAssembly.GetTypes()
where t.GetCustomAttribute<McpClientInterceptorTypeAttribute>() is not null
select t;
return WithInterceptors(interceptorTypes, services, serializerOptions);
}
/// <summary>Creates an instance of the target object.</summary>
private static object CreateTarget(
IServiceProvider? services,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type)
{
if (services is not null)
{
return ActivatorUtilities.CreateInstance(services, type);
}
return Activator.CreateInstance(type)!;
}
}

View file

@ -0,0 +1,37 @@
namespace ModelContextProtocol.Interceptors.Client;
/// <summary>
/// Attribute applied to a class to indicate that it contains MCP client interceptor methods.
/// </summary>
/// <remarks>
/// <para>
/// Classes marked with this attribute will be scanned for methods marked with
/// <see cref="McpClientInterceptorAttribute"/> when using assembly-based interceptor discovery.
/// </para>
/// <para>
/// This attribute is used by the <c>WithInterceptorsFromAssembly</c> extension method
/// in <c>Microsoft.Extensions.DependencyInjection.McpClientInterceptorExtensions</c>
/// to locate interceptor types in an assembly.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// [McpClientInterceptorType]
/// public class MyClientInterceptors
/// {
/// [McpClientInterceptor(
/// Name = "request-validator",
/// Events = new[] { InterceptorEvents.ToolsCall },
/// Phase = InterceptorPhase.Request)]
/// public ValidationInterceptorResult ValidateRequest(JsonNode? payload)
/// {
/// // Validation logic
/// return new ValidationInterceptorResult { Valid = true };
/// }
/// }
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class McpClientInterceptorTypeAttribute : Attribute
{
}

View file

@ -0,0 +1,355 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization.Metadata;
using ModelContextProtocol.Interceptors.Protocol.Llm;
using ModelContextProtocol.Protocol;
namespace ModelContextProtocol.Interceptors;
/// <summary>
/// Provides conversion utilities between typed MCP request/response objects and JsonNode for interceptor chains.
/// </summary>
/// <remarks>
/// This internal helper class enables the <see cref="InterceptingMcpClient"/> to convert typed MCP protocol
/// objects to <see cref="JsonNode"/> for interceptor processing and back to typed objects after mutations.
/// </remarks>
internal static class PayloadConverter
{
private static JsonSerializerOptions DefaultOptions => McpJsonUtilities.DefaultOptions;
#region Generic Conversion
/// <summary>
/// Converts a typed value to a <see cref="JsonNode"/>.
/// </summary>
/// <typeparam name="T">The type of value to convert.</typeparam>
/// <param name="value">The value to convert.</param>
/// <param name="options">Optional serializer options. Defaults to MCP options if not provided.</param>
/// <returns>The JSON representation of the value, or null if the value is null.</returns>
public static JsonNode? ToJsonNode<T>(T? value, JsonSerializerOptions? options = null)
{
if (value is null)
{
return null;
}
return JsonSerializer.SerializeToNode(value, options ?? DefaultOptions);
}
/// <summary>
/// Converts a <see cref="JsonNode"/> to a typed value.
/// </summary>
/// <typeparam name="T">The target type.</typeparam>
/// <param name="node">The JSON node to convert.</param>
/// <param name="options">Optional serializer options. Defaults to MCP options if not provided.</param>
/// <returns>The deserialized value, or default if the node is null.</returns>
public static T? FromJsonNode<T>(JsonNode? node, JsonSerializerOptions? options = null)
{
if (node is null)
{
return default;
}
return node.Deserialize<T>(options ?? DefaultOptions);
}
/// <summary>
/// Converts a typed value to a <see cref="JsonNode"/> using a specific type info.
/// </summary>
/// <typeparam name="T">The type of value to convert.</typeparam>
/// <param name="value">The value to convert.</param>
/// <param name="typeInfo">The JSON type info for serialization.</param>
/// <returns>The JSON representation of the value, or null if the value is null.</returns>
public static JsonNode? ToJsonNode<T>(T? value, JsonTypeInfo<T> typeInfo)
{
if (value is null)
{
return null;
}
return JsonSerializer.SerializeToNode(value, typeInfo);
}
/// <summary>
/// Converts a <see cref="JsonNode"/> to a typed value using a specific type info.
/// </summary>
/// <typeparam name="T">The target type.</typeparam>
/// <param name="node">The JSON node to convert.</param>
/// <param name="typeInfo">The JSON type info for deserialization.</param>
/// <returns>The deserialized value, or default if the node is null.</returns>
public static T? FromJsonNode<T>(JsonNode? node, JsonTypeInfo<T> typeInfo)
{
if (node is null)
{
return default;
}
return node.Deserialize(typeInfo);
}
#endregion
#region CallTool Request Conversion
/// <summary>
/// Creates a <see cref="JsonNode"/> payload for a tool call request.
/// </summary>
/// <param name="toolName">The name of the tool to call.</param>
/// <param name="arguments">Optional arguments dictionary.</param>
/// <returns>A JSON object representing the tool call request.</returns>
public static JsonNode ToCallToolRequestPayload(string toolName, IReadOnlyDictionary<string, object?>? arguments)
{
var obj = new JsonObject
{
["name"] = toolName
};
if (arguments is not null && arguments.Count > 0)
{
var argsObj = new JsonObject();
foreach (var kvp in arguments)
{
argsObj[kvp.Key] = kvp.Value switch
{
null => null,
JsonNode jn => jn.DeepClone(),
JsonElement je => JsonNode.Parse(je.GetRawText()),
_ => JsonSerializer.SerializeToNode(kvp.Value, DefaultOptions)
};
}
obj["arguments"] = argsObj;
}
return obj;
}
/// <summary>
/// Extracts tool name and arguments from a tool call request payload.
/// </summary>
/// <param name="node">The JSON payload representing a tool call request.</param>
/// <returns>A tuple containing the tool name and arguments dictionary.</returns>
/// <exception cref="InvalidOperationException">Thrown when the payload is invalid or missing required fields.</exception>
public static (string ToolName, Dictionary<string, JsonElement>? Arguments) FromCallToolRequestPayload(JsonNode? node)
{
if (node is not JsonObject obj)
{
throw new InvalidOperationException("CallTool request payload must be a JSON object.");
}
var name = obj["name"]?.GetValue<string>()
?? throw new InvalidOperationException("CallTool request payload must have a 'name' property.");
Dictionary<string, JsonElement>? arguments = null;
if (obj["arguments"] is JsonObject argsObj)
{
arguments = new Dictionary<string, JsonElement>();
foreach (var kvp in argsObj)
{
// Handle null values by creating a proper JsonElement representing null
// Using default(JsonElement) creates an undefined element that fails serialization
arguments[kvp.Key] = kvp.Value is not null
? JsonSerializer.Deserialize<JsonElement>(kvp.Value.ToJsonString())
: JsonSerializer.Deserialize<JsonElement>("null");
}
}
return (name, arguments);
}
/// <summary>
/// Converts a <see cref="CallToolRequestParams"/> to a <see cref="JsonNode"/>.
/// </summary>
/// <param name="requestParams">The request parameters.</param>
/// <returns>A JSON representation of the request.</returns>
public static JsonNode? ToCallToolRequestParamsPayload(CallToolRequestParams? requestParams)
{
if (requestParams is null)
{
return null;
}
return JsonSerializer.SerializeToNode(requestParams, DefaultOptions);
}
/// <summary>
/// Converts a <see cref="JsonNode"/> to <see cref="CallToolRequestParams"/>.
/// </summary>
/// <param name="node">The JSON node.</param>
/// <returns>The deserialized request parameters.</returns>
public static CallToolRequestParams? FromCallToolRequestParamsPayload(JsonNode? node)
{
if (node is null)
{
return null;
}
return node.Deserialize<CallToolRequestParams>(DefaultOptions);
}
#endregion
#region CallTool Result Conversion
/// <summary>
/// Converts a <see cref="CallToolResult"/> to a <see cref="JsonNode"/>.
/// </summary>
/// <param name="result">The tool call result.</param>
/// <returns>A JSON representation of the result.</returns>
public static JsonNode? ToCallToolResultPayload(CallToolResult? result)
{
if (result is null)
{
return null;
}
return JsonSerializer.SerializeToNode(result, DefaultOptions);
}
/// <summary>
/// Converts a <see cref="JsonNode"/> to a <see cref="CallToolResult"/>.
/// </summary>
/// <param name="node">The JSON node.</param>
/// <returns>The deserialized result.</returns>
public static CallToolResult? FromCallToolResultPayload(JsonNode? node)
{
if (node is null)
{
return null;
}
return node.Deserialize<CallToolResult>(DefaultOptions);
}
#endregion
#region ListTools Conversion
/// <summary>
/// Creates a <see cref="JsonNode"/> payload for a list tools request.
/// </summary>
/// <param name="cursor">Optional pagination cursor.</param>
/// <returns>A JSON object representing the list tools request.</returns>
public static JsonNode? ToListToolsRequestPayload(string? cursor)
{
if (cursor is null)
{
return new JsonObject();
}
return new JsonObject
{
["cursor"] = cursor
};
}
/// <summary>
/// Extracts the cursor from a list tools request payload.
/// </summary>
/// <param name="node">The JSON payload.</param>
/// <returns>The cursor value, or null if not present.</returns>
public static string? FromListToolsRequestPayload(JsonNode? node)
{
if (node is not JsonObject obj)
{
return null;
}
return obj["cursor"]?.GetValue<string>();
}
/// <summary>
/// Converts a <see cref="ListToolsResult"/> to a <see cref="JsonNode"/>.
/// </summary>
/// <param name="result">The list tools result.</param>
/// <returns>A JSON representation of the result.</returns>
public static JsonNode? ToListToolsResultPayload(ListToolsResult? result)
{
if (result is null)
{
return null;
}
return JsonSerializer.SerializeToNode(result, DefaultOptions);
}
/// <summary>
/// Converts a <see cref="JsonNode"/> to a <see cref="ListToolsResult"/>.
/// </summary>
/// <param name="node">The JSON node.</param>
/// <returns>The deserialized result.</returns>
public static ListToolsResult? FromListToolsResultPayload(JsonNode? node)
{
if (node is null)
{
return null;
}
return node.Deserialize<ListToolsResult>(DefaultOptions);
}
#endregion
#region LLM Completion Conversion
/// <summary>
/// Converts an <see cref="LlmCompletionRequest"/> to a <see cref="JsonNode"/>.
/// </summary>
/// <param name="request">The LLM completion request.</param>
/// <returns>A JSON representation of the request.</returns>
public static JsonNode? ToLlmCompletionRequestPayload(LlmCompletionRequest? request)
{
if (request is null)
{
return null;
}
return JsonSerializer.SerializeToNode(request, DefaultOptions);
}
/// <summary>
/// Converts a <see cref="JsonNode"/> to an <see cref="LlmCompletionRequest"/>.
/// </summary>
/// <param name="node">The JSON node.</param>
/// <returns>The deserialized request.</returns>
public static LlmCompletionRequest? FromLlmCompletionRequestPayload(JsonNode? node)
{
if (node is null)
{
return null;
}
return node.Deserialize<LlmCompletionRequest>(DefaultOptions);
}
/// <summary>
/// Converts an <see cref="LlmCompletionResponse"/> to a <see cref="JsonNode"/>.
/// </summary>
/// <param name="response">The LLM completion response.</param>
/// <returns>A JSON representation of the response.</returns>
public static JsonNode? ToLlmCompletionResponsePayload(LlmCompletionResponse? response)
{
if (response is null)
{
return null;
}
return JsonSerializer.SerializeToNode(response, DefaultOptions);
}
/// <summary>
/// Converts a <see cref="JsonNode"/> to an <see cref="LlmCompletionResponse"/>.
/// </summary>
/// <param name="node">The JSON node.</param>
/// <returns>The deserialized response.</returns>
public static LlmCompletionResponse? FromLlmCompletionResponsePayload(JsonNode? node)
{
if (node is null)
{
return null;
}
return node.Deserialize<LlmCompletionResponse>(DefaultOptions);
}
#endregion
}

View file

@ -0,0 +1,554 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using System.ComponentModel;
using System.Diagnostics;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace ModelContextProtocol.Interceptors.Client;
/// <summary>Provides an <see cref="McpClientInterceptor"/> that's implemented via reflection.</summary>
internal sealed partial class ReflectionMcpClientInterceptor : McpClientInterceptor
{
private readonly MethodInfo _method;
private readonly object? _target;
private readonly Func<ClientInterceptorContext<InvokeInterceptorRequestParams>, object>? _createTargetFunc;
private readonly IReadOnlyList<object> _metadata;
private readonly JsonSerializerOptions _serializerOptions;
/// <summary>
/// Creates an <see cref="McpClientInterceptor"/> instance for a method, specified via a <see cref="Delegate"/> instance.
/// </summary>
public static new ReflectionMcpClientInterceptor Create(
Delegate method,
McpClientInterceptorCreateOptions? options)
{
Throw.IfNull(method);
options = DeriveOptions(method.Method, options);
return new ReflectionMcpClientInterceptor(method.Method, method.Target, null, options);
}
/// <summary>
/// Creates an <see cref="McpClientInterceptor"/> instance for a method, specified via a <see cref="MethodInfo"/> instance.
/// </summary>
public static new ReflectionMcpClientInterceptor Create(
MethodInfo method,
object? target,
McpClientInterceptorCreateOptions? options)
{
Throw.IfNull(method);
options = DeriveOptions(method, options);
return new ReflectionMcpClientInterceptor(method, target, null, options);
}
/// <summary>
/// Creates an <see cref="McpClientInterceptor"/> instance for a method, specified via a <see cref="MethodInfo"/> instance.
/// </summary>
public static new ReflectionMcpClientInterceptor Create(
MethodInfo method,
Func<ClientInterceptorContext<InvokeInterceptorRequestParams>, object> createTargetFunc,
McpClientInterceptorCreateOptions? options)
{
Throw.IfNull(method);
Throw.IfNull(createTargetFunc);
options = DeriveOptions(method, options);
return new ReflectionMcpClientInterceptor(method, null, createTargetFunc, options);
}
private static McpClientInterceptorCreateOptions DeriveOptions(MethodInfo method, McpClientInterceptorCreateOptions? options)
{
McpClientInterceptorCreateOptions newOptions = options?.Clone() ?? new();
if (method.GetCustomAttribute<McpClientInterceptorAttribute>() is { } interceptorAttr)
{
newOptions.Name ??= interceptorAttr.Name;
newOptions.Version ??= interceptorAttr.Version;
newOptions.Description ??= interceptorAttr.Description;
newOptions.Events ??= interceptorAttr.Events.Length > 0 ? interceptorAttr.Events : null;
newOptions.Type ??= interceptorAttr.Type;
newOptions.Phase ??= interceptorAttr.Phase;
if (interceptorAttr.PriorityHint != 0)
{
newOptions.PriorityHint ??= interceptorAttr.PriorityHint;
}
}
if (method.GetCustomAttribute<DescriptionAttribute>() is { } descAttr)
{
newOptions.Description ??= descAttr.Description;
}
// Set metadata if not already provided
newOptions.Metadata ??= CreateMetadata(method);
return newOptions;
}
/// <summary>Initializes a new instance of the <see cref="ReflectionMcpClientInterceptor"/> class.</summary>
private ReflectionMcpClientInterceptor(
MethodInfo method,
object? target,
Func<ClientInterceptorContext<InvokeInterceptorRequestParams>, object>? createTargetFunc,
McpClientInterceptorCreateOptions? options)
{
_method = method;
_target = target;
_createTargetFunc = createTargetFunc;
_serializerOptions = options?.SerializerOptions ?? McpJsonUtilities.DefaultOptions;
_metadata = options?.Metadata ?? [];
string name = options?.Name ?? DeriveName(method);
ValidateInterceptorName(name);
ProtocolInterceptor = new Interceptor
{
Name = name,
Version = options?.Version,
Description = options?.Description,
Events = options?.Events?.ToList() ?? [],
Type = options?.Type ?? InterceptorType.Validation,
Phase = options?.Phase ?? InterceptorPhase.Request,
PriorityHint = options?.PriorityHint,
ConfigSchema = options?.ConfigSchema,
Meta = options?.Meta,
};
}
/// <inheritdoc />
public override Interceptor ProtocolInterceptor { get; }
/// <inheritdoc />
public override IReadOnlyList<object> Metadata => _metadata;
/// <inheritdoc />
public override async ValueTask<InterceptorResult> InvokeAsync(
ClientInterceptorContext<InvokeInterceptorRequestParams> context,
CancellationToken cancellationToken = default)
{
Throw.IfNull(context);
cancellationToken.ThrowIfCancellationRequested();
var stopwatch = Stopwatch.StartNew();
try
{
// Resolve target instance
object? targetInstance = _target ?? _createTargetFunc?.Invoke(context);
try
{
// Bind parameters
object?[] args = BindParameters(context, cancellationToken);
// Invoke the method
object? result = _method.Invoke(targetInstance, args);
// Handle async methods
result = await HandleAsyncResult(result).ConfigureAwait(false);
// Convert result to appropriate InterceptorResult
return ConvertToResult(result, stopwatch.ElapsedMilliseconds, context.Params?.Phase ?? ProtocolInterceptor.Phase);
}
finally
{
// Dispose target if needed
if (targetInstance != _target)
{
if (targetInstance is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync().ConfigureAwait(false);
}
else if (targetInstance is IDisposable disposable)
{
disposable.Dispose();
}
}
}
}
catch (TargetInvocationException ex) when (ex.InnerException is not null)
{
return CreateErrorResult(ex.InnerException.Message, stopwatch.ElapsedMilliseconds, context.Params?.Phase ?? ProtocolInterceptor.Phase);
}
catch (Exception ex)
{
return CreateErrorResult(ex.Message, stopwatch.ElapsedMilliseconds, context.Params?.Phase ?? ProtocolInterceptor.Phase);
}
}
private InterceptorResult CreateErrorResult(string message, long durationMs, InterceptorPhase phase)
{
return ProtocolInterceptor.Type switch
{
InterceptorType.Mutation => new MutationInterceptorResult
{
Interceptor = ProtocolInterceptor.Name,
Phase = phase,
DurationMs = durationMs,
Modified = false,
Info = new JsonObject { ["error"] = message }
},
InterceptorType.Observability => new ObservabilityInterceptorResult
{
Interceptor = ProtocolInterceptor.Name,
Phase = phase,
DurationMs = durationMs,
Observed = false,
Info = new JsonObject { ["error"] = message }
},
_ => new ValidationInterceptorResult
{
Interceptor = ProtocolInterceptor.Name,
Phase = phase,
DurationMs = durationMs,
Valid = false,
Severity = ValidationSeverity.Error,
Messages = [new() { Message = message, Severity = ValidationSeverity.Error }]
}
};
}
private object?[] BindParameters(ClientInterceptorContext<InvokeInterceptorRequestParams> context, CancellationToken cancellationToken)
{
var parameters = _method.GetParameters();
var args = new object?[parameters.Length];
for (int i = 0; i < parameters.Length; i++)
{
var param = parameters[i];
args[i] = BindParameter(param, context, cancellationToken);
}
return args;
}
private object? BindParameter(ParameterInfo param, ClientInterceptorContext<InvokeInterceptorRequestParams> context, CancellationToken cancellationToken)
{
var paramType = param.ParameterType;
var paramName = param.Name?.ToLowerInvariant();
// Bind CancellationToken
if (paramType == typeof(CancellationToken))
{
return cancellationToken;
}
// Bind IServiceProvider
if (paramType == typeof(IServiceProvider))
{
return context.Services;
}
// Bind McpClient
if (typeof(McpClient).IsAssignableFrom(paramType))
{
return context.Client;
}
// Bind payload
if (paramType == typeof(JsonNode) && paramName is "payload")
{
return context.Params?.Payload;
}
// Bind config
if (paramType == typeof(JsonNode) && paramName is "config")
{
return context.Params?.Config;
}
// Bind context
if (paramType == typeof(InvokeInterceptorContext))
{
return context.Params?.Context;
}
// Bind event
if (paramType == typeof(string) && paramName is "event")
{
return context.Params?.Event;
}
// Bind phase
if (paramType == typeof(InterceptorPhase) && paramName is "phase")
{
return context.Params?.Phase ?? ProtocolInterceptor.Phase;
}
// Try to resolve from DI
if (context.Services is not null)
{
var service = context.Services.GetService(paramType);
if (service is not null)
{
return service;
}
}
// Use default value if available
if (param.HasDefaultValue)
{
return param.DefaultValue;
}
return null;
}
private static async ValueTask<object?> HandleAsyncResult(object? result)
{
if (result is null)
{
return null;
}
// Handle Task
if (result is Task task)
{
await task.ConfigureAwait(false);
return GetTaskResult(task);
}
// Handle ValueTask
if (result is ValueTask valueTask)
{
await valueTask.ConfigureAwait(false);
return null;
}
// Handle ValueTask<T> for various result types
if (result is ValueTask<ValidationInterceptorResult> valueTaskValidation)
{
return await valueTaskValidation.ConfigureAwait(false);
}
if (result is ValueTask<MutationInterceptorResult> valueTaskMutation)
{
return await valueTaskMutation.ConfigureAwait(false);
}
if (result is ValueTask<ObservabilityInterceptorResult> valueTaskObservability)
{
return await valueTaskObservability.ConfigureAwait(false);
}
if (result is ValueTask<InterceptorResult> valueTaskResult)
{
return await valueTaskResult.ConfigureAwait(false);
}
if (result is ValueTask<bool> valueTaskBool)
{
return await valueTaskBool.ConfigureAwait(false);
}
if (result is ValueTask<JsonNode?> valueTaskPayload)
{
return await valueTaskPayload.ConfigureAwait(false);
}
return result;
}
private static object? GetTaskResult(Task task)
{
if (task is Task<ValidationInterceptorResult> taskValidation)
{
return taskValidation.Result;
}
if (task is Task<MutationInterceptorResult> taskMutation)
{
return taskMutation.Result;
}
if (task is Task<ObservabilityInterceptorResult> taskObservability)
{
return taskObservability.Result;
}
if (task is Task<InterceptorResult> taskResult)
{
return taskResult.Result;
}
if (task is Task<bool> taskBool)
{
return taskBool.Result;
}
if (task is Task<JsonNode?> taskPayload)
{
return taskPayload.Result;
}
return null;
}
private InterceptorResult ConvertToResult(object? result, long durationMs, InterceptorPhase phase)
{
// Already an InterceptorResult
if (result is InterceptorResult interceptorResult)
{
interceptorResult.Interceptor ??= ProtocolInterceptor.Name;
interceptorResult.DurationMs = durationMs;
if (interceptorResult.Phase == default)
{
interceptorResult.Phase = phase;
}
return interceptorResult;
}
// Handle bool for validation
if (result is bool isValid && ProtocolInterceptor.Type == InterceptorType.Validation)
{
return new ValidationInterceptorResult
{
Interceptor = ProtocolInterceptor.Name,
Phase = phase,
DurationMs = durationMs,
Valid = isValid,
Severity = isValid ? null : ValidationSeverity.Error,
};
}
// Handle JsonNode for mutation
if (result is JsonNode payload && ProtocolInterceptor.Type == InterceptorType.Mutation)
{
return new MutationInterceptorResult
{
Interceptor = ProtocolInterceptor.Name,
Phase = phase,
DurationMs = durationMs,
Modified = true,
Payload = payload
};
}
// Default based on interceptor type
return ProtocolInterceptor.Type switch
{
InterceptorType.Mutation => new MutationInterceptorResult
{
Interceptor = ProtocolInterceptor.Name,
Phase = phase,
DurationMs = durationMs,
Modified = false
},
InterceptorType.Observability => new ObservabilityInterceptorResult
{
Interceptor = ProtocolInterceptor.Name,
Phase = phase,
DurationMs = durationMs,
Observed = true
},
_ => new ValidationInterceptorResult
{
Interceptor = ProtocolInterceptor.Name,
Phase = phase,
DurationMs = durationMs,
Valid = true
}
};
}
/// <summary>Creates a name to use based on the supplied method.</summary>
internal static string DeriveName(MethodInfo method, JsonNamingPolicy? policy = null)
{
string name = method.Name;
// Remove any "Async" suffix if the method is an async method and if the method name isn't just "Async".
const string AsyncSuffix = "Async";
if (IsAsyncMethod(method) &&
name.EndsWith(AsyncSuffix, StringComparison.Ordinal) &&
name.Length > AsyncSuffix.Length)
{
name = name.Substring(0, name.Length - AsyncSuffix.Length);
}
// Replace anything other than ASCII letters or digits with underscores, trim off any leading or trailing underscores.
name = NonAsciiLetterDigitsRegex().Replace(name, "_").Trim('_');
// If after all our transformations the name is empty, just use the original method name.
if (name.Length == 0)
{
name = method.Name;
}
// Case the name based on the provided naming policy.
return (policy ?? JsonNamingPolicy.SnakeCaseLower).ConvertName(name) ?? name;
static bool IsAsyncMethod(MethodInfo method)
{
Type t = method.ReturnType;
if (t == typeof(Task) || t == typeof(ValueTask))
{
return true;
}
if (t.IsGenericType)
{
t = t.GetGenericTypeDefinition();
if (t == typeof(Task<>) || t == typeof(ValueTask<>))
{
return true;
}
}
return false;
}
}
/// <summary>Creates metadata from attributes on the specified method and its declaring class.</summary>
internal static IReadOnlyList<object> CreateMetadata(MethodInfo method)
{
List<object> metadata = [method];
if (method.DeclaringType is not null)
{
metadata.AddRange(method.DeclaringType.GetCustomAttributes());
}
metadata.AddRange(method.GetCustomAttributes());
return metadata.AsReadOnly();
}
#if NET
/// <summary>Regex that flags runs of characters other than ASCII digits or letters.</summary>
[GeneratedRegex("[^0-9A-Za-z]+")]
private static partial Regex NonAsciiLetterDigitsRegex();
/// <summary>Regex that validates interceptor names.</summary>
[GeneratedRegex(@"^[A-Za-z0-9_.-]{1,128}\z")]
private static partial Regex ValidateInterceptorNameRegex();
#else
private static Regex NonAsciiLetterDigitsRegex() => _nonAsciiLetterDigits;
private static readonly Regex _nonAsciiLetterDigits = new("[^0-9A-Za-z]+", RegexOptions.Compiled);
private static Regex ValidateInterceptorNameRegex() => _validateInterceptorName;
private static readonly Regex _validateInterceptorName = new(@"^[A-Za-z0-9_.-]{1,128}\z", RegexOptions.Compiled);
#endif
private static void ValidateInterceptorName(string name)
{
if (name is null)
{
throw new ArgumentException("Interceptor name cannot be null.");
}
if (!ValidateInterceptorNameRegex().IsMatch(name))
{
throw new ArgumentException($"The interceptor name '{name}' is invalid. Interceptor names must match the regular expression '{ValidateInterceptorNameRegex()}'");
}
}
}

View file

@ -0,0 +1,304 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ModelContextProtocol;
using ModelContextProtocol.Interceptors;
using ModelContextProtocol.Interceptors.Server;
using ModelContextProtocol.Server;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;
namespace Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Provides extension methods for configuring MCP interceptors via dependency injection.
/// </summary>
public static class McpServerInterceptorBuilderExtensions
{
private const string WithInterceptorsRequiresUnreferencedCodeMessage =
$"The non-generic {nameof(WithInterceptors)} and {nameof(WithInterceptorsFromAssembly)} methods require dynamic lookup of method metadata" +
$"and might not work in Native AOT. Use the generic {nameof(WithInterceptors)} method instead.";
/// <summary>Adds <see cref="McpServerInterceptor"/> instances to the service collection backing <paramref name="builder"/>.</summary>
/// <typeparam name="TInterceptorType">The interceptor type.</typeparam>
/// <param name="builder">The builder instance.</param>
/// <param name="serializerOptions">The serializer options governing interceptor parameter marshalling.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <remarks>
/// This method discovers all instance and static methods (public and non-public) on the specified <typeparamref name="TInterceptorType"/>
/// type, where the methods are attributed as <see cref="McpServerInterceptorAttribute"/>, and adds an <see cref="McpServerInterceptor"/>
/// instance for each. For instance methods, an instance is constructed for each invocation of the interceptor.
/// </remarks>
public static IMcpServerBuilder WithInterceptors<[DynamicallyAccessedMembers(
DynamicallyAccessedMemberTypes.PublicMethods |
DynamicallyAccessedMemberTypes.NonPublicMethods |
DynamicallyAccessedMemberTypes.PublicConstructors)] TInterceptorType>(
this IMcpServerBuilder builder,
JsonSerializerOptions? serializerOptions = null)
{
Throw.IfNull(builder);
foreach (var interceptorMethod in typeof(TInterceptorType).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
{
if (interceptorMethod.GetCustomAttribute<McpServerInterceptorAttribute>() is not null)
{
builder.Services.AddSingleton((Func<IServiceProvider, McpServerInterceptor>)(interceptorMethod.IsStatic ?
services => McpServerInterceptor.Create(interceptorMethod, options: new() { Services = services, SerializerOptions = serializerOptions }) :
services => McpServerInterceptor.Create(interceptorMethod, static r => CreateTarget(r.Services, typeof(TInterceptorType)), new() { Services = services, SerializerOptions = serializerOptions })));
}
}
return builder;
}
/// <summary>Adds <see cref="McpServerInterceptor"/> instances to the service collection backing <paramref name="builder"/>.</summary>
/// <typeparam name="TInterceptorType">The interceptor type.</typeparam>
/// <param name="builder">The builder instance.</param>
/// <param name="target">The target instance from which the interceptors should be sourced.</param>
/// <param name="serializerOptions">The serializer options governing interceptor parameter marshalling.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> or <paramref name="target"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// This method discovers all methods (public and non-public) on the specified <typeparamref name="TInterceptorType"/>
/// type, where the methods are attributed as <see cref="McpServerInterceptorAttribute"/>, and adds an <see cref="McpServerInterceptor"/>
/// instance for each, using <paramref name="target"/> as the associated instance for instance methods.
/// </para>
/// <para>
/// However, if <typeparamref name="TInterceptorType"/> is itself an <see cref="IEnumerable{T}"/> of <see cref="McpServerInterceptor"/>,
/// this method registers those interceptors directly without scanning for methods on <typeparamref name="TInterceptorType"/>.
/// </para>
/// </remarks>
public static IMcpServerBuilder WithInterceptors<[DynamicallyAccessedMembers(
DynamicallyAccessedMemberTypes.PublicMethods |
DynamicallyAccessedMemberTypes.NonPublicMethods)] TInterceptorType>(
this IMcpServerBuilder builder,
TInterceptorType target,
JsonSerializerOptions? serializerOptions = null)
{
Throw.IfNull(builder);
Throw.IfNull(target);
if (target is IEnumerable<McpServerInterceptor> interceptors)
{
return builder.WithInterceptors(interceptors);
}
foreach (var interceptorMethod in typeof(TInterceptorType).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
{
if (interceptorMethod.GetCustomAttribute<McpServerInterceptorAttribute>() is not null)
{
builder.Services.AddSingleton(services => McpServerInterceptor.Create(
interceptorMethod,
interceptorMethod.IsStatic ? null : target,
new() { Services = services, SerializerOptions = serializerOptions }));
}
}
return builder;
}
/// <summary>Adds <see cref="McpServerInterceptor"/> instances to the service collection backing <paramref name="builder"/>.</summary>
/// <param name="builder">The builder instance.</param>
/// <param name="interceptors">The <see cref="McpServerInterceptor"/> instances to add to the server.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> or <paramref name="interceptors"/> is <see langword="null"/>.</exception>
public static IMcpServerBuilder WithInterceptors(this IMcpServerBuilder builder, IEnumerable<McpServerInterceptor> interceptors)
{
Throw.IfNull(builder);
Throw.IfNull(interceptors);
foreach (var interceptor in interceptors)
{
if (interceptor is not null)
{
builder.Services.AddSingleton(interceptor);
}
}
return builder;
}
/// <summary>Adds <see cref="McpServerInterceptor"/> instances to the service collection backing <paramref name="builder"/>.</summary>
/// <param name="builder">The builder instance.</param>
/// <param name="interceptorTypes">Types with <see cref="McpServerInterceptorAttribute"/>-attributed methods to add as interceptors to the server.</param>
/// <param name="serializerOptions">The serializer options governing interceptor parameter marshalling.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> or <paramref name="interceptorTypes"/> is <see langword="null"/>.</exception>
/// <remarks>
/// This method discovers all instance and static methods (public and non-public) on the specified <paramref name="interceptorTypes"/>
/// types, where the methods are attributed as <see cref="McpServerInterceptorAttribute"/>, and adds an <see cref="McpServerInterceptor"/>
/// instance for each. For instance methods, an instance is constructed for each invocation of the interceptor.
/// </remarks>
[RequiresUnreferencedCode(WithInterceptorsRequiresUnreferencedCodeMessage)]
public static IMcpServerBuilder WithInterceptors(this IMcpServerBuilder builder, IEnumerable<Type> interceptorTypes, JsonSerializerOptions? serializerOptions = null)
{
Throw.IfNull(builder);
Throw.IfNull(interceptorTypes);
foreach (var interceptorType in interceptorTypes)
{
if (interceptorType is not null)
{
foreach (var interceptorMethod in interceptorType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
{
if (interceptorMethod.GetCustomAttribute<McpServerInterceptorAttribute>() is not null)
{
builder.Services.AddSingleton((Func<IServiceProvider, McpServerInterceptor>)(interceptorMethod.IsStatic ?
services => McpServerInterceptor.Create(interceptorMethod, options: new() { Services = services, SerializerOptions = serializerOptions }) :
services => McpServerInterceptor.Create(interceptorMethod, r => CreateTarget(r.Services, interceptorType), new() { Services = services, SerializerOptions = serializerOptions })));
}
}
}
}
return builder;
}
/// <summary>
/// Adds types marked with the <see cref="McpServerInterceptorTypeAttribute"/> attribute from the given assembly as interceptors to the server.
/// </summary>
/// <param name="builder">The builder instance.</param>
/// <param name="serializerOptions">The serializer options governing interceptor parameter marshalling.</param>
/// <param name="interceptorAssembly">The assembly to load the types from. If <see langword="null"/>, the calling assembly is used.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// This method scans the specified assembly (or the calling assembly if none is provided) for classes
/// marked with the <see cref="McpServerInterceptorTypeAttribute"/>. It then discovers all methods within those
/// classes that are marked with the <see cref="McpServerInterceptorAttribute"/> and registers them as <see cref="McpServerInterceptor"/>s
/// in the <paramref name="builder"/>'s <see cref="IServiceCollection"/>.
/// </para>
/// <para>
/// The method automatically handles both static and instance methods. For instance methods, a new instance
/// of the containing class is constructed for each invocation of the interceptor.
/// </para>
/// <para>
/// Interceptors registered through this method can be discovered by clients using the <c>interceptors/list</c> request
/// and invoked using the <c>interceptor/invoke</c> request.
/// </para>
/// <para>
/// Note that this method performs reflection at runtime and might not work in Native AOT scenarios. For
/// Native AOT compatibility, consider using the generic <see cref="M:WithInterceptors"/> method instead.
/// </para>
/// </remarks>
[RequiresUnreferencedCode(WithInterceptorsRequiresUnreferencedCodeMessage)]
public static IMcpServerBuilder WithInterceptorsFromAssembly(this IMcpServerBuilder builder, Assembly? interceptorAssembly = null, JsonSerializerOptions? serializerOptions = null)
{
Throw.IfNull(builder);
interceptorAssembly ??= Assembly.GetCallingAssembly();
return builder.WithInterceptors(
from t in interceptorAssembly.GetTypes()
where t.GetCustomAttribute<McpServerInterceptorTypeAttribute>() is not null
select t,
serializerOptions);
}
/// <summary>
/// Configures a handler for listing interceptors available from the Model Context Protocol server.
/// </summary>
/// <param name="builder">The builder instance.</param>
/// <param name="handler">The handler that processes list interceptors requests.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// This handler is called when a client requests a list of available interceptors. It should return all interceptors
/// that can be invoked through the server, including their names, descriptions, and supported events.
/// </para>
/// <para>
/// When interceptors are also defined using <see cref="McpServerInterceptor"/> collection, both sets of interceptors
/// will be combined in the response to clients. This allows for a mix of programmatically defined
/// interceptors and dynamically generated interceptors.
/// </para>
/// <para>
/// This method is typically paired with <see cref="WithInvokeInterceptorHandler"/> to provide a complete interceptors implementation,
/// where <see cref="WithListInterceptorsHandler"/> advertises available interceptors and <see cref="WithInvokeInterceptorHandler"/>
/// executes them when invoked by clients.
/// </para>
/// </remarks>
public static IMcpServerBuilder WithListInterceptorsHandler(this IMcpServerBuilder builder, Func<RequestContext<ListInterceptorsRequestParams>, CancellationToken, ValueTask<ListInterceptorsResult>> handler)
{
Throw.IfNull(builder);
builder.Services.Configure<InterceptorServerHandlers>(s => s.ListInterceptorsHandler = handler);
return builder;
}
/// <summary>
/// Configures a handler for invoking interceptors available from the Model Context Protocol server.
/// </summary>
/// <param name="builder">The builder instance.</param>
/// <param name="handler">The handler function that processes interceptor invocations.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <remarks>
/// The invoke interceptor handler is responsible for executing validation interceptors and returning their results to clients.
/// This method is typically paired with <see cref="WithListInterceptorsHandler"/> to provide a complete interceptors implementation,
/// where <see cref="WithListInterceptorsHandler"/> advertises available interceptors and this handler executes them.
/// </remarks>
public static IMcpServerBuilder WithInvokeInterceptorHandler(this IMcpServerBuilder builder, Func<RequestContext<InvokeInterceptorRequestParams>, CancellationToken, ValueTask<ValidationInterceptorResult>> handler)
{
Throw.IfNull(builder);
builder.Services.Configure<InterceptorServerHandlers>(s => s.InvokeInterceptorHandler = handler);
return builder;
}
/// <summary>
/// Adds a filter to the list interceptors handler pipeline.
/// </summary>
/// <param name="builder">The builder instance.</param>
/// <param name="filter">The filter function that wraps the 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 wraps handlers that return a list of available interceptors when requested by a client.
/// The filter can modify, log, or perform additional operations on requests and responses.
/// </para>
/// <para>
/// This filter works alongside any interceptors defined in the <see cref="McpServerInterceptor"/> collection.
/// Interceptors from both sources will be combined when returning results to clients.
/// </para>
/// </remarks>
public static IMcpServerBuilder AddListInterceptorsFilter(this IMcpServerBuilder builder, Func<RequestContext<ListInterceptorsRequestParams>, Func<RequestContext<ListInterceptorsRequestParams>, CancellationToken, ValueTask<ListInterceptorsResult>>, CancellationToken, ValueTask<ListInterceptorsResult>> filter)
{
Throw.IfNull(builder);
builder.Services.Configure<InterceptorServerFilters>(options => options.ListInterceptorsFilters.Add(filter));
return builder;
}
/// <summary>
/// Adds a filter to the invoke interceptor handler pipeline.
/// </summary>
/// <param name="builder">The builder instance.</param>
/// <param name="filter">The filter function that wraps the 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 wraps handlers that are invoked when a client calls an interceptor.
/// The filter can modify, log, or perform additional operations on requests and responses.
/// </para>
/// </remarks>
public static IMcpServerBuilder AddInvokeInterceptorFilter(this IMcpServerBuilder builder, Func<RequestContext<InvokeInterceptorRequestParams>, Func<RequestContext<InvokeInterceptorRequestParams>, CancellationToken, ValueTask<ValidationInterceptorResult>>, CancellationToken, ValueTask<ValidationInterceptorResult>> filter)
{
Throw.IfNull(builder);
builder.Services.Configure<InterceptorServerFilters>(options => options.InvokeInterceptorFilters.Add(filter));
return builder;
}
/// <summary>Creates an instance of the target object.</summary>
private static object CreateTarget(
IServiceProvider? services,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type) =>
services is not null ? ActivatorUtilities.CreateInstance(services, type) :
Activator.CreateInstance(type)!;
}

View file

@ -0,0 +1,54 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0;net9.0;net8.0;netstandard2.0</TargetFrameworks>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<IsPackable>true</IsPackable>
<PackageId>ModelContextProtocol.Interceptors</PackageId>
<Description>MCP Interceptors extension for the Model Context Protocol (MCP) .NET SDK - provides validation, mutation, and observation capabilities for MCP messages.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Package metadata -->
<Authors>FSIG Contributors</Authors>
<Copyright>© FSIG Contributors</Copyright>
<PackageTags>ModelContextProtocol;mcp;ai;llm;interceptors;validation</PackageTags>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<RepositoryUrl>https://github.com/modelcontextprotocol/experimental-ext-interceptors</RepositoryUrl>
<RepositoryType>git</RepositoryType>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<!-- Include common utilities for all target frameworks -->
<ItemGroup>
<Compile Include="..\Common\Throw.cs" Link="Common\Throw.cs" />
</ItemGroup>
<!-- Include polyfills for netstandard2.0 -->
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<Compile Include="..\Common\Polyfills\System\Runtime\CompilerServices\IsExternalInit.cs" Link="Polyfills\IsExternalInit.cs" />
<Compile Include="..\Common\Polyfills\System\Runtime\CompilerServices\RequiredMemberAttribute.cs" Link="Polyfills\RequiredMemberAttribute.cs" />
<Compile Include="..\Common\Polyfills\System\Runtime\CompilerServices\CompilerFeatureRequiredAttribute.cs" Link="Polyfills\CompilerFeatureRequiredAttribute.cs" />
<Compile Include="..\Common\Polyfills\System\Runtime\CompilerServices\CallerArgumentExpressionAttribute.cs" Link="Polyfills\CallerArgumentExpressionAttribute.cs" />
<Compile Include="..\Common\Polyfills\System\Diagnostics\CodeAnalysis\SetsRequiredMembersAttribute.cs" Link="Polyfills\SetsRequiredMembersAttribute.cs" />
<Compile Include="..\Common\Polyfills\System\Diagnostics\CodeAnalysis\DynamicallyAccessedMembersAttribute.cs" Link="Polyfills\DynamicallyAccessedMembersAttribute.cs" />
<Compile Include="..\Common\Polyfills\System\Diagnostics\CodeAnalysis\DynamicallyAccessedMemberTypes.cs" Link="Polyfills\DynamicallyAccessedMemberTypes.cs" />
<Compile Include="..\Common\Polyfills\System\Diagnostics\CodeAnalysis\RequiresUnreferencedCode.cs" Link="Polyfills\RequiresUnreferencedCode.cs" />
<Compile Include="..\Common\Polyfills\System\Diagnostics\CodeAnalysis\NullableAttributes.cs" Link="Polyfills\NullableAttributes.cs" />
</ItemGroup>
<ItemGroup>
<!-- Reference the MCP SDK NuGet package -->
<PackageReference Include="ModelContextProtocol" Version="0.6.0-preview.1" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,126 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using ModelContextProtocol.Interceptors.Client;
using ModelContextProtocol.Interceptors.Server;
namespace ModelContextProtocol.Interceptors;
/// <summary>
/// Represents an interceptor that can validate, mutate, or observe MCP messages.
/// </summary>
/// <remarks>
/// <para>
/// Interceptors are a mechanism for hooking into MCP events to provide cross-cutting
/// functionality such as validation, transformation, logging, and security enforcement.
/// </para>
/// <para>
/// See SEP-1763 for the full specification.
/// </para>
/// </remarks>
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public sealed class Interceptor
{
/// <summary>
/// Gets or sets the unique identifier for this interceptor.
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the semantic version of this interceptor.
/// </summary>
[JsonPropertyName("version")]
public string? Version { get; set; }
/// <summary>
/// Gets or sets a human-readable description of what this interceptor does.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
/// <summary>
/// Gets or sets the events this interceptor subscribes to.
/// </summary>
/// <remarks>
/// Use constants from <see cref="InterceptorEvents"/> for event names.
/// </remarks>
[JsonPropertyName("events")]
public IList<string> Events { get; set; } = [];
/// <summary>
/// Gets or sets the type of operation this interceptor performs.
/// </summary>
[JsonPropertyName("type")]
public InterceptorType Type { get; set; }
/// <summary>
/// Gets or sets the execution phase for this interceptor.
/// </summary>
[JsonPropertyName("phase")]
public InterceptorPhase Phase { get; set; }
/// <summary>
/// Gets or sets the priority hint for mutation interceptor ordering.
/// </summary>
/// <remarks>
/// <para>
/// Lower values execute first. Default is 0 if not specified.
/// Interceptors with equal priority are ordered alphabetically by name.
/// </para>
/// <para>
/// This field is only meaningful for mutation interceptors.
/// For validation and observability interceptors, it is ignored.
/// </para>
/// </remarks>
[JsonPropertyName("priorityHint")]
public InterceptorPriorityHint? PriorityHint { get; set; }
/// <summary>
/// Gets or sets the protocol version compatibility for this interceptor.
/// </summary>
[JsonPropertyName("compat")]
public InterceptorCompatibility? Compat { get; set; }
/// <summary>
/// Gets or sets the JSON Schema for interceptor configuration.
/// </summary>
/// <remarks>
/// Documents the expected configuration format for this interceptor.
/// </remarks>
[JsonPropertyName("configSchema")]
public JsonElement? ConfigSchema { get; set; }
/// <summary>
/// Gets or sets metadata reserved by MCP for protocol-level metadata.
/// </summary>
/// <remarks>
/// Implementations must not make assumptions about its contents.
/// </remarks>
[JsonPropertyName("_meta")]
public JsonObject? Meta { get; set; }
/// <summary>
/// Gets or sets the callable server interceptor corresponding to this metadata, if any.
/// </summary>
[JsonIgnore]
public McpServerInterceptor? McpServerInterceptor { get; set; }
/// <summary>
/// Gets or sets the callable client interceptor corresponding to this metadata, if any.
/// </summary>
[JsonIgnore]
public McpClientInterceptor? McpClientInterceptor { get; set; }
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string DebuggerDisplay
{
get
{
string desc = Description is not null ? $", Description = \"{Description}\"" : "";
string type = $", Type = {Type}";
return $"Name = {Name}{type}{desc}";
}
}
}

View file

@ -0,0 +1,146 @@
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Interceptors;
/// <summary>
/// Represents the result of executing an interceptor chain.
/// </summary>
/// <remarks>
/// <para>
/// The chain result aggregates results from all executed interceptors and provides
/// the final payload after all mutations, along with a validation summary.
/// </para>
/// <para>
/// See SEP-1763 for the full specification of chain execution semantics.
/// </para>
/// </remarks>
public sealed class InterceptorChainResult
{
/// <summary>
/// Gets or sets the overall chain execution status.
/// </summary>
[JsonPropertyName("status")]
public InterceptorChainStatus Status { get; set; }
/// <summary>
/// Gets or sets the event type that was processed.
/// </summary>
[JsonPropertyName("event")]
public string? Event { get; set; }
/// <summary>
/// Gets or sets the phase of execution.
/// </summary>
[JsonPropertyName("phase")]
public InterceptorPhase Phase { get; set; }
/// <summary>
/// Gets or sets the results from all executed interceptors.
/// </summary>
[JsonPropertyName("results")]
public IList<InterceptorResult> Results { get; set; } = [];
/// <summary>
/// Gets or sets the final payload after all mutations (if chain completed).
/// </summary>
[JsonPropertyName("finalPayload")]
public JsonNode? FinalPayload { get; set; }
/// <summary>
/// Gets or sets the validation summary.
/// </summary>
[JsonPropertyName("validationSummary")]
public ValidationSummary ValidationSummary { get; set; } = new();
/// <summary>
/// Gets or sets the total execution time in milliseconds.
/// </summary>
[JsonPropertyName("totalDurationMs")]
public long TotalDurationMs { get; set; }
/// <summary>
/// Gets or sets details about where the chain was aborted, if applicable.
/// </summary>
[JsonPropertyName("abortedAt")]
public ChainAbortInfo? AbortedAt { get; set; }
}
/// <summary>
/// Represents the status of an interceptor chain execution.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<InterceptorChainStatus>))]
public enum InterceptorChainStatus
{
/// <summary>
/// The chain completed successfully.
/// </summary>
[JsonStringEnumMemberName("success")]
Success,
/// <summary>
/// The chain was aborted due to a validation failure.
/// </summary>
[JsonStringEnumMemberName("validation_failed")]
ValidationFailed,
/// <summary>
/// The chain was aborted due to a mutation failure.
/// </summary>
[JsonStringEnumMemberName("mutation_failed")]
MutationFailed,
/// <summary>
/// The chain was aborted due to a timeout.
/// </summary>
[JsonStringEnumMemberName("timeout")]
Timeout
}
/// <summary>
/// Represents a summary of validation results from an interceptor chain.
/// </summary>
public sealed class ValidationSummary
{
/// <summary>
/// Gets or sets the number of error-level validations.
/// </summary>
[JsonPropertyName("errors")]
public int Errors { get; set; }
/// <summary>
/// Gets or sets the number of warning-level validations.
/// </summary>
[JsonPropertyName("warnings")]
public int Warnings { get; set; }
/// <summary>
/// Gets or sets the number of info-level validations.
/// </summary>
[JsonPropertyName("infos")]
public int Infos { get; set; }
}
/// <summary>
/// Represents information about where an interceptor chain was aborted.
/// </summary>
public sealed class ChainAbortInfo
{
/// <summary>
/// Gets or sets the name of the interceptor that caused the abort.
/// </summary>
[JsonPropertyName("interceptor")]
public required string Interceptor { get; set; }
/// <summary>
/// Gets or sets the reason for the abort.
/// </summary>
[JsonPropertyName("reason")]
public required string Reason { get; set; }
/// <summary>
/// Gets or sets the type of abort.
/// </summary>
[JsonPropertyName("type")]
public required string Type { get; set; }
}

View file

@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Interceptors;
/// <summary>
/// Specifies the MCP protocol version compatibility for an interceptor.
/// </summary>
public sealed class InterceptorCompatibility
{
/// <summary>
/// Gets or sets the minimum MCP protocol version required for this interceptor.
/// </summary>
[JsonPropertyName("minProtocol")]
public required string MinProtocol { get; set; }
/// <summary>
/// Gets or sets the maximum MCP protocol version supported by this interceptor.
/// </summary>
/// <remarks>
/// If not specified, the interceptor is compatible with all versions at or above <see cref="MinProtocol"/>.
/// </remarks>
[JsonPropertyName("maxProtocol")]
public string? MaxProtocol { get; set; }
}

View file

@ -0,0 +1,89 @@
namespace ModelContextProtocol.Interceptors;
/// <summary>
/// Provides constants with the names of events that interceptors can subscribe to.
/// </summary>
public static class InterceptorEvents
{
// MCP Server Features - Tools
/// <summary>
/// Event fired when listing available tools.
/// </summary>
public const string ToolsList = "tools/list";
/// <summary>
/// Event fired when invoking a tool.
/// </summary>
public const string ToolsCall = "tools/call";
// MCP Server Features - Prompts
/// <summary>
/// Event fired when listing available prompts.
/// </summary>
public const string PromptsList = "prompts/list";
/// <summary>
/// Event fired when retrieving a prompt.
/// </summary>
public const string PromptsGet = "prompts/get";
// MCP Server Features - Resources
/// <summary>
/// Event fired when listing available resources.
/// </summary>
public const string ResourcesList = "resources/list";
/// <summary>
/// Event fired when reading a resource.
/// </summary>
public const string ResourcesRead = "resources/read";
/// <summary>
/// Event fired when subscribing to resource updates.
/// </summary>
public const string ResourcesSubscribe = "resources/subscribe";
// MCP Client Features
/// <summary>
/// Event fired when a server requests sampling (LLM inference) from the client.
/// </summary>
public const string SamplingCreateMessage = "sampling/createMessage";
/// <summary>
/// Event fired when a server requests elicitation (user input) from the client.
/// </summary>
public const string ElicitationCreate = "elicitation/create";
/// <summary>
/// Event fired when listing client roots (filesystem access).
/// </summary>
public const string RootsList = "roots/list";
// LLM Interaction Events
/// <summary>
/// Event fired for LLM completion requests (using common OpenAI-like format).
/// </summary>
public const string LlmCompletion = "llm/completion";
// Wildcard Events
/// <summary>
/// Wildcard event that matches all request-phase events.
/// </summary>
public const string AllRequests = "*/request";
/// <summary>
/// Wildcard event that matches all response-phase events.
/// </summary>
public const string AllResponses = "*/response";
/// <summary>
/// Wildcard event that matches all events.
/// </summary>
public const string All = "*";
}

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