From 1c97bb926f4c20250bac6da2168298b9a093b9cf Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 18 Mar 2026 20:52:08 +0000 Subject: [PATCH 01/12] examples: MRTR dual-path options for 2025-11 client compat 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. --- examples/server/src/mrtr-dual-path/README.md | 58 ++++ .../server/src/mrtr-dual-path/degradeOnly.ts | 86 +++++ .../src/mrtr-dual-path/dualRegistration.ts | 91 ++++++ .../mrtr-dual-path/explicitVersionBranch.ts | 81 +++++ .../src/mrtr-dual-path/shimAwaitCanonical.ts | 89 ++++++ .../src/mrtr-dual-path/shimMrtrCanonical.ts | 84 +++++ examples/server/src/mrtr-dual-path/shims.ts | 295 ++++++++++++++++++ 7 files changed, 784 insertions(+) create mode 100644 examples/server/src/mrtr-dual-path/README.md create mode 100644 examples/server/src/mrtr-dual-path/degradeOnly.ts create mode 100644 examples/server/src/mrtr-dual-path/dualRegistration.ts create mode 100644 examples/server/src/mrtr-dual-path/explicitVersionBranch.ts create mode 100644 examples/server/src/mrtr-dual-path/shimAwaitCanonical.ts create mode 100644 examples/server/src/mrtr-dual-path/shimMrtrCanonical.ts create mode 100644 examples/server/src/mrtr-dual-path/shims.ts diff --git a/examples/server/src/mrtr-dual-path/README.md b/examples/server/src/mrtr-dual-path/README.md new file mode 100644 index 000000000..4cdd9fa37 --- /dev/null +++ b/examples/server/src/mrtr-dual-path/README.md @@ -0,0 +1,58 @@ +# MRTR dual-path options + +Five approaches to the top-left quadrant of the SEP-2322 compatibility matrix: a server that **can** hold SSE, talking to a **2025-11** client, running **MRTR-era** tool code. + +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). All five files register the same +weather-lookup tool so the diff between files is the argument. + +## The quadrant + +| Server infra | 2025-11 client | 2026-06 client | +| ------------ | ------------------------- | -------------- | +| Can hold SSE | **← this folder** | just use MRTR | +| MRTR-only | tool fails (unresolvable) | just use MRTR | + +Bottom-left is discounted: no amount of SDK work fills it when the server infra can't hold SSE. These demos are about whether the top-left is worth filling, and if so, how. + +## Options + +| | Author writes | SDK does | Hidden re-entry | Old client gets | +| ------------------------------- | ------------------------------- | ------------------------------ | ------------------------------------------- | ------------------------------- | +| [A](./shimMrtrCanonical.ts) | MRTR-native only | Emulates retry loop over SSE | Yes, but safe (guard is explicit in source) | Full elicitation | +| [B](./shimAwaitCanonical.ts) | `await elicit()` only | Exception → `IncompleteResult` | Yes, **unsafe** (invisible in source) | Full elicitation | +| [C](./explicitVersionBranch.ts) | One handler, `if (mrtr)` branch | Version accessor | No | Full elicitation | +| [D](./dualRegistration.ts) | Two handlers | Picks by version | No | Full elicitation | +| [E](./degradeOnly.ts) | MRTR-native only | Nothing | No | Error ("requires newer client") | + +"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. + +## Trade-offs + +**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. If elicitation-using tools are rare and old clients upgrade on +a reasonable timeline, E's cost (a few tools error for a few months) is lower than A's cost (permanent SDK machinery). + +**B** is the zero-migration option. Every existing `await ctx.elicitInput()` handler keeps working. The hidden re-entry on MRTR sessions is the price: a handler that does anything non-idempotent above the await is broken, and nothing warns you. Only safe if you can enforce "no +side effects before await" as a lint rule, which is hard in practice. + +**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. + +## Running + +All demos use `DEMO_PROTOCOL_VERSION` to simulate the negotiated version, since the real SDK doesn't surface it to handlers yet: + +```sh +DEMO_PROTOCOL_VERSION=2025-11 pnpm tsx src/mrtr-dual-path/shimMrtrCanonical.ts +DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/shimMrtrCanonical.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 `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/server/src/mrtr-dual-path/degradeOnly.ts b/examples/server/src/mrtr-dual-path/degradeOnly.ts new file mode 100644 index 000000000..098293ab4 --- /dev/null +++ b/examples/server/src/mrtr-dual-path/degradeOnly.ts @@ -0,0 +1,86 @@ +/** + * Option E: graceful degradation only. No SSE fallback. + * + * Tool author writes MRTR-native code. Pre-MRTR clients get a tool-level + * error: "this tool requires a newer client." No shim, no dual path, no + * SSE infrastructure used even though it's available. + * + * 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 position staked in comment 4083481545: "I'd argue for graceful + * degradation instead." The server is perfectly 2025-11-compliant — it just + * happens not to use the client's declared `elicitation: {}` capability, + * which is something servers are already allowed to do. + * + * The cost is the obvious one: an old client that *could* have been served + * (server holds SSE, client declared elicitation) isn't. Whether that's + * acceptable is a product call, not an SDK one. For most tools — pure + * request/response, no elicitation — this option and all the others are + * identical. The difference only shows for the minority of tools that + * actually elicit. + * + * Run: DEMO_PROTOCOL_VERSION=2025-11 pnpm tsx src/mrtr-dual-path/degradeOnly.ts + * DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/degradeOnly.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 => { + // ─────────────────────────────────────────────────────────────────── + // This guard is the entire top-left-quadrant story. + // + // Real SDK could surface this as a registration-time declaration + // (`requiresMrtr: true`) so the check doesn't live in every handler + // — or even filter the tool out of `tools/list` for old clients, + // per gjz22's SEP-1442 tie-in. Either way, no SSE code path. + // ─────────────────────────────────────────────────────────────────── + if (!supportsMrtr()) { + return errorResult( + `This tool requires interactive input, which needs a client on protocol version ${MRTR_MIN_VERSION} or later.` + ); + } + + 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/dualRegistration.ts b/examples/server/src/mrtr-dual-path/dualRegistration.ts new file mode 100644 index 000000000..f1b1f0775 --- /dev/null +++ b/examples/server/src/mrtr-dual-path/dualRegistration.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/dualRegistration.ts + * DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/dualRegistration.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/explicitVersionBranch.ts b/examples/server/src/mrtr-dual-path/explicitVersionBranch.ts new file mode 100644 index 000000000..0389861e4 --- /dev/null +++ b/examples/server/src/mrtr-dual-path/explicitVersionBranch.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/explicitVersionBranch.ts + * DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/explicitVersionBranch.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/shimAwaitCanonical.ts b/examples/server/src/mrtr-dual-path/shimAwaitCanonical.ts new file mode 100644 index 000000000..3d0b93e05 --- /dev/null +++ b/examples/server/src/mrtr-dual-path/shimAwaitCanonical.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/shimAwaitCanonical.ts + * DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/shimAwaitCanonical.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/shimMrtrCanonical.ts b/examples/server/src/mrtr-dual-path/shimMrtrCanonical.ts new file mode 100644 index 000000000..4560f67e3 --- /dev/null +++ b/examples/server/src/mrtr-dual-path/shimMrtrCanonical.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/shimMrtrCanonical.ts + * DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/shimMrtrCanonical.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/shims.ts b/examples/server/src/mrtr-dual-path/shims.ts new file mode 100644 index 000000000..7db8425a3 --- /dev/null +++ b/examples/server/src/mrtr-dual-path/shims.ts @@ -0,0 +1,295 @@ +/** + * 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 shimMrtrCanonical.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 (if server infra holds SSE). + * + * 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 ?? {}; +} From f9fc4474a5449aeab6dc65d9670410e44d72346f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 18 Mar 2026 20:57:22 +0000 Subject: [PATCH 02/12] rename: optionX prefix for diff reading order --- examples/server/src/mrtr-dual-path/README.md | 18 +++++++++--------- ...anonical.ts => optionAShimMrtrCanonical.ts} | 4 ++-- ...nonical.ts => optionBShimAwaitCanonical.ts} | 4 ++-- ...anch.ts => optionCExplicitVersionBranch.ts} | 4 ++-- ...istration.ts => optionDDualRegistration.ts} | 4 ++-- .../{degradeOnly.ts => optionEDegradeOnly.ts} | 4 ++-- examples/server/src/mrtr-dual-path/shims.ts | 2 +- 7 files changed, 20 insertions(+), 20 deletions(-) rename examples/server/src/mrtr-dual-path/{shimMrtrCanonical.ts => optionAShimMrtrCanonical.ts} (98%) rename examples/server/src/mrtr-dual-path/{shimAwaitCanonical.ts => optionBShimAwaitCanonical.ts} (98%) rename examples/server/src/mrtr-dual-path/{explicitVersionBranch.ts => optionCExplicitVersionBranch.ts} (98%) rename examples/server/src/mrtr-dual-path/{dualRegistration.ts => optionDDualRegistration.ts} (98%) rename examples/server/src/mrtr-dual-path/{degradeOnly.ts => optionEDegradeOnly.ts} (98%) diff --git a/examples/server/src/mrtr-dual-path/README.md b/examples/server/src/mrtr-dual-path/README.md index 4cdd9fa37..098bb9643 100644 --- a/examples/server/src/mrtr-dual-path/README.md +++ b/examples/server/src/mrtr-dual-path/README.md @@ -16,13 +16,13 @@ Bottom-left is discounted: no amount of SDK work fills it when the server infra ## Options -| | Author writes | SDK does | Hidden re-entry | Old client gets | -| ------------------------------- | ------------------------------- | ------------------------------ | ------------------------------------------- | ------------------------------- | -| [A](./shimMrtrCanonical.ts) | MRTR-native only | Emulates retry loop over SSE | Yes, but safe (guard is explicit in source) | Full elicitation | -| [B](./shimAwaitCanonical.ts) | `await elicit()` only | Exception → `IncompleteResult` | Yes, **unsafe** (invisible in source) | Full elicitation | -| [C](./explicitVersionBranch.ts) | One handler, `if (mrtr)` branch | Version accessor | No | Full elicitation | -| [D](./dualRegistration.ts) | Two handlers | Picks by version | No | Full elicitation | -| [E](./degradeOnly.ts) | MRTR-native only | Nothing | No | Error ("requires newer client") | +| | Author writes | SDK does | Hidden re-entry | Old client gets | +| -------------------------------------- | ------------------------------- | ------------------------------ | ------------------------------------------- | ------------------------------- | +| [A](./optionAShimMrtrCanonical.ts) | MRTR-native only | Emulates retry loop over SSE | Yes, but safe (guard is explicit in source) | Full elicitation | +| [B](./optionBShimAwaitCanonical.ts) | `await elicit()` only | Exception → `IncompleteResult` | Yes, **unsafe** (invisible in source) | Full elicitation | +| [C](./optionCExplicitVersionBranch.ts) | One handler, `if (mrtr)` branch | Version accessor | No | Full elicitation | +| [D](./optionDDualRegistration.ts) | Two handlers | Picks by version | No | Full elicitation | +| [E](./optionEDegradeOnly.ts) | MRTR-native only | Nothing | No | Error ("requires newer client") | "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. @@ -45,8 +45,8 @@ the dual-path burden on the tool author rather than the SDK. All demos use `DEMO_PROTOCOL_VERSION` to simulate the negotiated version, since the real SDK doesn't surface it to handlers yet: ```sh -DEMO_PROTOCOL_VERSION=2025-11 pnpm tsx src/mrtr-dual-path/shimMrtrCanonical.ts -DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/shimMrtrCanonical.ts +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 ``` `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 `shims.ts:wrap()`. diff --git a/examples/server/src/mrtr-dual-path/shimMrtrCanonical.ts b/examples/server/src/mrtr-dual-path/optionAShimMrtrCanonical.ts similarity index 98% rename from examples/server/src/mrtr-dual-path/shimMrtrCanonical.ts rename to examples/server/src/mrtr-dual-path/optionAShimMrtrCanonical.ts index 4560f67e3..98f842ff8 100644 --- a/examples/server/src/mrtr-dual-path/shimMrtrCanonical.ts +++ b/examples/server/src/mrtr-dual-path/optionAShimMrtrCanonical.ts @@ -18,8 +18,8 @@ * 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/shimMrtrCanonical.ts - * DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/shimMrtrCanonical.ts + * 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'; diff --git a/examples/server/src/mrtr-dual-path/shimAwaitCanonical.ts b/examples/server/src/mrtr-dual-path/optionBShimAwaitCanonical.ts similarity index 98% rename from examples/server/src/mrtr-dual-path/shimAwaitCanonical.ts rename to examples/server/src/mrtr-dual-path/optionBShimAwaitCanonical.ts index 3d0b93e05..710fc9214 100644 --- a/examples/server/src/mrtr-dual-path/shimAwaitCanonical.ts +++ b/examples/server/src/mrtr-dual-path/optionBShimAwaitCanonical.ts @@ -20,8 +20,8 @@ * 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/shimAwaitCanonical.ts - * DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/shimAwaitCanonical.ts + * 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'; diff --git a/examples/server/src/mrtr-dual-path/explicitVersionBranch.ts b/examples/server/src/mrtr-dual-path/optionCExplicitVersionBranch.ts similarity index 98% rename from examples/server/src/mrtr-dual-path/explicitVersionBranch.ts rename to examples/server/src/mrtr-dual-path/optionCExplicitVersionBranch.ts index 0389861e4..014fd1754 100644 --- a/examples/server/src/mrtr-dual-path/explicitVersionBranch.ts +++ b/examples/server/src/mrtr-dual-path/optionCExplicitVersionBranch.ts @@ -18,8 +18,8 @@ * 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/explicitVersionBranch.ts - * DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/explicitVersionBranch.ts + * 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'; diff --git a/examples/server/src/mrtr-dual-path/dualRegistration.ts b/examples/server/src/mrtr-dual-path/optionDDualRegistration.ts similarity index 98% rename from examples/server/src/mrtr-dual-path/dualRegistration.ts rename to examples/server/src/mrtr-dual-path/optionDDualRegistration.ts index f1b1f0775..737ef0278 100644 --- a/examples/server/src/mrtr-dual-path/dualRegistration.ts +++ b/examples/server/src/mrtr-dual-path/optionDDualRegistration.ts @@ -20,8 +20,8 @@ * 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/dualRegistration.ts - * DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/dualRegistration.ts + * 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'; diff --git a/examples/server/src/mrtr-dual-path/degradeOnly.ts b/examples/server/src/mrtr-dual-path/optionEDegradeOnly.ts similarity index 98% rename from examples/server/src/mrtr-dual-path/degradeOnly.ts rename to examples/server/src/mrtr-dual-path/optionEDegradeOnly.ts index 098293ab4..864d8b83b 100644 --- a/examples/server/src/mrtr-dual-path/degradeOnly.ts +++ b/examples/server/src/mrtr-dual-path/optionEDegradeOnly.ts @@ -20,8 +20,8 @@ * identical. The difference only shows for the minority of tools that * actually elicit. * - * Run: DEMO_PROTOCOL_VERSION=2025-11 pnpm tsx src/mrtr-dual-path/degradeOnly.ts - * DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/degradeOnly.ts + * 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'; diff --git a/examples/server/src/mrtr-dual-path/shims.ts b/examples/server/src/mrtr-dual-path/shims.ts index 7db8425a3..b503ca58b 100644 --- a/examples/server/src/mrtr-dual-path/shims.ts +++ b/examples/server/src/mrtr-dual-path/shims.ts @@ -75,7 +75,7 @@ export const MRTR_MIN_VERSION: ProtocolVersion = '2026-06'; /** * Stand-in for `ctx.protocolVersion` or similar. * - * Drive with `DEMO_PROTOCOL_VERSION=2025-11 pnpm tsx shimMrtrCanonical.ts` to simulate + * 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 { From 8f52db69fd96130679b7274d120f5b945b288493 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 18 Mar 2026 21:42:12 +0000 Subject: [PATCH 03/12] doc: client-side invisibility + sseRetryShim infra constraint 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. --- examples/server/src/mrtr-dual-path/README.md | 11 +++++++++-- examples/server/src/mrtr-dual-path/shims.ts | 11 ++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/examples/server/src/mrtr-dual-path/README.md b/examples/server/src/mrtr-dual-path/README.md index 098bb9643..0d6648a0c 100644 --- a/examples/server/src/mrtr-dual-path/README.md +++ b/examples/server/src/mrtr-dual-path/README.md @@ -27,10 +27,17 @@ Bottom-left is discounted: no amount of SDK work fills it when the server infra "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. +## Client impact + +None. All five options present identical wire behaviour to each client version. A 2025-11 client sees either a standard `elicitation/create` over SSE (A/B/C/D) or a `CallToolResult` with `isError: true` (E) — both 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. + ## Trade-offs -**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. If elicitation-using tools are rare and old clients upgrade on -a reasonable timeline, E's cost (a few tools error for a few months) is lower than A's cost (permanent SDK machinery). +**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** is the zero-migration option. Every existing `await ctx.elicitInput()` handler keeps working. The hidden re-entry on MRTR sessions is the price: a handler that does anything non-idempotent above the await is broken, and nothing warns you. Only safe if you can enforce "no side effects before await" as a lint rule, which is hard in practice. diff --git a/examples/server/src/mrtr-dual-path/shims.ts b/examples/server/src/mrtr-dual-path/shims.ts index b503ca58b..e82fc2f34 100644 --- a/examples/server/src/mrtr-dual-path/shims.ts +++ b/examples/server/src/mrtr-dual-path/shims.ts @@ -113,7 +113,16 @@ export type MrtrHandler = (args: TArgs, mrtr: MrtrParams, ctx: ServerCont * * 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 (if server infra holds SSE). + * 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)` From c3ce96d441f7dd4bc0dd7cb96b25c9b9b2e41c8e Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 18 Mar 2026 21:59:02 +0000 Subject: [PATCH 04/12] add client-side demo: one handler serves both SSE push and MRTR retry 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. --- .../src/mrtr-dual-path/clientDualPath.ts | 175 ++++++++++++++++++ examples/server/src/mrtr-dual-path/README.md | 3 + 2 files changed, 178 insertions(+) create mode 100644 examples/client/src/mrtr-dual-path/clientDualPath.ts 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..e204b6384 --- /dev/null +++ b/examples/client/src/mrtr-dual-path/clientDualPath.ts @@ -0,0 +1,175 @@ +/** + * Client-side dual-path: new SDK, old server. + * + * This is the "new client → old server" direction (point 2 from the SEP-2322 + * thread). A client on the 2026-06 SDK connects to a 2025-11 server. Version + * negotiation settles on 2025-11. The server pushes elicitation over SSE the + * old way. The client needs to handle that — and also handle `IncompleteResult` + * when talking to new servers. + * + * Unlike the server side (options A–E in examples/server/src/mrtr-dual-path/), + * there's only one sensible approach here. The elicitation handler has the + * same signature either way — "given an elicitation request, produce a + * response" — so the SDK routes to one user-supplied function from both + * paths. No version check in app code, no dual registration, no shim footguns. + * + * What the SDK keeps: the existing `setRequestHandler('elicitation/create', ...)` + * plumbing. What the SDK adds: a retry loop in `callTool` that unwraps + * `IncompleteResult` and calls the same handler for each `InputRequest`. + * + * Run against any of the optionA–E servers (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 { CallToolResult, ElicitRequestFormParams, ElicitResult } from '@modelcontextprotocol/client'; +import { Client, getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/client'; + +// ─────────────────────────────────────────────────────────────────────────── +// Inlined MRTR type shims. See examples/server/src/mrtr-dual-path/shims.ts +// for the full set with commentary — only the three the client side touches +// are repeated here. +// ─────────────────────────────────────────────────────────────────────────── + +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 handler. This is the whole client-side story. +// +// Shape: `(params: ElicitRequestFormParams) => Promise`. +// Nothing about this signature cares whether the request arrived as an SSE +// push or inside an `IncompleteResult`. The SDK calls it from either path; +// the app code is identical. +// ─────────────────────────────────────────────────────────────────────────── + +async function handleElicitation(params: ElicitRequestFormParams): Promise { + // Real client: present `params.requestedSchema` as a form, collect user input. + // Demo: hardcode an answer so the weather tool completes. + console.error(`[elicit] server asks: ${params.message}`); + return { action: 'accept', content: { units: 'metric' } }; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Path 1 of 2: SSE push (old server, negotiated 2025-11). +// +// This is today's API, unchanged. The SDK receives `elicitation/create` as +// a JSON-RPC request on the SSE stream and invokes the registered handler. +// The new SDK keeps this registration — it's cheap to carry and it's what +// makes the upgrade non-breaking for the client → old server direction. +// ─────────────────────────────────────────────────────────────────────────── + +function registerSseElicitation(client: Client): void { + client.setRequestHandler('elicitation/create', async request => { + if (request.params.mode !== 'form') { + return { action: 'decline' }; + } + return handleElicitation(request.params); + }); +} + +// ─────────────────────────────────────────────────────────────────────────── +// Path 2 of 2: MRTR retry loop (new server, negotiated 2026-06). +// +// The SDK's `callTool` would do this internally. When the result is +// `IncompleteResult`, iterate `inputRequests`, call the SAME handler for +// each `ElicitRequest` inside, pack results into `inputResponses`, re-issue +// the tool call. Repeat until complete. +// +// Note where `handleElicitation` appears: same function, same call shape. +// The loop is SDK machinery; the app-supplied handler doesn't know which +// path it's serving. +// ─────────────────────────────────────────────────────────────────────────── + +async function callToolMrtr(client: Client, 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 ?? {})) { + // The same handler as the SSE path. No adapter, no version check. + responses[key] = { result: await handleElicitation(req.params) }; + } + + mrtr = { inputResponses: responses, requestState: incomplete.requestState }; + } + + throw new Error('MRTR retry loop exceeded round limit'); +} + +// Reverse of the server-side `wrap()` shim. Real SDK would parse +// `JSONRPCIncompleteResultResponse` at the protocol layer; this just +// 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; + } +} + +// ─────────────────────────────────────────────────────────────────────────── +// Caitie's point 2: MRTR-only mode. +// +// Cloud-hosted clients (claude.ai class) can't hold the SSE backchannel +// even today, so for them SSE elicitation was never available and MRTR is +// the first time it becomes possible. Those clients would skip +// `registerSseElicitation` entirely — the real SDK shape would be a +// constructor flag, something like: +// +// new Client({ name, version }, { capabilities: { elicitation: {} }, sseElicitation: false }) +// +// With that set, the SDK doesn't register the `elicitation/create` handler. +// An old server that tries to push one gets method-not-found. The MRTR +// retry loop still works. Tree-shaking drops the SSE listener code. +// ─────────────────────────────────────────────────────────────────────────── + +async function main(): Promise { + const client = new Client({ name: 'mrtr-dual-path-client', version: '0.0.0' }, { capabilities: { elicitation: {} } }); + + // Enables the SSE path. Comment this out for MRTR-only mode. + registerSseElicitation(client); + + 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); + + // One call site. Which path fires under the hood depends on the server: + // old server → SSE handler invoked mid-call; new server → MRTR retry loop + // runs. The app code here is identical either way. + const result = await callToolMrtr(client, 'weather', { location: 'Tokyo' }); + console.error('[result]', JSON.stringify(result.content, null, 2)); + + await client.close(); +} + +try { + await main(); +} catch (error) { + console.error(error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} diff --git a/examples/server/src/mrtr-dual-path/README.md b/examples/server/src/mrtr-dual-path/README.md index 0d6648a0c..6fe920fda 100644 --- a/examples/server/src/mrtr-dual-path/README.md +++ b/examples/server/src/mrtr-dual-path/README.md @@ -33,6 +33,9 @@ None. All five options present identical wire behaviour to each client version. 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/clientDualPath.ts`](../../../client/src/mrtr-dual-path/clientDualPath.ts). One user-supplied `handleElicitation` function serves both the SSE push path and the MRTR +retry loop; the SDK routes to it. No A/B/C/D/E split because there's only one sensible shape. + ## Trade-offs **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 From b299ae26194264696bbc59ff296769b9a6d0ad18 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 18 Mar 2026 21:59:58 +0000 Subject: [PATCH 05/12] readme: explicit navigation for the two compat directions --- examples/server/src/mrtr-dual-path/README.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/examples/server/src/mrtr-dual-path/README.md b/examples/server/src/mrtr-dual-path/README.md index 6fe920fda..ef48e952d 100644 --- a/examples/server/src/mrtr-dual-path/README.md +++ b/examples/server/src/mrtr-dual-path/README.md @@ -1,9 +1,17 @@ # MRTR dual-path options -Five approaches to the top-left quadrant of the SEP-2322 compatibility matrix: a server that **can** hold SSE, talking to a **2025-11** client, running **MRTR-era** tool code. +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. -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). All five files register the same -weather-lookup tool so the diff between files is the argument. +## What to look at + +| Direction | Where | How many options | +| --------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| **Old client → new server** | [`optionA`](./optionAShimMrtrCanonical.ts)–[`optionE`](./optionEDegradeOnly.ts) in this folder | Five — server handler shape is genuinely contested | +| **New client → old server** | [`clientDualPath.ts`](../../../client/src/mrtr-dual-path/clientDualPath.ts) | One — handler signature is identical on both paths, SDK just routes | + +The asymmetry is real: the server-side control flow changes between SSE-elicit (`await` inline) and MRTR (`return IncompleteResult`), so there are trade-offs to argue about. The client-side handler shape is the same either way (`(params) => Promise`), so there's +nothing to choose. ## The quadrant From 8f528ae56aa6f7a6bc6bd8c23efa2275be494740 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 18 Mar 2026 22:05:50 +0000 Subject: [PATCH 06/12] split client demo: app code vs SDK machinery 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. --- .../src/mrtr-dual-path/clientDualPath.ts | 179 +++--------------- examples/client/src/mrtr-dual-path/sdkLib.ts | 108 +++++++++++ 2 files changed, 138 insertions(+), 149 deletions(-) create mode 100644 examples/client/src/mrtr-dual-path/sdkLib.ts diff --git a/examples/client/src/mrtr-dual-path/clientDualPath.ts b/examples/client/src/mrtr-dual-path/clientDualPath.ts index e204b6384..6f4cf531e 100644 --- a/examples/client/src/mrtr-dual-path/clientDualPath.ts +++ b/examples/client/src/mrtr-dual-path/clientDualPath.ts @@ -1,175 +1,56 @@ /** * Client-side dual-path: new SDK, old server. * - * This is the "new client → old server" direction (point 2 from the SEP-2322 - * thread). A client on the 2026-06 SDK connects to a 2025-11 server. Version - * negotiation settles on 2025-11. The server pushes elicitation over SSE the - * old way. The client needs to handle that — and also handle `IncompleteResult` - * when talking to new servers. + * 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. * - * Unlike the server side (options A–E in examples/server/src/mrtr-dual-path/), - * there's only one sensible approach here. The elicitation handler has the - * same signature either way — "given an elicitation request, produce a - * response" — so the SDK routes to one user-supplied function from both - * paths. No version check in app code, no dual registration, no shim footguns. + * 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. * - * What the SDK keeps: the existing `setRequestHandler('elicitation/create', ...)` - * plumbing. What the SDK adds: a retry loop in `callTool` that unwraps - * `IncompleteResult` and calls the same handler for each `InputRequest`. - * - * Run against any of the optionA–E servers (cwd: examples/client): + * 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 { CallToolResult, ElicitRequestFormParams, ElicitResult } from '@modelcontextprotocol/client'; +import type { ElicitRequestFormParams, ElicitResult } from '@modelcontextprotocol/client'; import { Client, getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/client'; -// ─────────────────────────────────────────────────────────────────────────── -// Inlined MRTR type shims. See examples/server/src/mrtr-dual-path/shims.ts -// for the full set with commentary — only the three the client side touches -// are repeated here. -// ─────────────────────────────────────────────────────────────────────────── - -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; -} +import { withMrtr } from './sdkLib.js'; // ─────────────────────────────────────────────────────────────────────────── -// The ONE handler. This is the whole client-side story. -// -// Shape: `(params: ElicitRequestFormParams) => Promise`. -// Nothing about this signature cares whether the request arrived as an SSE -// push or inside an `IncompleteResult`. The SDK calls it from either path; -// the app code is identical. +// 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 { - // Real client: present `params.requestedSchema` as a form, collect user input. - // Demo: hardcode an answer so the weather tool completes. console.error(`[elicit] server asks: ${params.message}`); return { action: 'accept', content: { units: 'metric' } }; } // ─────────────────────────────────────────────────────────────────────────── -// Path 1 of 2: SSE push (old server, negotiated 2025-11). -// -// This is today's API, unchanged. The SDK receives `elicitation/create` as -// a JSON-RPC request on the SSE stream and invokes the registered handler. -// The new SDK keeps this registration — it's cheap to carry and it's what -// makes the upgrade non-breaking for the client → old server direction. -// ─────────────────────────────────────────────────────────────────────────── - -function registerSseElicitation(client: Client): void { - client.setRequestHandler('elicitation/create', async request => { - if (request.params.mode !== 'form') { - return { action: 'decline' }; - } - return handleElicitation(request.params); - }); -} - -// ─────────────────────────────────────────────────────────────────────────── -// Path 2 of 2: MRTR retry loop (new server, negotiated 2026-06). -// -// The SDK's `callTool` would do this internally. When the result is -// `IncompleteResult`, iterate `inputRequests`, call the SAME handler for -// each `ElicitRequest` inside, pack results into `inputResponses`, re-issue -// the tool call. Repeat until complete. -// -// Note where `handleElicitation` appears: same function, same call shape. -// The loop is SDK machinery; the app-supplied handler doesn't know which -// path it's serving. -// ─────────────────────────────────────────────────────────────────────────── - -async function callToolMrtr(client: Client, 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 client = new Client({ name: 'mrtr-dual-path-client', version: '0.0.0' }, { capabilities: { elicitation: {} } }); - const incomplete = unwrapIncomplete(result); - if (!incomplete) { - return result as CallToolResult; - } +// 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 responses: InputResponses = {}; - for (const [key, req] of Object.entries(incomplete.inputRequests ?? {})) { - // The same handler as the SSE path. No adapter, no version check. - responses[key] = { result: await handleElicitation(req.params) }; - } +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); - mrtr = { inputResponses: responses, requestState: incomplete.requestState }; - } +// 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)); - throw new Error('MRTR retry loop exceeded round limit'); -} - -// Reverse of the server-side `wrap()` shim. Real SDK would parse -// `JSONRPCIncompleteResultResponse` at the protocol layer; this just -// 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; - } -} - -// ─────────────────────────────────────────────────────────────────────────── -// Caitie's point 2: MRTR-only mode. -// -// Cloud-hosted clients (claude.ai class) can't hold the SSE backchannel -// even today, so for them SSE elicitation was never available and MRTR is -// the first time it becomes possible. Those clients would skip -// `registerSseElicitation` entirely — the real SDK shape would be a -// constructor flag, something like: -// -// new Client({ name, version }, { capabilities: { elicitation: {} }, sseElicitation: false }) -// -// With that set, the SDK doesn't register the `elicitation/create` handler. -// An old server that tries to push one gets method-not-found. The MRTR -// retry loop still works. Tree-shaking drops the SSE listener code. -// ─────────────────────────────────────────────────────────────────────────── - -async function main(): Promise { - const client = new Client({ name: 'mrtr-dual-path-client', version: '0.0.0' }, { capabilities: { elicitation: {} } }); - - // Enables the SSE path. Comment this out for MRTR-only mode. - registerSseElicitation(client); - - 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); - - // One call site. Which path fires under the hood depends on the server: - // old server → SSE handler invoked mid-call; new server → MRTR retry loop - // runs. The app code here is identical either way. - const result = await callToolMrtr(client, 'weather', { location: 'Tokyo' }); - console.error('[result]', JSON.stringify(result.content, null, 2)); - - await client.close(); -} - -try { - await main(); -} catch (error) { - console.error(error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} +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; + } +} From 1cb4abb69420a5721312ef58b03a39830efd84fa Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 18 Mar 2026 22:07:32 +0000 Subject: [PATCH 07/12] readme: reflect client app/SDK split --- examples/server/src/mrtr-dual-path/README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/examples/server/src/mrtr-dual-path/README.md b/examples/server/src/mrtr-dual-path/README.md index ef48e952d..0380ac03a 100644 --- a/examples/server/src/mrtr-dual-path/README.md +++ b/examples/server/src/mrtr-dual-path/README.md @@ -5,10 +5,10 @@ the diff between files is the argument. ## What to look at -| Direction | Where | How many options | -| --------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -| **Old client → new server** | [`optionA`](./optionAShimMrtrCanonical.ts)–[`optionE`](./optionEDegradeOnly.ts) in this folder | Five — server handler shape is genuinely contested | -| **New client → old server** | [`clientDualPath.ts`](../../../client/src/mrtr-dual-path/clientDualPath.ts) | One — handler signature is identical on both paths, SDK just routes | +| Direction | Where | How many options | +| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| **Old client → new server** | [`optionA`](./optionAShimMrtrCanonical.ts)–[`optionE`](./optionEDegradeOnly.ts) in this folder | Five — server handler shape is genuinely contested | +| **New client → old server** | [`clientDualPath.ts`](../../../client/src/mrtr-dual-path/clientDualPath.ts) (app, ~55 lines) + [`sdkLib.ts`](../../../client/src/mrtr-dual-path/sdkLib.ts) (SDK machinery) | One — handler signature is identical on both paths, SDK just routes | The asymmetry is real: the server-side control flow changes between SSE-elicit (`await` inline) and MRTR (`return IncompleteResult`), so there are trade-offs to argue about. The client-side handler shape is the same either way (`(params) => Promise`), so there's nothing to choose. @@ -41,8 +41,9 @@ None. All five options present identical wire behaviour to each client version. 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/clientDualPath.ts`](../../../client/src/mrtr-dual-path/clientDualPath.ts). One user-supplied `handleElicitation` function serves both the SSE push path and the MRTR -retry loop; the SDK routes to it. No A/B/C/D/E split because there's only one sensible shape. +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 From 981fde1f194a75b783969ec502345ed1f0180149 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 18 Mar 2026 22:13:48 +0000 Subject: [PATCH 08/12] move README to examples/ root (covers both client and server folders) --- .../README.md => README-mrtr-dual-path.md} | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) rename examples/{server/src/mrtr-dual-path/README.md => README-mrtr-dual-path.md} (62%) diff --git a/examples/server/src/mrtr-dual-path/README.md b/examples/README-mrtr-dual-path.md similarity index 62% rename from examples/server/src/mrtr-dual-path/README.md rename to examples/README-mrtr-dual-path.md index 0380ac03a..bf673c35e 100644 --- a/examples/server/src/mrtr-dual-path/README.md +++ b/examples/README-mrtr-dual-path.md @@ -5,10 +5,10 @@ the diff between files is the argument. ## What to look at -| Direction | Where | How many options | -| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -| **Old client → new server** | [`optionA`](./optionAShimMrtrCanonical.ts)–[`optionE`](./optionEDegradeOnly.ts) in this folder | Five — server handler shape is genuinely contested | -| **New client → old server** | [`clientDualPath.ts`](../../../client/src/mrtr-dual-path/clientDualPath.ts) (app, ~55 lines) + [`sdkLib.ts`](../../../client/src/mrtr-dual-path/sdkLib.ts) (SDK machinery) | One — handler signature is identical on both paths, SDK just routes | +| Direction | Where | How many options | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| **Old client → new server** | [`optionA`](./server/src/mrtr-dual-path/optionAShimMrtrCanonical.ts)–[`optionE`](./server/src/mrtr-dual-path/optionEDegradeOnly.ts) in `server/src/mrtr-dual-path/` | Five — server handler shape is genuinely contested | +| **New client → old server** | [`clientDualPath.ts`](./client/src/mrtr-dual-path/clientDualPath.ts) (app, ~55 lines) + [`sdkLib.ts`](./client/src/mrtr-dual-path/sdkLib.ts) (SDK machinery) | One — handler signature is identical on both paths, SDK just routes | The asymmetry is real: the server-side control flow changes between SSE-elicit (`await` inline) and MRTR (`return IncompleteResult`), so there are trade-offs to argue about. The client-side handler shape is the same either way (`(params) => Promise`), so there's nothing to choose. @@ -17,20 +17,20 @@ nothing to choose. | Server infra | 2025-11 client | 2026-06 client | | ------------ | ------------------------- | -------------- | -| Can hold SSE | **← this folder** | just use MRTR | +| Can hold SSE | **← options A–E** | just use MRTR | | MRTR-only | tool fails (unresolvable) | just use MRTR | Bottom-left is discounted: no amount of SDK work fills it when the server infra can't hold SSE. These demos are about whether the top-left is worth filling, and if so, how. ## Options -| | Author writes | SDK does | Hidden re-entry | Old client gets | -| -------------------------------------- | ------------------------------- | ------------------------------ | ------------------------------------------- | ------------------------------- | -| [A](./optionAShimMrtrCanonical.ts) | MRTR-native only | Emulates retry loop over SSE | Yes, but safe (guard is explicit in source) | Full elicitation | -| [B](./optionBShimAwaitCanonical.ts) | `await elicit()` only | Exception → `IncompleteResult` | Yes, **unsafe** (invisible in source) | Full elicitation | -| [C](./optionCExplicitVersionBranch.ts) | One handler, `if (mrtr)` branch | Version accessor | No | Full elicitation | -| [D](./optionDDualRegistration.ts) | Two handlers | Picks by version | No | Full elicitation | -| [E](./optionEDegradeOnly.ts) | MRTR-native only | Nothing | No | Error ("requires newer client") | +| | 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 | Error ("requires newer client") | "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. @@ -41,9 +41,8 @@ None. All five options present identical wire behaviour to each client version. 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. +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 @@ -61,14 +60,20 @@ the dual-path burden on the tool author rather than the SDK. ## Running -All demos use `DEMO_PROTOCOL_VERSION` to simulate the negotiated version, since the real SDK doesn't surface it to handlers yet: +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 ``` -`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 `shims.ts:wrap()`. +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 From 23ac4152c1d853ac89d0962ab8b9ce190c404960 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 18 Mar 2026 22:27:43 +0000 Subject: [PATCH 09/12] reframe: E is the SDK default, not just a comparison baseline 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. --- examples/README-mrtr-dual-path.md | 14 ++++++---- .../src/mrtr-dual-path/optionEDegradeOnly.ts | 26 +++++++++---------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/examples/README-mrtr-dual-path.md b/examples/README-mrtr-dual-path.md index bf673c35e..95789a17d 100644 --- a/examples/README-mrtr-dual-path.md +++ b/examples/README-mrtr-dual-path.md @@ -15,12 +15,13 @@ nothing to choose. ## The quadrant -| Server infra | 2025-11 client | 2026-06 client | -| ------------ | ------------------------- | -------------- | -| Can hold SSE | **← options A–E** | just use MRTR | -| MRTR-only | tool fails (unresolvable) | just use MRTR | +| 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 | -Bottom-left is discounted: no amount of SDK work fills it when the server infra can't hold SSE. These demos are about whether the top-left is worth filling, and if so, how. +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 @@ -46,6 +47,9 @@ writes (one `handleElicitation` function, one registration, one tool call); [`sd ## 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. diff --git a/examples/server/src/mrtr-dual-path/optionEDegradeOnly.ts b/examples/server/src/mrtr-dual-path/optionEDegradeOnly.ts index 864d8b83b..7e2fc2a3c 100644 --- a/examples/server/src/mrtr-dual-path/optionEDegradeOnly.ts +++ b/examples/server/src/mrtr-dual-path/optionEDegradeOnly.ts @@ -1,24 +1,24 @@ /** - * Option E: graceful degradation only. No SSE fallback. + * Option E: graceful degradation. The SDK default. * * Tool author writes MRTR-native code. Pre-MRTR clients get a tool-level - * error: "this tool requires a newer client." No shim, no dual path, no - * SSE infrastructure used even though it's available. + * 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 position staked in comment 4083481545: "I'd argue for graceful - * degradation instead." The server is perfectly 2025-11-compliant — it just - * happens not to use the client's declared `elicitation: {}` capability, - * which is something servers are already allowed to do. + * 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. * - * The cost is the obvious one: an old client that *could* have been served - * (server holds SSE, client declared elicitation) isn't. Whether that's - * acceptable is a product call, not an SDK one. For most tools — pure - * request/response, no elicitation — this option and all the others are - * identical. The difference only shows for the minority of tools that - * actually elicit. + * 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 From b1da71cfa18c3584d0e4f07bc3b4024fa0aae7e5 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 18 Mar 2026 22:30:02 +0000 Subject: [PATCH 10/12] option E: degrade doesn't have to mean error 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. --- examples/README-mrtr-dual-path.md | 20 +++++++------- .../src/mrtr-dual-path/optionEDegradeOnly.ts | 26 +++++++++++++------ 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/examples/README-mrtr-dual-path.md b/examples/README-mrtr-dual-path.md index 95789a17d..0ff50a3ca 100644 --- a/examples/README-mrtr-dual-path.md +++ b/examples/README-mrtr-dual-path.md @@ -25,22 +25,22 @@ you choose to carry SSE infra." The rows collapse for E, which is the argument f ## 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 | Error ("requires newer client") | +| | 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 | "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. ## Client impact -None. All five options present identical wire behaviour to each client version. A 2025-11 client sees either a standard `elicitation/create` over SSE (A/B/C/D) or a `CallToolResult` with `isError: true` (E) — both 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. +None. All five options present identical wire behaviour to each client version. 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. diff --git a/examples/server/src/mrtr-dual-path/optionEDegradeOnly.ts b/examples/server/src/mrtr-dual-path/optionEDegradeOnly.ts index 7e2fc2a3c..abc858ce6 100644 --- a/examples/server/src/mrtr-dual-path/optionEDegradeOnly.ts +++ b/examples/server/src/mrtr-dual-path/optionEDegradeOnly.ts @@ -47,18 +47,28 @@ server.registerTool( }, async ({ location, _mrtr }): Promise => { // ─────────────────────────────────────────────────────────────────── - // This guard is the entire top-left-quadrant story. + // Pre-MRTR session: elicitation unavailable. Tool author chooses + // what that means for *this* tool — not the SDK, not the spec. // - // Real SDK could surface this as a registration-time declaration - // (`requiresMrtr: true`) so the check doesn't live in every handler - // — or even filter the tool out of `tools/list` for old clients, - // per gjz22's SEP-1442 tie-in. Either way, no SSE code path. + // 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 errorResult( - `This tool requires interactive input, which needs a client on protocol version ${MRTR_MIN_VERSION} or later.` - ); + 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'); From ea46f4c619fdfdd0a97a0444b1a01d7a4a2b805e Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 20 Mar 2026 14:48:52 +0000 Subject: [PATCH 11/12] add options F (ctx.once) and G (ToolBuilder) for footgun prevention 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. --- examples/README-mrtr-dual-path.md | 40 +++- .../src/mrtr-dual-path/optionFCtxOnce.ts | 85 +++++++++ .../src/mrtr-dual-path/optionGToolBuilder.ts | 106 +++++++++++ examples/server/src/mrtr-dual-path/shims.ts | 172 ++++++++++++++++++ 4 files changed, 393 insertions(+), 10 deletions(-) create mode 100644 examples/server/src/mrtr-dual-path/optionFCtxOnce.ts create mode 100644 examples/server/src/mrtr-dual-path/optionGToolBuilder.ts diff --git a/examples/README-mrtr-dual-path.md b/examples/README-mrtr-dual-path.md index 0ff50a3ca..f75c6d60f 100644 --- a/examples/README-mrtr-dual-path.md +++ b/examples/README-mrtr-dual-path.md @@ -25,22 +25,39 @@ you choose to carry SSE infra." The rows collapse for E, which is the argument f ## 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 | +| | 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) | "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) + +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. + +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 five options present identical wire behaviour to each client version. 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. +None. All seven options present identical wire behaviour to each client version (F and G 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. @@ -62,6 +79,9 @@ 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`: 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/shims.ts b/examples/server/src/mrtr-dual-path/shims.ts index e82fc2f34..90dc30cb1 100644 --- a/examples/server/src/mrtr-dual-path/shims.ts +++ b/examples/server/src/mrtr-dual-path/shims.ts @@ -302,3 +302,175 @@ 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); + }; + } +} From e51495fe8498448a328a6cf67d83ac74c49aad53 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 20 Mar 2026 17:46:38 +0000 Subject: [PATCH 12/12] =?UTF-8?q?add=20Option=20H=20(ContinuationStore)=20?= =?UTF-8?q?=E2=80=94=20genuine=20linear=20await,=20no=20footgun?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- examples/README-mrtr-dual-path.md | 56 +++-- .../optionHContinuationStore.ts | 98 ++++++++ examples/server/src/mrtr-dual-path/shims.ts | 233 ++++++++++++++++++ 3 files changed, 368 insertions(+), 19 deletions(-) create mode 100644 examples/server/src/mrtr-dual-path/optionHContinuationStore.ts diff --git a/examples/README-mrtr-dual-path.md b/examples/README-mrtr-dual-path.md index f75c6d60f..e19a6a901 100644 --- a/examples/README-mrtr-dual-path.md +++ b/examples/README-mrtr-dual-path.md @@ -5,13 +5,23 @@ the diff between files is the argument. ## What to look at -| Direction | Where | How many options | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -| **Old client → new server** | [`optionA`](./server/src/mrtr-dual-path/optionAShimMrtrCanonical.ts)–[`optionE`](./server/src/mrtr-dual-path/optionEDegradeOnly.ts) in `server/src/mrtr-dual-path/` | Five — server handler shape is genuinely contested | -| **New client → old server** | [`clientDualPath.ts`](./client/src/mrtr-dual-path/clientDualPath.ts) (app, ~55 lines) + [`sdkLib.ts`](./client/src/mrtr-dual-path/sdkLib.ts) (SDK machinery) | One — handler signature is identical on both paths, SDK just routes | +| 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 | -The asymmetry is real: the server-side control flow changes between SSE-elicit (`await` inline) and MRTR (`return IncompleteResult`), so there are trade-offs to argue about. The client-side handler shape is the same either way (`(params) => Promise`), so there's -nothing to choose. +## 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 @@ -25,20 +35,21 @@ you choose to carry SSE infra." The rows collapse for E, which is the argument f ## 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) | +| | 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) +## 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 @@ -50,12 +61,16 @@ 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 seven options present identical wire behaviour to each client version (F and G 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 — +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. @@ -71,8 +86,11 @@ reframes A/C/D from "ways to fill the top-left" to "opt-in exceptions for server 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** is the zero-migration option. Every existing `await ctx.elicitInput()` handler keeps working. The hidden re-entry on MRTR sessions is the price: a handler that does anything non-idempotent above the await is broken, and nothing warns you. Only safe if you can enforce "no -side effects before await" as a lint rule, which is hard in practice. +**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. 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 index 90dc30cb1..c04f10d1e 100644 --- a/examples/server/src/mrtr-dual-path/shims.ts +++ b/examples/server/src/mrtr-dual-path/shims.ts @@ -474,3 +474,236 @@ export class ToolBuilder { }; } } + +// ─────────────────────────────────────────────────────────────────────────── +// 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; +}