Serve input_required handlers on 2025-era connections via a legacy fulfilment shim (#2381)

This commit is contained in:
Felix Weinberger 2026-06-29 16:43:43 +01:00 • committed by GitHub
commit f0bf785239
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -27,6 +27,7 @@ below.
- [Per-era wire codecs](#per-era-wire-codecs)
- [Wire-only members hidden from public types](#wire-only-members-hidden-from-public-types)
- [Multi-round-trip requests](#multi-round-trip-requests)
- [Legacy shim for `input_required`](#legacy-shim-for-input_required)
- [`subscriptions/listen`](#subscriptionslisten)
- [`Mcp-Param-*` and standard headers (SEP-2243)](#mcp-param--and-standard-headers-sep-2243)
- [Cache fields and cache hints](#cache-fields-and-cache-hints)
@ -222,7 +223,10 @@ The 2026-07-28 revision is **per request** — `createMcpHandler` builds a fresh
request and there is no `Mcp-Session-Id`. If your v1 server kept state keyed on the
session id (`ctx.sessionId` / `extra.sessionId`), the 2026 answer is `requestState`: an
opaque string the server returns with `inputRequired(...)` and the client echoes
byte-for-byte on the retry. Read it at `ctx.mcpReq.requestState`.
byte-for-byte on the retry. Read it back with the typed accessor
`ctx.mcpReq.requestState<T>()` — it returns the payload your configured verify hook
decoded (see below), the raw wire string when no hook is configured, or `undefined`
when the round carried no state.
`requestState` round-trips through the client and is therefore **untrusted input** —
integrity-protect it (HMAC / AEAD over the payload, bound to principal, originating
@ -232,9 +236,53 @@ method/parameters, and an expiry) and reject failed verification on re-entry. Co
The `createRequestStateCodec({ key, ttlSeconds?, bind? })` helper returns
`{ mint, verify }` — `mint` HMAC-SHA256-seals a JSON-serializable payload and `verify`
is exactly the function you assign to the hook. The codec is **signed, not encrypted**
(the client can base64url-decode the payload). See `examples/mrtr/server.ts` and
(the client can base64url-decode the payload). `mint<T>` and
`ctx.mcpReq.requestState<T>()` are the typed encode/read pair: the seam captures what
`verify` returns and the accessor hands it to the handler already decoded — no second
`verify` call. See `examples/mrtr/server.ts` and
[Multi-round-trip requests](#multi-round-trip-requests) for the full handler shape.
**Multi-step flows: the phase switch.** `inputResponses` are **per round** — each retry
carries only that round's responses, never earlier rounds' (the modern client driver
and the [legacy shim](#legacy-shim-for-input_required) both guarantee replace, not
accumulate). A flow with more than one input round therefore threads everything it has
learned through `requestState`, as a discriminated union of phases, and switches on the
phase rather than probing which response keys arrived:
```typescript
type BrainstormState =
| { step: 'awaiting-count' }
| { step: 'awaiting-custom-count'; topic: string }
| { step: 'awaiting-ideas'; topic: string; count: number };
const stateCodec = createRequestStateCodec<BrainstormState>({ key: SECRET });
// ServerOptions: { requestState: { verify: stateCodec.verify } }
async (args, ctx) => {
const state = ctx.mcpReq.requestState<BrainstormState>();
switch (state?.step) {
case undefined: // first call — ask for the count
return inputRequired({
inputRequests: { count: inputRequired.elicit({ … }) },
requestState: await stateCodec.mint({ step: 'awaiting-count' })
});
case 'awaiting-count': {
const accepted = acceptedContent(ctx.mcpReq.inputResponses, 'count', COUNT_SCHEMA);
// …decide: follow-up question or the sampling round, carrying
// everything learned so far inside the next minted state…
}
case 'awaiting-ideas': {
const ideas = inputResponse(ctx.mcpReq.inputResponses, 'ideas');
return finish(ideas.kind === 'sampling' ? ideas.result : undefined, state.count, state.topic);
}
}
};
```
Each `case` knows exactly which answer to read and which data is in scope — the state
machine is explicit, and the same handler runs unchanged on 2025-era connections
through the legacy shim.
---
## Auth on 2026-07-28
@ -322,13 +370,13 @@ The protocol layer enforces the same boundary at runtime:
(typed `Partial<RequestMetaEnvelope>`); for notifications there is no per-message
context, so lifted envelope keys are dropped. On requests only, `inputResponses` /
`requestState` are lifted from top-level params to `ctx.mcpReq.inputResponses` /
`ctx.mcpReq.requestState`; notification params are never touched.
the `ctx.mcpReq.requestState()` accessor; notification params are never touched.
- **Collision note for 2025-era peers.** The `_meta` lift is invisible to conforming
2025 traffic (the `io.modelcontextprotocol/` prefix is reserved in 2025-11-25 too).
The retry-field lift is the one collision: 2025-11-25 does not reserve the bare names
`inputResponses`/`requestState`, so a 2025 peer's **custom-method request** that uses
them as ordinary top-level params has them lifted out of `request.params` (still
readable at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`).
readable at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState()`).
- **Raw-first result discrimination.** On a 2026-era exchange, `'complete'` is consumed
and stripped; `'input_required'` is fulfilled by the client's auto-fulfilment driver;
any other kind rejects with `SdkError(UnsupportedResultType)` (kind in
@ -361,7 +409,7 @@ client retries the original call with the responses.
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `await ctx.mcpReq.elicitInput({…})` / `requestSampling({…})` | `return inputRequired({ inputRequests: { id: inputRequired.elicit({…}) } })`; read `acceptedContent(ctx.mcpReq.inputResponses, 'id')` on re-entry |
| `throw new UrlElicitationRequiredError([…])` | `return inputRequired({ inputRequests: { id: inputRequired.elicitUrl({…}) } })` |
| handler shared across both eras | branch on the served era: keep the push-style call toward 2025-era requests, return `inputRequired(...)` toward 2026-07-28 requests |
| handler shared across both eras | **no branch needed** — write the `inputRequired(...)` form once; the [legacy shim](#legacy-shim-for-input_required) serves it to 2025-era connections by issuing real server→client requests |
`inputRequired` / `acceptedContent` / `InputRequiredSpec` are exported from
`@modelcontextprotocol/server`. On 2026-era requests the push-style APIs
@ -383,6 +431,97 @@ opt out via `ClientOptions.inputRequired` (`{ autoFulfill: false }`); drive manu
call with `allowInputRequired: true` plus `withInputRequired()`. Expect
`SdkError(InputRequiredRoundsExceeded)` when the cap is exhausted.
**Typed readers for `inputResponses`.** Beyond `acceptedContent(responses, key)` (a
structural read with an unvalidated cast), two typed readers ship from
`@modelcontextprotocol/server`:
- `acceptedContent(responses, key, schema)` — schema-aware overload (any synchronous
Standard Schema, e.g. a zod object): validates the untrusted accepted content and
returns it typed, or `undefined` on mismatch/decline/missing.
- `inputResponse(responses, key)` — discriminated view
(`{kind:'missing'} | {kind:'elicit', action, content?} | {kind:'sampling', result} | {kind:'roots', roots}`)
for decline/cancel detection and the non-elicitation kinds.
Content conveniences stay in your code — e.g. the text of a sampling response is a
one-liner over the discriminated view:
```typescript
const ideas = inputResponse(ctx.mcpReq.inputResponses, 'ideas');
const block = ideas.kind === 'sampling' && !Array.isArray(ideas.result.content) ? ideas.result.content : undefined;
const text = block?.type === 'text' ? block.text : undefined;
```
---
## Legacy shim for `input_required`
An `input_required` return on a **2025-era** connection is served by the SDK's legacy
shim, on by default: each embedded request is sent as a real server→client request
(`elicitation/create`, `sampling/createMessage`, `roots/list`) over the live session —
stamped with the originating request's id, so on sessionful Streamable HTTP the
requests ride the originating POST's stream — and the handler is re-entered with the
collected `inputResponses` until it returns a final result. Handlers are **written
once** in the 2026 `inputRequired(...)` style and serve both eras; the push-style APIs
remain available for code that still calls them directly.
The handler cannot tell which era fulfilled it — the shim mirrors the modern client
driver's semantics exactly:
- `inputResponses` are **per round** (replaced on every re-entry, never accumulated);
multi-step flows thread earlier answers through `requestState`.
- `requestState` is echoed byte-exact, and the configured
`ServerOptions.requestState.verify` hook runs on **every** round, exactly as it would
on a modern wire retry (so TTL expiry behaves identically; a rejection answers the
frozen `-32602`).
- Responses arrive as the bare result objects, era-wire-shape-validated only:
elicitation accepted content is NOT re-checked against `requestedSchema` —
exactly as on the modern era — so the handler validates with the
schema-aware `acceptedContent(responses, key, schema)` overload and can
re-issue the request instead of the call dying on a mistyped form field.
- Rounds with no embedded requests (requestState-only) are paced at 250ms.
- URL-mode elicitation legs are sent with a synthesized `elicitationId` (the
2025-11-25 wire requires one; the 2026 in-band shape has none).
Knobs live at `ServerOptions.inputRequired`:
| Member | Default | Meaning |
| --- | --- | --- |
| `maxRounds` | `8` | Handler re-entries per originating request before failing — deliberately tighter than the client driver's 10: the shim holds a live wire request open for the whole flow |
| `roundTimeoutMs` | `600_000` | Per-leg timeout (with `resetTimeoutOnProgress`) — embedded requests are human-paced, so the 60s protocol default does not apply |
| `legacyShim` | `true` | `false` restores the pre-shim loud failure (`-32603`) and the branch-on-era pattern |
Failures surface **per family**: `tools/call` failures (capability refusal, a failed
leg, round-cap exhaustion) become `isError` tool results — the 2025-era idiom hosts
already render — while `prompts/get` / `resources/read` failures surface as JSON-RPC
errors. Server bugs (malformed input-required results) fail loudly on both eras.
The shim emits no progress of its own. The originating request's `progressToken`
identifies a single must-increase stream that belongs to the handler — injecting
synthetic ticks into it cannot compose with handler-emitted progress (one stream,
one author), so the shim never writes to it: a 2025 client watching a multi-round
flow sees exactly what a hand-written 2025 push-style handler would have produced.
A handler that reports progress across rounds should derive its values from its
phase state so they increase across re-entries — the token spans the whole flow.
**Inherited limits** (the same ones hand-written push-style handlers have today):
- The shim pre-checks each embedded request kind against the client capabilities
declared at the 2025 `initialize` handshake (a bare `elicitation: {}` declaration
counts as form support — the pre-mode meaning, same as the modern `-32021` gate).
Capability-less clients get a clean refusal, never a hang.
- **Stateless legacy HTTP** (`createMcpHandler` with `legacy: 'stateless'`) builds a
fresh instance per request: no initialize handshake, no return path for
server→client requests. The shim degrades to the clean capability refusal there —
full shim behavior needs stdio (`serveStdio`) or a sessionful legacy wiring.
- JSON-mode legacy hosting (`enableJsonResponse`) cannot deliver server→client
requests mid-call: the transport drops them, so a shim leg waits out
`roundTimeoutMs` before failing per family — the same undeliverable class as
today's `elicitInput` in that configuration, which waits out its own 60s
default. Interactive tools need a streaming-capable session.
- The 2025-era `notifications/elicitation/complete` channel for URL-mode elicitation
is not bridged (upstream gap F8): URL-mode legs complete like any other elicitation
response.
---
## `subscriptions/listen`