## Summary
Bumps the MCP C# SDK dependency from 1.1.0 to 2.1.0 across all 9
projects and aligns the implementation with the 2.x surface and the
2026-07-28 spec — not just a version bump.
### Compile fixes
- Removed the gateway's `Capabilities.Tasks = null` suppression and its
test: the experimental Tasks API was removed from SDK core in 2.0 (moved
to a separate, wire-incompatible `ModelContextProtocol.Extensions.Tasks`
package).
- Added `ResolveInputRequestsAsync` (new abstract MRTR member on
`McpClient`) overrides to the test doubles. MRTR forwarding is
deliberately out of scope for this PR; the gateway's server reports
`IsMrtrSupported == false`.
- Deleted all `MCPEXP001` pragmas — `ServerCapabilities.Extensions`
(which carries `io.modelcontextprotocol/interceptors`) is stable in 2.x.
`MCPEXP002` subclassing pragmas are kept.
### Spec deprecations (SEP-2577 / SEP-2575)
- The gateway intentionally keeps proxying `logging/setLevel` and
`resources/subscribe` for down-level clients/backends, with targeted
`MCP9005` suppressions.
- On negotiated `2026-07-28` connections the SDK rejects these methods
at the session layer (replaced by per-request `_meta` log levels and
`subscriptions/listen`), so the affected tests now pin `protocolVersion:
"2025-06-18"` through a new fixture parameter — making them genuine
down-level-compatibility tests.
### Defensive hardening
- `Tool.inputSchema` is required and validated on deserialization in
2.x. The gateway's `ListToolsHandler` now fetches the backend
`tools/list` result as raw JSON and runs
`GatewayProxyConfigurator.EnsureToolInputSchemas` before and after the
interceptor chain, coercing missing/invalid schemas to the spec-default
`{"type":"object"}` so down-level backends and schema-stripping
interceptors can't fail the request.
### Tests & docs
- New tests: end-to-end schema-stripping coercion,
`EnsureToolInputSchemas` unit cases, and `2026-07-28` protocol-version
negotiation through the gateway (implicitly exercising
`server/discover`).
- `CLAUDE.md` gains an "SDK 2.x / spec 2026-07-28 notes" section;
`ARCHITECTURE_PHASES.md` no longer lists `Tasks` as a mirrored
capability.
## Test plan
- [x] `dotnet build` — 0 errors, 0 warnings (library, tests, all 7
samples)
- [x] `dotnet test` — 105/105 passing (was 98: −1 removed Tasks test, +8
new)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Ola Hungerford <olahungerford@gmail.com>
1532 lines
67 KiB
C#
1532 lines
67 KiB
C#
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<ListToolsResult>(new ListToolsResult
|
|
{
|
|
Tools = [new Tool { Name = "test-tool", Description = "A test tool" }],
|
|
});
|
|
};
|
|
options.Handlers.CallToolHandler = (request, ct) =>
|
|
{
|
|
return new ValueTask<CallToolResult>(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<ListToolsResult>(new ListToolsResult { Tools = [] });
|
|
options.Handlers.CallToolHandler = (request, ct) =>
|
|
new ValueTask<CallToolResult>(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<ListToolsResult>(new ListToolsResult
|
|
{
|
|
Tools = [new Tool { Name = "stripped-tool", Description = "Loses its schema" }],
|
|
});
|
|
};
|
|
options.Handlers.CallToolHandler = (request, ct) =>
|
|
new ValueTask<CallToolResult>(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<InterceptorResult>(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<InterceptorResult>(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<ListToolsResult>(new ListToolsResult
|
|
{
|
|
Tools = [new Tool { Name = "echo", Description = "Echo" }],
|
|
});
|
|
};
|
|
options.Handlers.CallToolHandler = (request, ct) =>
|
|
{
|
|
return new ValueTask<CallToolResult>(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<InterceptorResult>(new MutationInterceptorResult
|
|
{
|
|
Modified = false,
|
|
Payload = req.Payload,
|
|
});
|
|
})]);
|
|
|
|
var result = await fixture.ProxyClient.CallToolAsync("echo",
|
|
new Dictionary<string, object?> { ["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<ListToolsResult>(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<CallToolResult>(new CallToolResult());
|
|
};
|
|
},
|
|
interceptors: [CreateValidationInterceptor("blocker", (req, _, _, _) =>
|
|
{
|
|
return new ValueTask<InterceptorResult>(
|
|
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<string, object?> { ["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<ListToolsResult>(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<CallToolResult>(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<InterceptorResult>(new MutationInterceptorResult
|
|
{
|
|
Modified = true,
|
|
Payload = JsonNode.Parse(payloadStr),
|
|
});
|
|
})]);
|
|
|
|
var result = await fixture.ProxyClient.CallToolAsync("echo",
|
|
new Dictionary<string, object?> { ["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<ListToolsResult>(new ListToolsResult { Tools = [] });
|
|
};
|
|
options.Handlers.CallToolHandler = (request, ct) =>
|
|
{
|
|
return new ValueTask<CallToolResult>(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<string, object>
|
|
{
|
|
["com.example/feature"] = 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 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<string, object>();
|
|
options.Capabilities.Extensions["com.example/backend"] = JsonSerializer.SerializeToElement(new { version = 1 });
|
|
options.Handlers.ListToolsHandler = (request, ct) =>
|
|
new ValueTask<ListToolsResult>(new ListToolsResult { Tools = [] });
|
|
options.Handlers.CallToolHandler = (request, ct) =>
|
|
new ValueTask<CallToolResult>(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<ListToolsResult>(new ListToolsResult { Tools = [] });
|
|
options.Handlers.CallToolHandler = (request, ct) =>
|
|
new ValueTask<CallToolResult>(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<string, object>();
|
|
options.Capabilities.Extensions["com.example/backend"] = JsonSerializer.SerializeToElement(new { version = 1 });
|
|
options.Handlers.ListToolsHandler = (request, ct) =>
|
|
new ValueTask<ListToolsResult>(new ListToolsResult { Tools = [] });
|
|
options.Handlers.CallToolHandler = (request, ct) =>
|
|
new ValueTask<CallToolResult>(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<ListToolsResult>(new ListToolsResult { Tools = [] });
|
|
options.Handlers.CallToolHandler = (request, ct) =>
|
|
new ValueTask<CallToolResult>(new CallToolResult());
|
|
},
|
|
interceptors:
|
|
[
|
|
CreateValidationInterceptor("validator-1", (_, _, _, _) =>
|
|
new ValueTask<InterceptorResult>(ValidationInterceptorResult.Success())),
|
|
]);
|
|
|
|
await Assert.ThrowsAsync<McpProtocolException>(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<ListToolsResult>(new ListToolsResult { Tools = [] });
|
|
options.Handlers.CallToolHandler = (request, ct) =>
|
|
new ValueTask<CallToolResult>(new CallToolResult());
|
|
},
|
|
interceptors:
|
|
[
|
|
CreateValidationInterceptor("validator-1", (_, _, _, _) =>
|
|
new ValueTask<InterceptorResult>(ValidationInterceptorResult.Success())),
|
|
CreateMutationInterceptor("mutator-1", (req, _, _, _) =>
|
|
new ValueTask<InterceptorResult>(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<ListToolsResult>(new ListToolsResult
|
|
{
|
|
Tools = [new Tool { Name = "echo", Description = "Echo" }],
|
|
});
|
|
options.Handlers.CallToolHandler = (request, ct) =>
|
|
{
|
|
var msg = request.Params!.Arguments?["message"];
|
|
return new ValueTask<CallToolResult>(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<string>();
|
|
}
|
|
return new ValueTask<InterceptorResult>(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<string>();
|
|
}
|
|
return new ValueTask<InterceptorResult>(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<string, object?> { ["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<string>();
|
|
}
|
|
return new ValueTask<InterceptorResult>(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<ListToolsResult>(new ListToolsResult
|
|
{
|
|
Tools = [new Tool { Name = "echo", Description = "Echo" }],
|
|
});
|
|
options.Handlers.CallToolHandler = (request, ct) =>
|
|
{
|
|
var msg = request.Params!.Arguments?["message"];
|
|
return new ValueTask<CallToolResult>(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<string, object?> { ["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<ListResourcesResult>(new ListResourcesResult { Resources = [] });
|
|
options.Handlers.ReadResourceHandler = (request, ct) =>
|
|
new ValueTask<ReadResourceResult>(new ReadResourceResult { Contents = [] });
|
|
options.Handlers.ListResourceTemplatesHandler = (request, ct) =>
|
|
new ValueTask<ListResourceTemplatesResult>(new ListResourceTemplatesResult { ResourceTemplates = [] });
|
|
options.Handlers.SubscribeToResourcesHandler = (request, ct) =>
|
|
{
|
|
subscribedUri = request.Params!.Uri;
|
|
return new ValueTask<EmptyResult>(new EmptyResult());
|
|
};
|
|
options.Handlers.UnsubscribeFromResourcesHandler = (request, ct) =>
|
|
new ValueTask<EmptyResult>(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<InterceptorResult>(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<ListToolsResult>(new ListToolsResult { Tools = [] });
|
|
options.Handlers.CallToolHandler = (request, ct) =>
|
|
new ValueTask<CallToolResult>(new CallToolResult());
|
|
},
|
|
interceptorConfigs:
|
|
[
|
|
[CreateMutationInterceptor("mutator-a", (req, _, _, _) =>
|
|
new ValueTask<InterceptorResult>(new MutationInterceptorResult { Modified = false, Payload = req.Payload }))],
|
|
]);
|
|
|
|
await Assert.ThrowsAsync<McpProtocolException>(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<ListToolsResult>(new ListToolsResult { Tools = [] });
|
|
options.Handlers.CallToolHandler = (request, ct) =>
|
|
new ValueTask<CallToolResult>(new CallToolResult());
|
|
},
|
|
interceptorConfigs:
|
|
[
|
|
[CreateMutationInterceptor("mutator-a", (req, _, _, _) =>
|
|
{
|
|
var obj = JsonNode.Parse(req.Payload!.ToJsonString())!.AsObject();
|
|
obj["a"] = true;
|
|
return new ValueTask<InterceptorResult>(new MutationInterceptorResult
|
|
{
|
|
Modified = true,
|
|
Payload = obj,
|
|
});
|
|
})],
|
|
[CreateMutationInterceptor("mutator-b", (req, _, _, _) =>
|
|
{
|
|
var obj = JsonNode.Parse(req.Payload!.ToJsonString())!.AsObject();
|
|
obj["b"] = true;
|
|
return new ValueTask<InterceptorResult>(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<bool>());
|
|
Assert.True(chainResult.FinalPayload!["b"]!.GetValue<bool>());
|
|
Assert.True(chainResult.FinalPayload!["original"]!.GetValue<bool>());
|
|
}
|
|
|
|
[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<ListToolsResult>(new ListToolsResult
|
|
{
|
|
Tools = [new Tool { Name = "echo", Description = "Echo" }],
|
|
});
|
|
options.Handlers.CallToolHandler = (request, ct) =>
|
|
{
|
|
backendCalled = true;
|
|
return new ValueTask<CallToolResult>(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<ListToolsResult>(new ListToolsResult
|
|
{
|
|
Tools = [new Tool { Name = "echo", Description = "Echo" }],
|
|
});
|
|
options.Handlers.CallToolHandler = (request, ct) =>
|
|
{
|
|
backendCalled = true;
|
|
return new ValueTask<CallToolResult>(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<ListToolsResult>(new ListToolsResult
|
|
{
|
|
Tools = [new Tool { Name = "echo", Description = "Echo" }],
|
|
});
|
|
options.Handlers.CallToolHandler = (request, ct) =>
|
|
new ValueTask<CallToolResult>(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<ListToolsResult>(new ListToolsResult
|
|
{
|
|
Tools = [new Tool { Name = "echo", Description = "Echo" }],
|
|
});
|
|
options.Handlers.CallToolHandler = (request, ct) =>
|
|
new ValueTask<CallToolResult>(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<ListPromptsResult>(new ListPromptsResult
|
|
{
|
|
Prompts = [new Prompt { Name = "test-prompt", Description = "A test prompt" }],
|
|
});
|
|
options.Handlers.GetPromptHandler = (request, ct) =>
|
|
new ValueTask<GetPromptResult>(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<ListPromptsResult>(new ListPromptsResult { Prompts = [] });
|
|
options.Handlers.GetPromptHandler = (request, ct) =>
|
|
new ValueTask<GetPromptResult>(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<InterceptorResult>(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<ListResourcesResult>(new ListResourcesResult
|
|
{
|
|
Resources = [new Resource { Name = "test-resource", Uri = "resource://test" }],
|
|
});
|
|
options.Handlers.ReadResourceHandler = (request, ct) =>
|
|
new ValueTask<ReadResourceResult>(new ReadResourceResult
|
|
{
|
|
Contents = [new TextResourceContents { Text = "content", Uri = request.Params!.Uri, MimeType = "text/plain" }],
|
|
});
|
|
options.Handlers.ListResourceTemplatesHandler = (request, ct) =>
|
|
new ValueTask<ListResourceTemplatesResult>(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<ListResourcesResult>(new ListResourcesResult { Resources = [] });
|
|
options.Handlers.ReadResourceHandler = (request, ct) =>
|
|
new ValueTask<ReadResourceResult>(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<ListResourceTemplatesResult>(new ListResourceTemplatesResult { ResourceTemplates = [] });
|
|
},
|
|
interceptors: [CreateMutationInterceptor("uri-rewriter", (req, _, _, _) =>
|
|
{
|
|
var payloadStr = req.Payload!.ToJsonString();
|
|
payloadStr = payloadStr.Replace("resource://original", "resource://rewritten");
|
|
return new ValueTask<InterceptorResult>(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<CompleteResult>(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<EmptyResult>(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<InvokeInterceptorRequestParams, McpServer, IServiceProvider?, CancellationToken, ValueTask<InterceptorResult>> 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<InvokeInterceptorRequestParams, McpServer, IServiceProvider?, CancellationToken, ValueTask<InterceptorResult>> 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<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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates an in-memory test fixture with a backend server, interceptor server, and proxy server,
|
|
/// all connected via anonymous pipes.
|
|
/// </summary>
|
|
internal sealed class GatewayTestFixture : IAsyncDisposable
|
|
{
|
|
private readonly List<IAsyncDisposable> _disposables;
|
|
|
|
public McpClient ProxyClient { get; }
|
|
|
|
private GatewayTestFixture(McpClient proxyClient, List<IAsyncDisposable> disposables)
|
|
{
|
|
ProxyClient = proxyClient;
|
|
_disposables = disposables;
|
|
}
|
|
|
|
public static async Task<GatewayTestFixture> CreateAsync(
|
|
Action<McpServerOptions> backendConfigure,
|
|
McpServerInterceptor[]? interceptors = null,
|
|
int? timeoutMs = null,
|
|
bool exposeInterceptorProtocol = false,
|
|
string? protocolVersion = null)
|
|
{
|
|
var disposables = new List<IAsyncDisposable>();
|
|
|
|
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<McpServerInterceptor>();
|
|
var allEvents = new HashSet<string>();
|
|
|
|
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<string, object>();
|
|
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<GatewayTestFixture> CreateWithMultipleInterceptorServersAsync(
|
|
Action<McpServerOptions> backendConfigure,
|
|
McpServerInterceptor[][] interceptorConfigs,
|
|
bool exposeInterceptorProtocol = false)
|
|
{
|
|
var disposables = new List<IAsyncDisposable>();
|
|
|
|
try
|
|
{
|
|
var (backendServer, backendClient) = await CreateServerClientPair(
|
|
"test-backend", backendConfigure);
|
|
disposables.Add(backendServer);
|
|
disposables.Add(backendClient);
|
|
|
|
var interceptorClients = new List<McpClient>();
|
|
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<McpServerInterceptor>();
|
|
var allEvents = new HashSet<string>();
|
|
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<string, object>();
|
|
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<GatewayTestFixture> CreateWithBuilderGatewayAsync(
|
|
Action<McpServerOptions> backendConfigure,
|
|
Action<string>? onRegisterNotificationHandler = null,
|
|
bool exposeInterceptorProtocol = false)
|
|
{
|
|
var disposables = new List<IAsyncDisposable>();
|
|
|
|
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<McpServerInterceptor>();
|
|
var filter = new InterceptorMessageFilter(collection);
|
|
options.Filters.Message.IncomingFilters.Add(filter.CreateFilter);
|
|
|
|
options.Capabilities ??= new();
|
|
options.Capabilities.Extensions ??= new Dictionary<string, object>();
|
|
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<GatewayTestFixture> CreateWithBuilderGatewayFactoryAsync(
|
|
Action<McpServerOptions> backendConfigure,
|
|
bool exposeInterceptorProtocol = false)
|
|
{
|
|
var disposables = new List<IAsyncDisposable>();
|
|
|
|
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<McpServerInterceptor>();
|
|
var filter = new InterceptorMessageFilter(collection);
|
|
options.Filters.Message.IncomingFilters.Add(filter.CreateFilter);
|
|
|
|
options.Capabilities ??= new();
|
|
options.Capabilities.Extensions ??= new Dictionary<string, object>();
|
|
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<NamedClient>().Single(c => c.Name == "backend").Client,
|
|
InterceptorClients = [sp.GetServices<NamedClient>().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<McpServerOptions> configure,
|
|
Action<string>? onRegisterNotificationHandler = null) =>
|
|
await CreateServerClientPair(name, configure, onRegisterNotificationHandler);
|
|
|
|
private static async Task<(McpServer server, McpClient client)> CreateServerClientPair(
|
|
string name,
|
|
Action<McpServerOptions> configure,
|
|
Action<string>? 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<string> 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<ClientCompletionDetails> Completion => inner.Completion;
|
|
|
|
public override ValueTask DisposeAsync() => inner.DisposeAsync();
|
|
|
|
public override IAsyncDisposable RegisterNotificationHandler(string method, Func<JsonRpcNotification, CancellationToken, ValueTask> handler)
|
|
{
|
|
onRegister(method);
|
|
return inner.RegisterNotificationHandler(method, handler);
|
|
}
|
|
|
|
public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) =>
|
|
inner.SendMessageAsync(message, cancellationToken);
|
|
public override Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) =>
|
|
inner.SendRequestAsync(request, cancellationToken);
|
|
public override ValueTask<IDictionary<string, InputResponse>> ResolveInputRequestsAsync(IDictionary<string, InputRequest> 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<McpServerOptions>
|
|
{
|
|
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<NamedClient>))
|
|
{
|
|
return _clients;
|
|
}
|
|
|
|
if (serviceType == typeof(NamedClient[]))
|
|
{
|
|
return _clients;
|
|
}
|
|
|
|
if (serviceType == typeof(IEnumerable))
|
|
{
|
|
return _clients;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|