C# Re-implementation (#5)

Re-implementation of C# interceptors SDK with clearer focus on
server-side / gateway interceptors.
Fixes a lot of issues with the existing implementation.
Still more boilerplate than I'm happy with, but this is better than what
was there. Will refine further in later PRs.
This commit is contained in:
Peder Holdgaard Pedersen 2026-05-14 15:11:41 +02:00 • committed by GitHub
commit d01a626b76
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 7887 additions and 12886 deletions

View file

@ -0,0 +1,412 @@
using System.Text.Json;
using System.IO.Pipes;
using System.Security.Claims;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Interceptors.Gateway;
using ModelContextProtocol.Interceptors.Protocol;
using ModelContextProtocol.Interceptors.Server;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Xunit;
namespace ModelContextProtocol.Interceptors.Tests;
public class GatewayComponentsTests
{
[Fact]
public async Task GatewayInterceptorProtocolBridge_UsesCentralizedExtensionKey()
{
await using var fixture = await GatewayComponentFixture.CreateAsync();
var serverOptions = new McpServerOptions
{
Capabilities = new ServerCapabilities(),
};
var bridge = new GatewayInterceptorProtocolBridge(
[fixture.InterceptorClient],
InterceptorJsonUtilities.DefaultOptions);
bridge.Configure(serverOptions);
#pragma warning disable MCPEXP001
Assert.NotNull(serverOptions.Capabilities.Extensions);
Assert.True(serverOptions.Capabilities.Extensions!.ContainsKey(InterceptorProtocolConstants.ExtensionCapabilityKey));
#pragma warning restore MCPEXP001
}
[Fact]
public async Task GatewayProxyConfigurator_ClonesBackendCapabilities()
{
await using var fixture = await GatewayComponentFixture.CreateAsync(backendConfigure: options =>
{
options.Capabilities ??= new();
options.Capabilities.Tools ??= new() { ListChanged = true };
options.Capabilities.Experimental = new Dictionary<string, object>
{
["com.example/test"] = JsonSerializer.SerializeToElement(new { enabled = true }),
};
options.Handlers.ListToolsHandler = (request, ct) =>
new ValueTask<ListToolsResult>(new ListToolsResult { Tools = [] });
options.Handlers.CallToolHandler = (request, ct) =>
new ValueTask<CallToolResult>(new CallToolResult());
});
var provider = new GatewayInterceptorClientProvider([fixture.InterceptorClient], connectionResolver: null);
var configurator = new GatewayProxyConfigurator(
fixture.BackendClient,
provider,
events: null,
timeoutMs: null,
defaultContext: null,
InterceptorJsonUtilities.DefaultOptions);
var serverOptions = new McpServerOptions();
configurator.Configure(serverOptions, serverInfoOverride: null);
Assert.NotSame(fixture.BackendClient.ServerCapabilities, serverOptions.Capabilities);
Assert.NotNull(serverOptions.Capabilities?.Experimental);
Assert.True(serverOptions.Capabilities!.Experimental!.ContainsKey("com.example/test"));
await provider.DisposeAsync();
}
[Fact]
public async Task McpInterceptorGateway_CreateAsync_ConnectsExternalInterceptorTransport()
{
await using var fixture = await GatewayComponentFixture.CreateAsync();
await using var gateway = await McpInterceptorGateway.CreateAsync(new McpInterceptorGatewayOptions
{
BackendClient = fixture.BackendClient,
InterceptorServerConnections =
[
new McpInterceptorServerConnectionOptions
{
Transport = fixture.CreateInterceptorTransport(),
},
],
ExposeInterceptorProtocol = true,
});
var serverOptions = new McpServerOptions();
gateway.ConfigureServerOptions(serverOptions);
#pragma warning disable MCPEXP001
Assert.True(serverOptions.Capabilities?.Extensions?.ContainsKey(InterceptorProtocolConstants.ExtensionCapabilityKey) ?? false);
#pragma warning restore MCPEXP001
}
[Fact]
public void WithInterceptorGateway_RejectsExternalConnectionsInBuilderPath()
{
var services = new ServiceCollection();
var exception = Assert.Throws<ArgumentException>(() =>
services.AddMcpServer().WithInterceptorGateway(new McpInterceptorGatewayOptions
{
BackendClient = NullMcpClient.Instance,
InterceptorServerConnections =
[
new McpInterceptorServerConnectionOptions
{
Transport = new ThrowingClientTransport(),
},
],
}));
Assert.Contains(nameof(McpInterceptorGateway.CreateAsync), exception.Message);
}
[Fact]
public async Task GatewayInterceptorClientProvider_ResolvesConnectionsFromMessageContext()
{
await using var fixture = await GatewayComponentFixture.CreateAsync();
var provider = new GatewayInterceptorClientProvider(
staticClients: [],
connectionResolver: (context, @event, ct) =>
{
var userName = context.User?.Identity?.Name;
return ValueTask.FromResult<IReadOnlyList<McpInterceptorServerConnectionOptions>>(
@event == InterceptionEvents.ToolsCall && userName == "alice"
?
[
new McpInterceptorServerConnectionOptions
{
ConnectionId = "alice",
Transport = fixture.CreateInterceptorTransport(),
},
]
: []);
});
var message = new JsonRpcRequest
{
Method = RequestMethods.ToolsCall,
Id = new RequestId(1),
Params = JsonSerializer.SerializeToNode(new CallToolRequestParams { Name = "echo" }),
Context = new JsonRpcMessageContext
{
User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, "alice")], authenticationType: "test")),
},
};
var context = new MessageContext(NullMcpServer.Instance, message);
await using var resolved = await provider.ResolveAsync(context, InterceptionEvents.ToolsCall, CancellationToken.None);
Assert.Single(resolved.Clients);
await provider.DisposeAsync();
}
[Fact]
public async Task McpInterceptorGateway_SupportsResolverOnlyTransparentMode()
{
await using var fixture = await GatewayComponentFixture.CreateAsync();
await using var gateway = new McpInterceptorGateway(new McpInterceptorGatewayOptions
{
BackendClient = fixture.BackendClient,
InterceptorServerConnectionResolver = (context, @event, ct) =>
ValueTask.FromResult<IReadOnlyList<McpInterceptorServerConnectionOptions>>(
@event == InterceptionEvents.ToolsCall
?
[
new McpInterceptorServerConnectionOptions
{
Transport = fixture.CreateInterceptorTransport(),
},
]
: []),
});
var serverOptions = new McpServerOptions();
gateway.ConfigureServerOptions(serverOptions);
Assert.NotNull(serverOptions.Handlers.CallToolHandler);
}
[Fact]
public void McpInterceptorGateway_RejectsDynamicResolverWhenSepPassthroughEnabled()
{
var exception = Assert.Throws<ArgumentException>(() =>
new McpInterceptorGateway(new McpInterceptorGatewayOptions
{
BackendClient = NullMcpClient.Instance,
InterceptorClients = [NullMcpClient.Instance],
ExposeInterceptorProtocol = true,
InterceptorServerConnectionResolver = (context, @event, ct) =>
ValueTask.FromResult<IReadOnlyList<McpInterceptorServerConnectionOptions>>([]),
}));
Assert.Contains(nameof(McpInterceptorGatewayOptions.InterceptorServerConnectionResolver), exception.Message);
}
private sealed class GatewayComponentFixture : IAsyncDisposable
{
private readonly List<IAsyncDisposable> _disposables;
public McpClient BackendClient { get; }
public McpClient InterceptorClient { get; }
private readonly Func<IClientTransport> _interceptorTransportFactory;
private GatewayComponentFixture(McpClient backendClient, McpClient interceptorClient, Func<IClientTransport> interceptorTransportFactory, List<IAsyncDisposable> disposables)
{
BackendClient = backendClient;
InterceptorClient = interceptorClient;
_interceptorTransportFactory = interceptorTransportFactory;
_disposables = disposables;
}
public static async Task<GatewayComponentFixture> CreateAsync(Action<McpServerOptions>? backendConfigure = null)
{
var disposables = new List<IAsyncDisposable>();
try
{
var (backendServer, backendClient) = await McpInterceptorGatewayTests.GatewayTestFixture.CreateServerClientPairForTesting(
"component-backend",
options =>
{
options.Capabilities ??= new();
options.Capabilities.Tools ??= new();
options.Handlers.ListToolsHandler = (request, ct) =>
new ValueTask<ListToolsResult>(new ListToolsResult { Tools = [] });
options.Handlers.CallToolHandler = (request, ct) =>
new ValueTask<CallToolResult>(new CallToolResult());
backendConfigure?.Invoke(options);
});
disposables.Add(backendServer);
disposables.Add(backendClient);
Func<IClientTransport>? interceptorTransportFactory = null;
var (interceptorServer, interceptorClient) = await McpInterceptorGatewayTests.GatewayTestFixture.CreateServerClientPairForTesting(
"component-interceptor",
options =>
{
var collection = new McpServerPrimitiveCollection<McpServerInterceptor>();
collection.Add(new TestInterceptor(
new Interceptor
{
Name = "validator",
Type = InterceptorType.Validation,
Hooks =
[
new InterceptorHook { Events = [InterceptionEvents.ToolsCall], Phase = InterceptorPhase.Request },
new InterceptorHook { Events = [InterceptionEvents.ToolsCall], Phase = InterceptorPhase.Response },
],
},
(_, _, _, _) => new ValueTask<InterceptorResult>(ValidationInterceptorResult.Success())));
var filter = new InterceptorMessageFilter(collection);
options.Filters.Message.IncomingFilters.Add(filter.CreateFilter);
options.Capabilities ??= new();
#pragma warning disable MCPEXP001
options.Capabilities.Extensions ??= new Dictionary<string, object>();
options.Capabilities.Extensions[InterceptorProtocolConstants.ExtensionCapabilityKey] = JsonSerializer.SerializeToElement(
new InterceptorsCapability { SupportedEvents = [InterceptionEvents.ToolsCall] },
InterceptorJsonUtilities.DefaultOptions);
#pragma warning restore MCPEXP001
});
disposables.Add(interceptorServer);
disposables.Add(interceptorClient);
interceptorTransportFactory = () =>
{
var clientToServer = new AnonymousPipeServerStream(PipeDirection.Out);
var serverFromClient = new AnonymousPipeClientStream(PipeDirection.In, clientToServer.GetClientHandleAsString());
var serverToClient = new AnonymousPipeServerStream(PipeDirection.Out);
var clientFromServer = new AnonymousPipeClientStream(PipeDirection.In, serverToClient.GetClientHandleAsString());
var serverOptions = new McpServerOptions
{
ServerInfo = new() { Name = "component-interceptor-external", Version = "1.0.0" },
};
var collection = new McpServerPrimitiveCollection<McpServerInterceptor>();
collection.Add(new TestInterceptor(
new Interceptor
{
Name = "validator",
Type = InterceptorType.Validation,
Hooks =
[
new InterceptorHook { Events = [InterceptionEvents.ToolsCall], Phase = InterceptorPhase.Request },
new InterceptorHook { Events = [InterceptionEvents.ToolsCall], Phase = InterceptorPhase.Response },
],
},
(_, _, _, _) => new ValueTask<InterceptorResult>(ValidationInterceptorResult.Success())));
var filter = new InterceptorMessageFilter(collection);
serverOptions.Filters.Message.IncomingFilters.Add(filter.CreateFilter);
serverOptions.Capabilities ??= new();
#pragma warning disable MCPEXP001
serverOptions.Capabilities.Extensions ??= new Dictionary<string, object>();
serverOptions.Capabilities.Extensions[InterceptorProtocolConstants.ExtensionCapabilityKey] = JsonSerializer.SerializeToElement(
new InterceptorsCapability { SupportedEvents = [InterceptionEvents.ToolsCall] },
InterceptorJsonUtilities.DefaultOptions);
#pragma warning restore MCPEXP001
var serverTransport = new StreamServerTransport(serverFromClient, serverToClient);
var server = McpServer.Create(serverTransport, serverOptions);
_ = server.RunAsync();
disposables.Add(server);
return new StreamClientTransport(clientToServer, clientFromServer);
};
return new GatewayComponentFixture(backendClient, interceptorClient, interceptorTransportFactory, disposables);
}
catch
{
foreach (var disposable in disposables)
{
await disposable.DisposeAsync();
}
throw;
}
}
public async ValueTask DisposeAsync()
{
for (var i = _disposables.Count - 1; i >= 0; i--)
{
await _disposables[i].DisposeAsync();
}
}
public IClientTransport CreateInterceptorTransport() => _interceptorTransportFactory();
}
private sealed class ThrowingClientTransport : IClientTransport
{
public string Name => "throwing";
public Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
#pragma warning disable MCPEXP002
private sealed class NullMcpServer : McpServer
#pragma warning restore MCPEXP002
{
internal static NullMcpServer Instance { get; } = new();
public override string? SessionId => null;
public override string? NegotiatedProtocolVersion => null;
public override ClientCapabilities? ClientCapabilities => null;
public override Implementation? ClientInfo => null;
public override McpServerOptions ServerOptions => new();
public override IServiceProvider? Services => null;
public override LoggingLevel? LoggingLevel => null;
public override ValueTask DisposeAsync() => ValueTask.CompletedTask;
public override IAsyncDisposable RegisterNotificationHandler(string method, Func<JsonRpcNotification, CancellationToken, ValueTask> handler) => throw new NotSupportedException();
public override Task RunAsync(CancellationToken cancellationToken) => throw new NotSupportedException();
public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public override Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
#pragma warning disable MCPEXP002
private sealed class NullMcpClient : McpClient
#pragma warning restore MCPEXP002
{
internal static NullMcpClient Instance { get; } = new();
public override string? SessionId => null;
public override string? NegotiatedProtocolVersion => null;
public override ServerCapabilities ServerCapabilities => new();
public override Implementation ServerInfo => new() { Name = "null", Version = "1.0.0" };
public override string? ServerInstructions => null;
public override Task<ClientCompletionDetails> Completion => Task.FromResult(new ClientCompletionDetails());
public override ValueTask DisposeAsync() => ValueTask.CompletedTask;
public override IAsyncDisposable RegisterNotificationHandler(string method, Func<JsonRpcNotification, CancellationToken, ValueTask> handler) => throw new NotSupportedException();
public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public override Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
private sealed class TestInterceptor : McpServerInterceptor
{
private readonly Interceptor _interceptor;
private readonly Func<InvokeInterceptorRequestParams, McpServer, IServiceProvider?, CancellationToken, ValueTask<InterceptorResult>> _handler;
public TestInterceptor(
Interceptor interceptor,
Func<InvokeInterceptorRequestParams, McpServer, IServiceProvider?, CancellationToken, ValueTask<InterceptorResult>> handler)
{
_interceptor = interceptor;
_handler = handler;
}
public override Interceptor ProtocolInterceptor => _interceptor;
public override IReadOnlyList<object> Metadata => [];
public override ValueTask<InterceptorResult> InvokeAsync(
InvokeInterceptorRequestParams request,
McpServer server,
IServiceProvider? services,
CancellationToken cancellationToken = default) =>
_handler(request, server, services, cancellationToken);
}
}

View file

@ -1,504 +0,0 @@
using System.Text.Json.Nodes;
using ModelContextProtocol.Interceptors;
using ModelContextProtocol.Interceptors.Client;
namespace ModelContextProtocol.Interceptors.Tests;
public class InterceptorChainExecutorTests
{
[Fact]
public async Task ExecuteForSendingAsync_MutationsExecuteSequentiallyByPriority()
{
// Arrange
var executionOrder = new List<string>();
var interceptors = new List<McpClientInterceptor>
{
CreateMutationInterceptor("high-priority", -100, payload =>
{
executionOrder.Add("high-priority");
return MutationInterceptorResult.Unchanged(payload);
}),
CreateMutationInterceptor("medium-priority", 0, payload =>
{
executionOrder.Add("medium-priority");
return MutationInterceptorResult.Unchanged(payload);
}),
CreateMutationInterceptor("low-priority", 100, payload =>
{
executionOrder.Add("low-priority");
return MutationInterceptorResult.Unchanged(payload);
})
};
var executor = new InterceptorChainExecutor(interceptors);
var payload = JsonNode.Parse("{}");
// Act
await executor.ExecuteForSendingAsync(InterceptorEvents.ToolsCall, payload);
// Assert - Lower priority numbers execute first
Assert.Equal(["high-priority", "medium-priority", "low-priority"], executionOrder);
}
[Fact]
public async Task ExecuteForSendingAsync_MutationsWithSamePriority_OrderAlphabetically()
{
// Arrange
var executionOrder = new List<string>();
var interceptors = new List<McpClientInterceptor>
{
CreateMutationInterceptor("zebra", 0, payload =>
{
executionOrder.Add("zebra");
return MutationInterceptorResult.Unchanged(payload);
}),
CreateMutationInterceptor("alpha", 0, payload =>
{
executionOrder.Add("alpha");
return MutationInterceptorResult.Unchanged(payload);
}),
CreateMutationInterceptor("beta", 0, payload =>
{
executionOrder.Add("beta");
return MutationInterceptorResult.Unchanged(payload);
})
};
var executor = new InterceptorChainExecutor(interceptors);
var payload = JsonNode.Parse("{}");
// Act
await executor.ExecuteForSendingAsync(InterceptorEvents.ToolsCall, payload);
// Assert - Same priority, alphabetical order
Assert.Equal(["alpha", "beta", "zebra"], executionOrder);
}
[Fact]
public async Task ExecuteForSendingAsync_ValidationErrorBlocksExecution()
{
// Arrange
var mutationExecuted = false;
var interceptors = new List<McpClientInterceptor>
{
CreateMutationInterceptor("mutator", -1000, payload =>
{
mutationExecuted = true;
return MutationInterceptorResult.Mutated(JsonNode.Parse("{\"mutated\": true}"));
}),
CreateValidationInterceptor("validator", _ =>
ValidationInterceptorResult.Error("Validation failed"))
};
var executor = new InterceptorChainExecutor(interceptors);
var payload = JsonNode.Parse("{}");
// Act
var result = await executor.ExecuteForSendingAsync(InterceptorEvents.ToolsCall, payload);
// Assert
// Mutations execute first in sending direction, so mutator runs before validator
Assert.True(mutationExecuted, "Mutation should execute before validation in sending direction");
Assert.Equal(InterceptorChainStatus.ValidationFailed, result.Status);
Assert.NotNull(result.AbortedAt);
Assert.Equal("validator", result.AbortedAt.Interceptor);
}
[Fact]
public async Task ExecuteForReceivingAsync_ValidationsExecuteBeforeMutations()
{
// Arrange
var executionOrder = new List<string>();
var interceptors = new List<McpClientInterceptor>
{
CreateMutationInterceptor("mutator", 0, payload =>
{
executionOrder.Add("mutator");
return MutationInterceptorResult.Unchanged(payload);
}),
CreateValidationInterceptor("validator", _ =>
{
executionOrder.Add("validator");
return ValidationInterceptorResult.Success();
})
};
var executor = new InterceptorChainExecutor(interceptors);
var payload = JsonNode.Parse("{}");
// Act
await executor.ExecuteForReceivingAsync(InterceptorEvents.ToolsCall, payload);
// Assert - In receiving direction: Validate → Mutate
Assert.Equal("validator", executionOrder[0]);
Assert.Equal("mutator", executionOrder[1]);
}
[Fact]
public async Task ExecuteForReceivingAsync_ValidationErrorBlocksMutations()
{
// Arrange
var mutationExecuted = false;
var interceptors = new List<McpClientInterceptor>
{
CreateMutationInterceptor("mutator", 0, payload =>
{
mutationExecuted = true;
return MutationInterceptorResult.Mutated(JsonNode.Parse("{\"mutated\": true}"));
}),
CreateValidationInterceptor("validator", _ =>
ValidationInterceptorResult.Error("Validation failed"))
};
var executor = new InterceptorChainExecutor(interceptors);
var payload = JsonNode.Parse("{}");
// Act
var result = await executor.ExecuteForReceivingAsync(InterceptorEvents.ToolsCall, payload);
// Assert - In receiving direction, validation runs first and blocks mutation
Assert.False(mutationExecuted);
Assert.Equal(InterceptorChainStatus.ValidationFailed, result.Status);
}
[Fact]
public async Task ObservabilityInterceptor_NeverBlocksExecution()
{
// Arrange
var observabilityExecuted = false;
var mutationExecuted = false;
var interceptors = new List<McpClientInterceptor>
{
CreateMutationInterceptor("mutator", 0, payload =>
{
mutationExecuted = true;
return MutationInterceptorResult.Unchanged(payload);
}),
CreateObservabilityInterceptor("observer", _ =>
{
observabilityExecuted = true;
throw new Exception("Observability failure should not block");
})
};
var executor = new InterceptorChainExecutor(interceptors);
var payload = JsonNode.Parse("{}");
// Act
var result = await executor.ExecuteForSendingAsync(InterceptorEvents.ToolsCall, payload);
// Assert - Observability failures don't block
Assert.True(mutationExecuted);
Assert.True(observabilityExecuted);
Assert.Equal(InterceptorChainStatus.Success, result.Status);
}
[Fact]
public async Task Timeout_AbortsChainExecution()
{
// Arrange
var interceptors = new List<McpClientInterceptor>
{
CreateCancellableAsyncMutationInterceptor("slow-mutator", 0, async (payload, ct) =>
{
// Use the cancellation token so the delay can be cancelled
await Task.Delay(5000, ct);
return MutationInterceptorResult.Unchanged(payload);
})
};
var executor = new InterceptorChainExecutor(interceptors);
var payload = JsonNode.Parse("{}");
// Act
var result = await executor.ExecuteForSendingAsync(
InterceptorEvents.ToolsCall,
payload,
timeoutMs: 100);
// Assert - The chain should abort (either as Timeout or MutationFailed due to cancellation)
// The implementation catches OperationCanceledException from the mutation which gets reported
// as MutationFailed rather than Timeout depending on timing
Assert.NotEqual(InterceptorChainStatus.Success, result.Status);
Assert.NotNull(result.AbortedAt);
}
[Fact]
public async Task Mutations_PropagatePayloadThroughChain()
{
// Arrange
var interceptors = new List<McpClientInterceptor>
{
CreateMutationInterceptor("first", -100, payload =>
{
var obj = payload!.AsObject();
obj["step1"] = true;
return MutationInterceptorResult.Mutated(obj);
}),
CreateMutationInterceptor("second", 0, payload =>
{
var obj = payload!.AsObject();
obj["step2"] = true;
return MutationInterceptorResult.Mutated(obj);
}),
CreateMutationInterceptor("third", 100, payload =>
{
var obj = payload!.AsObject();
obj["step3"] = true;
return MutationInterceptorResult.Mutated(obj);
})
};
var executor = new InterceptorChainExecutor(interceptors);
var payload = JsonNode.Parse("{}");
// Act
var result = await executor.ExecuteForSendingAsync(InterceptorEvents.ToolsCall, payload);
// Assert
Assert.Equal(InterceptorChainStatus.Success, result.Status);
var final = result.FinalPayload!.AsObject();
Assert.True(final["step1"]!.GetValue<bool>());
Assert.True(final["step2"]!.GetValue<bool>());
Assert.True(final["step3"]!.GetValue<bool>());
}
[Fact]
public async Task ValidationWarning_DoesNotBlockExecution()
{
// Arrange
var mutationExecuted = false;
var interceptors = new List<McpClientInterceptor>
{
CreateMutationInterceptor("mutator", -1000, payload =>
{
mutationExecuted = true;
return MutationInterceptorResult.Unchanged(payload);
}),
CreateValidationInterceptor("validator", _ =>
ValidationInterceptorResult.Warning("This is just a warning"))
};
var executor = new InterceptorChainExecutor(interceptors);
var payload = JsonNode.Parse("{}");
// Act
var result = await executor.ExecuteForSendingAsync(InterceptorEvents.ToolsCall, payload);
// Assert - Warnings don't block
Assert.True(mutationExecuted);
Assert.Equal(InterceptorChainStatus.Success, result.Status);
Assert.Equal(1, result.ValidationSummary.Warnings);
}
// Helper methods to create test interceptors
private static McpClientInterceptor CreateMutationInterceptor(
string name,
int priority,
Func<JsonNode?, MutationInterceptorResult> handler)
{
return new TestMutationInterceptor(name, priority, handler);
}
private static McpClientInterceptor CreateAsyncMutationInterceptor(
string name,
int priority,
Func<JsonNode?, Task<MutationInterceptorResult>> handler)
{
return new TestAsyncMutationInterceptor(name, priority, handler);
}
private static McpClientInterceptor CreateCancellableAsyncMutationInterceptor(
string name,
int priority,
Func<JsonNode?, CancellationToken, Task<MutationInterceptorResult>> handler)
{
return new TestCancellableAsyncMutationInterceptor(name, priority, handler);
}
private static McpClientInterceptor CreateValidationInterceptor(
string name,
Func<JsonNode?, ValidationInterceptorResult> handler)
{
return new TestValidationInterceptor(name, handler);
}
private static McpClientInterceptor CreateObservabilityInterceptor(
string name,
Action<JsonNode?> handler)
{
return new TestObservabilityInterceptor(name, handler);
}
}
// Test interceptor implementations
file class TestMutationInterceptor : McpClientInterceptor
{
private readonly Func<JsonNode?, MutationInterceptorResult> _handler;
private readonly Interceptor _protocolInterceptor;
private readonly IReadOnlyList<object> _metadata = [];
public TestMutationInterceptor(string name, int priority, Func<JsonNode?, MutationInterceptorResult> handler)
{
_protocolInterceptor = new Interceptor
{
Name = name,
Type = InterceptorType.Mutation,
Phase = InterceptorPhase.Both,
Events = [InterceptorEvents.ToolsCall],
PriorityHint = new InterceptorPriorityHint(priority)
};
_handler = handler;
}
public override Interceptor ProtocolInterceptor => _protocolInterceptor;
public override IReadOnlyList<object> Metadata => _metadata;
public override ValueTask<InterceptorResult> InvokeAsync(
ClientInterceptorContext<InvokeInterceptorRequestParams> context,
CancellationToken cancellationToken = default)
{
var result = _handler(context.Params?.Payload);
result.Interceptor = ProtocolInterceptor.Name;
return new ValueTask<InterceptorResult>(result);
}
}
file class TestAsyncMutationInterceptor : McpClientInterceptor
{
private readonly Func<JsonNode?, Task<MutationInterceptorResult>> _handler;
private readonly Interceptor _protocolInterceptor;
private readonly IReadOnlyList<object> _metadata = [];
public TestAsyncMutationInterceptor(string name, int priority, Func<JsonNode?, Task<MutationInterceptorResult>> handler)
{
_protocolInterceptor = new Interceptor
{
Name = name,
Type = InterceptorType.Mutation,
Phase = InterceptorPhase.Both,
Events = [InterceptorEvents.ToolsCall],
PriorityHint = new InterceptorPriorityHint(priority)
};
_handler = handler;
}
public override Interceptor ProtocolInterceptor => _protocolInterceptor;
public override IReadOnlyList<object> Metadata => _metadata;
public override async ValueTask<InterceptorResult> InvokeAsync(
ClientInterceptorContext<InvokeInterceptorRequestParams> context,
CancellationToken cancellationToken = default)
{
var result = await _handler(context.Params?.Payload);
result.Interceptor = ProtocolInterceptor.Name;
return result;
}
}
file class TestCancellableAsyncMutationInterceptor : McpClientInterceptor
{
private readonly Func<JsonNode?, CancellationToken, Task<MutationInterceptorResult>> _handler;
private readonly Interceptor _protocolInterceptor;
private readonly IReadOnlyList<object> _metadata = [];
public TestCancellableAsyncMutationInterceptor(string name, int priority, Func<JsonNode?, CancellationToken, Task<MutationInterceptorResult>> handler)
{
_protocolInterceptor = new Interceptor
{
Name = name,
Type = InterceptorType.Mutation,
Phase = InterceptorPhase.Both,
Events = [InterceptorEvents.ToolsCall],
PriorityHint = new InterceptorPriorityHint(priority)
};
_handler = handler;
}
public override Interceptor ProtocolInterceptor => _protocolInterceptor;
public override IReadOnlyList<object> Metadata => _metadata;
public override async ValueTask<InterceptorResult> InvokeAsync(
ClientInterceptorContext<InvokeInterceptorRequestParams> context,
CancellationToken cancellationToken = default)
{
var result = await _handler(context.Params?.Payload, cancellationToken);
result.Interceptor = ProtocolInterceptor.Name;
return result;
}
}
file class TestValidationInterceptor : McpClientInterceptor
{
private readonly Func<JsonNode?, ValidationInterceptorResult> _handler;
private readonly Interceptor _protocolInterceptor;
private readonly IReadOnlyList<object> _metadata = [];
public TestValidationInterceptor(string name, Func<JsonNode?, ValidationInterceptorResult> handler)
{
_protocolInterceptor = new Interceptor
{
Name = name,
Type = InterceptorType.Validation,
Phase = InterceptorPhase.Both,
Events = [InterceptorEvents.ToolsCall]
};
_handler = handler;
}
public override Interceptor ProtocolInterceptor => _protocolInterceptor;
public override IReadOnlyList<object> Metadata => _metadata;
public override ValueTask<InterceptorResult> InvokeAsync(
ClientInterceptorContext<InvokeInterceptorRequestParams> context,
CancellationToken cancellationToken = default)
{
var result = _handler(context.Params?.Payload);
result.Interceptor = ProtocolInterceptor.Name;
result.Phase = context.Params?.Phase ?? InterceptorPhase.Request;
return new ValueTask<InterceptorResult>(result);
}
}
file class TestObservabilityInterceptor : McpClientInterceptor
{
private readonly Action<JsonNode?> _handler;
private readonly Interceptor _protocolInterceptor;
private readonly IReadOnlyList<object> _metadata = [];
public TestObservabilityInterceptor(string name, Action<JsonNode?> handler)
{
_protocolInterceptor = new Interceptor
{
Name = name,
Type = InterceptorType.Observability,
Phase = InterceptorPhase.Both,
Events = [InterceptorEvents.ToolsCall]
};
_handler = handler;
}
public override Interceptor ProtocolInterceptor => _protocolInterceptor;
public override IReadOnlyList<object> Metadata => _metadata;
public override ValueTask<InterceptorResult> InvokeAsync(
ClientInterceptorContext<InvokeInterceptorRequestParams> context,
CancellationToken cancellationToken = default)
{
_handler(context.Params?.Payload);
return new ValueTask<InterceptorResult>(new ObservabilityInterceptorResult
{
Interceptor = ProtocolInterceptor.Name,
Observed = true
});
}
}

View file

@ -0,0 +1,495 @@
using System.Text.Json.Nodes;
using ModelContextProtocol.Interceptors.Client;
using ModelContextProtocol.Interceptors.Protocol;
using Xunit;
namespace ModelContextProtocol.Interceptors.Tests;
public class InterceptorChainOrchestratorTests
{
[Fact]
public async Task RequestPhase_ExecutesMutationsBeforeValidations()
{
var executionOrder = new List<string>();
var mutation = CreateInterceptor("mut-1", InterceptorType.Mutation, (req, _) =>
{
executionOrder.Add("mutation");
return new ValueTask<InterceptorResult>(new MutationInterceptorResult
{
Modified = true,
Payload = JsonNode.Parse("""{"mutated":true}"""),
});
});
var validation = CreateInterceptor("val-1", InterceptorType.Validation, (req, _) =>
{
executionOrder.Add("validation");
return new ValueTask<InterceptorResult>(ValidationInterceptorResult.Success());
});
var sink = CreateInterceptor("sink-1", InterceptorType.Sink, (req, _) =>
{
executionOrder.Add("sink");
return new ValueTask<InterceptorResult>(new SinkInterceptorResult { Recorded = true });
});
var result = await RunAsync(
[mutation, validation, sink],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{"original":true}""")!,
},
CancellationToken.None);
Assert.Equal(InterceptorChainStatus.Success, result.Status);
Assert.Equal(["mutation", "validation", "sink"], executionOrder);
Assert.True(result.FinalPayload!["mutated"]!.GetValue<bool>());
}
[Fact]
public async Task ResponsePhase_ExecutesValidationsBeforeMutations()
{
var executionOrder = new List<string>();
var mutation = CreateInterceptor("mut-1", InterceptorType.Mutation, (req, _) =>
{
executionOrder.Add("mutation");
return new ValueTask<InterceptorResult>(new MutationInterceptorResult { Modified = false });
});
var validation = CreateInterceptor("val-1", InterceptorType.Validation, (req, _) =>
{
executionOrder.Add("validation");
return new ValueTask<InterceptorResult>(ValidationInterceptorResult.Success());
});
var sink = CreateInterceptor("sink-1", InterceptorType.Sink, (req, _) =>
{
executionOrder.Add("sink");
return new ValueTask<InterceptorResult>(new SinkInterceptorResult { Recorded = true });
});
var result = await RunAsync(
[mutation, validation, sink],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Response,
Payload = JsonNode.Parse("""{"test":true}""")!,
},
CancellationToken.None);
Assert.Equal(InterceptorChainStatus.Success, result.Status);
Assert.Equal(["validation", "sink", "mutation"], executionOrder);
}
[Fact]
public async Task MutationsExecuteSequentiallyByPriority()
{
var executionOrder = new List<string>();
var mutHigh = CreateInterceptor("mut-high", InterceptorType.Mutation, (req, _) =>
{
executionOrder.Add("high-priority");
return new ValueTask<InterceptorResult>(new MutationInterceptorResult { Modified = false });
}, priorityHint: 100);
var mutLow = CreateInterceptor("mut-low", InterceptorType.Mutation, (req, _) =>
{
executionOrder.Add("low-priority");
return new ValueTask<InterceptorResult>(new MutationInterceptorResult { Modified = false });
}, priorityHint: -100);
var mutDefault = CreateInterceptor("mut-default", InterceptorType.Mutation, (req, _) =>
{
executionOrder.Add("default-priority");
return new ValueTask<InterceptorResult>(new MutationInterceptorResult { Modified = false });
}, priorityHint: 0);
var result = await RunAsync(
[mutHigh, mutLow, mutDefault],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{}""")!,
},
CancellationToken.None);
Assert.Equal(InterceptorChainStatus.Success, result.Status);
Assert.Equal(["low-priority", "default-priority", "high-priority"], executionOrder);
}
[Fact]
public async Task MutationsChainPayloads()
{
var mut1 = CreateInterceptor("mut-1", InterceptorType.Mutation, (req, _) =>
{
var obj = req.Payload.AsObject();
obj["step1"] = true;
return new ValueTask<InterceptorResult>(new MutationInterceptorResult { Modified = true, Payload = obj });
}, priorityHint: 0);
var mut2 = CreateInterceptor("mut-2", InterceptorType.Mutation, (req, _) =>
{
Assert.True(req.Payload["step1"]!.GetValue<bool>()); // Verify we got mut1's output
var obj = req.Payload.AsObject();
obj["step2"] = true;
return new ValueTask<InterceptorResult>(new MutationInterceptorResult { Modified = true, Payload = obj });
}, priorityHint: 1);
var result = await RunAsync(
[mut1, mut2],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{"original":true}""")!,
},
CancellationToken.None);
Assert.Equal(InterceptorChainStatus.Success, result.Status);
Assert.True(result.FinalPayload!["original"]!.GetValue<bool>());
Assert.True(result.FinalPayload!["step1"]!.GetValue<bool>());
Assert.True(result.FinalPayload!["step2"]!.GetValue<bool>());
}
[Fact]
public async Task ValidationErrorAbortsChain()
{
var validation = CreateInterceptor("strict-val", InterceptorType.Validation, (req, _) =>
{
return new ValueTask<InterceptorResult>(ValidationInterceptorResult.Failure(
new ValidationMessage { Message = "Required field missing", Severity = ValidationSeverity.Error }));
});
var result = await RunAsync(
[validation],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{}""")!,
},
CancellationToken.None);
Assert.Equal(InterceptorChainStatus.ValidationFailed, result.Status);
Assert.NotNull(result.AbortedAt);
Assert.Equal("strict-val", result.AbortedAt!.Interceptor);
Assert.Equal("validation", result.AbortedAt.Type);
}
[Fact]
public async Task AuditMutation_RecordsResultButDoesNotApplyPayload()
{
var shadowMutation = CreateInterceptor("shadow", InterceptorType.Mutation, (req, _) =>
{
var proposed = JsonNode.Parse("""{"shadowed":true}""")!;
return new ValueTask<InterceptorResult>(new MutationInterceptorResult { Modified = true, Payload = proposed });
}, mode: InterceptorMode.Audit);
var result = await RunAsync(
[shadowMutation],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{"original":true}""")!,
},
CancellationToken.None);
Assert.Equal(InterceptorChainStatus.Success, result.Status);
Assert.Single(result.Results);
Assert.True(result.FinalPayload!["original"]!.GetValue<bool>());
Assert.Null(result.FinalPayload["shadowed"]);
var recorded = Assert.IsType<MutationInterceptorResult>(result.Results[0]);
Assert.True(recorded.Modified);
Assert.True(recorded.Payload!["shadowed"]!.GetValue<bool>());
}
[Fact]
public async Task AuditValidation_DoesNotBlockOnError()
{
var auditor = CreateInterceptor("auditor", InterceptorType.Validation, (req, _) =>
{
return new ValueTask<InterceptorResult>(ValidationInterceptorResult.Failure(
new ValidationMessage { Message = "Audit-only violation", Severity = ValidationSeverity.Error }));
}, mode: InterceptorMode.Audit);
var result = await RunAsync(
[auditor],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{}""")!,
},
CancellationToken.None);
Assert.Equal(InterceptorChainStatus.Success, result.Status);
Assert.Equal(1, result.ValidationSummary!.Errors);
Assert.Null(result.AbortedAt);
}
[Fact]
public async Task FailOpenMutation_AllowsChainToContinueOnCrash()
{
var crashing = CreateInterceptor("crashing", InterceptorType.Mutation, (req, _) =>
{
throw new InvalidOperationException("boom");
}, failOpen: true);
var following = CreateInterceptor("following", InterceptorType.Mutation, (req, _) =>
{
var payload = req.Payload.AsObject();
payload["reached"] = true;
return new ValueTask<InterceptorResult>(new MutationInterceptorResult { Modified = true, Payload = payload });
}, priorityHint: 1);
var result = await RunAsync(
[crashing, following],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{}""")!,
},
CancellationToken.None);
Assert.Equal(InterceptorChainStatus.Success, result.Status);
Assert.True(result.FinalPayload!["reached"]!.GetValue<bool>());
}
[Fact]
public async Task FailClosedMutation_HaltsChainOnCrash()
{
var crashing = CreateInterceptor("crashing", InterceptorType.Mutation, (req, _) =>
{
throw new InvalidOperationException("boom");
});
var result = await RunAsync(
[crashing],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{}""")!,
},
CancellationToken.None);
Assert.Equal(InterceptorChainStatus.MutationFailed, result.Status);
Assert.Equal("crashing", result.AbortedAt!.Interceptor);
}
[Fact]
public async Task FailOpenValidation_AllowsChainToContinueOnCrash()
{
var crashing = CreateInterceptor("crashing-validator", InterceptorType.Validation, (req, _) =>
{
throw new InvalidOperationException("validator boom");
}, failOpen: true);
var result = await RunAsync(
[crashing],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{}""")!,
},
CancellationToken.None);
Assert.Equal(InterceptorChainStatus.Success, result.Status);
}
[Fact]
public async Task FailClosedValidation_HaltsChainOnCrash()
{
var crashing = CreateInterceptor("crashing-validator", InterceptorType.Validation, (req, _) =>
{
throw new InvalidOperationException("validator boom");
});
var result = await RunAsync(
[crashing],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{}""")!,
},
CancellationToken.None);
Assert.Equal(InterceptorChainStatus.ValidationFailed, result.Status);
Assert.Equal("crashing-validator", result.AbortedAt!.Interceptor);
}
[Fact]
public async Task SinkFailuresAreSwallowed()
{
var sink = CreateInterceptor("failing-sink", InterceptorType.Sink, (req, _) =>
{
throw new InvalidOperationException("Sink failure");
});
var result = await RunAsync(
[sink],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{}""")!,
},
CancellationToken.None);
Assert.Equal(InterceptorChainStatus.Success, result.Status);
Assert.Single(result.Results);
var sinkResult = Assert.IsType<SinkInterceptorResult>(result.Results[0]);
Assert.False(sinkResult.Recorded);
}
[Fact]
public async Task FiltersInterceptorsByEvent()
{
var toolsInterceptor = CreateInterceptor("tools-only", InterceptorType.Validation, (req, _) =>
{
return new ValueTask<InterceptorResult>(ValidationInterceptorResult.Success());
}, events: [InterceptionEvents.ToolsCall]);
var promptsInterceptor = CreateInterceptor("prompts-only", InterceptorType.Validation, (req, _) =>
{
return new ValueTask<InterceptorResult>(ValidationInterceptorResult.Success());
}, events: [InterceptionEvents.PromptsGet]);
var result = await RunAsync(
[toolsInterceptor, promptsInterceptor],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{}""")!,
},
CancellationToken.None);
Assert.Equal(InterceptorChainStatus.Success, result.Status);
Assert.Single(result.Results); // Only the tools interceptor ran
}
[Fact]
public async Task FiltersInterceptorsByPhase()
{
var requestOnly = CreateInterceptor("request-only", InterceptorType.Validation, (req, _) =>
{
return new ValueTask<InterceptorResult>(ValidationInterceptorResult.Success());
}, phase: InterceptorPhase.Request);
var responseOnly = CreateInterceptor("response-only", InterceptorType.Validation, (req, _) =>
{
return new ValueTask<InterceptorResult>(ValidationInterceptorResult.Success());
}, phase: InterceptorPhase.Response);
var result = await RunAsync(
[requestOnly, responseOnly],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{}""")!,
},
CancellationToken.None);
Assert.Equal(InterceptorChainStatus.Success, result.Status);
Assert.Single(result.Results);
Assert.Equal("request-only", result.Results[0].InterceptorName);
}
[Fact]
public async Task ValidationSummaryCountsCorrectly()
{
var val = CreateInterceptor("val", InterceptorType.Validation, (req, _) =>
{
return new ValueTask<InterceptorResult>(new ValidationInterceptorResult
{
Valid = true,
Messages =
[
new ValidationMessage { Message = "Info", Severity = ValidationSeverity.Info },
new ValidationMessage { Message = "Warn 1", Severity = ValidationSeverity.Warn },
new ValidationMessage { Message = "Warn 2", Severity = ValidationSeverity.Warn },
],
});
});
var result = await RunAsync(
[val],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{}""")!,
},
CancellationToken.None);
Assert.Equal(InterceptorChainStatus.Success, result.Status);
Assert.Equal(0, result.ValidationSummary!.Errors);
Assert.Equal(2, result.ValidationSummary.Warnings);
Assert.Equal(1, result.ValidationSummary.Infos);
}
// ── Helpers ─────────────────────────────────────────────────────────────
private static ValueTask<InterceptorChainResult> RunAsync(
IEnumerable<TestEntry> entries,
ExecuteChainRequestParams chainParams,
CancellationToken ct)
{
var handlerByName = entries.ToDictionary(e => e.Descriptor.Name, e => e.Handler);
return InterceptorChainOrchestrator.ExecuteAsync(
entries.Select(e => e.Descriptor),
(req, c) => handlerByName[req.Name](req, c),
chainParams,
ct);
}
private static TestEntry CreateInterceptor(
string name,
InterceptorType type,
Func<InvokeInterceptorRequestParams, CancellationToken, ValueTask<InterceptorResult>> handler,
int priorityHint = 0,
string[]? events = null,
InterceptorPhase phase = InterceptorPhase.Both,
InterceptorMode? mode = null,
bool? failOpen = null)
{
var ev = events ?? [InterceptionEvents.All];
var hooks = phase switch
{
InterceptorPhase.Both =>
[
new InterceptorHook { Events = ev.ToList(), Phase = InterceptorPhase.Request },
new InterceptorHook { Events = ev.ToList(), Phase = InterceptorPhase.Response },
],
_ => new List<InterceptorHook> { new() { Events = ev.ToList(), Phase = phase } },
};
var descriptor = new Interceptor
{
Name = name,
Type = type,
Hooks = hooks,
Mode = mode,
FailOpen = failOpen,
PriorityHint = priorityHint,
};
return new TestEntry(descriptor, handler);
}
private sealed record TestEntry(
Interceptor Descriptor,
Func<InvokeInterceptorRequestParams, CancellationToken, ValueTask<InterceptorResult>> Handler);
}

View file

@ -1,26 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ModelContextProtocol.Interceptors\ModelContextProtocol.Interceptors.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,383 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using ModelContextProtocol.Interceptors;
using ModelContextProtocol.Interceptors.Protocol;
using Xunit;
namespace ModelContextProtocol.Interceptors.Tests;
public class ProtocolTypesSerializationTests
{
private static readonly JsonSerializerOptions Options = InterceptorJsonUtilities.DefaultOptions;
[Fact]
public void InterceptorType_SerializesAsString()
{
Assert.Equal("\"validation\"", JsonSerializer.Serialize(InterceptorType.Validation, Options));
Assert.Equal("\"mutation\"", JsonSerializer.Serialize(InterceptorType.Mutation, Options));
Assert.Equal("\"sink\"", JsonSerializer.Serialize(InterceptorType.Sink, Options));
}
[Fact]
public void InterceptorType_DeserializesFromString()
{
Assert.Equal(InterceptorType.Validation, JsonSerializer.Deserialize<InterceptorType>("\"validation\"", Options));
Assert.Equal(InterceptorType.Mutation, JsonSerializer.Deserialize<InterceptorType>("\"mutation\"", Options));
Assert.Equal(InterceptorType.Sink, JsonSerializer.Deserialize<InterceptorType>("\"sink\"", Options));
}
[Fact]
public void InterceptorPhase_SerializesAsString()
{
Assert.Equal("\"request\"", JsonSerializer.Serialize(InterceptorPhase.Request, Options));
Assert.Equal("\"response\"", JsonSerializer.Serialize(InterceptorPhase.Response, Options));
Assert.Equal("\"both\"", JsonSerializer.Serialize(InterceptorPhase.Both, Options));
}
[Fact]
public void ValidationSeverity_SerializesAsString()
{
Assert.Equal("\"error\"", JsonSerializer.Serialize(ValidationSeverity.Error, Options));
Assert.Equal("\"warn\"", JsonSerializer.Serialize(ValidationSeverity.Warn, Options));
Assert.Equal("\"info\"", JsonSerializer.Serialize(ValidationSeverity.Info, Options));
}
[Fact]
public void InterceptorChainStatus_SerializesAsString()
{
Assert.Equal("\"success\"", JsonSerializer.Serialize(InterceptorChainStatus.Success, Options));
Assert.Equal("\"validation_failed\"", JsonSerializer.Serialize(InterceptorChainStatus.ValidationFailed, Options));
Assert.Equal("\"mutation_failed\"", JsonSerializer.Serialize(InterceptorChainStatus.MutationFailed, Options));
Assert.Equal("\"timeout\"", JsonSerializer.Serialize(InterceptorChainStatus.Timeout, Options));
}
[Fact]
public void Interceptor_RoundTrips()
{
var interceptor = new Interceptor
{
Name = "pii-validator",
Version = "1.0.0",
Description = "Validates PII in payloads",
Type = InterceptorType.Validation,
Hooks =
[
new InterceptorHook { Events = [InterceptionEvents.ToolsCall, InterceptionEvents.PromptsGet], Phase = InterceptorPhase.Request },
new InterceptorHook { Events = [InterceptionEvents.ToolsCall, InterceptionEvents.PromptsGet], Phase = InterceptorPhase.Response },
],
PriorityHint = -1000,
Compat = new InterceptorCompatibility { MinProtocol = "2024-11-05" },
};
var json = JsonSerializer.Serialize(interceptor, Options);
var deserialized = JsonSerializer.Deserialize<Interceptor>(json, Options)!;
Assert.Equal("pii-validator", deserialized.Name);
Assert.Equal("1.0.0", deserialized.Version);
Assert.Equal("Validates PII in payloads", deserialized.Description);
Assert.Equal(2, deserialized.Hooks.Count);
Assert.Equal(2, deserialized.Hooks[0].Events.Count);
Assert.Equal(InterceptorPhase.Request, deserialized.Hooks[0].Phase);
Assert.Equal(InterceptorPhase.Response, deserialized.Hooks[1].Phase);
Assert.Equal(InterceptorType.Validation, deserialized.Type);
Assert.Equal(-1000, deserialized.PriorityHint);
Assert.NotNull(deserialized.Compat);
Assert.Equal("2024-11-05", deserialized.Compat.MinProtocol);
}
[Fact]
public void Interceptor_OmitsNullFields()
{
var interceptor = new Interceptor
{
Name = "test",
Type = InterceptorType.Sink,
Hooks = [new InterceptorHook { Events = [InterceptionEvents.All], Phase = InterceptorPhase.Request }],
};
var json = JsonSerializer.Serialize(interceptor, Options);
var doc = JsonDocument.Parse(json);
Assert.False(doc.RootElement.TryGetProperty("version", out _));
Assert.False(doc.RootElement.TryGetProperty("description", out _));
Assert.False(doc.RootElement.TryGetProperty("priorityHint", out _));
Assert.False(doc.RootElement.TryGetProperty("compat", out _));
Assert.False(doc.RootElement.TryGetProperty("configSchema", out _));
Assert.False(doc.RootElement.TryGetProperty("_meta", out _));
Assert.False(doc.RootElement.TryGetProperty("mode", out _));
Assert.False(doc.RootElement.TryGetProperty("failOpen", out _));
}
[Fact]
public void InterceptorMode_SerializesAsString()
{
Assert.Equal("\"active\"", JsonSerializer.Serialize(InterceptorMode.Active, Options));
Assert.Equal("\"audit\"", JsonSerializer.Serialize(InterceptorMode.Audit, Options));
}
[Fact]
public void Interceptor_RoundTripsModeAndFailOpen()
{
var interceptor = new Interceptor
{
Name = "audit-validator",
Type = InterceptorType.Validation,
Hooks = [new InterceptorHook { Events = [InterceptionEvents.ToolsCall], Phase = InterceptorPhase.Request }],
Mode = InterceptorMode.Audit,
FailOpen = true,
};
var json = JsonSerializer.Serialize(interceptor, Options);
Assert.Contains("\"mode\":\"audit\"", json);
Assert.Contains("\"failOpen\":true", json);
var deserialized = JsonSerializer.Deserialize<Interceptor>(json, Options)!;
Assert.Equal(InterceptorMode.Audit, deserialized.Mode);
Assert.True(deserialized.FailOpen);
}
[Fact]
public void ValidationInterceptorResult_RoundTrips()
{
var result = new ValidationInterceptorResult
{
InterceptorName = "pii-validator",
Phase = InterceptorPhase.Request,
Valid = false,
Severity = ValidationSeverity.Error,
DurationMs = 42,
Messages =
[
new ValidationMessage { Path = "$.arguments.email", Message = "Contains PII", Severity = ValidationSeverity.Error },
],
Suggestions =
[
new ValidationSuggestion { Path = "$.arguments.email", Value = JsonNode.Parse("\"[REDACTED]\"") },
],
};
var json = JsonSerializer.Serialize<InterceptorResult>(result, Options);
Assert.Contains("\"type\":\"validation\"", json);
var deserialized = JsonSerializer.Deserialize<InterceptorResult>(json, Options);
var validation = Assert.IsType<ValidationInterceptorResult>(deserialized);
Assert.Equal("pii-validator", validation.InterceptorName);
Assert.False(validation.Valid);
Assert.Equal(ValidationSeverity.Error, validation.Severity);
Assert.Single(validation.Messages!);
Assert.Equal("$.arguments.email", validation.Messages![0].Path);
Assert.Single(validation.Suggestions!);
}
[Fact]
public void MutationInterceptorResult_RoundTrips()
{
var result = new MutationInterceptorResult
{
InterceptorName = "pii-redactor",
Phase = InterceptorPhase.Request,
Modified = true,
Payload = JsonNode.Parse("""{"email":"[REDACTED]"}"""),
};
var json = JsonSerializer.Serialize<InterceptorResult>(result, Options);
Assert.Contains("\"type\":\"mutation\"", json);
var deserialized = JsonSerializer.Deserialize<InterceptorResult>(json, Options);
var mutation = Assert.IsType<MutationInterceptorResult>(deserialized);
Assert.True(mutation.Modified);
Assert.NotNull(mutation.Payload);
Assert.Equal("[REDACTED]", mutation.Payload!["email"]!.GetValue<string>());
}
[Fact]
public void SinkInterceptorResult_RoundTrips()
{
var result = new SinkInterceptorResult
{
InterceptorName = "logger",
Phase = InterceptorPhase.Request,
Recorded = true,
Metrics = new Dictionary<string, double> { ["latencyMs"] = 12.5, ["payloadBytes"] = 256 },
};
var json = JsonSerializer.Serialize<InterceptorResult>(result, Options);
Assert.Contains("\"type\":\"sink\"", json);
var deserialized = JsonSerializer.Deserialize<InterceptorResult>(json, Options);
var sink = Assert.IsType<SinkInterceptorResult>(deserialized);
Assert.True(sink.Recorded);
Assert.Equal(2, sink.Metrics!.Count);
Assert.Equal(12.5, sink.Metrics["latencyMs"]);
}
[Fact]
public void ListInterceptorsResult_RoundTrips()
{
var result = new ListInterceptorsResult
{
Interceptors =
[
new Interceptor
{
Name = "a",
Type = InterceptorType.Validation,
Hooks = [new InterceptorHook { Events = ["tools/call"], Phase = InterceptorPhase.Request }],
},
new Interceptor
{
Name = "b",
Type = InterceptorType.Sink,
Hooks =
[
new InterceptorHook { Events = ["*"], Phase = InterceptorPhase.Request },
new InterceptorHook { Events = ["*"], Phase = InterceptorPhase.Response },
],
},
],
NextCursor = "abc123",
};
var json = JsonSerializer.Serialize(result, Options);
var deserialized = JsonSerializer.Deserialize<ListInterceptorsResult>(json, Options)!;
Assert.Equal(2, deserialized.Interceptors.Count);
Assert.Equal("abc123", deserialized.NextCursor);
}
[Fact]
public void InvokeInterceptorRequestParams_RoundTrips()
{
var request = new InvokeInterceptorRequestParams
{
Name = "pii-validator",
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{"name":"call-tool","arguments":{"query":"test"}}""")!,
TimeoutMs = 5000,
Context = new InvokeInterceptorContext
{
Principal = new InterceptorPrincipal { Type = "user", Id = "user-123" },
TraceId = "trace-abc",
Timestamp = "2025-01-01T00:00:00Z",
SessionId = "session-xyz",
},
};
var json = JsonSerializer.Serialize(request, Options);
var deserialized = JsonSerializer.Deserialize<InvokeInterceptorRequestParams>(json, Options)!;
Assert.Equal("pii-validator", deserialized.Name);
Assert.Equal(InterceptionEvents.ToolsCall, deserialized.Event);
Assert.Equal(InterceptorPhase.Request, deserialized.Phase);
Assert.Equal(5000, deserialized.TimeoutMs);
Assert.NotNull(deserialized.Context);
Assert.Equal("user", deserialized.Context!.Principal!.Type);
Assert.Equal("user-123", deserialized.Context.Principal.Id);
}
[Fact]
public void ExecuteChainRequestParams_RoundTrips()
{
var request = new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{"test":true}""")!,
InterceptorNames = ["pii-validator", "content-filter"],
TimeoutMs = 10000,
};
var json = JsonSerializer.Serialize(request, Options);
var deserialized = JsonSerializer.Deserialize<ExecuteChainRequestParams>(json, Options)!;
Assert.Equal(InterceptionEvents.ToolsCall, deserialized.Event);
Assert.Equal(2, deserialized.InterceptorNames!.Count);
Assert.Equal(10000, deserialized.TimeoutMs);
}
[Fact]
public void InterceptorChainResult_RoundTrips()
{
var chainResult = new InterceptorChainResult
{
Status = InterceptorChainStatus.Success,
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Results =
[
new MutationInterceptorResult
{
InterceptorName = "redactor",
Phase = InterceptorPhase.Request,
Modified = true,
Payload = JsonNode.Parse("""{"redacted":true}"""),
},
new ValidationInterceptorResult
{
InterceptorName = "validator",
Phase = InterceptorPhase.Request,
Valid = true,
},
],
FinalPayload = JsonNode.Parse("""{"redacted":true}"""),
ValidationSummary = new ChainValidationSummary { Errors = 0, Warnings = 1, Infos = 2 },
TotalDurationMs = 150,
};
var json = JsonSerializer.Serialize(chainResult, Options);
var deserialized = JsonSerializer.Deserialize<InterceptorChainResult>(json, Options)!;
Assert.Equal(InterceptorChainStatus.Success, deserialized.Status);
Assert.Equal(2, deserialized.Results.Count);
Assert.IsType<MutationInterceptorResult>(deserialized.Results[0]);
Assert.IsType<ValidationInterceptorResult>(deserialized.Results[1]);
Assert.Equal(150, deserialized.TotalDurationMs);
Assert.NotNull(deserialized.ValidationSummary);
Assert.Equal(1, deserialized.ValidationSummary!.Warnings);
}
[Fact]
public void InterceptorChainResult_WithAbort_RoundTrips()
{
var chainResult = new InterceptorChainResult
{
Status = InterceptorChainStatus.ValidationFailed,
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Results = [],
TotalDurationMs = 50,
AbortedAt = new ChainAbortInfo
{
Interceptor = "strict-validator",
Reason = "Required field missing",
Type = "validation",
},
};
var json = JsonSerializer.Serialize(chainResult, Options);
var deserialized = JsonSerializer.Deserialize<InterceptorChainResult>(json, Options)!;
Assert.Equal(InterceptorChainStatus.ValidationFailed, deserialized.Status);
Assert.NotNull(deserialized.AbortedAt);
Assert.Equal("strict-validator", deserialized.AbortedAt!.Interceptor);
}
[Fact]
public void InterceptorsCapability_RoundTrips()
{
var capability = new InterceptorsCapability
{
SupportedEvents = [InterceptionEvents.ToolsCall, InterceptionEvents.ToolsList, InterceptionEvents.PromptsGet],
};
var json = JsonSerializer.Serialize(capability, Options);
var deserialized = JsonSerializer.Deserialize<InterceptorsCapability>(json, Options)!;
Assert.Equal(3, deserialized.SupportedEvents.Count);
Assert.Contains(InterceptionEvents.ToolsCall, deserialized.SupportedEvents);
}
}

View file

@ -1,328 +0,0 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using ModelContextProtocol.Interceptors;
using ModelContextProtocol.Interceptors.Protocol.Llm;
namespace ModelContextProtocol.Interceptors.Tests;
public class ProtocolTypesTests
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
#region InterceptorType Serialization
[Theory]
[InlineData(InterceptorType.Validation, "\"validation\"")]
[InlineData(InterceptorType.Mutation, "\"mutation\"")]
[InlineData(InterceptorType.Observability, "\"observability\"")]
public void InterceptorType_SerializesToCorrectJsonString(InterceptorType type, string expected)
{
var json = JsonSerializer.Serialize(type, JsonOptions);
Assert.Equal(expected, json);
}
[Theory]
[InlineData("\"validation\"", InterceptorType.Validation)]
[InlineData("\"mutation\"", InterceptorType.Mutation)]
[InlineData("\"observability\"", InterceptorType.Observability)]
public void InterceptorType_DeserializesFromJsonString(string json, InterceptorType expected)
{
var type = JsonSerializer.Deserialize<InterceptorType>(json, JsonOptions);
Assert.Equal(expected, type);
}
#endregion
#region InterceptorPhase Serialization
[Theory]
[InlineData(InterceptorPhase.Request, "\"request\"")]
[InlineData(InterceptorPhase.Response, "\"response\"")]
[InlineData(InterceptorPhase.Both, "\"both\"")]
public void InterceptorPhase_SerializesToCorrectJsonString(InterceptorPhase phase, string expected)
{
var json = JsonSerializer.Serialize(phase, JsonOptions);
Assert.Equal(expected, json);
}
#endregion
#region InterceptorPriorityHint Serialization
[Fact]
public void InterceptorPriorityHint_SerializesAsNumber_WhenBothPhasesEqual()
{
var hint = new InterceptorPriorityHint(100);
var json = JsonSerializer.Serialize(hint, JsonOptions);
Assert.Equal("100", json);
}
[Fact]
public void InterceptorPriorityHint_SerializesAsObject_WhenPhasesDiffer()
{
var hint = new InterceptorPriorityHint(-100, 50);
var json = JsonSerializer.Serialize(hint, JsonOptions);
var obj = JsonSerializer.Deserialize<JsonObject>(json);
Assert.NotNull(obj);
Assert.Equal(-100, obj["request"]!.GetValue<int>());
Assert.Equal(50, obj["response"]!.GetValue<int>());
}
[Fact]
public void InterceptorPriorityHint_DeserializesFromNumber()
{
var hint = JsonSerializer.Deserialize<InterceptorPriorityHint>("100", JsonOptions);
Assert.Equal(100, hint.GetPriorityForPhase(InterceptorPhase.Request));
Assert.Equal(100, hint.GetPriorityForPhase(InterceptorPhase.Response));
}
[Fact]
public void InterceptorPriorityHint_DeserializesFromObject()
{
var json = """{"request": -100, "response": 50}""";
var hint = JsonSerializer.Deserialize<InterceptorPriorityHint>(json, JsonOptions);
Assert.Equal(-100, hint.GetPriorityForPhase(InterceptorPhase.Request));
Assert.Equal(50, hint.GetPriorityForPhase(InterceptorPhase.Response));
}
#endregion
#region Interceptor Serialization
[Fact]
public void Interceptor_SerializesWithAllFields()
{
var interceptor = new Interceptor
{
Name = "test-interceptor",
Version = "1.0.0",
Description = "A test interceptor",
Events = [InterceptorEvents.ToolsCall, InterceptorEvents.LlmCompletion],
Type = InterceptorType.Validation,
Phase = InterceptorPhase.Request,
PriorityHint = new InterceptorPriorityHint(100)
};
var json = JsonSerializer.Serialize(interceptor, JsonOptions);
var deserialized = JsonSerializer.Deserialize<Interceptor>(json, JsonOptions);
Assert.NotNull(deserialized);
Assert.Equal("test-interceptor", deserialized.Name);
Assert.Equal("1.0.0", deserialized.Version);
Assert.Equal("A test interceptor", deserialized.Description);
Assert.Contains(InterceptorEvents.ToolsCall, deserialized.Events);
Assert.Contains(InterceptorEvents.LlmCompletion, deserialized.Events);
Assert.Equal(InterceptorType.Validation, deserialized.Type);
Assert.Equal(InterceptorPhase.Request, deserialized.Phase);
Assert.NotNull(deserialized.PriorityHint);
Assert.Equal(100, deserialized.PriorityHint.Value.GetPriorityForPhase(InterceptorPhase.Request));
}
#endregion
#region ValidationInterceptorResult Serialization
[Fact]
public void ValidationInterceptorResult_SerializesCorrectly()
{
var result = new ValidationInterceptorResult
{
Valid = false,
Severity = ValidationSeverity.Error,
Messages =
[
new() { Path = "$.name", Message = "Name is required", Severity = ValidationSeverity.Error }
],
Suggestions =
[
new() { Path = "$.name", Value = JsonNode.Parse("\"default\"") }
]
};
var json = JsonSerializer.Serialize(result, JsonOptions);
var deserialized = JsonSerializer.Deserialize<ValidationInterceptorResult>(json, JsonOptions);
Assert.NotNull(deserialized);
Assert.False(deserialized.Valid);
Assert.Equal(ValidationSeverity.Error, deserialized.Severity);
Assert.Single(deserialized.Messages!);
Assert.Equal("$.name", deserialized.Messages![0].Path);
Assert.Single(deserialized.Suggestions!);
}
#endregion
#region MutationInterceptorResult Serialization
[Fact]
public void MutationInterceptorResult_SerializesCorrectly()
{
var result = MutationInterceptorResult.Mutated(JsonNode.Parse("{\"modified\": true}"));
var json = JsonSerializer.Serialize(result, JsonOptions);
var deserialized = JsonSerializer.Deserialize<MutationInterceptorResult>(json, JsonOptions);
Assert.NotNull(deserialized);
Assert.True(deserialized.Modified);
Assert.NotNull(deserialized.Payload);
Assert.True(deserialized.Payload["modified"]!.GetValue<bool>());
}
#endregion
#region InterceptorChainResult Serialization
[Fact]
public void InterceptorChainStatus_SerializesCorrectly()
{
// Test chain status enum serialization
Assert.Equal("\"success\"", JsonSerializer.Serialize(InterceptorChainStatus.Success, JsonOptions));
Assert.Equal("\"validation_failed\"", JsonSerializer.Serialize(InterceptorChainStatus.ValidationFailed, JsonOptions));
Assert.Equal("\"mutation_failed\"", JsonSerializer.Serialize(InterceptorChainStatus.MutationFailed, JsonOptions));
Assert.Equal("\"timeout\"", JsonSerializer.Serialize(InterceptorChainStatus.Timeout, JsonOptions));
}
[Fact]
public void ValidationSummary_SerializesCorrectly()
{
var summary = new ValidationSummary { Errors = 1, Warnings = 2, Infos = 3 };
var json = JsonSerializer.Serialize(summary, JsonOptions);
var deserialized = JsonSerializer.Deserialize<ValidationSummary>(json, JsonOptions);
Assert.NotNull(deserialized);
Assert.Equal(1, deserialized.Errors);
Assert.Equal(2, deserialized.Warnings);
Assert.Equal(3, deserialized.Infos);
}
[Fact]
public void ChainAbortInfo_SerializesCorrectly()
{
var abortInfo = new ChainAbortInfo
{
Interceptor = "pii-validator",
Reason = "PII detected in request",
Type = "validation"
};
var json = JsonSerializer.Serialize(abortInfo, JsonOptions);
var deserialized = JsonSerializer.Deserialize<ChainAbortInfo>(json, JsonOptions);
Assert.NotNull(deserialized);
Assert.Equal("pii-validator", deserialized.Interceptor);
Assert.Equal("PII detected in request", deserialized.Reason);
Assert.Equal("validation", deserialized.Type);
}
#endregion
#region LlmCompletionRequest Serialization
[Fact]
public void LlmCompletionRequest_SerializesCorrectly()
{
var request = new LlmCompletionRequest
{
Model = "gpt-4",
Messages =
[
LlmMessage.System("You are a helpful assistant."),
LlmMessage.User("Hello!")
],
Temperature = 0.7,
MaxTokens = 1000,
TopP = 0.9
};
var json = JsonSerializer.Serialize(request, JsonOptions);
var deserialized = JsonSerializer.Deserialize<LlmCompletionRequest>(json, JsonOptions);
Assert.NotNull(deserialized);
Assert.Equal("gpt-4", deserialized.Model);
Assert.Equal(2, deserialized.Messages.Count);
Assert.Equal(LlmMessageRole.System, deserialized.Messages[0].Role);
Assert.Equal("You are a helpful assistant.", deserialized.Messages[0].Content);
Assert.Equal(LlmMessageRole.User, deserialized.Messages[1].Role);
Assert.Equal(0.7, deserialized.Temperature);
Assert.Equal(1000, deserialized.MaxTokens);
}
[Fact]
public void LlmMessage_ToolMessage_SerializesCorrectly()
{
var message = LlmMessage.Tool("call_abc123", "{\"result\": \"success\"}", "get_weather");
var json = JsonSerializer.Serialize(message, JsonOptions);
var deserialized = JsonSerializer.Deserialize<LlmMessage>(json, JsonOptions);
Assert.NotNull(deserialized);
Assert.Equal(LlmMessageRole.Tool, deserialized.Role);
Assert.Equal("call_abc123", deserialized.ToolCallId);
Assert.Equal("{\"result\": \"success\"}", deserialized.Content);
Assert.Equal("get_weather", deserialized.Name);
}
[Fact]
public void LlmMessage_AssistantWithToolCalls_SerializesCorrectly()
{
var message = LlmMessage.Assistant(null, [
new LlmToolCall
{
Id = "call_abc123",
Type = "function",
Function = new LlmFunctionCall
{
Name = "get_weather",
Arguments = "{\"location\": \"NYC\"}"
}
}
]);
var json = JsonSerializer.Serialize(message, JsonOptions);
var deserialized = JsonSerializer.Deserialize<LlmMessage>(json, JsonOptions);
Assert.NotNull(deserialized);
Assert.Equal(LlmMessageRole.Assistant, deserialized.Role);
Assert.NotNull(deserialized.ToolCalls);
Assert.Single(deserialized.ToolCalls);
Assert.Equal("call_abc123", deserialized.ToolCalls[0].Id);
Assert.Equal("get_weather", deserialized.ToolCalls[0].Function!.Name);
}
#endregion
#region InterceptorEvents Constants
[Fact]
public void InterceptorEvents_HasAllRequiredEvents()
{
// Server features
Assert.Equal("tools/list", InterceptorEvents.ToolsList);
Assert.Equal("tools/call", InterceptorEvents.ToolsCall);
Assert.Equal("prompts/list", InterceptorEvents.PromptsList);
Assert.Equal("prompts/get", InterceptorEvents.PromptsGet);
Assert.Equal("resources/list", InterceptorEvents.ResourcesList);
Assert.Equal("resources/read", InterceptorEvents.ResourcesRead);
Assert.Equal("resources/subscribe", InterceptorEvents.ResourcesSubscribe);
// Client features
Assert.Equal("sampling/createMessage", InterceptorEvents.SamplingCreateMessage);
Assert.Equal("elicitation/create", InterceptorEvents.ElicitationCreate);
Assert.Equal("roots/list", InterceptorEvents.RootsList);
// LLM interactions
Assert.Equal("llm/completion", InterceptorEvents.LlmCompletion);
// Wildcards
Assert.Equal("*/request", InterceptorEvents.AllRequests);
Assert.Equal("*/response", InterceptorEvents.AllResponses);
Assert.Equal("*", InterceptorEvents.All);
}
#endregion
}

View file

@ -0,0 +1,192 @@
using System.Text.Json.Nodes;
using ModelContextProtocol.Interceptors.Protocol;
using ModelContextProtocol.Interceptors.Server;
using Xunit;
namespace ModelContextProtocol.Interceptors.Tests;
[McpServerInterceptorType]
public class TestInterceptors
{
[McpServerInterceptor(Name = "bool-validator", Type = InterceptorType.Validation, Events = ["tools/call"])]
public static bool ValidateWithBool(JsonNode payload) => payload["valid"]?.GetValue<bool>() ?? false;
[McpServerInterceptor(Name = "result-validator", Type = InterceptorType.Validation, Events = ["tools/call"])]
public static ValidationInterceptorResult ValidateWithResult(JsonNode payload, string @event, InterceptorPhase phase)
{
return new ValidationInterceptorResult
{
Valid = true,
Messages = [new ValidationMessage { Message = $"Validated {payload} for {@event} in {phase}", Severity = ValidationSeverity.Info }],
};
}
[McpServerInterceptor(Name = "mutator", Type = InterceptorType.Mutation, Events = ["tools/call"], PriorityHint = -100)]
public static MutationInterceptorResult Mutate(JsonNode payload)
{
var obj = payload.AsObject();
obj["mutated"] = true;
return new MutationInterceptorResult { Modified = true, Payload = obj };
}
[McpServerInterceptor(Name = "sink", Type = InterceptorType.Sink, Events = ["*"])]
public static SinkInterceptorResult Sink(JsonNode payload, InvokeInterceptorContext? context)
{
return new SinkInterceptorResult
{
Recorded = true,
Metrics = new Dictionary<string, double> { ["payloadSize"] = payload.ToJsonString().Length },
};
}
[McpServerInterceptor(Name = "async-validator", Type = InterceptorType.Validation, Events = ["tools/call"])]
public static async Task<ValidationInterceptorResult> ValidateAsync(JsonNode payload, CancellationToken ct)
{
await Task.Delay(1, ct);
return ValidationInterceptorResult.Success();
}
}
public class ReflectionMcpServerInterceptorTests
{
[Fact]
public void Create_FromMethodWithAttribute_ExtractsMetadata()
{
var method = typeof(TestInterceptors).GetMethod(nameof(TestInterceptors.ValidateWithBool))!;
var interceptor = ReflectionMcpServerInterceptor.Create(method, target: null);
Assert.Equal("bool-validator", interceptor.ProtocolInterceptor.Name);
Assert.Equal(InterceptorType.Validation, interceptor.ProtocolInterceptor.Type);
Assert.Contains(interceptor.ProtocolInterceptor.Hooks, h => h.Events.Contains("tools/call"));
}
[Fact]
public async Task Invoke_BoolReturn_WrapsAsValidationResult()
{
var method = typeof(TestInterceptors).GetMethod(nameof(TestInterceptors.ValidateWithBool))!;
var interceptor = ReflectionMcpServerInterceptor.Create(method, target: null);
var request = new InvokeInterceptorRequestParams
{
Name = "bool-validator",
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{"valid":true}""")!,
};
var result = await interceptor.InvokeAsync(request, null!, null, CancellationToken.None);
var validation = Assert.IsType<ValidationInterceptorResult>(result);
Assert.True(validation.Valid);
}
[Fact]
public async Task Invoke_BoolReturn_FalseWrapsAsInvalid()
{
var method = typeof(TestInterceptors).GetMethod(nameof(TestInterceptors.ValidateWithBool))!;
var interceptor = ReflectionMcpServerInterceptor.Create(method, target: null);
var request = new InvokeInterceptorRequestParams
{
Name = "bool-validator",
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{"valid":false}""")!,
};
var result = await interceptor.InvokeAsync(request, null!, null, CancellationToken.None);
var validation = Assert.IsType<ValidationInterceptorResult>(result);
Assert.False(validation.Valid);
}
[Fact]
public async Task Invoke_BindsEventAndPhase()
{
var method = typeof(TestInterceptors).GetMethod(nameof(TestInterceptors.ValidateWithResult))!;
var interceptor = ReflectionMcpServerInterceptor.Create(method, target: null);
var request = new InvokeInterceptorRequestParams
{
Name = "result-validator",
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Response,
Payload = JsonNode.Parse("""{"test":true}""")!,
};
var result = await interceptor.InvokeAsync(request, null!, null, CancellationToken.None);
var validation = Assert.IsType<ValidationInterceptorResult>(result);
Assert.True(validation.Valid);
Assert.Single(validation.Messages!);
Assert.Contains("tools/call", validation.Messages![0].Message);
Assert.Contains("Response", validation.Messages[0].Message);
}
[Fact]
public async Task Invoke_MutationReturnsModifiedPayload()
{
var method = typeof(TestInterceptors).GetMethod(nameof(TestInterceptors.Mutate))!;
var interceptor = ReflectionMcpServerInterceptor.Create(method, target: null);
Assert.Equal("mutator", interceptor.ProtocolInterceptor.Name);
Assert.Equal(-100, interceptor.ProtocolInterceptor.PriorityHint);
var request = new InvokeInterceptorRequestParams
{
Name = "mutator",
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{"original":true}""")!,
};
var result = await interceptor.InvokeAsync(request, null!, null, CancellationToken.None);
var mutation = Assert.IsType<MutationInterceptorResult>(result);
Assert.True(mutation.Modified);
Assert.True(mutation.Payload!["mutated"]!.GetValue<bool>());
}
[Fact]
public async Task Invoke_SinkBindsContext()
{
var method = typeof(TestInterceptors).GetMethod(nameof(TestInterceptors.Sink))!;
var interceptor = ReflectionMcpServerInterceptor.Create(method, target: null);
var request = new InvokeInterceptorRequestParams
{
Name = "sink",
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{"data":"test"}""")!,
Context = new InvokeInterceptorContext { TraceId = "trace-123" },
};
var result = await interceptor.InvokeAsync(request, null!, null, CancellationToken.None);
var sink = Assert.IsType<SinkInterceptorResult>(result);
Assert.True(sink.Recorded);
Assert.True(sink.Metrics!["payloadSize"] > 0);
}
[Fact]
public async Task Invoke_AsyncMethod_ReturnsCorrectResult()
{
var method = typeof(TestInterceptors).GetMethod(nameof(TestInterceptors.ValidateAsync))!;
var interceptor = ReflectionMcpServerInterceptor.Create(method, target: null);
var request = new InvokeInterceptorRequestParams
{
Name = "async-validator",
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{}""")!,
};
var result = await interceptor.InvokeAsync(request, null!, null, CancellationToken.None);
var validation = Assert.IsType<ValidationInterceptorResult>(result);
Assert.True(validation.Valid);
}
[Fact]
public void Create_MethodWithoutAttribute_Throws()
{
var method = typeof(string).GetMethod(nameof(string.ToString), Type.EmptyTypes)!;
Assert.Throws<InvalidOperationException>(() => ReflectionMcpServerInterceptor.Create(method, target: null));
}
}