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>
This commit is contained in:
Peder Holdgaard Pedersen 2026-08-06 17:22:33 +02:00 • committed by GitHub
commit 28ada74189
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 700 additions and 77 deletions

View file

@ -565,8 +565,189 @@ public class InterceptorChainOrchestratorTests
Assert.Equal(1, result.ValidationSummary.Infos);
}
// ── Multi-server chains ─────────────────────────────────────────────────
[Fact]
public async Task MultiServer_MutationsOrderByGlobalPriorityAcrossServers()
{
var executionOrder = new List<string>();
TestEntry Mut(string name, PriorityHint hint) => CreateInterceptor(name, InterceptorType.Mutation, (req, _) =>
{
executionOrder.Add(name);
return new ValueTask<InterceptorResult>(new MutationInterceptorResult { Modified = false });
}, priorityHint: hint);
// Server A hosts the high-priority mutation, server B the low-priority one.
// The merged chain must run B's mutation first regardless of server order.
var serverA = Server(Mut("server-a-late", 100));
var serverB = Server(Mut("server-b-early", -100));
var result = await InterceptorChainOrchestrator.ExecuteAsync(
[.. serverA, .. serverB],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{}""")!,
},
CancellationToken.None);
Assert.Equal(InterceptorChainStatus.Success, result.Status);
Assert.Equal(["server-b-early", "server-a-late"], executionOrder);
}
[Fact]
public async Task MultiServer_MutationsTieBreakAlphabeticallyAcrossServers()
{
var executionOrder = new List<string>();
TestEntry Mut(string name) => CreateInterceptor(name, InterceptorType.Mutation, (req, _) =>
{
executionOrder.Add(name);
return new ValueTask<InterceptorResult>(new MutationInterceptorResult { Modified = false });
}, priorityHint: 0);
var serverA = Server(Mut("zulu"));
var serverB = Server(Mut("alpha"));
var result = await InterceptorChainOrchestrator.ExecuteAsync(
[.. serverA, .. serverB],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{}""")!,
},
CancellationToken.None);
Assert.Equal(InterceptorChainStatus.Success, result.Status);
Assert.Equal(["alpha", "zulu"], executionOrder);
}
[Fact]
public async Task MultiServer_PayloadChainsAcrossServersInOneMutationPass()
{
var serverA = Server(CreateInterceptor("a-first", InterceptorType.Mutation, (req, _) =>
{
var obj = req.Payload.AsObject();
obj["fromA"] = true;
return new ValueTask<InterceptorResult>(new MutationInterceptorResult { Modified = true, Payload = obj });
}, priorityHint: -1));
var serverB = Server(CreateInterceptor("b-second", InterceptorType.Mutation, (req, _) =>
{
Assert.True(req.Payload["fromA"]!.GetValue<bool>()); // Receives server A's output
var obj = req.Payload.AsObject();
obj["fromB"] = true;
return new ValueTask<InterceptorResult>(new MutationInterceptorResult { Modified = true, Payload = obj });
}, priorityHint: 1));
var result = await InterceptorChainOrchestrator.ExecuteAsync(
[.. serverB, .. serverA],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{"original":true}""")!,
},
CancellationToken.None);
Assert.Equal(InterceptorChainStatus.Success, result.Status);
Assert.True(result.FinalPayload!["original"]!.GetValue<bool>());
Assert.True(result.FinalPayload["fromA"]!.GetValue<bool>());
Assert.True(result.FinalPayload["fromB"]!.GetValue<bool>());
}
[Fact]
public async Task MultiServer_ValidationsFromAllServersSeePostMutationPayload()
{
var validatedPayloads = new List<JsonNode>();
var serverA = Server(
CreateInterceptor("mutator", InterceptorType.Mutation, (req, _) =>
{
var obj = req.Payload.AsObject();
obj["mutated"] = true;
return new ValueTask<InterceptorResult>(new MutationInterceptorResult { Modified = true, Payload = obj });
}),
CreateInterceptor("val-a", InterceptorType.Validation, (req, _) =>
{
lock (validatedPayloads) validatedPayloads.Add(req.Payload);
return new ValueTask<InterceptorResult>(ValidationInterceptorResult.Success());
}));
var serverB = Server(CreateInterceptor("val-b", InterceptorType.Validation, (req, _) =>
{
lock (validatedPayloads) validatedPayloads.Add(req.Payload);
return new ValueTask<InterceptorResult>(ValidationInterceptorResult.Success());
}));
var result = await InterceptorChainOrchestrator.ExecuteAsync(
[.. serverA, .. serverB],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{}""")!,
},
CancellationToken.None);
Assert.Equal(InterceptorChainStatus.Success, result.Status);
Assert.Equal(2, validatedPayloads.Count);
// Both servers' validations run after the mutation from server A, against its output.
Assert.All(validatedPayloads, p => Assert.True(p["mutated"]!.GetValue<bool>()));
}
[Fact]
public async Task MultiServer_DuplicateNamesInvokeOncePerHostingServer()
{
var serverAInvocations = 0;
var serverBInvocations = 0;
var serverA = Server(CreateInterceptor("shared-name", InterceptorType.Validation, (req, _) =>
{
Interlocked.Increment(ref serverAInvocations);
return new ValueTask<InterceptorResult>(ValidationInterceptorResult.Success());
}));
var serverB = Server(CreateInterceptor("shared-name", InterceptorType.Validation, (req, _) =>
{
Interlocked.Increment(ref serverBInvocations);
return new ValueTask<InterceptorResult>(ValidationInterceptorResult.Success());
}));
var result = await InterceptorChainOrchestrator.ExecuteAsync(
[.. serverA, .. serverB],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{}""")!,
},
CancellationToken.None);
Assert.Equal(InterceptorChainStatus.Success, result.Status);
// Each entry routes to its own host: one invocation per server, two results.
Assert.Equal(1, serverAInvocations);
Assert.Equal(1, serverBInvocations);
Assert.Equal(2, result.Results.Count);
Assert.All(result.Results, r => Assert.Equal("shared-name", r.InterceptorName));
}
// ── Helpers ─────────────────────────────────────────────────────────────
/// <summary>
/// Models one fake server: each of its entries gets an invoker bound to that server's own
/// handler, so duplicate names across servers stay distinct.
/// </summary>
private static List<InterceptorChainOrchestrator.ChainExecutionEntry> Server(params TestEntry[] entries)
{
return entries.Select(e => new InterceptorChainOrchestrator.ChainExecutionEntry(
e.Descriptor,
(req, ct) => e.Handler(req, ct))).ToList();
}
private static ValueTask<InterceptorChainResult> RunAsync(
IEnumerable<TestEntry> entries,
ExecuteChainRequestParams chainParams,

View file

@ -0,0 +1,245 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Interceptors.Client;
using ModelContextProtocol.Interceptors.Protocol;
using ModelContextProtocol.Interceptors.Server;
using ModelContextProtocol.Server;
using Xunit;
namespace ModelContextProtocol.Interceptors.Tests;
/// <summary>
/// Tests for the public <see cref="InterceptorChain"/> API over real in-memory interceptor servers.
/// </summary>
public class InterceptorChainTests
{
[Fact]
public async Task DiscoverAsync_MergesEntriesFromAllServersWithServerAttribution()
{
await using var fixture = await TwoServerFixture.CreateAsync(
serverAInterceptors: [MutationDescriptor("a-mutator", priorityHint: 10)],
serverBInterceptors: [MutationDescriptor("b-mutator", priorityHint: -10)]);
var chain = await InterceptorChain.DiscoverAsync([fixture.ClientA, fixture.ClientB]);
Assert.Equal(2, chain.Entries.Count);
var entryA = Assert.Single(chain.Entries, e => e.Interceptor.Name == "a-mutator");
var entryB = Assert.Single(chain.Entries, e => e.Interceptor.Name == "b-mutator");
Assert.Same(fixture.ClientA, entryA.Server);
Assert.Same(fixture.ClientB, entryB.Server);
}
[Fact]
public async Task ExecuteAsync_RunsMergedChainInGlobalPriorityOrderAcrossServers()
{
// Server A's mutation has the higher priority value, so server B's must run first
// and server A's must observe B's payload — the opposite of per-server sequencing.
await using var fixture = await TwoServerFixture.CreateAsync(
serverAInterceptors: [MutationDescriptor("a-late", priorityHint: 10)],
serverBInterceptors: [MutationDescriptor("b-early", priorityHint: -10)],
handler: (req, _) =>
{
var obj = req.Payload!.AsObject();
var order = obj["order"]?.AsArray() ?? [];
order.Add(req.Name);
obj["order"] = order.DeepClone();
return new ValueTask<InterceptorResult>(new MutationInterceptorResult { Modified = true, Payload = obj });
});
var result = await InterceptorChain.ExecuteAsync(
[fixture.ClientA, fixture.ClientB],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{}""")!,
});
Assert.Equal(InterceptorChainStatus.Success, result.Status);
Assert.Equal(2, result.Results.Count);
var order = result.FinalPayload!["order"]!.AsArray().Select(n => n!.GetValue<string>()).ToList();
Assert.Equal(["b-early", "a-late"], order);
}
[Fact]
public async Task ExecuteAsync_FailsClosedWhenAnyServerListFails()
{
await using var fixture = await TwoServerFixture.CreateAsync(
serverAInterceptors: [MutationDescriptor("a-mutator", priorityHint: 0)],
// Server B does not speak the interceptor protocol at all, so interceptors/list fails.
serverBInterceptors: null);
await Assert.ThrowsAsync<McpProtocolException>(async () =>
{
await InterceptorChain.ExecuteAsync(
[fixture.ClientA, fixture.ClientB],
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{}""")!,
});
});
}
[Fact]
public async Task SingleClientExecuteChainAsync_StillWorksThroughMergedPath()
{
await using var fixture = await TwoServerFixture.CreateAsync(
serverAInterceptors: [MutationDescriptor("solo-mutator", priorityHint: 0)],
serverBInterceptors: null,
handler: (req, _) =>
{
var obj = req.Payload!.AsObject();
obj["mutated"] = true;
return new ValueTask<InterceptorResult>(new MutationInterceptorResult { Modified = true, Payload = obj });
});
var result = await fixture.ClientA.ExecuteChainAsync(
new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = JsonNode.Parse("""{"original":true}""")!,
});
Assert.Equal(InterceptorChainStatus.Success, result.Status);
Assert.True(result.FinalPayload!["mutated"]!.GetValue<bool>());
Assert.True(result.FinalPayload["original"]!.GetValue<bool>());
}
// ── Helpers ─────────────────────────────────────────────────────────────
private static Interceptor MutationDescriptor(string name, PriorityHint priorityHint) => new()
{
Name = name,
Type = InterceptorType.Mutation,
Hooks =
[
new InterceptorHook { Events = [InterceptionEvents.All], Phase = InterceptorPhase.Request },
new InterceptorHook { Events = [InterceptionEvents.All], Phase = InterceptorPhase.Response },
],
PriorityHint = priorityHint,
};
private sealed class TwoServerFixture : IAsyncDisposable
{
private readonly List<IAsyncDisposable> _disposables;
public McpClient ClientA { get; }
public McpClient ClientB { get; }
private TwoServerFixture(McpClient clientA, McpClient clientB, List<IAsyncDisposable> disposables)
{
ClientA = clientA;
ClientB = clientB;
_disposables = disposables;
}
/// <summary>
/// Spins up two in-memory servers. A <see langword="null"/> interceptor list creates a
/// plain MCP server without the interceptor protocol (its <c>interceptors/list</c> fails).
/// </summary>
public static async Task<TwoServerFixture> CreateAsync(
Interceptor[]? serverAInterceptors,
Interceptor[]? serverBInterceptors,
Func<InvokeInterceptorRequestParams, CancellationToken, ValueTask<InterceptorResult>>? handler = null)
{
handler ??= (req, _) => new ValueTask<InterceptorResult>(new MutationInterceptorResult { Modified = false });
var disposables = new List<IAsyncDisposable>();
try
{
var (serverA, clientA) = await McpInterceptorGatewayTests.GatewayTestFixture.CreateServerClientPairForTesting(
"chain-server-a", options => Configure(options, serverAInterceptors, handler));
disposables.Add(serverA);
disposables.Add(clientA);
var (serverB, clientB) = await McpInterceptorGatewayTests.GatewayTestFixture.CreateServerClientPairForTesting(
"chain-server-b", options => Configure(options, serverBInterceptors, handler));
disposables.Add(serverB);
disposables.Add(clientB);
return new TwoServerFixture(clientA, clientB, disposables);
}
catch
{
foreach (var d in disposables)
{
await d.DisposeAsync();
}
throw;
}
}
private static void Configure(
McpServerOptions options,
Interceptor[]? interceptors,
Func<InvokeInterceptorRequestParams, CancellationToken, ValueTask<InterceptorResult>> handler)
{
if (interceptors is null)
{
return;
}
var collection = new McpServerPrimitiveCollection<McpServerInterceptor>();
foreach (var descriptor in interceptors)
{
collection.Add(new DelegatingInterceptor(descriptor, handler));
}
var filter = new InterceptorMessageFilter(collection);
options.Filters.Message.IncomingFilters.Add(filter.CreateFilter);
options.Capabilities ??= new();
#pragma warning disable MCPEXP001
options.Capabilities.Extensions ??= new Dictionary<string, object>();
options.Capabilities.Extensions[InterceptorProtocolConstants.ExtensionCapabilityKey] = JsonSerializer.SerializeToElement(
new InterceptorsCapability { SupportedEvents = [InterceptionEvents.All] },
InterceptorJsonUtilities.DefaultOptions);
#pragma warning restore MCPEXP001
}
public async ValueTask DisposeAsync()
{
for (var i = _disposables.Count - 1; i >= 0; i--)
{
try
{
await _disposables[i].DisposeAsync();
}
catch
{
// Swallow disposal errors in tests
}
}
}
}
private sealed class DelegatingInterceptor : McpServerInterceptor
{
private readonly Interceptor _interceptor;
private readonly Func<InvokeInterceptorRequestParams, CancellationToken, ValueTask<InterceptorResult>> _handler;
public DelegatingInterceptor(
Interceptor interceptor,
Func<InvokeInterceptorRequestParams, CancellationToken, ValueTask<InterceptorResult>> handler)
{
_interceptor = interceptor;
_handler = handler;
}
public override Interceptor ProtocolInterceptor => _interceptor;
public override IReadOnlyList<object> Metadata => [];
public override ValueTask<InterceptorResult> InvokeAsync(
InvokeInterceptorRequestParams request,
McpServer server,
IServiceProvider? services,
CancellationToken cancellationToken = default) =>
_handler(request, cancellationToken);
}
}

View file

@ -432,15 +432,78 @@ public class McpInterceptorGatewayTests
})],
]);
// Call tool — both interceptors should run (A first, then B)
// Call tool — both interceptors should run (A first, then B: equal priority,
// alphabetical tie-break in the merged chain)
var result = await fixture.ProxyClient.CallToolAsync("echo",
new Dictionary<string, object?> { ["message"] = "hello" });
// Both interceptor chains ran in order: A prepended first, then B
// A prepended first, then B
var text = result.Content[0].ToString()!;
Assert.Contains("B:A:hello", text);
}
[Fact]
public async Task CallToolAsync_OrdersMutationsByGlobalPriorityAcrossInterceptorClients()
{
// The first server hosts the higher-priority-value (later) mutation, the second server
// the lower-priority-value (earlier) one. The merged chain must run the second server's
// mutation first — under per-server sequencing it would run last.
static McpServerInterceptor PrependMutator(string name, int priority, string prefix) =>
new TestInterceptor(
new Interceptor
{
Name = name,
Type = InterceptorType.Mutation,
PriorityHint = priority,
Hooks = [new InterceptorHook { Events = [InterceptionEvents.All], Phase = InterceptorPhase.Request }],
},
(req, _, _, _) =>
{
var obj = JsonNode.Parse(req.Payload!.ToJsonString())!.AsObject();
if (obj["arguments"]?["message"] is JsonNode msgNode)
{
obj["arguments"]!["message"] = prefix + msgNode.GetValue<string>();
}
return new ValueTask<InterceptorResult>(new MutationInterceptorResult
{
Modified = true,
Payload = obj,
});
});
await using var fixture = await GatewayTestFixture.CreateWithMultipleInterceptorServersAsync(
backendConfigure: (options) =>
{
options.Capabilities ??= new();
options.Capabilities.Tools ??= new();
options.Handlers.ListToolsHandler = (request, ct) =>
new ValueTask<ListToolsResult>(new ListToolsResult
{
Tools = [new Tool { Name = "echo", Description = "Echo" }],
});
options.Handlers.CallToolHandler = (request, ct) =>
{
var msg = request.Params!.Arguments?["message"];
return new ValueTask<CallToolResult>(new CallToolResult
{
Content = [new TextContentBlock { Text = $"echo: {msg}" }],
});
};
},
interceptorConfigs:
[
[PrependMutator("late-mutator", priority: 1000, prefix: "LATE:")],
[PrependMutator("early-mutator", priority: -1000, prefix: "EARLY:")],
]);
var result = await fixture.ProxyClient.CallToolAsync("echo",
new Dictionary<string, object?> { ["message"] = "hello" });
// early (server 2) ran first, late (server 1) ran second and prepended last.
var text = result.Content[0].ToString()!;
Assert.Contains("LATE:EARLY:hello", text);
}
[Fact]
public async Task SubscribeMutation_UsesModifiedPayload()
{