Addresses the first two items of #15 (the chain-orchestrator multi-server item follows in a separate PR). ## `mode`: keep `"active"`, add `"audit"` round-trip coverage > **Update:** an earlier revision of this PR renamed `mode` from `"active"` to `"enforce"`, following a SEP **draft**. The now-current revised SEP-1763 ([PR #2624](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2624)) defines `mode?: "active" | "audit"` with default `"active"` — so `"active"` was correct all along. @jeongukjae flagged this; the rename has been reverted (commit 58edf2c). The C# SDK's wire value `"active"` matches the SEP and the TypeScript SDK. `InterceptorMode` keeps members `Active` (wire `"active"`, default) and `Audit` (wire `"audit"`). Added a deserialization test alongside the existing serialization test so both wire forms are covered. ## `priorityHint`: support `number | { request?, response? }` The SDK only supported a plain `int`. This adds a `PriorityHint` protocol type whose converter accepts both wire forms and preserves them on round-trip (a number stays a number, an object stays an object). Chain ordering now resolves the effective priority per phase exactly as the SEP's `resolvePriority` algorithm (unset → 0, alphabetical tie-break unchanged). - `Interceptor.PriorityHint` is now `PriorityHint?`; an implicit conversion from `int` keeps existing code compiling. - `[McpServerInterceptor]` gains `RequestPriorityHint` / `ResponsePriorityHint` (mutually exclusive with the scalar `PriorityHint`), following the official SDK's backing-field pattern for optional attribute values. - Behavior note: attribute-discovered interceptors with no priority set now omit `priorityHint` from the wire instead of emitting `0` — semantically identical per the SEP default, and consistent with the existing default-skipping for `Mode`/`FailOpen`. ## Testing `dotnet test` from `csharp/sdk/`: all passing, including new coverage for both `priorityHint` wire forms, partial objects, unknown-key tolerance, phase-dependent mutation ordering (the SEP's pii-redactor/compressor example), the attribute conflict error, and both `mode` wire values. Part of #15. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
8.2 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 # 87 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
PriorityHintexecutes 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→.PayloadJsonNode config→.Configstring event/string eventName→.EventInterceptorPhase phase→.PhaseInvokeInterceptorContext→.ContextCancellationToken,McpServer,IServiceProvider→ framework- Return
bool→ wrapped asValidationInterceptorResult { Valid = result }
SDK reference paths (local at /mnt/d/code/ai/mcp/csharp-sdk)
src/ModelContextProtocol.Core/Server/McpServerTool.cs— pattern we followsrc/ModelContextProtocol.Core/Server/McpMessageFilter.cs— our hook pointsrc/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs— Extensions dictsrc/ModelContextProtocol.Core/McpSession.Methods.cs— public SendRequestAsyncsrc/ModelContextProtocol/McpServerBuilderExtensions.cs— builder patternsrc/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. Supports multiple interceptor clients executed sequentially — each client's ExecuteChainAsync (SDK-level orchestration via list + invoke) receives the mutated payload from the previous one.
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/callListToolsAsync—tools/listListPromptsAsync—prompts/listGetPromptAsync—prompts/getListResourcesAsync—resources/listReadResourceAsync—resources/readSubscribeToResourceAsync—resources/subscribeListInterceptorsAsync— direct passthrough to interceptor client
LLM completion payloads (Protocol/LlmCompletionPayload.cs)
LlmCompletionRequestPayload— model, messages, maxTokens, temperature, metadataLlmCompletionResponsePayload— model, message, stopReason, usage, metadataLlmMessage— role + contentLlmUsage— inputTokens + outputTokens- Registered in
InterceptorJsonContextfor source-gen serialization - Not wired into
InterceptingMcpClient— these are for custom gateway use
Samples
InterceptorServerSample— stdio server hosting 3 interceptorsGatewaySample— single gateway: client → interceptor → everything serverInterceptorClientSample— client API: list, invoke, execute chain directlyGatewayChainSample— chained gateways: security layer → logging layer → serverTransparentProxySample— stdio proxy server: clients connect to it as if it were the backend, all requests routed through interceptorsConfigDrivenGatewaySample— sample-only config-driven host composed from the gateway primitives; demonstrates static and dynamic external interceptor resolution with transport-configured backend/interceptor clients