using System.IO.Pipes; using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; using System.Collections; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using ModelContextProtocol.Client; using ModelContextProtocol.Interceptors.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 McpInterceptorGatewayTests { [Fact] public async Task ConfigureServerOptions_MirrorsToolsCapability() { // Create an in-memory backend with tools capability await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new() { ListChanged = true }; options.Handlers.ListToolsHandler = (request, ct) => { return new ValueTask(new ListToolsResult { Tools = [new Tool { Name = "test-tool", Description = "A test tool" }], }); }; options.Handlers.CallToolHandler = (request, ct) => { return new ValueTask(new CallToolResult { Content = [new TextContentBlock { Text = $"Called: {request.Params!.Name}" }], }); }; }); // Verify the proxy client can list tools from the backend var tools = await fixture.ProxyClient.ListToolsAsync(); Assert.Single(tools); Assert.Equal("test-tool", tools[0].Name); } [Fact] public async Task Gateway_NegotiatesLatestProtocolVersion() { await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new(); options.Handlers.ListToolsHandler = (request, ct) => new ValueTask(new ListToolsResult { Tools = [] }); options.Handlers.CallToolHandler = (request, ct) => new ValueTask(new CallToolResult()); }); Assert.Equal("2026-07-28", fixture.ProxyClient.NegotiatedProtocolVersion); } [Fact] public async Task ListTools_InjectsDefaultInputSchemaWhenInterceptorStripsIt() { await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new(); options.Handlers.ListToolsHandler = (request, ct) => { return new ValueTask(new ListToolsResult { Tools = [new Tool { Name = "stripped-tool", Description = "Loses its schema" }], }); }; options.Handlers.CallToolHandler = (request, ct) => new ValueTask(new CallToolResult()); }, interceptors: [CreateMutationInterceptor("schema-stripper", (req, _, _, _) => { // Only the response phase carries a tools array; pass the request phase through. if (req.Payload?["tools"] is not JsonArray) { return new ValueTask(new MutationInterceptorResult { Modified = false, Payload = req.Payload, }); } var payload = req.Payload!.DeepClone(); foreach (var tool in payload["tools"]!.AsArray()) { tool!.AsObject().Remove("inputSchema"); } return new ValueTask(new MutationInterceptorResult { Modified = true, Payload = payload, }); }, events: [InterceptionEvents.ToolsList])]); // Without the gateway coercing the stripped inputSchema back to the spec default, // deserializing the mutated payload would fail and the request would error out. var tools = await fixture.ProxyClient.ListToolsAsync(); Assert.Single(tools); Assert.Equal("stripped-tool", tools[0].Name); Assert.Equal(JsonValueKind.Object, tools[0].JsonSchema.ValueKind); Assert.Equal("object", tools[0].JsonSchema.GetProperty("type").GetString()); } [Fact] public async Task CallToolAsync_PassesThroughInterceptors() { var interceptorInvoked = false; await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new(); options.Handlers.ListToolsHandler = (request, ct) => { return new ValueTask(new ListToolsResult { Tools = [new Tool { Name = "echo", Description = "Echo" }], }); }; options.Handlers.CallToolHandler = (request, ct) => { return new ValueTask(new CallToolResult { Content = [new TextContentBlock { Text = $"echo: {request.Params!.Arguments?["message"]}" }], }); }; }, interceptors: [CreateMutationInterceptor("test-mutator", (req, _, _, _) => { interceptorInvoked = true; // Pass through without mutation return new ValueTask(new MutationInterceptorResult { Modified = false, Payload = req.Payload, }); })]); var result = await fixture.ProxyClient.CallToolAsync("echo", new Dictionary { ["message"] = "hello" }); Assert.True(interceptorInvoked); Assert.Contains("echo: hello", result.Content[0].ToString()); } [Fact] public async Task CallToolAsync_ValidationAbortBlocksRequest() { await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new(); options.Handlers.ListToolsHandler = (request, ct) => { return new ValueTask(new ListToolsResult { Tools = [new Tool { Name = "echo", Description = "Echo" }], }); }; options.Handlers.CallToolHandler = (request, ct) => { Assert.Fail("Backend should not be called when validation aborts."); return new ValueTask(new CallToolResult()); }; }, interceptors: [CreateValidationInterceptor("blocker", (req, _, _, _) => { return new ValueTask( ValidationInterceptorResult.Failure(new ValidationMessage { Message = "Blocked!", Severity = ValidationSeverity.Error, })); })]); // The SDK wraps handler exceptions as CallToolResult { IsError = true } // The backend handler's Assert.Fail proves it was never called. var result = await fixture.ProxyClient.CallToolAsync("echo", new Dictionary { ["message"] = "should be blocked" }); Assert.True(result.IsError); } [Fact] public async Task CallToolAsync_MutationModifiesPayload() { await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new(); options.Handlers.ListToolsHandler = (request, ct) => { return new ValueTask(new ListToolsResult { Tools = [new Tool { Name = "echo", Description = "Echo" }], }); }; options.Handlers.CallToolHandler = (request, ct) => { // Verify the mutation was applied before reaching the backend var msg = request.Params!.Arguments?["message"]; return new ValueTask(new CallToolResult { Content = [new TextContentBlock { Text = $"echo: {msg}" }], }); }; }, interceptors: [CreateMutationInterceptor("email-redactor", (req, _, _, _) => { // Simulate redacting emails from the payload var payloadStr = req.Payload!.ToJsonString(); payloadStr = payloadStr.Replace("user@example.com", "[REDACTED]"); return new ValueTask(new MutationInterceptorResult { Modified = true, Payload = JsonNode.Parse(payloadStr), }); })]); var result = await fixture.ProxyClient.CallToolAsync("echo", new Dictionary { ["message"] = "Contact user@example.com" }); Assert.Contains("[REDACTED]", result.Content[0].ToString()); Assert.DoesNotContain("user@example.com", result.Content[0].ToString()); } [Fact] public async Task ConfigureServerOptions_OnlyRegistersAdvertisedCapabilities() { // Backend only has tools, no prompts/resources await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new(); options.Handlers.ListToolsHandler = (request, ct) => { return new ValueTask(new ListToolsResult { Tools = [] }); }; options.Handlers.CallToolHandler = (request, ct) => { return new ValueTask(new CallToolResult()); }; }); // Proxy should advertise tools but not prompts/resources var caps = fixture.ProxyClient.ServerCapabilities; Assert.NotNull(caps?.Tools); Assert.Null(caps?.Prompts); Assert.Null(caps?.Resources); } [Fact] public async Task ConfigureServerOptions_MirrorsBackendExperimentalCapabilities() { await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new(); options.Capabilities.Experimental = new Dictionary { ["com.example/feature"] = JsonSerializer.SerializeToElement(new { enabled = true }), }; options.Handlers.ListToolsHandler = (request, ct) => new ValueTask(new ListToolsResult { Tools = [] }); options.Handlers.CallToolHandler = (request, ct) => new ValueTask(new CallToolResult()); }); var caps = fixture.ProxyClient.ServerCapabilities; Assert.NotNull(caps.Experimental); Assert.True(caps.Experimental!.ContainsKey("com.example/feature")); } [Fact] public async Task ConfigureServerOptions_MirrorsBackendExtensionsInTransparentMode() { await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new(); options.Capabilities.Extensions ??= new Dictionary(); options.Capabilities.Extensions["com.example/backend"] = JsonSerializer.SerializeToElement(new { version = 1 }); options.Handlers.ListToolsHandler = (request, ct) => new ValueTask(new ListToolsResult { Tools = [] }); options.Handlers.CallToolHandler = (request, ct) => new ValueTask(new CallToolResult()); }); var caps = fixture.ProxyClient.ServerCapabilities; Assert.NotNull(caps.Extensions); Assert.True(caps.Extensions!.ContainsKey("com.example/backend")); Assert.False(caps.Extensions.ContainsKey("io.modelcontextprotocol/interceptors")); } [Fact] public async Task ConfigureServerOptions_DoesNotAdvertiseInterceptorsCapabilityByDefault() { await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new(); options.Handlers.ListToolsHandler = (request, ct) => new ValueTask(new ListToolsResult { Tools = [] }); options.Handlers.CallToolHandler = (request, ct) => new ValueTask(new CallToolResult()); }); var caps = fixture.ProxyClient.ServerCapabilities; Assert.False(caps?.Extensions?.ContainsKey("io.modelcontextprotocol/interceptors") ?? false); } [Fact] public async Task ConfigureServerOptions_AdvertisesInterceptorsCapabilityWhenEnabled() { await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new(); options.Capabilities.Extensions ??= new Dictionary(); options.Capabilities.Extensions["com.example/backend"] = JsonSerializer.SerializeToElement(new { version = 1 }); options.Handlers.ListToolsHandler = (request, ct) => new ValueTask(new ListToolsResult { Tools = [] }); options.Handlers.CallToolHandler = (request, ct) => new ValueTask(new CallToolResult()); }, exposeInterceptorProtocol: true); var caps = fixture.ProxyClient.ServerCapabilities; Assert.NotNull(caps?.Extensions); Assert.True(caps!.Extensions!.ContainsKey("io.modelcontextprotocol/interceptors")); Assert.True(caps.Extensions.ContainsKey("com.example/backend")); } [Fact] public async Task ListInterceptorsAsync_IsNotExposedByDefault() { await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new(); options.Handlers.ListToolsHandler = (request, ct) => new ValueTask(new ListToolsResult { Tools = [] }); options.Handlers.CallToolHandler = (request, ct) => new ValueTask(new CallToolResult()); }, interceptors: [ CreateValidationInterceptor("validator-1", (_, _, _, _) => new ValueTask(ValidationInterceptorResult.Success())), ]); await Assert.ThrowsAsync(async () => { await fixture.ProxyClient.ListInterceptorsAsync(); }); } [Fact] public async Task ListInterceptorsAsync_AggregatesFromInterceptorServersWhenEnabled() { await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new(); options.Handlers.ListToolsHandler = (request, ct) => new ValueTask(new ListToolsResult { Tools = [] }); options.Handlers.CallToolHandler = (request, ct) => new ValueTask(new CallToolResult()); }, interceptors: [ CreateValidationInterceptor("validator-1", (_, _, _, _) => new ValueTask(ValidationInterceptorResult.Success())), CreateMutationInterceptor("mutator-1", (req, _, _, _) => new ValueTask(new MutationInterceptorResult { Modified = false, Payload = req.Payload })), ], exposeInterceptorProtocol: true); // List interceptors through the proxy var result = await fixture.ProxyClient.ListInterceptorsAsync(); Assert.Equal(2, result.Interceptors.Count); Assert.Contains(result.Interceptors, i => i.Name == "validator-1"); Assert.Contains(result.Interceptors, i => i.Name == "mutator-1"); } [Fact] public async Task CallToolAsync_ChainsAllInterceptorClients() { // Set up two interceptor servers, each with one mutation interceptor // The first prepends "A:", the second prepends "B:" await using var fixture = await GatewayTestFixture.CreateWithMultipleInterceptorServersAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new(); options.Handlers.ListToolsHandler = (request, ct) => new ValueTask(new ListToolsResult { Tools = [new Tool { Name = "echo", Description = "Echo" }], }); options.Handlers.CallToolHandler = (request, ct) => { var msg = request.Params!.Arguments?["message"]; return new ValueTask(new CallToolResult { Content = [new TextContentBlock { Text = $"echo: {msg}" }], }); }; }, interceptorConfigs: [ [CreateMutationInterceptor("prepend-a", (req, _, _, _) => { // Prepend "A:" to the message argument var obj = JsonNode.Parse(req.Payload!.ToJsonString())!.AsObject(); if (obj["arguments"]?["message"] is JsonNode msgNode) { obj["arguments"]!["message"] = "A:" + msgNode.GetValue(); } return new ValueTask(new MutationInterceptorResult { Modified = true, Payload = obj, }); })], [CreateMutationInterceptor("prepend-b", (req, _, _, _) => { var obj = JsonNode.Parse(req.Payload!.ToJsonString())!.AsObject(); if (obj["arguments"]?["message"] is JsonNode msgNode) { obj["arguments"]!["message"] = "B:" + msgNode.GetValue(); } return new ValueTask(new MutationInterceptorResult { Modified = true, Payload = obj, }); })], ]); // Call tool — both interceptors should run (A first, then B: equal priority, // alphabetical tie-break in the merged chain) var result = await fixture.ProxyClient.CallToolAsync("echo", new Dictionary { ["message"] = "hello" }); // A prepended first, then B var text = result.Content[0].ToString()!; Assert.Contains("B:A:hello", text); } [Fact] public async Task CallToolAsync_OrdersMutationsByGlobalPriorityAcrossInterceptorClients() { // The first server hosts the higher-priority-value (later) mutation, the second server // the lower-priority-value (earlier) one. The merged chain must run the second server's // mutation first — under per-server sequencing it would run last. static McpServerInterceptor PrependMutator(string name, int priority, string prefix) => new TestInterceptor( new Interceptor { Name = name, Type = InterceptorType.Mutation, PriorityHint = priority, Hooks = [new InterceptorHook { Events = [InterceptionEvents.All], Phase = InterceptorPhase.Request }], }, (req, _, _, _) => { var obj = JsonNode.Parse(req.Payload!.ToJsonString())!.AsObject(); if (obj["arguments"]?["message"] is JsonNode msgNode) { obj["arguments"]!["message"] = prefix + msgNode.GetValue(); } return new ValueTask(new MutationInterceptorResult { Modified = true, Payload = obj, }); }); await using var fixture = await GatewayTestFixture.CreateWithMultipleInterceptorServersAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new(); options.Handlers.ListToolsHandler = (request, ct) => new ValueTask(new ListToolsResult { Tools = [new Tool { Name = "echo", Description = "Echo" }], }); options.Handlers.CallToolHandler = (request, ct) => { var msg = request.Params!.Arguments?["message"]; return new ValueTask(new CallToolResult { Content = [new TextContentBlock { Text = $"echo: {msg}" }], }); }; }, interceptorConfigs: [ [PrependMutator("late-mutator", priority: 1000, prefix: "LATE:")], [PrependMutator("early-mutator", priority: -1000, prefix: "EARLY:")], ]); var result = await fixture.ProxyClient.CallToolAsync("echo", new Dictionary { ["message"] = "hello" }); // early (server 2) ran first, late (server 1) ran second and prepended last. var text = result.Content[0].ToString()!; Assert.Contains("LATE:EARLY:hello", text); } [Fact] public async Task SubscribeMutation_UsesModifiedPayload() { var subscribedUri = ""; await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Resources ??= new() { Subscribe = true }; options.Handlers.ListResourcesHandler = (request, ct) => new ValueTask(new ListResourcesResult { Resources = [] }); options.Handlers.ReadResourceHandler = (request, ct) => new ValueTask(new ReadResourceResult { Contents = [] }); options.Handlers.ListResourceTemplatesHandler = (request, ct) => new ValueTask(new ListResourceTemplatesResult { ResourceTemplates = [] }); options.Handlers.SubscribeToResourcesHandler = (request, ct) => { subscribedUri = request.Params!.Uri; return new ValueTask(new EmptyResult()); }; options.Handlers.UnsubscribeFromResourcesHandler = (request, ct) => new ValueTask(new EmptyResult()); }, interceptors: [CreateMutationInterceptor("uri-rewriter", (req, _, _, _) => { // Mutate the subscribe payload to rewrite the URI var payloadStr = req.Payload!.ToJsonString(); payloadStr = payloadStr.Replace("resource://original", "resource://rewritten"); return new ValueTask(new MutationInterceptorResult { Modified = true, Payload = JsonNode.Parse(payloadStr), }); }, events: [InterceptionEvents.ResourcesSubscribe])], // resources/subscribe was replaced by subscriptions/listen in spec 2026-07-28; // the gateway keeps proxying it for down-level connections, so pin one here. protocolVersion: "2025-06-18"); await fixture.ProxyClient.SubscribeToResourceAsync("resource://original"); // The mutation interceptor should have rewritten the URI before it reached the backend Assert.Equal("resource://rewritten", subscribedUri); } [Fact] public async Task ExecuteChain_FailsWhenInterceptorProtocolNotExposed() { await using var fixture = await GatewayTestFixture.CreateWithMultipleInterceptorServersAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new(); options.Handlers.ListToolsHandler = (request, ct) => new ValueTask(new ListToolsResult { Tools = [] }); options.Handlers.CallToolHandler = (request, ct) => new ValueTask(new CallToolResult()); }, interceptorConfigs: [ [CreateMutationInterceptor("mutator-a", (req, _, _, _) => new ValueTask(new MutationInterceptorResult { Modified = false, Payload = req.Payload }))], ]); await Assert.ThrowsAsync(async () => { await fixture.ProxyClient.ExecuteChainAsync( new ExecuteChainRequestParams { Event = InterceptionEvents.ToolsCall, Phase = InterceptorPhase.Request, Payload = JsonNode.Parse("""{"original":true}""")!, }); }); } [Fact] public async Task ExecuteChain_AggregatesResultsAcrossInterceptorClientsViaGateway() { // Two interceptor servers, each with one mutation interceptor await using var fixture = await GatewayTestFixture.CreateWithMultipleInterceptorServersAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new(); options.Handlers.ListToolsHandler = (request, ct) => new ValueTask(new ListToolsResult { Tools = [] }); options.Handlers.CallToolHandler = (request, ct) => new ValueTask(new CallToolResult()); }, interceptorConfigs: [ [CreateMutationInterceptor("mutator-a", (req, _, _, _) => { var obj = JsonNode.Parse(req.Payload!.ToJsonString())!.AsObject(); obj["a"] = true; return new ValueTask(new MutationInterceptorResult { Modified = true, Payload = obj, }); })], [CreateMutationInterceptor("mutator-b", (req, _, _, _) => { var obj = JsonNode.Parse(req.Payload!.ToJsonString())!.AsObject(); obj["b"] = true; return new ValueTask(new MutationInterceptorResult { Modified = true, Payload = obj, }); })], ], exposeInterceptorProtocol: true); // Client-side ExecuteChainAsync does list+invoke against the proxy; with // ExposeInterceptorProtocol enabled, the proxy aggregates list and routes invoke // to the appropriate interceptor client behind it. var chainResult = await fixture.ProxyClient.ExecuteChainAsync( new ExecuteChainRequestParams { Event = InterceptionEvents.ToolsCall, Phase = InterceptorPhase.Request, Payload = JsonNode.Parse("""{"original":true}""")!, }); Assert.Equal(InterceptorChainStatus.Success, chainResult.Status); // Results from both interceptor servers should be aggregated Assert.Equal(2, chainResult.Results.Count); // Final payload should have mutations from both servers Assert.True(chainResult.FinalPayload!["a"]!.GetValue()); Assert.True(chainResult.FinalPayload!["b"]!.GetValue()); Assert.True(chainResult.FinalPayload!["original"]!.GetValue()); } [Fact] public async Task CallToolAsync_MutationFailureBlocksRequest() { var backendCalled = false; await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new(); options.Handlers.ListToolsHandler = (request, ct) => new ValueTask(new ListToolsResult { Tools = [new Tool { Name = "echo", Description = "Echo" }], }); options.Handlers.CallToolHandler = (request, ct) => { backendCalled = true; return new ValueTask(new CallToolResult { Content = [new TextContentBlock { Text = "backend-called" }], }); }; }, interceptors: [ CreateMutationInterceptor("failing-mutator", (_, _, _, _) => { throw new InvalidOperationException("PII redaction failed"); }), ]); var result = await fixture.ProxyClient.CallToolAsync("echo"); Assert.False(backendCalled); Assert.True(result.IsError); Assert.Contains("An error occurred invoking 'echo'.", result.Content[0].ToString()); } [Fact] public async Task CallToolAsync_MutationTimeoutBlocksRequest() { var backendCalled = false; await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new(); options.Handlers.ListToolsHandler = (request, ct) => new ValueTask(new ListToolsResult { Tools = [new Tool { Name = "echo", Description = "Echo" }], }); options.Handlers.CallToolHandler = (request, ct) => { backendCalled = true; return new ValueTask(new CallToolResult { Content = [new TextContentBlock { Text = "backend-called" }], }); }; }, interceptors: [ CreateMutationInterceptor("slow-mutator", async (_, _, _, ct) => { await Task.Delay(200, ct); return new MutationInterceptorResult { Modified = false }; }), ], timeoutMs: 25); var result = await fixture.ProxyClient.CallToolAsync("echo"); Assert.False(backendCalled); Assert.True(result.IsError); Assert.Contains("An error occurred invoking 'echo'.", result.Content[0].ToString()); } [Fact] public async Task WithInterceptorGateway_RegistersNotificationForwardingOncePerSession() { var forwardingRegistrations = 0; await using var fixture = await GatewayTestFixture.CreateWithBuilderGatewayAsync( backendConfigure: options => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new() { ListChanged = true }; options.Handlers.ListToolsHandler = (request, ct) => new ValueTask(new ListToolsResult { Tools = [new Tool { Name = "echo", Description = "Echo" }], }); options.Handlers.CallToolHandler = (request, ct) => new ValueTask(new CallToolResult { Content = [new TextContentBlock { Text = "ok" }], }); }, onRegisterNotificationHandler: method => { if (method == NotificationMethods.ToolListChangedNotification) { Interlocked.Increment(ref forwardingRegistrations); } }); await fixture.ProxyClient.ListToolsAsync(); await fixture.ProxyClient.ListToolsAsync(); await fixture.ProxyClient.CallToolAsync("echo"); Assert.Equal(1, forwardingRegistrations); } [Fact] public async Task WithInterceptorGateway_ServiceProviderFactoryOverloadConfiguresGateway() { await using var fixture = await GatewayTestFixture.CreateWithBuilderGatewayFactoryAsync( backendConfigure: options => { options.Capabilities ??= new(); options.Capabilities.Tools ??= new(); options.Handlers.ListToolsHandler = (request, ct) => new ValueTask(new ListToolsResult { Tools = [new Tool { Name = "echo", Description = "Echo" }], }); options.Handlers.CallToolHandler = (request, ct) => new ValueTask(new CallToolResult { Content = [new TextContentBlock { Text = request.Params!.Name }], }); }, exposeInterceptorProtocol: true); Assert.True(fixture.ProxyClient.ServerCapabilities.Extensions?.ContainsKey(InterceptorProtocolConstants.ExtensionCapabilityKey) ?? false); var tools = await fixture.ProxyClient.ListToolsAsync(); Assert.Single(tools); Assert.Equal("echo", tools[0].Name); } [Fact] public async Task ListPromptsAsync_ProxiesToBackend() { await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Prompts ??= new(); options.Handlers.ListPromptsHandler = (request, ct) => new ValueTask(new ListPromptsResult { Prompts = [new Prompt { Name = "test-prompt", Description = "A test prompt" }], }); options.Handlers.GetPromptHandler = (request, ct) => new ValueTask(new GetPromptResult { Messages = [new PromptMessage { Role = Role.User, Content = new TextContentBlock { Text = $"Prompt: {request.Params!.Name}" }, }], }); }); var prompts = await fixture.ProxyClient.ListPromptsAsync(); Assert.Single(prompts); Assert.Equal("test-prompt", prompts[0].Name); } [Fact] public async Task GetPromptAsync_ProxiesToBackendWithInterception() { await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Prompts ??= new(); options.Handlers.ListPromptsHandler = (request, ct) => new ValueTask(new ListPromptsResult { Prompts = [] }); options.Handlers.GetPromptHandler = (request, ct) => new ValueTask(new GetPromptResult { Messages = [new PromptMessage { Role = Role.User, Content = new TextContentBlock { Text = $"Hello from {request.Params!.Name}" }, }], }); }, interceptors: [CreateMutationInterceptor("prompt-mutator", (req, _, _, _) => { // Pass through — just verify the interceptor is invoked return new ValueTask(new MutationInterceptorResult { Modified = false, Payload = req.Payload, }); }, events: [InterceptionEvents.PromptsGet])]); var result = await fixture.ProxyClient.GetPromptAsync("my-prompt"); Assert.Single(result.Messages); Assert.Contains("Hello from my-prompt", result.Messages[0].Content.ToString()); } [Fact] public async Task ListResourcesAsync_ProxiesToBackend() { await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Resources ??= new(); options.Handlers.ListResourcesHandler = (request, ct) => new ValueTask(new ListResourcesResult { Resources = [new Resource { Name = "test-resource", Uri = "resource://test" }], }); options.Handlers.ReadResourceHandler = (request, ct) => new ValueTask(new ReadResourceResult { Contents = [new TextResourceContents { Text = "content", Uri = request.Params!.Uri, MimeType = "text/plain" }], }); options.Handlers.ListResourceTemplatesHandler = (request, ct) => new ValueTask(new ListResourceTemplatesResult { ResourceTemplates = [] }); }); var resources = await fixture.ProxyClient.ListResourcesAsync(); Assert.Single(resources); Assert.Equal("test-resource", resources[0].Name); } [Fact] public async Task ReadResourceAsync_ProxiesToBackendWithInterception() { await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Resources ??= new(); options.Handlers.ListResourcesHandler = (request, ct) => new ValueTask(new ListResourcesResult { Resources = [] }); options.Handlers.ReadResourceHandler = (request, ct) => new ValueTask(new ReadResourceResult { Contents = [new TextResourceContents { Text = $"Content of {request.Params!.Uri}", Uri = request.Params.Uri, MimeType = "text/plain" }], }); options.Handlers.ListResourceTemplatesHandler = (request, ct) => new ValueTask(new ListResourceTemplatesResult { ResourceTemplates = [] }); }, interceptors: [CreateMutationInterceptor("uri-rewriter", (req, _, _, _) => { var payloadStr = req.Payload!.ToJsonString(); payloadStr = payloadStr.Replace("resource://original", "resource://rewritten"); return new ValueTask(new MutationInterceptorResult { Modified = true, Payload = JsonNode.Parse(payloadStr), }); }, events: [InterceptionEvents.ResourcesRead])]); var result = await fixture.ProxyClient.ReadResourceAsync("resource://original"); Assert.Single(result.Contents); Assert.Contains("Content of resource://rewritten", ((TextResourceContents)result.Contents[0]).Text); } [Fact] public async Task CompleteAsync_ProxiesToBackend() { await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Completions ??= new(); options.Handlers.CompleteHandler = (request, ct) => new ValueTask(new CompleteResult { Completion = new Completion { Values = ["alpha", "beta"], HasMore = false }, }); }); var caps = fixture.ProxyClient.ServerCapabilities; Assert.NotNull(caps?.Completions); var result = await fixture.ProxyClient.CompleteAsync(new CompleteRequestParams { Ref = new PromptReference { Name = "test" }, Argument = new Argument { Name = "arg", Value = "a" }, }); Assert.Equal(2, result.Completion.Values.Count); Assert.Contains("alpha", result.Completion.Values); } // Logging is deprecated upstream (SEP-2577) but the gateway intentionally keeps proxying it // for down-level clients/backends. #pragma warning disable MCP9005 [Fact] public async Task SetLoggingLevelAsync_ProxiesToBackend() { LoggingLevel? receivedLevel = null; await using var fixture = await GatewayTestFixture.CreateAsync( backendConfigure: (options) => { options.Capabilities ??= new(); options.Capabilities.Logging ??= new(); options.Handlers.SetLoggingLevelHandler = (request, ct) => { receivedLevel = request.Params!.Level; return new ValueTask(new EmptyResult()); }; }, // logging/setLevel was replaced by per-request _meta log levels in spec 2026-07-28; // the gateway keeps proxying it for down-level connections, so pin one here. protocolVersion: "2025-06-18"); var caps = fixture.ProxyClient.ServerCapabilities; Assert.NotNull(caps?.Logging); await fixture.ProxyClient.SetLoggingLevelAsync(LoggingLevel.Warning); Assert.Equal(LoggingLevel.Warning, receivedLevel); } #pragma warning restore MCP9005 // ── Test helpers ────────────────────────────────────────────────── private static McpServerInterceptor CreateMutationInterceptor( string name, Func> handler, string[]? events = null) { var ev = events ?? [InterceptionEvents.All]; return new TestInterceptor( new Interceptor { Name = name, Type = InterceptorType.Mutation, Hooks = [ new InterceptorHook { Events = ev.ToList(), Phase = InterceptorPhase.Request }, new InterceptorHook { Events = ev.ToList(), Phase = InterceptorPhase.Response }, ], }, handler); } private static McpServerInterceptor CreateValidationInterceptor( string name, Func> handler) { return new TestInterceptor( new Interceptor { Name = name, Type = InterceptorType.Validation, Hooks = [ new InterceptorHook { Events = [InterceptionEvents.All], Phase = InterceptorPhase.Request }, new InterceptorHook { Events = [InterceptionEvents.All], Phase = InterceptorPhase.Response }, ], }, handler); } private sealed class TestInterceptor : McpServerInterceptor { private readonly Interceptor _interceptor; private readonly Func> _handler; public TestInterceptor( Interceptor interceptor, Func> handler) { _interceptor = interceptor; _handler = handler; } public override Interceptor ProtocolInterceptor => _interceptor; public override IReadOnlyList Metadata => []; public override ValueTask InvokeAsync( InvokeInterceptorRequestParams request, McpServer server, IServiceProvider? services, CancellationToken cancellationToken = default) => _handler(request, server, services, cancellationToken); } /// /// Creates an in-memory test fixture with a backend server, interceptor server, and proxy server, /// all connected via anonymous pipes. /// internal sealed class GatewayTestFixture : IAsyncDisposable { private readonly List _disposables; public McpClient ProxyClient { get; } private GatewayTestFixture(McpClient proxyClient, List disposables) { ProxyClient = proxyClient; _disposables = disposables; } public static async Task CreateAsync( Action backendConfigure, McpServerInterceptor[]? interceptors = null, int? timeoutMs = null, bool exposeInterceptorProtocol = false, string? protocolVersion = null) { var disposables = new List(); try { // 1. Create backend server + client via pipes var (backendServer, backendClient) = await CreateServerClientPair( "test-backend", backendConfigure, protocolVersion: protocolVersion); disposables.Add(backendServer); disposables.Add(backendClient); // 2. Create interceptor server + client via pipes var (interceptorServer, interceptorClient) = await CreateServerClientPair( "test-interceptors", options => { var collection = new McpServerPrimitiveCollection(); var allEvents = new HashSet(); foreach (var interceptor in interceptors ?? []) { collection.Add(interceptor); foreach (var hook in interceptor.ProtocolInterceptor.Hooks) { foreach (var ev in hook.Events) { allEvents.Add(ev); } } } var filter = new InterceptorMessageFilter(collection); options.Filters.Message.IncomingFilters.Add(filter.CreateFilter); options.Capabilities ??= new(); options.Capabilities.Extensions ??= new Dictionary(); options.Capabilities.Extensions[InterceptorProtocolConstants.ExtensionCapabilityKey] = JsonSerializer.SerializeToElement( new InterceptorsCapability { SupportedEvents = allEvents.ToList() }, InterceptorJsonUtilities.DefaultOptions); }); disposables.Add(interceptorServer); disposables.Add(interceptorClient); // 3. Create the gateway var gateway = new McpInterceptorGateway(new McpInterceptorGatewayOptions { BackendClient = backendClient, InterceptorClients = [interceptorClient], TimeoutMs = timeoutMs, ExposeInterceptorProtocol = exposeInterceptorProtocol, }); disposables.Add(gateway); // 4. Create proxy server + client via pipes var (proxyServer, proxyClient) = await CreateServerClientPair( "test-proxy", options => { gateway.ConfigureServerOptions(options); }, protocolVersion: protocolVersion); disposables.Add(proxyServer); disposables.Add(proxyClient); gateway.RegisterNotificationForwarding(proxyServer); return new GatewayTestFixture(proxyClient, disposables); } catch { foreach (var d in disposables) { await d.DisposeAsync(); } throw; } } public static async Task CreateWithMultipleInterceptorServersAsync( Action backendConfigure, McpServerInterceptor[][] interceptorConfigs, bool exposeInterceptorProtocol = false) { var disposables = new List(); try { var (backendServer, backendClient) = await CreateServerClientPair( "test-backend", backendConfigure); disposables.Add(backendServer); disposables.Add(backendClient); var interceptorClients = new List(); for (int i = 0; i < interceptorConfigs.Length; i++) { var interceptors = interceptorConfigs[i]; var (server, client) = await CreateServerClientPair( $"test-interceptors-{i}", options => { var collection = new McpServerPrimitiveCollection(); var allEvents = new HashSet(); foreach (var interceptor in interceptors) { collection.Add(interceptor); foreach (var hook in interceptor.ProtocolInterceptor.Hooks) foreach (var ev in hook.Events) allEvents.Add(ev); } var filter = new InterceptorMessageFilter(collection); options.Filters.Message.IncomingFilters.Add(filter.CreateFilter); options.Capabilities ??= new(); options.Capabilities.Extensions ??= new Dictionary(); options.Capabilities.Extensions[InterceptorProtocolConstants.ExtensionCapabilityKey] = JsonSerializer.SerializeToElement( new InterceptorsCapability { SupportedEvents = allEvents.ToList() }, InterceptorJsonUtilities.DefaultOptions); }); disposables.Add(server); disposables.Add(client); interceptorClients.Add(client); } var gateway = new McpInterceptorGateway(new McpInterceptorGatewayOptions { BackendClient = backendClient, InterceptorClients = interceptorClients, ExposeInterceptorProtocol = exposeInterceptorProtocol, }); disposables.Add(gateway); var (proxyServer, proxyClient) = await CreateServerClientPair( "test-proxy", options => gateway.ConfigureServerOptions(options)); disposables.Add(proxyServer); disposables.Add(proxyClient); gateway.RegisterNotificationForwarding(proxyServer); return new GatewayTestFixture(proxyClient, disposables); } catch { foreach (var d in disposables) await d.DisposeAsync(); throw; } } public static async Task CreateWithBuilderGatewayAsync( Action backendConfigure, Action? onRegisterNotificationHandler = null, bool exposeInterceptorProtocol = false) { var disposables = new List(); try { var (backendServer, backendClientInner) = await CreateServerClientPair( "test-backend", backendConfigure, onRegisterNotificationHandler); var backendClient = onRegisterNotificationHandler is null ? backendClientInner : new NotificationTrackingClient(backendClientInner, onRegisterNotificationHandler); disposables.Add(backendServer); disposables.Add(backendClient); var (interceptorServer, interceptorClient) = await CreateServerClientPair( "test-interceptors", options => { var collection = new McpServerPrimitiveCollection(); var filter = new InterceptorMessageFilter(collection); options.Filters.Message.IncomingFilters.Add(filter.CreateFilter); options.Capabilities ??= new(); options.Capabilities.Extensions ??= new Dictionary(); options.Capabilities.Extensions[InterceptorProtocolConstants.ExtensionCapabilityKey] = JsonSerializer.SerializeToElement( new InterceptorsCapability { SupportedEvents = [] }, InterceptorJsonUtilities.DefaultOptions); }); disposables.Add(interceptorServer); disposables.Add(interceptorClient); var services = new ServiceCollection(); services.AddMcpServer() .WithInterceptorGateway(new McpInterceptorGatewayOptions { BackendClient = backendClient, InterceptorClients = [interceptorClient], ExposeInterceptorProtocol = exposeInterceptorProtocol, }); var optionsDescriptor = services.Single(d => d.ServiceType == typeof(McpInterceptorGatewayOptions)); var gatewayOptions = optionsDescriptor.ImplementationFactory is not null ? (McpInterceptorGatewayOptions)optionsDescriptor.ImplementationFactory(EmptyServiceProvider.Instance)! : optionsDescriptor.ImplementationInstance as McpInterceptorGatewayOptions ?? throw new InvalidOperationException("Gateway options were not registered."); var gateway = new McpInterceptorGateway(gatewayOptions); disposables.Add(gateway); var serverOptions = new McpServerOptions(); new TestGatewayServerOptionsSetup(gateway).Configure(serverOptions); var (proxyServer, proxyClient) = await CreateServerClientPair( "test-proxy", options => { options.ServerInfo = serverOptions.ServerInfo; options.Capabilities = serverOptions.Capabilities; options.Handlers = serverOptions.Handlers; options.Filters = serverOptions.Filters; }); disposables.Add(proxyServer); disposables.Add(proxyClient); return new GatewayTestFixture(proxyClient, disposables); } catch { foreach (var d in disposables) { await d.DisposeAsync(); } throw; } } public static async Task CreateWithBuilderGatewayFactoryAsync( Action backendConfigure, bool exposeInterceptorProtocol = false) { var disposables = new List(); try { var (backendServer, backendClient) = await CreateServerClientPair( "test-backend", backendConfigure); disposables.Add(backendServer); disposables.Add(backendClient); var (interceptorServer, interceptorClient) = await CreateServerClientPair( "test-interceptors", options => { var collection = new McpServerPrimitiveCollection(); var filter = new InterceptorMessageFilter(collection); options.Filters.Message.IncomingFilters.Add(filter.CreateFilter); options.Capabilities ??= new(); options.Capabilities.Extensions ??= new Dictionary(); options.Capabilities.Extensions[InterceptorProtocolConstants.ExtensionCapabilityKey] = JsonSerializer.SerializeToElement( new InterceptorsCapability { SupportedEvents = [] }, InterceptorJsonUtilities.DefaultOptions); }); disposables.Add(interceptorServer); disposables.Add(interceptorClient); var services = new ServiceCollection(); services.AddSingleton(new NamedClient("backend", backendClient)); services.AddSingleton(new NamedClient("interceptor", interceptorClient)); services.AddMcpServer() .WithInterceptorGateway(sp => new McpInterceptorGatewayOptions { BackendClient = sp.GetServices().Single(c => c.Name == "backend").Client, InterceptorClients = [sp.GetServices().Single(c => c.Name == "interceptor").Client], ExposeInterceptorProtocol = exposeInterceptorProtocol, }); var serviceProvider = new TestServiceProvider( new NamedClient("backend", backendClient), new NamedClient("interceptor", interceptorClient)); var optionsDescriptor = services.Single(d => d.ServiceType == typeof(McpInterceptorGatewayOptions)); var serverOptions = new McpServerOptions(); var gatewayOptions = optionsDescriptor.ImplementationFactory is not null ? (McpInterceptorGatewayOptions)optionsDescriptor.ImplementationFactory(serviceProvider)! : optionsDescriptor.ImplementationInstance as McpInterceptorGatewayOptions ?? throw new InvalidOperationException("Gateway options were not registered."); var gateway = new McpInterceptorGateway(gatewayOptions); disposables.Add(gateway); new TestGatewayServerOptionsSetup(gateway).Configure(serverOptions); var (proxyServer, proxyClient) = await CreateServerClientPair( "test-proxy", options => { options.ServerInfo = serverOptions.ServerInfo; options.Capabilities = serverOptions.Capabilities; options.Handlers = serverOptions.Handlers; options.Filters = serverOptions.Filters; }); disposables.Add(proxyServer); disposables.Add(proxyClient); return new GatewayTestFixture(proxyClient, disposables); } catch { foreach (var d in disposables) { await d.DisposeAsync(); } throw; } } internal static async Task<(McpServer server, McpClient client)> CreateServerClientPairForTesting( string name, Action configure, Action? onRegisterNotificationHandler = null) => await CreateServerClientPair(name, configure, onRegisterNotificationHandler); private static async Task<(McpServer server, McpClient client)> CreateServerClientPair( string name, Action configure, Action? onRegisterNotificationHandler = null, string? protocolVersion = null) { // Create anonymous pipes for communication 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 = name, Version = "1.0.0" }, }; configure(serverOptions); // Server reads from serverFromClient, writes to serverToClient var serverTransport = new StreamServerTransport(serverFromClient, serverToClient); var server = McpServer.Create(serverTransport, serverOptions); _ = server.RunAsync(); // Run in background // Client writes to clientToServer, reads from clientFromServer var clientTransport = new StreamClientTransport(clientToServer, clientFromServer); var clientOptions = new McpClientOptions(); if (protocolVersion is not null) { clientOptions.ProtocolVersion = protocolVersion; } var client = await McpClient.CreateAsync(clientTransport, clientOptions); return (server, client); } public async ValueTask DisposeAsync() { for (int i = _disposables.Count - 1; i >= 0; i--) { try { await _disposables[i].DisposeAsync(); } catch { // Swallow disposal errors in tests } } } #pragma warning disable MCPEXP002 private sealed class NotificationTrackingClient(McpClient inner, Action onRegister) : McpClient #pragma warning restore MCPEXP002 { public override string? SessionId => inner.SessionId; public override string? NegotiatedProtocolVersion => inner.NegotiatedProtocolVersion; public override ServerCapabilities ServerCapabilities => inner.ServerCapabilities; public override Implementation ServerInfo => inner.ServerInfo; public override string? ServerInstructions => inner.ServerInstructions; public override Task Completion => inner.Completion; public override ValueTask DisposeAsync() => inner.DisposeAsync(); public override IAsyncDisposable RegisterNotificationHandler(string method, Func handler) { onRegister(method); return inner.RegisterNotificationHandler(method, handler); } public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) => inner.SendMessageAsync(message, cancellationToken); public override Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) => inner.SendRequestAsync(request, cancellationToken); public override ValueTask> ResolveInputRequestsAsync(IDictionary inputRequests, CancellationToken cancellationToken) => inner.ResolveInputRequestsAsync(inputRequests, cancellationToken); } private sealed record NamedClient(string Name, McpClient Client); private sealed class EmptyServiceProvider : IServiceProvider { internal static EmptyServiceProvider Instance { get; } = new(); public object? GetService(Type serviceType) => null; } private sealed class TestGatewayServerOptionsSetup(McpInterceptorGateway gateway) : IConfigureOptions { private readonly GatewayConnectionForwardingRegistrar _forwardingRegistrar = new(gateway); public void Configure(McpServerOptions options) { gateway.ConfigureServerOptions(options); _forwardingRegistrar.Configure(options); } } private sealed class TestServiceProvider(params NamedClient[] clients) : IServiceProvider { private readonly NamedClient[] _clients = clients; public object? GetService(Type serviceType) { if (serviceType == typeof(IEnumerable)) { return _clients; } if (serviceType == typeof(NamedClient[])) { return _clients; } if (serviceType == typeof(IEnumerable)) { return _clients; } return null; } } } }