From 50ae3b70c6168e1d3e5d22911877bf7894542466 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 12 May 2026 11:29:52 +0000 Subject: [PATCH 1/2] feat(core): RequiresInput catch in Dispatcher.dispatch -> IncompleteResult; MRTR types (SEP-2322) --- packages/core/src/exports/public/index.ts | 4 ++ packages/core/src/shared/context.ts | 13 +++++ packages/core/src/shared/dispatcher.ts | 52 ++++++++++++++++++-- packages/core/src/types/types.ts | 38 ++++++++++++++ packages/core/test/shared/dispatcher.test.ts | 33 ++++++++++++- 5 files changed, 135 insertions(+), 5 deletions(-) diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 19b48c7a59..e49e66735a 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -54,6 +54,10 @@ export type { } from '../../shared/protocol.js'; export { DEFAULT_REQUEST_TIMEOUT_MSEC } from '../../shared/protocol.js'; +// SEP-2322 multi-round-trip request types (IncompleteResult/InputRequest/InputResponseRequestParams +// are exported via the `export * from types/types.js` below) +export { RequiresInput } from '../../shared/dispatcher.js'; + // Task manager types (NOT TaskManager class itself — internal) export type { RequestTaskStore, TaskContext, TaskManagerOptions, TaskRequestOptions } from '../../shared/taskManager.js'; diff --git a/packages/core/src/shared/context.ts b/packages/core/src/shared/context.ts index 9c06919fff..66094b9ee5 100644 --- a/packages/core/src/shared/context.ts +++ b/packages/core/src/shared/context.ts @@ -167,6 +167,19 @@ export type BaseContext = { * This is used by certain transports to correctly associate related messages. */ notify: (notification: Notification) => Promise; + + /** + * SEP-2322: client-supplied answers to a prior round's + * {@linkcode IncompleteResult.inputRequests}, keyed by the same opaque ids. + * Populated from `request.params.inputResponses` when present. + */ + inputResponses?: Record; + + /** + * SEP-2322: opaque continuation token echoed from a prior round's + * {@linkcode IncompleteResult.requestState}. + */ + requestState?: string; }; /** diff --git a/packages/core/src/shared/dispatcher.ts b/packages/core/src/shared/dispatcher.ts index 959011728a..9795f93d50 100644 --- a/packages/core/src/shared/dispatcher.ts +++ b/packages/core/src/shared/dispatcher.ts @@ -1,5 +1,8 @@ import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js'; import type { + IncompleteResult, + InputRequest, + InputResponseRequestParams, JSONRPCErrorResponse, JSONRPCNotification, JSONRPCRequest, @@ -46,6 +49,33 @@ export type DispatchFn = (req: JSONRPCRequest, env?: RequestEnv) => AsyncGenerat */ export type DispatchMiddleware = (next: DispatchFn) => DispatchFn; +/** + * Thrown by a handler (typically via `ctx.mcpReq.send` when no backchannel is available) + * to signal that the request cannot complete without client input. {@linkcode Dispatcher.dispatch} + * catches this and yields a successful {@linkcode IncompleteResult} response (SEP-2322 + * Option E ephemeral path). The client services the {@linkcode RequiresInput.inputRequests} + * and retries with `params.inputResponses`. + */ +export class RequiresInput extends Error { + constructor( + readonly inputRequests: Record, + readonly requestState?: string + ) { + // eslint-disable-next-line unicorn/no-array-sort -- toSorted() requires ES2023 lib; consumers may target ES2022 + super(`Client input required: ${Object.keys(inputRequests).sort().join(', ')}`); + this.name = 'RequiresInput'; + } + + /** Convert to the wire result {@linkcode Dispatcher.dispatch} yields. */ + toIncompleteResult(): IncompleteResult { + return { + resultType: 'incomplete', + inputRequests: this.inputRequests, + ...(this.requestState !== undefined && { requestState: this.requestState }) + }; + } +} + /** * Derives the handler return type for the 3-arg `setRequestHandler` form from its * `result` schema, defaulting to {@linkcode Result} when no schema is supplied. @@ -158,6 +188,9 @@ export class Dispatcher { throw new SdkError(SdkErrorCode.NotConnected, 'No outbound channel: ctx.mcpReq.send requires a connected peer'); }); + // SEP-2322: lift inputResponses/requestState off the params if this is a retry round. + const mrtrParams = request.params as InputResponseRequestParams | undefined; + const base: BaseContext = { sessionId: env.sessionId, mcpReq: { @@ -165,6 +198,8 @@ export class Dispatcher { method: request.method, _meta: request.params?._meta, signal: localAbort.signal, + inputResponses: mrtrParams?.inputResponses, + requestState: mrtrParams?.requestState, send: (async (r: Request, schemaOrOptions?: unknown, maybeOptions?: RequestOptions) => { const isSchema = schemaOrOptions != null && typeof schemaOrOptions === 'object' && '~standard' in schemaOrOptions; const options = isSchema ? maybeOptions : (schemaOrOptions as RequestOptions | undefined); @@ -205,9 +240,16 @@ export class Dispatcher { : { jsonrpc: '2.0', id: request.id, result }; }, error => { - final = localAbort.signal.aborted - ? errorResponse(request.id, ProtocolErrorCode.InternalError, 'Request cancelled').message - : toErrorResponse(request.id, error); + if (localAbort.signal.aborted) { + final = errorResponse(request.id, ProtocolErrorCode.InternalError, 'Request cancelled').message; + } else if (error instanceof RequiresInput) { + // SEP-2322 Option E: a handler that needs client input throws RequiresInput + // (typically via ctx.mcpReq.send with no backchannel). This is a *successful* + // result discriminated by resultType:'incomplete', not an error response. + final = { jsonrpc: '2.0', id: request.id, result: error.toIncompleteResult() }; + } else { + final = toErrorResponse(request.id, error); + } } ) .finally(() => { @@ -289,7 +331,11 @@ export class Dispatcher { const handler = maybeHandler as (params: unknown, ctx: ContextT) => Result | Promise; stored = async (request, ctx) => { const userParams = { ...((request.params ?? {}) as Record) }; + // Protocol-envelope fields are stripped before user-schema validation so a strict + // schema does not reject SEP-2322 retry rounds. delete userParams._meta; + delete userParams.inputResponses; + delete userParams.requestState; const parsed = await validateStandardSchema(schemas.params, userParams); if (!parsed.success) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error}`); diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index a92deec8e1..de92f4a016 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -191,6 +191,44 @@ export type TaskAugmentedRequestParams = Infer; export type Notification = Infer; export type Result = Infer; + +/** + * A single server-to-client request the handler needs answered before it can finish. + * Carried in {@linkcode IncompleteResult.inputRequests}, keyed by an opaque id the client + * echoes back in {@linkcode InputResponseRequestParams.inputResponses}. SEP-2322. + */ +export interface InputRequest { + method: string; + params?: Record; +} + +/** + * SEP-2322 multi-round-trip result discriminator. The server returns this when it cannot + * complete without client input (sampling, elicitation). The client services + * {@linkcode IncompleteResult.inputRequests | inputRequests} and retries the same request + * with {@linkcode InputResponseRequestParams.inputResponses | inputResponses}. Handlers + * normally do not return this directly; throwing + * {@linkcode @modelcontextprotocol/core!shared/dispatcher.RequiresInput | RequiresInput} + * (or calling `ctx.mcpReq.send` with no backchannel) emits it. + */ +export interface IncompleteResult extends Result { + resultType: 'incomplete'; + /** Server-initiated requests the client must answer, keyed by an opaque id. */ + inputRequests?: Record; + /** Opaque continuation token the client echoes back unchanged. */ + requestState?: string; +} + +/** + * Params shape for a SEP-2322 retry: the client adds `inputResponses` (keyed by the ids + * from {@linkcode IncompleteResult.inputRequests}) and echoes `requestState` alongside + * the original method-specific params. + */ +export interface InputResponseRequestParams { + inputResponses?: Record; + requestState?: string; +} + export type RequestId = Infer; export type JSONRPCRequest = Infer; export type JSONRPCNotification = Infer; diff --git a/packages/core/test/shared/dispatcher.test.ts b/packages/core/test/shared/dispatcher.test.ts index 4f36e16b74..eadb4584c8 100644 --- a/packages/core/test/shared/dispatcher.test.ts +++ b/packages/core/test/shared/dispatcher.test.ts @@ -3,8 +3,8 @@ import { z } from 'zod/v4'; import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; import type { DispatchOutput } from '../../src/shared/dispatcher.js'; -import { Dispatcher } from '../../src/shared/dispatcher.js'; -import type { JSONRPCErrorResponse, JSONRPCRequest, JSONRPCResultResponse, Result } from '../../src/types/index.js'; +import { Dispatcher, RequiresInput } from '../../src/shared/dispatcher.js'; +import type { IncompleteResult, JSONRPCErrorResponse, JSONRPCRequest, JSONRPCResultResponse, Result } from '../../src/types/index.js'; import { ProtocolError, ProtocolErrorCode } from '../../src/types/index.js'; const req = (method: string, params?: Record, id = 1): JSONRPCRequest => ({ jsonrpc: '2.0', id, method, params }); @@ -320,3 +320,32 @@ describe('Dispatcher.setRequestHandler 3-arg (custom method + {params, result})' expect(() => setNotif('acme/ping', () => {})).toThrow(/not a spec notification method/); }); }); + +describe('RequiresInput → IncompleteResult (SEP-2322 Option E)', () => { + test('handler throwing RequiresInput yields a successful IncompleteResult, not an error', async () => { + const d = new Dispatcher(); + d.setRequestHandler('ping', async () => { + throw new RequiresInput({ r0: { method: 'elicitation/create', params: {} } }, 'state-token-1'); + }); + const out = await collect(d.dispatch(req('ping'))); + expect(out).toHaveLength(1); + expect(out[0]!.kind).toBe('response'); + const msg = out[0]!.message as JSONRPCResultResponse; + expect('result' in msg).toBe(true); + const result = msg.result as IncompleteResult; + expect(result.resultType).toBe('incomplete'); + expect(result.inputRequests).toEqual({ r0: { method: 'elicitation/create', params: {} } }); + expect(result.requestState).toBe('state-token-1'); + }); + + test('inputResponses/requestState are lifted onto ctx.mcpReq', async () => { + const d = new Dispatcher(); + let seen: { inputResponses?: unknown; requestState?: unknown } = {}; + d.setRequestHandler('ping', async (_r, ctx) => { + seen = { inputResponses: ctx.mcpReq.inputResponses, requestState: ctx.mcpReq.requestState }; + return {}; + }); + await collect(d.dispatch(req('ping', { inputResponses: { r0: { ok: true } }, requestState: 's1' }))); + expect(seen).toEqual({ inputResponses: { r0: { ok: true } }, requestState: 's1' }); + }); +}); From e579bffc0da0f7c88d5db5794a12e6f41e1198f7 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 12 May 2026 15:36:59 +0000 Subject: [PATCH 2/2] docs(types): use bare RequiresInput link (cross-package path failed to resolve) --- packages/core/src/types/types.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index de92f4a016..6cd1c457db 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -207,8 +207,7 @@ export interface InputRequest { * complete without client input (sampling, elicitation). The client services * {@linkcode IncompleteResult.inputRequests | inputRequests} and retries the same request * with {@linkcode InputResponseRequestParams.inputResponses | inputResponses}. Handlers - * normally do not return this directly; throwing - * {@linkcode @modelcontextprotocol/core!shared/dispatcher.RequiresInput | RequiresInput} + * normally do not return this directly; throwing {@linkcode RequiresInput} * (or calling `ctx.mcpReq.send` with no backchannel) emits it. */ export interface IncompleteResult extends Result {