C# Implementation (#2)
C# implementation of SEP-1763 --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
46d96779d6
commit
45f9e0ef27
58 changed files with 13862 additions and 21 deletions
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
/// {
|
||||
/// // 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
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
/// {
|
||||
/// 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) => ValueTask.FromResult(ValidationInterceptorResult.Success())),
|
||||
/// McpClientInterceptor.Create(
|
||||
/// name: "pii-filter",
|
||||
/// events: [InterceptorEvents.ToolsCall],
|
||||
/// type: InterceptorType.Validation,
|
||||
/// handler: (ctx, ct) => 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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, JsonNode>
|
||||
/// {
|
||||
/// ["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;
|
||||
}
|
||||
|
|
@ -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 & Observe → Send</item>
|
||||
/// <item><b>Receiving</b>: Receive → Validate & 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 & 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 & 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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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; } = [];
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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)!;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)!;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue