Serve input_required handlers on 2025-era connections via a legacy fulfilment shim (#2381)
This commit is contained in:
parent
7c42d476cd
commit
f0bf785239
4 changed files with 2196 additions and 217 deletions
|
|
@ -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';
|
||||
|
|
|
|||
311
packages/server/src/server/legacyInputRequiredShim.ts
Normal file
311
packages/server/src/server/legacyInputRequiredShim.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue