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
29 changed files with 2196 additions and 217 deletions

View file

@ -2,4 +2,4 @@
'@modelcontextprotocol/server': minor
---
Add `createRequestStateCodec({ key, ttlSeconds?, bind? })`, an opt-in HMAC-SHA256 sealing helper for the multi-round-trip `requestState`: `mint` seals a JSON-serializable payload (with TTL and optional context binding) and `verify` drops directly into `ServerOptions.requestState.verify`. WebCrypto-based and runtime-neutral; verification is fail-closed and constant-time. 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.
Add `createRequestStateCodec({ key, ttlSeconds?, bind? })`, an opt-in HMAC-SHA256 sealing helper for the multi-round-trip `requestState`: `mint` seals a JSON-serializable payload (with TTL and optional context binding) and `verify` drops directly into `ServerOptions.requestState.verify`. WebCrypto-based and runtime-neutral; verification is fail-closed and constant-time. The `ServerOptions.requestState.verify` hook's return type is widened to `unknown | Promise<unknown>` so the codec's `verify` is directly assignable; the seam captures the hook's resolved value (the decoded payload) and hands it to handlers via the typed `ctx.mcpReq.requestState<T>()` accessor.

View file

@ -0,0 +1,10 @@
---
'@modelcontextprotocol/server': minor
'@modelcontextprotocol/core-internal': minor
---
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; the shim emits no progress of its own (the originating progressToken is the handler's single must-increase stream — the shim never adds a second author to it).
`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.
New typed readers for `inputResponses`, exported from `@modelcontextprotocol/server`: a schema-aware `acceptedContent(responses, key, schema)` overload (validates untrusted accepted content against any synchronous Standard Schema) and `inputResponse(responses, key)` (discriminated `missing | elicit | sampling | roots` view, for decline/cancel detection and the non-elicitation kinds). Content conveniences like text extraction stay in application code as one-liners over the discriminated view.

View file

@ -6,7 +6,7 @@
Add the server side of multi round-trip requests (protocol revision 2026-07-28, SEP-2322). Handlers for `tools/call`, `prompts/get`, and `resources/read` can return the value built by `inputRequired()` (exported from the server package together with `acceptedContent()`)
to request additional client input in-band; the structured-content requirement and the tools/call result-schema validation are skipped for that return, the encode seam emits it as `resultType: 'input_required'`, and the handler reads the responses on re-entry from
`ctx.mcpReq.inputResponses` (with non-bare entries reported via `ctx.mcpReq.droppedInputResponseKeys`). The seam re-checks the at-least-one rule for hand-built results, checks every embedded request against the capabilities the client declared on that request's envelope
(answering the typed `-32021` error on violation), and fails loudly — never emitting a mis-typed result — when an input-required value is returned from any other method or toward a 2025-era request. A `UrlElicitationRequiredError` escaping a handler on a 2026-era request
(answering the typed `-32021` error on violation), and fails loudly — never emitting a mis-typed result — when an input-required value is returned from any other method. Toward a 2025-era request the return is served by the default-on legacy shim (real server→client requests plus handler re-entry); the loud failure for that case remains available via `ServerOptions.inputRequired.legacyShim: false`. A `UrlElicitationRequiredError` escaping a handler on a 2026-era request
fails as an internal error with a clear steer to `inputRequired.elicitUrl(...)`, so the `-32042` error never reaches the 2026-07-28 wire; 2025-era serving keeps today's `-32042` behavior
exactly. The typed local error raised when push-style server-to-client request APIs are used while serving a 2026-era request now steers to `inputRequired(...)`. Tool, prompt, and resource callback types accept the new return alongside their existing result types; 2025-era
wire behavior is unchanged. An optional `ServerOptions.requestState.verify` hook lets a server integrity-check the echoed `requestState` before the handler runs — a throw answers the wire-level `-32602` Invalid Params error with `data.reason: 'invalid_request_state'`; the SDK provides no default verification.

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`

View file

@ -145,7 +145,8 @@ function buildServer(reqCtx: McpRequestContext): McpServer {
// for integrity-protecting `requestState` in production.
const dates = acceptedContent<{ departure: string; nights: number }>(ctx.mcpReq.inputResponses, 'dates');
const destination =
ctx.mcpReq.requestState ?? acceptedContent<{ destination: string }>(ctx.mcpReq.inputResponses, 'dest')?.destination;
ctx.mcpReq.requestState<string>() ??
acceptedContent<{ destination: string }>(ctx.mcpReq.inputResponses, 'dest')?.destination;
if (!destination) {
return inputRequired({ inputRequests: { dest: inputRequired.elicit({ message: 'Where to?', requestedSchema: DEST }) } });
}

View file

@ -6,8 +6,9 @@
* server→client request: a form-mode elicitation for confirmation, then a
* URL-mode elicitation for sign-in via `inputRequired.elicitUrl(...)`. The
* step the tool is waiting for is carried in `requestState`, which the SDK
* round-trips opaquely (echoed byte-exact by the client; the server reads it
* raw at `ctx.mcpReq.requestState`).
* round-trips opaquely (echoed byte-exact by the client; the handler reads
* the verified payload back via the typed `ctx.mcpReq.requestState<T>()`
* accessor).
*
* `requestState` round-trips through the client and is therefore
* attacker-controlled input on re-entry. A real server MUST integrity-protect
@ -60,9 +61,9 @@ function buildServer(): McpServer {
// The handler reads the SAME context fields on every entry; what
// changes between rounds is which input responses have arrived and
// what (verified) `requestState` was echoed back. The seam-level
// verify hook has already proven integrity by the time the handler
// runs; calling `verify` again here just yields the payload.
const state = ctx.mcpReq.requestState === undefined ? undefined : await stateCodec.verify(ctx.mcpReq.requestState, ctx);
// verify hook has already proven integrity AND decoded the payload
// by the time the handler runs — the typed accessor returns it.
const state = ctx.mcpReq.requestState<DeployState>();
const step = state?.step ?? 'confirm';
console.error(`[server] tools/call deploy(${env}) step=${step}`);

View file

@ -60,7 +60,7 @@ Any other `mcpServers`-style host can spawn it too:
| list_changed | every mutation | resource list + resource updated notifications, delivered correctly over stdio and per-request HTTP |
| Prompts + completions | `plan-my-day`, `seed-board` | `completable()` argument values (project names, themes) wired to `completion/complete` |
The two protocol eras differ in how interactive tools converse with the client: on 2025-era connections the server _pushes_ `elicitation/create` / `sampling/createMessage` requests and awaits them inline; on 2026-07-28 it returns `input_required` results and the client retries the call with the answers. The interactive tools (`brainstorm_tasks`, `clear_done`, `prioritize`) implement both arms — branch on `reqCtx.era` to compare them side by side.
The two protocol eras differ in how interactive conversations travel: on 2025-era connections the wire carries _pushed_ `elicitation/create` / `sampling/createMessage` requests; on 2026-07-28 the server returns `input_required` results and the client retries the call with the answers. The interactive tools (`brainstorm_tasks`, `clear_done`, `prioritize`) are written **once** in the `input_required` style — on 2025-era connections the SDK's default-on legacy shim performs the push-style round trips for them, so there is no era branch in any handler. (For a side-by-side of the two wire styles written by hand, see `examples/elicitation`.)
One serving-mode caveat: over **HTTP with a 2025-era client**, `createMcpHandler`'s default stateless posture has no return path for push-style server→client requests, so the sampling/elicitation tools refuse cleanly on that leg (stdio is unaffected; 2026-07-28 HTTP is unaffected).

View file

@ -38,7 +38,8 @@ type BrainstormState =
/**
* HMAC-signs the `requestState` round-tripped through brainstorm_tasks' multi-round flow so a
* client cannot forge or mutate the carried step/theme/count. The seam runs `verify` before the
* handler (rejecting tampered state with -32602); the handler calls `verify` again to decode.
* handler (rejecting tampered state with -32602) and the handler reads the decoded payload via
* the typed `ctx.mcpReq.requestState<BrainstormState>()` accessor — no second decode.
* The key comes from the environment for real deployments and falls back to a per-process
* random one for the zero-setup demo (which is fine because one process serves every round).
*/
@ -445,41 +446,15 @@ export function buildServer(reqCtx: McpRequestContext): McpServer {
content: [{ type: 'text', text: `Nothing added (user answered: ${action}).` }]
});
if (reqCtx.era === 'legacy') {
// 2025 era: push-style round trips, awaited inline — one elicitation for the theme
// and count (with a follow-up form only when the user picks "custom"), then the
// host's model invents the tasks (sampling).
const countResult = await ctx.mcpReq.elicitInput({
mode: 'form',
message: countMessage,
requestedSchema: BRAINSTORM_COUNT_SCHEMA
});
if (countResult.action !== 'accept') return declined(countResult.action);
const topic = resolveTopic(countResult.content?.theme);
let wanted = parseBrainstormCount(countResult.content?.count);
if (countResult.content?.count === 'custom') {
const customResult = await ctx.mcpReq.elicitInput({
mode: 'form',
message: 'How many exactly?',
requestedSchema: BRAINSTORM_CUSTOM_COUNT_SCHEMA
});
if (customResult.action !== 'accept') return declined(customResult.action);
wanted = parseBrainstormCount(customResult.content?.customCount);
}
if (wanted === undefined) return declined('cancel');
const response = await ctx.mcpReq.requestSampling(buildBrainstormSampling(topic, wanted));
const ideasText = !Array.isArray(response.content) && response.content.type === 'text' ? response.content.text : '';
return finish(ideasText, wanted, topic);
}
// 2026-07-28: the same conversation as a multi-round input_required chain. The
// handler is a state machine over BrainstormState — it dispatches on `state.step`
// (not on which inputResponses key arrived), so each round knows exactly which
// answer to read and which data is in scope. State is HMAC-signed by stateCodec;
// the seam already verified integrity before this handler ran, so verify here is
// the decode.
const state: BrainstormState | undefined =
ctx.mcpReq.requestState === undefined ? undefined : await stateCodec.verify(ctx.mcpReq.requestState, ctx);
// The whole conversation as a multi-round input_required chain — written ONCE.
// The handler is a state machine over BrainstormState — it dispatches on
// `state.step` (not on which inputResponses key arrived), so each round knows
// exactly which answer to read and which data is in scope. State is HMAC-signed
// by stateCodec; the seam verified integrity AND decoded the payload before this
// handler ran — the typed accessor returns it. On a 2025-era session the SDK's
// legacy shim fulfils each round as real push-style requests; the handler never
// branches on the served era.
const state = ctx.mcpReq.requestState<BrainstormState>();
const askForIdeas = async (count: number, topic: string): Promise<InputRequiredResult> =>
inputRequired({
inputRequests: { ideas: inputRequired.createMessage(buildBrainstormSampling(topic, count)) },
@ -622,27 +597,19 @@ export function buildServer(reqCtx: McpRequestContext): McpServer {
if (done.length === 0) return { content: [{ type: 'text', text: 'No completed tasks to clear.' }] };
const message = `Delete ${done.length} completed task(s) from the board?`;
let action: string;
let confirmation: { confirm?: boolean } | undefined;
if (reqCtx.era === 'legacy') {
// 2025 era: a push-style elicitation/create request, answered inline.
const result = await ctx.mcpReq.elicitInput({ mode: 'form', message, requestedSchema: CLEAR_CONFIRM_SCHEMA });
action = result.action;
confirmation = result.action === 'accept' && result.content ? { confirm: result.content.confirm === true } : undefined;
} else {
// 2026-07-28: a single input_required round, so no requestState is needed —
// the first call has no inputResponses and returns the question; the re-call
// carries the answer. (For multi-round flows, dispatch on a discriminated
// requestState instead — see brainstorm_tasks.)
const response = ctx.mcpReq.inputResponses?.['confirmation'];
if (response === undefined) {
return inputRequired({
inputRequests: { confirmation: inputRequired.elicit({ message, requestedSchema: CLEAR_CONFIRM_SCHEMA }) }
});
}
action = elicitAction(response);
confirmation = acceptedContent<{ confirm?: boolean }>(ctx.mcpReq.inputResponses, 'confirmation');
// A single input_required round, written once for both eras — the first call has
// no inputResponses and returns the question; the re-call carries the answer.
// (For multi-round flows, dispatch on a discriminated requestState instead — see
// brainstorm_tasks.) On 2025-era sessions the SDK's legacy shim asks the question
// as a real push-style elicitation.
const response = ctx.mcpReq.inputResponses?.['confirmation'];
if (response === undefined) {
return inputRequired({
inputRequests: { confirmation: inputRequired.elicit({ message, requestedSchema: CLEAR_CONFIRM_SCHEMA }) }
});
}
const action = elicitAction(response);
const confirmation = acceptedContent<{ confirm?: boolean }>(ctx.mcpReq.inputResponses, 'confirmation');
if (confirmation?.confirm !== true) {
// Decline and cancel are answers — report them and stop, never ask again.
@ -675,21 +642,15 @@ export function buildServer(reqCtx: McpRequestContext): McpServer {
maxTokens: 400
};
let rankingText: string;
if (reqCtx.era === 'legacy') {
// 2025 era: push-style sampling/createMessage back to the client, awaited inline.
const response = await ctx.mcpReq.requestSampling(samplingRequest);
rankingText = !Array.isArray(response.content) && response.content.type === 'text' ? response.content.text : '';
} else {
// 2026-07-28: a single input_required round (the ranking arrives on the retried
// call), so no requestState is needed. For multi-round flows, dispatch on a
// discriminated requestState instead — see brainstorm_tasks.
const response = ctx.mcpReq.inputResponses?.['ranking'];
if (response === undefined) {
return inputRequired({ inputRequests: { ranking: inputRequired.createMessage(samplingRequest) } });
}
rankingText = sampledText(response);
// A single input_required round, written once for both eras (the ranking arrives
// on the retried call), so no requestState is needed. For multi-round flows,
// dispatch on a discriminated requestState instead — see brainstorm_tasks. On
// 2025-era sessions the SDK's legacy shim performs the sampling round trip.
const response = ctx.mcpReq.inputResponses?.['ranking'];
if (response === undefined) {
return inputRequired({ inputRequests: { ranking: inputRequired.createMessage(samplingRequest) } });
}
const rankingText = sampledText(response);
const ranked = applyRanking(rankingText, candidates);
for (const [index, task] of ranked.entries()) {

View file

@ -51,6 +51,7 @@ export type {
ProtocolOptions,
RequestHandlerSchemas,
RequestOptions,
RequestStateAccessor,
ServerContext
} from '../../shared/protocol';
export { DEFAULT_REQUEST_TIMEOUT_MSEC } from '../../shared/protocol';

View file

@ -6,11 +6,12 @@
* `prompts/get`, `resources/read`) requests additional client input by
* returning an {@linkcode InputRequiredResult} instead of a final result. The
* helpers here build that return value and its embedded requests as NEUTRAL
* values; only the 2026-07-28 wire codec maps them to/from the wire (the
* values; only the 2026-07-28 wire codec maps them to/from the wire. The
* 2025-era codec has no input-required vocabulary — on a 2025-era request the
* server seam fails such a return loudly; a handler that serves both eras
* branches on the served era and uses the push-style APIs toward 2025-era
* requests).
* server's legacy shim (on by default) fulfils the embedded requests as real
* server→client requests and re-enters the handler, so the same return shape
* serves both eras; `ServerOptions.inputRequired.legacyShim: false` restores
* the pre-shim loud failure.
*
* There is no nominal brand: `resultType: 'input_required'` is the
* discriminator, and hand-built result literals are equally legal — the
@ -19,13 +20,15 @@
import { isInputRequiredResult } from '../types/guards';
import type {
CreateMessageRequestParams,
CreateMessageResult,
CreateMessageResultWithTools,
ElicitRequestFormParams,
ElicitRequestURLParams,
ElicitResult,
InputRequest,
InputRequests,
InputRequiredResult,
InputResponses
InputResponses,
Root
} from '../types/types';
import type { StandardSchemaV1 } from '../util/standardSchema';
@ -144,14 +147,87 @@ export const inputRequired: InputRequiredBuilder = Object.assign(buildInputRequi
export function acceptedContent<T extends Record<string, unknown> = Record<string, unknown>>(
responses: InputResponses | Record<string, unknown> | undefined,
key: string
): T | undefined {
if (responses === undefined || typeof responses !== 'object' || responses === null) return undefined;
): T | undefined;
/**
* Schema-aware overload: validates the accepted content against the given
* schema (any Standard Schema, e.g. a zod object) before returning it, so the
* untrusted client value arrives in the handler already validated and typed.
*
* Returns `undefined` when the response is missing/declined/of another kind
* (as the two-argument form does) AND when the accepted content fails schema
* validation — handlers treat both the same way (re-issue the request or
* give up). Only synchronous schemas are supported (zod schemas without async
* refinements are synchronous); an asynchronously-validating schema throws a
* `TypeError`.
*/
export function acceptedContent<S extends StandardSchemaV1>(
responses: InputResponses | Record<string, unknown> | undefined,
key: string,
schema: S
): StandardSchemaV1.InferOutput<S> | undefined;
export function acceptedContent(
responses: InputResponses | Record<string, unknown> | undefined,
key: string,
schema?: StandardSchemaV1
): unknown {
const view = inputResponse(responses, key);
if (view.kind !== 'elicit' || view.action !== 'accept' || view.content === undefined) return undefined;
if (schema === undefined) return view.content;
const outcome = schema['~standard'].validate(view.content);
if (outcome instanceof Promise) {
throw new TypeError('acceptedContent(responses, key, schema) requires a synchronously-validating schema');
}
return outcome.issues === undefined ? outcome.value : undefined;
}
/**
* The discriminated view {@linkcode inputResponse} returns: which kind of
* embedded response (if any) a retried request carried for a key. Bare
* response objects are discriminated structurally — an `action` member means
* an elicitation result, a `roots` array a roots listing, a `role` + `content`
* pair a sampling result. A missing key or an entry that matches none of the
* three shapes reads as `{ kind: 'missing' }`.
*/
export type InputResponseView =
| { kind: 'missing' }
| { kind: 'elicit'; action: 'accept' | 'decline' | 'cancel'; content?: Record<string, unknown> }
| { kind: 'sampling'; result: CreateMessageResult | CreateMessageResultWithTools }
| { kind: 'roots'; roots: Root[] };
/**
* Reads one entry of a retried request's `inputResponses`
* (`ctx.mcpReq.inputResponses`) as a discriminated view, covering
* decline/cancel detection and the non-elicitation response kinds that
* {@linkcode acceptedContent} does not surface.
*
* The values arrive from the client and are not re-validated here — treat
* them as untrusted input (validate elicitation content with the
* schema-aware {@linkcode acceptedContent} overload where it matters).
*/
export function inputResponse(responses: InputResponses | Record<string, unknown> | undefined, key: string): InputResponseView {
if (responses === undefined || typeof responses !== 'object' || responses === null) return { kind: 'missing' };
const entry = (responses as Record<string, unknown>)[key];
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return undefined;
const candidate = entry as Partial<ElicitResult> & Record<string, unknown>;
if (candidate.action !== 'accept') return undefined;
if (candidate.content === undefined || typeof candidate.content !== 'object' || candidate.content === null) return undefined;
return candidate.content as T;
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return { kind: 'missing' };
const candidate = entry as Record<string, unknown>;
if (candidate['action'] === 'accept' || candidate['action'] === 'decline' || candidate['action'] === 'cancel') {
const content = candidate['content'];
return {
kind: 'elicit',
action: candidate['action'],
...(content !== null &&
typeof content === 'object' &&
!Array.isArray(content) && { content: content as Record<string, unknown> })
};
}
if (Array.isArray(candidate['roots'])) {
return { kind: 'roots', roots: candidate['roots'] as Root[] };
}
if (typeof candidate['role'] === 'string' && candidate['content'] !== undefined) {
return { kind: 'sampling', result: candidate as unknown as CreateMessageResult | CreateMessageResultWithTools };
}
return { kind: 'missing' };
}
/**

View file

@ -141,12 +141,23 @@ export function buildInputRequiredRetryParams(
};
}
/**
* The message both multi-round-trip loops emit when the round cap is
* exhausted — the client driver as a typed error, the server-side legacy
* shim as its per-family failure. One formatter so the texts cannot drift
* (hosts and models read the tool-result copy verbatim).
*/
export function inputRequiredRoundsExceededMessage(method: string, maxRounds: number): string {
return `Multi-round-trip request '${method}' still required input after ${maxRounds} rounds (inputRequired.maxRounds)`;
}
/**
* Abortable delay: resolves after `ms`, or rejects with the signal's reason
* (wrapped in an `SdkError` when it isn't already one) if the signal aborts
* first. Aborting after resolution is a no-op.
* first. Aborting after resolution is a no-op. Shared with the server-side
* legacy shim (the pacing semantics must match per era).
*/
function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
export function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason instanceof SdkError ? signal.reason : new SdkError(SdkErrorCode.RequestTimeout, String(signal.reason)));
@ -167,9 +178,14 @@ function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
/**
* A per-round abort linked to the caller's signal: the embedded sibling
* dispatches share it, so the first failure (or a caller abort) cancels the
* others instead of leaving them running.
* others instead of leaving them running. Shared with the server-side legacy
* shim (the abort-linkage semantics must match per era).
*/
function linkedRoundAbort(outer: AbortSignal | undefined): { signal: AbortSignal; abort: (reason: unknown) => void; dispose: () => void } {
export function linkedRoundAbort(outer: AbortSignal | undefined): {
signal: AbortSignal;
abort: (reason: unknown) => void;
dispose: () => void;
} {
const controller = new AbortController();
const onOuterAbort = (): void => controller.abort(outer?.reason);
outer?.addEventListener('abort', onOuterAbort, { once: true });
@ -211,17 +227,13 @@ export async function runInputRequiredDriver(args: {
while (true) {
round += 1;
if (round > config.maxRounds) {
throw new SdkError(
SdkErrorCode.InputRequiredRoundsExceeded,
`Multi-round-trip request '${method}' still required input after ${config.maxRounds} rounds (inputRequired.maxRounds)`,
{
rounds: config.maxRounds,
lastResult: {
inputRequests: payload.inputRequests,
...(payload.requestState !== undefined && { requestState: payload.requestState })
}
throw new SdkError(SdkErrorCode.InputRequiredRoundsExceeded, inputRequiredRoundsExceededMessage(method, config.maxRounds), {
rounds: config.maxRounds,
lastResult: {
inputRequests: payload.inputRequests,
...(payload.requestState !== undefined && { requestState: payload.requestState })
}
);
});
}
// Surface the round as synthetic progress: long interactive flows stay

View file

@ -24,6 +24,7 @@ import type {
} from './inputRequiredDriver';
import { runInputRequiredDriver } from './inputRequiredDriver';
import type { BaseContext, NonCompleteResultFlow, RequestOptions } from './protocol';
import { requestStateAccessor } from './protocol';
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
@ -88,6 +89,8 @@ export function synthesizeInputRequestContext(
id: key,
method,
_meta: params?.['_meta'] as RequestMeta | undefined,
// Embedded input requests never carry multi-round-trip state.
requestState: requestStateAccessor(undefined),
signal,
send: (() => relatedMessagingUnavailable('send')) as BaseContext['mcpReq']['send'],
notify: () => relatedMessagingUnavailable('notify')

View file

@ -283,6 +283,43 @@ function codecResultValidator(codec: WireCodec, method: string): StandardSchemaV
};
}
/**
* The type of `ctx.mcpReq.requestState`. The type parameter is
* caller-asserted (no validation, like the two-argument `acceptedContent<T>`)
* — pair with `createRequestStateCodec<T>` so the claim is backed by the
* codec's verification.
*/
export type RequestStateAccessor = <T = unknown>() => T | undefined;
/**
* Builds the `ctx.mcpReq.requestState` accessor for a resolved value. The
* `as T` below is the one place {@linkcode RequestStateAccessor}'s
* caller-asserted typing is implemented — no implementation can produce an
* arbitrary `T` from a runtime value honestly.
*/
export function requestStateAccessor(value: unknown): RequestStateAccessor {
return <T>(): T | undefined => value as T | undefined;
}
/** Shared no-state accessor: the common case allocates nothing per request. */
const NO_REQUEST_STATE = requestStateAccessor(undefined);
/**
* Returns a context whose `requestState` accessor reads the given value —
* how the server seam hands a verify hook's decoded payload (or the legacy
* shim's per-round echo) to the handler without mutating the original
* context.
*/
export function withRequestStateValue<ContextT extends BaseContext>(ctx: ContextT, value: unknown): ContextT {
return {
...ctx,
mcpReq: {
...ctx.mcpReq,
requestState: requestStateAccessor(value)
}
};
}
/**
* Base context provided to all request handlers.
*/
@ -346,19 +383,22 @@ export type BaseContext = {
droppedInputResponseKeys?: string[];
/**
* Multi-round-trip request state echoed by a retried request
* (protocol revision 2026-07-28), lifted out of the params the
* handler sees. Driver material — present verbatim when sent.
* Reads the multi-round-trip request state for the current round:
* the value the configured `ServerOptions.requestState.verify` hook
* resolved with (e.g. `createRequestStateCodec.verify`'s decoded
* payload — `mint<T>`/`requestState<T>()` are the typed pair), the
* raw wire string when no hook is configured, or `undefined` when
* the round carried no state. The type parameter is a compile-time
* cast only.
*
* SECURITY: `requestState` round-trips through the client and MUST be
* treated as attacker-controlled input. The SDK applies no integrity
* protection: if this value influences authorization, resource
* access, or business logic, the server MUST integrity-protect it
* (e.g. HMAC or AEAD) when minting it and MUST verify it here,
* rejecting state that fails verification (spec:
* SECURITY: `requestState` round-trips through the client and MUST
* be treated as attacker-controlled input. The SDK applies no
* integrity protection by default — servers whose state influences
* authorization or business logic MUST integrity-protect it and
* verify via the `requestState.verify` hook (spec:
* basic/patterns/mrtr, server requirements 4–5).
*/
requestState?: string;
requestState: RequestStateAccessor;
/**
* An abort signal used to communicate if the request was cancelled from the sender's side.
@ -1020,7 +1060,11 @@ export abstract class Protocol<ContextT extends BaseContext> {
partitionedInputResponses.droppedKeys.length > 0 && {
droppedInputResponseKeys: partitionedInputResponses.droppedKeys
}),
...(lifted.requestState !== undefined && { requestState: lifted.requestState }),
// The accessor surfaces the raw lifted value (captured as the
// string primitive so the lift record stays collectable); the
// server seam swaps in the verify hook's decoded payload
// before the handler runs.
requestState: lifted.requestState === undefined ? NO_REQUEST_STATE : requestStateAccessor(lifted.requestState),
signal: abortController.signal,
// BaseContext.mcpReq.send is declared with two overloads (spec-method-keyed and explicit-schema). Arrow
// literals can't carry overload signatures, so the inferred single-signature type isn't assignable to

View file

@ -636,8 +636,10 @@ export interface InputResponses {
* authorization, resource access, or business logic, the server MUST protect
* its integrity (e.g. HMAC or AEAD) and MUST reject state that fails
* verification (spec: basic/patterns/mrtr §Server Requirements). The SDK
* surfaces it raw at `ctx.mcpReq.requestState` and applies no integrity
* protection of its own.
* applies no integrity protection by default — without a configured
* `ServerOptions.requestState.verify` hook, `ctx.mcpReq.requestState()`
* returns the raw, unverified string; with one, the seam rejects state the
* hook refuses and the accessor returns the hook's decoded payload.
*/
export interface InputRequiredResult extends Result {
resultType: 'input_required';

View file

@ -154,7 +154,7 @@ describe('inbound retry material (T1/D-059)', () => {
const mcpReq = seen[0]!;
expect(mcpReq.inputResponses).toEqual({ bare: { action: 'accept', content: { ok: true } } });
expect(mcpReq.droppedInputResponseKeys?.sort()).toEqual(['not-an-object', 'wrapped']);
expect(mcpReq.requestState).toBe('echoed-back');
expect(mcpReq.requestState()).toBe('echoed-back');
// The handler-visible params never carry the lifted retry material.
await receiver.close();
await clientTx.close();

View file

@ -0,0 +1,75 @@
/**
* Typed readers for a retried request's `inputResponses`
* (`ctx.mcpReq.inputResponses`): the schema-aware `acceptedContent` overload
* and the discriminated `inputResponse` view.
*/
import { describe, expect, it } from 'vitest';
import * as z from 'zod/v4';
import { acceptedContent, inputResponse } from '../../src/shared/inputRequired';
const ACCEPTED = { action: 'accept', content: { count: '10', theme: 'release week' } };
const DECLINED = { action: 'decline' };
const SAMPLING = { role: 'assistant', content: { type: 'text', text: 'idea-1' }, model: 'test-model' };
const ROOTS = { roots: [{ uri: 'file:///ws', name: 'ws' }] };
describe('acceptedContent schema overload', () => {
const schema = z.object({ count: z.string(), theme: z.string().optional() });
it('returns validated, typed content for an accepted response', () => {
const content = acceptedContent({ key: ACCEPTED }, 'key', schema);
expect(content).toEqual({ count: '10', theme: 'release week' });
});
it('returns undefined when validation fails (malformed untrusted content never reaches the handler typed)', () => {
expect(acceptedContent({ key: { action: 'accept', content: { count: 42 } } }, 'key', schema)).toBeUndefined();
});
it('returns undefined for declined, missing, and non-elicit entries (same as the 2-arg form)', () => {
expect(acceptedContent({ key: DECLINED }, 'key', schema)).toBeUndefined();
expect(acceptedContent({}, 'key', schema)).toBeUndefined();
expect(acceptedContent(undefined, 'key', schema)).toBeUndefined();
expect(acceptedContent({ key: SAMPLING }, 'key', schema)).toBeUndefined();
});
it('applies schema transforms (the output type, not the input shape)', () => {
const coercing = z.object({ count: z.string().transform(value => Number.parseInt(value, 10)) });
expect(acceptedContent({ key: ACCEPTED }, 'key', coercing)).toEqual({ count: 10 });
});
it('throws on an asynchronously-validating schema', () => {
const asyncSchema = z.object({ count: z.string() }).refine(async () => true);
expect(() => acceptedContent({ key: ACCEPTED }, 'key', asyncSchema)).toThrow(TypeError);
});
it('the 2-arg form is unchanged (structural read, unvalidated cast)', () => {
expect(acceptedContent<{ count: string }>({ key: ACCEPTED }, 'key')).toEqual({ count: '10', theme: 'release week' });
expect(acceptedContent({ key: DECLINED }, 'key')).toBeUndefined();
});
});
describe('inputResponse discriminated view', () => {
it('discriminates elicitation responses with action and content', () => {
expect(inputResponse({ key: ACCEPTED }, 'key')).toEqual({
kind: 'elicit',
action: 'accept',
content: { count: '10', theme: 'release week' }
});
expect(inputResponse({ key: DECLINED }, 'key')).toEqual({ kind: 'elicit', action: 'decline' });
expect(inputResponse({ key: { action: 'cancel' } }, 'key')).toEqual({ kind: 'elicit', action: 'cancel' });
});
it('discriminates sampling and roots responses', () => {
expect(inputResponse({ key: SAMPLING }, 'key')).toEqual({ kind: 'sampling', result: SAMPLING });
expect(inputResponse({ key: ROOTS }, 'key')).toEqual({ kind: 'roots', roots: ROOTS.roots });
});
it('reads missing keys and malformed entries as missing', () => {
expect(inputResponse({}, 'key')).toEqual({ kind: 'missing' });
expect(inputResponse(undefined, 'key')).toEqual({ kind: 'missing' });
expect(inputResponse({ key: 'not-an-object' }, 'key')).toEqual({ kind: 'missing' });
expect(inputResponse({ key: { action: 'something-else' } }, 'key')).toEqual({ kind: 'missing' });
expect(inputResponse({ key: null }, 'key')).toEqual({ kind: 'missing' });
expect(inputResponse({ key: [1, 2] }, 'key')).toEqual({ kind: 'missing' });
});
});

View file

@ -184,7 +184,7 @@ describe('envelope lift on inbound requests', () => {
params: { name: 'echo', arguments: {} }
});
expect(seenCtx?.mcpReq.inputResponses).toEqual(inputResponses);
expect(seenCtx?.mcpReq.requestState).toBe('opaque-state-token');
expect(seenCtx?.mcpReq.requestState()).toBe('opaque-state-token');
});
test('the custom-method (3-arg) path also surfaces the envelope via ctx', async () => {
@ -255,7 +255,7 @@ describe('envelope lift on inbound requests', () => {
expect(seenRequest).toBe(legacy);
expect(seenCtx?.mcpReq.envelope).toBeUndefined();
expect(seenCtx?.mcpReq.inputResponses).toBeUndefined();
expect(seenCtx?.mcpReq.requestState).toBeUndefined();
expect(seenCtx?.mcpReq.requestState()).toBeUndefined();
});
});

View file

@ -82,9 +82,10 @@ export type { CacheHint, CacheScope } from '@modelcontextprotocol/core-internal'
// Multi round-trip requests (protocol revision 2026-07-28): the authoring
// helpers a handler uses to request additional client input by returning an
// input-required result instead of sending a server→client request.
export type { InputRequiredSpec } from '@modelcontextprotocol/core-internal';
export { acceptedContent, inputRequired } from '@modelcontextprotocol/core-internal';
// input-required result instead of sending a server→client request, and the
// typed readers for the responses a retried request carries back.
export type { InputRequiredSpec, InputResponseView } from '@modelcontextprotocol/core-internal';
export { acceptedContent, inputRequired, inputResponse } from '@modelcontextprotocol/core-internal';
// re-export curated public API from core
export * from '@modelcontextprotocol/core-internal/public';

View file

@ -0,0 +1,311 @@
/**
* The legacy `input_required` shim: serves write-once handlers on 2025-era
* sessions. `Server` holds one instance and delegates to it from the
* multi-round-trip seam when a handler returns an input-required result on a
* 2025-era request: each embedded request goes out as a real server→client
* request (`elicitation/create` / `sampling/createMessage` / `roots/list`,
* 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 or the round cap is exhausted.
*
* Semantics mirror the modern client driver so a handler cannot tell which
* era fulfilled it: per-round REPLACED `inputResponses`, byte-exact
* `requestState` echo (re-verified by the configured hook each round), paced
* requestState-only rounds. The loop lives inside the originating request's
* lifetime — nothing is parked, cancellation chains through every leg.
* Failures surface per family: tools/call → `isError` tool results,
* prompts/resources → JSON-RPC errors; malformed results fail loudly as
* server bugs. Package-internal — not exported from the index.
*/
import type {
ClientCapabilities,
CreateMessageRequest,
ElicitRequestFormParams,
ElicitRequestURLParams,
JSONRPCRequest,
RequestOptions,
Result,
ServerContext
} from '@modelcontextprotocol/core-internal';
import {
inputRequiredRoundsExceededMessage,
isInputRequiredResult,
linkedRoundAbort,
missingClientCapabilities,
ProtocolError,
ProtocolErrorCode,
REQUEST_STATE_ONLY_LEG_PACING_MS,
requestStateAccessor,
requiredClientCapabilitiesForInputRequest,
sleep,
withRequestStateValue
} from '@modelcontextprotocol/core-internal';
/**
* Default handler re-entries per originating request — tighter than the
* client driver's 10 because the shim holds a live wire request open.
*/
const DEFAULT_LEGACY_SHIM_MAX_ROUNDS = 8;
/** Default per-leg timeout: legs are human-paced, so the 60s protocol default is wrong. */
const DEFAULT_LEGACY_SHIM_ROUND_TIMEOUT_MS = 600_000;
/** The `ServerOptions.inputRequired` bag with defaults applied. */
export interface ResolvedLegacyShimOptions {
maxRounds: number;
roundTimeoutMs: number;
legacyShim: boolean;
}
/** Resolves and validates `ServerOptions.inputRequired`, failing loudly at construction time. */
export function resolveLegacyShimOptions(
options: { maxRounds?: number; roundTimeoutMs?: number; legacyShim?: boolean } | undefined
): ResolvedLegacyShimOptions {
if (options?.maxRounds !== undefined && (!Number.isInteger(options.maxRounds) || options.maxRounds < 1)) {
throw new RangeError(`inputRequired.maxRounds must be a positive integer (got ${options.maxRounds})`);
}
if (options?.roundTimeoutMs !== undefined && (!Number.isFinite(options.roundTimeoutMs) || options.roundTimeoutMs <= 0)) {
throw new RangeError(`inputRequired.roundTimeoutMs must be a positive number (got ${options.roundTimeoutMs})`);
}
return {
maxRounds: options?.maxRounds ?? DEFAULT_LEGACY_SHIM_MAX_ROUNDS,
roundTimeoutMs: options?.roundTimeoutMs ?? DEFAULT_LEGACY_SHIM_ROUND_TIMEOUT_MS,
legacyShim: options?.legacyShim ?? true
};
}
/** The embedded input-request kinds the 2026-07-28 revision defines. */
export type EmbeddedInputRequestMethod = 'elicitation/create' | 'sampling/createMessage' | 'roots/list';
/** A coerced `inputRequests` entry: the kind-narrowed embedded request. */
export interface CoercedEmbeddedInputRequest {
method: EmbeddedInputRequestMethod;
params?: Record<string, unknown>;
}
/**
* Validates one `inputRequests` entry: malformed or unknown kinds are server
* bugs and fail loudly on both eras. Shared by the modern seam's capability
* check and the shim's gate.
*/
export function coerceEmbeddedInputRequest(
method: string,
key: string,
entry: unknown
): { embedded: CoercedEmbeddedInputRequest; required: ClientCapabilities } {
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`
);
}
return { embedded: embedded as CoercedEmbeddedInputRequest, required };
}
/**
* The 2025-11-25 URL-mode wire shape requires an `elicitationId`; the 2026
* in-band shape has none, so URL legs mint one (CSPRNG-backed, with a
* getRandomValues fallback for runtimes without `randomUUID`).
*/
function syntheticElicitationId(): string {
const webCrypto = globalThis.crypto;
if (webCrypto?.randomUUID !== undefined) {
return webCrypto.randomUUID();
}
const bytes = new Uint8Array(16);
webCrypto.getRandomValues(bytes);
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = [...bytes].map(byte => byte.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/** Per-family surfacing: tools/call → isError result (the 2025 idiom); prompts/resources → JSON-RPC error. */
function legacyShimFailure(method: string, message: string): Result {
if (method === 'tools/call') {
return { content: [{ type: 'text', text: message }], isError: true };
}
throw new ProtocolError(ProtocolErrorCode.InternalError, message);
}
/**
* Everything the shim needs from `Server`: the knobs, the per-request
* resolved capability view (initialize state on sessionful legacy; empty on
* per-request stateless instances), the requestState verify runner
* (deny-on-error → the frozen `-32602`), and the 2025-era senders. The
* shim's own gate is authoritative; elicitation accepted content passes
* through UNVALIDATED for parity with the modern client driver.
*/
export interface LegacyInputRequiredShimHost {
readonly maxRounds: number;
readonly roundTimeoutMs: number;
resolvedClientCapabilities(ctx: ServerContext): ClientCapabilities | undefined;
verifyRequestState(state: string, ctx: ServerContext, method: string): Promise<unknown>;
sendElicitation(params: ElicitRequestFormParams | ElicitRequestURLParams, options: RequestOptions): Promise<unknown>;
sendSampling(params: CreateMessageRequest['params'], options: RequestOptions): Promise<unknown>;
listRoots(params: Record<string, unknown> | undefined, options: RequestOptions): Promise<unknown>;
}
/** The fulfilment loop — see the module doc for the contract. */
export class LegacyInputRequiredShim {
constructor(private readonly _host: LegacyInputRequiredShimHost) {}
async fulfill(
method: string,
handler: (request: JSONRPCRequest, ctx: ServerContext) => Promise<Result>,
request: JSONRPCRequest,
ctx: ServerContext,
firstResult: Result
): Promise<Result> {
const { maxRounds, roundTimeoutMs } = this._host;
const outerSignal = ctx.mcpReq.signal;
let current = firstResult;
let round = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
round += 1;
if (round > maxRounds) {
return legacyShimFailure(method, inputRequiredRoundsExceededMessage(method, maxRounds));
}
// At-least-one re-check per round (server bug → loud, as on modern).
const inputRequests = current.inputRequests as Record<string, unknown> | null | undefined;
const hasInputRequests = inputRequests != null && Object.keys(inputRequests).length > 0;
const requestState = typeof current.requestState === 'string' ? current.requestState : undefined;
if (!hasInputRequests && requestState === undefined) {
throw new ProtocolError(
ProtocolErrorCode.InternalError,
`Handler for ${method} returned an input-required result with neither inputRequests nor requestState ` +
`(every InputRequiredResult must include at least one of the two)`
);
}
let responses: Record<string, unknown> | undefined;
if (hasInputRequests) {
// The shim's own capability pre-check (never gated on
// enforceStrictCapabilities). The whole round gates before
// any wire traffic, so a refusal has no side effects.
const declared = this._host.resolvedClientCapabilities(ctx);
const coerced: [string, CoercedEmbeddedInputRequest][] = [];
for (const [key, entry] of Object.entries(inputRequests!)) {
const { embedded, required } = coerceEmbeddedInputRequest(method, key, entry);
// Request-carrying kinds need params; absent = server bug.
if (embedded.method !== 'roots/list' && embedded.params === undefined) {
throw new ProtocolError(
ProtocolErrorCode.InternalError,
`Handler for ${method} returned an input request '${key}' of kind '${embedded.method}' without params`
);
}
const missing = missingClientCapabilities(required, declared);
if (missing !== undefined) {
return legacyShimFailure(
method,
`Cannot request input '${key}' (${embedded.method}): the client on this 2025-era connection did not ` +
`declare the required capability${declared === undefined ? ' (no client capabilities are available on this connection — per-request legacy serving cannot receive server-to-client requests)' : ''}`
);
}
coerced.push([key, embedded]);
}
// Fulfil concurrently (driver parity); first failure aborts siblings.
const roundAbort = linkedRoundAbort(outerSignal);
try {
const legOptions: RequestOptions = {
relatedRequestId: ctx.mcpReq.id,
timeout: roundTimeoutMs,
resetTimeoutOnProgress: true,
// The no-op handler stamps a progressToken on the leg —
// without one, resetTimeoutOnProgress could never fire.
onprogress: () => {},
signal: roundAbort.signal
};
const fulfilled = await Promise.all(
coerced.map(async ([key, embedded]) => {
try {
return [key, await this._dispatchLeg(embedded, legOptions)] as const;
} catch (error) {
roundAbort.abort(error);
throw error;
}
})
);
responses = Object.fromEntries(fulfilled);
} catch (error) {
if (outerSignal.aborted) {
// Cancelled requests are never answered — propagate.
throw error;
}
return legacyShimFailure(
method,
`Fulfilling input required by '${method}' failed: ${error instanceof Error ? error.message : String(error)}`
);
} finally {
roundAbort.dispose();
}
} else {
// requestState-only round: paced so the loop never hot-spins
// (driver parity); counted in the same round cap.
await sleep(REQUEST_STATE_ONLY_LEG_PACING_MS, outerSignal);
}
// Byte-exact requestState echo: build the round's context first,
// verify against it (the order and view a modern wire retry
// gets), then swap in the decoded payload. Deny-on-error → -32602.
let ctxNext: ServerContext = {
...ctx,
mcpReq: {
...ctx.mcpReq,
// REPLACE semantics: this round's responses only — multi-step
// flows thread earlier answers through requestState.
inputResponses: responses,
droppedInputResponseKeys: undefined,
requestState: requestStateAccessor(requestState)
}
};
if (requestState !== undefined) {
const decoded = await this._host.verifyRequestState(requestState, ctxNext, method);
if (decoded !== undefined) {
ctxNext = withRequestStateValue(ctxNext, decoded);
}
}
// Re-entry hits the same stored handler a wire retry would
// (for McpServer: the full funnel).
const next = await handler(request, ctxNext);
if (!isInputRequiredResult(next)) {
return next;
}
current = next;
}
}
/** Routes one embedded request through the host's existing 2025-era senders (gate already ran). */
private async _dispatchLeg(embedded: CoercedEmbeddedInputRequest, options: RequestOptions): Promise<unknown> {
switch (embedded.method) {
case 'elicitation/create': {
let params = embedded.params as ElicitRequestFormParams | ElicitRequestURLParams;
if (params.mode === 'url' && (params as ElicitRequestURLParams).elicitationId === undefined) {
params = { ...(params as ElicitRequestURLParams), elicitationId: syntheticElicitationId() };
}
return await this._host.sendElicitation(params, options);
}
case 'sampling/createMessage': {
return await this._host.sendSampling(embedded.params as CreateMessageRequest['params'], options);
}
case 'roots/list': {
return await this._host.listRoots(embedded.params, options);
}
}
}
}

View file

@ -47,9 +47,10 @@ export interface RequestStateCodecOptions {
* JSON-serializable payload into the wire string a handler returns from
* `inputRequired({ requestState })`; `verify` is the function to drop into
* {@linkcode server/server.ServerOptions | ServerOptions}`.requestState.verify`
* (it throws on any failure, which the seam answers as the frozen `-32602`)
* AND the function a handler calls to read the payload back from
* `ctx.mcpReq.requestState` after the seam has run.
* (it throws on any failure, which the seam answers as the frozen `-32602`).
* The decoded payload `verify` resolves with is handed to the handler by the
* seam via the typed `ctx.mcpReq.requestState<T>()` accessor — `mint<T>` and
* `requestState<T>()` are the typed encode/read pair.
*/
export interface RequestStateCodec<T = unknown> {
/**
@ -125,10 +126,9 @@ function base64UrlToBytes(s: string): Uint8Array<ArrayBuffer> {
* The codec is **signed, not encrypted**: the body is integrity-protected but
* the client can base64url-decode it and read the payload (`p`) in clear. Do
* not put secrets in the payload; use an AEAD construction if confidentiality
* is required. The handler reads its payload back by calling `verify` again on
* `ctx.mcpReq.requestState` after the seam has run — re-calling `verify` is
* the intended pattern (the seam already proved integrity; the second call is
* the decode).
* is required. The handler reads its payload back via the typed
* `ctx.mcpReq.requestState<T>()` accessor — the seam has already run `verify`
* (integrity proven, payload decoded) by the time the handler is entered.
*
* Verification is fail-closed and constant-time (WebCrypto `subtle.verify` for
* the body MAC; a fixed-length XOR-accumulator compare for the bind tag).

Some files were not shown because too many files have changed in this diff Show more