experimental-ext-interceptors/csharp/sdk/README.md
Peder Holdgaard Pedersen 28ada74189
C# SDK: multi-server interceptor chain orchestration per SEP (#18)
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>
2026-08-06 17:22:33 +02:00

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.