Serve input_required handlers on 2025-era connections via a legacy fulfilment shim#2381
Serve input_required handlers on 2025-era connections via a legacy fulfilment shim#2381felixweinberger wants to merge 8 commits into
Conversation
🦋 Changeset detectedLatest commit: bec0500 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 |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
…lfilment shim A tools/call, prompts/get, or resources/read handler that returns an input_required result on a 2025-era connection is now served by a default-on shim at the server seam: each embedded request is sent as a real server-to-client request (elicitation/create, sampling/createMessage, roots/list) over the live session, stamped with the originating request id for stream association, and the handler is re-entered with the collected inputResponses until it returns a final result. Handlers are written once in the input_required style and serve both eras; ServerOptions.inputRequired.legacyShim: false restores the previous loud -32603 failure. Round semantics mirror the client auto-fulfilment driver: per-round replaced inputResponses, byte-exact requestState echo with the verify hook running every round against the round's own context, paced requestState-only rounds, shared round-cap accounting (default 8), and elicitation accepted content passed through unvalidated for the handler to check. Legs carry an explicit human-paced timeout (inputRequired.roundTimeoutMs, default 600s) with a live resetTimeoutOnProgress, URL-mode legs synthesize the elicitationId the 2025-11-25 wire requires, and one synthetic progress tick per completed round (progressToken-gated, monotonic above handler-emitted progress) keeps watchdog clients alive. Failures surface per family: isError tool results for tools/call, JSON-RPC errors for prompts/resources. The shim's own capability pre-check reads the per-request resolved view, so capability-less clients and stateless per-request legacy serving get a clean refusal before any wire traffic. ctx.mcpReq.requestState becomes a typed accessor: requestState<T>() returns the verify hook's decoded payload (createRequestStateCodec's verify), the raw wire string without a hook, or undefined. New typed readers for inputResponses ship from the server package: a schema-aware acceptedContent overload, a discriminated inputResponse view, and samplingText.
The discriminated inputResponse() view and the schema-aware acceptedContent overload carry protocol knowledge (bare response shape discrimination, the documented validation path for unvalidated accepted content) and stay public. Text extraction from a sampling response is a content convenience handlers write themselves — the migration guide now shows it as a one-liner over the discriminated view instead of shipping a samplingText export.
RequestStateAccessor is now a named public type (re-exported with the other protocol context types) instead of an indexed-access incantation, and the factory's outer cast goes: a generic arrow is directly assignable to the declared signature. The single remaining 'as T' is the deliberate caller-asserted typing the accessor documents — no implementation can produce an arbitrary T from a runtime value.
The driver's onprogress message and the shim's wire progress notification now come from one formatter, and the migration guide states why the shim's round cap (8) is tighter than the client driver's (10). Also restores the auto-generated codemod versions file this branch had accidentally regenerated.
The reference server's three interactive tools (brainstorm_tasks, clear_done, prioritize) each carried a hand-written 2025 push-style arm next to their input_required form. The shim makes the input_required form serve both eras, so the arms go: brainstorm_tasks keeps only its requestState phase machine (read via the typed accessor — no second decode), and clear_done/prioritize keep only their single-round input_required shape. The cli-client e2e legs exercise the same conversations on stdio/legacy through the shim unchanged.
9021ba9 to
8126aaa
Compare
The shim no longer emits progress against the originating request's progressToken. That token is a single must-increase stream owned by the handler, and a second author cannot compose with it — the previous floor/suppression machinery managed the conflict instead of removing it. A client watchdog tight enough to need synthetic liveness already cannot complete an interactive flow on the 2025 era (push-style legs emit nothing either), and neither sibling SDK bridge emits synthetic progress. Handlers that report progress across rounds derive values from their phase state, which increases across re-entries. Also: the inputRequired module header now describes the shim default instead of the pre-shim loud failure; the migration guide's sampling one-liner narrows the content block type before reading .text, and its JSON-mode note states the real behavior (undeliverable legs wait out roundTimeoutMs — the transport drops server-to-client requests in that mode, as it does for elicitInput today).
Both changesets ship in the same version bump; the older one still promised a loud failure for input_required returns toward 2025-era requests, which the shim now serves by default.
Server no longer carries the fulfilment loop inline: it lazily holds one LegacyInputRequiredShim (legacyInputRequiredShim.ts) constructed against a narrow host contract — knobs, the resolved capability view, the requestState verify runner, and the three capability-check-free sender cores — and the seam delegates to it in one line. The shared embedded-request validation moves with it (the modern capability check imports it back), withRequestStateValue moves to core-internal next to requestStateAccessor, and server.ts shrinks by ~300 lines. Behavior unchanged; package-internal, not exported from the index.
|
|
||
| Serve `input_required` handlers on 2025-era connections: the legacy shim (on by default) converts each embedded request of an `input_required` return into a real server→client request (`elicitation/create`, `sampling/createMessage`, `roots/list`) over the live session — stamped with the originating request's id for stream association — and re-enters the handler with the collected `inputResponses` until a final result. Handlers are written once in the 2026 `inputRequired(...)` style and serve both eras; the previous loud `-32603` failure remains available via `ServerOptions.inputRequired.legacyShim: false`. Knobs: `inputRequired.maxRounds` (default 8) and `inputRequired.roundTimeoutMs` (default 600 000 ms per leg; legs carry a progressToken so a client reporting progress mid-leg resets the leg timeout). Semantics mirror the modern client driver exactly: per-round replaced `inputResponses`, byte-exact `requestState` echo with the verify hook running every round, paced requestState-only rounds, and elicitation accepted content passed through UNVALIDATED (the handler validates via the schema-aware `acceptedContent` overload, exactly as on the 2026 era). URL-mode legs synthesize the `elicitationId` the 2025-11-25 wire requires. Failures surface per family (`tools/call` → `isError` tool result; `prompts/get` / `resources/read` → JSON-RPC error); stateless legacy HTTP degrades to a clean capability refusal; synthetic per-round progress (emitted only when the originating request carried a progressToken) stays monotonic above handler-emitted progress on the same token. | ||
|
|
||
| `ctx.mcpReq.requestState` is now a typed accessor: `ctx.mcpReq.requestState<T>()` returns the payload the configured `requestState.verify` hook resolved with (e.g. `createRequestStateCodec.verify` — the hook's return value is now load-bearing; verifiers that are not also decoders should resolve `undefined`), the raw wire string when no hook is configured, or `undefined` when the round carried no state. Code that read the property directly becomes a call: `ctx.mcpReq.requestState` → `ctx.mcpReq.requestState<string>()`. Note the member is now always present (a function), so truthiness no longer means "has state", and it is dropped by JSON serialization of the context. |
There was a problem hiding this comment.
🟡 The still-pending sibling changeset .changeset/add-request-state-codec.md ends with "(the seam already discarded the return value) so the codec's verify is directly assignable" — but this PR makes the verify hook's resolved value load-bearing (it now backs the typed ctx.mcpReq.requestState<T>() accessor), and since neither changeset is consumed in .changeset/pre.json both ship in the same @modelcontextprotocol/server version bump, so the published changelog will contradict itself and the shipped behavior. Reword that parenthetical (and optionally the property-style "at ctx.mcpReq.requestState" phrasing in .changeset/hide-wire-only-members.md). This is a different artifact and claim from the already-flagged mrtr-server-seam.md staleness.
Extended reasoning...
The stale claim. The repo contains another still-pending changeset, .changeset/add-request-state-codec.md, whose last sentence reads: "The ServerOptions.requestState.verify hook's return type is widened to unknown | Promise<unknown> (the seam already discarded the return value) so the codec's verify is directly assignable." That parenthetical accurately described the pre-PR seam, which awaited the hook and threw away whatever it resolved with.
Why this PR makes it wrong. _verifyRequestState() in packages/server/src/server/server.ts now returns the hook's resolved value, and withRequestStateValue() installs it on the handler context so ctx.mcpReq.requestState<T>() hands the decoded payload straight to the handler — both on modern wire retries and on every legacy-shim round. The PR's own ServerOptions JSDoc says "The resolved value is LOAD-BEARING" and warns that "a verifier that is not also the decoder should resolve undefined", and this PR's changeset (.changeset/legacy-input-required-shim.md, line 8) repeats that. The seam no longer discards the return value; it is the documented mechanism behind the new accessor.
Why both entries land in the same release notes. Neither add-request-state-codec nor legacy-input-required-shim appears in .changeset/pre.json's consumed changesets array (verified by grep — zero hits), so both are pending and will be folded into the same next minor bump for @modelcontextprotocol/server. The published CHANGELOG section will therefore contain one entry asserting the verify hook's return value is discarded and another asserting it now backs ctx.mcpReq.requestState<T>() and that non-decoder verifiers must resolve undefined.
Concrete impact walk-through. A consumer reading the release notes to decide how to write a requestState.verify hook: (1) they read the codec entry's parenthetical and conclude the return value is irrelevant, so they return e.g. a boolean true from a verifier that only checks an HMAC; (2) on the very SDK version those notes describe, the seam captures that true and ctx.mcpReq.requestState<string>() now returns true instead of the raw wire string; (3) the handler's state read silently breaks — exactly the failure mode the new JSDoc warns about, but the changelog told them otherwise.
Why nothing in the PR catches it and why this isn't a duplicate. The sibling changeset is not in this PR's diff, and no mechanical gate (typecheck, docs:check) reads changeset prose for cross-file consistency. The already-posted comment on .changeset/legacy-input-required-shim.md line 10 covers a different artifact (.changeset/mrtr-server-seam.md) and a different contradicted claim (the loud-failure promise) — this one needs its own one-line edit. A lesser instance of the same staleness exists in .changeset/hide-wire-only-members.md, which describes the lifted material as surfacing "at ctx.mcpReq.requestState" — a property read that is now an accessor call.
Fix. A one-line reword of the parenthetical in add-request-state-codec.md, e.g.: "…is widened to unknown | Promise<unknown>; the value the hook resolves with now backs the typed ctx.mcpReq.requestState<T>() accessor, so the codec's verify is directly assignable and its decoded payload is what the handler reads." Optionally adjust the hide-wire-only-members.md phrasing to ctx.mcpReq.requestState() while there. Changelog/documentation prose only — no runtime impact.
Serve
input_requiredhandlers on 2025-era connections: a default-on legacy fulfilment shim at the server seam converts each embedded request of aninput_requiredreturn into a real server→client request (elicitation/create,sampling/createMessage,roots/list) over the live session and re-enters the handler with the collectedinputResponsesuntil a final result. Handlers are written once in the 2026 style and serve both eras.Motivation and Context
Today a handler that wants to serve both protocol eras has to implement every interactive conversation twice: an awaited push-style arm for 2025-era connections and an
input_requiredstate machine for 2026-07-28 — and the SDK fails loudly (-32603) if the 2026-style return reaches a 2025-era request. This PR makes theinput_requiredform the single way to write interactive handlers:ServerOptions.inputRequired.legacyShim: falserestores the loud failure) mirrors the client auto-fulfilment driver exactly so a handler cannot tell which era fulfilled it: per-round REPLACEDinputResponses, byte-exactrequestStateecho with the configured verify hook running every round against the round's own context, paced requestState-only rounds, and a round cap (inputRequired.maxRounds, default 8) sharing the driver's accounting and message. Elicitation accepted content passes through unvalidated, exactly as the modern driver does, so the handler's recovery path (schema-awareacceptedContent→ re-ask) behaves identically per era.relatedRequestId) and an explicit human-paced timeout (inputRequired.roundTimeoutMs, default 600s) plus a liveresetTimeoutOnProgress(legs carry a progressToken, so a client reporting progress mid-leg extends the leg). URL-mode legs synthesize theelicitationIdthe 2025-11-25 wire requires (CSPRNG-backed). One synthetic progress tick per completed round (only when the originating request carried a progressToken) stays monotonic above any handler-emitted progress on the same token.enforceStrictCapabilities) reads the per-request resolved capability view: capability-less clients and stateless per-request legacy serving (no initialize, no server→client channel) get a clean typed refusal before any wire traffic — never a hang. Failures surface per family:isErrortool results fortools/call, JSON-RPC errors forprompts/get/resources/read; server bugs (malformed input-required results) fail loudly on both eras.requestState:ctx.mcpReq.requestStatebecomes an accessor —ctx.mcpReq.requestState<T>()returns the verify hook's decoded payload (createRequestStateCodec.verify), the raw wire string with no hook, orundefined. The hook's resolved value is now load-bearing (documented); codec users read verified state with no second decode call.inputResponsesreaders from@modelcontextprotocol/server: a schema-awareacceptedContent(responses, key, schema)overload, a discriminatedinputResponse(responses, key)view (missing | elicit | sampling | roots), andsamplingText(responses, key).How Has This Been Tested?
legacyInputRequiredShim.test.ts(26 tests: happy paths across all three embedded kinds incl. concurrent legs, REPLACE/echo semantics, round-cap exhaustion per family, capability gating incl. the bare-elicitation:{}-means-form rule and the stateless refusal, leg failures, decline pass-through, unvalidated-content recovery, leg timeout 600s vs the 60s default and progress-based reset under fake timers, synthetic progress gating + monotonicity, requestState verify-per-round + typed accessor + the frozen-32602, URL-legelicitationIdsynthesis, knob validation) andlegacyShimWriteOnce.test.ts(a multi-phase HMAC-codec write-once tool completing its full elicit → custom-count → sampling conversation on a 2025 session).typescript:mrtr:legacy-shim:write-once-on-2025running on every stateful arm (stdio, in-memory, sessionful Streamable HTTP, legacy SSE) with a realClientanswering the realelicitation/create; a serveStdio entry test; reader unit tests in core-internal.Breaking Changes
ctx.mcpReq.requestState(v2 alpha surface) changes from an optionalstringproperty to an always-present typed accessor: reads becomectx.mcpReq.requestState<string>(). Truthiness no longer means "has state", and a configuredrequestState.verifyhook's resolved value now backs the accessor (verifiers that are not decoders should resolveundefined).input_requiredreturn on a 2025-era request is now fulfilled by the shim instead of failing with-32603. The previous behavior is available viaServerOptions.inputRequired.legacyShim: false. Documented indocs/migration/support-2026-07-28.md(new section "Legacy shim forinput_required") with a changeset for@modelcontextprotocol/serverand@modelcontextprotocol/core-internal.Types of changes
Checklist
Additional context
test/conformance/src/everythingServer.ts) is updated to the accessor read; the conformance baseline is unchanged.legacyShim: falseescape hatch — the default-on fulfilment is the deliberate behavior change this PR makes.examples/mrtrstory on dual eras to demo the shim end-to-end (its stateless-HTTP legacy leg needs the documented refusal handling), and simplifyexamples/elicitation's era branches away.