Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions csharp/sdk/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ C# implementation of gateway-level interceptors from [SEP-1763](https://github.c
## Build & test
```
dotnet build # from csharp/sdk/
dotnet test # 87 tests across the interceptor test project
dotnet test # 97 tests across the interceptor test project
```

## Key architectural constraints
Expand Down Expand Up @@ -55,7 +55,7 @@ Interceptor methods auto-bind from `InvokeInterceptorRequestParams`:

**`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.
**`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.

Expand Down
14 changes: 13 additions & 1 deletion csharp/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ var chainResult = await interceptorClient.ExecuteChainAsync(new ExecuteChainRequ
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)
Expand Down Expand Up @@ -135,7 +147,7 @@ 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 can be chained — they execute in order, each receiving the previous client's mutated payload.
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:

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using ModelContextProtocol.Client;
using ModelContextProtocol.Interceptors.Protocol;

namespace ModelContextProtocol.Interceptors.Client;

/// <summary>
/// An interceptor chain assembled from one or more MCP servers, per the SEP orchestration pattern:
/// discover via <c>interceptors/list</c> on every server, merge all entries into a single chain
/// sorted by priority hint (alphabetical tie-break), and execute each interceptor via
/// <c>interceptor/invoke</c> on the server that hosts it — mutations sequentially (each receiving
/// the previous one's output), validations in parallel.
/// </summary>
/// <remarks>
/// Interceptors with the same name on different servers are not deduplicated; each entry is
/// invoked on its own host, so results may share an <see cref="InterceptorResult.InterceptorName"/>.
/// </remarks>
public sealed class InterceptorChain
{
/// <summary>Creates a chain from pre-discovered entries.</summary>
public InterceptorChain(IReadOnlyList<InterceptorChainEntry> entries)
{
ArgumentNullException.ThrowIfNull(entries);
Entries = entries;
}

/// <summary>Gets all interceptor entries in the chain, collected from one or more servers.</summary>
public IReadOnlyList<InterceptorChainEntry> Entries { get; }

/// <summary>
/// Discovers interceptors by calling <c>interceptors/list</c> on every server in parallel and
/// assembles them into a chain, preserving server order for stable ordering of exact ties.
/// </summary>
/// <remarks>
/// Discovery is fail-closed: if any server's <c>interceptors/list</c> fails, the whole call
/// throws rather than silently dropping that server's interceptors.
/// </remarks>
public static async ValueTask<InterceptorChain> DiscoverAsync(
IEnumerable<McpClient> servers,
ListInterceptorsRequestParams? requestParams = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(servers);

var serverList = servers.ToList();
var listed = await Task.WhenAll(serverList.Select(
server => server.ListInterceptorsAsync(requestParams, cancellationToken).AsTask())).ConfigureAwait(false);

var entries = new List<InterceptorChainEntry>();
for (var i = 0; i < serverList.Count; i++)
{
foreach (var interceptor in listed[i].Interceptors)
{
entries.Add(new InterceptorChainEntry { Interceptor = interceptor, Server = serverList[i] });
}
}

return new InterceptorChain(entries);
}

/// <summary>
/// Executes the chain for the given event and phase using the SEP execution model.
/// </summary>
public ValueTask<InterceptorChainResult> ExecuteAsync(
ExecuteChainRequestParams requestParams,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(requestParams);

var executionEntries = Entries
.Select(e => new InterceptorChainOrchestrator.ChainExecutionEntry(
e.Interceptor,
(invokeParams, ct) => e.Server.InvokeInterceptorAsync(invokeParams, ct)))
.ToList();

return InterceptorChainOrchestrator.ExecuteAsync(executionEntries, requestParams, cancellationToken);
}

/// <summary>
/// Discovers interceptors across the given servers (filtered by the request's event) and
/// executes the merged chain in one call.
/// </summary>
public static async ValueTask<InterceptorChainResult> ExecuteAsync(
IEnumerable<McpClient> servers,
ExecuteChainRequestParams requestParams,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(requestParams);

var chain = await DiscoverAsync(
servers,
new ListInterceptorsRequestParams { Event = requestParams.Event },
cancellationToken).ConfigureAwait(false);

return await chain.ExecuteAsync(requestParams, cancellationToken).ConfigureAwait(false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using ModelContextProtocol.Client;
using ModelContextProtocol.Interceptors.Protocol;

namespace ModelContextProtocol.Interceptors.Client;

/// <summary>
/// An entry in an interceptor chain: an interceptor descriptor paired with the MCP server that
/// hosts it. The server reference is used to route <c>interceptor/invoke</c> calls to the correct
/// server when a chain spans multiple servers.
/// </summary>
public sealed class InterceptorChainEntry
{
/// <summary>Gets the interceptor descriptor, as returned by <c>interceptors/list</c>.</summary>
public required Interceptor Interceptor { get; init; }

/// <summary>Gets the client connected to the MCP server hosting this interceptor.</summary>
public required McpClient Server { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
namespace ModelContextProtocol.Interceptors.Client;

/// <summary>
/// Client-side chain orchestrator. Takes a list of interceptor descriptors (typically obtained
/// via <c>interceptors/list</c>) and an invoker delegate (typically wired to
/// <c>interceptor/invoke</c> over the wire), and runs them according to the SEP execution model.
/// Client-side chain orchestrator. Takes chain entries — interceptor descriptors (typically obtained
/// via <c>interceptors/list</c>, potentially from multiple servers) each paired with an invoker
/// delegate routing to the server hosting it — and runs them according to the SEP execution model.
/// </summary>
/// <remarks>
/// Per the SEP, chain execution is a convenience utility provided by SDKs — not a wire JSON-RPC
/// method. This orchestrator enforces the trust-boundary-aware ordering:
/// method. This orchestrator merges all entries into a single chain sorted by priority hint
/// (alphabetical tie-break) and enforces the trust-boundary-aware ordering:
/// <list type="bullet">
/// <item>Sending (request phase): mutations (sequential by priority) → validations (parallel) → sinks (fire-and-forget)</item>
/// <item>Receiving (response phase): validations (parallel) → sinks (fire-and-forget) → mutations (sequential by priority)</item>
Expand All @@ -24,11 +25,31 @@ internal delegate ValueTask<InterceptorResult> InterceptorInvoker(
InvokeInterceptorRequestParams request,
CancellationToken cancellationToken);

internal static async ValueTask<InterceptorChainResult> ExecuteAsync(
/// <summary>
/// An interceptor descriptor paired with the invoker that routes <c>interceptor/invoke</c>
/// to the server hosting it. Mirrors the SEP's <c>ChainEntry</c>.
/// </summary>
internal readonly record struct ChainExecutionEntry(Interceptor Descriptor, InterceptorInvoker Invoker);

/// <summary>
/// Convenience overload for the single-server case: every descriptor shares one invoker.
/// </summary>
internal static ValueTask<InterceptorChainResult> ExecuteAsync(
IEnumerable<Interceptor> interceptors,
InterceptorInvoker invoker,
ExecuteChainRequestParams chainParams,
CancellationToken cancellationToken)
{
return ExecuteAsync(
interceptors.Select(i => new ChainExecutionEntry(i, invoker)).ToList(),
chainParams,
cancellationToken);
}

internal static async ValueTask<InterceptorChainResult> ExecuteAsync(
IReadOnlyList<ChainExecutionEntry> entries,
ExecuteChainRequestParams chainParams,
CancellationToken cancellationToken)
{
if (chainParams.Phase is not (InterceptorPhase.Request or InterceptorPhase.Response))
{
Expand All @@ -45,15 +66,17 @@ internal static async ValueTask<InterceptorChainResult> ExecuteAsync(
ChainAbortInfo? abortInfo = null;
var status = InterceptorChainStatus.Success;

var applicable = FilterInterceptors(interceptors, chainParams);

var mutations = applicable.Where(i => i.Type == InterceptorType.Mutation)
.OrderBy(i => i.PriorityHint?.GetEffective(chainParams.Phase) ?? 0)
.ThenBy(i => i.Name, StringComparer.Ordinal)
// Merge & sort: one global order across all servers by effective priority
// (ascending, alphabetical tie-break), then partition by type. The stable sort
// preserves caller-supplied server order for exact ties.
var applicable = FilterEntries(entries, chainParams)
.OrderBy(e => e.Descriptor.PriorityHint?.GetEffective(chainParams.Phase) ?? 0)
.ThenBy(e => e.Descriptor.Name, StringComparer.Ordinal)
.ToList();

var validations = applicable.Where(i => i.Type == InterceptorType.Validation).ToList();
var sinks = applicable.Where(i => i.Type == InterceptorType.Sink).ToList();
var mutations = applicable.Where(e => e.Descriptor.Type == InterceptorType.Mutation).ToList();
var validations = applicable.Where(e => e.Descriptor.Type == InterceptorType.Validation).ToList();
var sinks = applicable.Where(e => e.Descriptor.Type == InterceptorType.Sink).ToList();

using var timeoutCts = chainParams.TimeoutMs.HasValue
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
Expand All @@ -69,23 +92,23 @@ internal static async ValueTask<InterceptorChainResult> ExecuteAsync(
if (chainParams.Phase == InterceptorPhase.Request)
{
// Sending: Mutations -> Validations -> Sinks
(currentPayload, status, abortInfo) = await ExecuteMutationsAsync(mutations, invoker, chainParams, currentPayload, results, ct);
(currentPayload, status, abortInfo) = await ExecuteMutationsAsync(mutations, chainParams, currentPayload, results, ct);
if (status != InterceptorChainStatus.Success) goto Done;

(status, abortInfo) = await ExecuteValidationsAsync(validations, invoker, chainParams, currentPayload, results, summary, ct);
(status, abortInfo) = await ExecuteValidationsAsync(validations, chainParams, currentPayload, results, summary, ct);
if (status != InterceptorChainStatus.Success) goto Done;

await ExecuteSinksAsync(sinks, invoker, chainParams, currentPayload, results, ct);
await ExecuteSinksAsync(sinks, chainParams, currentPayload, results, ct);
}
else
{
// Receiving: Validations -> Sinks -> Mutations
(status, abortInfo) = await ExecuteValidationsAsync(validations, invoker, chainParams, currentPayload, results, summary, ct);
(status, abortInfo) = await ExecuteValidationsAsync(validations, chainParams, currentPayload, results, summary, ct);
if (status != InterceptorChainStatus.Success) goto Done;

await ExecuteSinksAsync(sinks, invoker, chainParams, currentPayload, results, ct);
await ExecuteSinksAsync(sinks, chainParams, currentPayload, results, ct);

(currentPayload, status, abortInfo) = await ExecuteMutationsAsync(mutations, invoker, chainParams, currentPayload, results, ct);
(currentPayload, status, abortInfo) = await ExecuteMutationsAsync(mutations, chainParams, currentPayload, results, ct);
}
}
catch (OperationCanceledException) when (timeoutCts?.IsCancellationRequested == true)
Expand All @@ -109,14 +132,13 @@ internal static async ValueTask<InterceptorChainResult> ExecuteAsync(
}

private static async ValueTask<(JsonNode payload, InterceptorChainStatus status, ChainAbortInfo? abort)> ExecuteMutationsAsync(
List<Interceptor> mutations,
InterceptorInvoker invoker,
List<ChainExecutionEntry> mutations,
ExecuteChainRequestParams chainParams,
JsonNode currentPayload,
List<InterceptorResult> results,
CancellationToken ct)
{
foreach (var descriptor in mutations)
foreach (var (descriptor, invoker) in mutations)
{
var isAudit = descriptor.Mode == InterceptorMode.Audit;
var failOpen = descriptor.FailOpen == true;
Expand Down Expand Up @@ -157,21 +179,21 @@ internal static async ValueTask<InterceptorChainResult> ExecuteAsync(
}

private static async ValueTask<(InterceptorChainStatus status, ChainAbortInfo? abort)> ExecuteValidationsAsync(
List<Interceptor> validations,
InterceptorInvoker invoker,
List<ChainExecutionEntry> validations,
ExecuteChainRequestParams chainParams,
JsonNode currentPayload,
List<InterceptorResult> results,
ChainValidationSummary summary,
CancellationToken ct)
{
var tasks = validations.Select(async descriptor =>
var tasks = validations.Select(async entry =>
{
var descriptor = entry.Descriptor;
try
{
var invokeParams = CreateInvokeParams(descriptor, chainParams, currentPayload);
var sw = Stopwatch.StartNew();
var result = await invoker(invokeParams, ct);
var result = await entry.Invoker(invokeParams, ct);
sw.Stop();
result.InterceptorName = descriptor.Name;
result.DurationMs = sw.ElapsedMilliseconds;
Expand Down Expand Up @@ -238,20 +260,20 @@ internal static async ValueTask<InterceptorChainResult> ExecuteAsync(
}

private static async ValueTask ExecuteSinksAsync(
List<Interceptor> sinks,
InterceptorInvoker invoker,
List<ChainExecutionEntry> sinks,
ExecuteChainRequestParams chainParams,
JsonNode currentPayload,
List<InterceptorResult> results,
CancellationToken ct)
{
var tasks = sinks.Select(async descriptor =>
var tasks = sinks.Select(async entry =>
{
var descriptor = entry.Descriptor;
try
{
var invokeParams = CreateInvokeParams(descriptor, chainParams, currentPayload);
var sw = Stopwatch.StartNew();
var result = await invoker(invokeParams, ct);
var result = await entry.Invoker(invokeParams, ct);
sw.Stop();
result.InterceptorName = descriptor.Name;
result.DurationMs = sw.ElapsedMilliseconds;
Expand All @@ -271,14 +293,15 @@ private static async ValueTask ExecuteSinksAsync(
results.AddRange(completedResults);
}

private static List<Interceptor> FilterInterceptors(
IEnumerable<Interceptor> interceptors,
private static List<ChainExecutionEntry> FilterEntries(
IReadOnlyList<ChainExecutionEntry> entries,
ExecuteChainRequestParams chainParams)
{
var result = new List<Interceptor>();
var result = new List<ChainExecutionEntry>();

foreach (var descriptor in interceptors)
foreach (var entry in entries)
{
var descriptor = entry.Descriptor;
if (chainParams.InterceptorNames is { Count: > 0 } names && !names.Contains(descriptor.Name))
{
continue;
Expand All @@ -304,7 +327,7 @@ private static List<Interceptor> FilterInterceptors(
continue;
}

result.Add(descriptor);
result.Add(entry);
}

return result;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,14 @@ public static ValueTask<InterceptorResult> InvokeInterceptorAsync(
/// dispatches each one via <c>interceptor/invoke</c>, orchestrating ordering, parallelism,
/// audit-mode, and fail-open semantics locally.
/// </remarks>
public static async ValueTask<InterceptorChainResult> ExecuteChainAsync(
public static ValueTask<InterceptorChainResult> ExecuteChainAsync(
this McpClient client,
ExecuteChainRequestParams requestParams,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(requestParams);

var listed = await client.ListInterceptorsAsync(
new ListInterceptorsRequestParams { Event = requestParams.Event },
cancellationToken).ConfigureAwait(false);

return await InterceptorChainOrchestrator.ExecuteAsync(
listed.Interceptors,
(invokeParams, ct) => client.InvokeInterceptorAsync(invokeParams, ct),
requestParams,
cancellationToken).ConfigureAwait(false);
return InterceptorChain.ExecuteAsync([client], requestParams, cancellationToken);
}
}
Loading
Loading