| Filename | Latest commit message | Latest commit date |
|---|---|---|
## 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>
|
||
| .. | ||
| docs | ||
| samples | ||
| src/ModelContextProtocol.Interceptors | ||
| tests/ModelContextProtocol.Interceptors.Tests | ||
| CLAUDE.md | ||
| ModelContextProtocol.Interceptors.sln | ||
| README.md | ||
ModelContextProtocol.Interceptors
C# implementation of the MCP Interceptors Extension (SEP-2624) — gateway-level interceptors for the Model Context Protocol.
Architecture work is tracked in docs/ARCHITECTURE_PHASES.md.
SEP follow-up notes from the implementation are captured in 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
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
// 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:
// 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.)
// 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:
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:
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:
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:
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:
ExposeInterceptorProtocol = true
Claude Desktop integration — point it at a proxy binary:
{
"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.