## Summary
Bumps the MCP C# SDK dependency from 1.1.0 to 2.1.0 across all 9
projects and aligns the implementation with the 2.x surface and the
2026-07-28 spec — not just a version bump.
### Compile fixes
- Removed the gateway's `Capabilities.Tasks = null` suppression and its
test: the experimental Tasks API was removed from SDK core in 2.0 (moved
to a separate, wire-incompatible `ModelContextProtocol.Extensions.Tasks`
package).
- Added `ResolveInputRequestsAsync` (new abstract MRTR member on
`McpClient`) overrides to the test doubles. MRTR forwarding is
deliberately out of scope for this PR; the gateway's server reports
`IsMrtrSupported == false`.
- Deleted all `MCPEXP001` pragmas — `ServerCapabilities.Extensions`
(which carries `io.modelcontextprotocol/interceptors`) is stable in 2.x.
`MCPEXP002` subclassing pragmas are kept.
### Spec deprecations (SEP-2577 / SEP-2575)
- The gateway intentionally keeps proxying `logging/setLevel` and
`resources/subscribe` for down-level clients/backends, with targeted
`MCP9005` suppressions.
- On negotiated `2026-07-28` connections the SDK rejects these methods
at the session layer (replaced by per-request `_meta` log levels and
`subscriptions/listen`), so the affected tests now pin `protocolVersion:
"2025-06-18"` through a new fixture parameter — making them genuine
down-level-compatibility tests.
### Defensive hardening
- `Tool.inputSchema` is required and validated on deserialization in
2.x. The gateway's `ListToolsHandler` now fetches the backend
`tools/list` result as raw JSON and runs
`GatewayProxyConfigurator.EnsureToolInputSchemas` before and after the
interceptor chain, coercing missing/invalid schemas to the spec-default
`{"type":"object"}` so down-level backends and schema-stripping
interceptors can't fail the request.
### Tests & docs
- New tests: end-to-end schema-stripping coercion,
`EnsureToolInputSchemas` unit cases, and `2026-07-28` protocol-version
negotiation through the gateway (implicitly exercising
`server/discover`).
- `CLAUDE.md` gains an "SDK 2.x / spec 2026-07-28 notes" section;
`ARCHITECTURE_PHASES.md` no longer lists `Tasks` as a mirrored
capability.
## Test plan
- [x] `dotnet build` — 0 errors, 0 warnings (library, tests, all 7
samples)
- [x] `dotnet test` — 105/105 passing (was 98: −1 removed Tasks test, +8
new)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Ola Hungerford <olahungerford@gmail.com>
9.5 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 (v2.1.0, MCP spec 2026-07-28). Focus is on the protocol-level extension (client → interceptor server → server), NOT in-process middleware.
Build & test
dotnet build # from csharp/sdk/
dotnet test # 105 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 (stable since SDK 2.x — no experimental pragma needed). We advertise InterceptorsCapability { SupportedEvents } under Extensions["io.modelcontextprotocol/interceptors"].
Client SendRequestAsync: The public SendRequestAsync<TParameters, TResult> endpoint extension takes JsonSerializerOptions (call it with named cancellationToken: — 2.x inserted an optional RequestId parameter before it). We pass InterceptorJsonUtilities.DefaultOptions which chains McpJsonUtilities.DefaultOptions + our InterceptorJsonContext. The internal overload takes JsonTypeInfo<T> — we can't use it.
SDK 2.x / spec 2026-07-28 notes:
- Tasks moved out of SDK core into a separate, wire-incompatible
ModelContextProtocol.Extensions.Taskspackage; the gateway no longer needs to suppress a backendTaskscapability. - Logging (
logging/setLevel) andresources/subscribeare deprecated by the spec (SEP-2577 / SEP-2575) and rejected on2026-07-28connections, but the gateway intentionally keeps proxying them for down-level clients/backends (#pragma warning disable MCP9005at those sites; tests pinprotocolVersion: "2025-06-18"). Tool.inputSchemais required and validated on deserialization; the gateway fetchestools/listas raw JSON and coerces missing/invalid schemas to{"type":"object"}(GatewayProxyConfigurator.EnsureToolInputSchemas) so down-level backends and schema-stripping interceptors can't fail the request.- MRTR (multi-round-trip requests) is not forwarded; the gateway's server reports
IsMrtrSupported == false.
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. 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/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