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

This commit is contained in:
Felix Weinberger 2026-06-29 16:43:43 +01:00 • committed by GitHub
commit f0bf785239
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 2196 additions and 217 deletions

View file

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

View file

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

View file

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

View file

@ -56,12 +56,14 @@ import {
Protocol,
ProtocolError,
ProtocolErrorCode,
requiredClientCapabilitiesForInputRequest,
SdkError,
SdkErrorCode
SdkErrorCode,
withRequestStateValue
} from '@modelcontextprotocol/core-internal';
import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims';
import { coerceEmbeddedInputRequest, LegacyInputRequiredShim, resolveLegacyShimOptions } from './legacyInputRequiredShim';
/**
* The request methods whose 2026-07-28 result vocabulary includes
* `input_required` (the multi round-trip methods). Returning an
@ -117,20 +119,51 @@ export type ServerOptions = ProtocolOptions & {
*/
cacheHints?: Partial<Record<CacheableResultMethod, CacheHint>>;
/**
* Multi-round-trip serving knobs. On 2026-era requests the client
* fulfils `input_required` returns; on 2025-era connections the SDK's
* legacy shim fulfils them server-side (real server→client requests +
* handler re-entry), so handlers are written once and serve both eras.
*/
inputRequired?: {
/**
* Handler re-entries per originating request before the shim fails
* (tools/call: `isError` result; prompts/resources: JSON-RPC error).
* @default 8
*/
maxRounds?: number;
/**
* Per-leg timeout (ms) for the shim's embedded server→client
* requests, sent with `resetTimeoutOnProgress: true`. Human-paced —
* deliberately far above the 60s protocol default.
* @default 600_000
*/
roundTimeoutMs?: number;
/**
* `false` disables the shim: an `input_required` return on a
* 2025-era request fails loudly (the pre-shim behavior).
* @default true
*/
legacyShim?: boolean;
};
/**
* Multi-round-trip `requestState` integrity hook (protocol revision
* 2026-07-28).
*/
requestState?: {
/**
* Called on every re-entered multi-round-trip request that carries a
* `requestState` (i.e. whenever `ctx.mcpReq.requestState` is present),
* BEFORE the handler runs. Throw or reject to refuse the request: the
* seam answers with a wire-level `-32602` Invalid Params error whose
* message is frozen to `"Invalid or expired requestState"` and whose
* `data.reason` is `'invalid_request_state'` — the thrown reason is
* surfaced via the server's `onerror` callback only and never reaches
* the wire.
* Called on every multi-round-trip request round whose echoed
* `requestState` is a string (i.e. whenever
* `ctx.mcpReq.requestState()` would return one), BEFORE the handler
* runs — including the legacy shim's in-process rounds. Throw or
* reject to refuse the request: the seam answers with a wire-level
* `-32602` Invalid Params error whose message is frozen to
* `"Invalid or expired requestState"` and whose `data.reason` is
* `'invalid_request_state'` — the thrown reason is surfaced via the
* server's `onerror` callback only and never reaches the wire.
*
* This is the place to put HMAC or AEAD verification of
* `requestState`. The spec MUST for integrity-protecting state that
@ -139,16 +172,20 @@ export type ServerOptions = ProtocolOptions & {
* the SDK provides NO default verification —
* {@linkcode server/requestStateCodec.createRequestStateCodec | createRequestStateCodec}
* is the SDK-provided HMAC helper whose `verify` drops in here
* directly. Leaving this option
* unconfigured keeps today's behavior — `ctx.mcpReq.requestState` is
* passed through raw and MUST be treated as attacker-controlled
* input.
* directly. Leaving this option unconfigured keeps the passthrough
* behavior — `ctx.mcpReq.requestState()` returns the raw wire string,
* which MUST be treated as attacker-controlled input.
*
* The return value is ignored (the seam awaits-and-discards); the
* hook signature accepts any return so a verifier that also yields
* the decoded payload — as
* The resolved value is LOAD-BEARING: when the hook resolves with a
* non-`undefined` value — as
* {@linkcode server/requestStateCodec.RequestStateCodec | RequestStateCodec}`.verify`
* does — is directly assignable.
* does (the decoded payload) — the seam hands THAT value to the
* handler via the typed `ctx.mcpReq.requestState<T>()` 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<unknown>;
};
@ -236,6 +273,21 @@ export class Server extends Protocol<ServerContext> {
private _jsonSchemaValidator: jsonSchemaValidator;
private _cacheHints?: ServerOptions['cacheHints'];
private _requestStateVerify?: (state: string, ctx: ServerContext) => unknown | Promise<unknown>;
private _inputRequiredServing: { maxRounds: number; roundTimeoutMs: number; legacyShim: boolean };
private _legacyShim?: LegacyInputRequiredShim;
/** Lazily-built legacy shim; the loop lives in legacyInputRequiredShim.ts behind a narrow host contract. */
private _legacyInputRequiredShim(): LegacyInputRequiredShim {
return (this._legacyShim ??= new LegacyInputRequiredShim({
maxRounds: this._inputRequiredServing.maxRounds,
roundTimeoutMs: this._inputRequiredServing.roundTimeoutMs,
resolvedClientCapabilities: ctx => this._inputRequestCapabilityView(ctx),
verifyRequestState: (state, ctx, method) => this._verifyRequestState(state, ctx, method),
sendElicitation: (params, options) => this._sendElicitationLeg(params, options, { validateAcceptedContent: false }),
sendSampling: (params, options) => this.createMessage(params, options),
listRoots: (params, options) => this.listRoots(params, options)
}));
}
/**
* Callback for when initialization has fully completed (i.e., the client has sent an `notifications/initialized` notification).
@ -255,6 +307,8 @@ export class Server extends Protocol<ServerContext> {
this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator();
this._requestStateVerify = options?.requestState?.verify;
this._inputRequiredServing = resolveLegacyShimOptions(options?.inputRequired);
// Configured cache hints fail loudly at construction time (before any
// handler registration consults them).
if (options?.cacheHints !== undefined) {
@ -506,11 +560,14 @@ export class Server extends Protocol<ServerContext> {
* 2026-07-28 wire and the throw is not silently converted. Requests
* served on the 2025 era keep today's `-32042` behavior byte-exact (the
* error is rethrown unchanged).
* - an input-required RETURN is only legal toward the 2026-07-28 era; it
* must satisfy the at-least-one rule (`inputRequests` or
* `requestState`), and every embedded request must be covered by the
* capabilities the client declared on this request's envelope
* (violations answer with the typed `-32021` error).
* - an input-required RETURN toward a 2026-07-28 request must satisfy
* the at-least-one rule, and every embedded request must be covered by
* the capabilities declared on the request's envelope (violations
* answer the typed `-32021` error). Toward a 2025-era request the
* return is fulfilled by the default-on legacy shim, whose own gate
* consults the initialize-declared capabilities and surfaces
* violations per family; `inputRequired.legacyShim: false` restores
* the pre-shim loud failure.
*/
private async _invokeInputRequiredCapableHandler(
method: string,
@ -528,28 +585,23 @@ export class Server extends Protocol<ServerContext> {
// wire field is `string | undefined`) is treated as invalid regardless
// of whether a hook is configured, so a malformed value cannot bypass
// verification.
const rawRequestState = ctx.mcpReq.requestState as unknown;
const rawRequestState: unknown = ctx.mcpReq.requestState();
if (rawRequestState !== undefined && typeof rawRequestState !== 'string') {
throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid or expired requestState', {
reason: 'invalid_request_state'
});
}
if (this._requestStateVerify !== undefined && typeof rawRequestState === 'string') {
try {
await this._requestStateVerify(rawRequestState, ctx);
} catch (error) {
this.onerror?.(
new Error(`requestState verification rejected ${method}: ${error instanceof Error ? error.message : String(error)}`)
);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid or expired requestState', {
reason: 'invalid_request_state'
});
let ctxForHandler = ctx;
if (typeof rawRequestState === 'string') {
const decoded = await this._verifyRequestState(rawRequestState, ctx, method);
if (decoded !== undefined) {
ctxForHandler = withRequestStateValue(ctx, decoded);
}
}
let result: Result;
try {
result = await handler(request, ctx);
result = await handler(request, ctxForHandler);
} catch (error) {
if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) {
if (!servedModern) {
@ -577,15 +629,19 @@ export class Server extends Protocol<ServerContext> {
}
if (!servedModern) {
// The 2025-era wire has no input_required vocabulary: fail loudly
// rather than putting a mis-typed result on the wire. A handler
// that serves both eras branches on the served era and uses the
// push-style APIs toward 2025-era requests.
throw new ProtocolError(
ProtocolErrorCode.InternalError,
`Handler for ${method} returned an input-required result, but this request is served on protocol revision ` +
`${this._negotiatedProtocolVersion ?? LATEST_PROTOCOL_VERSION}, which has no input_required vocabulary`
);
if (!this._inputRequiredServing.legacyShim) {
// The escape hatch (`inputRequired.legacyShim: false`)
// restores the pre-shim posture: the 2025-era wire has no
// input_required vocabulary, so fail loudly rather than
// putting a mis-typed result on the wire.
throw new ProtocolError(
ProtocolErrorCode.InternalError,
`Handler for ${method} returned an input-required result, but this request is served on protocol revision ` +
`${this._negotiatedProtocolVersion ?? LATEST_PROTOCOL_VERSION}, which has no input_required vocabulary`
);
}
// Write-once handlers served to deployed 2025 clients.
return await this._legacyInputRequiredShim().fulfill(method, handler, request, ctxForHandler, result);
}
// F7 at-least-one re-check (hand-built results are legal; the rule is
@ -601,27 +657,12 @@ export class Server extends Protocol<ServerContext> {
);
}
// Per-embedded-request capability check against the capabilities the
// client declared on THIS request's envelope (-32021 on violation).
// Per-embedded-request capability check against the request's own
// envelope declaration (-32021 on violation).
if (hasInputRequests) {
const declared = ctx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined;
const declared = this._inputRequestCapabilityView(ctx);
for (const [key, entry] of Object.entries(inputRequests)) {
if (entry === null || typeof entry !== 'object' || typeof (entry as { method?: unknown }).method !== 'string') {
throw new ProtocolError(
ProtocolErrorCode.InternalError,
`Handler for ${method} returned an invalid input request '${key}': each inputRequests entry must be an ` +
`embedded elicitation/create, sampling/createMessage, or roots/list request`
);
}
const embedded = entry as { method: string; params?: Record<string, unknown> };
const required = requiredClientCapabilitiesForInputRequest(embedded);
if (required === undefined) {
throw new ProtocolError(
ProtocolErrorCode.InternalError,
`Handler for ${method} returned an input request '${key}' of kind '${embedded.method}', which is not an ` +
`embedded request the 2026-07-28 revision defines`
);
}
const { embedded, required } = coerceEmbeddedInputRequest(method, key, entry);
const missing = missingClientCapabilities(required, declared);
if (missing !== undefined) {
throw new MissingRequiredClientCapabilityError(
@ -636,6 +677,40 @@ export class Server extends Protocol<ServerContext> {
return result;
}
/**
* Runs the configured `requestState.verify` hook and returns its
* resolved value (`undefined` when unconfigured or the hook returns
* nothing). Deny-on-error: any hook failure answers the frozen `-32602`;
* the reason goes to `onerror` only.
*/
private async _verifyRequestState(state: string, ctx: ServerContext, method: string): Promise<unknown> {
if (this._requestStateVerify === undefined) {
return undefined;
}
try {
return await this._requestStateVerify(state, ctx);
} catch (error) {
this.onerror?.(
new Error(`requestState verification rejected ${method}: ${error instanceof Error ? error.message : String(error)}`)
);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid or expired requestState', {
reason: 'invalid_request_state'
});
}
}
/**
* The per-request resolved client-capabilities view: the request's own
* `_meta` envelope on the 2026 era; the `initialize`-declared state on a
* 2025-era connection. Per-request instances that never saw an
* initialize (stateless legacy) hold nothing, so gates refuse there.
*/
private _inputRequestCapabilityView(ctx: ServerContext): ClientCapabilities | undefined {
return this._servedModernEra()
? (ctx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined)
: this._clientCapabilities;
}
/**
* Guard for the push-style server→client request APIs ({@linkcode createMessage},
* {@linkcode elicitInput}, {@linkcode listRoots}, {@linkcode ping}) on a
@ -1061,23 +1136,49 @@ export class Server extends Protocol<ServerContext> {
if (!this._clientCapabilities?.elicitation?.url) {
throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support url elicitation.');
}
break;
}
case 'form': {
if (!this._clientCapabilities?.elicitation?.form) {
throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support form elicitation.');
}
break;
}
}
return this._sendElicitationLeg(params, options);
}
/**
* The capability-check-free core of {@linkcode elicitInput}. The shim
* uses it because its gate differs from the public checks: a bare
* `elicitation: {}` counts as form support (the pre-mode rule), and
* accepted content passes through unvalidated for parity with the
* modern client driver (handlers validate via the schema-aware
* `acceptedContent` overload and can re-ask).
*/
private async _sendElicitationLeg(
params: ElicitRequestFormParams | ElicitRequestURLParams,
options?: RequestOptions,
behavior?: { validateAcceptedContent: boolean }
): Promise<ElicitResult> {
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);