Skip to content

feat: multi round-trip requests — server seam, guards, and URL-elicitation conversion#2314

Open
felixweinberger wants to merge 6 commits into
fweinberger/mrtr-neutral-contract-enginefrom
fweinberger/mrtr-server-seam
Open

feat: multi round-trip requests — server seam, guards, and URL-elicitation conversion#2314
felixweinberger wants to merge 6 commits into
fweinberger/mrtr-neutral-contract-enginefrom
fweinberger/mrtr-server-seam

Conversation

@felixweinberger

Copy link
Copy Markdown
Contributor

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 the
server 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
-32042 URL-elicitation behavior untouched there.

How Has This Been Tested?

  • A server seam suite covering the write-once round trip (input_required out, validated completion on the retry),
    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.
  • New end-to-end requirements through the modern HTTP entry: the write-once roundtrip, the loud-fail steer with zero
    wire traffic for the attempted server→client request, URL elicitation with -32042 absent from the 2026 wire, the
    round cap, and a 2025-axis freeze cell pinning the exact -32042 error shape.
  • Full repo gates: typecheck, lint, docs, all package suites, the e2e matrix, and the integration suite.

Breaking Changes

None for 2025-era traffic: the -32042 surface and the push-style server→client APIs behave exactly as before toward
2025-era requests. On 2026-era requests the push-style APIs fail with a typed error that steers to inputRequired().

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

  • The seam lives in the server's handler-wrapping hook on exactly the three multi-round-trip methods; re-entered
    (retried) requests go back through the normal funnel so completing results get full validation.
  • requestState round-trips through the client and is therefore attacker-controlled input on re-entry; the migration
    guide spells out the integrity-protection obligation for servers that rely on it.
  • The conformance fixtures are not armed for the new API yet; that (and the expected-failures burn-down) is a
    follow-up change.

@felixweinberger felixweinberger requested a review from a team as a code owner June 16, 2026 21:11
@pkg-pr-new

pkg-pr-new Bot commented Jun 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@2314

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/codemod@2314

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@2314

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server-legacy@2314

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@2314

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/fastify@2314

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@2314

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@2314

commit: 379ea3e

…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.
@felixweinberger felixweinberger force-pushed the fweinberger/mrtr-server-seam branch from 326f3d2 to 379ea3e Compare June 16, 2026 23:00
@changeset-bot

changeset-bot Bot commented Jun 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 379ea3e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/core Minor
@modelcontextprotocol/server Minor
@modelcontextprotocol/express Major
@modelcontextprotocol/fastify Major
@modelcontextprotocol/hono Major
@modelcontextprotocol/node Major

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-bot

changeset-bot Bot commented Jun 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 379ea3e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/core Minor
@modelcontextprotocol/server Minor
@modelcontextprotocol/express Major
@modelcontextprotocol/fastify Major
@modelcontextprotocol/hono Major
@modelcontextprotocol/node Major

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

Comment on lines +802 to +805
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 };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 falsekey = '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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant