experimental-ext-interceptors/csharp/sdk/CLAUDE.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

8.4 KiB

MCP Interceptors - C# SDK

What this is

C# implementation of gateway-level interceptors from SEP-2624. NuGet package additive to the official C# MCP 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