Skip to content

Commit c3ce96d

Browse files
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.
1 parent 8f52db6 commit c3ce96d

2 files changed

Lines changed: 178 additions & 0 deletions

File tree

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* Client-side dual-path: new SDK, old server.
3+
*
4+
* This is the "new client → old server" direction (point 2 from the SEP-2322
5+
* thread). A client on the 2026-06 SDK connects to a 2025-11 server. Version
6+
* negotiation settles on 2025-11. The server pushes elicitation over SSE the
7+
* old way. The client needs to handle that — and also handle `IncompleteResult`
8+
* when talking to new servers.
9+
*
10+
* Unlike the server side (options A–E in examples/server/src/mrtr-dual-path/),
11+
* there's only one sensible approach here. The elicitation handler has the
12+
* same signature either way — "given an elicitation request, produce a
13+
* response" — so the SDK routes to one user-supplied function from both
14+
* paths. No version check in app code, no dual registration, no shim footguns.
15+
*
16+
* What the SDK keeps: the existing `setRequestHandler('elicitation/create', ...)`
17+
* plumbing. What the SDK adds: a retry loop in `callTool` that unwraps
18+
* `IncompleteResult` and calls the same handler for each `InputRequest`.
19+
*
20+
* Run against any of the optionA–E servers (cwd: examples/client):
21+
* DEMO_PROTOCOL_VERSION=2025-11 pnpm tsx src/mrtr-dual-path/clientDualPath.ts
22+
* DEMO_PROTOCOL_VERSION=2026-06 pnpm tsx src/mrtr-dual-path/clientDualPath.ts
23+
*/
24+
25+
import type { CallToolResult, ElicitRequestFormParams, ElicitResult } from '@modelcontextprotocol/client';
26+
import { Client, getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/client';
27+
28+
// ───────────────────────────────────────────────────────────────────────────
29+
// Inlined MRTR type shims. See examples/server/src/mrtr-dual-path/shims.ts
30+
// for the full set with commentary — only the three the client side touches
31+
// are repeated here.
32+
// ───────────────────────────────────────────────────────────────────────────
33+
34+
type InputRequest = { method: 'elicitation/create'; params: ElicitRequestFormParams };
35+
type InputResponses = { [key: string]: { result: ElicitResult } };
36+
37+
interface IncompleteResult {
38+
inputRequests?: { [key: string]: InputRequest };
39+
requestState?: string;
40+
}
41+
42+
interface MrtrParams {
43+
inputResponses?: InputResponses;
44+
requestState?: string;
45+
}
46+
47+
// ───────────────────────────────────────────────────────────────────────────
48+
// The ONE handler. This is the whole client-side story.
49+
//
50+
// Shape: `(params: ElicitRequestFormParams) => Promise<ElicitResult>`.
51+
// Nothing about this signature cares whether the request arrived as an SSE
52+
// push or inside an `IncompleteResult`. The SDK calls it from either path;
53+
// the app code is identical.
54+
// ───────────────────────────────────────────────────────────────────────────
55+
56+
async function handleElicitation(params: ElicitRequestFormParams): Promise<ElicitResult> {
57+
// Real client: present `params.requestedSchema` as a form, collect user input.
58+
// Demo: hardcode an answer so the weather tool completes.
59+
console.error(`[elicit] server asks: ${params.message}`);
60+
return { action: 'accept', content: { units: 'metric' } };
61+
}
62+
63+
// ───────────────────────────────────────────────────────────────────────────
64+
// Path 1 of 2: SSE push (old server, negotiated 2025-11).
65+
//
66+
// This is today's API, unchanged. The SDK receives `elicitation/create` as
67+
// a JSON-RPC request on the SSE stream and invokes the registered handler.
68+
// The new SDK keeps this registration — it's cheap to carry and it's what
69+
// makes the upgrade non-breaking for the client → old server direction.
70+
// ───────────────────────────────────────────────────────────────────────────
71+
72+
function registerSseElicitation(client: Client): void {
73+
client.setRequestHandler('elicitation/create', async request => {
74+
if (request.params.mode !== 'form') {
75+
return { action: 'decline' };
76+
}
77+
return handleElicitation(request.params);
78+
});
79+
}
80+
81+
// ───────────────────────────────────────────────────────────────────────────
82+
// Path 2 of 2: MRTR retry loop (new server, negotiated 2026-06).
83+
//
84+
// The SDK's `callTool` would do this internally. When the result is
85+
// `IncompleteResult`, iterate `inputRequests`, call the SAME handler for
86+
// each `ElicitRequest` inside, pack results into `inputResponses`, re-issue
87+
// the tool call. Repeat until complete.
88+
//
89+
// Note where `handleElicitation` appears: same function, same call shape.
90+
// The loop is SDK machinery; the app-supplied handler doesn't know which
91+
// path it's serving.
92+
// ───────────────────────────────────────────────────────────────────────────
93+
94+
async function callToolMrtr(client: Client, name: string, args: Record<string, unknown>): Promise<CallToolResult> {
95+
let mrtr: MrtrParams = {};
96+
97+
for (let round = 0; round < 8; round++) {
98+
const result = await client.callTool({ name, arguments: { ...args, _mrtr: mrtr } });
99+
100+
const incomplete = unwrapIncomplete(result);
101+
if (!incomplete) {
102+
return result as CallToolResult;
103+
}
104+
105+
const responses: InputResponses = {};
106+
for (const [key, req] of Object.entries(incomplete.inputRequests ?? {})) {
107+
// The same handler as the SSE path. No adapter, no version check.
108+
responses[key] = { result: await handleElicitation(req.params) };
109+
}
110+
111+
mrtr = { inputResponses: responses, requestState: incomplete.requestState };
112+
}
113+
114+
throw new Error('MRTR retry loop exceeded round limit');
115+
}
116+
117+
// Reverse of the server-side `wrap()` shim. Real SDK would parse
118+
// `JSONRPCIncompleteResultResponse` at the protocol layer; this just
119+
// unwraps the JSON-text-block smuggle the server demos use.
120+
function unwrapIncomplete(result: Awaited<ReturnType<Client['callTool']>>): IncompleteResult | undefined {
121+
const first = (result as CallToolResult).content?.[0];
122+
if (first?.type !== 'text') return undefined;
123+
try {
124+
const parsed = JSON.parse(first.text) as { __mrtrIncomplete?: true } & IncompleteResult;
125+
return parsed.__mrtrIncomplete ? parsed : undefined;
126+
} catch {
127+
return undefined;
128+
}
129+
}
130+
131+
// ───────────────────────────────────────────────────────────────────────────
132+
// Caitie's point 2: MRTR-only mode.
133+
//
134+
// Cloud-hosted clients (claude.ai class) can't hold the SSE backchannel
135+
// even today, so for them SSE elicitation was never available and MRTR is
136+
// the first time it becomes possible. Those clients would skip
137+
// `registerSseElicitation` entirely — the real SDK shape would be a
138+
// constructor flag, something like:
139+
//
140+
// new Client({ name, version }, { capabilities: { elicitation: {} }, sseElicitation: false })
141+
//
142+
// With that set, the SDK doesn't register the `elicitation/create` handler.
143+
// An old server that tries to push one gets method-not-found. The MRTR
144+
// retry loop still works. Tree-shaking drops the SSE listener code.
145+
// ───────────────────────────────────────────────────────────────────────────
146+
147+
async function main(): Promise<void> {
148+
const client = new Client({ name: 'mrtr-dual-path-client', version: '0.0.0' }, { capabilities: { elicitation: {} } });
149+
150+
// Enables the SSE path. Comment this out for MRTR-only mode.
151+
registerSseElicitation(client);
152+
153+
const transport = new StdioClientTransport({
154+
command: 'pnpm',
155+
args: ['tsx', '../server/src/mrtr-dual-path/optionAShimMrtrCanonical.ts'],
156+
env: { ...getDefaultEnvironment(), DEMO_PROTOCOL_VERSION: process.env.DEMO_PROTOCOL_VERSION ?? '2026-06' }
157+
});
158+
await client.connect(transport);
159+
160+
// One call site. Which path fires under the hood depends on the server:
161+
// old server → SSE handler invoked mid-call; new server → MRTR retry loop
162+
// runs. The app code here is identical either way.
163+
const result = await callToolMrtr(client, 'weather', { location: 'Tokyo' });
164+
console.error('[result]', JSON.stringify(result.content, null, 2));
165+
166+
await client.close();
167+
}
168+
169+
try {
170+
await main();
171+
} catch (error) {
172+
console.error(error);
173+
// eslint-disable-next-line unicorn/no-process-exit
174+
process.exit(1);
175+
}

examples/server/src/mrtr-dual-path/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ None. All five options present identical wire behaviour to each client version.
3333
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
3434
`elicitation`/`sampling` capabilities.
3535

36+
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
37+
retry loop; the SDK routes to it. No A/B/C/D/E split because there's only one sensible shape.
38+
3639
## Trade-offs
3740

3841
**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

0 commit comments

Comments
 (0)