Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/add-request-state-codec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 10 additions & 0 deletions .changeset/legacy-input-required-shim.md
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
felixweinberger marked this conversation as resolved.

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.
Comment thread
felixweinberger marked this conversation as resolved.
2 changes: 1 addition & 1 deletion .changeset/mrtr-server-seam.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
149 changes: 144 additions & 5 deletions docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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`
Expand Down
3 changes: 2 additions & 1 deletion examples/elicitation/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) } });
}
Expand Down
Loading
Loading