Addresses the third item of #15 ("Chain orchestrator only supports one MCP server"). > **Stacked on #17** — this PR targets that branch because it builds on the per-phase `PriorityHint` resolution introduced there. After #17 merges, the base will be switched to `main` (or it can be retargeted now and rebased). ## What changed The SEP's chain execution pattern requires: discover via `interceptors/list` on one or more servers → merge & sort all interceptors into a single chain by `priorityHint` (ascending, alphabetical tie-break) → invoke each interceptor on the server that hosts it (mutations sequential with payload chaining, validations parallel) → aggregate one result. The SDK previously ran each client's entire chain sequentially, so a `-1000` mutation on server 2 ran after a `+1000` mutation on server 1, and each server's validations only saw its own server's mutations. - **Orchestrator** (`InterceptorChainOrchestrator`, internal): now operates on chain entries — descriptor + invoker routing to its host — mirroring the SEP's `ChainEntry`, with one global sort across all filtered entries. The stable sort preserves caller-supplied server order for exact ties. - **New public API** (`InterceptorChain` / `InterceptorChainEntry`): `DiscoverAsync` lists from every server in parallel (fail-closed: any list failure throws rather than silently dropping a server's interceptors), instance `ExecuteAsync` runs prebuilt entries (a future caching seam), and a one-shot static `ExecuteAsync(servers, params)` does discover + execute. - **`InterceptorChainRunner`** (used by the gateway and `InterceptingMcpClient`): one merged chain instead of the per-client sequential loop. The single-client `client.ExecuteChainAsync(...)` extension keeps its signature and delegates to the same path (still 1 list + N invokes). - Duplicate interceptor names across servers are not deduplicated — each entry invokes on its own host (results may share an `InterceptorName`; documented). ## Behavior changes for multi-client gateways - Interceptors from all servers form one globally-ordered chain (previously server 1's full chain ran before server 2's). - Validations from all servers run as one parallel batch against the payload after **all** mutations. - Sinks run once per phase for the merged chain (previously once per client). - `TimeoutMs` bounds the whole merged chain, not each per-client sub-chain. - Any server's `interceptors/list` failure fails the chain up-front (fail-closed). - Cost per phase: M parallel `interceptors/list` calls + N `interceptor/invoke` calls. ## Testing `dotnet test` from `csharp/sdk/`: 97 passing (was 87 after #17). New coverage: - Orchestrator: global priority ordering across servers, alphabetical tie-break across servers, payload chaining across servers in one mutation pass, all-server validations seeing the post-mutation payload, duplicate names invoking once per host. - `InterceptorChainTests` (real in-memory servers): discovery merge with server attribution, merged execution order, fail-closed discovery, single-client path equivalence. - Gateway: two interceptor servers where the second hosts the lower-priority mutation — the backend receives the globally-ordered payload (fails under the old sequential runner). Closes #15. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
287 lines
12 KiB
Markdown
287 lines
12 KiB
Markdown
# ModelContextProtocol.Interceptors
|
|
|
|
C# implementation of the [MCP Interceptors Extension (SEP-2624)](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2624) — gateway-level interceptors for the Model Context Protocol.
|
|
|
|
Architecture work is tracked in [`docs/ARCHITECTURE_PHASES.md`](docs/ARCHITECTURE_PHASES.md).
|
|
SEP follow-up notes from the implementation are captured in [`docs/SEP_PROPOSAL_NOTES.md`](docs/SEP_PROPOSAL_NOTES.md).
|
|
|
|
## Overview
|
|
|
|
This package enables creating interceptor servers that sit between MCP clients and servers, providing validation, mutation, and sink (non-blocking observational) capabilities without modifying either the client or server.
|
|
|
|
```
|
|
Client ──▶ Interceptor Server ──▶ Server
|
|
◀── (validates/mutates) ◀── (tools)
|
|
```
|
|
|
|
## Quick Start
|
|
|
|
### Creating an Interceptor Server
|
|
|
|
```csharp
|
|
var builder = Host.CreateApplicationBuilder(args);
|
|
|
|
builder.Services.AddMcpServer()
|
|
.WithStdioServerTransport()
|
|
.WithInterceptors<MyInterceptors>();
|
|
|
|
var app = builder.Build();
|
|
await app.RunAsync();
|
|
|
|
[McpServerInterceptorType]
|
|
public class MyInterceptors
|
|
{
|
|
[McpServerInterceptor(Name = "pii-validator", Type = InterceptorType.Validation,
|
|
Events = [InterceptionEvents.ToolsCall], Phase = InterceptorPhase.Request)]
|
|
public static ValidationInterceptorResult ValidatePii(JsonNode payload)
|
|
{
|
|
// Check for PII patterns
|
|
return ValidationInterceptorResult.Success();
|
|
}
|
|
|
|
[McpServerInterceptor(Name = "email-redactor", Type = InterceptorType.Mutation,
|
|
Events = [InterceptionEvents.ToolsCall], PriorityHint = -1000)]
|
|
public static MutationInterceptorResult RedactEmails(JsonNode payload)
|
|
{
|
|
// Modify the payload
|
|
return new MutationInterceptorResult { Modified = true, Payload = modifiedPayload };
|
|
}
|
|
}
|
|
```
|
|
|
|
`PriorityHint` applies to both phases; use `RequestPriorityHint`/`ResponsePriorityHint` instead to
|
|
order a mutation differently per phase (unset phases default to 0).
|
|
|
|
### Consuming Interceptors from a Client
|
|
|
|
```csharp
|
|
// Connect to the interceptor server
|
|
var interceptorClient = await McpClient.CreateAsync(interceptorTransport);
|
|
|
|
// List available interceptors
|
|
var interceptors = await interceptorClient.ListInterceptorsAsync();
|
|
|
|
// Invoke a single interceptor
|
|
var result = await interceptorClient.InvokeInterceptorAsync(new InvokeInterceptorRequestParams
|
|
{
|
|
Name = "pii-validator",
|
|
Event = InterceptionEvents.ToolsCall,
|
|
Phase = InterceptorPhase.Request,
|
|
Payload = JsonNode.Parse("""{"name":"call-tool","arguments":{"query":"test"}}""")!,
|
|
});
|
|
|
|
// Execute a full chain (SDK-level orchestration: discovers via `interceptors/list`,
|
|
// then dispatches each applicable interceptor via `interceptor/invoke`)
|
|
var chainResult = await interceptorClient.ExecuteChainAsync(new ExecuteChainRequestParams
|
|
{
|
|
Event = InterceptionEvents.ToolsCall,
|
|
Phase = InterceptorPhase.Request,
|
|
Payload = myPayload,
|
|
});
|
|
|
|
// Or span multiple interceptor servers: interceptors are discovered from every server,
|
|
// merged into one chain sorted globally by priority hint, and each is invoked on the
|
|
// server that hosts it
|
|
var multiResult = await InterceptorChain.ExecuteAsync(
|
|
[interceptorClientA, interceptorClientB],
|
|
new ExecuteChainRequestParams
|
|
{
|
|
Event = InterceptionEvents.ToolsCall,
|
|
Phase = InterceptorPhase.Request,
|
|
Payload = myPayload,
|
|
});
|
|
```
|
|
|
|
### Gateway Pattern (Client-Side)
|
|
|
|
Use `InterceptingMcpClient` when your code is the caller and you want to route operations through interceptors before they reach the server:
|
|
|
|
```csharp
|
|
// Connect to both the interceptor server and the actual MCP server
|
|
var interceptorClient = await McpClient.CreateAsync(interceptorTransport);
|
|
var mcpClient = await McpClient.CreateAsync(mcpTransport);
|
|
|
|
// Create the gateway wrapper
|
|
var gateway = new InterceptingMcpClient(mcpClient, new InterceptingMcpClientOptions
|
|
{
|
|
InterceptorClient = interceptorClient,
|
|
Events = [InterceptionEvents.ToolsCall],
|
|
});
|
|
|
|
// All tool calls now flow through interceptors automatically
|
|
var result = await gateway.CallToolAsync("my-tool", new Dictionary<string, object?> { ["query"] = "test" });
|
|
```
|
|
|
|
### Transparent Proxy (Server-Side)
|
|
|
|
Use `McpInterceptorGateway` to create an MCP server that transparently proxies requests through interceptors to a backend server. Connecting clients see the proxy as the backend itself — no client-side changes needed.
|
|
|
|
By default, the gateway is transparent-only: it does not advertise or expose the SEP interceptor protocol to connecting clients. If you want the gateway to also expose `interceptors/list` and `interceptor/invoke`, enable that explicitly with `ExposeInterceptorProtocol = true`.
|
|
|
|
```
|
|
Client ──▶ Proxy Server ──▶ Interceptor Server ──▶ Backend Server
|
|
◀── (transparent) ◀── (validates/mutates) ◀── (tools, etc.)
|
|
```
|
|
|
|
```csharp
|
|
// Connect to the backend and interceptor servers
|
|
await using var backend = await McpClient.CreateAsync(backendTransport);
|
|
await using var interceptors = await McpClient.CreateAsync(interceptorTransport);
|
|
|
|
// Create the gateway
|
|
await using var gateway = new McpInterceptorGateway(new McpInterceptorGatewayOptions
|
|
{
|
|
BackendClient = backend,
|
|
InterceptorClients = [interceptors],
|
|
Events = [InterceptionEvents.ToolsCall], // null = intercept all events
|
|
ExposeInterceptorProtocol = false,
|
|
});
|
|
|
|
// Configure and start the proxy server on stdio
|
|
var serverOptions = new McpServerOptions();
|
|
gateway.ConfigureServerOptions(serverOptions);
|
|
|
|
await using var server = McpServer.Create(
|
|
new StdioServerTransport("my-proxy"), serverOptions);
|
|
gateway.RegisterNotificationForwarding(server);
|
|
await server.RunAsync();
|
|
```
|
|
|
|
The proxy mirrors the backend's advertised capability graph and forwards `*_list_changed` notifications for the supported list surfaces. Multiple interceptor clients form a single merged chain per the SEP — interceptors from all servers are sorted globally by priority hint (alphabetical tie-break) and each is invoked on the server that hosts it.
|
|
|
|
If you want the gateway to connect to external SEP-exposing interceptor servers itself, use the async factory and provide standard MCP client transports:
|
|
|
|
```csharp
|
|
await using var gateway = await McpInterceptorGateway.CreateAsync(new McpInterceptorGatewayOptions
|
|
{
|
|
BackendClient = backend,
|
|
InterceptorServerConnections =
|
|
[
|
|
new McpInterceptorServerConnectionOptions
|
|
{
|
|
Transport = new StdioClientTransport(new StdioClientTransportOptions
|
|
{
|
|
Command = "dotnet",
|
|
Arguments = ["run", "--project", "path/to/interceptor-server"],
|
|
}),
|
|
},
|
|
],
|
|
});
|
|
```
|
|
|
|
This follows the same transport-driven pattern as `McpClient.CreateAsync(...)`: you supply `IClientTransport` instances such as `StdioClientTransport`, `HttpClientTransport`, or `StreamClientTransport`, along with optional `McpClientOptions` and logging via `McpInterceptorServerConnectionOptions`.
|
|
|
|
For dynamic transparent middleware scenarios, the gateway can also resolve external interceptor connections per request:
|
|
|
|
```csharp
|
|
await using var gateway = new McpInterceptorGateway(new McpInterceptorGatewayOptions
|
|
{
|
|
BackendClient = backend,
|
|
InterceptorClients = [staticSecurityLayer], // optional: resolver-only mode is also supported
|
|
InterceptorServerConnectionResolver = (context, @event, ct) =>
|
|
{
|
|
if (@event == InterceptionEvents.ToolsCall && context.User?.Identity?.Name == "alice")
|
|
{
|
|
return ValueTask.FromResult<IReadOnlyList<McpInterceptorServerConnectionOptions>>(
|
|
[
|
|
new McpInterceptorServerConnectionOptions
|
|
{
|
|
ConnectionId = "alice-logging",
|
|
Transport = new StdioClientTransport(new StdioClientTransportOptions
|
|
{
|
|
Command = "dotnet",
|
|
Arguments = ["run", "--project", "path/to/alice-interceptor-server"],
|
|
}),
|
|
},
|
|
]);
|
|
}
|
|
|
|
return ValueTask.FromResult<IReadOnlyList<McpInterceptorServerConnectionOptions>>([]);
|
|
},
|
|
});
|
|
```
|
|
|
|
The resolver receives the SDK's existing `MessageContext` plus the SEP event name. This lets you base resolution on existing request data such as `context.User`, `context.Items`, scoped services, or transport-specific information without introducing a separate gateway-specific identity abstraction.
|
|
|
|
In this first version, dynamic resolution is supported for the transparent proxy path only. SEP passthrough (`ExposeInterceptorProtocol = true`) still requires statically configured interceptor clients.
|
|
|
|
**With DI / builder pattern:**
|
|
|
|
```csharp
|
|
builder.Services.AddMcpServer()
|
|
.WithInterceptorGateway(new McpInterceptorGatewayOptions
|
|
{
|
|
BackendClient = backend,
|
|
InterceptorClients = [interceptors],
|
|
});
|
|
```
|
|
|
|
The builder extension handles notification forwarding automatically, registering once per session for multi-connection transports (HTTP) and once for single-connection transports (stdio).
|
|
|
|
You can also configure the gateway from DI using a service-provider-based overload:
|
|
|
|
```csharp
|
|
builder.Services.AddMcpServer()
|
|
.WithInterceptorGateway(sp => new McpInterceptorGatewayOptions
|
|
{
|
|
BackendClient = sp.GetRequiredService<BackendMcpClientHolder>().Client,
|
|
InterceptorClients = [sp.GetRequiredService<InterceptorMcpClientHolder>().Client],
|
|
});
|
|
```
|
|
|
|
Internally, the builder path wires notification forwarding once per logical connection. Current transports may expose a session identifier, but that is treated as an implementation detail rather than a public architectural concept.
|
|
|
|
The builder-based gateway configuration currently expects already-connected interceptor clients. External interceptor server connections that need async transport setup should use `McpInterceptorGateway.CreateAsync(...)`.
|
|
|
|
If you want to see how these primitives can be composed into a config-driven host without the library defining a config format, see `samples/ConfigDrivenGatewaySample`. The sample shows transport-agnostic outbound connectivity for backend/interceptor servers (including Streamable HTTP). Its JSON schema is sample-only.
|
|
|
|
If you want to host the gateway itself over Streamable HTTP instead of stdio, compose `McpInterceptorGateway` with the core SDK's ASP.NET transport support (`AddMcpServer().WithHttpTransport()` and `MapMcp()`).
|
|
|
|
To expose the SEP interceptor protocol through the gateway as an advanced mode, set:
|
|
|
|
```csharp
|
|
ExposeInterceptorProtocol = true
|
|
```
|
|
|
|
**Claude Desktop integration** — point it at a proxy binary:
|
|
|
|
```json
|
|
{
|
|
"mcpServers": {
|
|
"my-server-with-interceptors": {
|
|
"command": "dotnet",
|
|
"args": ["run", "--project", "path/to/TransparentProxySample"]
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Interceptor Types
|
|
|
|
| Type | Execution | Purpose |
|
|
|------|-----------|---------|
|
|
| **Validation** | Parallel | Validates payloads. Error severity aborts the chain. |
|
|
| **Mutation** | Sequential (by priority) | Transforms payloads. Output chains to next mutation. |
|
|
| **Sink** | Parallel (fire-and-forget) | Non-blocking, non-mutating reactions to context (logging, telemetry, avatar/voice triggers). Failures are swallowed. |
|
|
|
|
## Chain Execution Order
|
|
|
|
**Request phase (sending):** Mutations → Validations → Sinks → send
|
|
**Response phase (receiving):** Validations → Sinks → Mutations → process
|
|
|
|
## Parameter Binding
|
|
|
|
Interceptor methods support automatic parameter binding:
|
|
|
|
| Parameter Type | Bound From |
|
|
|---------------|------------|
|
|
| `JsonNode payload` | `InvokeInterceptorRequestParams.Payload` |
|
|
| `JsonNode config` | `InvokeInterceptorRequestParams.Config` |
|
|
| `string event` | `InvokeInterceptorRequestParams.Event` |
|
|
| `InterceptorPhase phase` | `InvokeInterceptorRequestParams.Phase` |
|
|
| `InvokeInterceptorContext` | `InvokeInterceptorRequestParams.Context` |
|
|
| `CancellationToken` | Framework cancellation token |
|
|
| `McpServer` | Current server instance |
|
|
| `IServiceProvider` | Request-scoped DI container |
|
|
|
|
Methods can return `InterceptorResult` (or any subclass), `bool` (wrapped as `ValidationInterceptorResult`), or `Task<T>`/`ValueTask<T>` variants of these.
|