Skip to content

examples: MRTR dual-path options for 2025-11 client compat#1701

Draft
felixweinberger wants to merge 12 commits intomainfrom
fweinberger/mrtr-dual-path-options
Draft

examples: MRTR dual-path options for 2025-11 client compat#1701
felixweinberger wants to merge 12 commits intomainfrom
fweinberger/mrtr-dual-path-options

Conversation

@felixweinberger
Copy link
Contributor

@felixweinberger felixweinberger commented Mar 18, 2026

Follow-up to #1597 and modelcontextprotocol#2322 (comment). Counterpart to python-sdk#2322. Same weather-lookup tool throughout so the diff between files is the argument.

Recommended tiers

Tier Option Who it's for Trade-off
Easy / default H (ContinuationStore) Most servers. Single-instance, or can do sticky routing on request_state Server stateful within a tool call — sticky routing at scale
Stateless / advanced E + F or G Horizontally scaled, ephemeral workers, lambda-style Must write re-entrant handlers; F/G mitigate the footgun
Transition compat A / C / D Servers that want old-client elicitation during transition Carries SSE infra; opt-in
Don't ship B Nobody Hidden footgun, no upside over H

H is the "keep await" option done safely — SSE-era ergonomics, MRTR wire protocol, zero migration, zero footgun. The price is server-side state (continuation frame in memory), so horizontal scale needs sticky routing. If your deployment can't do that (lambda, truly ephemeral workers), drop to the stateless tier: write guard-first handlers (E) and use ctx.once (F) or ToolBuilder (G) to keep side-effects safe.

What to look at

Axis Where How many options
Old client → new server (dual-path) optionAoptionE Five
New client → old server (dual-path) clientDualPath.ts + sdkLib.ts One — handler signature identical on both paths
MRTR footgun prevention optionF, optionG, optionH Three

Options

Author writes SDK does Hidden re-entry Footgun Statefulness
A MRTR-native Emulates retry loop over SSE Yes, safe Convention-only SSE-capable required
B await elicit() Exception → IncompleteResult Yes, unsafe Present Stateless
C Version branch inline Version accessor No Convention-only SSE-capable for SSE branch
D Two handlers Picks by version No Convention-only SSE-capable for SSE handler
E MRTR-native Nothing No Convention-only Stateless
F MRTR-native + ctx.once once() guard in requestState No Opt-in mitigated Stateless
G Step functions + .build() Step-tracking in requestState No Structurally eliminated (endStep) Stateless
H SSE-era await ctx.elicit() Holds coroutine in ContinuationStore No — genuine suspension Eliminated — no re-entry Stateful (sticky routing)

All eight present identical wire behaviour to each client version — the server's internal choice doesn't leak. That's the cleanest argument against per-feature -mrtr capability flags.

Client side: new client → old server

  • clientDualPath.tswhat the app developer writes. ~55 lines: one handleElicitation, one withMrtr registration, one callTool. Delta from today: zero.
  • sdkLib.tswhat the SDK would ship. Retry loop, IncompleteResult parsing, mrtrOnly opt-out.

Motivation and Context

The comment thread landed on: bottom-left (MRTR-only server + old client) is "E by necessity." Top-left is "E by default, A/C/D if you opt in."

F/G/H respond to SDK-WG feedback that the naive MRTR handler is de-facto GOTO — re-entry jumps to the top, side-effects above the guard are a landmine. F is opt-in mitigation, G is structural decomposition, H is genuine suspension via ContinuationStore (no re-entry at all, but server-side stateful).

How Has This Been Tested?

Typecheck + lint clean. Server demos runnable against Inspector with DEMO_PROTOCOL_VERSION=2025-11 / 2026-06. IncompleteResult smuggled through as JSON text block — same hack as #1597.

Breaking Changes

None. Demo-only.

Types of changes

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Exploratory — not intended to merge as-is. The shims.ts machinery (sseRetryShim, MrtrCtx.once, ToolBuilder, ContinuationStore, linearMrtr) and client sdkLib.ts sketch what each option needs from the SDK.

Five demo servers registering the same weather-lookup tool, each
showing a different approach to the top-left quadrant of the
SEP-2322 compatibility matrix: server CAN hold SSE, client is
on 2025-11, tool code is MRTR-native.

Follow-up to #1597 and modelcontextprotocol#2322 comment 4083481545.

- shimMrtrCanonical.ts (A): SDK emulates retry loop over SSE
- shimAwaitCanonical.ts (B): await-style, exception-based MRTR shim
- explicitVersionBranch.ts (C): one handler, version branch inline
- dualRegistration.ts (D): two handlers, SDK picks by version
- degradeOnly.ts (E): MRTR-only, old clients get an error

Exploratory, not intended to merge as-is.
@changeset-bot
Copy link

changeset-bot bot commented Mar 18, 2026

⚠️ No Changeset found

Latest commit: e51495f

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 18, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@1701

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1701

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@1701

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@1701

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@1701

commit: e51495f

All five options present identical wire behaviour per client version;
the server's internal choice doesn't leak. That's the cleanest
argument against per-feature -mrtr capability flags.

Also sharpens the sseRetryShim warning: it only works on SSE-capable
infra, and that constraint lives nowhere near the tool registration.
The client side of the dual-path story (new SDK, old server). Unlike
the server options A-E, there's only one sensible shape: the elicitation
handler signature is identical whether the request arrives via SSE push
or embedded in IncompleteResult, so the SDK routes to one user-supplied
function from both paths.

Lives in examples/client/src/mrtr-dual-path/ (parallel dir) because the
client package isn't a dep of examples/server. README in the server dir
points to it.
sdkLib.ts (~110 lines): type shims, IncompleteResult parsing, the
retry loop, withMrtr() helper. Stand-in for what the SDK ships.

clientDualPath.ts (~55 lines): just the app-developer surface.
handleElicitation + one withMrtr() call + one callTool() call.
The point being: the app code is identical to today.
Both rows of the quadrant collapse to E: horizontally scaled servers
get it by necessity, SSE-capable servers get it by default. The server
works for old clients either way - version negotiation succeeds,
tools/list complete, non-eliciting tools unaffected. Only elicitation
is unavailable.

A/C/D reframed as opt-in exceptions for servers that choose to carry
SSE infra through the transition.
Tool author chooses: proceed with a default (unit preference is
nice-to-have, default to metric) or error (confirmation is essential,
tell them to upgrade). Both are valid E; the SDK just surfaces
'elicitation unavailable' and the tool decides.

The weather demo now defaults and returns a real result for old
clients. The error path is in a comment block for tools where the
elicitation is blocking.
F and G address a different axis from A-E. A-E are about dual-path
(old client vs new). F and G are about the MRTR footgun: code above
the inputResponses guard runs on every retry, so a DB write there
executes N times for N-round elicitation.

F (ctx.once): idempotency guard inside the monolithic handler.
Opt-in, one line per side-effect. Makes safe code visually distinct
from unsafe code; doesn't eliminate the footgun, makes it reviewable.

G (ToolBuilder): Marcelo's step decomposition. incompleteStep may
return IncompleteResult or data; endStep runs exactly once when all
steps complete. No 'above the guard' zone because the SDK's
step-tracking is the guard. Boilerplate: two function defs +
.build() to replace the 3-line check.

Both depend on requestState integrity - real SDK MUST HMAC-sign the
blob or the client can forge step-done markers. Demos use plain
base64 for clarity.
Counterpart to python-sdk#2322's linear.py. The Option B footgun was:
await elicit() LOOKS like a suspension but is actually a re-entry point.
H fixes that by making it a REAL suspension — the Promise chain is held
in a ContinuationStore across MRTR rounds, keyed by request_state.

Mechanism: Round 1 spawns the handler as a detached Promise; elicit()
sends IncompleteResult through a channel and parks on recv. Round 2's
retry resolves the channel; the handler continues from where it stopped.
No re-entry, no double-execution, zero migration from SSE-era code.

Trade-off: server holds the frame in memory between rounds. Client sees
pure MRTR (no SSE), but server is stateful within a tool call.
Horizontal scale needs sticky routing on the request_state token.

README gains a 'Recommended tiers' table: H = easy/default (most
servers), E+F/G = stateless/advanced (lambda, ephemeral workers),
A/C/D = transition compat (opt-in SSE), B = don't ship.
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.

1 participant