feat: multi round-trip requests — server seam, guards, and URL-elicitation conversion#2314
Conversation
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
…the URL-elicitation conversion Handlers for tools/call, prompts/get and resources/read can return the inputRequired() value (now exported from the server package together with acceptedContent()) on 2026-07-28-served requests: the structured-content requirement and the tools/call result-schema validation skip it, the encode seam emits it as resultType 'input_required', and re-entry reads ctx.mcpReq.inputResponses through the funnel with full validation of the completing result. The seam re-checks the at-least-one rule for hand-built results, checks every embedded request against the capabilities declared on that request's envelope (typed -32003 on violation), and fails loudly instead of putting a mis-typed result on the wire when input_required is returned from any other method or toward a 2025-era request. UrlElicitationRequiredError on a 2026-era request converts to a URL-mode elicitation inside input_required (capability-gated) so -32042 never reaches the modern wire, while 2025-era serving keeps the exact -32042 behavior. The push-style API gate message now steers to inputRequired(...). The neutral InputRequiredResult type extends Result so handler-result typing composes; tool/prompt/resource callback types accept the new return alongside their existing results. The migration guide gains the write-once entry including the requestState consumer obligation.
…-fail steer, no -32042 on 2026, rounds cap, legacy -32042 freeze Five new requirement rows with a new scenario file: the write-once tool roundtrip through the modern entry arm (fresh ids, bare inputResponses, byte-exact requestState echo asserted on the recorded HTTP exchanges), the push-style API loud-fail with the inputRequired() steer and zero wire traffic for the attempted server-to-client request, URL elicitation riding the multi-round-trip flow with -32042 absent from the 2026 wire, the configurable round cap raising the typed rounds-exceeded error, and the 2025-axis freeze cell pinning the exact -32042 error shape on legacy serving. The wire sniffer accepts input_required server results (valid 2026-07-28 output that is deliberately not part of the neutral result union), and the three existing requirements that assert the legacy -32042 error surface gain a modern-error-surface entry exclusion: on the 2026-07-28 era that surface is replaced by the input_required conversion, which the new requirements cover.
… note Adds two clauses to the multi-round-trip migration entry — the per-family error-surfacing difference (only tools/call wraps handler failures into isError results) and the note that notifications/elicitation/complete has no delivery channel under modern per-request HTTP serving (resume URL elicitations through requestState instead). The JSDoc link cleanup this commit previously carried now lands with the earlier client-side change.
… context Replace the WeakMap keyed by built contexts with a symbol-keyed property set on the context at buildContext time, the same carrier pattern used for the result cache-hint fallback. The multi-round-trip seam reads the classification straight from the context it is handed; what gets classified and when is unchanged. The symbol key is module-private and never serialized, so the public context types and wire output are unaffected.
…licitations; reject empty URL-elicitation conversions
- Elicitation modes are sub-capabilities: a form-mode (or mode-omitted)
embedded elicitation now requires elicitation.form on the request's declared
client capabilities, so a form-mode input request toward a URL-only client
is refused with the typed -32003 error instead of passing the check.
- A bare 'elicitation: {}' declaration is read as form support (the pre-mode
meaning of a bare declaration), so existing clients that declare the
capability without a mode keep receiving form-mode elicitations. Documented
on the capability helpers.
- The URL-elicitation conversion no longer produces an input_required result
with an empty inputRequests map when the error carries no elicitations; it
fails loudly as an internal error instead.
- Unit coverage for the capability mapping and the implied-form reading, plus
seam tests: form-mode toward a URL-only client (-32003), toward a bare
elicitation declaration (allowed), and the empty-elicitations conversion.
…ke the migration example self-contained - The wire sniffer's input_required exception is now opt-in (allowInputRequiredResults), set by the wiring for the entryModern arm only, so an input_required result leaking onto a 2025-era cell's wire is flagged again. Unit-tested both ways. - The migration guide's write-once example now defines the confirmSchema it references.
326f3d2 to
379ea3e
Compare
🦋 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 |
🦋 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 |
| for (const [index, params] of error.elicitations.entries()) { | ||
| const preferred = params.elicitationId; | ||
| const key = preferred && !(preferred in inputRequests) ? preferred : `url-elicitation-${index + 1}`; | ||
| inputRequests[key] = { method: 'elicitation/create', params }; |
There was a problem hiding this comment.
🟡 The F5 URL-elicitation conversion derives embedded-request keys with const key = preferred && !(preferred in inputRequests) ? preferred : url-elicitation-${index + 1}`` — but the fallback key is never itself collision-checked, so a later id-less elicitation whose fallback (url-elicitation-<index+1>) equals an earlier elicitation's explicit `elicitationId` silently overwrites it, dropping a required elicitation and stalling the round trip; separately, `preferred in inputRequests` walks the prototype chain, so an `elicitationId` of `constructor`/`toString`/`proto` etc. is always seen as already-present and renamed to the fallback, diverging the map key from the server's intended id. Fix by using `Object.hasOwn` (as the codebase already does in clientCapabilityRequirements.ts) and deduping the chosen key including the fallback. Low impact — both modes require the server to mint pathological ids, and the keys are server-controlled, so this is a nit-level robustness fix.
Extended reasoning...
What the bug is. In Server._convertUrlElicitationRequiredError (packages/server/src/server/server.ts), each embedded URL-elicitation is keyed as:
const key = preferred && !(preferred in inputRequests) ? preferred : `url-elicitation-${index + 1}`;
inputRequests[key] = { method: 'elicitation/create', params };There are two distinct defects in this one line, both newly introduced by this PR.
Flaw 1 — the fallback key is never collision-checked (data loss). The explicit preferred id is guarded with !(preferred in inputRequests), but the synthesized fallback url-elicitation-${index + 1} is assigned with no such guard. If an earlier elicitation already occupies a key equal to a later index's fallback pattern, the later (fallback) assignment overwrites it. inputRequests then has fewer keys than error.elicitations.length, so one required elicitation is silently dropped. Because the client only fulfils the requests it actually receives, the dropped one is never satisfied and the multi-round-trip never completes — the call stalls until the round cap / a timeout.
Flaw 2 — in traverses the prototype chain. inputRequests is a plain {}, so Object.prototype is on its chain. preferred in inputRequests is therefore true for any elicitationId equal to an inherited member name (constructor, toString, valueOf, hasOwnProperty, __proto__, …) even when the map is empty. elicitationId is a required, opaque, server-chosen z.string() (schemas.ts:1819), so these are spec-conforming ids. The condition preferred && !(preferred in inputRequests) is then false, so the elicitation is renamed to the url-elicitation-N fallback, diverging the embedded request's key from the id the server intended. A handler that reads ctx.mcpReq.inputResponses by its own elicitationId on re-entry (exactly what the e2e mrtr handler does with inputResponses['auth-1']) would not find the response, breaking correlation on retry.
Why existing code doesn't prevent it. The codebase already guards this exact class of problem elsewhere: requiredClientCapabilitiesForRequest uses Object.hasOwn(...) and clientCapabilityRequirements.test.ts has a 'prototype-chain names never resolve to a requirement' test (constructor/hasOwnProperty). The new F5 code regressed to the unsafe in operator and added an un-deduped fallback. The F5 tests only ever convert a single elicitation (elicit-7), so the multi-elicitation collision and the prototype-name cases are uncovered.
Step-by-step proof (Flaw 1). Throw new UrlElicitationRequiredError([{ mode: 'url', elicitationId: 'url-elicitation-2', url, message }, { mode: 'url', url, message /* no id */ }]) while serving a 2026-era request that declared elicitation.url. Iteration: (a) index 0, preferred = 'url-elicitation-2', not in map → key = 'url-elicitation-2', map = { 'url-elicitation-2': e0 }. (b) index 1, preferred = undefined (falsy) → fallback url-elicitation-${1 + 1} = 'url-elicitation-2' → inputRequests['url-elicitation-2'] = e1, overwriting e0. Result: { 'url-elicitation-2': e1 } — a single key for two required elicitations. The client fulfils e1 only; e0 is never requested, so the server's retry condition is never met and the round trip cannot complete. (An elicitationId: '' at index 1 triggers the same overwrite, since '' && … is falsy.)
Step-by-step proof (Flaw 2). Throw new UrlElicitationRequiredError([{ mode: 'url', elicitationId: 'toString', url, message }]). Iteration: index 0, preferred = 'toString'; 'toString' in inputRequests is true (inherited from Object.prototype) even though the map is empty, so !(preferred in inputRequests) is false → key = 'url-elicitation-1'. The embedded request is keyed 'url-elicitation-1' instead of 'toString', so a handler that re-reads ctx.mcpReq.inputResponses['toString'] on retry finds nothing.
Impact and severity. Both failure modes are genuine correctness defects in new code, but triggering either requires the server to mint a pathological elicitationId — one matching the internal url-elicitation-N pattern (or empty/duplicate), or a prototype member name. A server using ordinary, distinct, non-prototype ids never hits this. The ids are server-controlled end to end (the server both mints them and reads the responses), so there is no attacker-controlled or security dimension. Hence nit: real and worth the trivial fix, but low real-world likelihood.
How to fix. Compute the chosen key and dedupe it including the fallback, and use Object.hasOwn (or back inputRequests with a Map) instead of in. For example: pick preferred only when preferred && !Object.hasOwn(inputRequests, preferred); otherwise pick a fallback and bump its suffix until !Object.hasOwn(inputRequests, fallback) holds before assigning.
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?
the at-least-one re-check, the per-request capability check (-32003), the legacy/server-bug guards, the
URL-elicitation conversion (and its loud failure without the capability), and the push-API loud-fail steer.
wire traffic for the attempted server→client request, URL elicitation with
-32042absent from the 2026 wire, theround cap, and a 2025-axis freeze cell pinning the exact
-32042error shape.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().Types of changes
Checklist
Additional context
(retried) requests go back through the normal funnel so completing results get full validation.
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.