-
Notifications
You must be signed in to change notification settings - Fork 1.9k
feat: multi round-trip requests — server seam, e2e coverage, docs and examples #2314
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5b0564b
8dfc1da
34d9114
c512d08
58297e2
893242c
aae41cc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| --- | ||
| '@modelcontextprotocol/core': minor | ||
| '@modelcontextprotocol/server': minor | ||
| --- | ||
|
|
||
| Add the server side of multi round-trip requests (protocol revision 2026-07-28, SEP-2322). Handlers for `tools/call`, `prompts/get`, and `resources/read` can return the value built by `inputRequired()` (exported from the server package together with `acceptedContent()`) | ||
| to request additional client input in-band; the structured-content requirement and the tools/call result-schema validation are skipped for that return, the encode seam emits it as `resultType: 'input_required'`, and the handler reads the responses on re-entry from | ||
| `ctx.mcpReq.inputResponses` (with non-bare entries reported via `ctx.mcpReq.droppedInputResponseKeys`). The seam re-checks the at-least-one rule for hand-built results, checks every embedded request against the capabilities the client declared on that request's envelope | ||
| (answering the typed `-32003` error on violation), and fails loudly — never emitting a mis-typed result — when an input-required value is returned from any other method or toward a 2025-era request. A `UrlElicitationRequiredError` escaping a handler on a 2026-era request | ||
| fails as an internal error with a clear steer to `inputRequired.elicitUrl(...)`, so the `-32042` error never reaches the 2026-07-28 wire; 2025-era serving keeps today's `-32042` behavior | ||
| exactly. The typed local error raised when push-style server-to-client request APIs are used while serving a 2026-era request now steers to `inputRequired(...)`. Tool, prompt, and resource callback types accept the new return alongside their existing result types; 2025-era | ||
| wire behavior is unchanged. An optional `ServerOptions.requestState.verify` hook lets a server integrity-check the echoed `requestState` before the handler runs — a throw answers the wire-level `-32602` Invalid Params error with `data.reason: 'invalid_request_state'`; the SDK provides no default verification. |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| /** | ||
| * Drives the multi-round-trip server example | ||
| * (`examples/server/src/multiRoundTrip.ts`) two ways on a 2026-07-28 | ||
| * connection: | ||
| * | ||
| * 1. **auto-fulfilment** (the default) — the same `elicitation/create` | ||
| * handler the client would register for the 2025-era flow fulfils the | ||
| * embedded form and URL elicitations, and the SDK retries the original | ||
| * `tools/call` for you. `client.callTool()` returns a plain | ||
| * `CallToolResult`; | ||
| * 2. **manual mode** — `inputRequired: { autoFulfill: false }` plus per-call | ||
| * `allowInputRequired: true`: the input-required value is handed back, and | ||
| * the example collects responses, echoes `requestState`, and retries | ||
| * itself. | ||
| * | ||
| * Start the server first, then: | ||
| * | ||
| * tsx examples/client/src/multiRoundTripClient.ts | ||
| * | ||
| * (Attaching the per-request `_meta` envelope explicitly is a stop-gap; | ||
| * automatic envelope emission for every request is a client-side follow-up.) | ||
| */ | ||
| import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/client'; | ||
| import { | ||
| Client, | ||
| CLIENT_CAPABILITIES_META_KEY, | ||
| CLIENT_INFO_META_KEY, | ||
| isInputRequiredResult, | ||
| PROTOCOL_VERSION_META_KEY, | ||
| StreamableHTTPClientTransport | ||
| } from '@modelcontextprotocol/client'; | ||
|
|
||
| const URL = process.env.MCP_SERVER_URL ?? 'http://localhost:3000/'; | ||
| const CLIENT_INFO = { name: 'mrtr-example-client', version: '1.0.0' }; | ||
|
|
||
| // Per-request envelope every 2026-era request carries on the wire. The | ||
| // declared client capabilities are what the server's −32003 check reads. | ||
| function envelope(negotiated: string): Record<string, unknown> { | ||
| return { | ||
| [PROTOCOL_VERSION_META_KEY]: negotiated, | ||
| [CLIENT_INFO_META_KEY]: CLIENT_INFO, | ||
| [CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {}, url: {} } } | ||
| }; | ||
| } | ||
|
|
||
| async function autoFulfilLeg(): Promise<void> { | ||
| console.log('--- auto-fulfilment (the default) ---'); | ||
| const client = new Client(CLIENT_INFO, { | ||
| versionNegotiation: { mode: 'auto' }, | ||
| capabilities: { elicitation: { form: {}, url: {} } } | ||
| }); | ||
| // The SAME handler a 2025-flow client registers: the auto-fulfilment | ||
| // engine dispatches embedded form and URL elicitations through it. | ||
| client.setRequestHandler('elicitation/create', async request => { | ||
| const params = request.params as { mode?: string; message: string; url?: string }; | ||
| if (params.mode === 'url') { | ||
| console.log(`[client] (auto) url elicitation: ${params.message} → ${params.url}`); | ||
| return { action: 'accept' }; | ||
| } | ||
| console.log(`[client] (auto) form elicitation: ${params.message}`); | ||
| return { action: 'accept', content: { confirm: true } }; | ||
| }); | ||
|
|
||
| await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); | ||
| const negotiated = client.getNegotiatedProtocolVersion()!; | ||
| console.log('negotiated protocol version:', negotiated); | ||
|
|
||
| // callTool returns a plain CallToolResult — the interactive rounds happen | ||
| // inside the call. | ||
| const result = await client.request({ | ||
| method: 'tools/call', | ||
| params: { name: 'deploy', arguments: { env: 'prod' }, _meta: envelope(negotiated) } | ||
| }); | ||
| console.log('deploy result:', JSON.stringify(result.content)); | ||
| await client.close(); | ||
| } | ||
|
|
||
| async function manualLeg(): Promise<void> { | ||
| console.log('--- manual mode (autoFulfill: false + allowInputRequired) ---'); | ||
| const client = new Client(CLIENT_INFO, { | ||
| versionNegotiation: { mode: 'auto' }, | ||
| capabilities: { elicitation: { form: {}, url: {} } }, | ||
| inputRequired: { autoFulfill: false } | ||
| }); | ||
| await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); | ||
| const negotiated = client.getNegotiatedProtocolVersion()!; | ||
|
|
||
| let inputResponses: Record<string, unknown> | undefined; | ||
| let requestState: string | undefined; | ||
| for (let round = 0; round < 10; round++) { | ||
| // allowInputRequired: true → the call resolves with either the | ||
| // complete CallToolResult or the input-required value (use | ||
| // `withInputRequired(schema)` on the explicit-schema path to type | ||
| // both outcomes; here the method-keyed path is used for brevity). | ||
| const value = (await client.request( | ||
| { | ||
| method: 'tools/call', | ||
| params: { | ||
| name: 'deploy', | ||
| arguments: { env: 'staging' }, | ||
| _meta: envelope(negotiated), | ||
| ...(inputResponses && { inputResponses }), | ||
| ...(requestState && { requestState }) | ||
| } | ||
| }, | ||
| { allowInputRequired: true } | ||
| )) as CallToolResult | InputRequiredResult; | ||
| if (!isInputRequiredResult(value)) { | ||
| console.log('deploy result:', JSON.stringify(value.content)); | ||
| break; | ||
| } | ||
| // Collect responses and echo requestState byte-exact. | ||
| console.log(`[client] (manual) round ${round + 1}: server asked for ${Object.keys(value.inputRequests ?? {}).join(', ')}`); | ||
| inputResponses = {}; | ||
| for (const [key, entry] of Object.entries(value.inputRequests ?? {})) { | ||
| inputResponses[key] = entry.method === 'elicitation/create' ? { action: 'accept', content: { confirm: true } } : {}; | ||
| } | ||
| requestState = value.requestState; | ||
| } | ||
| await client.close(); | ||
| } | ||
|
|
||
| await autoFulfilLeg(); | ||
| await manualLeg(); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| /** | ||
| * A write-once tool served via `createMcpHandler` that requests client input | ||
| * with multi round-trip results (protocol revision 2026-07-28). | ||
| * | ||
| * The `deploy` tool returns `inputRequired(...)` instead of pushing a | ||
| * server→client request: a form-mode elicitation for confirmation, then a | ||
| * URL-mode elicitation for sign-in via `inputRequired.elicitUrl(...)`. The | ||
| * step the tool is waiting for is carried in `requestState`, which the SDK | ||
| * round-trips opaquely (echoed byte-exact by the client; the server reads it | ||
| * raw at `ctx.mcpReq.requestState`). | ||
| * | ||
| * `requestState` round-trips through the client and is therefore | ||
| * attacker-controlled input on re-entry. A real server MUST integrity-protect | ||
| * it (e.g. HMAC or AEAD): this example mints `body.hmac` with a per-process | ||
| * key and rejects tampered state via the {@linkcode ServerOptions.requestState} | ||
| * `verify` hook, which answers a wire-level `-32602` Invalid Params error. | ||
| * | ||
| * Run with: | ||
| * | ||
| * tsx examples/server/src/multiRoundTrip.ts | ||
| * | ||
| * and point the paired client example at it: | ||
| * | ||
| * tsx examples/client/src/multiRoundTripClient.ts | ||
| */ | ||
| import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; | ||
| import { createServer } from 'node:http'; | ||
|
|
||
| import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/server'; | ||
| import { acceptedContent, createMcpHandler, inputRequired, McpServer } from '@modelcontextprotocol/server'; | ||
| import * as z from 'zod/v4'; | ||
|
|
||
| const CONFIRM_SCHEMA = { type: 'object' as const, properties: { confirm: { type: 'boolean' as const } }, required: ['confirm'] }; | ||
|
|
||
| // Per-process integrity key for requestState. The 2026-07-28 path serves every | ||
| // request from a fresh server instance — the state itself is the only thing | ||
| // that survives between rounds — so the key is process-local. | ||
| const STATE_KEY = randomBytes(32); | ||
|
|
||
| type DeployState = { step: 'confirm' | 'signed-in'; env: string }; | ||
|
|
||
| function mintState(payload: DeployState): string { | ||
| const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); | ||
| return `${body}.${createHmac('sha256', STATE_KEY).update(body).digest('base64url')}`; | ||
| } | ||
|
|
||
| function verifyState(state: string): void { | ||
| const dot = state.lastIndexOf('.'); | ||
| const body = dot > 0 ? state.slice(0, dot) : ''; | ||
| const expected = createHmac('sha256', STATE_KEY).update(body).digest(); | ||
| const provided = Buffer.from(state.slice(dot + 1), 'base64url'); | ||
| if (dot <= 0 || provided.length !== expected.length || !timingSafeEqual(provided, expected)) { | ||
| throw new Error('requestState failed integrity verification'); | ||
| } | ||
| } | ||
|
|
||
| function readState(ctx: { mcpReq: { requestState?: string } }): DeployState | undefined { | ||
| // The seam-level verify hook has already proven integrity by the time the | ||
| // handler runs; this only re-reads the body. | ||
| const state = ctx.mcpReq.requestState; | ||
| return state === undefined | ||
| ? undefined | ||
| : (JSON.parse(Buffer.from(state.slice(0, state.lastIndexOf('.')), 'base64url').toString()) as DeployState); | ||
| } | ||
|
|
||
| function buildServer(): McpServer { | ||
| const server = new McpServer( | ||
| { name: 'mrtr-example-server', version: '1.0.0' }, | ||
| { capabilities: { tools: {} }, requestState: { verify: verifyState } } | ||
| ); | ||
|
|
||
| server.registerTool( | ||
| 'deploy', | ||
| { | ||
| title: 'Deploy (write-once)', | ||
| description: 'Deploys to the named environment after a confirmation and a sign-in.', | ||
| inputSchema: z.object({ env: z.string() }) | ||
| }, | ||
| async ({ env }, ctx): Promise<CallToolResult | InputRequiredResult> => { | ||
| // The handler reads the SAME context fields on every entry; what | ||
| // changes between rounds is which input responses have arrived and | ||
| // what (verified) `requestState` was echoed back. | ||
| const state = readState(ctx); | ||
| const step = state?.step ?? 'confirm'; | ||
| console.error(`[server] tools/call deploy(${env}) step=${step}`); | ||
|
|
||
| if (step === 'confirm') { | ||
| const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); | ||
| if (!confirmed?.confirm) { | ||
| return inputRequired({ | ||
| inputRequests: { | ||
| confirm: inputRequired.elicit({ message: `Deploy to ${env}?`, requestedSchema: CONFIRM_SCHEMA }) | ||
| }, | ||
| // The next entry stays at the 'confirm' step until the | ||
| // user actually accepts. | ||
| requestState: mintState({ step: 'confirm', env }) | ||
| }); | ||
| } | ||
| // Move to the URL-mode sign-in step. URL elicitation rides | ||
| // the multi-round-trip flow on this revision — the throw-style | ||
| // UrlElicitationRequiredError of earlier revisions is not | ||
| // available toward 2026-07-28 requests. | ||
| return inputRequired({ | ||
| inputRequests: { | ||
| auth: inputRequired.elicitUrl({ | ||
| message: 'Sign in to continue', | ||
| url: `https://example.com/auth?env=${env}` | ||
| }) | ||
| }, | ||
| requestState: mintState({ step: 'signed-in', env }) | ||
|
Comment on lines
+103
to
+110
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The deploy example interpolates the unconstrained, client-supplied Extended reasoning...What the bug is. In auth: inputRequired.elicitUrl({
message: 'Sign in to continue',
url: `https://example.com/auth?env=${env}`
})
The code path that triggers it. The first round of the deploy flow returns the form-mode confirm elicitation; once accepted, the handler builds this URL-mode elicitation and the client presents the URL to the human user to open in a browser (that is the whole point of URL elicitation). Nothing between the tool argument and the URL sanitizes or encodes Why existing code doesn't prevent it. The example otherwise goes out of its way to model security best practice — it HMAC-protects Impact. Two layers. Functionally, any Step-by-step proof. (1) A caller (or a prompt-injected model) invokes How to fix. One token: |
||
| }); | ||
| } | ||
|
|
||
| // step === 'signed-in': the URL-mode elicitation completed out of | ||
| // band — verify the auth response actually arrived. | ||
| const auth = ctx.mcpReq.inputResponses?.['auth'] as { action?: string } | undefined; | ||
| if (auth?.action !== 'accept') { | ||
| return { isError: true, content: [{ type: 'text', text: 'auth response missing or declined' }] }; | ||
| } | ||
| return { content: [{ type: 'text', text: `deployed to ${state?.env ?? env}` }] }; | ||
| } | ||
| ); | ||
|
|
||
| return server; | ||
| } | ||
|
|
||
| // Host with the per-request HTTP entry on its default posture (2026-07-28 | ||
| // served per request; 2025-era traffic served stateless from the same | ||
| // factory). | ||
| const handler = createMcpHandler(() => buildServer()); | ||
| const port = Number(process.env.PORT ?? '3000'); | ||
|
|
||
| createServer((req, res) => void handler.node(req, res)).listen(port, () => { | ||
| console.error(`multi-round-trip example server listening on http://localhost:${port}/`); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.