Skip to content

feat: multi round-trip requests — server seam, e2e coverage, docs and examples#2314

Merged
felixweinberger merged 7 commits into
v2-2026-07-28from
fweinberger/mrtr-server-seam
Jun 18, 2026
Merged

feat: multi round-trip requests — server seam, e2e coverage, docs and examples#2314
felixweinberger merged 7 commits into
v2-2026-07-28from
fweinberger/mrtr-server-seam

Conversation

@felixweinberger

@felixweinberger felixweinberger commented Jun 16, 2026

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, the at-least-one re-check, the per-request capability check
    (-32003, mode-aware), the legacy/server-bug guards, and the push-API loud-fail steer.
  • New end-to-end requirements through the modern HTTP entry, with supersession links from the legacy -32042 rows.
  • A runnable example pair (server + client) exercising both the default auto-fulfilment and manual mode end to end.
  • 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(),
and throwing UrlElicitationRequiredError fails loudly with a clear steer to inputRequired.elicitUrl(...).

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 reads the served era as plain instance state (createMcpHandler/serveStdio bind it) — there is no
    per-request classification carrier.
  • 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: aae41cc

@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: aae41cc

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 thread packages/server/src/server/server.ts Outdated
@felixweinberger felixweinberger force-pushed the fweinberger/mrtr-neutral-contract-engine branch from e461f95 to fbb3dbb Compare June 18, 2026 12:39
@felixweinberger felixweinberger force-pushed the fweinberger/mrtr-server-seam branch from 379ea3e to 179a5e0 Compare June 18, 2026 12:39
@felixweinberger felixweinberger changed the title feat: multi round-trip requests — server seam, guards, and URL-elicitation conversion feat: multi round-trip requests — server seam, e2e coverage, docs and examples Jun 18, 2026
@felixweinberger felixweinberger force-pushed the fweinberger/mrtr-server-seam branch from 179a5e0 to cfa23e9 Compare June 18, 2026 12:45
@felixweinberger felixweinberger force-pushed the fweinberger/mrtr-neutral-contract-engine branch from fbb3dbb to dbb2a08 Compare June 18, 2026 12:45
Comment thread .changeset/mrtr-server-seam.md Outdated
Comment thread examples/server/src/multiRoundTrip.ts Outdated
Comment thread packages/server/src/server/server.ts Outdated
Base automatically changed from fweinberger/mrtr-neutral-contract-engine to v2-2026-07-28 June 18, 2026 13:03
…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.
@felixweinberger felixweinberger force-pushed the fweinberger/mrtr-server-seam branch from cfa23e9 to 58297e2 Compare June 18, 2026 13:06
Comment thread examples/server/src/multiRoundTrip.ts
… 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.
@felixweinberger felixweinberger force-pushed the fweinberger/mrtr-server-seam branch from 317ad59 to 893242c Compare June 18, 2026 13:25
Comment thread packages/server/src/server/server.ts
Comment thread docs/migration.md Outdated
@felixweinberger felixweinberger merged commit 0dfc5ce into v2-2026-07-28 Jun 18, 2026
14 checks passed
@felixweinberger felixweinberger deleted the fweinberger/mrtr-server-seam branch June 18, 2026 14:02
Comment on lines +555 to +583
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`
);
}
}
}

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

Comment on lines +103 to +110
return inputRequired({
inputRequests: {
auth: inputRequired.elicitUrl({
message: 'Sign in to continue',
url: `https://example.com/auth?env=${env}`
})
},
requestState: mintState({ step: 'signed-in', env })

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

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