diff --git a/examples/README-mrtr-dual-path.md b/examples/README-mrtr-dual-path.md new file mode 100644 index 000000000..e19a6a901 --- /dev/null +++ b/examples/README-mrtr-dual-path.md @@ -0,0 +1,124 @@ +# MRTR dual-path options + +Follow-up to [typescript-sdk#1597](https://github.com/modelcontextprotocol/typescript-sdk/pull/1597) and [modelcontextprotocol#2322 (comment)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322#issuecomment-4083481545). Same weather-lookup tool throughout so +the diff between files is the argument. + +## What to look at + +| Axis | Where | How many options | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | +| **Old client → new server** (dual-path) | [`optionA`](./server/src/mrtr-dual-path/optionAShimMrtrCanonical.ts)–[`optionE`](./server/src/mrtr-dual-path/optionEDegradeOnly.ts) | Five — server handler shape is genuinely contested | +| **New client → old server** (dual-path) | [`clientDualPath.ts`](./client/src/mrtr-dual-path/clientDualPath.ts) + [`sdkLib.ts`](./client/src/mrtr-dual-path/sdkLib.ts) | One — handler signature is identical on both paths | +| **MRTR footgun prevention** | [`optionF`](./server/src/mrtr-dual-path/optionFCtxOnce.ts), [`optionG`](./server/src/mrtr-dual-path/optionGToolBuilder.ts), [`optionH`](./server/src/mrtr-dual-path/optionHContinuationStore.ts) | Three — opt-in primitive, structural decomposition, or genuine suspension | + +## Recommended tiers + +| Tier | Option | Who it's for | Trade-off | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------ | +| **Easy / default** | [H](./server/src/mrtr-dual-path/optionHContinuationStore.ts) (`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](./server/src/mrtr-dual-path/optionEDegradeOnly.ts) + [F](./server/src/mrtr-dual-path/optionFCtxOnce.ts) or [G](./server/src/mrtr-dual-path/optionGToolBuilder.ts) | Horizontally scaled, ephemeral workers, lambda-style | Must write re-entrant handlers; F/G mitigate the footgun | +| **Transition compat** | [A](./server/src/mrtr-dual-path/optionAShimMrtrCanonical.ts) / [C](./server/src/mrtr-dual-path/optionCExplicitVersionBranch.ts) / [D](./server/src/mrtr-dual-path/optionDDualRegistration.ts) | Servers that want old-client elicitation during transition | Carries SSE infra; opt-in | +| **Don't ship** | [B](./server/src/mrtr-dual-path/optionBShimAwaitCanonical.ts) | 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. B is the cautionary tale — same surface as H but the await is a goto, not a suspension. + +## The quadrant + +| Server infra | 2025-11 client | 2026-06 client | +| ------------------------------- | --------------------------------- | -------------- | +| Can hold SSE | E by default; A/C/D if you opt in | MRTR | +| MRTR-only (horizontally scaled) | E by necessity | MRTR | + +In both rows the server _works_ for old clients — version negotiation succeeds, `tools/list` is complete, tools that don't elicit are unaffected. Only elicitation inside a tool is unavailable. Bottom-left isn't "unresolvable"; it's "E is the only option." Top-left is "E, unless +you choose to carry SSE infra." The rows collapse for E, which is the argument for making it the SDK default. + +## Options + +| | Author writes | SDK does | Hidden re-entry | Old client gets | +| ---------------------------------------------------------------- | ------------------------------- | ------------------------------------ | ------------------------------------------- | ------------------------------------------------------ | +| [A](./server/src/mrtr-dual-path/optionAShimMrtrCanonical.ts) | MRTR-native only | Emulates retry loop over SSE | Yes, but safe (guard is explicit in source) | Full elicitation | +| [B](./server/src/mrtr-dual-path/optionBShimAwaitCanonical.ts) | `await elicit()` only | Exception → `IncompleteResult` | Yes, **unsafe** (invisible in source) | Full elicitation | +| [C](./server/src/mrtr-dual-path/optionCExplicitVersionBranch.ts) | One handler, `if (mrtr)` branch | Version accessor | No | Full elicitation | +| [D](./server/src/mrtr-dual-path/optionDDualRegistration.ts) | Two handlers | Picks by version | No | Full elicitation | +| [E](./server/src/mrtr-dual-path/optionEDegradeOnly.ts) | MRTR-native only | Nothing | No | Result with default, or error — tool author's choice | +| [F](./server/src/mrtr-dual-path/optionFCtxOnce.ts) | MRTR-native + `ctx.once` wraps | `once()` guard in requestState | No | (same as E — F/G are orthogonal to the dual-path axis) | +| [G](./server/src/mrtr-dual-path/optionGToolBuilder.ts) | Step functions + `.build()` | Step-tracking in requestState | No | (same as E) | +| [H](./server/src/mrtr-dual-path/optionHContinuationStore.ts) | SSE-era `await ctx.elicit()` | Holds coroutine in ContinuationStore | No — genuine suspension, not re-entry | (same as E) | + +"Hidden re-entry" = the handler function is invoked more than once for a single logical tool call, and the author can't tell from the source text. A is safe because MRTR-native code has the re-entry guard (`if (!prefs) return`) visible in the source even though the _loop_ is +hidden. B is unsafe because `await elicit()` looks like a suspension point but is actually a re-entry point on MRTR sessions — see the `auditLog` landmine in that file. + +## Footgun prevention (F, G, H) + +A–E are about the dual-path axis (old client vs new). F and G are about a different axis: even in a pure-MRTR world, the naive handler shape has a footgun. Code above the `if (!prefs)` guard runs on every retry. If that code is a DB write or HTTP POST, it executes N times for +N-round elicitation. The guard is present in A/E but nothing _enforces_ putting side-effects below it — safety depends on the developer knowing the convention. The analogy raised in SDK-WG review: the naive MRTR handler is de-facto GOTO — re-entry jumps to the top, and the state +machine progression is implicit in the `inputResponses` checks. + +**F (`ctx.once`)** keeps the monolithic handler but wraps side-effects in an idempotency guard. `ctx.once('audit', () => auditLog(...))` checks `requestState` — if the key is already marked executed, skip. Opt-in: an unwrapped mutation still fires twice. The footgun isn't +eliminated; it's made _visually distinct_ from safe code, which is reviewable. + +**G (`ToolBuilder`)** decomposes the handler into named step functions. `incompleteStep` may return `IncompleteResult` or data; `endStep` receives everything and runs exactly once. There is no "above the guard" zone because there is no guard — the SDK's step-tracking is the +guard. Side-effects go in `endStep`; it's structurally unreachable until all elicitations complete. Boilerplate: two function definitions + `.build()` to replace A/E's 3-line check. Worth it at 3+ rounds; overkill for single-question tools where F is lighter. + +**H (`ContinuationStore`)** keeps the `await ctx.elicit()` surface but makes the await _genuine_ — the coroutine frame is held in a `Map` between rounds, keyed by `request_state`. 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. The price: server-side state within a tool call, so horizontal scale +needs sticky routing on the token. Counterpart to [python-sdk#2322's `linear.py`](https://github.com/modelcontextprotocol/python-sdk/pull/2322). + +Both F and G depend on `requestState` integrity. The demos use plain base64 JSON; a real SDK MUST HMAC-sign the blob, because otherwise the client can forge step-done / once-executed markers and skip the guards. Per-session key derived from `initialize` keeps it stateless. +Without signing, the safety story is advisory. + +## Client impact + +None. All eight options present identical wire behaviour to each client version (F, G, H are the same as E on the wire — the footgun-prevention is server-internal). A 2025-11 client sees either a standard `elicitation/create` over SSE (A/B/C/D) or a plain `CallToolResult` (E — +either a real result with a default, or an error, tool author's choice). All vanilla 2025-11 shapes. A 2026-06 client sees `IncompleteResult` in every case. The server's internal choice doesn't leak. This is the cleanest argument against per-feature `-mrtr` capability flags: +there's nothing for them to signal, because the client's behaviour is already fully determined by `protocolVersion` plus the existing `elicitation`/`sampling` capabilities. + +For the reverse direction — new client SDK connecting to an old server — see `examples/client/src/mrtr-dual-path/`. Split into two files to make the boundary explicit: [`clientDualPath.ts`](./client/src/mrtr-dual-path/clientDualPath.ts) is ~55 lines of what the app developer +writes (one `handleElicitation` function, one registration, one tool call); [`sdkLib.ts`](./client/src/mrtr-dual-path/sdkLib.ts) is the retry loop + `IncompleteResult` parsing the SDK would ship. The app file is small on purpose — the delta from today's client code is zero. + +## Trade-offs + +**E is the SDK-default position.** A horizontally scaled server gets E for free — it's the only thing that works on that infra. A server that can hold SSE also gets E by default, and opts into A/C/D only if serving old-client elicitation is worth the extra infra dependency. That +reframes A/C/D from "ways to fill the top-left" to "opt-in exceptions for servers that choose to carry SSE through the transition." + +**A vs E** is the core tension. Same author-facing code (MRTR-native), the only difference is whether old clients get served. A requires shipping and maintaining `sseRetryShim` in the SDK; E requires shipping nothing. A also carries a deployment-time hazard E doesn't: the shim +calls real SSE under the hood, so if the SDK ships it and someone uses it on MRTR-only infra, it fails at runtime when an old client connects — a constraint that lives nowhere near the tool code. E fails predictably (same error every time, from the first test); A fails only when +old client + wrong infra coincide. + +**B vs H** are both "keep `await`." B does it via exception-shim: the await throws, handler re-runs from top, await returns cached answer. Everything above runs twice. H does it via ContinuationStore: the await genuinely suspends, frame held in memory, retry resumes from the +await point. Nothing above re-runs. Same author-facing surface, opposite safety story. B exists in this deck only as the cautionary tale — there's no reason to ship it when H exists. + +**H vs E/F/G** is the statefulness trade. H is ergonomic and safe but the server holds a frame in memory, so horizontal scale needs sticky routing on `request_state`. E/F/G encode everything in `request_state` itself, so any server instance can handle any round — true +statelessness, at the cost of writing re-entrant handlers. Pick H if your deployment can do sticky routing (most can — hash the token). Pick E/F/G if it can't (lambda, ephemeral workers). + +**C vs D** is a factoring question. C keeps both paths in one function body (duplication is visible, one file per tool). D separates them into two functions (cleaner per-handler, but two things to keep in sync and a registration API that only exists for the transition). Both put +the dual-path burden on the tool author rather than the SDK. + +**A vs C/D** is about who owns the SSE fallback. A: SDK owns it, author writes once. C/D: author owns it, writes twice. A is less code for authors but more magic; C/D is more code for authors but no magic. + +**F vs G** is the footgun-prevention trade. F is minimal — one line per side-effect, composes with any handler shape (A, E, or raw MRTR). G is structural — the step decomposition makes double-execution impossible for `endStep`, but costs two function definitions per tool. Neither +replaces A–E; they layer on top. The likely SDK answer is: ship F as a primitive on the MRTR context, ship G as an opt-in builder, recommend G for multi-round tools and F for single-question tools with one side-effect. + +## Running + +All demos use `DEMO_PROTOCOL_VERSION` to simulate the negotiated version, since the real SDK doesn't surface it to handlers yet. Server demos run from `examples/server`: + +```sh +DEMO_PROTOCOL_VERSION=2025-11 pnpm tsx src/mrtr-dual-path/optionAShimMrtrCanonical.ts +DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/optionAShimMrtrCanonical.ts +``` + +The client demo spawns the server itself (run from `examples/client`): + +```sh +DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/clientDualPath.ts +``` + +`IncompleteResult` is smuggled through the current `registerTool` signature as a JSON text block (same hack as #1597). A real implementation emits `JSONRPCIncompleteResultResponse` at the protocol layer — see `server/src/mrtr-dual-path/shims.ts:wrap()`. + +## Not in scope + +- Sampling and roots (same shape as elicitation, just noisier to demo) +- `requestState` / continuation-state handlers (#1597's bucket 2 — each option extends to it the same way) +- A paired demo client (drive via Inspector, look for `__mrtrIncomplete` in tool output) diff --git a/examples/client/src/mrtr-dual-path/clientDualPath.ts b/examples/client/src/mrtr-dual-path/clientDualPath.ts new file mode 100644 index 000000000..6f4cf531e --- /dev/null +++ b/examples/client/src/mrtr-dual-path/clientDualPath.ts @@ -0,0 +1,56 @@ +/** + * Client-side dual-path: new SDK, old server. + * + * Everything here is what the APP DEVELOPER writes. The SDK machinery + * (retry loop, IncompleteResult parsing, SSE listener wiring) lives in + * sdkLib.ts — that file is a stand-in for what the real SDK ships. + * + * The point: the app-facing code is identical to today's. You write one + * elicitation handler, you register it, you call tools. The SDK routes + * your handler from either the SSE push path (old server) or the MRTR + * retry loop (new server). Which path fires is invisible to this file. + * + * Run against the server demos (cwd: examples/client): + * DEMO_PROTOCOL_VERSION=2025-11 pnpm tsx src/mrtr-dual-path/clientDualPath.ts + * DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/clientDualPath.ts + */ + +import type { ElicitRequestFormParams, ElicitResult } from '@modelcontextprotocol/client'; +import { Client, getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/client'; + +import { withMrtr } from './sdkLib.js'; + +// ─────────────────────────────────────────────────────────────────────────── +// The one thing the app owns: given an elicitation request, produce a +// response. In a real client this presents `requestedSchema` as a form. +// The signature is identical whether the request arrived via SSE push +// or inside an IncompleteResult — the SDK dispatches to this from both. +// ─────────────────────────────────────────────────────────────────────────── + +async function handleElicitation(params: ElicitRequestFormParams): Promise { + console.error(`[elicit] server asks: ${params.message}`); + return { action: 'accept', content: { units: 'metric' } }; +} + +// ─────────────────────────────────────────────────────────────────────────── + +const client = new Client({ name: 'mrtr-dual-path-client', version: '0.0.0' }, { capabilities: { elicitation: {} } }); + +// One registration. Both paths dispatch to `handleElicitation`. +// Pass `{ mrtrOnly: true }` to drop the SSE listener (cloud-hosted clients +// that can't hold the backchannel — Caitie's point 2). +const { callTool } = withMrtr(client, handleElicitation); + +const transport = new StdioClientTransport({ + command: 'pnpm', + args: ['tsx', '../server/src/mrtr-dual-path/optionAShimMrtrCanonical.ts'], + env: { ...getDefaultEnvironment(), DEMO_PROTOCOL_VERSION: process.env.DEMO_PROTOCOL_VERSION ?? '2026-06' } +}); +await client.connect(transport); + +// Same call site as today. Which path fires under the hood — SSE push or +// MRTR retry — depends on the server, not on anything in this file. +const result = await callTool('weather', { location: 'Tokyo' }); +console.error('[result]', JSON.stringify(result.content, null, 2)); + +await client.close(); diff --git a/examples/client/src/mrtr-dual-path/sdkLib.ts b/examples/client/src/mrtr-dual-path/sdkLib.ts new file mode 100644 index 000000000..117d19dc2 --- /dev/null +++ b/examples/client/src/mrtr-dual-path/sdkLib.ts @@ -0,0 +1,108 @@ +/** + * Stand-in for what the client SDK would ship for MRTR. + * + * Everything in this file is machinery the SDK provides. A client app + * developer never writes any of it — they just call `withMrtr(client, handler)` + * (or in the real SDK: register a handler the way they do today, and the + * SDK's `callTool` does the retry loop internally). + * + * See clientDualPath.ts for the app-developer side — that file is short + * on purpose. + */ + +import type { CallToolResult, Client, ElicitRequestFormParams, ElicitResult } from '@modelcontextprotocol/client'; + +// ─────────────────────────────────────────────────────────────────────────── +// Type shims — see examples/server/src/mrtr-dual-path/shims.ts for the +// full set with commentary. +// ─────────────────────────────────────────────────────────────────────────── + +type InputRequest = { method: 'elicitation/create'; params: ElicitRequestFormParams }; +type InputResponses = { [key: string]: { result: ElicitResult } }; + +interface IncompleteResult { + inputRequests?: { [key: string]: InputRequest }; + requestState?: string; +} + +interface MrtrParams { + inputResponses?: InputResponses; + requestState?: string; +} + +// ─────────────────────────────────────────────────────────────────────────── +// The one SDK-surface export. +// +// Real SDK shape: the app registers via `setRequestHandler('elicitation/create', h)` +// exactly as today, and `client.callTool()` gains the retry loop internally, +// dispatching to that registered handler. No new API; the MRTR loop is +// invisible to the app. +// +// Demo shape: this helper does both — registers the handler on the SSE +// path AND returns a `callTool` that runs the MRTR retry loop using the +// same handler. One registration point, two dispatch paths, same as the +// real SDK would do but with the wiring visible. +// ─────────────────────────────────────────────────────────────────────────── + +export type ElicitationHandler = (params: ElicitRequestFormParams) => Promise; + +export interface MrtrClientOptions { + /** + * Drop the SSE `elicitation/create` listener. Old servers that push + * elicitation get method-not-found; the MRTR retry loop still works. + * For cloud-hosted clients that can't hold the SSE backchannel anyway. + */ + mrtrOnly?: boolean; +} + +export function withMrtr( + client: Client, + handleElicitation: ElicitationHandler, + options: MrtrClientOptions = {} +): { callTool: (name: string, args: Record) => Promise } { + // Path 1: SSE push (old server, negotiated 2025-11). Today's plumbing, + // unchanged. Skipped if mrtrOnly is set. + if (!options.mrtrOnly) { + client.setRequestHandler('elicitation/create', async request => { + if (request.params.mode !== 'form') return { action: 'decline' }; + return handleElicitation(request.params); + }); + } + + // Path 2: MRTR retry loop (new server, negotiated 2026-06). What the + // real SDK's `callTool` would do internally. Calls the SAME handler + // as path 1 — that's the whole point. + async function callTool(name: string, args: Record): Promise { + let mrtr: MrtrParams = {}; + + for (let round = 0; round < 8; round++) { + const result = await client.callTool({ name, arguments: { ...args, _mrtr: mrtr } }); + + const incomplete = unwrapIncomplete(result); + if (!incomplete) return result as CallToolResult; + + const responses: InputResponses = {}; + for (const [key, req] of Object.entries(incomplete.inputRequests ?? {})) { + responses[key] = { result: await handleElicitation(req.params) }; + } + mrtr = { inputResponses: responses, requestState: incomplete.requestState }; + } + + throw new Error('MRTR retry loop exceeded round limit'); + } + + return { callTool }; +} + +// Protocol-layer parsing. Real SDK parses `JSONRPCIncompleteResultResponse` +// off the wire; this unwraps the JSON-text-block smuggle the server demos use. +function unwrapIncomplete(result: Awaited>): IncompleteResult | undefined { + const first = (result as CallToolResult).content?.[0]; + if (first?.type !== 'text') return undefined; + try { + const parsed = JSON.parse(first.text) as { __mrtrIncomplete?: true } & IncompleteResult; + return parsed.__mrtrIncomplete ? parsed : undefined; + } catch { + return undefined; + } +} diff --git a/examples/server/src/mrtr-dual-path/optionAShimMrtrCanonical.ts b/examples/server/src/mrtr-dual-path/optionAShimMrtrCanonical.ts new file mode 100644 index 000000000..98f842ff8 --- /dev/null +++ b/examples/server/src/mrtr-dual-path/optionAShimMrtrCanonical.ts @@ -0,0 +1,84 @@ +/** + * Option A: SDK shim, MRTR as canonical. Hidden retry loop. + * + * Tool author writes MRTR-native code only. The SDK wrapper (`sseRetryShim`) + * detects the negotiated version: + * - 2026-06 client → pass `IncompleteResult` through, client drives retry + * - 2025-11 client → SDK emulates the retry loop locally, fulfilling each + * `InputRequest` via real SSE elicitation, re-invoking the handler until + * it returns a complete result + * + * Author experience: one code path. Re-entry is explicit in the source + * (the `if (!prefs)` guard), so the handler is safe to re-invoke by + * construction. But the *fact* that it's re-invoked for old clients is + * invisible — the shim is doing work the author can't see. + * + * What makes this the "⚠️ clunky" cell: the SDK is running a loop on the + * author's behalf. If the handler has a subtle ordering assumption between + * rounds, or does something expensive before the guard, the author won't + * find out until an old client connects in prod. It works, but it's magic. + * + * Run: DEMO_PROTOCOL_VERSION=2025-11 pnpm tsx src/mrtr-dual-path/optionAShimMrtrCanonical.ts + * DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/optionAShimMrtrCanonical.ts + */ + +import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import type { MrtrHandler } from './shims.js'; +import { acceptedContent, elicitForm, sseRetryShim } from './shims.js'; + +type Units = 'metric' | 'imperial'; + +function lookupWeather(location: string, units: Units): string { + const temp = units === 'metric' ? '22°C' : '72°F'; + return `Weather in ${location}: ${temp}, partly cloudy.`; +} + +const server = new McpServer({ name: 'mrtr-option-a', version: '0.0.0' }); + +// ─────────────────────────────────────────────────────────────────────────── +// This is what the tool author writes. One function, MRTR-native. +// No version check, no SSE awareness. The `if (!prefs)` guard IS the +// re-entry contract; the author sees it, but doesn't see the shim calling +// this function in a loop for 2025-11 sessions. +// ─────────────────────────────────────────────────────────────────────────── + +const weatherHandler: MrtrHandler<{ location: string }> = async ({ location }, { inputResponses }) => { + const prefs = acceptedContent<{ units: Units }>(inputResponses, 'units'); + if (!prefs) { + return { + inputRequests: { + units: elicitForm({ + message: 'Which units?', + requestedSchema: { + type: 'object', + properties: { units: { type: 'string', enum: ['metric', 'imperial'], title: 'Units' } }, + required: ['units'] + } + }) + } + }; + } + + return { content: [{ type: 'text', text: lookupWeather(location, prefs.units) }] }; +}; + +// ─────────────────────────────────────────────────────────────────────────── +// Registration applies the shim. In a real SDK this could be a flag on +// `registerTool` itself, or inferred from the handler signature — the point +// is the author opts in once at registration, not per-call. +// ─────────────────────────────────────────────────────────────────────────── + +server.registerTool( + 'weather', + { + description: 'Weather lookup (Option A: SDK shim, MRTR canonical)', + inputSchema: z.object({ location: z.string() }) + }, + sseRetryShim(weatherHandler) +); + +const transport = new StdioServerTransport(); +await server.connect(transport); +console.error('[option-A] ready'); diff --git a/examples/server/src/mrtr-dual-path/optionBShimAwaitCanonical.ts b/examples/server/src/mrtr-dual-path/optionBShimAwaitCanonical.ts new file mode 100644 index 000000000..710fc9214 --- /dev/null +++ b/examples/server/src/mrtr-dual-path/optionBShimAwaitCanonical.ts @@ -0,0 +1,89 @@ +/** + * Option B: SDK shim, `await elicit()` as canonical. The footgun direction. + * + * Tool author writes today's `await elicit(...)` style. The shim routes: + * - 2025-11 client → native SSE, blocks inline (today's behaviour exactly) + * - 2026-06 client → `elicit()` throws `NeedsInputSignal`, shim catches it, + * emits `IncompleteResult`. On retry the handler runs from the top, and + * this time `elicit()` finds the answer in `inputResponses`. + * + * Author experience: zero migration. Handlers that work today keep working. + * The `await` reads linearly. + * + * The problem: the `await` is a lie on MRTR sessions. Everything above it + * re-executes on retry. See the commented-out `auditLog()` below — uncomment + * it and a 2026-06 client triggers *two* audit entries for one tool call. + * A 2025-11 client triggers one. Same source, different observable behaviour, + * and nothing in the code warns you. + * + * This is the "wrap legacy `await elicitInput()` so it behaves like MRTR + * bucket-1" follow-up #1597's README raised. It works for idempotent + * handlers. It breaks silently for everything else. + * + * Run: DEMO_PROTOCOL_VERSION=2025-11 pnpm tsx src/mrtr-dual-path/optionBShimAwaitCanonical.ts + * DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/optionBShimAwaitCanonical.ts + */ + +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { mrtrExceptionShim, readMrtr, wrap } from './shims.js'; + +type Units = 'metric' | 'imperial'; + +function lookupWeather(location: string, units: Units): string { + const temp = units === 'metric' ? '22°C' : '72°F'; + return `Weather in ${location}: ${temp}, partly cloudy.`; +} + +// Pretend side-effect to make the hazard concrete. Uncomment the call in +// the handler and watch the count diverge between protocol versions. +let auditCount = 0; +function auditLog(location: string): void { + auditCount++; + console.error(`[audit] lookup requested for ${location} (count=${auditCount})`); +} +void auditLog; + +const server = new McpServer({ name: 'mrtr-option-b', version: '0.0.0' }); + +// ─────────────────────────────────────────────────────────────────────────── +// This is what the tool author writes. Looks linear. Isn't, on MRTR. +// ─────────────────────────────────────────────────────────────────────────── + +const weatherHandler = mrtrExceptionShim<{ location: string }>(async ({ location }, elicit): Promise => { + // auditLog(location); + // ^^^^^^^^^^^^^^^^^ + // On 2025-11: runs once. On 2026-06: runs once on the initial call, + // once more on the retry. The await below isn't a suspension point + // on MRTR — it's a re-entry point. Nothing in this syntax says so. + + const prefs = await elicit<{ units: Units }>('units', { + message: 'Which units?', + requestedSchema: { + type: 'object', + properties: { units: { type: 'string', enum: ['metric', 'imperial'], title: 'Units' } }, + required: ['units'] + } + }); + + if (!prefs) { + return { content: [{ type: 'text', text: 'Cancelled.' }] }; + } + + return { content: [{ type: 'text', text: lookupWeather(location, prefs.units) }] }; +}); + +server.registerTool( + 'weather', + { + description: 'Weather lookup (Option B: SDK shim, await-elicit canonical)', + inputSchema: z.object({ location: z.string(), _mrtr: z.unknown().optional() }) + }, + async ({ location, _mrtr }, ctx) => wrap(await weatherHandler({ location }, readMrtr({ _mrtr }), ctx)) +); + +const transport = new StdioServerTransport(); +await server.connect(transport); +console.error('[option-B] ready'); diff --git a/examples/server/src/mrtr-dual-path/optionCExplicitVersionBranch.ts b/examples/server/src/mrtr-dual-path/optionCExplicitVersionBranch.ts new file mode 100644 index 000000000..014fd1754 --- /dev/null +++ b/examples/server/src/mrtr-dual-path/optionCExplicitVersionBranch.ts @@ -0,0 +1,81 @@ +/** + * Option C: explicit version branch in the handler body. + * + * No shim. Tool author checks `negotiatedVersion()` themselves and writes + * both code paths inline. The SDK provides nothing except the version + * accessor and the raw primitives for each path. + * + * Author experience: everything is visible. Both protocol behaviours are + * right there in the source, separated by an `if`. No hidden re-entry, + * no magic wrappers. A reader can trace exactly what happens for each + * client version. + * + * The cost is also visible: the elicitation schema is duplicated, the + * cancel-handling is duplicated, and there's now a conditional at the top + * of every handler that uses elicitation. For one tool, fine. For twenty, + * it's twenty copies of the same `if (supportsMrtr())` branch. + * + * This is one reading of "have clients implement both paths (i.e. not + * something we hide in the SDK)" from the thread. + * + * Run: DEMO_PROTOCOL_VERSION=2025-11 pnpm tsx src/mrtr-dual-path/optionCExplicitVersionBranch.ts + * DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/optionCExplicitVersionBranch.ts + */ + +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { acceptedContent, elicitForm, readMrtr, supportsMrtr, wrap } from './shims.js'; + +type Units = 'metric' | 'imperial'; + +function lookupWeather(location: string, units: Units): string { + const temp = units === 'metric' ? '22°C' : '72°F'; + return `Weather in ${location}: ${temp}, partly cloudy.`; +} + +const unitsSchema = { + type: 'object' as const, + properties: { units: { type: 'string' as const, enum: ['metric', 'imperial'], title: 'Units' } }, + required: ['units'] +}; + +const server = new McpServer({ name: 'mrtr-option-c', version: '0.0.0' }); + +server.registerTool( + 'weather', + { + description: 'Weather lookup (Option C: explicit version branch)', + inputSchema: z.object({ location: z.string(), _mrtr: z.unknown().optional() }) + }, + async ({ location, _mrtr }, ctx): Promise => { + // ─────────────────────────────────────────────────────────────────── + // This is what the tool author writes. The branch is the whole story. + // ─────────────────────────────────────────────────────────────────── + + if (supportsMrtr()) { + // MRTR path: check inputResponses, return IncompleteResult if missing. + const { inputResponses } = readMrtr({ _mrtr }); + const prefs = acceptedContent<{ units: Units }>(inputResponses, 'units'); + if (!prefs) { + return wrap({ + inputRequests: { units: elicitForm({ message: 'Which units?', requestedSchema: unitsSchema }) } + }); + } + return { content: [{ type: 'text', text: lookupWeather(location, prefs.units) }] }; + } + + // SSE path: inline await, blocks on the POST response stream. + const result = await ctx.mcpReq.elicitInput({ mode: 'form', message: 'Which units?', requestedSchema: unitsSchema }); + if (result.action !== 'accept' || !result.content) { + return { content: [{ type: 'text', text: 'Cancelled.' }] }; + } + const units = result.content.units as Units; + return { content: [{ type: 'text', text: lookupWeather(location, units) }] }; + } +); + +const transport = new StdioServerTransport(); +await server.connect(transport); +console.error('[option-C] ready'); diff --git a/examples/server/src/mrtr-dual-path/optionDDualRegistration.ts b/examples/server/src/mrtr-dual-path/optionDDualRegistration.ts new file mode 100644 index 000000000..737ef0278 --- /dev/null +++ b/examples/server/src/mrtr-dual-path/optionDDualRegistration.ts @@ -0,0 +1,91 @@ +/** + * Option D: dual registration. Two handlers, SDK picks by version. + * + * Tool author writes two separate functions — one MRTR-native, one SSE-native + * — and hands both to the SDK at registration. The SDK dispatches based on + * negotiated version. No shim converts between them; each path is exactly + * what the author wrote for that protocol era. + * + * Author experience: no hidden control flow, and unlike Option C the two + * paths are structurally separated rather than tangled in one function body. + * Shared logic (the schema, the lookup call) factors out naturally. Each + * handler is readable in isolation. + * + * The cost: two functions per elicitation-using tool, both live until SSE + * is deprecated. There's no mechanical link between them — if the MRTR + * handler changes the elicitation schema and the SSE handler doesn't, + * nothing catches it. Also: the registration API grows a shape that only + * exists for the transition period. + * + * This is the other reading of "have clients implement both paths" — the + * two paths are separate functions, not branches. + * + * Run: DEMO_PROTOCOL_VERSION=2025-11 pnpm tsx src/mrtr-dual-path/optionDDualRegistration.ts + * DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/optionDDualRegistration.ts + */ + +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import type { MrtrHandler } from './shims.js'; +import { acceptedContent, dispatchByVersion, elicitForm, readMrtr, wrap } from './shims.js'; + +type Units = 'metric' | 'imperial'; + +function lookupWeather(location: string, units: Units): string { + const temp = units === 'metric' ? '22°C' : '72°F'; + return `Weather in ${location}: ${temp}, partly cloudy.`; +} + +const unitsSchema = { + type: 'object' as const, + properties: { units: { type: 'string' as const, enum: ['metric', 'imperial'], title: 'Units' } }, + required: ['units'] +}; + +const server = new McpServer({ name: 'mrtr-option-d', version: '0.0.0' }); + +// ─────────────────────────────────────────────────────────────────────────── +// The tool author writes two functions. Each is clean in isolation. +// ─────────────────────────────────────────────────────────────────────────── + +const weatherMrtr: MrtrHandler<{ location: string }> = async ({ location }, { inputResponses }) => { + const prefs = acceptedContent<{ units: Units }>(inputResponses, 'units'); + if (!prefs) { + return { + inputRequests: { units: elicitForm({ message: 'Which units?', requestedSchema: unitsSchema }) } + }; + } + return { content: [{ type: 'text', text: lookupWeather(location, prefs.units) }] }; +}; + +const weatherSse = async ({ location }: { location: string }, ctx: Parameters[2]): Promise => { + const result = await ctx.mcpReq.elicitInput({ mode: 'form', message: 'Which units?', requestedSchema: unitsSchema }); + if (result.action !== 'accept' || !result.content) { + return { content: [{ type: 'text', text: 'Cancelled.' }] }; + } + return { content: [{ type: 'text', text: lookupWeather(location, result.content.units as Units) }] }; +}; + +// ─────────────────────────────────────────────────────────────────────────── +// Registration takes both. The real SDK shape might be +// server.registerTool('weather', opts, { mrtr: ..., sse: ... }) +// or a decorator, or overloads — the point is both handlers are visible +// at the registration site and the SDK owns the switch. +// ─────────────────────────────────────────────────────────────────────────── + +const weatherHandler = dispatchByVersion({ mrtr: weatherMrtr, sse: weatherSse }); + +server.registerTool( + 'weather', + { + description: 'Weather lookup (Option D: dual registration)', + inputSchema: z.object({ location: z.string(), _mrtr: z.unknown().optional() }) + }, + async ({ location, _mrtr }, ctx) => wrap(await weatherHandler({ location }, readMrtr({ _mrtr }), ctx)) +); + +const transport = new StdioServerTransport(); +await server.connect(transport); +console.error('[option-D] ready'); diff --git a/examples/server/src/mrtr-dual-path/optionEDegradeOnly.ts b/examples/server/src/mrtr-dual-path/optionEDegradeOnly.ts new file mode 100644 index 000000000..abc858ce6 --- /dev/null +++ b/examples/server/src/mrtr-dual-path/optionEDegradeOnly.ts @@ -0,0 +1,96 @@ +/** + * Option E: graceful degradation. The SDK default. + * + * Tool author writes MRTR-native code. Pre-MRTR clients get a tool-level + * error for *this tool*: "requires a newer client." The server itself + * works fine — version negotiation succeeds, tools/list is complete, every + * other tool on the server is unaffected. Only elicitation is unavailable. + * + * Author experience: one code path, trivially understood. The version check + * is one line at the top; everything below it is plain MRTR. + * + * This is the only option that works on horizontally-scaled (MRTR-only) + * infra, and it's also correct on SSE-capable infra — the rows of the + * quadrant collapse here. That's why it's the default: a server adopting + * the new SDK gets this behaviour without asking for it. A/C/D are opt-in + * for servers that want to carry SSE infra through the transition. + * + * Matches the position in comment 4083481545: the server is perfectly + * 2025-11-compliant; it just doesn't use the client's declared + * `elicitation: {}` capability. Servers are already allowed to do that — + * no spec change, no new capability flags, no negotiation. + * + * Run: DEMO_PROTOCOL_VERSION=2025-11 pnpm tsx src/mrtr-dual-path/optionEDegradeOnly.ts + * DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/optionEDegradeOnly.ts + */ + +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { acceptedContent, elicitForm, errorResult, MRTR_MIN_VERSION, readMrtr, supportsMrtr, wrap } from './shims.js'; + +type Units = 'metric' | 'imperial'; + +function lookupWeather(location: string, units: Units): string { + const temp = units === 'metric' ? '22°C' : '72°F'; + return `Weather in ${location}: ${temp}, partly cloudy.`; +} + +const server = new McpServer({ name: 'mrtr-option-e', version: '0.0.0' }); + +server.registerTool( + 'weather', + { + description: 'Weather lookup (Option E: degrade only, no SSE fallback)', + inputSchema: z.object({ location: z.string(), _mrtr: z.unknown().optional() }) + }, + async ({ location, _mrtr }): Promise => { + // ─────────────────────────────────────────────────────────────────── + // Pre-MRTR session: elicitation unavailable. Tool author chooses + // what that means for *this* tool — not the SDK, not the spec. + // + // For weather, unit preference is nice-to-have. Defaulting to + // metric and returning the answer is a better old-client + // experience than "upgrade to check the weather." + // + // For a tool where the elicitation is essential — confirm a + // destructive action, collect required auth — error instead: + // + // return errorResult( + // `This tool requires interactive confirmation, which needs a ` + + // `client on protocol version ${MRTR_MIN_VERSION} or later.` + // ); + // + // Either way: no SSE code path. The server is still valid 2025-11. + // ─────────────────────────────────────────────────────────────────── + if (!supportsMrtr()) { + return { content: [{ type: 'text', text: lookupWeather(location, 'metric') }] }; + } + void errorResult; + void MRTR_MIN_VERSION; + + const { inputResponses } = readMrtr({ _mrtr }); + const prefs = acceptedContent<{ units: Units }>(inputResponses, 'units'); + if (!prefs) { + return wrap({ + inputRequests: { + units: elicitForm({ + message: 'Which units?', + requestedSchema: { + type: 'object', + properties: { units: { type: 'string', enum: ['metric', 'imperial'], title: 'Units' } }, + required: ['units'] + } + }) + } + }); + } + + return { content: [{ type: 'text', text: lookupWeather(location, prefs.units) }] }; + } +); + +const transport = new StdioServerTransport(); +await server.connect(transport); +console.error('[option-E] ready'); diff --git a/examples/server/src/mrtr-dual-path/optionFCtxOnce.ts b/examples/server/src/mrtr-dual-path/optionFCtxOnce.ts new file mode 100644 index 000000000..8a37799f0 --- /dev/null +++ b/examples/server/src/mrtr-dual-path/optionFCtxOnce.ts @@ -0,0 +1,85 @@ +/** + * Option F: ctx.once — idempotency guard inside the monolithic handler. + * + * Same MRTR-native shape as A/E, but side-effects get wrapped in + * `ctx.once(key, fn)`. The guard lives in `requestState` — on retry, + * keys marked executed skip their fn. Makes the hazard *visible* at + * the call site without restructuring the handler. + * + * Opt-in: an unwrapped `db.write()` above the guard still fires twice. + * The footgun isn't eliminated — it's made reviewable. `ctx.once('x', …)` + * reads differently from a bare call; a reviewer can grep for effects + * that aren't wrapped. + * + * When to reach for this over G (ToolBuilder): single elicitation, one + * or two side-effects, handler fits in ten lines. When the step count + * hits 3+, the ToolBuilder boilerplate pays for itself. + * + * Run: DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/optionFCtxOnce.ts + */ + +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { acceptedContent, elicitForm, MrtrCtx, readMrtr, wrap } from './shims.js'; + +type Units = 'metric' | 'imperial'; + +function lookupWeather(location: string, units: Units): string { + const temp = units === 'metric' ? '22°C' : '72°F'; + return `Weather in ${location}: ${temp}, partly cloudy.`; +} + +// The side-effect the footgun is about. In Option B this was commented +// out; here it's live, because the guard makes it safe. +let auditCount = 0; +function auditLog(location: string): void { + auditCount++; + console.error(`[audit] lookup requested for ${location} (count=${auditCount})`); +} + +const server = new McpServer({ name: 'mrtr-option-f', version: '0.0.0' }); + +server.registerTool( + 'weather', + { + description: 'Weather lookup (Option F: ctx.once idempotency guard)', + inputSchema: z.object({ location: z.string(), _mrtr: z.unknown().optional() }) + }, + async ({ location, _mrtr }): Promise => { + const ctx = new MrtrCtx(readMrtr({ _mrtr })); + + // ─────────────────────────────────────────────────────────────────── + // This is the hazard line. In A/E it would run on every retry. + // Here it runs once — `ctx.once` checks requestState, skips on retry. + // A reviewer sees `ctx.once` and knows the author considered + // re-entry. A bare `auditLog(location)` would be the red flag. + // ─────────────────────────────────────────────────────────────────── + ctx.once('audit', () => auditLog(location)); + + const prefs = acceptedContent<{ units: Units }>(ctx.inputResponses, 'units'); + if (!prefs) { + // `ctx.incomplete()` encodes the executed-keys set into + // requestState so the `once` guard holds across retry. + return wrap( + ctx.incomplete({ + units: elicitForm({ + message: 'Which units?', + requestedSchema: { + type: 'object', + properties: { units: { type: 'string', enum: ['metric', 'imperial'], title: 'Units' } }, + required: ['units'] + } + }) + }) + ); + } + + return { content: [{ type: 'text', text: lookupWeather(location, prefs.units) }] }; + } +); + +const transport = new StdioServerTransport(); +await server.connect(transport); +console.error('[option-F] ready'); diff --git a/examples/server/src/mrtr-dual-path/optionGToolBuilder.ts b/examples/server/src/mrtr-dual-path/optionGToolBuilder.ts new file mode 100644 index 000000000..ad8cbaa15 --- /dev/null +++ b/examples/server/src/mrtr-dual-path/optionGToolBuilder.ts @@ -0,0 +1,106 @@ +/** + * Option G: ToolBuilder — Marcelo's explicit step decomposition. + * + * The monolithic handler becomes a sequence of named step functions. + * `incompleteStep` may return `IncompleteResult` (needs more input) or + * a data object (satisfied, pass to next step). `endStep` receives + * everything and runs exactly once — it's structurally unreachable + * until every prior step has returned data. + * + * The footgun is eliminated by code shape, not discipline. There is + * no "above the guard" zone because there is no guard — the SDK's + * step-tracking (via `requestState`) is the guard. Side-effects go + * in `endStep`; anything in an `incompleteStep` is documented as + * must-be-idempotent, and the return-type split makes the distinction + * visible at the function signature level. + * + * Boilerplate: two function definitions + `.build()` to replace + * A/E's 3-line `if (!prefs) return`. Worth it at 3+ rounds or when + * the side-effect story matters. Overkill for a single-question tool. + * + * Run: DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/optionGToolBuilder.ts + */ + +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { acceptedContent, elicitForm, readMrtr, ToolBuilder, wrap } from './shims.js'; + +type Units = 'metric' | 'imperial'; + +function lookupWeather(location: string, units: Units): string { + const temp = units === 'metric' ? '22°C' : '72°F'; + return `Weather in ${location}: ${temp}, partly cloudy.`; +} + +let auditCount = 0; +function auditLog(location: string): void { + auditCount++; + console.error(`[audit] lookup requested for ${location} (count=${auditCount})`); +} + +const server = new McpServer({ name: 'mrtr-option-g', version: '0.0.0' }); + +// ─────────────────────────────────────────────────────────────────────────── +// Step 1: ask for units. Returns IncompleteResult if not yet provided, +// or `{ units }` to pass forward. MUST be idempotent — it can re-run +// if requestState is tampered with (demo doesn't sign) or if the step +// before it isn't the most-recently-completed one. No side-effects here. +// ─────────────────────────────────────────────────────────────────────────── + +const askUnits = (_args: { location: string }, inputs: Parameters[0]) => { + const prefs = acceptedContent<{ units: Units }>(inputs, 'units'); + if (!prefs) { + return { + inputRequests: { + units: elicitForm({ + message: 'Which units?', + requestedSchema: { + type: 'object', + properties: { units: { type: 'string', enum: ['metric', 'imperial'], title: 'Units' } }, + required: ['units'] + } + }) + } + }; + } + return { units: prefs.units }; +}; + +// ─────────────────────────────────────────────────────────────────────────── +// End step: has everything, does the work. Runs exactly once. This is +// where side-effects live — the SDK guarantees this function is not +// reached until `askUnits` (and any other incompleteSteps) have all +// returned data. The `auditLog` call here fires once regardless of how +// many MRTR rounds it took to collect the inputs. +// ─────────────────────────────────────────────────────────────────────────── + +const fetchWeather = ({ location }: { location: string }, collected: Record): CallToolResult => { + auditLog(location); + const units = collected.units as Units; + return { content: [{ type: 'text', text: lookupWeather(location, units) }] }; +}; + +// ─────────────────────────────────────────────────────────────────────────── +// Assembly. Steps are named (not ordinal) so reordering during +// development doesn't silently remap data. The builder is the +// MRTR-native handler; everything from A/E's dual-path discussion +// still applies (wrap in sseRetryShim for top-left, degrade for +// bottom-left). The footgun-prevention is orthogonal to that axis. +// ─────────────────────────────────────────────────────────────────────────── + +const weatherHandler = new ToolBuilder<{ location: string }>().incompleteStep('askUnits', askUnits).endStep(fetchWeather).build(); + +server.registerTool( + 'weather', + { + description: 'Weather lookup (Option G: ToolBuilder step decomposition)', + inputSchema: z.object({ location: z.string(), _mrtr: z.unknown().optional() }) + }, + async ({ location, _mrtr }) => wrap(await weatherHandler({ location }, readMrtr({ _mrtr }), undefined as never)) +); + +const transport = new StdioServerTransport(); +await server.connect(transport); +console.error('[option-G] ready'); diff --git a/examples/server/src/mrtr-dual-path/optionHContinuationStore.ts b/examples/server/src/mrtr-dual-path/optionHContinuationStore.ts new file mode 100644 index 000000000..615e5b06f --- /dev/null +++ b/examples/server/src/mrtr-dual-path/optionHContinuationStore.ts @@ -0,0 +1,98 @@ +/** + * Option H: ContinuationStore — `await ctx.elicit()` is genuinely linear. + * + * Counterpart to python-sdk#2322's option_h_linear.py. The Option B + * footgun was: `await elicit()` LOOKS like a suspension point but is + * actually a re-entry point, so everything above it runs twice. This + * fixes that by making it a REAL suspension point — the Promise chain + * is held in a `ContinuationStore` across MRTR rounds, keyed by + * `request_state`. + * + * Handler code stays exactly as it was in the SSE era. Side-effects + * above the await fire once because the function never restarts — it + * resumes. Zero migration, zero footgun. + * + * Trade-off: the server holds the frame in memory between rounds. + * Client still sees pure MRTR (no SSE, independent HTTP requests), + * but the server is stateful *within a tool call*. Horizontal scale + * needs sticky routing on the `request_state` token. Same operational + * shape as Option A's SSE hold, without the long-lived connection. + * + * When to reach for this: migrating SSE-era tools to MRTR wire protocol + * without rewriting the handler, or when the linear style is genuinely + * clearer than guard-first (complex branching, many rounds). If the + * deployment can do sticky routing (most can — hash the token), this + * is strictly better than B: same ergonomics, no footgun. + * + * When not to: if you need true statelessness across server instances + * (lambda, ephemeral workers, no sticky routing). Use E/F/G — they + * encode everything in `request_state` itself. + * + * Run: DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/optionHContinuationStore.ts + */ + +import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import type { LinearCtx } from './shims.js'; +import { ContinuationStore, linearMrtr, readMrtr, wrap } from './shims.js'; + +type Units = 'metric' | 'imperial'; + +function lookupWeather(location: string, units: Units): string { + const temp = units === 'metric' ? '22°C' : '72°F'; + return `Weather in ${location}: ${temp}, partly cloudy.`; +} + +let auditCount = 0; +function auditLog(location: string): void { + auditCount++; + console.error(`[audit] lookup requested for ${location} (count=${auditCount})`); +} + +// ─────────────────────────────────────────────────────────────────────────── +// This is what the tool author writes. Linear, front-to-back, no re-entry +// contract to reason about. The `auditLog` above the await fires exactly +// once — the await is a real suspension point, not a goto. +// +// Compare to Option B where the same `auditLog` line fires twice. Here +// it's safe because the function never restarts. The ContinuationStore +// holds the suspended Promise; the retry's `inputResponses` resolves it. +// ─────────────────────────────────────────────────────────────────────────── + +async function weather(args: { location: string }, ctx: LinearCtx): Promise { + auditLog(args.location); + + const prefs = await ctx.elicit<{ units: Units }>('Which units?', { + type: 'object', + properties: { units: { type: 'string', enum: ['metric', 'imperial'], title: 'Units' } }, + required: ['units'] + }); + + return lookupWeather(args.location, prefs.units); +} + +// ─────────────────────────────────────────────────────────────────────────── +// Registration. The store is a per-process Map. +// Unlike the Python version this doesn't need an explicit context +// manager — Node's event loop keeps pending Promises alive without +// a task group. TTL (default 5min) cleans up abandoned frames. +// ─────────────────────────────────────────────────────────────────────────── + +const store = new ContinuationStore(); +const weatherHandler = linearMrtr(weather, store); + +const server = new McpServer({ name: 'mrtr-option-h', version: '0.0.0' }); + +server.registerTool( + 'weather', + { + description: 'Weather lookup (Option H: ContinuationStore, genuinely linear await)', + inputSchema: z.object({ location: z.string(), _mrtr: z.unknown().optional() }) + }, + async ({ location, _mrtr }) => wrap(await weatherHandler({ location }, readMrtr({ _mrtr }), undefined as never)) +); + +const transport = new StdioServerTransport(); +await server.connect(transport); +console.error('[option-H] ready'); diff --git a/examples/server/src/mrtr-dual-path/shims.ts b/examples/server/src/mrtr-dual-path/shims.ts new file mode 100644 index 000000000..c04f10d1e --- /dev/null +++ b/examples/server/src/mrtr-dual-path/shims.ts @@ -0,0 +1,709 @@ +/** + * MRTR dual-path exploration — shared shims. + * + * This file extends the types from #1597's mrtr-backcompat/shims.ts with the + * machinery needed to demonstrate five approaches to the "top-left quadrant" + * from the SEP-2322 thread (comment 4083481545): a server that CAN hold SSE, + * talking to a 2025-11 client, running MRTR-era tool code. + * + * Everything here is a stand-in for what the SDK would eventually provide. + * None of it is production-grade; the point is to make the API surface area + * of each option concrete enough to compare. + * + * Builds on: typescript-sdk#1597 (pcarleton's mrtr-backcompat demos). + * See that PR's shims.ts for the baseline IncompleteResult / InputRequests + * types — those are copied here unchanged so this folder is self-contained. + */ + +import type { CallToolResult, ElicitRequestFormParams, ElicitResult, ServerContext } from '@modelcontextprotocol/server'; + +// ─────────────────────────────────────────────────────────────────────────── +// Baseline MRTR types (copied from #1597 — see that PR for full commentary) +// ─────────────────────────────────────────────────────────────────────────── + +export type InputRequest = { + method: 'elicitation/create'; + params: ElicitRequestFormParams; +}; + +export type InputRequests = { [key: string]: InputRequest }; +export type InputResponses = { [key: string]: { result: ElicitResult } }; + +export interface IncompleteResult { + inputRequests?: InputRequests; + requestState?: string; +} + +export interface MrtrParams { + inputResponses?: InputResponses; + requestState?: string; +} + +export type MrtrToolResult = CallToolResult | IncompleteResult; + +export function isIncomplete(r: MrtrToolResult): r is IncompleteResult { + return ('inputRequests' in r && r.inputRequests !== undefined) || ('requestState' in r && r.requestState !== undefined); +} + +export function elicitForm(params: Omit): InputRequest { + return { method: 'elicitation/create', params: { mode: 'form', ...params } }; +} + +export function acceptedContent>(responses: InputResponses | undefined, key: string): T | undefined { + const entry = responses?.[key]; + if (!entry) return undefined; + const { result } = entry; + if (result.action !== 'accept' || !result.content) return undefined; + return result.content as T; +} + +// ─────────────────────────────────────────────────────────────────────────── +// New for dual-path: negotiated version stand-in +// ─────────────────────────────────────────────────────────────────────────── + +/** + * The two protocol versions the demos care about. + * + * Real SDK would surface the negotiated version from the initialize handshake. + * Today's SDK does track it internally but doesn't expose it to tool handlers, + * so we read it from an env var to keep the demos runnable. + */ +export type ProtocolVersion = '2025-11' | '2026-06'; + +export const MRTR_MIN_VERSION: ProtocolVersion = '2026-06'; + +/** + * Stand-in for `ctx.protocolVersion` or similar. + * + * Drive with `DEMO_PROTOCOL_VERSION=2025-11 pnpm tsx optionAShimMrtrCanonical.ts` to simulate + * an old-client session against the same handler code. + */ +export function negotiatedVersion(): ProtocolVersion { + const v = process.env.DEMO_PROTOCOL_VERSION; + return v === '2025-11' ? '2025-11' : '2026-06'; +} + +export function supportsMrtr(v: ProtocolVersion = negotiatedVersion()): boolean { + return v >= MRTR_MIN_VERSION; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Option A machinery: SDK emulates the MRTR retry loop over SSE +// ─────────────────────────────────────────────────────────────────────────── + +/** + * The signature an MRTR-native handler would have once the SDK threads + * `inputResponses` / `requestState` through natively. + * + * This is what tool authors write under Option A. One function, re-entrant + * by construction: check `inputResponses`, return `IncompleteResult` if + * something's missing, compute the real result otherwise. + */ +export type MrtrHandler = (args: TArgs, mrtr: MrtrParams, ctx: ServerContext) => Promise; + +/** + * Wraps an MRTR-native handler so it also works for 2025-11 clients. + * + * Mechanism: when the negotiated version is pre-MRTR and the handler returns + * `IncompleteResult`, this wrapper drives the retry loop *locally* — it sends + * each `InputRequest` as a real `elicitation/create` over the SSE stream (via + * the existing `ctx.mcpReq.elicitInput()`), collects the answers, and + * re-invokes the handler with `inputResponses` populated. Repeat until the + * handler returns a complete result. + * + * This is the "⚠️ clunky but possible" shim from the comment's matrix. The + * tool author doesn't see the loop; they write MRTR-native code and it + * transparently works for old clients too. + * + * This is only valid on server infra that can actually hold SSE — the + * `ctx.mcpReq.elicitInput()` call below is a real SSE round-trip. On a + * horizontally-scaled deployment that can't (the whole reason to adopt + * MRTR in the first place), this shim fails at runtime when an old client + * connects — the elicit goes out on a stream the LB has already dropped, + * or was never held open. Nothing at registration time catches that; it's + * a deployment-time constraint living far from the tool code. If that's + * the deployment, use option E instead. + * + * Hidden cost: the handler is silently re-invoked. The MRTR shape makes that + * safe *by construction* (re-entry point is explicit — the `if (!prefs)` + * guard), but it's still invisible machinery. + */ +export function sseRetryShim(mrtrHandler: MrtrHandler): (args: TArgs, ctx: ServerContext) => Promise { + return async (args, ctx) => { + // Fast path: new client — just pass IncompleteResult through. + // (In the real SDK this would emit JSONRPCIncompleteResultResponse on the wire.) + if (supportsMrtr()) { + const result = await mrtrHandler(args, {}, ctx); + return wrap(result); + } + + // Old client: drive the retry loop locally, using real SSE for each elicit. + const responses: InputResponses = {}; + let requestState: string | undefined; + + // Bounded to catch handlers that never converge. A well-formed MRTR handler + // asks for strictly fewer things each round; an unbounded loop is a bug. + for (let round = 0; round < 8; round++) { + const result = await mrtrHandler(args, { inputResponses: responses, requestState }, ctx); + + if (!isIncomplete(result)) { + return result; + } + + requestState = result.requestState; + + // No new questions but still incomplete: nothing more we can do here. + // Return a tool-level error rather than looping on an empty ask. + if (!result.inputRequests || Object.keys(result.inputRequests).length === 0) { + return errorResult('Tool returned IncompleteResult with no inputRequests on a pre-MRTR session.'); + } + + // Fulfil each InputRequest via the *existing* SSE elicitation path. + // This is the one place the shim actually needs SSE-capable infra: + // `ctx.mcpReq.elicitInput()` issues `elicitation/create` on the POST + // response stream and blocks until the client answers. + for (const [key, req] of Object.entries(result.inputRequests)) { + const answer = await ctx.mcpReq.elicitInput(req.params); + responses[key] = { result: answer }; + } + } + + return errorResult('MRTR retry loop exceeded round limit (handler never converged).'); + }; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Option B machinery: exception-based shim, `await elicit()` canonical +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Sentinel thrown by `elicit()` when the session is MRTR-capable and the + * answer wasn't pre-supplied in `inputResponses`. + * + * Control-flow-by-exception: the shim catches this at the top of the handler + * wrapper, packages it as `IncompleteResult`, and returns. On retry the + * handler runs *from the top again* and this time `elicit()` finds the answer. + */ +export class NeedsInputSignal extends Error { + constructor(public readonly inputRequests: InputRequests) { + super('NeedsInputSignal (control flow, not an error)'); + } +} + +/** + * The `await`-able elicit function for Option B handlers. + * + * - Pre-MRTR session → real SSE elicitation, blocks inline (today's behaviour) + * - MRTR session, answer present → return it + * - MRTR session, answer absent → throw NeedsInputSignal + * + * The third case is the footgun. The handler author wrote `await elicit(...)` + * and assumed linear control flow. On MRTR retry, *everything above this line + * runs again*. If that includes a mutation — a DB write, an HTTP POST — it + * happens twice. The MRTR shape surfaces re-entry in the source text + * (`if (!prefs) return`); this shape hides it behind `await`. + */ +export function makeElicit(ctx: ServerContext, mrtr: MrtrParams) { + return async function elicit>( + key: string, + params: Omit + ): Promise { + // Old client: native SSE, no trickery. + if (!supportsMrtr()) { + const result = await ctx.mcpReq.elicitInput({ mode: 'form', ...params }); + if (result.action !== 'accept' || !result.content) return undefined; + return result.content as T; + } + + // New client: check inputResponses first. + const preSupplied = acceptedContent(mrtr.inputResponses, key); + if (preSupplied) return preSupplied; + + // Answer not pre-supplied → signal the shim to emit IncompleteResult. + // Everything on the stack between here and `mrtrExceptionShim`'s catch + // unwinds. On retry the handler re-executes from line one. + throw new NeedsInputSignal({ [key]: elicitForm(params) }); + }; +} + +/** + * Wrap an `await elicit()`-style handler so it emits `IncompleteResult` on + * MRTR sessions. + * + * Catches `NeedsInputSignal`, packages as `IncompleteResult`. That's it. + * The hidden re-entry on retry is the trade — zero migration for existing + * tools, silent double-execution of everything above the await. + */ +export function mrtrExceptionShim( + handler: (args: TArgs, elicit: ReturnType, ctx: ServerContext) => Promise +): (args: TArgs, mrtr: MrtrParams, ctx: ServerContext) => Promise { + return async (args, mrtr, ctx) => { + const elicit = makeElicit(ctx, mrtr); + try { + return await handler(args, elicit, ctx); + } catch (error) { + if (error instanceof NeedsInputSignal) { + return { inputRequests: error.inputRequests }; + } + throw error; + } + }; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Option D machinery: dual registration +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Two handlers, one per protocol era. SDK dispatches by negotiated version. + * No shim, no magic — the author wrote both and the SDK just picks. + */ +export interface DualPathHandlers { + mrtr: MrtrHandler; + sse: (args: TArgs, ctx: ServerContext) => Promise; +} + +export function dispatchByVersion( + handlers: DualPathHandlers +): (args: TArgs, mrtr: MrtrParams, ctx: ServerContext) => Promise { + return async (args, mrtr, ctx) => { + if (supportsMrtr()) { + return handlers.mrtr(args, mrtr, ctx); + } + return handlers.sse(args, ctx); + }; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Shared helpers +// ─────────────────────────────────────────────────────────────────────────── + +export function errorResult(message: string): CallToolResult { + return { content: [{ type: 'text', text: message }], isError: true }; +} + +/** + * Smuggle `IncompleteResult` through the current `registerTool` signature + * as a JSON text block. Same hack as #1597 — real SDK would emit + * `JSONRPCIncompleteResultResponse` at the protocol layer. + */ +export function wrap(result: MrtrToolResult): CallToolResult { + if (!isIncomplete(result)) return result; + return { + content: [{ type: 'text', text: JSON.stringify({ __mrtrIncomplete: true, ...result }) }] + }; +} + +/** + * Stand-in for reading MRTR params off the retry request. + * See #1597 for why this rides on `arguments._mrtr` today. + */ +export function readMrtr(args: Record | undefined): MrtrParams { + const raw = (args as { _mrtr?: MrtrParams } | undefined)?._mrtr; + return raw ?? {}; +} + +// ─────────────────────────────────────────────────────────────────────────── +// requestState encode/decode — used by options F and G +// +// DEMO ONLY: plain base64 JSON. Real SDK MUST HMAC-sign this blob, +// because the client can otherwise forge step-done / once-executed +// markers and skip the guards entirely. Per-session key derived from +// initialize keeps it stateless. Without signing, F and G's safety +// story is advisory, not enforced. +// ─────────────────────────────────────────────────────────────────────────── + +export function encodeState(state: unknown): string { + return Buffer.from(JSON.stringify(state), 'utf8').toString('base64'); +} + +export function decodeState(blob: string | undefined): T | undefined { + if (!blob) return undefined; + try { + return JSON.parse(Buffer.from(blob, 'base64').toString('utf8')) as T; + } catch { + return undefined; + } +} + +// ─────────────────────────────────────────────────────────────────────────── +// Option F machinery: ctx.once — idempotency guard for side-effects +// ─────────────────────────────────────────────────────────────────────────── + +interface OnceState { + executed: string[]; +} + +/** + * MRTR context with a `once` guard. Handler code looks like Option A/E + * (monolithic, guard-first) but side-effects above or below the guard + * can be wrapped to guarantee at-most-once execution across retries. + * + * Opt-in: an unwrapped `db.write()` above the guard still fires twice. + * The footgun isn't eliminated — it's made *visually distinct* from + * safe code, which is reviewable. Use this when ToolBuilder is overkill + * (single elicitation, one side-effect) or when the side-effect genuinely + * needs to happen before the guard. + * + * Crash window: if the server dies between `fn()` completing and + * `requestState` reaching the client, the next invocation re-executes + * `fn()`. At-most-once under normal operation, not crash-safe. For + * financial operations use external idempotency (request ID as DB + * unique constraint). + */ +export class MrtrCtx { + private executed: Set; + + constructor(private readonly mrtr: MrtrParams) { + const prior = decodeState(mrtr.requestState); + this.executed = new Set(prior?.executed); + } + + get inputResponses(): InputResponses | undefined { + return this.mrtr.inputResponses; + } + + /** + * Run `fn` at most once across all MRTR rounds for this tool call. + * On subsequent rounds where `key` is marked done in requestState, + * skip `fn` entirely. Makes the hazard visible at the call site. + */ + once(key: string, fn: () => void): void { + if (this.executed.has(key)) return; + fn(); + this.executed.add(key); + } + + /** + * Serialize executed-keys into requestState for the next round. + * Call this when building an IncompleteResult so the guard holds + * across retry. Without this, `once` is a no-op on retry. + */ + incomplete(inputRequests: InputRequests): IncompleteResult { + return { + inputRequests, + requestState: encodeState({ executed: [...this.executed] } satisfies OnceState) + }; + } +} + +// ─────────────────────────────────────────────────────────────────────────── +// Option G machinery: ToolBuilder — Marcelo's explicit step decomposition +// ─────────────────────────────────────────────────────────────────────────── + +interface BuilderState { + done: string[]; +} + +/** + * An `incomplete_step` function. Receives args + all `inputResponses` + * collected so far. Returns either a new `IncompleteResult` (needs more + * input) or a data object to accumulate and pass to the next step. + * + * MUST be idempotent — this can re-run if the step before it wasn't + * the most-recently-completed one. Side-effects belong in `endStep`. + */ +export type IncompleteStep = (args: TArgs, inputs: InputResponses) => IncompleteResult | Record; + +/** + * The `end_step` function. Receives args + the merged data from all + * prior steps. Runs exactly once, when every `incomplete_step` has + * returned data (not `IncompleteResult`). This is the safe zone — + * put side-effects here. + */ +export type EndStep = (args: TArgs, collected: Record) => CallToolResult; + +/** + * Explicit step builder. Eliminates the "above the guard" zone by + * decomposing the monolithic handler into discrete step functions. + * `endStep` is structurally unreachable until all elicitations + * complete — the SDK enforces that via `requestState` tracking, + * not developer discipline. + * + * Steps are named (not ordinal) so reordering them during development + * doesn't silently remap data. Each `incompleteStep` name must be + * unique; the SDK would throw at build time on duplicates (demo skips + * that check). + * + * Boilerplate vs Option A/E: two function definitions + `.build()` to + * replace a 3-line guard. Worth it at 3+ elicitation rounds; overkill + * for single-question tools where `ctx.once` (Option F) is lighter. + */ +export class ToolBuilder { + private steps: Array<{ name: string; fn: IncompleteStep }> = []; + private end?: EndStep; + + incompleteStep(name: string, fn: IncompleteStep): this { + this.steps.push({ name, fn }); + return this; + } + + endStep(fn: EndStep): this { + this.end = fn; + return this; + } + + build(): MrtrHandler { + const steps = this.steps; + const end = this.end; + if (!end) throw new Error('ToolBuilder: endStep is required'); + + return async (args, mrtr) => { + const prior = decodeState(mrtr.requestState); + const done = new Set(prior?.done); + const inputs = mrtr.inputResponses ?? {}; + const collected: Record = {}; + + for (const step of steps) { + const result = step.fn(args, inputs); + if ('inputRequests' in result || 'requestState' in result) { + // Step needs more input. Encode which steps are done + // so retry can fast-forward past them. + return { + ...(result as IncompleteResult), + requestState: encodeState({ done: [...done] } satisfies BuilderState) + }; + } + // Step returned data. Merge and mark done. + Object.assign(collected, result); + done.add(step.name); + } + + // All steps complete — this line runs exactly once per tool call. + return end(args, collected); + }; + } +} + +// ─────────────────────────────────────────────────────────────────────────── +// Option H machinery: ContinuationStore — keep `await ctx.elicit()` genuine +// +// Counterpart to python-sdk#2322's linear.py. The Option B footgun was: +// `await elicit()` LOOKS like a suspension point but is actually a re-entry +// point, so everything above it runs twice. This fixes that by making it a +// REAL suspension point — the Promise chain is held in memory across MRTR +// rounds, keyed by request_state. +// +// Trade-off: the server holds the frame between rounds. Client sees pure +// MRTR (no SSE, independent HTTP requests), but the server is stateful +// within a tool call. Horizontal scale needs sticky routing on the +// request_state token. Same operational shape as Option A's SSE hold, +// without the long-lived connection. +// ─────────────────────────────────────────────────────────────────────────── + +type LinearAsk = IncompleteResult | CallToolResult; + +/** + * One-shot Promise + its resolver. After `resolve` fires, the caller + * swaps in a fresh channel for the next round. Node's event loop keeps + * the pending Promise alive; that's what holds the continuation. + */ +interface Channel { + next: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +} + +function channel(): Channel { + let resolve!: (v: T) => void; + let reject!: (r?: unknown) => void; + const next = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { next, resolve, reject }; +} + +/** + * In-memory state for one suspended linear handler. Two channels: + * `ask` carries IncompleteResult/CallToolResult from the handler to the + * wrapper (and onward to the client); `answer` carries inputResponses + * from the wrapper (the retry) back into the suspended `ctx.elicit()`. + */ +class Continuation { + private askCh: Channel = channel(); + private answerCh: Channel = channel(); + + ask(msg: LinearAsk): void { + this.askCh.resolve(msg); + } + + async nextAsk(): Promise { + const msg = await this.askCh.next; + this.askCh = channel(); + return msg; + } + + answer(responses: InputResponses): void { + this.answerCh.resolve(responses); + } + + async nextAnswer(): Promise { + const responses = await this.answerCh.next; + this.answerCh = channel(); + return responses; + } + + abort(reason: string): void { + this.answerCh.reject(new Error(reason)); + } +} + +/** + * Owns the token → continuation map. One per server process. Unlike the + * Python version this isn't a context manager — Node's event loop keeps + * pending Promises alive without an explicit task group. TTL is a simple + * setTimeout that aborts the frame if the client never retries. + */ +export class ContinuationStore { + private frames = new Map }>(); + + constructor(private readonly ttlMs = 300_000) {} + + start(token: string, runner: (cont: Continuation) => Promise): Continuation { + const cont = new Continuation(); + const timer = setTimeout(() => this.expire(token), this.ttlMs); + this.frames.set(token, { cont, timer }); + + // Fire-and-forget. The Promise is held alive by the event loop; + // the pending `cont.nextAnswer()` inside is what keeps the frame. + void runner(cont).finally(() => this.delete(token)); + + return cont; + } + + get(token: string): Continuation | undefined { + const entry = this.frames.get(token); + if (!entry) return undefined; + // Reset TTL on each access — the client is still driving. + clearTimeout(entry.timer); + entry.timer = setTimeout(() => this.expire(token), this.ttlMs); + return entry.cont; + } + + private expire(token: string): void { + const entry = this.frames.get(token); + if (!entry) return; + entry.cont.abort(`Continuation ${token} expired after ${this.ttlMs}ms`); + this.frames.delete(token); + } + + private delete(token: string): void { + const entry = this.frames.get(token); + if (!entry) return; + clearTimeout(entry.timer); + this.frames.delete(token); + } +} + +/** + * Thrown inside a linear handler when the user declines/cancels. + * The wrapper catches this and emits a non-error CallToolResult. + */ +export class ElicitDeclined extends Error { + constructor(public readonly action: string) { + super(`Elicitation ${action}`); + } +} + +/** + * The `ctx` handed to a linear handler. `await ctx.elicit()` genuinely + * suspends — the await parks on `cont.nextAnswer()` until the next MRTR + * round delivers the answer. No re-entry, no double-execution. + */ +export class LinearCtx { + private counter = 0; + + constructor(private readonly cont: Continuation) {} + + /** + * Send one or more input requests in a single round; returns the + * full responses dict on resume. Lower-level than `elicit()` — + * hand-rolled schemas, no decline handling, multiple asks batched. + */ + async ask(inputRequests: InputRequests): Promise { + this.cont.ask({ inputRequests }); + return this.cont.nextAnswer(); + } + + /** + * Ask one elicitation question. Suspends until the answer arrives + * on a later round. Throws `ElicitDeclined` if the user cancels. + */ + async elicit>( + message: string, + requestedSchema: ElicitRequestFormParams['requestedSchema'] + ): Promise { + const key = `q${this.counter++}`; + const responses = await this.ask({ [key]: elicitForm({ message, requestedSchema }) }); + const result = responses[key]?.result; + if (!result || result.action !== 'accept' || !result.content) { + throw new ElicitDeclined(result?.action ?? 'cancel'); + } + return result.content as T; + } +} + +/** + * Signature of a linear handler: SSE-era shape, runs exactly once + * front-to-back. Returning a string is shorthand for single TextContent. + */ +export type LinearHandler = (args: TArgs, ctx: LinearCtx) => Promise; + +/** + * Wrap a linear `await ctx.elicit()` handler into a standard MRTR + * handler. Round 1 spawns the handler as a detached Promise; `elicit()` + * sends IncompleteResult through the ask channel and parks on the answer + * channel. Round 2's retry resolves the answer channel; the handler + * continues from where it stopped. No re-entry. + * + * Zero migration from SSE-era code, zero footgun. The price: the server + * holds the frame in memory, so horizontal scale needs sticky routing + * on `request_state`. If you need true statelessness, use E/F/G instead. + */ +export function linearMrtr(handler: LinearHandler, store: ContinuationStore): MrtrHandler { + return async (args, mrtr) => { + const token = mrtr.requestState; + + if (token === undefined) { + return start(args, handler, store); + } + return resume(token, mrtr.inputResponses ?? {}, store); + }; +} + +async function start(args: TArgs, handler: LinearHandler, store: ContinuationStore): Promise { + const token = crypto.randomUUID(); + const cont = store.start(token, async c => { + const linearCtx = new LinearCtx(c); + try { + const result = await handler(args, linearCtx); + const wrapped: CallToolResult = typeof result === 'string' ? { content: [{ type: 'text', text: result }] } : result; + c.ask(wrapped); + } catch (error) { + if (error instanceof ElicitDeclined) { + c.ask({ content: [{ type: 'text', text: `Cancelled (${error.action}).` }] }); + return; + } + c.ask({ content: [{ type: 'text', text: String(error) }], isError: true }); + } + }); + return next(token, cont); +} + +async function resume(token: string, responses: InputResponses, store: ContinuationStore): Promise { + const cont = store.get(token); + if (!cont) { + return errorResult('Continuation expired or unknown. Retry the tool call from scratch.'); + } + cont.answer(responses); + return next(token, cont); +} + +async function next(token: string, cont: Continuation): Promise { + const msg = await cont.nextAsk(); + if (isIncomplete(msg)) { + return { ...msg, requestState: token }; + } + return msg; +}