Skip to content

Commit d7d52d9

Browse files
feat(core): RequiresInput catch in Dispatcher.dispatch -> IncompleteResult; MRTR types (SEP-2322)
1 parent df45523 commit d7d52d9

5 files changed

Lines changed: 135 additions & 5 deletions

File tree

packages/core/src/exports/public/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ export type {
5151
} from '../../shared/protocol.js';
5252
export { DEFAULT_REQUEST_TIMEOUT_MSEC, readExt } from '../../shared/protocol.js';
5353

54+
// SEP-2322 multi-round-trip request types (IncompleteResult/InputRequest/InputResponseRequestParams
55+
// are exported via the `export * from types/types.js` below)
56+
export { RequiresInput } from '../../shared/dispatcher.js';
57+
5458
// Task manager types (NOT TaskManager class itself — internal)
5559
export type { RequestTaskStore, TaskContext, TaskManagerOptions, TaskRequestOptions } from '../../shared/taskManager.js';
5660

packages/core/src/shared/context.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,19 @@ export type BaseContext = {
167167
* This is used by certain transports to correctly associate related messages.
168168
*/
169169
notify: (notification: Notification) => Promise<void>;
170+
171+
/**
172+
* SEP-2322: client-supplied answers to a prior round's
173+
* {@linkcode IncompleteResult.inputRequests}, keyed by the same opaque ids.
174+
* Populated from `request.params.inputResponses` when present.
175+
*/
176+
inputResponses?: Record<string, Result>;
177+
178+
/**
179+
* SEP-2322: opaque continuation token echoed from a prior round's
180+
* {@linkcode IncompleteResult.requestState}.
181+
*/
182+
requestState?: string;
170183
};
171184

172185
/**

packages/core/src/shared/dispatcher.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js';
22
import type {
3+
IncompleteResult,
4+
InputRequest,
5+
InputResponseRequestParams,
36
JSONRPCErrorResponse,
47
JSONRPCNotification,
58
JSONRPCRequest,
@@ -46,6 +49,33 @@ export type DispatchFn = (req: JSONRPCRequest, env?: RequestEnv) => AsyncGenerat
4649
*/
4750
export type DispatchMiddleware = (next: DispatchFn) => DispatchFn;
4851

52+
/**
53+
* Thrown by a handler (typically via `ctx.mcpReq.send` when no backchannel is available)
54+
* to signal that the request cannot complete without client input. {@linkcode Dispatcher.dispatch}
55+
* catches this and yields a successful {@linkcode IncompleteResult} response (SEP-2322
56+
* Option E ephemeral path). The client services the {@linkcode RequiresInput.inputRequests}
57+
* and retries with `params.inputResponses`.
58+
*/
59+
export class RequiresInput extends Error {
60+
constructor(
61+
readonly inputRequests: Record<string, InputRequest>,
62+
readonly requestState?: string
63+
) {
64+
// eslint-disable-next-line unicorn/no-array-sort -- toSorted() requires ES2023 lib; consumers may target ES2022
65+
super(`Client input required: ${Object.keys(inputRequests).sort().join(', ')}`);
66+
this.name = 'RequiresInput';
67+
}
68+
69+
/** Convert to the wire result {@linkcode Dispatcher.dispatch} yields. */
70+
toIncompleteResult(): IncompleteResult {
71+
return {
72+
resultType: 'incomplete',
73+
inputRequests: this.inputRequests,
74+
...(this.requestState !== undefined && { requestState: this.requestState })
75+
};
76+
}
77+
}
78+
4979
/**
5080
* Derives the handler return type for the 3-arg `setRequestHandler` form from its
5181
* `result` schema, defaulting to {@linkcode Result} when no schema is supplied.
@@ -158,13 +188,18 @@ export class Dispatcher<ContextT extends BaseContext = BaseContext> {
158188
throw new SdkError(SdkErrorCode.NotConnected, 'No outbound channel: ctx.mcpReq.send requires a connected peer');
159189
});
160190

191+
// SEP-2322: lift inputResponses/requestState off the params if this is a retry round.
192+
const mrtrParams = request.params as InputResponseRequestParams | undefined;
193+
161194
const base: BaseContext = {
162195
sessionId: env.sessionId,
163196
mcpReq: {
164197
id: request.id,
165198
method: request.method,
166199
_meta: request.params?._meta,
167200
signal: localAbort.signal,
201+
inputResponses: mrtrParams?.inputResponses,
202+
requestState: mrtrParams?.requestState,
168203
send: (async (r: Request, schemaOrOptions?: unknown, maybeOptions?: RequestOptions) => {
169204
const isSchema = schemaOrOptions != null && typeof schemaOrOptions === 'object' && '~standard' in schemaOrOptions;
170205
const options = isSchema ? maybeOptions : (schemaOrOptions as RequestOptions | undefined);
@@ -205,9 +240,16 @@ export class Dispatcher<ContextT extends BaseContext = BaseContext> {
205240
: { jsonrpc: '2.0', id: request.id, result };
206241
},
207242
error => {
208-
final = localAbort.signal.aborted
209-
? errorResponse(request.id, ProtocolErrorCode.InternalError, 'Request cancelled').message
210-
: toErrorResponse(request.id, error);
243+
if (localAbort.signal.aborted) {
244+
final = errorResponse(request.id, ProtocolErrorCode.InternalError, 'Request cancelled').message;
245+
} else if (error instanceof RequiresInput) {
246+
// SEP-2322 Option E: a handler that needs client input throws RequiresInput
247+
// (typically via ctx.mcpReq.send with no backchannel). This is a *successful*
248+
// result discriminated by resultType:'incomplete', not an error response.
249+
final = { jsonrpc: '2.0', id: request.id, result: error.toIncompleteResult() };
250+
} else {
251+
final = toErrorResponse(request.id, error);
252+
}
211253
}
212254
)
213255
.finally(() => {
@@ -289,7 +331,11 @@ export class Dispatcher<ContextT extends BaseContext = BaseContext> {
289331
const handler = maybeHandler as (params: unknown, ctx: ContextT) => Result | Promise<Result>;
290332
stored = async (request, ctx) => {
291333
const userParams = { ...((request.params ?? {}) as Record<string, unknown>) };
334+
// Protocol-envelope fields are stripped before user-schema validation so a strict
335+
// schema does not reject SEP-2322 retry rounds.
292336
delete userParams._meta;
337+
delete userParams.inputResponses;
338+
delete userParams.requestState;
293339
const parsed = await validateStandardSchema(schemas.params, userParams);
294340
if (!parsed.success) {
295341
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error}`);

packages/core/src/types/types.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,44 @@ export type TaskAugmentedRequestParams = Infer<typeof TaskAugmentedRequestParams
191191
export type RequestMeta = Infer<typeof RequestMetaSchema>;
192192
export type Notification = Infer<typeof NotificationSchema>;
193193
export type Result = Infer<typeof ResultSchema>;
194+
195+
/**
196+
* A single server-to-client request the handler needs answered before it can finish.
197+
* Carried in {@linkcode IncompleteResult.inputRequests}, keyed by an opaque id the client
198+
* echoes back in {@linkcode InputResponseRequestParams.inputResponses}. SEP-2322.
199+
*/
200+
export interface InputRequest {
201+
method: string;
202+
params?: Record<string, unknown>;
203+
}
204+
205+
/**
206+
* SEP-2322 multi-round-trip result discriminator. The server returns this when it cannot
207+
* complete without client input (sampling, elicitation). The client services
208+
* {@linkcode IncompleteResult.inputRequests | inputRequests} and retries the same request
209+
* with {@linkcode InputResponseRequestParams.inputResponses | inputResponses}. Handlers
210+
* normally do not return this directly; throwing
211+
* {@linkcode @modelcontextprotocol/core!shared/dispatcher.RequiresInput | RequiresInput}
212+
* (or calling `ctx.mcpReq.send` with no backchannel) emits it.
213+
*/
214+
export interface IncompleteResult extends Result {
215+
resultType: 'incomplete';
216+
/** Server-initiated requests the client must answer, keyed by an opaque id. */
217+
inputRequests?: Record<string, InputRequest>;
218+
/** Opaque continuation token the client echoes back unchanged. */
219+
requestState?: string;
220+
}
221+
222+
/**
223+
* Params shape for a SEP-2322 retry: the client adds `inputResponses` (keyed by the ids
224+
* from {@linkcode IncompleteResult.inputRequests}) and echoes `requestState` alongside
225+
* the original method-specific params.
226+
*/
227+
export interface InputResponseRequestParams {
228+
inputResponses?: Record<string, Result>;
229+
requestState?: string;
230+
}
231+
194232
export type RequestId = Infer<typeof RequestIdSchema>;
195233
export type JSONRPCRequest = Infer<typeof JSONRPCRequestSchema>;
196234
export type JSONRPCNotification = Infer<typeof JSONRPCNotificationSchema>;

packages/core/test/shared/dispatcher.test.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { z } from 'zod/v4';
33

44
import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js';
55
import type { DispatchOutput } from '../../src/shared/dispatcher.js';
6-
import { Dispatcher } from '../../src/shared/dispatcher.js';
7-
import type { JSONRPCErrorResponse, JSONRPCRequest, JSONRPCResultResponse, Result } from '../../src/types/index.js';
6+
import { Dispatcher, RequiresInput } from '../../src/shared/dispatcher.js';
7+
import type { IncompleteResult, JSONRPCErrorResponse, JSONRPCRequest, JSONRPCResultResponse, Result } from '../../src/types/index.js';
88
import { ProtocolError, ProtocolErrorCode } from '../../src/types/index.js';
99

1010
const req = (method: string, params?: Record<string, unknown>, id = 1): JSONRPCRequest => ({ jsonrpc: '2.0', id, method, params });
@@ -320,3 +320,32 @@ describe('Dispatcher.setRequestHandler 3-arg (custom method + {params, result})'
320320
expect(() => setNotif('acme/ping', () => {})).toThrow(/not a spec notification method/);
321321
});
322322
});
323+
324+
describe('RequiresInput → IncompleteResult (SEP-2322 Option E)', () => {
325+
test('handler throwing RequiresInput yields a successful IncompleteResult, not an error', async () => {
326+
const d = new Dispatcher();
327+
d.setRequestHandler('ping', async () => {
328+
throw new RequiresInput({ r0: { method: 'elicitation/create', params: {} } }, 'state-token-1');
329+
});
330+
const out = await collect(d.dispatch(req('ping')));
331+
expect(out).toHaveLength(1);
332+
expect(out[0]!.kind).toBe('response');
333+
const msg = out[0]!.message as JSONRPCResultResponse;
334+
expect('result' in msg).toBe(true);
335+
const result = msg.result as IncompleteResult;
336+
expect(result.resultType).toBe('incomplete');
337+
expect(result.inputRequests).toEqual({ r0: { method: 'elicitation/create', params: {} } });
338+
expect(result.requestState).toBe('state-token-1');
339+
});
340+
341+
test('inputResponses/requestState are lifted onto ctx.mcpReq', async () => {
342+
const d = new Dispatcher();
343+
let seen: { inputResponses?: unknown; requestState?: unknown } = {};
344+
d.setRequestHandler('ping', async (_r, ctx) => {
345+
seen = { inputResponses: ctx.mcpReq.inputResponses, requestState: ctx.mcpReq.requestState };
346+
return {};
347+
});
348+
await collect(d.dispatch(req('ping', { inputResponses: { r0: { ok: true } }, requestState: 's1' })));
349+
expect(seen).toEqual({ inputResponses: { r0: { ok: true } }, requestState: 's1' });
350+
});
351+
});

0 commit comments

Comments
 (0)