diff --git a/.changeset/add-request-state-codec.md b/.changeset/add-request-state-codec.md index 8d93b0f9b5..1dcc71d748 100644 --- a/.changeset/add-request-state-codec.md +++ b/.changeset/add-request-state-codec.md @@ -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` (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` 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()` accessor. diff --git a/.changeset/legacy-input-required-shim.md b/.changeset/legacy-input-required-shim.md new file mode 100644 index 0000000000..61bcb33e12 --- /dev/null +++ b/.changeset/legacy-input-required-shim.md @@ -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()` 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()`. 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. diff --git a/.changeset/mrtr-server-seam.md b/.changeset/mrtr-server-seam.md index fd1198b2c8..9285837356 100644 --- a/.changeset/mrtr-server-seam.md +++ b/.changeset/mrtr-server-seam.md @@ -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. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index 1b38a0a2db..ce733f729e 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -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()` — 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` and +`ctx.mcpReq.requestState()` 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({ key: SECRET }); +// ServerOptions: { requestState: { verify: stateCodec.verify } } + +async (args, ctx) => { + const state = ctx.mcpReq.requestState(); + 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`); 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` diff --git a/examples/elicitation/server.ts b/examples/elicitation/server.ts index f0f1d7faa1..256ce13144 100644 --- a/examples/elicitation/server.ts +++ b/examples/elicitation/server.ts @@ -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() ?? + acceptedContent<{ destination: string }>(ctx.mcpReq.inputResponses, 'dest')?.destination; if (!destination) { return inputRequired({ inputRequests: { dest: inputRequired.elicit({ message: 'Where to?', requestedSchema: DEST }) } }); } diff --git a/examples/mrtr/server.ts b/examples/mrtr/server.ts index c06568241f..66fe7bb82d 100644 --- a/examples/mrtr/server.ts +++ b/examples/mrtr/server.ts @@ -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()` + * 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(); const step = state?.step ?? 'confirm'; console.error(`[server] tools/call deploy(${env}) step=${step}`); diff --git a/examples/todos-server/README.md b/examples/todos-server/README.md index d7abd3f282..271ad20374 100644 --- a/examples/todos-server/README.md +++ b/examples/todos-server/README.md @@ -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). diff --git a/examples/todos-server/todos.ts b/examples/todos-server/todos.ts index 40220e39e1..1877c93eda 100644 --- a/examples/todos-server/todos.ts +++ b/examples/todos-server/todos.ts @@ -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()` 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(); const askForIdeas = async (count: number, topic: string): Promise => 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()) { diff --git a/packages/core-internal/src/exports/public/index.ts b/packages/core-internal/src/exports/public/index.ts index 10a357bb04..3fd909d69d 100644 --- a/packages/core-internal/src/exports/public/index.ts +++ b/packages/core-internal/src/exports/public/index.ts @@ -51,6 +51,7 @@ export type { ProtocolOptions, RequestHandlerSchemas, RequestOptions, + RequestStateAccessor, ServerContext } from '../../shared/protocol'; export { DEFAULT_REQUEST_TIMEOUT_MSEC } from '../../shared/protocol'; diff --git a/packages/core-internal/src/shared/inputRequired.ts b/packages/core-internal/src/shared/inputRequired.ts index 43781d8990..0c7ba043e1 100644 --- a/packages/core-internal/src/shared/inputRequired.ts +++ b/packages/core-internal/src/shared/inputRequired.ts @@ -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 = Record>( responses: InputResponses | Record | 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( + responses: InputResponses | Record | undefined, + key: string, + schema: S +): StandardSchemaV1.InferOutput | undefined; + +export function acceptedContent( + responses: InputResponses | Record | 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 } + | { 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 | undefined, key: string): InputResponseView { + if (responses === undefined || typeof responses !== 'object' || responses === null) return { kind: 'missing' }; const entry = (responses as Record)[key]; - if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return undefined; - const candidate = entry as Partial & Record; - 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; + 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 }) + }; + } + 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' }; } /** diff --git a/packages/core-internal/src/shared/inputRequiredDriver.ts b/packages/core-internal/src/shared/inputRequiredDriver.ts index d754b544f9..673461cbc9 100644 --- a/packages/core-internal/src/shared/inputRequiredDriver.ts +++ b/packages/core-internal/src/shared/inputRequiredDriver.ts @@ -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 { +export function sleep(ms: number, signal: AbortSignal | undefined): Promise { 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 { /** * 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 diff --git a/packages/core-internal/src/shared/inputRequiredEngine.ts b/packages/core-internal/src/shared/inputRequiredEngine.ts index b665edbbde..cc8fec2c4b 100644 --- a/packages/core-internal/src/shared/inputRequiredEngine.ts +++ b/packages/core-internal/src/shared/inputRequiredEngine.ts @@ -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 { 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') diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 08cc100477..ffab6e8df8 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -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`) + * — pair with `createRequestStateCodec` so the claim is backed by the + * codec's verification. + */ +export type RequestStateAccessor = () => 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 | 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(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`/`requestState()` 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 { 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 diff --git a/packages/core-internal/src/types/types.ts b/packages/core-internal/src/types/types.ts index 2338ff3013..b16a7b5efc 100644 --- a/packages/core-internal/src/types/types.ts +++ b/packages/core-internal/src/types/types.ts @@ -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'; diff --git a/packages/core-internal/test/shared/inputRequiredFunnel.test.ts b/packages/core-internal/test/shared/inputRequiredFunnel.test.ts index d12b526bd1..85642f8f43 100644 --- a/packages/core-internal/test/shared/inputRequiredFunnel.test.ts +++ b/packages/core-internal/test/shared/inputRequiredFunnel.test.ts @@ -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(); diff --git a/packages/core-internal/test/shared/inputRequiredReaders.test.ts b/packages/core-internal/test/shared/inputRequiredReaders.test.ts new file mode 100644 index 0000000000..03fde34f1b --- /dev/null +++ b/packages/core-internal/test/shared/inputRequiredReaders.test.ts @@ -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' }); + }); +}); diff --git a/packages/core-internal/test/shared/wireOnlyLift.test.ts b/packages/core-internal/test/shared/wireOnlyLift.test.ts index 4ce1395ae1..e2ce36c5fa 100644 --- a/packages/core-internal/test/shared/wireOnlyLift.test.ts +++ b/packages/core-internal/test/shared/wireOnlyLift.test.ts @@ -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(); }); }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index b419da882d..e1b1a6bfea 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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'; diff --git a/packages/server/src/server/legacyInputRequiredShim.ts b/packages/server/src/server/legacyInputRequiredShim.ts new file mode 100644 index 0000000000..2657e1fe7d --- /dev/null +++ b/packages/server/src/server/legacyInputRequiredShim.ts @@ -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; +} + +/** + * 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 }; + 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; + sendElicitation(params: ElicitRequestFormParams | ElicitRequestURLParams, options: RequestOptions): Promise; + sendSampling(params: CreateMessageRequest['params'], options: RequestOptions): Promise; + listRoots(params: Record | undefined, options: RequestOptions): Promise; +} + +/** 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, + request: JSONRPCRequest, + ctx: ServerContext, + firstResult: Result + ): Promise { + 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 | 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 | 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 { + 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); + } + } + } +} diff --git a/packages/server/src/server/requestStateCodec.ts b/packages/server/src/server/requestStateCodec.ts index 91433a144e..9fe499b369 100644 --- a/packages/server/src/server/requestStateCodec.ts +++ b/packages/server/src/server/requestStateCodec.ts @@ -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()` accessor — `mint` and + * `requestState()` are the typed encode/read pair. */ export interface RequestStateCodec { /** @@ -125,10 +126,9 @@ function base64UrlToBytes(s: string): Uint8Array { * 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()` 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). diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index dc15beea33..4151849494 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -56,12 +56,14 @@ import { Protocol, ProtocolError, ProtocolErrorCode, - requiredClientCapabilitiesForInputRequest, SdkError, - SdkErrorCode + SdkErrorCode, + withRequestStateValue } from '@modelcontextprotocol/core-internal'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; +import { coerceEmbeddedInputRequest, LegacyInputRequiredShim, resolveLegacyShimOptions } from './legacyInputRequiredShim'; + /** * The request methods whose 2026-07-28 result vocabulary includes * `input_required` (the multi round-trip methods). Returning an @@ -117,20 +119,51 @@ export type ServerOptions = ProtocolOptions & { */ cacheHints?: Partial>; + /** + * Multi-round-trip serving knobs. On 2026-era requests the client + * fulfils `input_required` returns; on 2025-era connections the SDK's + * legacy shim fulfils them server-side (real server→client requests + + * handler re-entry), so handlers are written once and serve both eras. + */ + inputRequired?: { + /** + * Handler re-entries per originating request before the shim fails + * (tools/call: `isError` result; prompts/resources: JSON-RPC error). + * @default 8 + */ + maxRounds?: number; + + /** + * Per-leg timeout (ms) for the shim's embedded server→client + * requests, sent with `resetTimeoutOnProgress: true`. Human-paced — + * deliberately far above the 60s protocol default. + * @default 600_000 + */ + roundTimeoutMs?: number; + + /** + * `false` disables the shim: an `input_required` return on a + * 2025-era request fails loudly (the pre-shim behavior). + * @default true + */ + legacyShim?: boolean; + }; + /** * Multi-round-trip `requestState` integrity hook (protocol revision * 2026-07-28). */ requestState?: { /** - * Called on every re-entered multi-round-trip request that carries a - * `requestState` (i.e. whenever `ctx.mcpReq.requestState` is present), - * BEFORE the handler runs. Throw or reject to refuse the request: the - * seam answers with a wire-level `-32602` Invalid Params error whose - * message is frozen to `"Invalid or expired requestState"` and whose - * `data.reason` is `'invalid_request_state'` — the thrown reason is - * surfaced via the server's `onerror` callback only and never reaches - * the wire. + * Called on every multi-round-trip request round whose echoed + * `requestState` is a string (i.e. whenever + * `ctx.mcpReq.requestState()` would return one), BEFORE the handler + * runs — including the legacy shim's in-process rounds. Throw or + * reject to refuse the request: the seam answers with a wire-level + * `-32602` Invalid Params error whose message is frozen to + * `"Invalid or expired requestState"` and whose `data.reason` is + * `'invalid_request_state'` — the thrown reason is surfaced via the + * server's `onerror` callback only and never reaches the wire. * * This is the place to put HMAC or AEAD verification of * `requestState`. The spec MUST for integrity-protecting state that @@ -139,16 +172,20 @@ export type ServerOptions = ProtocolOptions & { * the SDK provides NO default verification — * {@linkcode server/requestStateCodec.createRequestStateCodec | createRequestStateCodec} * is the SDK-provided HMAC helper whose `verify` drops in here - * directly. Leaving this option - * unconfigured keeps today's behavior — `ctx.mcpReq.requestState` is - * passed through raw and MUST be treated as attacker-controlled - * input. + * directly. Leaving this option unconfigured keeps the passthrough + * behavior — `ctx.mcpReq.requestState()` returns the raw wire string, + * which MUST be treated as attacker-controlled input. * - * The return value is ignored (the seam awaits-and-discards); the - * hook signature accepts any return so a verifier that also yields - * the decoded payload — as + * The resolved value is LOAD-BEARING: when the hook resolves with a + * non-`undefined` value — as * {@linkcode server/requestStateCodec.RequestStateCodec | RequestStateCodec}`.verify` - * does — is directly assignable. + * does (the decoded payload) — the seam hands THAT value to the + * handler via the typed `ctx.mcpReq.requestState()` accessor, so a + * codec-using handler reads its verified state with no second decode + * call. A verifier that is not also the decoder should resolve + * `undefined` (return nothing) to keep the accessor on the raw wire + * string — resolving an incidental value (e.g. a boolean + * verification flag) would replace what the handler reads. */ verify?: (state: string, ctx: ServerContext) => unknown | Promise; }; @@ -236,6 +273,21 @@ export class Server extends Protocol { private _jsonSchemaValidator: jsonSchemaValidator; private _cacheHints?: ServerOptions['cacheHints']; private _requestStateVerify?: (state: string, ctx: ServerContext) => unknown | Promise; + private _inputRequiredServing: { maxRounds: number; roundTimeoutMs: number; legacyShim: boolean }; + private _legacyShim?: LegacyInputRequiredShim; + + /** Lazily-built legacy shim; the loop lives in legacyInputRequiredShim.ts behind a narrow host contract. */ + private _legacyInputRequiredShim(): LegacyInputRequiredShim { + return (this._legacyShim ??= new LegacyInputRequiredShim({ + maxRounds: this._inputRequiredServing.maxRounds, + roundTimeoutMs: this._inputRequiredServing.roundTimeoutMs, + resolvedClientCapabilities: ctx => this._inputRequestCapabilityView(ctx), + verifyRequestState: (state, ctx, method) => this._verifyRequestState(state, ctx, method), + sendElicitation: (params, options) => this._sendElicitationLeg(params, options, { validateAcceptedContent: false }), + sendSampling: (params, options) => this.createMessage(params, options), + listRoots: (params, options) => this.listRoots(params, options) + })); + } /** * Callback for when initialization has fully completed (i.e., the client has sent an `notifications/initialized` notification). @@ -255,6 +307,8 @@ export class Server extends Protocol { this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); this._requestStateVerify = options?.requestState?.verify; + this._inputRequiredServing = resolveLegacyShimOptions(options?.inputRequired); + // Configured cache hints fail loudly at construction time (before any // handler registration consults them). if (options?.cacheHints !== undefined) { @@ -506,11 +560,14 @@ export class Server extends Protocol { * 2026-07-28 wire and the throw is not silently converted. Requests * served on the 2025 era keep today's `-32042` behavior byte-exact (the * error is rethrown unchanged). - * - an input-required RETURN is only legal toward the 2026-07-28 era; it - * must satisfy the at-least-one rule (`inputRequests` or - * `requestState`), and every embedded request must be covered by the - * capabilities the client declared on this request's envelope - * (violations answer with the typed `-32021` error). + * - an input-required RETURN toward a 2026-07-28 request must satisfy + * the at-least-one rule, and every embedded request must be covered by + * the capabilities declared on the request's envelope (violations + * answer the typed `-32021` error). Toward a 2025-era request the + * return is fulfilled by the default-on legacy shim, whose own gate + * consults the initialize-declared capabilities and surfaces + * violations per family; `inputRequired.legacyShim: false` restores + * the pre-shim loud failure. */ private async _invokeInputRequiredCapableHandler( method: string, @@ -528,28 +585,23 @@ export class Server extends Protocol { // wire field is `string | undefined`) is treated as invalid regardless // of whether a hook is configured, so a malformed value cannot bypass // verification. - const rawRequestState = ctx.mcpReq.requestState as unknown; + const rawRequestState: unknown = ctx.mcpReq.requestState(); if (rawRequestState !== undefined && typeof rawRequestState !== 'string') { throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid or expired requestState', { reason: 'invalid_request_state' }); } - if (this._requestStateVerify !== undefined && typeof rawRequestState === 'string') { - try { - await this._requestStateVerify(rawRequestState, ctx); - } catch (error) { - this.onerror?.( - new Error(`requestState verification rejected ${method}: ${error instanceof Error ? error.message : String(error)}`) - ); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid or expired requestState', { - reason: 'invalid_request_state' - }); + let ctxForHandler = ctx; + if (typeof rawRequestState === 'string') { + const decoded = await this._verifyRequestState(rawRequestState, ctx, method); + if (decoded !== undefined) { + ctxForHandler = withRequestStateValue(ctx, decoded); } } let result: Result; try { - result = await handler(request, ctx); + result = await handler(request, ctxForHandler); } catch (error) { if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { if (!servedModern) { @@ -577,15 +629,19 @@ export class Server extends Protocol { } if (!servedModern) { - // The 2025-era wire has no input_required vocabulary: fail loudly - // rather than putting a mis-typed result on the wire. A handler - // that serves both eras branches on the served era and uses the - // push-style APIs toward 2025-era requests. - throw new ProtocolError( - ProtocolErrorCode.InternalError, - `Handler for ${method} returned an input-required result, but this request is served on protocol revision ` + - `${this._negotiatedProtocolVersion ?? LATEST_PROTOCOL_VERSION}, which has no input_required vocabulary` - ); + if (!this._inputRequiredServing.legacyShim) { + // The escape hatch (`inputRequired.legacyShim: false`) + // restores the pre-shim posture: the 2025-era wire has no + // input_required vocabulary, so fail loudly rather than + // putting a mis-typed result on the wire. + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned an input-required result, but this request is served on protocol revision ` + + `${this._negotiatedProtocolVersion ?? LATEST_PROTOCOL_VERSION}, which has no input_required vocabulary` + ); + } + // Write-once handlers served to deployed 2025 clients. + return await this._legacyInputRequiredShim().fulfill(method, handler, request, ctxForHandler, result); } // F7 at-least-one re-check (hand-built results are legal; the rule is @@ -601,27 +657,12 @@ export class Server extends Protocol { ); } - // Per-embedded-request capability check against the capabilities the - // client declared on THIS request's envelope (-32021 on violation). + // Per-embedded-request capability check against the request's own + // envelope declaration (-32021 on violation). if (hasInputRequests) { - const declared = ctx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined; + const declared = this._inputRequestCapabilityView(ctx); for (const [key, entry] of Object.entries(inputRequests)) { - if (entry === null || typeof entry !== 'object' || typeof (entry as { method?: unknown }).method !== 'string') { - throw new ProtocolError( - ProtocolErrorCode.InternalError, - `Handler for ${method} returned an invalid input request '${key}': each inputRequests entry must be an ` + - `embedded elicitation/create, sampling/createMessage, or roots/list request` - ); - } - const embedded = entry as { method: string; params?: Record }; - const required = requiredClientCapabilitiesForInputRequest(embedded); - if (required === undefined) { - throw new ProtocolError( - ProtocolErrorCode.InternalError, - `Handler for ${method} returned an input request '${key}' of kind '${embedded.method}', which is not an ` + - `embedded request the 2026-07-28 revision defines` - ); - } + const { embedded, required } = coerceEmbeddedInputRequest(method, key, entry); const missing = missingClientCapabilities(required, declared); if (missing !== undefined) { throw new MissingRequiredClientCapabilityError( @@ -636,6 +677,40 @@ export class Server extends Protocol { return result; } + /** + * Runs the configured `requestState.verify` hook and returns its + * resolved value (`undefined` when unconfigured or the hook returns + * nothing). Deny-on-error: any hook failure answers the frozen `-32602`; + * the reason goes to `onerror` only. + */ + private async _verifyRequestState(state: string, ctx: ServerContext, method: string): Promise { + if (this._requestStateVerify === undefined) { + return undefined; + } + try { + return await this._requestStateVerify(state, ctx); + } catch (error) { + this.onerror?.( + new Error(`requestState verification rejected ${method}: ${error instanceof Error ? error.message : String(error)}`) + ); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid or expired requestState', { + reason: 'invalid_request_state' + }); + } + } + + /** + * The per-request resolved client-capabilities view: the request's own + * `_meta` envelope on the 2026 era; the `initialize`-declared state on a + * 2025-era connection. Per-request instances that never saw an + * initialize (stateless legacy) hold nothing, so gates refuse there. + */ + private _inputRequestCapabilityView(ctx: ServerContext): ClientCapabilities | undefined { + return this._servedModernEra() + ? (ctx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined) + : this._clientCapabilities; + } + /** * Guard for the push-style server→client request APIs ({@linkcode createMessage}, * {@linkcode elicitInput}, {@linkcode listRoots}, {@linkcode ping}) on a @@ -1061,23 +1136,49 @@ export class Server extends Protocol { if (!this._clientCapabilities?.elicitation?.url) { throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support url elicitation.'); } + break; + } + case 'form': { + if (!this._clientCapabilities?.elicitation?.form) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support form elicitation.'); + } + break; + } + } + + return this._sendElicitationLeg(params, options); + } + + /** + * The capability-check-free core of {@linkcode elicitInput}. The shim + * uses it because its gate differs from the public checks: a bare + * `elicitation: {}` counts as form support (the pre-mode rule), and + * accepted content passes through unvalidated for parity with the + * modern client driver (handlers validate via the schema-aware + * `acceptedContent` overload and can re-ask). + */ + private async _sendElicitationLeg( + params: ElicitRequestFormParams | ElicitRequestURLParams, + options?: RequestOptions, + behavior?: { validateAcceptedContent: boolean } + ): Promise { + const mode = (params.mode ?? 'form') as 'form' | 'url'; + const validateAcceptedContent = behavior?.validateAcceptedContent ?? true; + switch (mode) { + case 'url': { const urlParams = params as ElicitRequestURLParams; // Method-keyed request(): the era registry's plain // ElicitResult schema is exactly the narrow surface. return this.request({ method: 'elicitation/create', params: urlParams }, options); } case 'form': { - if (!this._clientCapabilities?.elicitation?.form) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support form elicitation.'); - } - const formParams: ElicitRequestFormParams = params.mode === 'form' ? (params as ElicitRequestFormParams) : { ...(params as ElicitRequestFormParams), mode: 'form' }; const result = await this.request({ method: 'elicitation/create', params: formParams }, options); - if (result.action === 'accept' && result.content && formParams.requestedSchema) { + if (validateAcceptedContent && result.action === 'accept' && result.content && formParams.requestedSchema) { try { const validator = this._jsonSchemaValidator.getValidator(formParams.requestedSchema as JsonSchemaType); const validationResult = validator(result.content); diff --git a/packages/server/test/server/inputRequired.test.ts b/packages/server/test/server/inputRequired.test.ts index ab6895abfe..22dde018b8 100644 --- a/packages/server/test/server/inputRequired.test.ts +++ b/packages/server/test/server/inputRequired.test.ts @@ -271,8 +271,15 @@ describe('guards', () => { await close(); }); - it('a 2025-era request never sees an input_required result: the server fails loudly instead (server-bug guard)', async () => { - const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + // The default posture on 2025-era requests is the legacy shim (fulfil the + // embedded requests over the live session — see legacyInputRequiredShim.test.ts); + // the pre-shim loud failure remains reachable via the escape hatch and is + // pinned here. + it('with inputRequired.legacyShim: false, a 2025-era request never sees an input_required result: the server fails loudly instead', async () => { + const server = new McpServer( + { name: 's', version: '1.0.0' }, + { capabilities: { tools: {} }, inputRequired: { legacyShim: false } } + ); server.registerTool('deploy', { inputSchema: z.object({}) }, async () => inputRequired({ requestState: 'state' })); const { request, close } = await wire(server); @@ -413,11 +420,33 @@ describe('requestState.verify hook', () => { await close(); }); + it('the hook’s resolved value (the decoded payload) backs the typed ctx.mcpReq.requestState() accessor', async () => { + const server = new McpServer( + { name: 's', version: '1.0.0' }, + { + capabilities: { tools: {} }, + requestState: { verify: state => ({ decodedFrom: state }) } + } + ); + let seen: { decodedFrom: string } | undefined; + server.registerTool('deploy', { inputSchema: z.object({}) }, async (_args, ctx) => { + seen = ctx.mcpReq.requestState<{ decodedFrom: string }>(); + return { content: [{ type: 'text', text: 'ok' }] }; + }); + const { request, close } = await wire(server, { era: 'modern' }); + + const answer = resultOf(await request(reentry(1, 'sealed'))); + expect(seen).toEqual({ decodedFrom: 'sealed' }); + expect(answer.content).toEqual([{ type: 'text', text: 'ok' }]); + + await close(); + }); + it('not configured → today’s behavior (raw passthrough; the handler reads the state itself)', async () => { const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); let seen: string | undefined; server.registerTool('deploy', { inputSchema: z.object({}) }, async (_args, ctx) => { - seen = ctx.mcpReq.requestState; + seen = ctx.mcpReq.requestState(); return { content: [{ type: 'text', text: 'ok' }] }; }); const { request, close } = await wire(server, { era: 'modern' }); diff --git a/packages/server/test/server/legacyInputRequiredShim.test.ts b/packages/server/test/server/legacyInputRequiredShim.test.ts new file mode 100644 index 0000000000..62d640b179 --- /dev/null +++ b/packages/server/test/server/legacyInputRequiredShim.test.ts @@ -0,0 +1,779 @@ +/** + * The legacy `input_required` shim (write-once handlers on 2025-era + * sessions): + * + * - an MRTR-native handler returning `input_required` on a 2025-era + * sessionful connection has each embedded request sent as a REAL + * server→client request (`elicitation/create`, `sampling/createMessage`, + * `roots/list`) through the existing senders, stamped with the originating + * request's id, and is re-entered with the collected `inputResponses` + * until a final result; + * - round semantics mirror the modern client driver: `inputResponses` are + * REPLACED each round (never accumulated), `requestState` is echoed + * byte-exact and re-verified by the configured hook each round, + * requestState-only rounds are paced, and the round cap counts handler + * re-entries (default 8); + * - the shim's OWN capability pre-check (never gated on + * `enforceStrictCapabilities`) reads the initialize-declared capabilities: + * capability-less clients — including per-request stateless legacy + * instances, which never see an initialize — get a clean, typed refusal + * before any wire traffic, never a hang; + * - failures surface per family: tools/call → `isError` tool results; + * prompts/get and resources/read → JSON-RPC errors; + * - every leg carries the explicit human-paced timeout (600s default, NOT + * the 60s protocol default) with resetTimeoutOnProgress; + * - the shim emits NO progress of its own: the originating token is the + * handler's single must-increase stream, and the shim never adds a second + * author to it. + */ +import type { JSONRPCRequest, RequestId } from '@modelcontextprotocol/core-internal'; +import { acceptedContent, inputRequired, inputResponse } from '@modelcontextprotocol/core-internal'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as z from 'zod/v4'; + +import { errorOf, legacyInitialize, resultOf, toolText, wireLegacy } from './legacyShimHarness'; +import { McpServer } from '../../src/server/mcp'; +import { Server } from '../../src/server/server'; + +/** A sessionful legacy connection serving one write-once elicitation tool. */ +async function elicitingToolServer(options?: ConstructorParameters[1]) { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} }, ...options }); + const seenResponses: Array | undefined> = []; + server.registerTool('deploy', { inputSchema: z.object({ env: z.string() }) }, async ({ env }, ctx) => { + seenResponses.push(ctx.mcpReq.inputResponses); + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (!confirmed?.confirm) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: `Deploy to ${env}?`, + requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + }) + } + }); + } + return { content: [{ type: 'text', text: `deployed to ${env}` }] }; + }); + return { server, seenResponses }; +} + +const callDeploy = (id: number, meta?: Record): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: 'deploy', arguments: { env: 'prod' }, ...(meta !== undefined && { _meta: meta }) } +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('legacy shim: write-once fulfilment on a sessionful 2025-era connection', () => { + it('fulfils an elicitation round as a REAL elicitation/create request and re-enters the handler to a final result', async () => { + const { server, seenResponses } = await elicitingToolServer(); + const wire = await wireLegacy(server); + wire.respond('elicitation/create', () => ({ action: 'accept', content: { confirm: true } })); + + await wire.request(legacyInitialize(1, { elicitation: { form: {} } })); + const answer = await wire.request(callDeploy(2)); + + expect(resultOf(answer).isError).toBeUndefined(); + expect(toolText(answer)).toBe('deployed to prod'); + // The mis-typed input_required result never reached the wire. + expect(resultOf(answer).resultType).toBeUndefined(); + + // A real wire request went out, form-mode, with the handler's params. + const legs = wire.peerRequests('elicitation/create'); + expect(legs).toHaveLength(1); + expect(legs[0]!.params).toMatchObject({ mode: 'form', message: 'Deploy to prod?' }); + + // Stream association: the leg is stamped with the ORIGINATING request id. + const [legOptions] = wire.sentOptionsFor('elicitation/create'); + expect(legOptions?.relatedRequestId).toBe(2); + + // First entry had no responses; the re-entry carried the bare response. + expect(seenResponses).toEqual([undefined, { confirm: { action: 'accept', content: { confirm: true } } }]); + + await wire.close(); + }); + + it('fulfils sampling and roots requests in one round, concurrently, as bare response objects', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + let secondEntry: Record | undefined; + server.registerTool('plan', { inputSchema: z.object({}) }, async (_args, ctx) => { + if (ctx.mcpReq.inputResponses === undefined) { + return inputRequired({ + inputRequests: { + ideas: inputRequired.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'ideas?' } }], + maxTokens: 100 + }), + workspace: inputRequired.listRoots() + } + }); + } + secondEntry = ctx.mcpReq.inputResponses; + const ideas = inputResponse(ctx.mcpReq.inputResponses, 'ideas'); + const text = + ideas.kind === 'sampling' && !Array.isArray(ideas.result.content) && ideas.result.content.type === 'text' + ? ideas.result.content.text + : undefined; + const roots = inputResponse(ctx.mcpReq.inputResponses, 'workspace'); + return { + content: [ + { type: 'text', text: `ideas: ${text}` }, + { type: 'text', text: `roots: ${roots.kind === 'roots' ? roots.roots.map(root => root.uri).join(',') : 'none'}` } + ] + }; + }); + const wire = await wireLegacy(server); + wire.respond('sampling/createMessage', () => ({ + role: 'assistant', + content: { type: 'text', text: 'idea-1' }, + model: 'test-model' + })); + wire.respond('roots/list', () => ({ roots: [{ uri: 'file:///workspace', name: 'ws' }] })); + + await wire.request(legacyInitialize(1, { sampling: {}, roots: {} })); + const answer = await wire.request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'plan', arguments: {} } }); + + expect(toolText(answer)).toBe('ideas: idea-1\nroots: file:///workspace'); + expect(wire.peerRequests('sampling/createMessage')).toHaveLength(1); + expect(wire.peerRequests('roots/list')).toHaveLength(1); + // Bare response objects — exactly the shape a modern retry carries. + expect(secondEntry).toEqual({ + ideas: { role: 'assistant', content: { type: 'text', text: 'idea-1' }, model: 'test-model' }, + workspace: { roots: [{ uri: 'file:///workspace', name: 'ws' }] } + }); + + await wire.close(); + }); + + it('REPLACES inputResponses each round (driver parity — never accumulates) and echoes requestState byte-exact', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + const entries: Array<{ responses: Record | undefined; state: string | undefined }> = []; + server.registerTool('two-step', { inputSchema: z.object({}) }, async (_args, ctx) => { + entries.push({ responses: ctx.mcpReq.inputResponses, state: ctx.mcpReq.requestState() }); + if (ctx.mcpReq.requestState() === undefined) { + return inputRequired({ + inputRequests: { + first: inputRequired.elicit({ message: 'one?', requestedSchema: { type: 'object', properties: {} } }) + }, + requestState: 'opaque-round-1' + }); + } + if (ctx.mcpReq.requestState() === 'opaque-round-1') { + return inputRequired({ + inputRequests: { + second: inputRequired.elicit({ message: 'two?', requestedSchema: { type: 'object', properties: {} } }) + }, + requestState: 'opaque-round-2' + }); + } + return { content: [{ type: 'text', text: 'done' }] }; + }); + const wire = await wireLegacy(server); + wire.respond('elicitation/create', request => ({ + action: 'accept', + content: { answered: (request.params as { message: string }).message } + })); + + await wire.request(legacyInitialize(1, { elicitation: { form: {} } })); + const answer = await wire.request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'two-step', arguments: {} } }); + + expect(toolText(answer)).toBe('done'); + expect(entries).toHaveLength(3); + expect(entries[0]).toEqual({ responses: undefined, state: undefined }); + // Round 1's response under its key; round 1's state echoed byte-exact. + expect(entries[1]!.state).toBe('opaque-round-1'); + expect(Object.keys(entries[1]!.responses!)).toEqual(['first']); + // Round 2 REPLACED the map: only round 2's key is present. + expect(entries[2]!.state).toBe('opaque-round-2'); + expect(Object.keys(entries[2]!.responses!)).toEqual(['second']); + + await wire.close(); + }); + + it('serves prompts/get write-once handlers through the same loop', async () => { + const server = new Server({ name: 's', version: '1.0.0' }, { capabilities: { prompts: {} } }); + server.setRequestHandler('prompts/get', async (_request, ctx) => { + const name = acceptedContent<{ name: string }>(ctx.mcpReq.inputResponses, 'name'); + if (name === undefined) { + return inputRequired({ + inputRequests: { + name: inputRequired.elicit({ message: 'Name?', requestedSchema: { type: 'object', properties: {} } }) + } + }); + } + return { messages: [{ role: 'user', content: { type: 'text', text: `hello ${name.name}` } }] }; + }); + const wire = await wireLegacy(server); + wire.respond('elicitation/create', () => ({ action: 'accept', content: { name: 'ada' } })); + + await wire.request(legacyInitialize(1, { elicitation: { form: {} } })); + const answer = await wire.request({ jsonrpc: '2.0', id: 2, method: 'prompts/get', params: { name: 'greeting' } }); + + const messages = resultOf(answer).messages as Array<{ content: { text: string } }>; + expect(messages[0]!.content.text).toBe('hello ada'); + + await wire.close(); + }); +}); + +describe('legacy shim: round cap (default 8, counts handler re-entries)', () => { + function alwaysHungryServer(options?: ConstructorParameters[1]) { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {}, prompts: {} }, ...options }); + let invocations = 0; + server.registerTool('hungry', { inputSchema: z.object({}) }, async () => { + invocations += 1; + return inputRequired({ + inputRequests: { more: inputRequired.elicit({ message: 'more?', requestedSchema: { type: 'object', properties: {} } }) } + }); + }); + return { server, invocations: () => invocations }; + } + + it('tools/call exhaustion surfaces as an isError tool result', async () => { + const { server, invocations } = alwaysHungryServer({ inputRequired: { maxRounds: 2 } }); + const wire = await wireLegacy(server); + wire.respond('elicitation/create', () => ({ action: 'accept', content: {} })); + + await wire.request(legacyInitialize(1, { elicitation: { form: {} } })); + const answer = await wire.request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'hungry', arguments: {} } }); + + expect(resultOf(answer).isError).toBe(true); + expect(toolText(answer)).toContain('still required input after 2 rounds'); + // First invocation + 2 re-entries (the cap counts re-entries). + expect(invocations()).toBe(3); + expect(wire.peerRequests('elicitation/create')).toHaveLength(2); + + await wire.close(); + }); + + it('prompts/get exhaustion surfaces as a JSON-RPC error', async () => { + const server = new Server({ name: 's', version: '1.0.0' }, { capabilities: { prompts: {} }, inputRequired: { maxRounds: 1 } }); + server.setRequestHandler('prompts/get', async () => + inputRequired({ + inputRequests: { more: inputRequired.elicit({ message: 'more?', requestedSchema: { type: 'object', properties: {} } }) } + }) + ); + const wire = await wireLegacy(server); + wire.respond('elicitation/create', () => ({ action: 'accept', content: {} })); + + await wire.request(legacyInitialize(1, { elicitation: { form: {} } })); + const answer = await wire.request({ jsonrpc: '2.0', id: 2, method: 'prompts/get', params: { name: 'p' } }); + + expect(errorOf(answer).code).toBe(-32_603); + expect(errorOf(answer).message).toContain('still required input after 1 rounds'); + + await wire.close(); + }); +}); + +describe('legacy shim: capability pre-check (the shim’s own, never enforceStrictCapabilities-gated)', () => { + it('refuses cleanly when the client declared no elicitation capability — no wire traffic, isError for tools/call', async () => { + const { server } = await elicitingToolServer(); + const wire = await wireLegacy(server); + + await wire.request(legacyInitialize(1, {})); + const answer = await wire.request(callDeploy(2)); + + expect(resultOf(answer).isError).toBe(true); + expect(toolText(answer)).toContain("Cannot request input 'confirm' (elicitation/create)"); + // The refusal happened BEFORE any wire traffic. + expect(wire.peerRequests('elicitation/create')).toHaveLength(0); + + await wire.close(); + }); + + it('reads a bare `elicitation: {}` declaration as form support (the pre-mode 2025 meaning — same rule as the modern -32021 gate)', async () => { + const { server } = await elicitingToolServer(); + const wire = await wireLegacy(server); + wire.respond('elicitation/create', () => ({ action: 'accept', content: { confirm: true } })); + + await wire.request(legacyInitialize(1, { elicitation: {} })); + const answer = await wire.request(callDeploy(2)); + + expect(toolText(answer)).toBe('deployed to prod'); + expect(wire.peerRequests('elicitation/create')).toHaveLength(1); + + await wire.close(); + }); + + it('URL-mode elicitation requires elicitation.url specifically', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('signin', { inputSchema: z.object({}) }, async (_args, ctx) => { + if (ctx.mcpReq.inputResponses === undefined) { + return inputRequired({ + inputRequests: { auth: inputRequired.elicitUrl({ message: 'Sign in', url: 'https://example.com/auth' }) } + }); + } + return { content: [{ type: 'text', text: 'ok' }] }; + }); + const wire = await wireLegacy(server); + + await wire.request(legacyInitialize(1, { elicitation: { form: {} } })); + const answer = await wire.request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'signin', arguments: {} } }); + + expect(resultOf(answer).isError).toBe(true); + expect(toolText(answer)).toContain("Cannot request input 'auth' (elicitation/create)"); + + await wire.close(); + }); + + it('sampling with tools requires sampling.tools; prompts/resources refusals surface as JSON-RPC errors', async () => { + const server = new Server({ name: 's', version: '1.0.0' }, { capabilities: { resources: {} } }); + server.setRequestHandler('resources/read', async () => + inputRequired({ + inputRequests: { + pick: inputRequired.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'pick' } }], + maxTokens: 10, + tools: [{ name: 'chooser', inputSchema: { type: 'object' } }] + }) + } + }) + ); + const wire = await wireLegacy(server); + + await wire.request(legacyInitialize(1, { sampling: {} })); + const answer = await wire.request({ jsonrpc: '2.0', id: 2, method: 'resources/read', params: { uri: 'res://x' } }); + + expect(errorOf(answer).code).toBe(-32_603); + expect(errorOf(answer).message).toContain("Cannot request input 'pick' (sampling/createMessage)"); + + await wire.close(); + }); + + it('degrades to a clean refusal on an instance that never saw an initialize (the stateless legacy posture) — no hang', async () => { + // Per-request stateless legacy serving builds a fresh instance per + // POST: no initialize handshake ever runs, so no client capabilities + // exist and there is no return path for server→client requests. The + // shim's structural gate refuses before any send is attempted. + const { server } = await elicitingToolServer(); + const wire = await wireLegacy(server); + + const answer = await wire.request(callDeploy(2)); + + expect(resultOf(answer).isError).toBe(true); + expect(toolText(answer)).toContain('per-request legacy serving cannot receive server-to-client requests'); + expect(wire.peerRequests('elicitation/create')).toHaveLength(0); + + await wire.close(); + }); +}); + +describe('legacy shim: leg failures and validation', () => { + it('a failed leg (peer answers an error) maps per family — tools/call → isError', async () => { + const { server } = await elicitingToolServer(); + const wire = await wireLegacy(server); + wire.respond('elicitation/create', () => ({ __error: { code: -32_000, message: 'user closed the window' } })); + + await wire.request(legacyInitialize(1, { elicitation: { form: {} } })); + const answer = await wire.request(callDeploy(2)); + + expect(resultOf(answer).isError).toBe(true); + expect(toolText(answer)).toContain("Fulfilling input required by 'tools/call' failed"); + expect(toolText(answer)).toContain('user closed the window'); + + await wire.close(); + }); + + it('elicitation accepted content reaches the handler UNVALIDATED (modern-driver parity: the handler re-prompts, the call never dies)', async () => { + // On the 2026 era the client driver passes accepted content through + // without requestedSchema validation — the handler validates with the + // schema-aware acceptedContent overload and can re-issue the request. + // The shim must behave identically or the same handler dies on legacy + // where it recovers on modern. + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + const seen: Array = []; + server.registerTool('deploy', { inputSchema: z.object({}) }, async (_args, ctx) => { + seen.push(ctx.mcpReq.inputResponses?.['confirm']); + const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', z.object({ confirm: z.boolean() })); + if (confirmed?.confirm !== true) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: 'Deploy?', + requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + }) + } + }); + } + return { content: [{ type: 'text', text: 'deployed' }] }; + }); + const wire = await wireLegacy(server); + // First answer violates the schema (string), second conforms. + let calls = 0; + wire.respond('elicitation/create', () => + ++calls === 1 ? { action: 'accept', content: { confirm: 'yes' } } : { action: 'accept', content: { confirm: true } } + ); + + await wire.request(legacyInitialize(1, { elicitation: { form: {} } })); + const answer = await wire.request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'deploy', arguments: {} } }); + + // The malformed content reached the handler (bare, unvalidated), the + // handler re-asked, and the flow completed — no isError. + expect(resultOf(answer).isError).toBeUndefined(); + expect(toolText(answer)).toBe('deployed'); + expect(seen).toEqual([ + undefined, + { action: 'accept', content: { confirm: 'yes' } }, + { action: 'accept', content: { confirm: true } } + ]); + + await wire.close(); + }); + + it('a hand-built embedded request without params is a server bug and fails loudly (-32603), not a leg failure', async () => { + const server = new Server({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler( + 'tools/call', + async () => + ({ + resultType: 'input_required', + inputRequests: { s: { method: 'sampling/createMessage' } } + }) as never + ); + const wire = await wireLegacy(server); + + await wire.request(legacyInitialize(1, { sampling: {} })); + const answer = await wire.request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'x', arguments: {} } }); + + expect(errorOf(answer).code).toBe(-32_603); + expect(errorOf(answer).message).toContain('without params'); + expect(wire.peerRequests('sampling/createMessage')).toHaveLength(0); + + await wire.close(); + }); + + it('URL-mode legs synthesize the elicitationId the 2025-11-25 wire requires (the 2026 in-band shape has none)', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('signin', { inputSchema: z.object({}) }, async (_args, ctx) => { + const view = inputResponse(ctx.mcpReq.inputResponses, 'auth'); + if (view.kind !== 'elicit' || view.action !== 'accept') { + return inputRequired({ + inputRequests: { auth: inputRequired.elicitUrl({ message: 'Sign in', url: 'https://example.com/auth' }) } + }); + } + return { content: [{ type: 'text', text: 'authorized' }] }; + }); + const wire = await wireLegacy(server); + wire.respond('elicitation/create', () => ({ action: 'accept' })); + + await wire.request(legacyInitialize(1, { elicitation: { url: {} } })); + const answer = await wire.request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'signin', arguments: {} } }); + + expect(resultOf(answer).isError).toBeUndefined(); + expect(toolText(answer)).toBe('authorized'); + // The leg satisfied the 2025-11-25 schema: mode url + a synthesized id. + const legs = wire.peerRequests('elicitation/create'); + expect(legs).toHaveLength(1); + const legParams = legs[0]!.params as { mode: string; elicitationId?: unknown }; + expect(legParams.mode).toBe('url'); + expect(typeof legParams.elicitationId).toBe('string'); + expect((legParams.elicitationId as string).length).toBeGreaterThan(0); + + await wire.close(); + }); + + it('a declined elicitation is NOT a failure: the bare decline response reaches the handler', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('ask', { inputSchema: z.object({}) }, async (_args, ctx) => { + if (ctx.mcpReq.inputResponses === undefined) { + return inputRequired({ + inputRequests: { q: inputRequired.elicit({ message: 'sure?', requestedSchema: { type: 'object', properties: {} } }) } + }); + } + const view = inputResponse(ctx.mcpReq.inputResponses, 'q'); + return { content: [{ type: 'text', text: `user said: ${view.kind === 'elicit' ? view.action : 'nothing'}` }] }; + }); + const wire = await wireLegacy(server); + wire.respond('elicitation/create', () => ({ action: 'decline' })); + + await wire.request(legacyInitialize(1, { elicitation: { form: {} } })); + const answer = await wire.request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'ask', arguments: {} } }); + + expect(resultOf(answer).isError).toBeUndefined(); + expect(toolText(answer)).toBe('user said: decline'); + + await wire.close(); + }); +}); + +describe('legacy shim: timeouts (per-leg 600s default, NOT the 60s protocol default)', () => { + it('a leg outlives the 60s protocol default and completes after it', async () => { + vi.useFakeTimers(); + const { server } = await elicitingToolServer(); + const wire = await wireLegacy(server); + + // Defer the answer: capture the leg id, answer manually after + // advancing past the 60s protocol default. + let legId: RequestId | undefined; + wire.respond('elicitation/create', request => { + legId = request.id; + return { __defer: true }; + }); + + await wire.request(legacyInitialize(1, { elicitation: { form: {} } })); + const pending = wire.request(callDeploy(2)); + + // Let the leg go out. + await vi.advanceTimersByTimeAsync(1); + expect(legId).toBeDefined(); + + // 65 seconds: past the 60s protocol default — the leg must still be alive. + await vi.advanceTimersByTimeAsync(65_000); + + await wire.answerFromPeer(legId!, { action: 'accept', content: { confirm: true } }); + + await vi.advanceTimersByTimeAsync(1); + const answer = await pending; + expect(toolText(answer)).toBe('deployed to prod'); + + await wire.close(); + }); + + it('a client reporting progress against the leg resets the leg timeout (resetTimeoutOnProgress is live: the leg carries a progressToken)', async () => { + vi.useFakeTimers(); + const { server } = await elicitingToolServer({ inputRequired: { roundTimeoutMs: 1000 } }); + const wire = await wireLegacy(server); + + let legId: RequestId | undefined; + let legProgressToken: unknown; + wire.respond('elicitation/create', request => { + legId = request.id; + legProgressToken = (request.params as { _meta?: { progressToken?: unknown } })._meta?.progressToken; + return { __defer: true }; + }); + + await wire.request(legacyInitialize(1, { elicitation: { form: {} } })); + const pending = wire.request(callDeploy(2)); + + await vi.advanceTimersByTimeAsync(1); + expect(legId).toBeDefined(); + // The leg carries a token — without one, no progress could ever + // reference it and resetTimeoutOnProgress would be inert. + expect(legProgressToken).toBeDefined(); + + // 600ms in (timeout 1000ms): client reports progress → reset. + await vi.advanceTimersByTimeAsync(600); + await wire.notifyFromPeer({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { progressToken: legProgressToken as string | number, progress: 1 } + }); + // Another 600ms (1200ms total — past the original deadline, within + // the reset one): the leg must still be alive. + await vi.advanceTimersByTimeAsync(600); + await wire.answerFromPeer(legId!, { action: 'accept', content: { confirm: true } }); + + await vi.advanceTimersByTimeAsync(1); + const answer = await pending; + expect(toolText(answer)).toBe('deployed to prod'); + + await wire.close(); + }); + + it('a configured roundTimeoutMs bounds the leg and maps per family', async () => { + vi.useFakeTimers(); + const { server } = await elicitingToolServer({ inputRequired: { roundTimeoutMs: 100 } }); + const wire = await wireLegacy(server); + wire.respond('elicitation/create', () => ({ __defer: true }) as never); + + await wire.request(legacyInitialize(1, { elicitation: { form: {} } })); + const pending = wire.request(callDeploy(2)); + + await vi.advanceTimersByTimeAsync(150); + const answer = await pending; + + expect(resultOf(answer).isError).toBe(true); + expect(toolText(answer)).toContain("Fulfilling input required by 'tools/call' failed"); + + await wire.close(); + }); +}); + +describe('legacy shim: progress (the shim never writes to the originating token)', () => { + it('emits NO synthetic progress, even across a multi-round flow whose originating request carried a progressToken', async () => { + // The originating token is the handler's single must-increase stream. + // The shim deliberately adds no second author to it: a 2025 client + // watching a multi-round flow sees exactly what a hand-written 2025 + // push-style handler would have produced — silence unless the handler + // itself reports progress. + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('two-rounds', { inputSchema: z.object({}) }, async (_args, ctx) => { + const state = ctx.mcpReq.requestState(); + if (state === undefined) { + return inputRequired({ + inputRequests: { a: inputRequired.elicit({ message: 'a?', requestedSchema: { type: 'object', properties: {} } }) }, + requestState: 'r1' + }); + } + if (state === 'r1') { + return inputRequired({ + inputRequests: { b: inputRequired.elicit({ message: 'b?', requestedSchema: { type: 'object', properties: {} } }) }, + requestState: 'r2' + }); + } + return { content: [{ type: 'text', text: 'done' }] }; + }); + const wire = await wireLegacy(server); + wire.respond('elicitation/create', () => ({ action: 'accept', content: {} })); + + await wire.request(legacyInitialize(1, { elicitation: { form: {} } })); + const answer = await wire.request({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'two-rounds', arguments: {}, _meta: { progressToken: 'tok-7' } } + }); + + expect(toolText(answer)).toBe('done'); + expect(wire.notifications.filter(notification => notification.method === 'notifications/progress')).toHaveLength(0); + + await wire.close(); + }); + + it("the handler's own progress on the originating token passes through untouched across re-entries", async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('working', { inputSchema: z.object({}) }, async (_args, ctx) => { + const progressToken = ctx.mcpReq._meta?.progressToken as string; + if (ctx.mcpReq.inputResponses === undefined) { + await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken, progress: 1 } }); + return inputRequired({ + inputRequests: { q: inputRequired.elicit({ message: 'q?', requestedSchema: { type: 'object', properties: {} } }) } + }); + } + await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken, progress: 2 } }); + return { content: [{ type: 'text', text: 'done' }] }; + }); + const wire = await wireLegacy(server); + wire.respond('elicitation/create', () => ({ action: 'accept', content: {} })); + + await wire.request(legacyInitialize(1, { elicitation: { form: {} } })); + await wire.request({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'working', arguments: {}, _meta: { progressToken: 'tok-m' } } + }); + + const progress = wire.notifications + .filter(notification => notification.method === 'notifications/progress') + .map(notification => (notification.params as { progress: number }).progress); + // Exactly the handler's values, in order, with nothing interleaved — + // the stream stays monotonic because it has one author. + expect(progress).toEqual([1, 2]); + + await wire.close(); + }); +}); + +describe('legacy shim: requestState-only rounds are paced (driver parity)', () => { + it('waits ~250ms before re-entering on a requestState-only round', async () => { + vi.useFakeTimers(); + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + const entryTimes: number[] = []; + server.registerTool('shed', { inputSchema: z.object({}) }, async (_args, ctx) => { + entryTimes.push(Date.now()); + if (ctx.mcpReq.requestState() === undefined) { + return inputRequired({ requestState: 'wait' }); + } + return { content: [{ type: 'text', text: 'ready' }] }; + }); + const wire = await wireLegacy(server); + + await wire.request(legacyInitialize(1, {})); + const pending = wire.request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'shed', arguments: {} } }); + + await vi.advanceTimersByTimeAsync(1); + expect(entryTimes).toHaveLength(1); + // 249ms in: still pacing. + await vi.advanceTimersByTimeAsync(248); + expect(entryTimes).toHaveLength(1); + // 250ms: re-entered. + await vi.advanceTimersByTimeAsync(2); + expect(entryTimes).toHaveLength(2); + + const answer = await pending; + expect(toolText(answer)).toBe('ready'); + + await wire.close(); + }); +}); + +describe('legacy shim: requestState verification each round (deny-on-error, frozen -32602)', () => { + it('runs the configured verify hook on every echoed round and hands the decoded payload to the typed accessor', async () => { + const verified: string[] = []; + const server = new McpServer( + { name: 's', version: '1.0.0' }, + { + capabilities: { tools: {} }, + requestState: { + verify: state => { + verified.push(state); + return JSON.parse(state) as unknown; + } + } + } + ); + const decodedSeen: Array = []; + server.registerTool('phased', { inputSchema: z.object({}) }, async (_args, ctx) => { + const state = ctx.mcpReq.requestState<{ phase: string }>(); + decodedSeen.push(state); + if (state === undefined) { + return inputRequired({ + inputRequests: { q: inputRequired.elicit({ message: 'q?', requestedSchema: { type: 'object', properties: {} } }) }, + requestState: JSON.stringify({ phase: 'second' }) + }); + } + return { content: [{ type: 'text', text: `phase was ${state.phase}` }] }; + }); + const wire = await wireLegacy(server); + wire.respond('elicitation/create', () => ({ action: 'accept', content: {} })); + + await wire.request(legacyInitialize(1, { elicitation: { form: {} } })); + const answer = await wire.request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'phased', arguments: {} } }); + + expect(toolText(answer)).toBe('phase was second'); + expect(verified).toEqual([JSON.stringify({ phase: 'second' })]); + expect(decodedSeen).toEqual([undefined, { phase: 'second' }]); + + await wire.close(); + }); + + it('a verify-hook rejection mid-loop answers the frozen -32602 (never per-family-mapped, exactly as a modern wire retry)', async () => { + const server = new McpServer( + { name: 's', version: '1.0.0' }, + { + capabilities: { tools: {} }, + requestState: { + verify: () => { + throw new Error('expired'); + } + } + } + ); + server.registerTool('phased', { inputSchema: z.object({}) }, async () => inputRequired({ requestState: 'sealed-state' })); + const wire = await wireLegacy(server); + + await wire.request(legacyInitialize(1, {})); + const answer = await wire.request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'phased', arguments: {} } }); + + expect(errorOf(answer).code).toBe(-32_602); + expect(errorOf(answer).message).toBe('Invalid or expired requestState'); + expect(errorOf(answer).data).toMatchObject({ reason: 'invalid_request_state' }); + + await wire.close(); + }); +}); + +describe('legacy shim: construction-time knob validation', () => { + it('rejects nonsense knob values loudly', () => { + expect(() => new Server({ name: 's', version: '1' }, { inputRequired: { maxRounds: 0 } })).toThrow(RangeError); + expect(() => new Server({ name: 's', version: '1' }, { inputRequired: { maxRounds: 1.5 } })).toThrow(RangeError); + expect(() => new Server({ name: 's', version: '1' }, { inputRequired: { roundTimeoutMs: -1 } })).toThrow(RangeError); + expect(() => new Server({ name: 's', version: '1' }, { inputRequired: { roundTimeoutMs: Number.NaN } })).toThrow(RangeError); + }); +}); diff --git a/packages/server/test/server/legacyShimHarness.ts b/packages/server/test/server/legacyShimHarness.ts new file mode 100644 index 0000000000..41c1674bcb --- /dev/null +++ b/packages/server/test/server/legacyShimHarness.ts @@ -0,0 +1,119 @@ +/** + * Test harness for the legacy `input_required` shim: wires a server to an + * in-memory peer that can ANSWER server→client requests (elicitation, + * sampling, roots), records outbound messages with their transport send + * options (the relatedRequestId stamp), and resolves originating requests + * with their eventual responses. Shared by the shim unit suite and the + * write-once acceptance test. + */ +import type { + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResultResponse, + RequestId +} from '@modelcontextprotocol/core-internal'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; + +import type { McpServer } from '../../src/server/mcp'; +import type { Server } from '../../src/server/server'; + +export type PeerResponder = ( + request: JSONRPCRequest +) => Record | { __error: { code: number; message: string } } | { __defer: true }; + +export const legacyInitialize = (id: number, capabilities: Record = {}): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities, clientInfo: { name: 'legacy-client', version: '1.0.0' } } +}); + +export function resultOf(message: JSONRPCMessage): Record { + return (message as JSONRPCResultResponse).result as unknown as Record; +} + +export function errorOf(message: JSONRPCMessage): { code: number; message: string; data?: unknown } { + return (message as JSONRPCErrorResponse).error; +} + +export function toolText(message: JSONRPCMessage): string { + const content = resultOf(message).content as Array<{ type: string; text: string }>; + return content.map(block => block.text).join('\n'); +} + +export async function wireLegacy(server: McpServer | Server) { + const [peerTx, serverTx] = InMemoryTransport.createLinkedPair(); + + const sent: Array<{ message: JSONRPCMessage; options?: { relatedRequestId?: RequestId } }> = []; + const originalSend = serverTx.send.bind(serverTx); + serverTx.send = (message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }) => { + sent.push({ message, options }); + return originalSend(message, options); + }; + + const responders = new Map(); + const waiters = new Map void>(); + const notifications: JSONRPCNotification[] = []; + + peerTx.onmessage = message => { + const candidate = message as Partial & Partial; + if (candidate.method !== undefined && candidate.id !== undefined) { + // A server→client request: route to the registered responder. + const responder = responders.get(candidate.method); + if (responder === undefined) { + void peerTx.send({ + jsonrpc: '2.0', + id: candidate.id, + error: { code: -32_601, message: `peer has no responder for ${candidate.method}` } + }); + return; + } + const outcome = responder(message as JSONRPCRequest); + if ('__defer' in outcome) { + // The test answers later via answerFromPeer. + } else if ('__error' in outcome) { + void peerTx.send({ jsonrpc: '2.0', id: candidate.id, error: outcome.__error as { code: number; message: string } }); + } else { + void peerTx.send({ jsonrpc: '2.0', id: candidate.id, result: outcome }); + } + return; + } + if (candidate.method !== undefined) { + notifications.push(message as JSONRPCNotification); + return; + } + const id = (message as { id?: string | number }).id; + const waiter = id === undefined ? undefined : waiters.get(id); + if (id !== undefined && waiter) { + waiters.delete(id); + waiter(message); + } + }; + + await server.connect(serverTx); + await peerTx.start(); + + const request = (message: JSONRPCRequest): Promise => + new Promise(resolve => { + waiters.set(message.id, resolve); + void peerTx.send(message); + }); + + return { + request, + respond: (method: string, responder: PeerResponder) => void responders.set(method, responder), + sent, + notifications, + peerRequests: (method: string) => sent.map(entry => entry.message as JSONRPCRequest).filter(message => message.method === method), + sentOptionsFor: (method: string) => + sent.filter(entry => (entry.message as JSONRPCRequest).method === method).map(entry => entry.options), + answerFromPeer: (id: RequestId, result: Record) => peerTx.send({ jsonrpc: '2.0', id, result }), + notifyFromPeer: (notification: JSONRPCNotification) => peerTx.send(notification), + close: async () => { + await peerTx.close(); + await serverTx.close(); + } + }; +} diff --git a/packages/server/test/server/legacyShimWriteOnce.test.ts b/packages/server/test/server/legacyShimWriteOnce.test.ts new file mode 100644 index 0000000000..e394c37141 --- /dev/null +++ b/packages/server/test/server/legacyShimWriteOnce.test.ts @@ -0,0 +1,210 @@ +/** + * Acceptance: a realistic write-once tool — a brainstorming flow written as + * a 2026-style `requestState` phase machine with NO era branch and NO + * push-style arm — served to a 2025-era client through the legacy shim. The + * multi-round conversation (count elicitation → custom count follow-up → + * sampling) completes over real server→client requests, with the HMAC + * requestState codec verifying state each round. + */ +import type { CallToolResult, ElicitRequestFormParams, InputRequiredResult, JSONRPCRequest } from '@modelcontextprotocol/core-internal'; +import { acceptedContent, inputRequired, inputResponse } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { McpServer } from '../../src/server/mcp'; +import { createRequestStateCodec } from '../../src/server/requestStateCodec'; +import { legacyInitialize, resultOf, toolText, wireLegacy } from './legacyShimHarness'; + +type BrainstormState = + | { step: 'awaiting-count' } + | { step: 'awaiting-custom-count'; topic: string } + | { step: 'awaiting-ideas'; topic: string; count: number }; + +const BRAINSTORM_COUNT_SCHEMA: ElicitRequestFormParams['requestedSchema'] = { + type: 'object', + properties: { + theme: { type: 'string', title: 'Theme for the invented tasks', default: "an engineer's week in hell" }, + count: { type: 'string', title: 'How many tasks should I invent?', enum: ['5', '10', '20', '50', 'custom'] } + }, + required: ['count'] +}; + +const BRAINSTORM_CUSTOM_COUNT_SCHEMA: ElicitRequestFormParams['requestedSchema'] = { + type: 'object', + properties: { + customCount: { type: 'integer', title: 'Custom amount', minimum: 1, maximum: 100 } + }, + required: ['customCount'] +}; + +function buildBrainstormSampling(topic: string, wanted: number) { + return { + systemPrompt: 'You invent short, funny todo items for a given theme. Reply with one task per line, no numbering, no commentary.', + messages: [ + { role: 'user' as const, content: { type: 'text' as const, text: `Invent ${wanted} todo tasks for the theme "${topic}".` } } + ], + maxTokens: Math.min(200 + wanted * 40, 1500) + }; +} + +function parseBrainstormCount(raw: unknown): number | undefined { + const value = typeof raw === 'string' ? Number.parseInt(raw, 10) : typeof raw === 'number' ? raw : Number.NaN; + return Number.isInteger(value) && value >= 1 && value <= 100 ? value : undefined; +} + +function elicitAction(response: unknown): string { + const view = inputResponse({ response }, 'response'); + return view.kind === 'elicit' ? view.action : 'cancel'; +} + +// Userland content convenience over the discriminated reader — text +// extraction is the handler's own one-liner, not SDK surface. +function sampledText(responses: Record | undefined, key: string): string | undefined { + const view = inputResponse(responses, key); + if (view.kind !== 'sampling') return undefined; + const blocks = Array.isArray(view.result.content) ? view.result.content : [view.result.content]; + const text = blocks.find((block): block is { type: 'text'; text: string } => block.type === 'text'); + return text?.text; +} + +describe('acceptance: chat-cli brainstorm_tasks written once, served to a 2025 client', () => { + async function buildBrainstormServer() { + const stateCodec = createRequestStateCodec({ key: 'brainstorm-acceptance-test-key-32bytes!!' }); + const added: string[] = []; + const server = new McpServer( + { name: 'todos', version: '1.0.0' }, + { capabilities: { tools: {} }, requestState: { verify: stateCodec.verify } } + ); + + // The 2026-style requestState phase machine — the ONLY arm; no + // 2025 push-style branch exists anywhere in this handler. + server.registerTool( + 'brainstorm_tasks', + { inputSchema: z.object({ theme: z.string().optional() }) }, + async ({ theme }, ctx): Promise => { + const fallbackTopic = theme ?? "an engineer's week in hell"; + const resolveTopic = (raw: unknown): string => + typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : fallbackTopic; + const countMessage = 'Let me invent some tasks for the board.'; + + const finish = (ideasText: string, wanted: number, topic: string): CallToolResult => { + const titles = ideasText + .split('\n') + .map(line => line.replace(/^[-*\d.\s]+/, '').trim()) + .filter(line => line.length > 0) + .slice(0, wanted); + if (titles.length === 0) { + return { content: [{ type: 'text', text: 'The model did not return any task ideas.' }], isError: true }; + } + added.push(...titles.map(title => `${title} [${topic}]`)); + return { content: [{ type: 'text', text: `Added ${titles.length} brainstormed task(s)` }] }; + }; + const declined = (action: string): CallToolResult => ({ + content: [{ type: 'text', text: `Nothing added (user answered: ${action}).` }] + }); + + const state = ctx.mcpReq.requestState(); + const askForIdeas = async (count: number, topic: string): Promise => + inputRequired({ + inputRequests: { ideas: inputRequired.createMessage(buildBrainstormSampling(topic, count)) }, + requestState: await stateCodec.mint({ step: 'awaiting-ideas', topic, count }) + }); + + switch (state?.step) { + case undefined: { + return inputRequired({ + inputRequests: { + count: inputRequired.elicit({ message: countMessage, requestedSchema: BRAINSTORM_COUNT_SCHEMA }) + }, + requestState: await stateCodec.mint({ step: 'awaiting-count' }) + }); + } + case 'awaiting-count': { + const response = ctx.mcpReq.inputResponses?.['count']; + const accepted = acceptedContent<{ count?: string; theme?: string }>(ctx.mcpReq.inputResponses, 'count'); + if (accepted === undefined) return declined(elicitAction(response)); + const topic = resolveTopic(accepted.theme); + if (accepted.count === 'custom') { + return inputRequired({ + inputRequests: { + customCount: inputRequired.elicit({ + message: 'How many exactly?', + requestedSchema: BRAINSTORM_CUSTOM_COUNT_SCHEMA + }) + }, + requestState: await stateCodec.mint({ step: 'awaiting-custom-count', topic }) + }); + } + const wanted = parseBrainstormCount(accepted.count); + if (wanted === undefined) return declined('cancel'); + return askForIdeas(wanted, topic); + } + case 'awaiting-custom-count': { + const response = ctx.mcpReq.inputResponses?.['customCount']; + const accepted = acceptedContent<{ customCount?: number }>(ctx.mcpReq.inputResponses, 'customCount'); + const wanted = parseBrainstormCount(accepted?.customCount); + if (wanted === undefined) return declined(elicitAction(response)); + return askForIdeas(wanted, state.topic); + } + case 'awaiting-ideas': { + return finish(sampledText(ctx.mcpReq.inputResponses, 'ideas') ?? '', state.count, state.topic); + } + } + } + ); + return { server, added }; + } + + const callBrainstorm = (id: number): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: 'brainstorm_tasks', arguments: { theme: 'release week' } } + }); + + it('runs the full elicit→custom-count→sampling conversation over a 2025 session', async () => { + const { server, added } = await buildBrainstormServer(); + const wire = await wireLegacy(server); + + wire.respond('elicitation/create', request => { + const message = (request.params as { message: string }).message; + if (message === 'How many exactly?') { + return { action: 'accept', content: { customCount: 2 } }; + } + return { action: 'accept', content: { count: 'custom', theme: 'release week' } }; + }); + wire.respond('sampling/createMessage', () => ({ + role: 'assistant', + content: { type: 'text', text: 'Ship the changelog\nApologize to CI' }, + model: 'test-model' + })); + + await wire.request(legacyInitialize(1, { elicitation: { form: {} }, sampling: {} })); + const answer = await wire.request(callBrainstorm(2)); + + expect(resultOf(answer).isError).toBeUndefined(); + expect(toolText(answer)).toBe('Added 2 brainstormed task(s)'); + expect(added).toEqual(['Ship the changelog [release week]', 'Apologize to CI [release week]']); + + // The conversation really happened over the wire: two elicitations + // (count, then custom count), then one sampling request. + expect(wire.peerRequests('elicitation/create')).toHaveLength(2); + expect(wire.peerRequests('sampling/createMessage')).toHaveLength(1); + + await wire.close(); + }); + + it('surfaces a decline as the tool result the handler chose', async () => { + const { server, added } = await buildBrainstormServer(); + const wire = await wireLegacy(server); + wire.respond('elicitation/create', () => ({ action: 'decline' })); + + await wire.request(legacyInitialize(1, { elicitation: { form: {} }, sampling: {} })); + const answer = await wire.request(callBrainstorm(2)); + + expect(toolText(answer)).toBe('Nothing added (user answered: decline).'); + expect(added).toEqual([]); + + await wire.close(); + }); +}); diff --git a/packages/server/test/server/serveStdio.test.ts b/packages/server/test/server/serveStdio.test.ts index a207a32bd2..0a23f5afc4 100644 --- a/packages/server/test/server/serveStdio.test.ts +++ b/packages/server/test/server/serveStdio.test.ts @@ -32,6 +32,7 @@ import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, InMemoryTransport, + inputRequired, isJSONRPCErrorResponse, isJSONRPCResultResponse, LATEST_PROTOCOL_VERSION, @@ -811,3 +812,60 @@ describe('teardown', () => { expect(peerClosed).toBe(true); }); }); + +describe('legacy input_required shim through the stdio entry', () => { + it('a write-once tool returning inputRequired() is fulfilled over the legacy-pinned connection', async () => { + const factory = () => { + const server = new McpServer({ name: 'shim-stdio', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('confirm-deploy', { inputSchema: z.object({}) }, async (_args, ctx) => { + const responses = ctx.mcpReq.inputResponses as Record | undefined; + if (responses?.confirm?.content?.ok !== true) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ message: 'OK?', requestedSchema: { type: 'object', properties: {} } }) + } + }); + } + return { content: [{ type: 'text', text: 'confirmed' }] }; + }); + return server; + }; + const harness = await startEntryWith(factory); + + // Teach the peer side to ANSWER the server→client elicitation leg. + const original = harness.peerTx.onmessage!; + harness.peerTx.onmessage = (message, extra) => { + const candidate = message as { method?: string; id?: string | number }; + if (candidate.method === 'elicitation/create' && candidate.id !== undefined) { + void harness.peerTx.send({ jsonrpc: '2.0', id: candidate.id, result: { action: 'accept', content: { ok: true } } }); + return; + } + original(message, extra); + }; + + const init: JSONRPCRequest = { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { elicitation: { form: {} } }, + clientInfo: { name: 'legacy-client', version: '1.0.0' } + } + }; + expect(isJSONRPCResultResponse(await harness.request(init))).toBe(true); + + const answer = await harness.request({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'confirm-deploy', arguments: {} } + }); + expect(isJSONRPCResultResponse(answer)).toBe(true); + const result = (answer as unknown as { result: { content: Array<{ text: string }>; isError?: boolean } }).result; + expect(result.isError).toBeUndefined(); + expect(result.content[0]!.text).toBe('confirmed'); + + await harness.handle.close(); + }); +}); diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 0df9f18a0a..cc75173e10 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -716,9 +716,10 @@ function createMcpServer() { // Diagnostic tools for the input-required conformance scenarios. Each tool // is written write-once style: it returns `inputRequired(...)` until the // retried request carries the responses it needs (read from - // `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`), then completes. - // These tools are only meaningful toward 2026-07-28 requests; calling them - // on a 2025-era session fails loudly at the server seam by design. + // `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState()`), then completes. + // The conformance scenarios drive them on 2026-07-28 requests; on a + // 2025-era session the default legacy shim would fulfil them by pushing + // real server→client requests instead. // Basic elicitation round trip. Also exercised by the result-type, // missing-input-response, ignore-extra-params and validate-input @@ -821,10 +822,10 @@ function createMcpServer() { requestState: await requestStateCodec.mint({ tool: 'request_state', nonce: randomUUID() }) }); } - // The seam-level verify hook has already proven integrity by the - // time the handler runs; calling `verify` again here just yields - // the payload (and would re-reject if it somehow had not). - const state = ctx.mcpReq.requestState === undefined ? undefined : await requestStateCodec.verify(ctx.mcpReq.requestState, ctx); + // The seam-level verify hook has already proven integrity AND + // decoded the payload by the time the handler runs — the typed + // accessor returns it directly. + const state = ctx.mcpReq.requestState>(); if (state === undefined) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid requestState: missing or failed integrity verification'); } @@ -878,7 +879,7 @@ function createMcpServer() { inputSchema: z.object({}) }, async (_args, ctx): Promise => { - const state = ctx.mcpReq.requestState === undefined ? undefined : await requestStateCodec.verify(ctx.mcpReq.requestState, ctx); + const state = ctx.mcpReq.requestState>(); const round = state?.tool === 'multi_round' && typeof state.round === 'number' ? state.round : 0; if (round === 0) { return inputRequired({ @@ -928,7 +929,7 @@ function createMcpServer() { inputSchema: z.object({}) }, async (_args, ctx): Promise => { - if (ctx.mcpReq.requestState !== undefined && acceptedContent(ctx.mcpReq.inputResponses, 'confirm') !== undefined) { + if (ctx.mcpReq.requestState() !== undefined && acceptedContent(ctx.mcpReq.inputResponses, 'confirm') !== undefined) { return { content: [{ type: 'text', text: 'integrity-ok: requestState verified' }] }; } return inputRequired({ diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 99480ae3f7..4fda9e661f 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -3120,6 +3120,14 @@ export const REQUIREMENTS: Record = { removedInSpecVersion: '2026-07-28', note: 'Bounded to the 2025-11-25 axis: this is the freeze cell pinning that the 2026-07-28 era guard leaves the deployed -32042 surface untouched on legacy serving.' }, + 'typescript:mrtr:legacy-shim:write-once-on-2025': { + source: 'sdk', + behavior: + 'A write-once tool that returns inputRequired() is served to a 2025-era connection by the legacy shim: the embedded request goes out as a REAL server→client elicitation/create over the live session, the client answers it through its registered handler, the handler is re-entered with the collected inputResponses and the byte-exact requestState echo, and the originating call completes as a plain CallToolResult — no era branch in the handler.', + transports: STATEFUL_TRANSPORTS, + removedInSpecVersion: '2026-07-28', + note: 'Bounded to the 2025-11-25 axis (modern-era requests never reach the shim — the client auto-fulfilment driver serves them; see typescript:mrtr:tools-call:write-once-roundtrip). Restricted to stateful arms: the per-request entry has no server→client back-channel, where the shim degrades to its clean capability refusal.' + }, // Legacy SSE 'transport:sse:server-transport': { source: 'sdk', diff --git a/test/e2e/scenarios/mrtr.test.ts b/test/e2e/scenarios/mrtr.test.ts index 9276401b54..08887ac126 100644 --- a/test/e2e/scenarios/mrtr.test.ts +++ b/test/e2e/scenarios/mrtr.test.ts @@ -480,6 +480,43 @@ verifies('roots:mrtr:list:empty', async ({ transport }: TestArgs) => { expect(result.structuredContent).toEqual({ count: 0 }); }); +verifies('typescript:mrtr:legacy-shim:write-once-on-2025', async ({ transport }: TestArgs) => { + const makeServer = () => { + const server = new McpServer({ name: 'shim-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + // The SAME write-once shape as the 2026 cell — no era branch: the + // legacy shim converts the embedded request into a real + // elicitation/create over the session and re-enters the handler. + server.registerTool('deploy', { inputSchema: z.object({ env: z.string() }) }, async ({ env }, ctx) => { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (!confirmed?.confirm) { + return inputRequired({ + inputRequests: { confirm: inputRequired.elicit({ message: `Deploy to ${env}?`, requestedSchema: CONFIRM_SCHEMA }) }, + requestState: 'shim-opaque-state' + }); + } + return { content: [{ type: 'text', text: `deployed to ${env} (state ${ctx.mcpReq.requestState()})` }] }; + }); + return server; + }; + + const client = new Client({ name: 'shim-client', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } }); + const elicitations: Array> = []; + client.setRequestHandler('elicitation/create', async request => { + elicitations.push(request.params as Record); + return { action: 'accept', content: { confirm: true } }; + }); + + await using _ = await wire(transport, makeServer, client); + + const result = await client.callTool({ name: 'deploy', arguments: { env: 'prod' } }); + expect(result.isError).toBeUndefined(); + expect((result.content as Array<{ text: string }>)[0]!.text).toBe('deployed to prod (state shim-opaque-state)'); + + // A REAL elicitation/create reached the client's registered handler. + expect(elicitations).toHaveLength(1); + expect(elicitations[0]).toMatchObject({ mode: 'form', message: 'Deploy to prod?' }); +}); + verifies('typescript:mrtr:legacy-32042-freeze', async ({ transport }: TestArgs) => { const URL_PARAMS = { mode: 'url' as const,