From 2b8956a8bf140bfd5070d3577bf48c9877eb3d56 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 11:03:19 +0000 Subject: [PATCH 01/13] Serve input_required handlers on 2025-era connections via a legacy fulfilment shim A tools/call, prompts/get, or resources/read handler that returns an input_required result on a 2025-era connection is now served by a default-on shim at the server seam: each embedded request is sent as a real server-to-client request (elicitation/create, sampling/createMessage, roots/list) over the live session, stamped with the originating request id for stream association, and the handler is re-entered with the collected inputResponses until it returns a final result. Handlers are written once in the input_required style and serve both eras; ServerOptions.inputRequired.legacyShim: false restores the previous loud -32603 failure. Round semantics mirror the client auto-fulfilment driver: per-round replaced inputResponses, byte-exact requestState echo with the verify hook running every round against the round's own context, paced requestState-only rounds, shared round-cap accounting (default 8), and elicitation accepted content passed through unvalidated for the handler to check. Legs carry an explicit human-paced timeout (inputRequired.roundTimeoutMs, default 600s) with a live resetTimeoutOnProgress, URL-mode legs synthesize the elicitationId the 2025-11-25 wire requires, and one synthetic progress tick per completed round (progressToken-gated, monotonic above handler-emitted progress) keeps watchdog clients alive. Failures surface per family: isError tool results for tools/call, JSON-RPC errors for prompts/resources. The shim's own capability pre-check reads the per-request resolved view, so capability-less clients and stateless per-request legacy serving get a clean refusal before any wire traffic. ctx.mcpReq.requestState becomes a typed accessor: requestState() returns the verify hook's decoded payload (createRequestStateCodec's verify), the raw wire string without a hook, or undefined. New typed readers for inputResponses ship from the server package: a schema-aware acceptedContent overload, a discriminated inputResponse view, and samplingText. --- .changeset/legacy-input-required-shim.md | 10 + docs/migration/support-2026-07-28.md | 134 ++- examples/elicitation/server.ts | 3 +- examples/mrtr/server.ts | 11 +- .../core-internal/src/shared/inputRequired.ts | 113 ++- .../src/shared/inputRequiredDriver.ts | 40 +- .../src/shared/inputRequiredEngine.ts | 3 + packages/core-internal/src/shared/protocol.ts | 52 +- packages/core-internal/src/types/types.ts | 6 +- .../test/shared/inputRequiredFunnel.test.ts | 2 +- .../test/shared/inputRequiredReaders.test.ts | 101 +++ .../test/shared/wireOnlyLift.test.ts | 4 +- packages/server/src/index.ts | 7 +- .../server/src/server/requestStateCodec.ts | 14 +- packages/server/src/server/server.ts | 662 +++++++++++++-- .../server/test/server/inputRequired.test.ts | 35 +- .../server/legacyInputRequiredShim.test.ts | 791 ++++++++++++++++++ .../server/test/server/legacyShimHarness.ts | 119 +++ .../test/server/legacyShimWriteOnce.test.ts | 200 +++++ .../server/test/server/serveStdio.test.ts | 58 ++ test/conformance/src/everythingServer.ts | 19 +- test/e2e/requirements.ts | 8 + test/e2e/scenarios/mrtr.test.ts | 37 + 23 files changed, 2296 insertions(+), 133 deletions(-) create mode 100644 .changeset/legacy-input-required-shim.md create mode 100644 packages/core-internal/test/shared/inputRequiredReaders.test.ts create mode 100644 packages/server/test/server/legacyInputRequiredShim.test.ts create mode 100644 packages/server/test/server/legacyShimHarness.ts create mode 100644 packages/server/test/server/legacyShimWriteOnce.test.ts diff --git a/.changeset/legacy-input-required-shim.md b/.changeset/legacy-input-required-shim.md new file mode 100644 index 0000000000..dbcd45fc8a --- /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; synthetic per-round progress (emitted only when the originating request carried a progressToken) stays monotonic above handler-emitted progress on the same token. + +`ctx.mcpReq.requestState` is now a typed accessor: `ctx.mcpReq.requestState()` 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), `inputResponse(responses, key)` (discriminated `missing | elicit | sampling | roots` view), and `samplingText(responses, key)`. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index a094123616..03aa6b3638 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -18,6 +18,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) @@ -178,7 +179,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 @@ -188,9 +192,51 @@ 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': + return finish(samplingText(ctx.mcpReq.inputResponses, 'ideas'), 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 @@ -271,13 +317,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 @@ -310,7 +356,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 @@ -332,6 +378,84 @@ 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), three 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. +- `samplingText(responses, key)` — the text of a sampling response (first text block + for with-tools array content), or `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 | +| `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. + +When the originating request carried a `progressToken`, the shim emits one synthetic +`notifications/progress` per completed round against it, so a deployed 2025 client +composing `resetTimeoutOnProgress` around its call sees liveness across a long +multi-round 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 clients (`enableJsonResponse`) cannot receive server→client + requests mid-call, exactly as with today's `elicitInput`. +- 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/packages/core-internal/src/shared/inputRequired.ts b/packages/core-internal/src/shared/inputRequired.ts index 43781d8990..827325c5a7 100644 --- a/packages/core-internal/src/shared/inputRequired.ts +++ b/packages/core-internal/src/shared/inputRequired.ts @@ -19,13 +19,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 +146,107 @@ 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' }; +} + +/** + * Convenience reader: the text of a sampling response, or `undefined` when + * the entry is missing, not a sampling result, or carries no text block. For + * with-tools results (content array), the first text block's text is + * returned. + */ +export function samplingText(responses: InputResponses | Record | undefined, key: string): string | undefined { + const view = inputResponse(responses, key); + if (view.kind !== 'sampling') return undefined; + const content = view.result.content; + const blocks = Array.isArray(content) ? content : [content]; + for (const block of blocks) { + if (block !== null && typeof block === 'object' && (block as { type?: unknown }).type === 'text') { + const text = (block as { text?: unknown }).text; + if (typeof text === 'string') return text; + } + } + return undefined; } /** 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..d88770cb68 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -283,6 +283,21 @@ function codecResultValidator(codec: WireCodec, method: string): StandardSchemaV }; } +/** + * Builds the `ctx.mcpReq.requestState` accessor for a given resolved value — + * the ONE place the generic-arrow cast lives (an arrow literal cannot carry + * the declared generic signature, so every construction site must cast; this + * factory owns that cast). Used by the protocol layer (raw lifted value), the + * server seam (verify hook's decoded payload), and the legacy shim's + * per-round re-entry contexts. + */ +export function requestStateAccessor(value: unknown): BaseContext['mcpReq']['requestState'] { + return ((): T | undefined => value as T | undefined) as BaseContext['mcpReq']['requestState']; +} + +/** Shared no-state accessor: the common case allocates nothing per request. */ +const NO_REQUEST_STATE = requestStateAccessor(undefined); + /** * Base context provided to all request handlers. */ @@ -346,19 +361,32 @@ 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 echoed by a retried + * request (protocol revision 2026-07-28; on 2025-era connections the + * server's legacy shim echoes it in-process between rounds). + * + * Returns `undefined` when the request carried no `requestState`. + * When the server configured a `ServerOptions.requestState.verify` + * hook and it resolved with a value (as the `verify` of the server + * package's `createRequestStateCodec` does — the decoded payload), + * that value is returned: minting with `codec.mint` and reading + * with `requestState()` is the typed pair. Otherwise the raw wire + * string is returned. + * + * The type parameter is a compile-time cast only — the accessor + * performs no validation of its own. * * 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: - * basic/patterns/mrtr, server requirements 4–5). + * protection by default: 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 via the `requestState.verify` hook, rejecting state that + * fails verification (spec: basic/patterns/mrtr, server requirements + * 4–5). Without a configured hook this accessor returns the raw, + * unverified string. */ - requestState?: string; + requestState: () => T | undefined; /** * An abort signal used to communicate if the request was cancelled from the sender's side. @@ -1020,7 +1048,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..76e1c1bdc3 --- /dev/null +++ b/packages/core-internal/test/shared/inputRequiredReaders.test.ts @@ -0,0 +1,101 @@ +/** + * Typed readers for a retried request's `inputResponses` + * (`ctx.mcpReq.inputResponses`): the schema-aware `acceptedContent` overload, + * the discriminated `inputResponse` view, and the `samplingText` convenience. + */ +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { acceptedContent, inputResponse, samplingText } 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 SAMPLING_WITH_TOOLS = { + role: 'assistant', + content: [ + { type: 'tool_use', id: 't1', name: 'chooser', input: {} }, + { type: 'text', text: 'after-tools' } + ], + 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' }); + }); +}); + +describe('samplingText', () => { + it('returns the text of a single-block sampling response', () => { + expect(samplingText({ key: SAMPLING }, 'key')).toBe('idea-1'); + }); + + it('returns the first text block of a with-tools (array) sampling response', () => { + expect(samplingText({ key: SAMPLING_WITH_TOOLS }, 'key')).toBe('after-tools'); + }); + + it('returns undefined for missing entries, non-sampling kinds, and text-free content', () => { + expect(samplingText({}, 'key')).toBeUndefined(); + expect(samplingText({ key: ACCEPTED }, 'key')).toBeUndefined(); + expect( + samplingText({ key: { role: 'assistant', content: { type: 'image', data: 'aGk=', mimeType: 'image/png' }, model: 'm' } }, 'key') + ).toBeUndefined(); + }); +}); 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..1fba6dfaee 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, samplingText } from '@modelcontextprotocol/core-internal'; // re-export curated public API from core export * from '@modelcontextprotocol/core-internal/public'; 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..8283ce0051 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -42,10 +42,12 @@ import { attachCacheHintFallback, CLIENT_CAPABILITIES_META_KEY, codecForVersion, + inputRequiredRoundsExceededMessage, isInputRequiredResult, isModernProtocolVersion, LATEST_PROTOCOL_VERSION, legacyProtocolVersions, + linkedRoundAbort, LOG_LEVEL_META_KEY, LoggingLevelSchema, mergeCapabilities, @@ -56,9 +58,12 @@ import { Protocol, ProtocolError, ProtocolErrorCode, + REQUEST_STATE_ONLY_LEG_PACING_MS, + requestStateAccessor, requiredClientCapabilitiesForInputRequest, SdkError, - SdkErrorCode + SdkErrorCode, + sleep } from '@modelcontextprotocol/core-internal'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; @@ -69,6 +74,20 @@ import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims' */ const INPUT_REQUIRED_CAPABLE_METHODS: ReadonlySet = new Set(['tools/call', 'prompts/get', 'resources/read']); +/** + * Default round cap for the legacy `input_required` shim (handler re-entries + * per originating request). Deliberately tighter than the modern client + * driver's default of 10: the shim holds a live wire request open per flow. + */ +const DEFAULT_LEGACY_SHIM_MAX_ROUNDS = 8; + +/** + * Default per-leg timeout for the embedded server→client requests the legacy + * shim sends, paired with `resetTimeoutOnProgress: true`. The 60s protocol + * default is wrong for human-in-the-loop legs (form fills, sign-ins). + */ +const DEFAULT_LEGACY_SHIM_ROUND_TIMEOUT_MS = 600_000; + export type ServerOptions = ProtocolOptions & { /** * Capabilities to advertise as being supported by this server. @@ -117,20 +136,66 @@ export type ServerOptions = ProtocolOptions & { */ cacheHints?: Partial>; + /** + * Multi-round-trip serving knobs (`input_required` results from + * `tools/call` / `prompts/get` / `resources/read` handlers). + * + * On 2026-07-28-era requests the client fulfils the embedded requests and + * retries. On 2025-era connections the SDK's **legacy shim** fulfils them + * server-side instead: each embedded request is sent as a real + * server→client request (`elicitation/create`, `sampling/createMessage`, + * `roots/list`) over the live session, and the handler is re-entered with + * the collected `inputResponses` and the echoed `requestState` until it + * returns a final result — handlers are written once and serve both eras. + */ + inputRequired?: { + /** + * Maximum number of handler re-entries the legacy shim performs for a + * single originating request before failing (`tools/call`: an + * `isError` tool result; `prompts/get` / `resources/read`: a JSON-RPC + * error). + * + * @default 8 + */ + maxRounds?: number; + + /** + * Per-leg timeout in milliseconds for each embedded server→client + * request the legacy shim sends, passed with + * `resetTimeoutOnProgress: true`. The default is deliberately much + * larger than the 60s protocol default — embedded requests are + * human-paced (form fills, sign-ins). + * + * @default 600_000 + */ + roundTimeoutMs?: number; + + /** + * Set to `false` to disable the legacy shim: an `input_required` + * return on a 2025-era request then fails loudly (the pre-shim + * behavior), and a handler that serves both eras must branch on the + * served era itself. + * + * @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 +204,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; }; @@ -169,6 +238,81 @@ export type ServerOptions = ProtocolOptions & { let writeClientIdentity: (server: Server, identity: PerRequestClientIdentity) => void; let installDiscoverHandler: (server: Server, servedModernVersions: readonly string[]) => void; +/** + * Returns a context whose `requestState` accessor reads the given value — + * how the seam hands a verify hook's decoded payload (or the shim's per-round + * echo) to the handler without mutating the original context. + */ +function withRequestStateValue(ctx: ServerContext, value: unknown): ServerContext { + return { + ...ctx, + mcpReq: { + ...ctx.mcpReq, + requestState: requestStateAccessor(value) + } + }; +} + +/** The embedded input-request kinds the 2026-07-28 revision defines. */ +type EmbeddedInputRequestMethod = 'elicitation/create' | 'sampling/createMessage' | 'roots/list'; + +/** + * Tracks the highest progress value emitted against the originating request's + * progressToken, so the legacy shim's synthetic per-round ticks stay + * monotonic even when the handler reports its own progress on the same token + * (the spec requires progress to increase per token). + */ +interface SyntheticProgressState { + floor: number; +} + +/** + * Wraps `ctx.mcpReq.notify` to observe handler-emitted progress against the + * given token (pass-through otherwise). Installed by the seam on legacy-era + * multi-round-trip requests that carry a progressToken, BEFORE the first + * handler entry, so the shim's synthetic ticks can continue above anything + * the handler emitted. + */ +function withProgressTracking(ctx: ServerContext, progressToken: string | number, state: SyntheticProgressState): ServerContext { + const notify = ctx.mcpReq.notify; + return { + ...ctx, + mcpReq: { + ...ctx.mcpReq, + notify: notification => { + if (notification.method === 'notifications/progress') { + const params = notification.params as { progressToken?: unknown; progress?: unknown } | undefined; + if (params?.progressToken === progressToken && typeof params.progress === 'number' && params.progress > state.floor) { + state.floor = params.progress; + } + } + return notify(notification); + } + } + }; +} + +/** + * Synthesizes the `elicitationId` the 2025-11-25 URL-mode elicitation shape + * requires: the 2026 in-band shape deliberately has none (correlation lives + * in `requestState`), so a URL-mode leg the legacy shim sends must mint one + * to be schema-valid toward conforming 2025 clients. Always CSPRNG-backed — + * `randomUUID` where available, `getRandomValues` formatted as a v4 UUID + * otherwise (the SDK already requires the Web Crypto API elsewhere). + */ +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)}`; +} + /** Connection-scoped client-identity fields backfilled per request from a validated `_meta` envelope. */ export interface PerRequestClientIdentity { /** The client's name/version information, when the envelope carried it. */ @@ -236,6 +380,7 @@ 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 }; /** * Callback for when initialization has fully completed (i.e., the client has sent an `notifications/initialized` notification). @@ -255,6 +400,26 @@ export class Server extends Protocol { this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); this._requestStateVerify = options?.requestState?.verify; + // Configured multi-round-trip knobs fail loudly at construction time. + const inputRequiredOptions = options?.inputRequired; + if ( + inputRequiredOptions?.maxRounds !== undefined && + (!Number.isInteger(inputRequiredOptions.maxRounds) || inputRequiredOptions.maxRounds < 1) + ) { + throw new RangeError(`inputRequired.maxRounds must be a positive integer (got ${inputRequiredOptions.maxRounds})`); + } + if ( + inputRequiredOptions?.roundTimeoutMs !== undefined && + (!Number.isFinite(inputRequiredOptions.roundTimeoutMs) || inputRequiredOptions.roundTimeoutMs <= 0) + ) { + throw new RangeError(`inputRequired.roundTimeoutMs must be a positive number (got ${inputRequiredOptions.roundTimeoutMs})`); + } + this._inputRequiredServing = { + maxRounds: inputRequiredOptions?.maxRounds ?? DEFAULT_LEGACY_SHIM_MAX_ROUNDS, + roundTimeoutMs: inputRequiredOptions?.roundTimeoutMs ?? DEFAULT_LEGACY_SHIM_ROUND_TIMEOUT_MS, + legacyShim: inputRequiredOptions?.legacyShim ?? true + }; + // Configured cache hints fail loudly at construction time (before any // handler registration consults them). if (options?.cacheHints !== undefined) { @@ -528,28 +693,37 @@ 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') { + // Deny-on-error: ANY verify-hook failure mode (throw or rejection) + // answers the frozen -32602 — the handler never runs on state the + // hook did not pass. + const decoded = await this._verifyRequestState(rawRequestState, ctx, method); + if (decoded !== undefined) { + ctxForHandler = withRequestStateValue(ctx, decoded); } } + // When the legacy shim may engage and the originating request asked + // for progress, observe handler-emitted progress from the FIRST entry + // on, so the shim's synthetic per-round ticks stay monotonic above + // anything the handler reports against the same token. + const progressToken = ctx.mcpReq._meta?.progressToken; + let syntheticProgress: SyntheticProgressState | undefined; + if (!servedModern && this._inputRequiredServing.legacyShim && progressToken !== undefined) { + syntheticProgress = { floor: 0 }; + ctxForHandler = withProgressTracking(ctxForHandler, progressToken, syntheticProgress); + } + 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 +751,22 @@ 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` + ); + } + // The legacy shim: fulfil the embedded requests as real + // server→client requests over the live 2025-era session and + // re-enter the handler until it returns a final result — + // write-once handlers served to deployed 2025 clients. + return await this._fulfillInputRequiredOnLegacy(method, handler, request, ctxForHandler, result, syntheticProgress); } // F7 at-least-one re-check (hand-built results are legal; the rule is @@ -601,27 +782,13 @@ 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 per-request + // resolved view — on this (modern) era the capabilities the client + // declared on THIS request's envelope (-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 } = this._coerceEmbeddedInputRequest(method, key, entry); const missing = missingClientCapabilities(required, declared); if (missing !== undefined) { throw new MissingRequiredClientCapabilityError( @@ -636,6 +803,332 @@ export class Server extends Protocol { return result; } + /** + * Validates one `inputRequests` entry of an input-required result: a + * malformed entry or an unknown embedded-request kind is a server bug and + * fails loudly (both eras — the vocabulary is the 2026-07-28 revision's + * regardless of which era the request is served on). Returns the coerced + * entry together with the client capabilities it requires. + */ + private _coerceEmbeddedInputRequest( + method: string, + key: string, + entry: unknown + ): { embedded: { method: EmbeddedInputRequestMethod; params?: Record }; 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` + ); + } + // The cast records the invariant the check above just established: + // requiredClientCapabilitiesForInputRequest answers undefined for any + // method outside the three embedded kinds. + return { embedded: embedded as { method: EmbeddedInputRequestMethod; params?: Record }, required }; + } + + /** + * Runs the configured `requestState.verify` hook on an echoed + * `requestState` and returns its resolved value (the decoded payload for + * codec verifiers; `undefined` when no hook is configured or the hook + * returns nothing). + * + * Deny-on-error: ANY failure mode of the hook — rejection or synchronous + * throw — is treated as verification failure and answered with the frozen + * `-32602` wire error; the thrown reason is surfaced via `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 legacy `input_required` shim (write-once handlers on 2025-era + * sessions): converts each embedded input 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 so sessionful Streamable HTTP routes + * them onto the originating POST's stream — then re-enters the handler + * with the collected `inputResponses` and the echoed `requestState`, + * until the handler returns a final result or the round cap is exhausted. + * + * Semantics mirror the modern client driver exactly, so a handler cannot + * tell which era fulfilled it: `inputResponses` are per-round (REPLACED, + * never accumulated), `requestState` is echoed byte-exact (and re-verified + * by the configured hook each round, exactly as a wire retry would be), + * requestState-only rounds are paced, and the round cap counts handler + * re-entries. + * + * The loop lives entirely within the originating wire request's lifetime: + * no awaits are parked, no state survives the request, and the caller's + * cancellation chains through every leg. + * + * Failure surfacing is per family: `tools/call` failures (capability + * refusal, leg failure, round-cap exhaustion) become `isError` tool + * results — the 2025-era idiom hosts already render — while `prompts/get` + * and `resources/read` failures surface as JSON-RPC errors. Server bugs + * (malformed input-required results) fail loudly on both eras, and + * requestState verification failures keep the frozen `-32602`. + */ + private async _fulfillInputRequiredOnLegacy( + method: string, + handler: (request: JSONRPCRequest, ctx: ServerContext) => Promise, + request: JSONRPCRequest, + ctx: ServerContext, + firstResult: Result, + syntheticProgress: SyntheticProgressState | undefined + ): Promise { + const { maxRounds, roundTimeoutMs } = this._inputRequiredServing; + const progressToken = ctx.mcpReq._meta?.progressToken; + 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 this._legacyShimFailure(method, inputRequiredRoundsExceededMessage(method, maxRounds)); + } + + // At-least-one re-check per round (hand-built results are legal; + // a violation is a server bug and fails loudly, as on the modern + // era). + 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` — against the per-request + // resolved view. The whole round gates BEFORE any wire + // traffic, so a refusal has no side effects. + const declared = this._inputRequestCapabilityView(ctx); + const coerced: [string, { method: EmbeddedInputRequestMethod; params?: Record }][] = []; + for (const [key, entry] of Object.entries(inputRequests!)) { + const { embedded, required } = this._coerceEmbeddedInputRequest(method, key, entry); + // The wire legs need params for the request-carrying + // kinds; a hand-built entry without them is a server bug + // and fails loudly, like every other malformation. + 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 this._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 (the embedded requests are independent, + // mirroring the modern client driver); the first failure + // aborts the sibling legs via the shared linked per-round + // signal. + const roundAbort = linkedRoundAbort(outerSignal); + try { + const legOptions: RequestOptions = { + relatedRequestId: ctx.mcpReq.id, + timeout: roundTimeoutMs, + resetTimeoutOnProgress: true, + // The no-op handler makes the leg carry a + // progressToken, which is what lets a client that + // reports progress mid-leg actually reset the leg + // timeout — without it resetTimeoutOnProgress could + // never fire (no token, nothing to report against). + onprogress: () => {}, + signal: roundAbort.signal + }; + const fulfilled = await Promise.all( + coerced.map(async ([key, embedded]) => { + try { + return [key, await this._dispatchLegacyInputRequestLeg(embedded, legOptions)] as const; + } catch (error) { + roundAbort.abort(error); + throw error; + } + }) + ); + responses = Object.fromEntries(fulfilled); + } catch (error) { + if (outerSignal.aborted) { + // The originating request was cancelled: propagate so + // the protocol layer drops the response (cancelled + // requests are never answered). + throw error; + } + return this._legacyShimFailure( + method, + `Fulfilling input required by '${method}' failed: ${error instanceof Error ? error.message : String(error)}` + ); + } finally { + roundAbort.dispose(); + } + } else { + // requestState-only (load-shedding) round: fixed pacing so + // the loop never hot-spins; counted in the same round cap + // (mirrors the modern client driver). + await sleep(REQUEST_STATE_ONLY_LEG_PACING_MS, outerSignal); + } + + // One synthetic progress tick per completed round against the + // ORIGINATING request, only when it asked for progress. Rides the + // related-notification path (free relatedRequestId stamp), so a + // deployed 2025 client composing `resetTimeoutOnProgress` around + // its call sees liveness instead of silence between rounds. The + // tick continues above any progress the handler emitted against + // the same token (tracked since the first entry), so the per-token + // stream stays monotonic. + if (progressToken !== undefined && syntheticProgress !== undefined) { + syntheticProgress.floor += 1; + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { + progressToken, + progress: syntheticProgress.floor, + message: `Fulfilling input required by '${method}' (round ${round})` + } + }); + } + + // Byte-exact requestState echo. The re-entry context carries this + // round's material FIRST (raw state accessor + this round's + // responses), then the configured verify hook runs against that + // context — exactly the order and view a modern wire retry gets — + // and its decoded payload replaces the accessor value. + // Deny-on-error → the frozen -32602. + let ctxNext: ServerContext = { + ...ctx, + mcpReq: { + ...ctx.mcpReq, + // REPLACE semantics: this round's responses only — never + // accumulated across rounds (parity with the modern + // client driver; multi-step flows thread earlier answers + // through requestState). + inputResponses: responses, + droppedInputResponseKeys: undefined, + requestState: requestStateAccessor(requestState) + } + }; + if (requestState !== undefined) { + const decoded = await this._verifyRequestState(requestState, ctxNext, method); + if (decoded !== undefined) { + ctxNext = withRequestStateValue(ctxNext, decoded); + } + } + + // Re-entry goes through the SAME stored handler the wire retry + // would hit (for McpServer that is the full funnel: input + // re-validation, output projection, tools/call error catch). + const next = await handler(request, ctxNext); + if (!isInputRequiredResult(next)) { + return next; + } + current = next; + } + } + + /** + * The per-request resolved client-capabilities view (plan ruling F-2): + * the capabilities the request itself is entitled to rely on. On the + * 2026-07-28 era that is the request's own `_meta` envelope declaration; + * on a 2025-era connection it is the `initialize`-declared state this + * connection-pinned instance holds — and per-request instances that never + * saw an initialize (stateless legacy serving) hold nothing, so the view + * is empty there and capability gates refuse structurally. + */ + private _inputRequestCapabilityView(ctx: ServerContext): ClientCapabilities | undefined { + return this._servedModernEra() + ? (ctx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined) + : this._clientCapabilities; + } + + /** + * Routes one embedded input request of the legacy shim through the + * existing 2025-era senders — the same wire paths a hand-written + * era-branching handler used. The shim's capability gate has already run. + * + * Response validation deliberately mirrors the MODERN flow, not the + * public push APIs: wire shapes are era-validated by the senders, but + * elicitation accepted content is NOT re-checked against + * `requestedSchema` — on the 2026 era the client driver passes it through + * and the handler validates with the schema-aware `acceptedContent` + * overload (and can re-issue the request), so the shim does the same to + * keep handler behavior era-identical. + */ + private async _dispatchLegacyInputRequestLeg( + embedded: { method: EmbeddedInputRequestMethod; params?: Record }, + 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) { + // The 2026 in-band URL shape carries no elicitationId + // (correlation lives in requestState), but the 2025-11-25 + // wire schema requires one — synthesize it so conforming + // 2025 clients accept the leg. + params = { ...(params as ElicitRequestURLParams), elicitationId: syntheticElicitationId() }; + } + return await this._sendElicitationLeg(params, options, { validateAcceptedContent: false }); + } + case 'sampling/createMessage': { + return await this._sendSamplingLeg(embedded.params as CreateMessageRequest['params'], options); + } + case 'roots/list': { + return await this.request({ method: 'roots/list', params: embedded.params }, options); + } + } + } + + /** + * Per-family failure surfacing for the legacy shim: `tools/call` + * failures become `isError` tool results (the 2025-era idiom — hosts and + * models already render them), `prompts/get` / `resources/read` failures + * surface as JSON-RPC errors. + */ + private _legacyShimFailure(method: string, message: string): Result { + if (method === 'tools/call') { + return { content: [{ type: 'text', text: message }], isError: true }; + } + throw new ProtocolError(ProtocolErrorCode.InternalError, message); + } + /** * Guard for the push-style server→client request APIs ({@linkcode createMessage}, * {@linkcode elicitInput}, {@linkcode listRoots}, {@linkcode ping}) on a @@ -975,6 +1468,20 @@ export class Server extends Protocol { throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support sampling tools capability.'); } + return this._sendSamplingLeg(params, options); + } + + /** + * The capability-check-free core of {@linkcode createMessage}: message + * structure validation, the wire request, and result-variant validation. + * Shared by the public push API (which applies its era and capability + * checks first) and the legacy `input_required` shim (whose own + * capability gate has already run). + */ + private async _sendSamplingLeg( + params: CreateMessageRequest['params'], + options?: RequestOptions + ): Promise { // Message structure validation - always validate tool_use/tool_result pairs. // These may appear even without tools/toolChoice in the current request when // a previous sampling request returned tool_use and this is a follow-up with results. @@ -1061,23 +1568,54 @@ 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}: mode + * normalization, the wire request, and (for the public push API) + * accepted-content schema validation. Shared by the public push API + * (which applies its era and per-mode capability checks first, and keeps + * its historical content validation) and the legacy `input_required` + * shim, whose own gate applies the documented pre-mode rule instead — a + * bare `elicitation: {}` declaration (the 2025-06-18 shape) counts as + * form support, exactly as the modern era's `-32021` gate reads it — and + * which passes accepted content through UNVALIDATED for parity with the + * modern client driver (the handler validates via the schema-aware + * `acceptedContent` overload and can re-issue the request). + */ + 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..9fcd9adb0b --- /dev/null +++ b/packages/server/test/server/legacyInputRequiredShim.test.ts @@ -0,0 +1,791 @@ +/** + * 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; + * - one synthetic progress notification per completed round rides the + * related-notification path, only when the originating request carried a + * progressToken. + */ +import type { JSONRPCRequest, RequestId } from '@modelcontextprotocol/core-internal'; +import { acceptedContent, inputRequired, inputResponse, samplingText } 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 text = samplingText(ctx.mcpReq.inputResponses, 'ideas'); + 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: synthetic progress (per completed round, progressToken-gated)', () => { + it('emits one notifications/progress per completed round against the originating token', async () => { + 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'); + const progress = wire.notifications.filter(notification => notification.method === 'notifications/progress'); + expect(progress).toHaveLength(2); + expect(progress[0]!.params).toMatchObject({ + progressToken: 'tok-7', + progress: 1, + message: "Fulfilling input required by 'tools/call' (round 1)" + }); + expect(progress[1]!.params).toMatchObject({ progressToken: 'tok-7', progress: 2 }); + + await wire.close(); + }); + + it('synthetic ticks stay monotonic above progress the handler emits against the same token', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('working', { inputSchema: z.object({}) }, async (_args, ctx) => { + if (ctx.mcpReq.inputResponses === undefined) { + // The handler reports its own progress on the originating + // token during the FIRST entry, before asking for input. + const progressToken = ctx.mcpReq._meta?.progressToken as string; + await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken, progress: 100 } }); + return inputRequired({ + inputRequests: { q: inputRequired.elicit({ message: 'q?', requestedSchema: { type: 'object', properties: {} } }) } + }); + } + 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); + // Handler's own 100, then the synthetic round tick ABOVE it — never a + // regression on the per-token stream. + expect(progress).toEqual([100, 101]); + + await wire.close(); + }); + + it('emits nothing when the originating request carried no progressToken', 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: { form: {} } })); + await wire.request(callDeploy(2)); + + expect(wire.notifications.filter(notification => notification.method === 'notifications/progress')).toHaveLength(0); + + 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..574423949d --- /dev/null +++ b/packages/server/test/server/legacyShimWriteOnce.test.ts @@ -0,0 +1,200 @@ +/** + * 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, samplingText } 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'; +} + +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(samplingText(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, From 5d92ca453bdbcf243668a2aaab47545b1fd498ad Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 12:54:18 +0000 Subject: [PATCH 02/13] Keep sampling-text extraction in userland MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The discriminated inputResponse() view and the schema-aware acceptedContent overload carry protocol knowledge (bare response shape discrimination, the documented validation path for unvalidated accepted content) and stay public. Text extraction from a sampling response is a content convenience handlers write themselves — the migration guide now shows it as a one-liner over the discriminated view instead of shipping a samplingText export. --- .changeset/legacy-input-required-shim.md | 2 +- docs/migration/support-2026-07-28.md | 18 ++++++++--- .../core-internal/src/shared/inputRequired.ts | 20 ------------ .../test/shared/inputRequiredReaders.test.ts | 32 ++----------------- packages/server/src/index.ts | 2 +- .../server/legacyInputRequiredShim.test.ts | 8 +++-- .../test/server/legacyShimWriteOnce.test.ts | 14 ++++++-- 7 files changed, 36 insertions(+), 60 deletions(-) diff --git a/.changeset/legacy-input-required-shim.md b/.changeset/legacy-input-required-shim.md index dbcd45fc8a..3a9cbe7226 100644 --- a/.changeset/legacy-input-required-shim.md +++ b/.changeset/legacy-input-required-shim.md @@ -7,4 +7,4 @@ Serve `input_required` handlers on 2025-era connections: the legacy shim (on by `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), `inputResponse(responses, key)` (discriminated `missing | elicit | sampling | roots` view), and `samplingText(responses, key)`. +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/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index 03aa6b3638..bbfdb7e7be 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -227,8 +227,10 @@ async (args, ctx) => { // …decide: follow-up question or the sampling round, carrying // everything learned so far inside the next minted state… } - case 'awaiting-ideas': - return finish(samplingText(ctx.mcpReq.inputResponses, 'ideas'), state.count, state.topic); + case 'awaiting-ideas': { + const ideas = inputResponse(ctx.mcpReq.inputResponses, 'ideas'); + return finish(ideas.kind === 'sampling' ? ideas.result : undefined, state.count, state.topic); + } } }; ``` @@ -379,7 +381,7 @@ 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), three typed readers ship from +structural read with an unvalidated cast), two typed readers ship from `@modelcontextprotocol/server`: - `acceptedContent(responses, key, schema)` — schema-aware overload (any synchronous @@ -388,8 +390,14 @@ structural read with an unvalidated cast), three typed readers ship from - `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. -- `samplingText(responses, key)` — the text of a sampling response (first text block - for with-tools array content), or `undefined`. + +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 text = ideas.kind === 'sampling' && !Array.isArray(ideas.result.content) ? ideas.result.content.text : undefined; +``` --- diff --git a/packages/core-internal/src/shared/inputRequired.ts b/packages/core-internal/src/shared/inputRequired.ts index 827325c5a7..080ea5a669 100644 --- a/packages/core-internal/src/shared/inputRequired.ts +++ b/packages/core-internal/src/shared/inputRequired.ts @@ -229,26 +229,6 @@ export function inputResponse(responses: InputResponses | Record | undefined, key: string): string | undefined { - const view = inputResponse(responses, key); - if (view.kind !== 'sampling') return undefined; - const content = view.result.content; - const blocks = Array.isArray(content) ? content : [content]; - for (const block of blocks) { - if (block !== null && typeof block === 'object' && (block as { type?: unknown }).type === 'text') { - const text = (block as { text?: unknown }).text; - if (typeof text === 'string') return text; - } - } - return undefined; -} - /** * Wraps a result schema so a request issued through `client.request()` / * `ctx.mcpReq.send()` with `allowInputRequired: true` is typed as either the diff --git a/packages/core-internal/test/shared/inputRequiredReaders.test.ts b/packages/core-internal/test/shared/inputRequiredReaders.test.ts index 76e1c1bdc3..03fde34f1b 100644 --- a/packages/core-internal/test/shared/inputRequiredReaders.test.ts +++ b/packages/core-internal/test/shared/inputRequiredReaders.test.ts @@ -1,24 +1,16 @@ /** * Typed readers for a retried request's `inputResponses` - * (`ctx.mcpReq.inputResponses`): the schema-aware `acceptedContent` overload, - * the discriminated `inputResponse` view, and the `samplingText` convenience. + * (`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, samplingText } from '../../src/shared/inputRequired'; +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 SAMPLING_WITH_TOOLS = { - role: 'assistant', - content: [ - { type: 'tool_use', id: 't1', name: 'chooser', input: {} }, - { type: 'text', text: 'after-tools' } - ], - model: 'test-model' -}; const ROOTS = { roots: [{ uri: 'file:///ws', name: 'ws' }] }; describe('acceptedContent schema overload', () => { @@ -81,21 +73,3 @@ describe('inputResponse discriminated view', () => { expect(inputResponse({ key: [1, 2] }, 'key')).toEqual({ kind: 'missing' }); }); }); - -describe('samplingText', () => { - it('returns the text of a single-block sampling response', () => { - expect(samplingText({ key: SAMPLING }, 'key')).toBe('idea-1'); - }); - - it('returns the first text block of a with-tools (array) sampling response', () => { - expect(samplingText({ key: SAMPLING_WITH_TOOLS }, 'key')).toBe('after-tools'); - }); - - it('returns undefined for missing entries, non-sampling kinds, and text-free content', () => { - expect(samplingText({}, 'key')).toBeUndefined(); - expect(samplingText({ key: ACCEPTED }, 'key')).toBeUndefined(); - expect( - samplingText({ key: { role: 'assistant', content: { type: 'image', data: 'aGk=', mimeType: 'image/png' }, model: 'm' } }, 'key') - ).toBeUndefined(); - }); -}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 1fba6dfaee..e1b1a6bfea 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -85,7 +85,7 @@ export type { CacheHint, CacheScope } 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, samplingText } 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/test/server/legacyInputRequiredShim.test.ts b/packages/server/test/server/legacyInputRequiredShim.test.ts index 9fcd9adb0b..d7b9b82dbf 100644 --- a/packages/server/test/server/legacyInputRequiredShim.test.ts +++ b/packages/server/test/server/legacyInputRequiredShim.test.ts @@ -27,7 +27,7 @@ * progressToken. */ import type { JSONRPCRequest, RequestId } from '@modelcontextprotocol/core-internal'; -import { acceptedContent, inputRequired, inputResponse, samplingText } 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'; @@ -113,7 +113,11 @@ describe('legacy shim: write-once fulfilment on a sessionful 2025-era connection }); } secondEntry = ctx.mcpReq.inputResponses; - const text = samplingText(ctx.mcpReq.inputResponses, 'ideas'); + 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: [ diff --git a/packages/server/test/server/legacyShimWriteOnce.test.ts b/packages/server/test/server/legacyShimWriteOnce.test.ts index 574423949d..e394c37141 100644 --- a/packages/server/test/server/legacyShimWriteOnce.test.ts +++ b/packages/server/test/server/legacyShimWriteOnce.test.ts @@ -7,7 +7,7 @@ * requestState codec verifying state each round. */ import type { CallToolResult, ElicitRequestFormParams, InputRequiredResult, JSONRPCRequest } from '@modelcontextprotocol/core-internal'; -import { acceptedContent, inputRequired, inputResponse, samplingText } from '@modelcontextprotocol/core-internal'; +import { acceptedContent, inputRequired, inputResponse } from '@modelcontextprotocol/core-internal'; import { describe, expect, it } from 'vitest'; import * as z from 'zod/v4'; @@ -57,6 +57,16 @@ function elicitAction(response: unknown): string { 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!!' }); @@ -137,7 +147,7 @@ describe('acceptance: chat-cli brainstorm_tasks written once, served to a 2025 c return askForIdeas(wanted, state.topic); } case 'awaiting-ideas': { - return finish(samplingText(ctx.mcpReq.inputResponses, 'ideas') ?? '', state.count, state.topic); + return finish(sampledText(ctx.mcpReq.inputResponses, 'ideas') ?? '', state.count, state.topic); } } } From bd162bf33e0b68c39b5a775f09ddf14f96b646ba Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 13:05:58 +0000 Subject: [PATCH 03/13] Name the requestState accessor type and drop the redundant cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RequestStateAccessor is now a named public type (re-exported with the other protocol context types) instead of an indexed-access incantation, and the factory's outer cast goes: a generic arrow is directly assignable to the declared signature. The single remaining 'as T' is the deliberate caller-asserted typing the accessor documents — no implementation can produce an arbitrary T from a runtime value. --- .../core-internal/src/exports/public/index.ts | 1 + packages/core-internal/src/shared/protocol.ts | 28 ++++++++++++++----- 2 files changed, 22 insertions(+), 7 deletions(-) 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/protocol.ts b/packages/core-internal/src/shared/protocol.ts index d88770cb68..6bd4abf287 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -284,15 +284,29 @@ function codecResultValidator(codec: WireCodec, method: string): StandardSchemaV } /** - * Builds the `ctx.mcpReq.requestState` accessor for a given resolved value — - * the ONE place the generic-arrow cast lives (an arrow literal cannot carry - * the declared generic signature, so every construction site must cast; this - * factory owns that cast). Used by the protocol layer (raw lifted value), the + * The type of `ctx.mcpReq.requestState`: reads the multi-round-trip request + * state resolved for the current round. + * + * The type parameter is **caller-asserted** — the accessor performs no + * validation, so `requestState()` is a typed read in exactly the way the + * two-argument `acceptedContent` is: the runtime value is whatever the + * configured verify hook resolved (or the raw wire string), and `T` is the + * caller's claim about it. Pair it 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 given resolved value. + * The single `as T` below is where {@linkcode RequestStateAccessor}'s + * caller-asserted typing is implemented — no closure over a runtime value + * can produce an arbitrary `T` honestly, so the one deliberate cast lives + * here and nowhere else. Used by the protocol layer (raw lifted value), the * server seam (verify hook's decoded payload), and the legacy shim's * per-round re-entry contexts. */ -export function requestStateAccessor(value: unknown): BaseContext['mcpReq']['requestState'] { - return ((): T | undefined => value as T | undefined) as BaseContext['mcpReq']['requestState']; +export function requestStateAccessor(value: unknown): RequestStateAccessor { + return (): T | undefined => value as T | undefined; } /** Shared no-state accessor: the common case allocates nothing per request. */ @@ -386,7 +400,7 @@ export type BaseContext = { * 4–5). Without a configured hook this accessor returns the raw, * unverified string. */ - requestState: () => T | undefined; + requestState: RequestStateAccessor; /** * An abort signal used to communicate if the request was cancelled from the sender's side. From 1e8900057088cd3d74638f0a1067fe1dfd4a2977 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 13:14:32 +0000 Subject: [PATCH 04/13] Share the per-round liveness message, disclose the round-cap choice The driver's onprogress message and the shim's wire progress notification now come from one formatter, and the migration guide states why the shim's round cap (8) is tighter than the client driver's (10). Also restores the auto-generated codemod versions file this branch had accidentally regenerated. --- docs/migration/support-2026-07-28.md | 2 +- .../core-internal/src/shared/inputRequiredDriver.ts | 11 ++++++++++- packages/server/src/server/server.ts | 3 ++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index bbfdb7e7be..92ec562ecf 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -434,7 +434,7 @@ Knobs live at `ServerOptions.inputRequired`: | Member | Default | Meaning | | --- | --- | --- | -| `maxRounds` | `8` | Handler re-entries per originating request before failing | +| `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 | diff --git a/packages/core-internal/src/shared/inputRequiredDriver.ts b/packages/core-internal/src/shared/inputRequiredDriver.ts index 673461cbc9..b9bb213dad 100644 --- a/packages/core-internal/src/shared/inputRequiredDriver.ts +++ b/packages/core-internal/src/shared/inputRequiredDriver.ts @@ -151,6 +151,15 @@ export function inputRequiredRoundsExceededMessage(method: string, maxRounds: nu return `Multi-round-trip request '${method}' still required input after ${maxRounds} rounds (inputRequired.maxRounds)`; } +/** + * The per-round liveness message both loops emit — the client driver via the + * caller's `onprogress`, the server-side legacy shim as a wire progress + * notification. One formatter so the texts cannot drift. + */ +export function inputRequiredRoundMessage(method: string, round: number): string { + return `Fulfilling input required by '${method}' (round ${round})`; +} + /** * 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 @@ -239,7 +248,7 @@ export async function runInputRequiredDriver(args: { // Surface the round as synthetic progress: long interactive flows stay // observable, and consumers composing `resetTimeoutOnProgress`-style // watchdogs around the call see liveness instead of silence. - requestOptions.onprogress?.({ progress: round, message: `Fulfilling input required by '${method}' (round ${round})` }); + requestOptions.onprogress?.({ progress: round, message: inputRequiredRoundMessage(method, round) }); const entries = Object.entries(payload.inputRequests ?? {}); let responses: Record | undefined; diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 8283ce0051..ed9f969d01 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -42,6 +42,7 @@ import { attachCacheHintFallback, CLIENT_CAPABILITIES_META_KEY, codecForVersion, + inputRequiredRoundMessage, inputRequiredRoundsExceededMessage, isInputRequiredResult, isModernProtocolVersion, @@ -1021,7 +1022,7 @@ export class Server extends Protocol { params: { progressToken, progress: syntheticProgress.floor, - message: `Fulfilling input required by '${method}' (round ${round})` + message: inputRequiredRoundMessage(method, round) } }); } From 8126aaab54024dc7dfd26da78070ae0586333a62 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 13:18:55 +0000 Subject: [PATCH 05/13] Delete the todos-server era branches: the legacy shim serves them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference server's three interactive tools (brainstorm_tasks, clear_done, prioritize) each carried a hand-written 2025 push-style arm next to their input_required form. The shim makes the input_required form serve both eras, so the arms go: brainstorm_tasks keeps only its requestState phase machine (read via the typed accessor — no second decode), and clear_done/prioritize keep only their single-round input_required shape. The cli-client e2e legs exercise the same conversations on stdio/legacy through the shim unchanged. --- examples/todos-server/todos.ts | 98 ++++++++++------------------------ 1 file changed, 29 insertions(+), 69 deletions(-) diff --git a/examples/todos-server/todos.ts b/examples/todos-server/todos.ts index 40220e39e1..430f5bab3a 100644 --- a/examples/todos-server/todos.ts +++ b/examples/todos-server/todos.ts @@ -445,41 +445,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 +596,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 +641,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()) { From 55f8a8bb6e50dfb8b65e1d6bef0d17dc9ca70142 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 13:42:48 +0000 Subject: [PATCH 06/13] Address review findings: delete synthetic progress, fix stale docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shim no longer emits progress against the originating request's progressToken. That token is a single must-increase stream owned by the handler, and a second author cannot compose with it — the previous floor/suppression machinery managed the conflict instead of removing it. A client watchdog tight enough to need synthetic liveness already cannot complete an interactive flow on the 2025 era (push-style legs emit nothing either), and neither sibling SDK bridge emits synthetic progress. Handlers that report progress across rounds derive values from their phase state, which increases across re-entries. Also: the inputRequired module header now describes the shim default instead of the pre-shim loud failure; the migration guide's sampling one-liner narrows the content block type before reading .text, and its JSON-mode note states the real behavior (undeliverable legs wait out roundTimeoutMs — the transport drops server-to-client requests in that mode, as it does for elicitInput today). --- .changeset/legacy-input-required-shim.md | 2 +- docs/migration/support-2026-07-28.md | 21 ++++-- .../core-internal/src/shared/inputRequired.ts | 9 ++- .../src/shared/inputRequiredDriver.ts | 11 +-- packages/server/src/server/server.ts | 74 +------------------ .../server/legacyInputRequiredShim.test.ts | 52 +++++-------- 6 files changed, 41 insertions(+), 128 deletions(-) diff --git a/.changeset/legacy-input-required-shim.md b/.changeset/legacy-input-required-shim.md index 3a9cbe7226..61bcb33e12 100644 --- a/.changeset/legacy-input-required-shim.md +++ b/.changeset/legacy-input-required-shim.md @@ -3,7 +3,7 @@ '@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; synthetic per-round progress (emitted only when the originating request carried a progressToken) stays monotonic above handler-emitted progress on the same token. +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. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index 92ec562ecf..216c024af3 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -396,7 +396,8 @@ one-liner over the discriminated view: ```typescript const ideas = inputResponse(ctx.mcpReq.inputResponses, 'ideas'); -const text = ideas.kind === 'sampling' && !Array.isArray(ideas.result.content) ? ideas.result.content.text : undefined; +const block = ideas.kind === 'sampling' && !Array.isArray(ideas.result.content) ? ideas.result.content : undefined; +const text = block?.type === 'text' ? block.text : undefined; ``` --- @@ -443,10 +444,13 @@ leg, round-cap exhaustion) become `isError` tool results — the 2025-era idiom already render — while `prompts/get` / `resources/read` failures surface as JSON-RPC errors. Server bugs (malformed input-required results) fail loudly on both eras. -When the originating request carried a `progressToken`, the shim emits one synthetic -`notifications/progress` per completed round against it, so a deployed 2025 client -composing `resetTimeoutOnProgress` around its call sees liveness across a long -multi-round flow. +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): @@ -458,8 +462,11 @@ multi-round flow. 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 clients (`enableJsonResponse`) cannot receive server→client - requests mid-call, exactly as with today's `elicitInput`. +- 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. diff --git a/packages/core-internal/src/shared/inputRequired.ts b/packages/core-internal/src/shared/inputRequired.ts index 080ea5a669..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 diff --git a/packages/core-internal/src/shared/inputRequiredDriver.ts b/packages/core-internal/src/shared/inputRequiredDriver.ts index b9bb213dad..673461cbc9 100644 --- a/packages/core-internal/src/shared/inputRequiredDriver.ts +++ b/packages/core-internal/src/shared/inputRequiredDriver.ts @@ -151,15 +151,6 @@ export function inputRequiredRoundsExceededMessage(method: string, maxRounds: nu return `Multi-round-trip request '${method}' still required input after ${maxRounds} rounds (inputRequired.maxRounds)`; } -/** - * The per-round liveness message both loops emit — the client driver via the - * caller's `onprogress`, the server-side legacy shim as a wire progress - * notification. One formatter so the texts cannot drift. - */ -export function inputRequiredRoundMessage(method: string, round: number): string { - return `Fulfilling input required by '${method}' (round ${round})`; -} - /** * 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 @@ -248,7 +239,7 @@ export async function runInputRequiredDriver(args: { // Surface the round as synthetic progress: long interactive flows stay // observable, and consumers composing `resetTimeoutOnProgress`-style // watchdogs around the call see liveness instead of silence. - requestOptions.onprogress?.({ progress: round, message: inputRequiredRoundMessage(method, round) }); + requestOptions.onprogress?.({ progress: round, message: `Fulfilling input required by '${method}' (round ${round})` }); const entries = Object.entries(payload.inputRequests ?? {}); let responses: Record | undefined; diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index ed9f969d01..3e1f844ee1 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -42,7 +42,6 @@ import { attachCacheHintFallback, CLIENT_CAPABILITIES_META_KEY, codecForVersion, - inputRequiredRoundMessage, inputRequiredRoundsExceededMessage, isInputRequiredResult, isModernProtocolVersion, @@ -257,42 +256,6 @@ function withRequestStateValue(ctx: ServerContext, value: unknown): ServerContex /** The embedded input-request kinds the 2026-07-28 revision defines. */ type EmbeddedInputRequestMethod = 'elicitation/create' | 'sampling/createMessage' | 'roots/list'; -/** - * Tracks the highest progress value emitted against the originating request's - * progressToken, so the legacy shim's synthetic per-round ticks stay - * monotonic even when the handler reports its own progress on the same token - * (the spec requires progress to increase per token). - */ -interface SyntheticProgressState { - floor: number; -} - -/** - * Wraps `ctx.mcpReq.notify` to observe handler-emitted progress against the - * given token (pass-through otherwise). Installed by the seam on legacy-era - * multi-round-trip requests that carry a progressToken, BEFORE the first - * handler entry, so the shim's synthetic ticks can continue above anything - * the handler emitted. - */ -function withProgressTracking(ctx: ServerContext, progressToken: string | number, state: SyntheticProgressState): ServerContext { - const notify = ctx.mcpReq.notify; - return { - ...ctx, - mcpReq: { - ...ctx.mcpReq, - notify: notification => { - if (notification.method === 'notifications/progress') { - const params = notification.params as { progressToken?: unknown; progress?: unknown } | undefined; - if (params?.progressToken === progressToken && typeof params.progress === 'number' && params.progress > state.floor) { - state.floor = params.progress; - } - } - return notify(notification); - } - } - }; -} - /** * Synthesizes the `elicitationId` the 2025-11-25 URL-mode elicitation shape * requires: the 2026 in-band shape deliberately has none (correlation lives @@ -711,17 +674,6 @@ export class Server extends Protocol { } } - // When the legacy shim may engage and the originating request asked - // for progress, observe handler-emitted progress from the FIRST entry - // on, so the shim's synthetic per-round ticks stay monotonic above - // anything the handler reports against the same token. - const progressToken = ctx.mcpReq._meta?.progressToken; - let syntheticProgress: SyntheticProgressState | undefined; - if (!servedModern && this._inputRequiredServing.legacyShim && progressToken !== undefined) { - syntheticProgress = { floor: 0 }; - ctxForHandler = withProgressTracking(ctxForHandler, progressToken, syntheticProgress); - } - let result: Result; try { result = await handler(request, ctxForHandler); @@ -767,7 +719,7 @@ export class Server extends Protocol { // server→client requests over the live 2025-era session and // re-enter the handler until it returns a final result — // write-once handlers served to deployed 2025 clients. - return await this._fulfillInputRequiredOnLegacy(method, handler, request, ctxForHandler, result, syntheticProgress); + return await this._fulfillInputRequiredOnLegacy(method, handler, request, ctxForHandler, result); } // F7 at-least-one re-check (hand-built results are legal; the rule is @@ -897,11 +849,9 @@ export class Server extends Protocol { handler: (request: JSONRPCRequest, ctx: ServerContext) => Promise, request: JSONRPCRequest, ctx: ServerContext, - firstResult: Result, - syntheticProgress: SyntheticProgressState | undefined + firstResult: Result ): Promise { const { maxRounds, roundTimeoutMs } = this._inputRequiredServing; - const progressToken = ctx.mcpReq._meta?.progressToken; const outerSignal = ctx.mcpReq.signal; let current = firstResult; let round = 0; @@ -1007,26 +957,6 @@ export class Server extends Protocol { await sleep(REQUEST_STATE_ONLY_LEG_PACING_MS, outerSignal); } - // One synthetic progress tick per completed round against the - // ORIGINATING request, only when it asked for progress. Rides the - // related-notification path (free relatedRequestId stamp), so a - // deployed 2025 client composing `resetTimeoutOnProgress` around - // its call sees liveness instead of silence between rounds. The - // tick continues above any progress the handler emitted against - // the same token (tracked since the first entry), so the per-token - // stream stays monotonic. - if (progressToken !== undefined && syntheticProgress !== undefined) { - syntheticProgress.floor += 1; - await ctx.mcpReq.notify({ - method: 'notifications/progress', - params: { - progressToken, - progress: syntheticProgress.floor, - message: inputRequiredRoundMessage(method, round) - } - }); - } - // Byte-exact requestState echo. The re-entry context carries this // round's material FIRST (raw state accessor + this round's // responses), then the configured verify hook runs against that diff --git a/packages/server/test/server/legacyInputRequiredShim.test.ts b/packages/server/test/server/legacyInputRequiredShim.test.ts index d7b9b82dbf..62d640b179 100644 --- a/packages/server/test/server/legacyInputRequiredShim.test.ts +++ b/packages/server/test/server/legacyInputRequiredShim.test.ts @@ -22,9 +22,9 @@ * 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; - * - one synthetic progress notification per completed round rides the - * related-notification path, only when the originating request carried a - * progressToken. + * - 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'; @@ -594,8 +594,13 @@ describe('legacy shim: timeouts (per-leg 600s default, NOT the 60s protocol defa }); }); -describe('legacy shim: synthetic progress (per completed round, progressToken-gated)', () => { - it('emits one notifications/progress per completed round against the originating token', async () => { +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(); @@ -625,30 +630,22 @@ describe('legacy shim: synthetic progress (per completed round, progressToken-ga }); expect(toolText(answer)).toBe('done'); - const progress = wire.notifications.filter(notification => notification.method === 'notifications/progress'); - expect(progress).toHaveLength(2); - expect(progress[0]!.params).toMatchObject({ - progressToken: 'tok-7', - progress: 1, - message: "Fulfilling input required by 'tools/call' (round 1)" - }); - expect(progress[1]!.params).toMatchObject({ progressToken: 'tok-7', progress: 2 }); + expect(wire.notifications.filter(notification => notification.method === 'notifications/progress')).toHaveLength(0); await wire.close(); }); - it('synthetic ticks stay monotonic above progress the handler emits against the same token', async () => { + 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) { - // The handler reports its own progress on the originating - // token during the FIRST entry, before asking for input. - const progressToken = ctx.mcpReq._meta?.progressToken as string; - await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken, progress: 100 } }); + 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); @@ -665,22 +662,9 @@ describe('legacy shim: synthetic progress (per completed round, progressToken-ga const progress = wire.notifications .filter(notification => notification.method === 'notifications/progress') .map(notification => (notification.params as { progress: number }).progress); - // Handler's own 100, then the synthetic round tick ABOVE it — never a - // regression on the per-token stream. - expect(progress).toEqual([100, 101]); - - await wire.close(); - }); - - it('emits nothing when the originating request carried no progressToken', 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: { form: {} } })); - await wire.request(callDeploy(2)); - - expect(wire.notifications.filter(notification => notification.method === 'notifications/progress')).toHaveLength(0); + // 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(); }); From cc7e241b83e3cb2151b8a8a344aa789073803d99 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 13:45:46 +0000 Subject: [PATCH 07/13] Align the pending MRTR seam changeset with the legacy shim default Both changesets ship in the same version bump; the older one still promised a loud failure for input_required returns toward 2025-era requests, which the shim now serves by default. --- .changeset/mrtr-server-seam.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From bec0500702b8f47c8eed5e76aa3bc4667ceed7d1 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 14:03:00 +0000 Subject: [PATCH 08/13] Isolate the legacy input_required shim into its own module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server no longer carries the fulfilment loop inline: it lazily holds one LegacyInputRequiredShim (legacyInputRequiredShim.ts) constructed against a narrow host contract — knobs, the resolved capability view, the requestState verify runner, and the three capability-check-free sender cores — and the seam delegates to it in one line. The shared embedded-request validation moves with it (the modern capability check imports it back), withRequestStateValue moves to core-internal next to requestStateAccessor, and server.ts shrinks by ~300 lines. Behavior unchanged; package-internal, not exported from the index. --- packages/core-internal/src/shared/protocol.ts | 16 + .../src/server/legacyInputRequiredShim.ts | 342 ++++++++++++++++++ packages/server/src/server/server.ts | 337 ++--------------- 3 files changed, 385 insertions(+), 310 deletions(-) create mode 100644 packages/server/src/server/legacyInputRequiredShim.ts diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 6bd4abf287..34a88b9aff 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -312,6 +312,22 @@ export function requestStateAccessor(value: unknown): RequestStateAccessor { /** 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. */ diff --git a/packages/server/src/server/legacyInputRequiredShim.ts b/packages/server/src/server/legacyInputRequiredShim.ts new file mode 100644 index 0000000000..bd0a7280bc --- /dev/null +++ b/packages/server/src/server/legacyInputRequiredShim.ts @@ -0,0 +1,342 @@ +/** + * The legacy `input_required` shim (write-once handlers on 2025-era + * sessions), isolated from the core server: `Server` holds one + * {@linkcode LegacyInputRequiredShim} which it delegates to from the + * multi-round-trip seam when a handler returns an input-required result on a + * 2025-era request. + * + * The shim converts each embedded input request of the return into a REAL + * server→client request (`elicitation/create`, `sampling/createMessage`, + * `roots/list`) over the live session — stamped with the originating + * request's id so sessionful Streamable HTTP routes them onto the + * originating POST's stream — then re-enters the handler with the collected + * `inputResponses` and the echoed `requestState`, until the handler returns + * a final result or the round cap is exhausted. + * + * Semantics mirror the modern client driver exactly, so a handler cannot + * tell which era fulfilled it: `inputResponses` are per-round (REPLACED, + * never accumulated), `requestState` is echoed byte-exact (and re-verified + * by the configured hook each round, exactly as a wire retry would be), + * requestState-only rounds are paced, and the round cap counts handler + * re-entries. + * + * The loop lives entirely within the originating wire request's lifetime: + * no awaits are parked, no state survives the request, and the caller's + * cancellation chains through every leg. + * + * Failure surfacing is per family: `tools/call` failures (capability + * refusal, leg failure, round-cap exhaustion) become `isError` tool + * results — the 2025-era idiom hosts already render — while `prompts/get` + * and `resources/read` failures surface as JSON-RPC errors. Server bugs + * (malformed input-required results) fail loudly on both eras, and + * requestState verification failures keep the frozen `-32602`. + * + * Not public API — package-internal, deliberately not exported from the + * package 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'; + +/** 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 of an input-required result: a + * malformed entry or an unknown embedded-request kind is a server bug and + * fails loudly (both eras — the vocabulary is the 2026-07-28 revision's + * regardless of which era the request is served on). Returns the coerced + * entry together with the client capabilities it requires. Shared by the + * modern seam's capability check and the legacy 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` + ); + } + // The cast records the invariant the check above just established: + // requiredClientCapabilitiesForInputRequest answers undefined for any + // method outside the three embedded kinds. + return { embedded: embedded as CoercedEmbeddedInputRequest, required }; +} + +/** + * Synthesizes the `elicitationId` the 2025-11-25 URL-mode elicitation shape + * requires: the 2026 in-band shape deliberately has none (correlation lives + * in `requestState`), so a URL-mode leg the legacy shim sends must mint one + * to be schema-valid toward conforming 2025 clients. Always CSPRNG-backed — + * `randomUUID` where available, `getRandomValues` formatted as a v4 UUID + * otherwise (the SDK already requires the Web Crypto API elsewhere). + */ +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 failure surfacing for the legacy shim: `tools/call` failures + * become `isError` tool results (the 2025-era idiom — hosts and models + * already render them), `prompts/get` / `resources/read` failures surface as + * JSON-RPC errors. + */ +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`, as a narrow contract: + * the resolved knobs, the per-request resolved capability view (plan ruling + * F-2 — `initialize` state on a sessionful legacy connection, empty on + * per-request stateless instances), the requestState verify runner + * (deny-on-error → the frozen `-32602`), and the three existing 2025-era + * senders (capability-check-free cores; the shim's own gate is + * authoritative, and 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, held by `Server` and delegated to from the + * multi-round-trip seam (see the module doc for the full 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 (hand-built results are legal; + // a violation is a server bug and fails loudly, as on the modern + // era). + 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` — against the per-request + // resolved view. 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); + // The wire legs need params for the request-carrying + // kinds; a hand-built entry without them is a server bug + // and fails loudly, like every other malformation. + 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 (the embedded requests are independent, + // mirroring the modern client driver); the first failure + // aborts the sibling legs via the shared linked per-round + // signal. + const roundAbort = linkedRoundAbort(outerSignal); + try { + const legOptions: RequestOptions = { + relatedRequestId: ctx.mcpReq.id, + timeout: roundTimeoutMs, + resetTimeoutOnProgress: true, + // The no-op handler makes the leg carry a + // progressToken, which is what lets a client that + // reports progress mid-leg actually reset the leg + // timeout — without it resetTimeoutOnProgress could + // never fire (no token, nothing to report against). + 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) { + // The originating request was cancelled: propagate so + // the protocol layer drops the response (cancelled + // requests are never answered). + throw error; + } + return legacyShimFailure( + method, + `Fulfilling input required by '${method}' failed: ${error instanceof Error ? error.message : String(error)}` + ); + } finally { + roundAbort.dispose(); + } + } else { + // requestState-only (load-shedding) round: fixed pacing so + // the loop never hot-spins; counted in the same round cap + // (mirrors the modern client driver). + await sleep(REQUEST_STATE_ONLY_LEG_PACING_MS, outerSignal); + } + + // Byte-exact requestState echo. The re-entry context carries this + // round's material FIRST (raw state accessor + this round's + // responses), then the configured verify hook runs against that + // context — exactly the order and view a modern wire retry gets — + // and its decoded payload replaces the accessor value. + // Deny-on-error → the frozen -32602. + let ctxNext: ServerContext = { + ...ctx, + mcpReq: { + ...ctx.mcpReq, + // REPLACE semantics: this round's responses only — never + // accumulated across rounds (parity with the modern + // client driver; 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 goes through the SAME stored handler the wire retry + // would hit (for McpServer that is the full funnel: input + // re-validation, output projection, tools/call error catch). + const next = await handler(request, ctxNext); + if (!isInputRequiredResult(next)) { + return next; + } + current = next; + } + } + + /** + * Routes one embedded input request through the host's existing 2025-era + * senders — the same wire paths a hand-written era-branching handler + * used. The shim's capability gate has already run. + */ + 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) { + // The 2026 in-band URL shape carries no elicitationId + // (correlation lives in requestState), but the 2025-11-25 + // wire schema requires one — synthesize it so conforming + // 2025 clients accept the leg. + 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/server.ts b/packages/server/src/server/server.ts index 3e1f844ee1..af52fea294 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -42,12 +42,10 @@ import { attachCacheHintFallback, CLIENT_CAPABILITIES_META_KEY, codecForVersion, - inputRequiredRoundsExceededMessage, isInputRequiredResult, isModernProtocolVersion, LATEST_PROTOCOL_VERSION, legacyProtocolVersions, - linkedRoundAbort, LOG_LEVEL_META_KEY, LoggingLevelSchema, mergeCapabilities, @@ -58,15 +56,14 @@ import { Protocol, ProtocolError, ProtocolErrorCode, - REQUEST_STATE_ONLY_LEG_PACING_MS, - requestStateAccessor, - requiredClientCapabilitiesForInputRequest, SdkError, SdkErrorCode, - sleep + withRequestStateValue } from '@modelcontextprotocol/core-internal'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; +import { coerceEmbeddedInputRequest, LegacyInputRequiredShim } from './legacyInputRequiredShim'; + /** * The request methods whose 2026-07-28 result vocabulary includes * `input_required` (the multi round-trip methods). Returning an @@ -238,45 +235,6 @@ export type ServerOptions = ProtocolOptions & { let writeClientIdentity: (server: Server, identity: PerRequestClientIdentity) => void; let installDiscoverHandler: (server: Server, servedModernVersions: readonly string[]) => void; -/** - * Returns a context whose `requestState` accessor reads the given value — - * how the seam hands a verify hook's decoded payload (or the shim's per-round - * echo) to the handler without mutating the original context. - */ -function withRequestStateValue(ctx: ServerContext, value: unknown): ServerContext { - return { - ...ctx, - mcpReq: { - ...ctx.mcpReq, - requestState: requestStateAccessor(value) - } - }; -} - -/** The embedded input-request kinds the 2026-07-28 revision defines. */ -type EmbeddedInputRequestMethod = 'elicitation/create' | 'sampling/createMessage' | 'roots/list'; - -/** - * Synthesizes the `elicitationId` the 2025-11-25 URL-mode elicitation shape - * requires: the 2026 in-band shape deliberately has none (correlation lives - * in `requestState`), so a URL-mode leg the legacy shim sends must mint one - * to be schema-valid toward conforming 2025 clients. Always CSPRNG-backed — - * `randomUUID` where available, `getRandomValues` formatted as a v4 UUID - * otherwise (the SDK already requires the Web Crypto API elsewhere). - */ -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)}`; -} - /** Connection-scoped client-identity fields backfilled per request from a validated `_meta` envelope. */ export interface PerRequestClientIdentity { /** The client's name/version information, when the envelope carried it. */ @@ -345,6 +303,28 @@ export class Server extends Protocol { private _cacheHints?: ServerOptions['cacheHints']; private _requestStateVerify?: (state: string, ctx: ServerContext) => unknown | Promise; private _inputRequiredServing: { maxRounds: number; roundTimeoutMs: number; legacyShim: boolean }; + private _legacyShim?: LegacyInputRequiredShim; + + /** + * The legacy `input_required` shim, constructed lazily on the first + * 2025-era input-required return. Everything it needs from this server + * crosses one narrow host contract — the loop itself lives in + * `legacyInputRequiredShim.ts`, not here. + */ + 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), + // Capability-check-free sender cores: the shim's gate is + // authoritative, and elicitation accepted content passes through + // UNVALIDATED for parity with the modern client driver. + sendElicitation: (params, options) => this._sendElicitationLeg(params, options, { validateAcceptedContent: false }), + sendSampling: (params, options) => this._sendSamplingLeg(params, options), + listRoots: (params, options) => this.request({ method: 'roots/list', params }, options) + })); + } /** * Callback for when initialization has fully completed (i.e., the client has sent an `notifications/initialized` notification). @@ -719,7 +699,7 @@ export class Server extends Protocol { // server→client requests over the live 2025-era session and // re-enter the handler until it returns a final result — // write-once handlers served to deployed 2025 clients. - return await this._fulfillInputRequiredOnLegacy(method, handler, request, ctxForHandler, result); + return await this._legacyInputRequiredShim().fulfill(method, handler, request, ctxForHandler, result); } // F7 at-least-one re-check (hand-built results are legal; the rule is @@ -741,7 +721,7 @@ export class Server extends Protocol { if (hasInputRequests) { const declared = this._inputRequestCapabilityView(ctx); for (const [key, entry] of Object.entries(inputRequests)) { - const { embedded, required } = this._coerceEmbeddedInputRequest(method, key, entry); + const { embedded, required } = coerceEmbeddedInputRequest(method, key, entry); const missing = missingClientCapabilities(required, declared); if (missing !== undefined) { throw new MissingRequiredClientCapabilityError( @@ -756,40 +736,6 @@ export class Server extends Protocol { return result; } - /** - * Validates one `inputRequests` entry of an input-required result: a - * malformed entry or an unknown embedded-request kind is a server bug and - * fails loudly (both eras — the vocabulary is the 2026-07-28 revision's - * regardless of which era the request is served on). Returns the coerced - * entry together with the client capabilities it requires. - */ - private _coerceEmbeddedInputRequest( - method: string, - key: string, - entry: unknown - ): { embedded: { method: EmbeddedInputRequestMethod; params?: Record }; 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` - ); - } - // The cast records the invariant the check above just established: - // requiredClientCapabilitiesForInputRequest answers undefined for any - // method outside the three embedded kinds. - return { embedded: embedded as { method: EmbeddedInputRequestMethod; params?: Record }, required }; - } - /** * Runs the configured `requestState.verify` hook on an echoed * `requestState` and returns its resolved value (the decoded payload for @@ -816,184 +762,6 @@ export class Server extends Protocol { } } - /** - * The legacy `input_required` shim (write-once handlers on 2025-era - * sessions): converts each embedded input 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 so sessionful Streamable HTTP routes - * them onto the originating POST's stream — then re-enters the handler - * with the collected `inputResponses` and the echoed `requestState`, - * until the handler returns a final result or the round cap is exhausted. - * - * Semantics mirror the modern client driver exactly, so a handler cannot - * tell which era fulfilled it: `inputResponses` are per-round (REPLACED, - * never accumulated), `requestState` is echoed byte-exact (and re-verified - * by the configured hook each round, exactly as a wire retry would be), - * requestState-only rounds are paced, and the round cap counts handler - * re-entries. - * - * The loop lives entirely within the originating wire request's lifetime: - * no awaits are parked, no state survives the request, and the caller's - * cancellation chains through every leg. - * - * Failure surfacing is per family: `tools/call` failures (capability - * refusal, leg failure, round-cap exhaustion) become `isError` tool - * results — the 2025-era idiom hosts already render — while `prompts/get` - * and `resources/read` failures surface as JSON-RPC errors. Server bugs - * (malformed input-required results) fail loudly on both eras, and - * requestState verification failures keep the frozen `-32602`. - */ - private async _fulfillInputRequiredOnLegacy( - method: string, - handler: (request: JSONRPCRequest, ctx: ServerContext) => Promise, - request: JSONRPCRequest, - ctx: ServerContext, - firstResult: Result - ): Promise { - const { maxRounds, roundTimeoutMs } = this._inputRequiredServing; - 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 this._legacyShimFailure(method, inputRequiredRoundsExceededMessage(method, maxRounds)); - } - - // At-least-one re-check per round (hand-built results are legal; - // a violation is a server bug and fails loudly, as on the modern - // era). - 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` — against the per-request - // resolved view. The whole round gates BEFORE any wire - // traffic, so a refusal has no side effects. - const declared = this._inputRequestCapabilityView(ctx); - const coerced: [string, { method: EmbeddedInputRequestMethod; params?: Record }][] = []; - for (const [key, entry] of Object.entries(inputRequests!)) { - const { embedded, required } = this._coerceEmbeddedInputRequest(method, key, entry); - // The wire legs need params for the request-carrying - // kinds; a hand-built entry without them is a server bug - // and fails loudly, like every other malformation. - 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 this._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 (the embedded requests are independent, - // mirroring the modern client driver); the first failure - // aborts the sibling legs via the shared linked per-round - // signal. - const roundAbort = linkedRoundAbort(outerSignal); - try { - const legOptions: RequestOptions = { - relatedRequestId: ctx.mcpReq.id, - timeout: roundTimeoutMs, - resetTimeoutOnProgress: true, - // The no-op handler makes the leg carry a - // progressToken, which is what lets a client that - // reports progress mid-leg actually reset the leg - // timeout — without it resetTimeoutOnProgress could - // never fire (no token, nothing to report against). - onprogress: () => {}, - signal: roundAbort.signal - }; - const fulfilled = await Promise.all( - coerced.map(async ([key, embedded]) => { - try { - return [key, await this._dispatchLegacyInputRequestLeg(embedded, legOptions)] as const; - } catch (error) { - roundAbort.abort(error); - throw error; - } - }) - ); - responses = Object.fromEntries(fulfilled); - } catch (error) { - if (outerSignal.aborted) { - // The originating request was cancelled: propagate so - // the protocol layer drops the response (cancelled - // requests are never answered). - throw error; - } - return this._legacyShimFailure( - method, - `Fulfilling input required by '${method}' failed: ${error instanceof Error ? error.message : String(error)}` - ); - } finally { - roundAbort.dispose(); - } - } else { - // requestState-only (load-shedding) round: fixed pacing so - // the loop never hot-spins; counted in the same round cap - // (mirrors the modern client driver). - await sleep(REQUEST_STATE_ONLY_LEG_PACING_MS, outerSignal); - } - - // Byte-exact requestState echo. The re-entry context carries this - // round's material FIRST (raw state accessor + this round's - // responses), then the configured verify hook runs against that - // context — exactly the order and view a modern wire retry gets — - // and its decoded payload replaces the accessor value. - // Deny-on-error → the frozen -32602. - let ctxNext: ServerContext = { - ...ctx, - mcpReq: { - ...ctx.mcpReq, - // REPLACE semantics: this round's responses only — never - // accumulated across rounds (parity with the modern - // client driver; multi-step flows thread earlier answers - // through requestState). - inputResponses: responses, - droppedInputResponseKeys: undefined, - requestState: requestStateAccessor(requestState) - } - }; - if (requestState !== undefined) { - const decoded = await this._verifyRequestState(requestState, ctxNext, method); - if (decoded !== undefined) { - ctxNext = withRequestStateValue(ctxNext, decoded); - } - } - - // Re-entry goes through the SAME stored handler the wire retry - // would hit (for McpServer that is the full funnel: input - // re-validation, output projection, tools/call error catch). - const next = await handler(request, ctxNext); - if (!isInputRequiredResult(next)) { - return next; - } - current = next; - } - } - /** * The per-request resolved client-capabilities view (plan ruling F-2): * the capabilities the request itself is entitled to rely on. On the @@ -1009,57 +777,6 @@ export class Server extends Protocol { : this._clientCapabilities; } - /** - * Routes one embedded input request of the legacy shim through the - * existing 2025-era senders — the same wire paths a hand-written - * era-branching handler used. The shim's capability gate has already run. - * - * Response validation deliberately mirrors the MODERN flow, not the - * public push APIs: wire shapes are era-validated by the senders, but - * elicitation accepted content is NOT re-checked against - * `requestedSchema` — on the 2026 era the client driver passes it through - * and the handler validates with the schema-aware `acceptedContent` - * overload (and can re-issue the request), so the shim does the same to - * keep handler behavior era-identical. - */ - private async _dispatchLegacyInputRequestLeg( - embedded: { method: EmbeddedInputRequestMethod; params?: Record }, - 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) { - // The 2026 in-band URL shape carries no elicitationId - // (correlation lives in requestState), but the 2025-11-25 - // wire schema requires one — synthesize it so conforming - // 2025 clients accept the leg. - params = { ...(params as ElicitRequestURLParams), elicitationId: syntheticElicitationId() }; - } - return await this._sendElicitationLeg(params, options, { validateAcceptedContent: false }); - } - case 'sampling/createMessage': { - return await this._sendSamplingLeg(embedded.params as CreateMessageRequest['params'], options); - } - case 'roots/list': { - return await this.request({ method: 'roots/list', params: embedded.params }, options); - } - } - } - - /** - * Per-family failure surfacing for the legacy shim: `tools/call` - * failures become `isError` tool results (the 2025-era idiom — hosts and - * models already render them), `prompts/get` / `resources/read` failures - * surface as JSON-RPC errors. - */ - private _legacyShimFailure(method: string, message: string): Result { - if (method === 'tools/call') { - return { content: [{ type: 'text', text: message }], isError: true }; - } - throw new ProtocolError(ProtocolErrorCode.InternalError, message); - } - /** * Guard for the push-style server→client request APIs ({@linkcode createMessage}, * {@linkcode elicitInput}, {@linkcode listRoots}, {@linkcode ping}) on a From 52195103c6c9b886ded25e86314f825ea9ba7561 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 14:20:53 +0000 Subject: [PATCH 09/13] Shrink the server-side shim footprint further MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shim module now owns its knob defaults and validation (resolveLegacyShimOptions, mirroring the client driver's config resolver), and the sampling sender split is reverted: public createMessage works as-is for shim legs (the era assert is a no-op on legacy and the tools-capability check is guaranteed redundant behind the shim's gate), so only the elicitation core keeps a split — the one place the public checks genuinely differ from the gate. --- .../src/server/legacyInputRequiredShim.ts | 43 +++++++++++++++ packages/server/src/server/server.ts | 54 ++----------------- 2 files changed, 47 insertions(+), 50 deletions(-) diff --git a/packages/server/src/server/legacyInputRequiredShim.ts b/packages/server/src/server/legacyInputRequiredShim.ts index bd0a7280bc..2a74090c11 100644 --- a/packages/server/src/server/legacyInputRequiredShim.ts +++ b/packages/server/src/server/legacyInputRequiredShim.ts @@ -58,6 +58,49 @@ import { withRequestStateValue } from '@modelcontextprotocol/core-internal'; +/** + * Default round cap for the legacy `input_required` shim (handler re-entries + * per originating request). Deliberately tighter than the modern client + * driver's default of 10: the shim holds a live wire request open per flow. + */ +const DEFAULT_LEGACY_SHIM_MAX_ROUNDS = 8; + +/** + * Default per-leg timeout for the embedded server→client requests the legacy + * shim sends, paired with `resetTimeoutOnProgress: true`. The 60s protocol + * default is wrong for human-in-the-loop legs (form fills, sign-ins). + */ +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` (fail-loud at + * construction time, like the sibling cacheHints validation) — the shim + * module owns its knobs the same way the client driver owns + * `resolveInputRequiredDriverConfig`. + */ +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'; diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index af52fea294..01f9d33425 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -62,7 +62,7 @@ import { } from '@modelcontextprotocol/core-internal'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; -import { coerceEmbeddedInputRequest, LegacyInputRequiredShim } from './legacyInputRequiredShim'; +import { coerceEmbeddedInputRequest, LegacyInputRequiredShim, resolveLegacyShimOptions } from './legacyInputRequiredShim'; /** * The request methods whose 2026-07-28 result vocabulary includes @@ -71,20 +71,6 @@ import { coerceEmbeddedInputRequest, LegacyInputRequiredShim } from './legacyInp */ const INPUT_REQUIRED_CAPABLE_METHODS: ReadonlySet = new Set(['tools/call', 'prompts/get', 'resources/read']); -/** - * Default round cap for the legacy `input_required` shim (handler re-entries - * per originating request). Deliberately tighter than the modern client - * driver's default of 10: the shim holds a live wire request open per flow. - */ -const DEFAULT_LEGACY_SHIM_MAX_ROUNDS = 8; - -/** - * Default per-leg timeout for the embedded server→client requests the legacy - * shim sends, paired with `resetTimeoutOnProgress: true`. The 60s protocol - * default is wrong for human-in-the-loop legs (form fills, sign-ins). - */ -const DEFAULT_LEGACY_SHIM_ROUND_TIMEOUT_MS = 600_000; - export type ServerOptions = ProtocolOptions & { /** * Capabilities to advertise as being supported by this server. @@ -321,8 +307,8 @@ export class Server extends Protocol { // authoritative, and elicitation accepted content passes through // UNVALIDATED for parity with the modern client driver. sendElicitation: (params, options) => this._sendElicitationLeg(params, options, { validateAcceptedContent: false }), - sendSampling: (params, options) => this._sendSamplingLeg(params, options), - listRoots: (params, options) => this.request({ method: 'roots/list', params }, options) + sendSampling: (params, options) => this.createMessage(params, options), + listRoots: (params, options) => this.listRoots(params, options) })); } @@ -344,25 +330,7 @@ export class Server extends Protocol { this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); this._requestStateVerify = options?.requestState?.verify; - // Configured multi-round-trip knobs fail loudly at construction time. - const inputRequiredOptions = options?.inputRequired; - if ( - inputRequiredOptions?.maxRounds !== undefined && - (!Number.isInteger(inputRequiredOptions.maxRounds) || inputRequiredOptions.maxRounds < 1) - ) { - throw new RangeError(`inputRequired.maxRounds must be a positive integer (got ${inputRequiredOptions.maxRounds})`); - } - if ( - inputRequiredOptions?.roundTimeoutMs !== undefined && - (!Number.isFinite(inputRequiredOptions.roundTimeoutMs) || inputRequiredOptions.roundTimeoutMs <= 0) - ) { - throw new RangeError(`inputRequired.roundTimeoutMs must be a positive number (got ${inputRequiredOptions.roundTimeoutMs})`); - } - this._inputRequiredServing = { - maxRounds: inputRequiredOptions?.maxRounds ?? DEFAULT_LEGACY_SHIM_MAX_ROUNDS, - roundTimeoutMs: inputRequiredOptions?.roundTimeoutMs ?? DEFAULT_LEGACY_SHIM_ROUND_TIMEOUT_MS, - legacyShim: inputRequiredOptions?.legacyShim ?? true - }; + this._inputRequiredServing = resolveLegacyShimOptions(options?.inputRequired); // Configured cache hints fail loudly at construction time (before any // handler registration consults them). @@ -1116,20 +1084,6 @@ export class Server extends Protocol { throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support sampling tools capability.'); } - return this._sendSamplingLeg(params, options); - } - - /** - * The capability-check-free core of {@linkcode createMessage}: message - * structure validation, the wire request, and result-variant validation. - * Shared by the public push API (which applies its era and capability - * checks first) and the legacy `input_required` shim (whose own - * capability gate has already run). - */ - private async _sendSamplingLeg( - params: CreateMessageRequest['params'], - options?: RequestOptions - ): Promise { // Message structure validation - always validate tool_use/tool_result pairs. // These may appear even without tools/toolChoice in the current request when // a previous sampling request returned tool_use and this is a follow-up with results. From c9449eb220f2b9bf1b9b5d008374559ee448ce17 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 14:24:32 +0000 Subject: [PATCH 10/13] Trim comments to contract-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shim module, the server seam, and the requestState docs carried prose that restated the code or repeated the module header per method. Comments now state only what the code cannot: the parity contract, the security notes, and the per-family/gating rules — once each. --- packages/core-internal/src/shared/protocol.ts | 60 +++--- .../src/server/legacyInputRequiredShim.ts | 174 +++++------------- packages/server/src/server/server.ts | 100 +++------- 3 files changed, 100 insertions(+), 234 deletions(-) diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 34a88b9aff..ffab6e8df8 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -284,26 +284,18 @@ function codecResultValidator(codec: WireCodec, method: string): StandardSchemaV } /** - * The type of `ctx.mcpReq.requestState`: reads the multi-round-trip request - * state resolved for the current round. - * - * The type parameter is **caller-asserted** — the accessor performs no - * validation, so `requestState()` is a typed read in exactly the way the - * two-argument `acceptedContent` is: the runtime value is whatever the - * configured verify hook resolved (or the raw wire string), and `T` is the - * caller's claim about it. Pair it with `createRequestStateCodec` so the - * claim is backed by the codec's verification. + * 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 given resolved value. - * The single `as T` below is where {@linkcode RequestStateAccessor}'s - * caller-asserted typing is implemented — no closure over a runtime value - * can produce an arbitrary `T` honestly, so the one deliberate cast lives - * here and nowhere else. Used by the protocol layer (raw lifted value), the - * server seam (verify hook's decoded payload), and the legacy shim's - * per-round re-entry contexts. + * 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; @@ -391,30 +383,20 @@ export type BaseContext = { droppedInputResponseKeys?: string[]; /** - * Reads the multi-round-trip request state echoed by a retried - * request (protocol revision 2026-07-28; on 2025-era connections the - * server's legacy shim echoes it in-process between rounds). - * - * Returns `undefined` when the request carried no `requestState`. - * When the server configured a `ServerOptions.requestState.verify` - * hook and it resolved with a value (as the `verify` of the server - * package's `createRequestStateCodec` does — the decoded payload), - * that value is returned: minting with `codec.mint` and reading - * with `requestState()` is the typed pair. Otherwise the raw wire - * string is returned. - * - * The type parameter is a compile-time cast only — the accessor - * performs no validation of its own. + * 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 by default: 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 via the `requestState.verify` hook, rejecting state that - * fails verification (spec: basic/patterns/mrtr, server requirements - * 4–5). Without a configured hook this accessor returns the raw, - * unverified string. + * 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: RequestStateAccessor; diff --git a/packages/server/src/server/legacyInputRequiredShim.ts b/packages/server/src/server/legacyInputRequiredShim.ts index 2a74090c11..2657e1fe7d 100644 --- a/packages/server/src/server/legacyInputRequiredShim.ts +++ b/packages/server/src/server/legacyInputRequiredShim.ts @@ -1,38 +1,21 @@ /** - * The legacy `input_required` shim (write-once handlers on 2025-era - * sessions), isolated from the core server: `Server` holds one - * {@linkcode LegacyInputRequiredShim} which it delegates to from the + * 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. - * - * The shim converts each embedded input request of the return into a REAL - * server→client request (`elicitation/create`, `sampling/createMessage`, - * `roots/list`) over the live session — stamped with the originating - * request's id so sessionful Streamable HTTP routes them onto the - * originating POST's stream — then re-enters the handler with the collected - * `inputResponses` and the echoed `requestState`, until the handler returns + * 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 exactly, so a handler cannot - * tell which era fulfilled it: `inputResponses` are per-round (REPLACED, - * never accumulated), `requestState` is echoed byte-exact (and re-verified - * by the configured hook each round, exactly as a wire retry would be), - * requestState-only rounds are paced, and the round cap counts handler - * re-entries. - * - * The loop lives entirely within the originating wire request's lifetime: - * no awaits are parked, no state survives the request, and the caller's - * cancellation chains through every leg. - * - * Failure surfacing is per family: `tools/call` failures (capability - * refusal, leg failure, round-cap exhaustion) become `isError` tool - * results — the 2025-era idiom hosts already render — while `prompts/get` - * and `resources/read` failures surface as JSON-RPC errors. Server bugs - * (malformed input-required results) fail loudly on both eras, and - * requestState verification failures keep the frozen `-32602`. - * - * Not public API — package-internal, deliberately not exported from the - * package index. + * 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, @@ -59,17 +42,12 @@ import { } from '@modelcontextprotocol/core-internal'; /** - * Default round cap for the legacy `input_required` shim (handler re-entries - * per originating request). Deliberately tighter than the modern client - * driver's default of 10: the shim holds a live wire request open per flow. + * 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 for the embedded server→client requests the legacy - * shim sends, paired with `resetTimeoutOnProgress: true`. The 60s protocol - * default is wrong for human-in-the-loop legs (form fills, sign-ins). - */ +/** 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. */ @@ -79,12 +57,7 @@ export interface ResolvedLegacyShimOptions { legacyShim: boolean; } -/** - * Resolves and validates `ServerOptions.inputRequired` (fail-loud at - * construction time, like the sibling cacheHints validation) — the shim - * module owns its knobs the same way the client driver owns - * `resolveInputRequiredDriverConfig`. - */ +/** Resolves and validates `ServerOptions.inputRequired`, failing loudly at construction time. */ export function resolveLegacyShimOptions( options: { maxRounds?: number; roundTimeoutMs?: number; legacyShim?: boolean } | undefined ): ResolvedLegacyShimOptions { @@ -111,12 +84,9 @@ export interface CoercedEmbeddedInputRequest { } /** - * Validates one `inputRequests` entry of an input-required result: a - * malformed entry or an unknown embedded-request kind is a server bug and - * fails loudly (both eras — the vocabulary is the 2026-07-28 revision's - * regardless of which era the request is served on). Returns the coerced - * entry together with the client capabilities it requires. Shared by the - * modern seam's capability check and the legacy shim's gate. + * 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, @@ -139,19 +109,13 @@ export function coerceEmbeddedInputRequest( `embedded request the 2026-07-28 revision defines` ); } - // The cast records the invariant the check above just established: - // requiredClientCapabilitiesForInputRequest answers undefined for any - // method outside the three embedded kinds. return { embedded: embedded as CoercedEmbeddedInputRequest, required }; } /** - * Synthesizes the `elicitationId` the 2025-11-25 URL-mode elicitation shape - * requires: the 2026 in-band shape deliberately has none (correlation lives - * in `requestState`), so a URL-mode leg the legacy shim sends must mint one - * to be schema-valid toward conforming 2025 clients. Always CSPRNG-backed — - * `randomUUID` where available, `getRandomValues` formatted as a v4 UUID - * otherwise (the SDK already requires the Web Crypto API elsewhere). + * 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; @@ -166,12 +130,7 @@ function syntheticElicitationId(): string { return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } -/** - * Per-family failure surfacing for the legacy shim: `tools/call` failures - * become `isError` tool results (the 2025-era idiom — hosts and models - * already render them), `prompts/get` / `resources/read` failures surface as - * JSON-RPC errors. - */ +/** 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 }; @@ -180,14 +139,12 @@ function legacyShimFailure(method: string, message: string): Result { } /** - * Everything the shim needs from `Server`, as a narrow contract: - * the resolved knobs, the per-request resolved capability view (plan ruling - * F-2 — `initialize` state on a sessionful legacy connection, empty on + * 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 three existing 2025-era - * senders (capability-check-free cores; the shim's own gate is - * authoritative, and elicitation accepted content passes through UNVALIDATED - * for parity with the modern client driver). + * (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; @@ -199,10 +156,7 @@ export interface LegacyInputRequiredShimHost { listRoots(params: Record | undefined, options: RequestOptions): Promise; } -/** - * The fulfilment loop, held by `Server` and delegated to from the - * multi-round-trip seam (see the module doc for the full contract). - */ +/** The fulfilment loop — see the module doc for the contract. */ export class LegacyInputRequiredShim { constructor(private readonly _host: LegacyInputRequiredShimHost) {} @@ -225,9 +179,7 @@ export class LegacyInputRequiredShim { return legacyShimFailure(method, inputRequiredRoundsExceededMessage(method, maxRounds)); } - // At-least-one re-check per round (hand-built results are legal; - // a violation is a server bug and fails loudly, as on the modern - // era). + // 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; @@ -241,17 +193,14 @@ export class LegacyInputRequiredShim { let responses: Record | undefined; if (hasInputRequests) { - // The shim's OWN capability pre-check — never gated on - // `enforceStrictCapabilities` — against the per-request - // resolved view. The whole round gates BEFORE any wire - // traffic, so a refusal has no side effects. + // 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); - // The wire legs need params for the request-carrying - // kinds; a hand-built entry without them is a server bug - // and fails loudly, like every other malformation. + // Request-carrying kinds need params; absent = server bug. if (embedded.method !== 'roots/list' && embedded.params === undefined) { throw new ProtocolError( ProtocolErrorCode.InternalError, @@ -269,21 +218,15 @@ export class LegacyInputRequiredShim { coerced.push([key, embedded]); } - // Fulfil concurrently (the embedded requests are independent, - // mirroring the modern client driver); the first failure - // aborts the sibling legs via the shared linked per-round - // signal. + // 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 makes the leg carry a - // progressToken, which is what lets a client that - // reports progress mid-leg actually reset the leg - // timeout — without it resetTimeoutOnProgress could - // never fire (no token, nothing to report against). + // The no-op handler stamps a progressToken on the leg — + // without one, resetTimeoutOnProgress could never fire. onprogress: () => {}, signal: roundAbort.signal }; @@ -300,9 +243,7 @@ export class LegacyInputRequiredShim { responses = Object.fromEntries(fulfilled); } catch (error) { if (outerSignal.aborted) { - // The originating request was cancelled: propagate so - // the protocol layer drops the response (cancelled - // requests are never answered). + // Cancelled requests are never answered — propagate. throw error; } return legacyShimFailure( @@ -313,26 +254,20 @@ export class LegacyInputRequiredShim { roundAbort.dispose(); } } else { - // requestState-only (load-shedding) round: fixed pacing so - // the loop never hot-spins; counted in the same round cap - // (mirrors the modern client driver). + // 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. The re-entry context carries this - // round's material FIRST (raw state accessor + this round's - // responses), then the configured verify hook runs against that - // context — exactly the order and view a modern wire retry gets — - // and its decoded payload replaces the accessor value. - // Deny-on-error → the frozen -32602. + // 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 — never - // accumulated across rounds (parity with the modern - // client driver; multi-step flows thread earlier answers - // through requestState). + // REPLACE semantics: this round's responses only — multi-step + // flows thread earlier answers through requestState. inputResponses: responses, droppedInputResponseKeys: undefined, requestState: requestStateAccessor(requestState) @@ -345,9 +280,8 @@ export class LegacyInputRequiredShim { } } - // Re-entry goes through the SAME stored handler the wire retry - // would hit (for McpServer that is the full funnel: input - // re-validation, output projection, tools/call error catch). + // 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; @@ -356,20 +290,12 @@ export class LegacyInputRequiredShim { } } - /** - * Routes one embedded input request through the host's existing 2025-era - * senders — the same wire paths a hand-written era-branching handler - * used. The shim's capability gate has already run. - */ + /** 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) { - // The 2026 in-band URL shape carries no elicitationId - // (correlation lives in requestState), but the 2025-11-25 - // wire schema requires one — synthesize it so conforming - // 2025 clients accept the leg. params = { ...(params as ElicitRequestURLParams), elicitationId: syntheticElicitationId() }; } return await this._host.sendElicitation(params, options); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 01f9d33425..ef2b9651e4 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -120,45 +120,30 @@ export type ServerOptions = ProtocolOptions & { cacheHints?: Partial>; /** - * Multi-round-trip serving knobs (`input_required` results from - * `tools/call` / `prompts/get` / `resources/read` handlers). - * - * On 2026-07-28-era requests the client fulfils the embedded requests and - * retries. On 2025-era connections the SDK's **legacy shim** fulfils them - * server-side instead: each embedded request is sent as a real - * server→client request (`elicitation/create`, `sampling/createMessage`, - * `roots/list`) over the live session, and the handler is re-entered with - * the collected `inputResponses` and the echoed `requestState` until it - * returns a final result — handlers are written once and serve both eras. + * 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?: { /** - * Maximum number of handler re-entries the legacy shim performs for a - * single originating request before failing (`tools/call`: an - * `isError` tool result; `prompts/get` / `resources/read`: a JSON-RPC - * error). - * + * 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 in milliseconds for each embedded server→client - * request the legacy shim sends, passed with - * `resetTimeoutOnProgress: true`. The default is deliberately much - * larger than the 60s protocol default — embedded requests are - * human-paced (form fills, sign-ins). - * + * 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; /** - * Set to `false` to disable the legacy shim: an `input_required` - * return on a 2025-era request then fails loudly (the pre-shim - * behavior), and a handler that serves both eras must branch on the - * served era itself. - * + * `false` disables the shim: an `input_required` return on a + * 2025-era request fails loudly (the pre-shim behavior). * @default true */ legacyShim?: boolean; @@ -291,21 +276,13 @@ export class Server extends Protocol { private _inputRequiredServing: { maxRounds: number; roundTimeoutMs: number; legacyShim: boolean }; private _legacyShim?: LegacyInputRequiredShim; - /** - * The legacy `input_required` shim, constructed lazily on the first - * 2025-era input-required return. Everything it needs from this server - * crosses one narrow host contract — the loop itself lives in - * `legacyInputRequiredShim.ts`, not here. - */ + /** 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), - // Capability-check-free sender cores: the shim's gate is - // authoritative, and elicitation accepted content passes through - // UNVALIDATED for parity with the modern client driver. sendElicitation: (params, options) => this._sendElicitationLeg(params, options, { validateAcceptedContent: false }), sendSampling: (params, options) => this.createMessage(params, options), listRoots: (params, options) => this.listRoots(params, options) @@ -613,9 +590,6 @@ export class Server extends Protocol { } let ctxForHandler = ctx; if (typeof rawRequestState === 'string') { - // Deny-on-error: ANY verify-hook failure mode (throw or rejection) - // answers the frozen -32602 — the handler never runs on state the - // hook did not pass. const decoded = await this._verifyRequestState(rawRequestState, ctx, method); if (decoded !== undefined) { ctxForHandler = withRequestStateValue(ctx, decoded); @@ -663,10 +637,7 @@ export class Server extends Protocol { `${this._negotiatedProtocolVersion ?? LATEST_PROTOCOL_VERSION}, which has no input_required vocabulary` ); } - // The legacy shim: fulfil the embedded requests as real - // server→client requests over the live 2025-era session and - // re-enter the handler until it returns a final result — - // write-once handlers served to deployed 2025 clients. + // Write-once handlers served to deployed 2025 clients. return await this._legacyInputRequiredShim().fulfill(method, handler, request, ctxForHandler, result); } @@ -683,9 +654,8 @@ export class Server extends Protocol { ); } - // Per-embedded-request capability check against the per-request - // resolved view — on this (modern) era 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 = this._inputRequestCapabilityView(ctx); for (const [key, entry] of Object.entries(inputRequests)) { @@ -705,14 +675,10 @@ export class Server extends Protocol { } /** - * Runs the configured `requestState.verify` hook on an echoed - * `requestState` and returns its resolved value (the decoded payload for - * codec verifiers; `undefined` when no hook is configured or the hook - * returns nothing). - * - * Deny-on-error: ANY failure mode of the hook — rejection or synchronous - * throw — is treated as verification failure and answered with the frozen - * `-32602` wire error; the thrown reason is surfaced via `onerror` only. + * 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) { @@ -731,13 +697,10 @@ export class Server extends Protocol { } /** - * The per-request resolved client-capabilities view (plan ruling F-2): - * the capabilities the request itself is entitled to rely on. On the - * 2026-07-28 era that is the request's own `_meta` envelope declaration; - * on a 2025-era connection it is the `initialize`-declared state this - * connection-pinned instance holds — and per-request instances that never - * saw an initialize (stateless legacy serving) hold nothing, so the view - * is empty there and capability gates refuse structurally. + * 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() @@ -1184,17 +1147,12 @@ export class Server extends Protocol { } /** - * The capability-check-free core of {@linkcode elicitInput}: mode - * normalization, the wire request, and (for the public push API) - * accepted-content schema validation. Shared by the public push API - * (which applies its era and per-mode capability checks first, and keeps - * its historical content validation) and the legacy `input_required` - * shim, whose own gate applies the documented pre-mode rule instead — a - * bare `elicitation: {}` declaration (the 2025-06-18 shape) counts as - * form support, exactly as the modern era's `-32021` gate reads it — and - * which passes accepted content through UNVALIDATED for parity with the - * modern client driver (the handler validates via the schema-aware - * `acceptedContent` overload and can re-issue the request). + * 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, From 96fac650b670115a435b59856e159870d82eb96e Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 14:35:44 +0000 Subject: [PATCH 11/13] Align the pending requestState codec changeset with the captured verify return Same class as the MRTR seam changeset fix: it ships in the same version bump and still described the hook's return as discarded. --- .changeset/add-request-state-codec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From 9eab91aa29e2e960b6b1fce54ce1aee1ce38d93d Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 14:49:47 +0000 Subject: [PATCH 12/13] Update todos-server prose to the write-once style The README still sent readers to compare era branches the previous commit deleted, and the stateCodec JSDoc still taught the retired second-verify decode pattern instead of the typed accessor. --- examples/todos-server/README.md | 2 +- examples/todos-server/todos.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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 430f5bab3a..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). */ From 9146dd299036ba7711798ba1dfca9dc13035fdec Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 15:29:45 +0000 Subject: [PATCH 13/13] Update the seam docblock for the legacy shim default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It still said input-required returns are only legal toward the 2026-07-28 era and that capability violations always answer -32021 — the body of this very method now fulfils 2025-era returns through the shim, whose gate surfaces violations per family. --- packages/server/src/server/server.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index ef2b9651e4..4151849494 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -560,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,