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>
98 lines
8.4 KiB
Markdown
98 lines
8.4 KiB
Markdown
# MCP Interceptors - C# SDK
|
|
|
|
## What this is
|
|
C# implementation of gateway-level interceptors from [SEP-2624](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2624). NuGet package additive to the official [C# MCP SDK](https://github.com/modelcontextprotocol/csharp-sdk) (v1.1.0). Focus is on the protocol-level extension (client → interceptor server → server), NOT in-process middleware.
|
|
|
|
## Build & test
|
|
```
|
|
dotnet build # from csharp/sdk/
|
|
dotnet test # 98 tests across the interceptor test project
|
|
```
|
|
|
|
## Key architectural constraints
|
|
|
|
**Why message filter, not handlers**: `McpServerHandlers` and `McpServerImpl` are `internal` in the SDK. We can't register handlers for new JSON-RPC methods from outside. Instead we use `McpServerOptions.Filters.Message.IncomingFilters` to intercept `interceptors/list` and `interceptor/invoke`, handle them, send `JsonRpcResponse` via `context.Server.SendMessageAsync()`, and skip calling `next`. See `InterceptorMessageFilter.cs`. Chain execution is no longer a wire method — it's SDK orchestration in `Client/InterceptorChainOrchestrator.cs`.
|
|
|
|
**Why `ServerCapabilities.Extensions`**: The SDK's intended mechanism for protocol extensions. Requires `#pragma warning disable MCPEXP001`. We advertise `InterceptorsCapability { SupportedEvents }` under `Extensions["io.modelcontextprotocol/interceptors"]`.
|
|
|
|
**Client `SendRequestAsync`**: The public overload (`McpSession.Methods.cs:24`) takes `JsonSerializerOptions`. We pass `InterceptorJsonUtilities.DefaultOptions` which chains `McpJsonUtilities.DefaultOptions` + our `InterceptorJsonContext`. The internal overload takes `JsonTypeInfo<T>` — we can't use it.
|
|
|
|
**`InterceptingMcpClient` is composition**: `McpClient` has an internal constructor; subclassing is `[Experimental]`. We wrap it as a concrete class exposing `.Inner` for direct access.
|
|
|
|
## Chain execution order (SEP-2624)
|
|
- **Request phase (sending)**: Mutations (sequential by priority ↑) → Validations (parallel) → Sinks (fire-and-forget)
|
|
- **Response phase (receiving)**: Validations (parallel) → Sinks (fire-and-forget) → Mutations (sequential by priority ↑)
|
|
- Lower `PriorityHint` executes first; scalar (both phases) or per-phase `{request, response}` form, unset phase resolves to 0; ties broken alphabetically by name
|
|
|
|
## JSON-RPC methods
|
|
| Method | Params → Result |
|
|
|--------|----------------|
|
|
| `interceptors/list` | `ListInterceptorsRequestParams` → `ListInterceptorsResult` |
|
|
| `interceptor/invoke` | `InvokeInterceptorRequestParams` → `InterceptorResult` (polymorphic) |
|
|
|
|
## `InterceptorResult` polymorphism
|
|
Uses `[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]` with `"validation"`, `"mutation"`, `"sink"` discriminators. Serialization/deserialization handles this automatically via STJ source-gen in `InterceptorJsonContext`.
|
|
|
|
## Parameter binding (ReflectionMcpServerInterceptor)
|
|
Interceptor methods auto-bind from `InvokeInterceptorRequestParams`:
|
|
- `JsonNode payload` → `.Payload`
|
|
- `JsonNode config` → `.Config`
|
|
- `string event` / `string eventName` → `.Event`
|
|
- `InterceptorPhase phase` → `.Phase`
|
|
- `InvokeInterceptorContext` → `.Context`
|
|
- `CancellationToken`, `McpServer`, `IServiceProvider` → framework
|
|
- Return `bool` → wrapped as `ValidationInterceptorResult { Valid = result }`
|
|
|
|
## SDK reference paths (local at /mnt/d/code/ai/mcp/csharp-sdk)
|
|
- `src/ModelContextProtocol.Core/Server/McpServerTool.cs` — pattern we follow
|
|
- `src/ModelContextProtocol.Core/Server/McpMessageFilter.cs` — our hook point
|
|
- `src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs` — Extensions dict
|
|
- `src/ModelContextProtocol.Core/McpSession.Methods.cs` — public SendRequestAsync
|
|
- `src/ModelContextProtocol/McpServerBuilderExtensions.cs` — builder pattern
|
|
- `src/ModelContextProtocol.Core/McpJsonUtilities.cs` — JSON context chaining pattern
|
|
|
|
## Transparent gateway/proxy (`Gateway/`)
|
|
|
|
**`McpInterceptorGateway`**: Configures an `McpServer` as a transparent proxy. Reads backend `ServerCapabilities`, registers handler delegates (`CallToolHandler`, `ListToolsHandler`, etc.) that route through interceptor chains before forwarding to the backend. By default the gateway is transparent-only; SEP passthrough is opt-in via `ExposeInterceptorProtocol = true`. To connecting clients, the proxy appears to be the backend server.
|
|
|
|
**`InterceptorChainRunner`** (internal): Shared interception logic used by both `InterceptingMcpClient` and `McpInterceptorGateway`. Multiple interceptor clients form a single merged chain per the SEP: interceptors discovered from every client (parallel `interceptors/list`, fail-closed) are sorted globally by priority hint (alphabetical tie-break) and each is invoked on the client hosting it. Public API: `Client/InterceptorChain.cs` (`DiscoverAsync` + `ExecuteAsync` over `IEnumerable<McpClient>`); duplicate names across servers are not deduplicated.
|
|
|
|
**Gateway split**: `GatewayProxyConfigurator` owns transparent MCP proxy wiring, `GatewayInterceptorProtocolBridge` owns optional SEP passthrough wiring, and `GatewayConnectionForwardingRegistrar` owns connection-bound notification forwarding registration.
|
|
|
|
**`McpInterceptorGatewayOptions`**: Configuration — `BackendClient`, optional preconnected `InterceptorClients`, optional external `InterceptorServerConnections`, optional dynamic `InterceptorServerConnectionResolver`, `Events` filter, `TimeoutMs`, `DefaultContext`, optional `ServerInfo` override, and `ExposeInterceptorProtocol`.
|
|
|
|
**External interceptor connections**: `McpInterceptorGateway.CreateAsync(...)` can create interceptor clients from standard SDK transports (`IClientTransport`) via `McpInterceptorServerConnectionOptions`. Dynamic per-request transparent resolution is supported via `InterceptorServerConnectionResolver` using the SDK's `MessageContext` plus SEP event name.
|
|
|
|
**Builder extension**: `IMcpServerBuilder.WithInterceptorGateway(options)` and `WithInterceptorGateway(Func<IServiceProvider, McpInterceptorGatewayOptions>)` for DI/builder scenarios. Builder-based wiring still expects already-connected interceptor clients; async external transport setup should use `McpInterceptorGateway.CreateAsync(...)`.
|
|
|
|
**Notification forwarding**: `gateway.RegisterNotificationForwarding(proxyServer)` subscribes to backend `tools/list_changed`, `prompts/list_changed`, `resources/list_changed` and re-sends through the proxy.
|
|
|
|
**Why handler delegates (not message filters) for the proxy**: The SDK's `With*Handler` methods automatically set `ServerCapabilities`, are type-safe, and are the intended extension point. Message filters are still used for interceptor protocol passthrough (`interceptors/list`, `interceptor/invoke`).
|
|
|
|
**Tool call error handling note**: When an interceptor validation aborts a `tools/call`, the gateway throws `McpInterceptorValidationException`. The SDK catches this and returns `CallToolResult { IsError = true }` (not a JSON-RPC error), since tool execution errors are returned as results by design.
|
|
|
|
## `InterceptingMcpClient` wrapped methods
|
|
- `CallToolAsync` — `tools/call`
|
|
- `ListToolsAsync` — `tools/list`
|
|
- `ListPromptsAsync` — `prompts/list`
|
|
- `GetPromptAsync` — `prompts/get`
|
|
- `ListResourcesAsync` — `resources/list`
|
|
- `ReadResourceAsync` — `resources/read`
|
|
- `SubscribeToResourceAsync` — `resources/subscribe`
|
|
- `ListInterceptorsAsync` — direct passthrough to interceptor client
|
|
|
|
## LLM completion payloads (`Protocol/LlmCompletionPayload.cs`)
|
|
- `LlmCompletionRequestPayload` — model, messages, maxTokens, temperature, metadata
|
|
- `LlmCompletionResponsePayload` — model, message, stopReason, usage, metadata
|
|
- `LlmMessage` — role + content
|
|
- `LlmUsage` — inputTokens + outputTokens
|
|
- Registered in `InterceptorJsonContext` for source-gen serialization
|
|
- Not wired into `InterceptingMcpClient` — these are for custom gateway use
|
|
|
|
## Samples
|
|
- `InterceptorServerSample` — stdio server hosting 3 interceptors
|
|
- `GatewaySample` — single gateway: client → interceptor → everything server
|
|
- `InterceptorClientSample` — client API: list, invoke, execute chain directly
|
|
- `GatewayChainSample` — chained gateways: security layer → logging layer → server
|
|
- `TransparentProxySample` — stdio proxy server: clients connect to it as if it were the backend, all requests routed through interceptors
|
|
- `ConfigDrivenGatewaySample` — sample-only config-driven host composed from the gateway primitives; demonstrates static and dynamic external interceptor resolution with transport-configured backend/interceptor clients
|