From 4eb0f4a3d8df785f42bde93875556782a2e02bd7 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 27 Feb 2026 13:24:36 +0000 Subject: [PATCH 1/2] examples: add MRTR backwards-compat exploration demos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three side-by-side demo servers exploring the migration story for existing `await elicitInput()`-style handlers under the MRTR SEP (transports-wg#12), bucketed by migration cost: 1. Simple retry — idempotent; mechanical inversion to 'check inputResponses, else return IncompleteResult' 2. Continuation state — multi-step dialogue; handler becomes a re-entrant state machine threading requestState through the client 3. Persistent — mutation before elicitation; MRTR alone cannot rescue this, must migrate to Tasks workflow Each demo registers _before (today's pattern) and _after (MRTR pattern) so the diff is visible in one file. The SDK doesn't have the real types yet so shims.ts stubs IncompleteResult/InputRequests and carries the MRTR params via a reserved `arguments._mrtr` key as a transport stand-in. Relates to the backwards-compatibility discussion at https://github.com/modelcontextprotocol/transports-wg/pull/12 --- .../src/mrtr-backcompat/01SimpleRetry.ts | 153 +++++++++++ .../mrtr-backcompat/02ContinuationState.ts | 244 ++++++++++++++++++ .../src/mrtr-backcompat/03TasksMigration.ts | 234 +++++++++++++++++ examples/server/src/mrtr-backcompat/README.md | 58 +++++ examples/server/src/mrtr-backcompat/shims.ts | 203 +++++++++++++++ 5 files changed, 892 insertions(+) create mode 100644 examples/server/src/mrtr-backcompat/01SimpleRetry.ts create mode 100644 examples/server/src/mrtr-backcompat/02ContinuationState.ts create mode 100644 examples/server/src/mrtr-backcompat/03TasksMigration.ts create mode 100644 examples/server/src/mrtr-backcompat/README.md create mode 100644 examples/server/src/mrtr-backcompat/shims.ts diff --git a/examples/server/src/mrtr-backcompat/01SimpleRetry.ts b/examples/server/src/mrtr-backcompat/01SimpleRetry.ts new file mode 100644 index 000000000..b83b75bc1 --- /dev/null +++ b/examples/server/src/mrtr-backcompat/01SimpleRetry.ts @@ -0,0 +1,153 @@ +/** + * MRTR backwards-compatibility exploration — scenario 1 of 3. + * + * "Simple" case: the tool is idempotent. All work before and after the + * elicitation is pure / read-only, so if the client drops the connection + * and re-invokes the tool from scratch, no harm is done. The only cost of + * restarting is a little wasted compute. + * + * Migration verdict: trivial. The `await elicitInput(...)` call becomes a + * one-shot "do I already have the answer?" check at the top of the + * handler. No `requestState` is needed because the handler is cheap to + * re-enter — the arguments plus the single elicitation response are + * sufficient to compute the result. + * + * This demo registers two tools side-by-side on one server: + * - weather_before: today's `await ctx.mcpReq.elicitInput(...)` style + * - weather_after: MRTR style: return IncompleteResult, resume on retry + * + * Run with: pnpm tsx src/mrtr-backcompat/01SimpleRetry.ts + */ + +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { acceptedContent, elicitForm, readMrtr, wrap } from './shims.js'; + +// --------------------------------------------------------------------------- +// Simulated external lookup. Stateless, deterministic — restarting from +// scratch costs nothing but latency. +// --------------------------------------------------------------------------- + +type Units = 'metric' | 'imperial'; + +function lookupWeather(location: string, units: Units): string { + // In a real server this would be an HTTP GET to a weather API: no + // side-effects, safe to call again on retry. + const temp = units === 'metric' ? '22°C' : '72°F'; + return `Weather in ${location}: ${temp}, partly cloudy.`; +} + +// --------------------------------------------------------------------------- +// Server setup +// --------------------------------------------------------------------------- + +const server = new McpServer({ name: 'mrtr-01-simple-retry', version: '0.0.0' }); + +// ───────────────────────────────────────────────────────────────────────────── +// BEFORE: current SDK pattern. +// +// The handler awaits elicitInput mid-execution. Under the hood this issues +// an `elicitation/create` request on the POST SSE stream and blocks until +// the client delivers a matching response on a *separate* HTTP request. +// Works, but requires stateful routing (or shared storage) so that the +// elicitation response finds its way back to this in-flight await. +// ───────────────────────────────────────────────────────────────────────────── + +server.registerTool( + 'weather_before', + { + description: 'Weather lookup (pre-MRTR: inline await elicitInput)', + inputSchema: z.object({ + location: z.string().describe('City name') + }) + }, + async ({ location }, ctx): Promise => { + // The tool needs the user's unit preference. Today it asks inline: + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: 'Which units?', + requestedSchema: { + type: 'object', + properties: { + units: { type: 'string', enum: ['metric', 'imperial'], title: 'Units' } + }, + required: ['units'] + } + }); + + 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) }] }; + } +); + +// ───────────────────────────────────────────────────────────────────────────── +// AFTER: MRTR pattern. +// +// Structural change: the handler is re-entrant. Every invocation starts by +// checking whether the elicitation response is already present (carried on +// the retry via `inputResponses`). If not, it describes what it needs and +// returns — no await, no in-memory state, no SSE dependency. The *entire* +// handler can run on any server instance because it consumes only what's +// in the request. +// +// The migration for this class of tool is nearly mechanical: invert the +// control flow from "await answer" to "check for answer, else ask". +// ───────────────────────────────────────────────────────────────────────────── + +server.registerTool( + 'weather_after', + { + description: 'Weather lookup (MRTR: return IncompleteResult, resume on retry)', + inputSchema: z.object({ + location: z.string().describe('City name'), + // Stand-in for the SEP's `params.inputResponses` until the + // transport/SDK thread it through natively. Optional so the + // initial call looks identical to a normal tool invocation. + _mrtr: z.unknown().optional() + }) + }, + async ({ location, _mrtr }): Promise => { + const { inputResponses } = readMrtr({ _mrtr }); + + // 1. Check: did the client already answer "units"? + const prefs = acceptedContent<{ units: Units }>(inputResponses, 'units'); + if (!prefs) { + // 2. Not yet — describe what we need and return immediately. + // No server-side state is retained; the client will retry + // with `inputResponses.units` populated. + return wrap({ + inputRequests: { + units: elicitForm({ + message: 'Which units?', + requestedSchema: { + type: 'object', + properties: { + units: { type: 'string', enum: ['metric', 'imperial'], title: 'Units' } + }, + required: ['units'] + } + }) + } + // No requestState: `location` comes back on the retry as a + // regular argument, and the lookup is cheap/idempotent, so + // there's nothing worth carrying. + }); + } + + // 3. We have everything. Compute and return the real result. + return { content: [{ type: 'text', text: lookupWeather(location, prefs.units) }] }; + } +); + +// --------------------------------------------------------------------------- + +const transport = new StdioServerTransport(); +await server.connect(transport); +// stderr: stdout is reserved for the stdio transport's JSON-RPC frames. +console.error('[mrtr-01] ready (weather_before, weather_after)'); diff --git a/examples/server/src/mrtr-backcompat/02ContinuationState.ts b/examples/server/src/mrtr-backcompat/02ContinuationState.ts new file mode 100644 index 000000000..bb074e77f --- /dev/null +++ b/examples/server/src/mrtr-backcompat/02ContinuationState.ts @@ -0,0 +1,244 @@ +/** + * MRTR backwards-compatibility exploration — scenario 2 of 3. + * + * "Continuation state" case: the tool performs a multi-step conversation + * where each elicitation depends on the answers that came before. In the + * pre-MRTR world the handler's local variables hold that accumulated + * context across `await elicitInput()` calls. Under MRTR the handler + * must be re-entrant, so that context has to be serialised into + * `requestState` and threaded back through the client. + * + * Migration verdict: manageable but non-trivial. The handler becomes a + * small state machine: on each entry it decodes `requestState`, figures + * out which step it's on, and either asks the next question or completes. + * Nothing about the *business logic* changes, only where the intermediate + * state lives (serialised blob vs. local variables). + * + * The demo mirrors the ADO "custom rules" example from the SEP: resolving + * a work item triggers a cascade of conditionally-required fields. + * + * Run with: pnpm tsx src/mrtr-backcompat/02ContinuationState.ts + */ + +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import type { InputRequests } from './shims.js'; +import { acceptedContent, decodeState, elicitForm, encodeState, readMrtr, wrap } from './shims.js'; + +// --------------------------------------------------------------------------- +// Domain model — the simulated ADO rules from the SEP's real-world example. +// Rule 1: State→Resolved requires `resolution`. +// Rule 2: resolution=Duplicate requires `duplicateOfId`. +// --------------------------------------------------------------------------- + +type Resolution = 'Fixed' | "Won't Fix" | 'Duplicate' | 'By Design'; + +interface WorkItemUpdate { + workItemId: number; + newState: 'Resolved'; + resolution?: Resolution; + duplicateOfId?: number; +} + +function applyUpdate(u: Required> & Partial): string { + // In reality: a PATCH to the ADO REST API. Deferred until we have every + // required field, so it runs exactly once. + const extra = u.resolution === 'Duplicate' ? ` of #${u.duplicateOfId}` : ''; + return `Work item #${u.workItemId} → ${u.newState} (${u.resolution}${extra}).`; +} + +// --------------------------------------------------------------------------- +// Server setup +// --------------------------------------------------------------------------- + +const server = new McpServer({ name: 'mrtr-02-continuation-state', version: '0.0.0' }); + +// ───────────────────────────────────────────────────────────────────────────── +// BEFORE: current SDK pattern. +// +// Sequential awaits. Each answer lands in a local (`resolution`, +// `dupeId`), naturally carrying context into the next question. +// This is the ergonomic win of the current model — but it's exactly what +// makes the handler non-resumable across server instances. +// ───────────────────────────────────────────────────────────────────────────── + +server.registerTool( + 'resolve_work_item_before', + { + description: 'Resolve a work item (pre-MRTR: sequential await elicitInput)', + inputSchema: z.object({ + workItemId: z.number().int() + }) + }, + async ({ workItemId }, ctx): Promise => { + // Step 1 — rule 1 fires unconditionally on Resolve. + const r1 = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: `Resolving #${workItemId} requires a resolution. How was this bug resolved?`, + requestedSchema: { + type: 'object', + properties: { + resolution: { + type: 'string', + enum: ['Fixed', "Won't Fix", 'Duplicate', 'By Design'], + title: 'Resolution' + } + }, + required: ['resolution'] + } + }); + if (r1.action !== 'accept' || !r1.content) { + return { content: [{ type: 'text', text: 'Cancelled.' }] }; + } + const resolution = r1.content.resolution as Resolution; + + // Step 2 — rule 2 fires only if resolution was Duplicate. + // `resolution` is a local variable: free continuation state. + let duplicateOfId: number | undefined; + if (resolution === 'Duplicate') { + const r2 = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: 'Since this is a duplicate, which work item is the original?', + requestedSchema: { + type: 'object', + properties: { + duplicateOfId: { type: 'number', title: 'Duplicate of (work item ID)' } + }, + required: ['duplicateOfId'] + } + }); + if (r2.action !== 'accept' || !r2.content) { + return { content: [{ type: 'text', text: 'Cancelled.' }] }; + } + duplicateOfId = r2.content.duplicateOfId as number; + } + + return { + content: [{ type: 'text', text: applyUpdate({ workItemId, newState: 'Resolved', resolution, duplicateOfId }) }] + }; + } +); + +// ───────────────────────────────────────────────────────────────────────────── +// AFTER: MRTR pattern with requestState. +// +// The handler is a resumable state machine. Each entry: +// 1. Decodes `requestState` (if any) to recover prior answers. +// 2. Merges in any new `inputResponses` from this round-trip. +// 3. Decides: can we complete? If not, what's the *next* thing to ask? +// 4. Encodes the merged state back into `requestState` for the next round. +// +// Note the trade-off vs. scenario 1: we *could* skip requestState and +// simply re-ask for resolution every time (it's not expensive for the +// server). But that means re-prompting the user, which is bad UX. The +// requestState mechanism exists precisely so the client doesn't have to +// re-answer things it already answered. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Continuation state shape. Keep this small — everything here round-trips + * through the client, so size matters for latency. + * + * In production this would be signed/encrypted (see shims.ts). + */ +interface Continuation { + resolution?: Resolution; + // We don't store duplicateOfId here because as soon as we have it + // (when needed) we can complete — there's no step after it. +} + +server.registerTool( + 'resolve_work_item_after', + { + description: 'Resolve a work item (MRTR: re-entrant with requestState)', + inputSchema: z.object({ + workItemId: z.number().int(), + _mrtr: z.unknown().optional() + }) + }, + async ({ workItemId, _mrtr }): Promise => { + const { inputResponses, requestState } = readMrtr({ _mrtr }); + + // (1) Recover prior state. Start from scratch if absent/invalid — + // the worst case is we ask the user again, which is the SEP's + // prescribed recovery for malformed state anyway. + const prior = decodeState(requestState) ?? {}; + + // (2) Merge new answers. The client only ever sends the responses + // for *this* round's inputRequests, so at most one of these + // will be present per invocation. + const resolutionAnswer = acceptedContent<{ resolution: Resolution }>(inputResponses, 'resolution'); + const resolution = prior.resolution ?? resolutionAnswer?.resolution; + + // (3) State machine. + + // Step 1: no resolution yet → ask for it. + if (!resolution) { + return wrap({ + inputRequests: { + resolution: elicitForm({ + message: `Resolving #${workItemId} requires a resolution. How was this bug resolved?`, + requestedSchema: { + type: 'object', + properties: { + resolution: { + type: 'string', + enum: ['Fixed', "Won't Fix", 'Duplicate', 'By Design'], + title: 'Resolution' + } + }, + required: ['resolution'] + } + }) + } + // No requestState needed yet: the only thing we know is + // `workItemId`, and that's already a tool argument. + }); + } + + // Step 2: resolution=Duplicate and we still need duplicateOfId. + if (resolution === 'Duplicate') { + const dupAnswer = acceptedContent<{ duplicateOfId: number }>(inputResponses, 'duplicateOf'); + if (!dupAnswer) { + const next: Continuation = { resolution }; + const inputRequests: InputRequests = { + duplicateOf: elicitForm({ + message: 'Since this is a duplicate, which work item is the original?', + requestedSchema: { + type: 'object', + properties: { + duplicateOfId: { type: 'number', title: 'Duplicate of (work item ID)' } + }, + required: ['duplicateOfId'] + } + }) + }; + // Encode `resolution` into requestState so that whichever + // server instance handles the retry doesn't have to ask + // for it again. This is the crux of scenario 2. + return wrap({ inputRequests, requestState: encodeState(next) }); + } + return { + content: [ + { + type: 'text', + text: applyUpdate({ workItemId, newState: 'Resolved', resolution, duplicateOfId: dupAnswer.duplicateOfId }) + } + ] + }; + } + + // Step 2': resolution ≠ Duplicate → no further questions, complete now. + return { + content: [{ type: 'text', text: applyUpdate({ workItemId, newState: 'Resolved', resolution }) }] + }; + } +); + +// --------------------------------------------------------------------------- + +const transport = new StdioServerTransport(); +await server.connect(transport); +console.error('[mrtr-02] ready (resolve_work_item_before, resolve_work_item_after)'); diff --git a/examples/server/src/mrtr-backcompat/03TasksMigration.ts b/examples/server/src/mrtr-backcompat/03TasksMigration.ts new file mode 100644 index 000000000..3cccde845 --- /dev/null +++ b/examples/server/src/mrtr-backcompat/03TasksMigration.ts @@ -0,0 +1,234 @@ +/** + * MRTR backwards-compatibility exploration — scenario 3 of 3. + * + * "Persistent" case: the tool performs a mutation *before* the elicitation. + * If the client retries the entire tool call from scratch (as MRTR requires), + * that mutation happens twice. This is the class of handler that the + * ephemeral MRTR workflow **cannot** rescue on its own. + * + * Migration verdict: these tools should move to the Tasks workflow. The + * mutation becomes the "create task" step (happens exactly once, gets a + * durable ID), the elicitation is expressed via `status: input_required` + * on that task, and the final step runs when the client delivers the + * response via `tasks/input_response`. + * + * This demo registers the unsafe-to-retry "before" tool so the hazard is + * visible at runtime. The "after" side is a **sketch** of the Tasks shape + * — see `simpleTaskInteractive.ts` in this same folder for a fully wired + * Tasks server. Duplicating that plumbing here would obscure the + * comparison rather than clarify it. + * + * Run with: pnpm tsx src/mrtr-backcompat/03TasksMigration.ts + */ + +import { randomUUID } from 'node:crypto'; + +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +// --------------------------------------------------------------------------- +// Simulated provisioning backend. +// +// `createVm()` is the kind of expensive, externally-visible side effect +// (billing, quota, audit trail) that makes naive retry unacceptable. +// --------------------------------------------------------------------------- + +interface Vm { + id: string; + name: string; + attached: boolean; +} + +const provisioned: Vm[] = []; + +function createVm(name: string): Vm { + const vm: Vm = { id: `vm-${randomUUID().slice(0, 8)}`, name, attached: false }; + provisioned.push(vm); + console.error(`[backend] provisioned ${vm.id} (total now: ${provisioned.length})`); + return vm; +} + +function attachDisk(vmId: string): void { + const vm = provisioned.find(v => v.id === vmId); + if (vm) vm.attached = true; +} + +// --------------------------------------------------------------------------- +// Server setup +// --------------------------------------------------------------------------- + +const server = new McpServer({ name: 'mrtr-03-tasks-migration', version: '0.0.0' }); + +// ───────────────────────────────────────────────────────────────────────────── +// BEFORE: current SDK pattern — and why it's unsafe under MRTR. +// +// mutation → await elicitInput → mutation. Under today's protocol the +// handler's async frame stays alive across the elicitation round-trip, so +// `createVm()` runs exactly once. Under MRTR the client would re-invoke +// the *entire* handler on retry, and the second invocation would call +// `createVm()` again → two VMs, one orphaned. +// +// You cannot fix this with requestState alone: by the time you're asked +// to return an IncompleteResult, the VM already exists. Encoding its ID +// into requestState helps the *retry* skip the create, but does nothing +// if the client abandons the flow — the orphan is still there. +// ───────────────────────────────────────────────────────────────────────────── + +server.registerTool( + 'provision_vm_before', + { + description: 'Provision a VM (pre-MRTR: UNSAFE to naively convert to MRTR)', + inputSchema: z.object({ + name: z.string() + }) + }, + async ({ name }, ctx): Promise => { + // Mutation BEFORE elicitation. This is the problematic pattern. + const vm = createVm(name); + + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: `Attach a persistent disk to ${vm.id}?`, + requestedSchema: { + type: 'object', + properties: { attach: { type: 'boolean', title: 'Attach disk' } }, + required: ['attach'] + } + }); + + if (result.action === 'accept' && result.content?.attach === true) { + attachDisk(vm.id); + } + + return { + content: [{ type: 'text', text: `Ready: ${vm.id} (disk ${vm.attached ? 'attached' : 'detached'})` }] + }; + } +); + +// ───────────────────────────────────────────────────────────────────────────── +// AFTER: Tasks-workflow shape (sketch). +// +// The SEP prescribes Tasks for persistent tools: the task ID *is* the +// handle to the already-performed mutation. The client can poll, cancel, +// or let the task time out — none of which the ephemeral MRTR flow can +// model. The key structural shift: +// +// - createTask: perform the mutation ONCE, persist the task, return ID. +// - getTask: report `input_required` when waiting on the client. +// - tasks/result: surface the InputRequests payload. +// - tasks/input_response: accept the InputResponses, resume work. +// - getTaskResult: return the final CallToolResult once `completed`. +// +// The sketch below shows the handler shape against the SDK's experimental +// `ToolTaskHandler`. It uses an in-process store and does NOT implement +// the `tasks/input_response` plumbing — that requires transport-level +// changes the SEP introduces. See `../simpleTaskInteractive.ts` for the +// full message-queue wiring today's SDK needs. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Per-task state the handler persists. In production this lives in the + * real TaskStore (Redis, Postgres, etc.) so any server instance can + * handle any phase. + */ +interface ProvisionTaskRecord { + status: 'working' | 'input_required' | 'completed'; + vmId: string; + attachDecision?: boolean; +} + +// Demo-only in-memory store. Real deployments back this with durable +// storage — which these tools already need for the VM lifecycle anyway, +// so the incremental cost of storing task state is low. +const taskRecords = new Map(); + +// Reference the map so the example typechecks/lints as a runnable demo +// even though the sketch isn't wired into the transport. +void taskRecords; + +/** + * Sketch of the Tasks handler shape. Exported for reference from the + * README; not registered on this demo server because the SDK's current + * Tasks wiring (message queue + result handler) would triple the file + * length without adding clarity to the comparison. + */ +export const provisionVmTaskHandlerSketch = { + /** + * Phase 1 — initial tools/call. + * + * Crucially: this runs ONCE. It allocates the VM, persists a task + * record pointing at it, and returns the task envelope. The client + * now holds a durable handle and will drive the rest via `tasks/*`. + */ + createTask(args: { name: string }): { taskId: string } { + const vm = createVm(args.name); + const taskId = `task-${vm.id}`; + taskRecords.set(taskId, { status: 'input_required', vmId: vm.id }); + // The real `ctx.task.store.createTask()` returns a full + // CreateTaskResult (ttl, pollInterval, etc.); abbreviated here. + return { taskId }; + }, + + /** + * Phase 2 — tasks/get. + * + * Reports status. When `input_required`, the client knows to call + * `tasks/result` to fetch the InputRequests payload. + */ + getTask(taskId: string): { status: ProvisionTaskRecord['status']; statusMessage: string } { + const rec = taskRecords.get(taskId); + if (!rec) throw new Error(`unknown task: ${taskId}`); + return { + status: rec.status, + statusMessage: + rec.status === 'input_required' ? 'Waiting for disk-attachment decision (call tasks/result)' : `Task ${rec.status}.` + }; + }, + + /** + * Phase 2.5 — tasks/result while status=input_required. + * + * This is the SEP's integration point: instead of the CallToolResult, + * the server returns an InputRequests payload (plus related-task + * metadata). Shown as pseudo-JSON because the wire types don't exist + * in the SDK yet. + */ + // pseudo: + // getTaskResult(taskId) when input_required --> + // { inputRequests: { attach: elicitForm({...}) }, + // _meta: { [RELATED_TASK_META_KEY]: { taskId } } } + + /** + * Phase 3 — tasks/input_response (new method from the SEP). + * + * Client delivers InputResponses + task metadata; server updates the + * record and flips status back to `working`/`completed`. + */ + provideInputResponse(taskId: string, attach: boolean): void { + const rec = taskRecords.get(taskId); + if (!rec) throw new Error(`unknown task: ${taskId}`); + rec.attachDecision = attach; + if (attach) attachDisk(rec.vmId); + rec.status = 'completed'; + }, + + /** + * Phase 4 — tasks/result while status=completed. + */ + getTaskResult(taskId: string): CallToolResult { + const rec = taskRecords.get(taskId); + if (!rec || rec.status !== 'completed') throw new Error(`task ${taskId} not complete`); + const vm = provisioned.find(v => v.id === rec.vmId); + return { + content: [{ type: 'text', text: `Ready: ${rec.vmId} (disk ${vm?.attached ? 'attached' : 'detached'})` }] + }; + } +}; + +// --------------------------------------------------------------------------- + +const transport = new StdioServerTransport(); +await server.connect(transport); +console.error('[mrtr-03] ready (provision_vm_before; see sketch for after-shape)'); diff --git a/examples/server/src/mrtr-backcompat/README.md b/examples/server/src/mrtr-backcompat/README.md new file mode 100644 index 000000000..575b810ca --- /dev/null +++ b/examples/server/src/mrtr-backcompat/README.md @@ -0,0 +1,58 @@ +# MRTR Backwards-Compatibility Exploration + +**SEP:** [Multi Round-Trip Requests — transports-wg#12](https://github.com/modelcontextprotocol/transports-wg/pull/12) **Discussion anchor:** the ["Backward Compatibility" section](https://github.com/modelcontextprotocol/transports-wg/pull/12/files#diff-) and review thread at +line ~1160 ("this ideally is doing a lot of heavy lifting"). + +## What this folder is + +Three side-by-side demos that show how an existing `await elicitInput(...)`-style tool handler migrates under MRTR, bucketed by how painful the migration is: + +| # | Scenario | Hazard of naive retry | Migration cost | requestState? | Tasks? | +| --- | -------------------------------------------- | ------------------------------------------ | ---------------------------------- | ------------- | ------ | +| 1 | **Simple retry** — idempotent lookup | Wasted compute only | Trivial (mechanical inversion) | No | No | +| 2 | **Continuation state** — multi-step dialogue | User re-prompted for answers already given | Moderate (handler → state machine) | Yes | No | +| 3 | **Persistent** — mutation before elicitation | Duplicate side-effects (e.g. two VMs) | High (migrate to Tasks workflow) | N/A | Yes | + +Each demo registers a `_before` tool (current SDK pattern) and a `_after` tool (MRTR pattern, or a sketch thereof for #3), so you can diff the handler bodies directly. + +## Files + +| File | What it shows | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `shims.ts` | Local stand-ins for `IncompleteResult`, `InputRequests`/`InputResponses`, and helpers. Since the SDK doesn't thread `inputResponses`/`requestState` through yet, MRTR params ride on `arguments._mrtr` and `IncompleteResult` is smuggled out as a marked JSON text block. | +| `01SimpleRetry.ts` | Weather lookup. One elicitation, no state worth carrying. Migration is "check for answer, else ask" — essentially mechanical. | +| `02ContinuationState.ts` | ADO-style conditional-field cascade (mirrors the SEP's real-world example). The "after" handler decodes `requestState`, merges new `inputResponses`, and decides whether to ask the next question or complete. | +| `03TasksMigration.ts` | VM provisioning — a mutation runs _before_ the elicitation. Demonstrates why MRTR's ephemeral workflow can't save this class of handler and sketches the Tasks shape it should move to. See `../simpleTaskInteractive.ts` for the full Tasks wiring. | + +## The three-tier classification, as a decision tree + +``` +Does the handler mutate external state before the first elicitation? +│ +├─ Yes → Scenario 3: migrate to Tasks (input_required → tasks/input_response) +│ +└─ No → Does the handler ask >1 question where later questions depend on earlier answers? + │ + ├─ Yes → Scenario 2: ephemeral MRTR with requestState + │ + └─ No → Scenario 1: ephemeral MRTR, no requestState needed +``` + +In practice most existing tools fall into bucket 1. Bucket 2 covers wizard-style flows. Bucket 3 is rare but is the case where a naive "just retry" story actively breaks things — and the SDK-level backcompat shim (if we build one) must detect and reject or redirect these. + +## Running the demos + +```sh +# From repo root +pnpm tsx examples/server/src/mrtr-backcompat/01SimpleRetry.ts +pnpm tsx examples/server/src/mrtr-backcompat/02ContinuationState.ts +pnpm tsx examples/server/src/mrtr-backcompat/03TasksMigration.ts +``` + +All three use stdio transport so they're easy to drive from the Inspector. The `_after` tools expect MRTR params under `arguments._mrtr = { inputResponses, requestState }` as a transport stand-in. + +## What this exploration does NOT do + +- **No transport changes.** `IncompleteResult` is encoded as a JSON text payload, not a real `JSONRPCIncompleteResultResponse`. +- **No SDK-level backcompat shim.** The "after" handlers are hand-written. A follow-up would be: can the SDK wrap a legacy `await elicitInput()` handler so it behaves like bucket-1-or-2 automatically? That's the real question behind the review-thread concern. +- **No paired client.** You can craft the `_mrtr` argument manually in Inspector. A tiny demo client that auto-retries on the `__mrtrIncomplete` marker would be a nice next step. diff --git a/examples/server/src/mrtr-backcompat/shims.ts b/examples/server/src/mrtr-backcompat/shims.ts new file mode 100644 index 000000000..1f40524ea --- /dev/null +++ b/examples/server/src/mrtr-backcompat/shims.ts @@ -0,0 +1,203 @@ +/** + * MRTR (Multi Round-Trip Request) type shims. + * + * These types approximate the shapes proposed in the SEP + * (https://github.com/modelcontextprotocol/transports-wg/pull/12) and exist + * solely to support the backwards-compatibility exploration in this folder. + * They are NOT part of the SDK API and will be replaced once the SEP lands + * and the real schema/types are generated. + * + * The core idea: instead of `await server.elicitInput(...)` inside a tool + * handler (which requires holding an SSE stream open across a server→client + * round-trip), the handler returns an `IncompleteResult` describing what + * input it needs. The client resolves those inputs and retries the entire + * tool call with `inputResponses` (and optionally `requestState`) attached. + * Any server instance can then process the retry without shared storage or + * sticky routing. + */ + +import type { CallToolResult, ElicitRequestFormParams, ElicitResult } from '@modelcontextprotocol/server'; + +/** + * A server-initiated request that would previously have been sent as a + * separate JSON-RPC request on the POST SSE stream. The SEP lists + * elicitation and sampling as the primary cases; these demos only need + * form elicitation, so we keep the union narrow. + * + * Note: in the real schema this will NOT include the JSON-RPC `id` field + * (the SEP has a TODO to define `ServerInputRequest` for exactly this + * reason). Here we reuse the existing params types directly. + */ +export type InputRequest = { + method: 'elicitation/create'; + params: ElicitRequestFormParams; +}; + +/** + * Map of server-chosen keys → input requests. + * + * Keys are arbitrary strings chosen by the server. The client echoes each + * key back in `inputResponses` with the matching result. Using a map (rather + * than an array) structurally guarantees key uniqueness. + */ +export type InputRequests = { [key: string]: InputRequest }; + +/** + * Map of server-chosen keys → client responses. + * + * For each key the server issued in `inputRequests`, the client returns a + * response under the same key. We only model elicitation results here. + */ +export type InputResponses = { [key: string]: { result: ElicitResult } }; + +/** + * The result a tool returns when it cannot complete yet. + * + * At least one of `inputRequests` or `requestState` must be present; + * a result with neither would be indistinguishable from an empty + * `CallToolResult`. + */ +export interface IncompleteResult { + /** + * Requests the client must resolve before retrying. + * When absent the client MAY retry immediately (e.g. load-shedding). + */ + inputRequests?: InputRequests; + + /** + * Opaque blob the server wants echoed back on retry. + * + * The server encodes whatever continuation state it needs here so that + * any instance handling the retry can resume without re-doing work. + * Real implementations SHOULD sign/encrypt this and bind it to the + * authenticated user; the demos use plain base64-JSON for clarity. + */ + requestState?: string; +} + +/** + * Extra parameters the MRTR workflow attaches to a retried tool call. + * + * Today these would live under `params` alongside `name` and `arguments` + * (see the SEP's `RetryAugmentedRequestParams`). Since the SDK doesn't + * thread them through yet, the demos read them from `arguments._mrtr` + * as a stand-in — see `readMrtr()` below. + */ +export interface MrtrParams { + inputResponses?: InputResponses; + requestState?: string; +} + +/** + * The handler's return type under MRTR: either the real result, or an + * indication that another round-trip is needed. + * + * Ideally the SDK would accept this union directly from `registerTool` + * callbacks and translate `IncompleteResult` into the wire-level + * `JSONRPCIncompleteResultResponse`. For now callers use `wrap()` to + * smuggle it through as a `CallToolResult`. + */ +export type MrtrToolResult = CallToolResult | IncompleteResult; + +/** + * Pull `_mrtr` out of the tool arguments. + * + * This is the stand-in for `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState` + * that a real SDK integration would surface. Passing MRTR params via a + * reserved argument key keeps the demos runnable against the current SDK + * without transport changes, and keeps the "before" and "after" tools + * side-by-side on one server. + */ +export function readMrtr(args: Record | undefined): MrtrParams { + const raw = (args as { _mrtr?: MrtrParams } | undefined)?._mrtr; + return raw ?? {}; +} + +/** + * Type guard: does this look like an IncompleteResult? + * + * The presence of either `inputRequests` or `requestState` is the + * discriminator the SEP proposes; `content` is absent on incomplete results. + */ +export function isIncomplete(r: MrtrToolResult): r is IncompleteResult { + return ('inputRequests' in r && r.inputRequests !== undefined) || ('requestState' in r && r.requestState !== undefined); +} + +/** + * Helper: build a form-elicitation InputRequest. + * + * Purely sugar to keep the demo callsites tidy; the object shape is + * identical to what `server.elicitInput({ mode: 'form', ... })` takes today. + */ +export function elicitForm(params: Omit): InputRequest { + return { method: 'elicitation/create', params: { mode: 'form', ...params } }; +} + +/** + * Read a typed, accepted elicitation response. + * + * Returns `undefined` when the key is missing, the user declined/cancelled, + * or no content was supplied. Callers treat `undefined` as "not yet provided" + * and issue the corresponding `InputRequest` again. + */ +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; +} + +/** + * Encode request state as base64 JSON. + * + * **Demo-only.** Real servers MUST sign and/or encrypt this blob (the SEP + * recommends AES-GCM or a signed JWT) because the client is an untrusted + * intermediary. State containing per-user data MUST be cryptographically + * bound to the authenticated user to prevent replay/hijacking. + */ +export function encodeState(state: unknown): string { + return Buffer.from(JSON.stringify(state), 'utf8').toString('base64'); +} + +/** + * Decode request state previously produced by `encodeState`. + * + * Returns `undefined` for malformed input rather than throwing, because + * the appropriate recovery per the SEP is simply to re-request the + * missing information (not to fail the tool call). + */ +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 { + // Invalid/tampered state: behave as if none was provided and + // let the handler re-request what it needs. + return undefined; + } +} + +/** + * Wrap an MRTR-aware handler result into something the current `registerTool` + * callback signature accepts. + * + * The SDK doesn't yet know how to serialise `IncompleteResult`, so for the + * demos we emit it as a `CallToolResult` with a JSON text payload that the + * paired demo client knows how to interpret. This is strictly a shim for + * exploration — the real implementation would emit a + * `JSONRPCIncompleteResultResponse` at the protocol layer. + */ +export function wrap(result: MrtrToolResult): CallToolResult { + if (!isIncomplete(result)) return result; + return { + content: [ + { + type: 'text', + // Non-standard marker so the demo client can unwrap. + // Real transport emits a dedicated response type instead. + text: JSON.stringify({ __mrtrIncomplete: true, ...result }) + } + ] + }; +} From 0ad41633e4e1483f547cd7b7d9e226fd15c4318f Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 27 Feb 2026 13:31:47 +0000 Subject: [PATCH 2/2] examples(mrtr): add flight-booking wizard showing linear requestState growth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to 02ContinuationState.ts. Where 02 shows conditional branching (second question depends on first answer), 04 shows linear accumulation: a fixed three-step wizard where requestState grows monotonically each round (route → route+dates → full itinerary). This is the more common shape of multi-elicitation tool in practice (booking flows, setup wizards, onboarding), and the migration is correspondingly simpler — a flat chain of 'have X yet? ask for X' checks with no branching logic. Also highlights that prompts referencing prior answers (e.g. 'dates for LHR → SFO?') work identically under MRTR: the data just comes from decoded requestState instead of closure variables. --- .../src/mrtr-backcompat/04FlightBooking.ts | 290 ++++++++++++++++++ examples/server/src/mrtr-backcompat/README.md | 9 +- 2 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 examples/server/src/mrtr-backcompat/04FlightBooking.ts diff --git a/examples/server/src/mrtr-backcompat/04FlightBooking.ts b/examples/server/src/mrtr-backcompat/04FlightBooking.ts new file mode 100644 index 000000000..10ecd34c4 --- /dev/null +++ b/examples/server/src/mrtr-backcompat/04FlightBooking.ts @@ -0,0 +1,290 @@ +/** + * MRTR backwards-compatibility exploration — scenario 2b (linear wizard). + * + * A companion to `02ContinuationState.ts`. Where 02 demonstrates + * *conditional* branching (the second question only exists if the first + * answer was "Duplicate"), this demo shows *linear accumulation*: a + * fixed-length wizard where each step adds to a growing bundle of + * answers and the final step consumes the whole bundle. + * + * This is the most common shape of multi-elicitation tool in the wild — + * booking wizards, setup flows, onboarding. The `requestState` story + * here is dead simple: stuff every answer in, pass it back, read it + * out on the next round. + * + * Worth noticing in the "after" handler: + * + * - The state blob *grows* across rounds (route → route+dates → + * complete). This is the SEP's intended use: each IncompleteResult + * re-encodes everything gathered so far, so the retry is + * self-contained. + * + * - Elicitation prompts can reference prior answers ("dates for + * LHR → SFO?"). In the "before" world those come from closure + * variables; under MRTR they're decoded from requestState. Same + * UX, different source. + * + * - No branching → the state-machine logic is a flat sequence of + * "missing X? ask for X" checks. Much simpler than 02's + * conditional cascade. Most real-world migrations will look more + * like this file than like 02. + * + * Run with: pnpm tsx src/mrtr-backcompat/04FlightBooking.ts + */ + +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { acceptedContent, decodeState, elicitForm, encodeState, readMrtr, wrap } from './shims.js'; + +// --------------------------------------------------------------------------- +// Domain: a toy booking backend. Stateless lookups only — the actual +// "confirm booking" mutation is deferred until we have every answer, so +// it runs exactly once (no scenario-3 problems here). +// --------------------------------------------------------------------------- + +interface Itinerary { + from: string; + to: string; + depart: string; + ret?: string; + pax: number; + cabin: 'economy' | 'premium' | 'business'; +} + +function quote(itin: Itinerary): number { + const base = { economy: 120, premium: 340, business: 900 }[itin.cabin]; + const oneWay = !itin.ret; + return base * itin.pax * (oneWay ? 1 : 2); +} + +function confirmBooking(itin: Itinerary): string { + // In reality: POST to the booking API. Happens once, at the very end, + // with the full itinerary assembled — which is why this tool stays in + // scenario 2 territory rather than scenario 3. + const ref = `BK${(itin.from + itin.to).toUpperCase().slice(0, 4)}${Math.floor(Math.random() * 900 + 100)}`; + const legs = itin.ret ? `${itin.depart} / ${itin.ret}` : itin.depart; + return `Booked ${itin.from.toUpperCase()}→${itin.to.toUpperCase()} (${legs}), ${itin.pax}×${itin.cabin}. Ref ${ref}. Total $${quote(itin)}.`; +} + +// --------------------------------------------------------------------------- +// Server setup +// --------------------------------------------------------------------------- + +const server = new McpServer({ name: 'mrtr-04-flight-booking', version: '0.0.0' }); + +// ───────────────────────────────────────────────────────────────────────────── +// BEFORE: current SDK pattern. +// +// Three sequential `await elicitInput()` calls. Each answer lands in a +// local variable and is implicitly carried into the next prompt's +// message text. Clean and readable — this is the ergonomic baseline +// MRTR needs to match. +// ───────────────────────────────────────────────────────────────────────────── + +server.registerTool( + 'book_flight_before', + { + description: 'Flight booking wizard (pre-MRTR: three sequential awaits)', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + // Step 1: route + const r1 = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: 'Where are you flying?', + requestedSchema: { + type: 'object', + properties: { + from: { type: 'string', title: 'From (airport code)', minLength: 3, maxLength: 3 }, + to: { type: 'string', title: 'To (airport code)', minLength: 3, maxLength: 3 } + }, + required: ['from', 'to'] + } + }); + if (r1.action !== 'accept' || !r1.content) { + return { content: [{ type: 'text', text: 'Booking cancelled.' }] }; + } + const from = r1.content.from as string; + const to = r1.content.to as string; + + // Step 2: dates — prompt references step 1's answers (closure vars) + const r2 = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: `Dates for ${from.toUpperCase()} → ${to.toUpperCase()}? (leave return blank for one-way)`, + requestedSchema: { + type: 'object', + properties: { + depart: { type: 'string', title: 'Departure date', format: 'date' }, + ret: { type: 'string', title: 'Return date (optional)', format: 'date' } + }, + required: ['depart'] + } + }); + if (r2.action !== 'accept' || !r2.content) { + return { content: [{ type: 'text', text: 'Booking cancelled.' }] }; + } + const depart = r2.content.depart as string; + const ret = r2.content.ret as string | undefined; + + // Step 3: passengers + cabin + const r3 = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: 'Passengers and cabin class?', + requestedSchema: { + type: 'object', + properties: { + pax: { type: 'integer', title: 'Passengers', minimum: 1, maximum: 9 }, + cabin: { type: 'string', enum: ['economy', 'premium', 'business'], title: 'Cabin' } + }, + required: ['pax', 'cabin'] + } + }); + if (r3.action !== 'accept' || !r3.content) { + return { content: [{ type: 'text', text: 'Booking cancelled.' }] }; + } + const pax = r3.content.pax as number; + const cabin = r3.content.cabin as Itinerary['cabin']; + + // All answers are locals — assemble and confirm. + return { content: [{ type: 'text', text: confirmBooking({ from, to, depart, ret, pax, cabin }) }] }; + } +); + +// ───────────────────────────────────────────────────────────────────────────── +// AFTER: MRTR with requestState. +// +// The three closure variables above become a single serialised blob that +// round-trips through the client. Each invocation decodes it, merges the +// answer from this round's inputResponses, and either asks the next +// question (with the *grown* blob re-encoded) or completes. +// +// The shape is a fall-through chain of "have X yet?" checks. Because the +// wizard is linear, there's exactly one missing piece at any given time, +// so the merge logic stays trivial — we never have to reconcile +// out-of-order or partial answers. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Everything the wizard has collected so far. Grows monotonically: + * round 1 → {} + * round 2 → { from, to } + * round 3 → { from, to, depart, ret? } + * final → full Itinerary (never encoded — we complete instead) + * + * Real implementations sign/encrypt this (see shims.ts). Size matters + * too: an extravagant wizard with many steps would want to consider + * whether a task store is cheaper than shipping a kilobyte-scale blob + * through every HTTP round-trip. + */ +type BookingState = Partial>; + +server.registerTool( + 'book_flight_after', + { + description: 'Flight booking wizard (MRTR: linear requestState accumulation)', + inputSchema: z.object({ + _mrtr: z.unknown().optional() + }) + }, + async ({ _mrtr }): Promise => { + const { inputResponses, requestState } = readMrtr({ _mrtr }); + const prior = decodeState(requestState) ?? {}; + + // Merge this round's answer (at most one key will be present). + // We give each step a stable key so the merge is trivial and so + // a misbehaving client sending a stale key is harmlessly ignored. + const route = acceptedContent<{ from: string; to: string }>(inputResponses, 'route'); + const dates = acceptedContent<{ depart: string; ret?: string }>(inputResponses, 'dates'); + const details = acceptedContent<{ pax: number; cabin: Itinerary['cabin'] }>(inputResponses, 'details'); + + const from = prior.from ?? route?.from; + const to = prior.to ?? route?.to; + const depart = prior.depart ?? dates?.depart; + // `ret` is genuinely optional, so distinguish "not asked yet" from + // "asked, user left it blank". We key off `depart` being known. + const ret = prior.depart === undefined ? dates?.ret : prior.ret; + + // ── Step 1: need route? ───────────────────────────────────────── + if (!from || !to) { + return wrap({ + inputRequests: { + route: elicitForm({ + message: 'Where are you flying?', + requestedSchema: { + type: 'object', + properties: { + from: { type: 'string', title: 'From (airport code)', minLength: 3, maxLength: 3 }, + to: { type: 'string', title: 'To (airport code)', minLength: 3, maxLength: 3 } + }, + required: ['from', 'to'] + } + }) + } + // No requestState: we haven't learned anything yet. + }); + } + + // ── Step 2: need dates? ───────────────────────────────────────── + if (!depart) { + // State now carries the route so the next server instance can + // (a) skip re-asking and (b) reference it in the prompt text. + const state: BookingState = { from, to }; + return wrap({ + inputRequests: { + dates: elicitForm({ + // Prompt references prior state — same UX as + // `before`'s closure-variable interpolation. + message: `Dates for ${from.toUpperCase()} → ${to.toUpperCase()}? (leave return blank for one-way)`, + requestedSchema: { + type: 'object', + properties: { + depart: { type: 'string', title: 'Departure date', format: 'date' }, + ret: { type: 'string', title: 'Return date (optional)', format: 'date' } + }, + required: ['depart'] + } + }) + }, + requestState: encodeState(state) + }); + } + + // ── Step 3: need pax + cabin? ─────────────────────────────────── + if (!details) { + // State has grown: route + dates. This is the "accumulation" + // the demo exists to illustrate — contrast with 02 where the + // blob is one field and the growth is in the question tree. + const state: BookingState = { from, to, depart, ret }; + return wrap({ + inputRequests: { + details: elicitForm({ + message: 'Passengers and cabin class?', + requestedSchema: { + type: 'object', + properties: { + pax: { type: 'integer', title: 'Passengers', minimum: 1, maximum: 9 }, + cabin: { type: 'string', enum: ['economy', 'premium', 'business'], title: 'Cabin' } + }, + required: ['pax', 'cabin'] + } + }) + }, + requestState: encodeState(state) + }); + } + + // ── Complete. ──────────────────────────────────────────────────── + // Everything we need is either in `prior` (decoded from the blob) + // or in `details` (this round's inputResponses). Assemble and go. + const itin: Itinerary = { from, to, depart, ret, pax: details.pax, cabin: details.cabin }; + return { content: [{ type: 'text', text: confirmBooking(itin) }] }; + } +); + +// --------------------------------------------------------------------------- + +const transport = new StdioServerTransport(); +await server.connect(transport); +console.error('[mrtr-04] ready (book_flight_before, book_flight_after)'); diff --git a/examples/server/src/mrtr-backcompat/README.md b/examples/server/src/mrtr-backcompat/README.md index 575b810ca..535eeecd0 100644 --- a/examples/server/src/mrtr-backcompat/README.md +++ b/examples/server/src/mrtr-backcompat/README.md @@ -5,7 +5,7 @@ line ~1160 ("this ideally is doing a lot of heavy lifting"). ## What this folder is -Three side-by-side demos that show how an existing `await elicitInput(...)`-style tool handler migrates under MRTR, bucketed by how painful the migration is: +Four side-by-side demos that show how an existing `await elicitInput(...)`-style tool handler migrates under MRTR, bucketed by how painful the migration is: | # | Scenario | Hazard of naive retry | Migration cost | requestState? | Tasks? | | --- | -------------------------------------------- | ------------------------------------------ | ---------------------------------- | ------------- | ------ | @@ -21,8 +21,9 @@ Each demo registers a `_before` tool (current SDK pattern) and a `_a | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `shims.ts` | Local stand-ins for `IncompleteResult`, `InputRequests`/`InputResponses`, and helpers. Since the SDK doesn't thread `inputResponses`/`requestState` through yet, MRTR params ride on `arguments._mrtr` and `IncompleteResult` is smuggled out as a marked JSON text block. | | `01SimpleRetry.ts` | Weather lookup. One elicitation, no state worth carrying. Migration is "check for answer, else ask" — essentially mechanical. | -| `02ContinuationState.ts` | ADO-style conditional-field cascade (mirrors the SEP's real-world example). The "after" handler decodes `requestState`, merges new `inputResponses`, and decides whether to ask the next question or complete. | +| `02ContinuationState.ts` | ADO-style conditional-field cascade (mirrors the SEP's real-world example). Conditional branching: the second question only exists if the first answer was "Duplicate". | | `03TasksMigration.ts` | VM provisioning — a mutation runs _before_ the elicitation. Demonstrates why MRTR's ephemeral workflow can't save this class of handler and sketches the Tasks shape it should move to. See `../simpleTaskInteractive.ts` for the full Tasks wiring. | +| `04FlightBooking.ts` | Three-step booking wizard. Linear accumulation: `requestState` grows monotonically each round (route → route+dates → full itinerary). The most common shape of multi-elicitation tool in practice; shows the migration is simpler than 02's branching case. | ## The three-tier classification, as a decision tree @@ -34,6 +35,7 @@ Does the handler mutate external state before the first elicitation? └─ No → Does the handler ask >1 question where later questions depend on earlier answers? │ ├─ Yes → Scenario 2: ephemeral MRTR with requestState + │ (02 for conditional branching, 04 for linear wizard) │ └─ No → Scenario 1: ephemeral MRTR, no requestState needed ``` @@ -47,9 +49,10 @@ In practice most existing tools fall into bucket 1. Bucket 2 covers wizard-style pnpm tsx examples/server/src/mrtr-backcompat/01SimpleRetry.ts pnpm tsx examples/server/src/mrtr-backcompat/02ContinuationState.ts pnpm tsx examples/server/src/mrtr-backcompat/03TasksMigration.ts +pnpm tsx examples/server/src/mrtr-backcompat/04FlightBooking.ts ``` -All three use stdio transport so they're easy to drive from the Inspector. The `_after` tools expect MRTR params under `arguments._mrtr = { inputResponses, requestState }` as a transport stand-in. +All four use stdio transport so they're easy to drive from the Inspector. The `_after` tools expect MRTR params under `arguments._mrtr = { inputResponses, requestState }` as a transport stand-in. ## What this exploration does NOT do