|
1 | 1 | import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js'; |
2 | 2 | import type { |
| 3 | + IncompleteResult, |
| 4 | + InputRequest, |
| 5 | + InputResponseRequestParams, |
3 | 6 | JSONRPCErrorResponse, |
4 | 7 | JSONRPCNotification, |
5 | 8 | JSONRPCRequest, |
@@ -46,6 +49,33 @@ export type DispatchFn = (req: JSONRPCRequest, env?: RequestEnv) => AsyncGenerat |
46 | 49 | */ |
47 | 50 | export type DispatchMiddleware = (next: DispatchFn) => DispatchFn; |
48 | 51 |
|
| 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 | + |
49 | 79 | /** |
50 | 80 | * Derives the handler return type for the 3-arg `setRequestHandler` form from its |
51 | 81 | * `result` schema, defaulting to {@linkcode Result} when no schema is supplied. |
@@ -158,13 +188,18 @@ export class Dispatcher<ContextT extends BaseContext = BaseContext> { |
158 | 188 | throw new SdkError(SdkErrorCode.NotConnected, 'No outbound channel: ctx.mcpReq.send requires a connected peer'); |
159 | 189 | }); |
160 | 190 |
|
| 191 | + // SEP-2322: lift inputResponses/requestState off the params if this is a retry round. |
| 192 | + const mrtrParams = request.params as InputResponseRequestParams | undefined; |
| 193 | + |
161 | 194 | const base: BaseContext = { |
162 | 195 | sessionId: env.sessionId, |
163 | 196 | mcpReq: { |
164 | 197 | id: request.id, |
165 | 198 | method: request.method, |
166 | 199 | _meta: request.params?._meta, |
167 | 200 | signal: localAbort.signal, |
| 201 | + inputResponses: mrtrParams?.inputResponses, |
| 202 | + requestState: mrtrParams?.requestState, |
168 | 203 | send: (async (r: Request, schemaOrOptions?: unknown, maybeOptions?: RequestOptions) => { |
169 | 204 | const isSchema = schemaOrOptions != null && typeof schemaOrOptions === 'object' && '~standard' in schemaOrOptions; |
170 | 205 | const options = isSchema ? maybeOptions : (schemaOrOptions as RequestOptions | undefined); |
@@ -205,9 +240,16 @@ export class Dispatcher<ContextT extends BaseContext = BaseContext> { |
205 | 240 | : { jsonrpc: '2.0', id: request.id, result }; |
206 | 241 | }, |
207 | 242 | 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 | + } |
211 | 253 | } |
212 | 254 | ) |
213 | 255 | .finally(() => { |
@@ -289,7 +331,11 @@ export class Dispatcher<ContextT extends BaseContext = BaseContext> { |
289 | 331 | const handler = maybeHandler as (params: unknown, ctx: ContextT) => Result | Promise<Result>; |
290 | 332 | stored = async (request, ctx) => { |
291 | 333 | 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. |
292 | 336 | delete userParams._meta; |
| 337 | + delete userParams.inputResponses; |
| 338 | + delete userParams.requestState; |
293 | 339 | const parsed = await validateStandardSchema(schemas.params, userParams); |
294 | 340 | if (!parsed.success) { |
295 | 341 | throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error}`); |
|
0 commit comments