feat: multi round-trip requests — server seam, e2e coverage, docs and examples#2314
Conversation
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
326f3d2 to
379ea3e
Compare
🦋 Changeset detectedLatest commit: aae41cc The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
🦋 Changeset detectedLatest commit: 379ea3e The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
e461f95 to
fbb3dbb
Compare
379ea3e to
179a5e0
Compare
179a5e0 to
cfa23e9
Compare
fbb3dbb to
dbb2a08
Compare
…uards The server half of multi round-trip requests on the one-instance-one-era model: handlers for tools/call, prompts/get, and resources/read can return inputRequired(...) on a 2026-07-28-bound instance, the seam re-checks the at-least-one rule for hand-built results, and the per-embedded-request -32003 capability check (mode-aware) reads the request's own envelope capabilities. Era is read as plain instance state (no per-request classification carrier). A 2025-style throw of UrlElicitationRequiredError on a 2026-era request fails loudly with a clear inputRequired.elicitUrl(...) steer (it is not converted; -32042 never reaches the 2026-07-28 wire); 2025-era serving keeps the exact -32042 behavior. The push-style server→client APIs fail with the inputRequired(...) steer before any wire traffic on a modern-era instance. McpServer's funnel skips outputSchema/result-schema validation for input_required and widens the callback return types.
…he legacy -32042 rows Five new requirements (write-once roundtrip, push-API loud-fail, URL elicitation via inputRequired.elicitUrl, rounds cap, the 2025 -32042 freeze cell), all on the entryModern arm with raw wire-byte assertions. The two -32042 rows (mcpserver:tool:url-elicitation-error, elicitation:url:required-error) are bounded with removedInSpecVersion + supersededBy/supersedes linking to the new typescript:mrtr:url-elicitation:no-32042-on-2026 row instead of entryExclusions; elicitation:url:complete-unknown-ignored retires outright on the 2026-07-28 axis citing the upstream removal of notifications/elicitation/complete. The wire sniffer accepts input_required server output only when the modern arm opts in; raw-result-type's modern leg disables auto-fulfilment so it keeps proving the discrimination surface itself. inputRequired.elicitUrl()'s parameter type no longer carries elicitationId.
…a passages - migration.md: the multi-round-trip entry written purely from a 1.x→2.0 perspective — push-API loud-fail with the inputRequired() steer, inputRequired.elicitUrl() for 2026-era URL elicitation (the 2025-style throw fails loudly, never converted; 2025 -32042 unchanged), the per-family error surfacing note, the requestState consumer obligation, and a -32003 cross-reference. The both-eras posture is the documented branch-on-served-era; no automatic legacy bridge is promised. The raw-first-discrimination paragraph now points at the multi-round-trip engine instead of an upcoming arm. - migration.md/-SKILL.md: drop the residual "v2 alpha" framings (the resultType before/after example and the two pattern-table headers) so both documents are 1.x→2.0 only. - inboundClassification.ts: document the notification-POST header cross-checks as an SDK-defensive posture (the spec leaves them undefined); behavior unchanged.
A small server/client pair for the multi round-trip flow on the 2026-07-28 revision: a write-once `deploy` tool hosted via `createMcpHandler` that returns `inputRequired(...)` for a form-mode elicitation and then a URL-mode elicitation (via `inputRequired.elicitUrl`), with `requestState` echoing the step; and a client that calls it twice — once with the default auto-fulfilment (the registered `elicitation/create` handler is dispatched for both the form and URL modes), once in manual mode (`autoFulfill: false` + `allowInputRequired`). Runnable from source via tsx; indexed in both examples READMEs.
… null-guard inputRequests
cfa23e9 to
58297e2
Compare
… on rejection Adds an optional ServerOptions.requestState.verify hook that runs before the handler on every re-entered multi-round-trip request that carries requestState. A throw answers a real JSON-RPC -32602 Invalid Params error (above the McpServer tools/call funnel, so it is not swallowed into an isError tool result) with a frozen message and data.reason 'invalid_request_state'; the granular reason is surfaced via onerror only. Unconfigured behavior is unchanged — the SDK provides no default verification. The reference example now mints body.hmac requestState with a per-process key and rejects tampered state via the new hook.
317ad59 to
893242c
Compare
…ocument the hook in migration.md
| const declared = ctx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined; | ||
| for (const [key, entry] of Object.entries(inputRequests)) { | ||
| if (entry === null || typeof entry !== 'object' || typeof (entry as { method?: unknown }).method !== 'string') { | ||
| throw new ProtocolError( | ||
| ProtocolErrorCode.InternalError, | ||
| `Handler for ${method} returned an invalid input request '${key}': each inputRequests entry must be an ` + | ||
| `embedded elicitation/create, sampling/createMessage, or roots/list request` | ||
| ); | ||
| } | ||
| const embedded = entry as { method: string; params?: Record<string, unknown> }; | ||
| const required = requiredClientCapabilitiesForInputRequest(embedded); | ||
| if (required === undefined) { | ||
| throw new ProtocolError( | ||
| ProtocolErrorCode.InternalError, | ||
| `Handler for ${method} returned an input request '${key}' of kind '${embedded.method}', which is not an ` + | ||
| `embedded request the 2026-07-28 revision defines` | ||
| ); | ||
| } | ||
| const missing = missingClientCapabilities(required, declared); | ||
| if (missing !== undefined) { | ||
| throw new MissingRequiredClientCapabilityError( | ||
| { requiredCapabilities: missing }, | ||
| `Cannot request input '${key}' (${embedded.method}): the request's client capabilities do not declare ` + | ||
| `the required capability` | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
🔴 The seam-emitted -32003 MissingRequiredClientCapabilityError is thrown from inside the wrapped handler, so over createMcpHandler it travels the in-band path of PerRequestHTTPServerTransport and reaches the client as HTTP 200 with the error in the body — not the HTTP 400 promised by the error class JSDoc, LADDER_ERROR_HTTP_STATUS, createMcpHandler's entry-gate comment, and the migration.md text added in this PR. Since REQUIRED_CLIENT_CAPABILITIES_BY_METHOD is empty, this seam is currently the only live emitter of -32003, so the documented 400 contract is never honored in practice. Either map a terminal -32003 to 400 when the response is not yet settled/streamed, or scope the 400 claims in errors.ts and migration.md to the entry-gate path.
Extended reasoning...
What the bug is. The per-embedded-input-request capability check added in this PR (Server._invokeInputRequiredCapableHandler, packages/server/src/server/server.ts:555-583) throws MissingRequiredClientCapabilityError (-32003) when a handler embeds an input request the request's declared client capabilities do not cover. Because the throw happens at handler time — inside the wrapped handler that Protocol._onrequest awaits — the resulting JSON-RPC error response travels the in-band path of PerRequestHTTPServerTransport: only errors produced inside the synchronous dispatch window get the LADDER_ERROR_HTTP_STATUS mapping (perRequestTransport.ts keys it on _dispatchWindowOpen, which is closed by the time handlers run because handlers are deferred to a microtask), while handler-produced errors are answered with HTTP 200 and the error in the body (httpStatusForErrorCode(code, 'in-band') === 200).
The contract this contradicts. The codebase says 400 is the spec-mandated status for this error: MissingRequiredClientCapabilityError's JSDoc states unconditionally "On HTTP, the response status is 400 Bad Request" (packages/core/src/types/errors.ts), LADDER_ERROR_HTTP_STATUS maps -32003 → 400 (inboundClassification.ts), and createMcpHandler.ts's entry gate explains it answers at the entry precisely because that "pins the spec-mandated HTTP 400 for this error; a handler-time emission would surface in-band on HTTP 200." The migration.md text extended in this PR compounds it: "When the HTTP entry refuses such a request, the response uses HTTP status 400 as the specification requires. The multi-round-trip seam answers with the same error…" — inviting the reader to assume the seam path also arrives on 400.
Why this matters in practice. REQUIRED_CLIENT_CAPABILITIES_BY_METHOD is empty (clientCapabilityRequirements.ts; the test pins "the table is empty"), so the entry-level gate currently emits nothing — the seam added by this PR is the only live emitter of -32003. That means after this PR, every real-world -32003 a 2026-era client can receive arrives on HTTP 200, and the documented HTTP contract for the error is never honored. A client (or another SDK) written to the documented contract — keying capability refusals off HTTP 400 — never sees the refusal as such.
Step-by-step proof. (1) A 2026-07-28 client POSTs tools/call to a createMcpHandler endpoint with a per-request envelope whose clientCapabilities declare no elicitation. (2) The tool handler returns inputRequired({ inputRequests: { confirm: inputRequired.elicit({...}) } }). (3) The seam computes missingClientCapabilities({ elicitation: { form: {} } }, {}) ≠ undefined and throws MissingRequiredClientCapabilityError. (4) The throw propagates out of the wrapped handler; Protocol._onrequest converts it to a JSON-RPC error response and calls transport.send(). (5) In PerRequestHTTPServerTransport.send(), _dispatchWindowOpen is false (handlers run after the synchronous dispatch window), so the ladder mapping is skipped and the terminal error is emitted as Response.json(message, { status: 200 }). (6) The client receives HTTP 200 with {"error":{"code":-32003,...}} in the body — contradicting the JSDoc, the ladder table, and the migration prose.
Why nothing catches it. The new unit tests (inputRequired.test.ts) exercise the -32003 path over InMemoryTransport only, and the e2e mrtr cells always declare the required capabilities, so no test pins the HTTP status of a seam-emitted -32003. The createMcpHandler comment shows the handler-time/200 trade-off was known, but with the requirement table empty there is no path on which the documented 400 actually happens.
How to fix. Three reasonable options: (a) have the per-request transport (or the invoke path) apply the ladder mapping to a terminal -32003 error response when the response is not yet settled/streamed — for the common auto/json single-JSON-body case the deferred response is still pending when the terminal error arrives, so a 400 is achievable (only an already-upgraded SSE exchange genuinely cannot change the status); (b) hoist the embedded-input-request capability check so it can be answered at the entry gate; or (c) if 200-with-in-band-error is the intended behavior for handler-time -32003, scope the 400 claim in the error-class JSDoc and the migration.md sentence so consumers don't key capability-refusal handling off the HTTP status. The error remains recognizable in-band by code + data.requiredCapabilities, which is why this is normal-but-not-blocking — but the SDK's stated contract and its only live behavior currently disagree.
| return inputRequired({ | ||
| inputRequests: { | ||
| auth: inputRequired.elicitUrl({ | ||
| message: 'Sign in to continue', | ||
| url: `https://example.com/auth?env=${env}` | ||
| }) | ||
| }, | ||
| requestState: mintState({ step: 'signed-in', env }) |
There was a problem hiding this comment.
🟡 The deploy example interpolates the unconstrained, client-supplied env tool argument directly into the URL-mode elicitation URL (url: https://example.com/auth?env=${env}``) without encoding, so a value like prod&redirect_uri=https://evil.example (or anything containing &, #, =, or spaces) injects extra query parameters/fragments into — or simply breaks — the sign-in URL the human user is asked to open. Since this file is the canonical copy-paste example for the new URL-elicitation flow, please build the URL with encodeURIComponent(env) or new URL()/URLSearchParams.
Extended reasoning...
What the bug is. In examples/server/src/multiRoundTrip.ts (the deploy handler's sign-in step, around line 107), the URL-mode elicitation is built as:
auth: inputRequired.elicitUrl({
message: 'Sign in to continue',
url: `https://example.com/auth?env=${env}`
})env is the raw tool argument, typed only as z.string() with no constraint, and it is interpolated into the query string with no encodeURIComponent and no URL/URLSearchParams construction. Any reserved character in the value lands directly in the URL.
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 env: the schema is an unconstrained string, and the template literal concatenates it verbatim after ?env=.
Why existing code doesn't prevent it. The example otherwise goes out of its way to model security best practice — it HMAC-protects requestState and wires the new requestState.verify hook — but the URL construction has no equivalent guard. The seam-level checks added in this PR (capability check, at-least-one rule) validate the shape of the embedded request, not the contents of its url field.
Impact. Two layers. Functionally, any env containing &, #, =, or spaces produces a semantically wrong or ambiguous URL even on the benign path. Security-wise, in MCP the tool arguments are typically composed by an LLM host (and can be influenced by untrusted prompt content), while the URL elicitation is shown to and opened by the human user — so the entity choosing env and the entity opening the URL differ. A real server copied from this canonical example (it is linked from the server README, paired with the client example, and referenced by the migration docs) with a real auth endpoint would let injected query params (the classic redirect_uri-style injection) ride a trusted-host URL presented to the human. The injection cannot change the host (it lands after ?), so the ceiling is parameter injection on the auth endpoint, not a full open redirect — and the demo URL is example.com — which is why this is a nit rather than a blocker.
Step-by-step proof. (1) A caller (or a prompt-injected model) invokes tools/call deploy with arguments: { env: 'prod&redirect_uri=https://evil.example' }. (2) The confirm round completes normally. (3) The handler builds url: 'https://example.com/auth?env=prod&redirect_uri=https://evil.example' — the injected redirect_uri is now a separate query parameter on the trusted host. (4) The URL-mode elicitation carrying that URL is presented to the human user, who sees an example.com/auth link and opens it. With a real auth endpoint that honours redirect_uri, the sign-in flow would redirect to the attacker-chosen URL. Even with a harmless value like env: 'us east' or env: 'a#b', step (3) already produces a malformed/ambiguous URL.
How to fix. One token: url: https://example.com/auth?env=${encodeURIComponent(env)}`` — or, more idiomatically, build the URL with const u = new URL('https://example.com/auth'); u.searchParams.set('env', env); and pass u.href. Example-only; no SDK runtime path is affected.
This adds the server half of multi round-trip requests (SEP-2322, protocol revision 2026-07-28): handlers for
tools/call, prompts/get and resources/read can return
inputRequired(...)to request client input in-band, and theserver seam validates, guards, and emits that result on the 2026-07-28 wire.
Motivation and Context
Write-once handlers need a supported way to request input on the 2026-07-28 era, where the server→client request
channel no longer exists. The seam also has to protect the 2025-era wire from mis-typed results and keep the deployed
-32042URL-elicitation behavior untouched there.How Has This Been Tested?
(-32003, mode-aware), the legacy/server-bug guards, and the push-API loud-fail steer.
-32042rows.Breaking Changes
None for 2025-era traffic: the
-32042surface and the push-style server→client APIs behave exactly as before toward2025-era requests. On 2026-era requests the push-style APIs fail with a typed error that steers to
inputRequired(),and throwing
UrlElicitationRequiredErrorfails loudly with a clear steer toinputRequired.elicitUrl(...).Types of changes
Checklist
Additional context
createMcpHandler/serveStdiobind it) — there is noper-request classification carrier.
requestStateround-trips through the client and is therefore attacker-controlled input on re-entry; the migrationguide spells out the integrity-protection obligation for servers that rely on it.
follow-up change.