Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions examples/README-mrtr-dual-path.md

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions examples/client/src/mrtr-dual-path/clientDualPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Client-side dual-path: new SDK, old server.
*
* Everything here is what the APP DEVELOPER writes. The SDK machinery
* (retry loop, IncompleteResult parsing, SSE listener wiring) lives in
* sdkLib.ts — that file is a stand-in for what the real SDK ships.
*
* The point: the app-facing code is identical to today's. You write one
* elicitation handler, you register it, you call tools. The SDK routes
* your handler from either the SSE push path (old server) or the MRTR
* retry loop (new server). Which path fires is invisible to this file.
*
* Run against the server demos (cwd: examples/client):
* DEMO_PROTOCOL_VERSION=2025-11 pnpm tsx src/mrtr-dual-path/clientDualPath.ts
* DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/clientDualPath.ts
*/

import type { ElicitRequestFormParams, ElicitResult } from '@modelcontextprotocol/client';
import { Client, getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/client';

import { withMrtr } from './sdkLib.js';

// ───────────────────────────────────────────────────────────────────────────
// The one thing the app owns: given an elicitation request, produce a
// response. In a real client this presents `requestedSchema` as a form.
// The signature is identical whether the request arrived via SSE push
// or inside an IncompleteResult — the SDK dispatches to this from both.
// ───────────────────────────────────────────────────────────────────────────

async function handleElicitation(params: ElicitRequestFormParams): Promise<ElicitResult> {
console.error(`[elicit] server asks: ${params.message}`);
return { action: 'accept', content: { units: 'metric' } };
}

// ───────────────────────────────────────────────────────────────────────────

const client = new Client({ name: 'mrtr-dual-path-client', version: '0.0.0' }, { capabilities: { elicitation: {} } });

// One registration. Both paths dispatch to `handleElicitation`.
// Pass `{ mrtrOnly: true }` to drop the SSE listener (cloud-hosted clients
// that can't hold the backchannel — Caitie's point 2).
const { callTool } = withMrtr(client, handleElicitation);

const transport = new StdioClientTransport({
command: 'pnpm',
args: ['tsx', '../server/src/mrtr-dual-path/optionAShimMrtrCanonical.ts'],
env: { ...getDefaultEnvironment(), DEMO_PROTOCOL_VERSION: process.env.DEMO_PROTOCOL_VERSION ?? '2026-06' }
});
await client.connect(transport);

// Same call site as today. Which path fires under the hood — SSE push or
// MRTR retry — depends on the server, not on anything in this file.
const result = await callTool('weather', { location: 'Tokyo' });
console.error('[result]', JSON.stringify(result.content, null, 2));

await client.close();
108 changes: 108 additions & 0 deletions examples/client/src/mrtr-dual-path/sdkLib.ts
Original file line number Diff line number Diff line change
@@ -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<ElicitResult>;

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<string, unknown>) => Promise<CallToolResult> } {
// 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<string, unknown>): Promise<CallToolResult> {
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<ReturnType<Client['callTool']>>): 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;
}
}
84 changes: 84 additions & 0 deletions examples/server/src/mrtr-dual-path/optionAShimMrtrCanonical.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Option A: SDK shim, MRTR as canonical. Hidden retry loop.
*
* Tool author writes MRTR-native code only. The SDK wrapper (`sseRetryShim`)
* detects the negotiated version:
* - 2026-06 client → pass `IncompleteResult` through, client drives retry
* - 2025-11 client → SDK emulates the retry loop locally, fulfilling each
* `InputRequest` via real SSE elicitation, re-invoking the handler until
* it returns a complete result
*
* Author experience: one code path. Re-entry is explicit in the source
* (the `if (!prefs)` guard), so the handler is safe to re-invoke by
* construction. But the *fact* that it's re-invoked for old clients is
* invisible — the shim is doing work the author can't see.
*
* What makes this the "⚠️ clunky" cell: the SDK is running a loop on the
* author's behalf. If the handler has a subtle ordering assumption between
* rounds, or does something expensive before the guard, the author won't
* find out until an old client connects in prod. It works, but it's magic.
*
* Run: DEMO_PROTOCOL_VERSION=2025-11 pnpm tsx src/mrtr-dual-path/optionAShimMrtrCanonical.ts
* DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/optionAShimMrtrCanonical.ts
*/

import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

import type { MrtrHandler } from './shims.js';
import { acceptedContent, elicitForm, sseRetryShim } from './shims.js';

type Units = 'metric' | 'imperial';

function lookupWeather(location: string, units: Units): string {
const temp = units === 'metric' ? '22°C' : '72°F';
return `Weather in ${location}: ${temp}, partly cloudy.`;
}

const server = new McpServer({ name: 'mrtr-option-a', version: '0.0.0' });

// ───────────────────────────────────────────────────────────────────────────
// This is what the tool author writes. One function, MRTR-native.
// No version check, no SSE awareness. The `if (!prefs)` guard IS the
// re-entry contract; the author sees it, but doesn't see the shim calling
// this function in a loop for 2025-11 sessions.
// ───────────────────────────────────────────────────────────────────────────

const weatherHandler: MrtrHandler<{ location: string }> = async ({ location }, { inputResponses }) => {
const prefs = acceptedContent<{ units: Units }>(inputResponses, 'units');
if (!prefs) {
return {
inputRequests: {
units: elicitForm({
message: 'Which units?',
requestedSchema: {
type: 'object',
properties: { units: { type: 'string', enum: ['metric', 'imperial'], title: 'Units' } },
required: ['units']
}
})
}
};
}

return { content: [{ type: 'text', text: lookupWeather(location, prefs.units) }] };
};

// ───────────────────────────────────────────────────────────────────────────
// Registration applies the shim. In a real SDK this could be a flag on
// `registerTool` itself, or inferred from the handler signature — the point
// is the author opts in once at registration, not per-call.
// ───────────────────────────────────────────────────────────────────────────

server.registerTool(
'weather',
{
description: 'Weather lookup (Option A: SDK shim, MRTR canonical)',
inputSchema: z.object({ location: z.string() })
},
sseRetryShim(weatherHandler)
);

const transport = new StdioServerTransport();
await server.connect(transport);
console.error('[option-A] ready');
89 changes: 89 additions & 0 deletions examples/server/src/mrtr-dual-path/optionBShimAwaitCanonical.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Option B: SDK shim, `await elicit()` as canonical. The footgun direction.
*
* Tool author writes today's `await elicit(...)` style. The shim routes:
* - 2025-11 client → native SSE, blocks inline (today's behaviour exactly)
* - 2026-06 client → `elicit()` throws `NeedsInputSignal`, shim catches it,
* emits `IncompleteResult`. On retry the handler runs from the top, and
* this time `elicit()` finds the answer in `inputResponses`.
*
* Author experience: zero migration. Handlers that work today keep working.
* The `await` reads linearly.
*
* The problem: the `await` is a lie on MRTR sessions. Everything above it
* re-executes on retry. See the commented-out `auditLog()` below — uncomment
* it and a 2026-06 client triggers *two* audit entries for one tool call.
* A 2025-11 client triggers one. Same source, different observable behaviour,
* and nothing in the code warns you.
*
* This is the "wrap legacy `await elicitInput()` so it behaves like MRTR
* bucket-1" follow-up #1597's README raised. It works for idempotent
* handlers. It breaks silently for everything else.
*
* Run: DEMO_PROTOCOL_VERSION=2025-11 pnpm tsx src/mrtr-dual-path/optionBShimAwaitCanonical.ts
* DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/optionBShimAwaitCanonical.ts
*/

import type { CallToolResult } from '@modelcontextprotocol/server';
import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

import { mrtrExceptionShim, readMrtr, wrap } from './shims.js';

type Units = 'metric' | 'imperial';

function lookupWeather(location: string, units: Units): string {
const temp = units === 'metric' ? '22°C' : '72°F';
return `Weather in ${location}: ${temp}, partly cloudy.`;
}

// Pretend side-effect to make the hazard concrete. Uncomment the call in
// the handler and watch the count diverge between protocol versions.
let auditCount = 0;
function auditLog(location: string): void {
auditCount++;
console.error(`[audit] lookup requested for ${location} (count=${auditCount})`);
}
void auditLog;

const server = new McpServer({ name: 'mrtr-option-b', version: '0.0.0' });

// ───────────────────────────────────────────────────────────────────────────
// This is what the tool author writes. Looks linear. Isn't, on MRTR.
// ───────────────────────────────────────────────────────────────────────────

const weatherHandler = mrtrExceptionShim<{ location: string }>(async ({ location }, elicit): Promise<CallToolResult> => {
// 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');
Loading
Loading