Skip to content

C# SDK: multi-server interceptor chain orchestration per SEP#18

Open
PederHP wants to merge 6 commits into
mainfrom
fix/issue-15-multi-server-chain
Open

C# SDK: multi-server interceptor chain orchestration per SEP#18
PederHP wants to merge 6 commits into
mainfrom
fix/issue-15-multi-server-chain

Conversation

@PederHP

@PederHP PederHP commented Jun 11, 2026

Copy link
Copy Markdown
Member

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

PederHP and others added 3 commits June 11, 2026 17:22
The SEP defines mode as "enforce" | "audit"; the C# SDK used "active" on
the wire, making it incompatible with the TypeScript SDK. Renames the
enum member and wire value, updates the attribute default and the
default-skipping logic, and adds a deserialization test.

Part of #15.

Co-Authored-By: Claude Fable 5 <[email protected]>
The SEP allows priorityHint to be either a single number or
{ request?, response? } with different priorities per phase; the C# SDK
only supported a plain int. Adds a PriorityHint type with a converter
that round-trips both wire forms, resolves effective priority per phase
in chain ordering, and exposes RequestPriorityHint/ResponsePriorityHint
on the interceptor attribute.

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 Mode/FailOpen default-skipping).

Part of #15.

Co-Authored-By: Claude Fable 5 <[email protected]>
ExecuteChainRequestParams.Phase could be set to InterceptorPhase.Both,
which is documented as attribute-only and invalid on the wire. The
orchestrator would silently match no hooks while ordering and branching
as if it were the response phase. Throw ArgumentException up front
instead.

Co-Authored-By: Claude Fable 5 <[email protected]>
@PederHP
PederHP force-pushed the fix/issue-15-multi-server-chain branch from 9a916c6 to e1bd0ec Compare June 11, 2026 17:52
PederHP and others added 2 commits June 18, 2026 14:31
The earlier rename (0cc036b) changed mode from "active" to "enforce"
against a draft SEP. The revised SEP-1763 (PR #2624, now the current
revision) defines mode as "active" | "audit" with default "active" — so
"active" was correct all along. jeongukjae flagged this on PR #17,
dismissing the approval: 'current revised sep has "active" and "audit",
not "enforce"'.

Reverts InterceptorMode.Enforce -> Active and wire value "enforce" ->
"active", the attribute default, the default-skipping logic, and the
serialization tests. The priorityHint object-form work in this PR stays
— the revised SEP still defines priorityHint as number | {request,
response}.

Part of #15.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The SEP chain execution pattern discovers interceptors from one or more
MCP servers, merges them into a single chain sorted globally by
priorityHint (alphabetical tie-break), and routes each interceptor/invoke
to the server hosting it. The SDK previously ran each client's full chain
sequentially, so cross-server priorities were ignored and each server's
validations only saw its own mutations.

- The orchestrator now operates on chain entries (descriptor + invoker),
  mirroring the SEP's ChainEntry, with one global sort across servers.
- New public InterceptorChain/InterceptorChainEntry API: parallel
  fail-closed discovery via interceptors/list on every server, then
  merged execution. Duplicate names across servers are not deduplicated;
  each entry invokes on its own host.
- InterceptorChainRunner (gateway + InterceptingMcpClient) now executes
  one merged chain instead of per-client sequential chains; single-client
  ExecuteChainAsync delegates to the same path unchanged.

Behavior changes for multi-client gateways: validations from all servers
run as one parallel batch after all mutations, sinks run once per phase,
TimeoutMs bounds the whole merged chain, and any server's list failure
fails the chain.

Closes #15.

Co-Authored-By: Claude Fable 5 <[email protected]>
@PederHP
PederHP force-pushed the fix/issue-15-multi-server-chain branch from e1bd0ec to 2e579cf Compare June 18, 2026 12:34
Base automatically changed from fix/issue-15-mode-priorityhint to main July 21, 2026 13:56
@PederHP

PederHP commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

@copilot resolve the merge conflicts in this pull request

Copilot AI commented Jul 21, 2026

Copy link
Copy Markdown

@copilot resolve the merge conflicts in this pull request

Resolved the merge conflicts and pushed the fix in commit c670b9f.

@PederHP PederHP self-assigned this Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

C# SDK differences from spec

2 participants