From 2c9d144bad85b775261aa1a4b0f67f2e023a15ac Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 11:49:18 +0000 Subject: [PATCH 1/2] feat(core): neutral multi-round-trip contract and 2026-07-28 in-band wire vocabulary The neutral InputRequest/InputResponse/InputRequiredResult types and the HandlerResultTypeMap; the inputRequired() builder family with its per-kind constructors, acceptedContent(), and withInputRequired(); the isInputRequiredResult() guard; the 2026-07-28 in-band wire schemas and the WireCodec.inputRequestSchema/inputResponseSchema accessors; spec-type parity checks and the SEP-2322 corpus pending-entry burn-down. --- .changeset/mrtr-client-engine.md | 12 + packages/client/src/index.ts | 6 + .../test/client/inputRequiredEngine.test.ts | 374 ++++++++++++++++++ .../test/client/modernEraInboundDrop.test.ts | 36 ++ packages/core/src/errors/sdkErrors.ts | 9 + packages/core/src/exports/public/index.ts | 1 + packages/core/src/shared/inputRequired.ts | 186 +++++++++ .../core/src/shared/inputRequiredDriver.ts | 231 +++++++++++ packages/core/src/types/guards.ts | 19 + packages/core/src/types/types.ts | 92 +++++ packages/core/src/wire/codec.ts | 14 + packages/core/src/wire/rev2025-11-25/codec.ts | 9 + packages/core/src/wire/rev2026-07-28/codec.ts | 30 +- .../src/wire/rev2026-07-28/inputRequired.ts | 83 ++++ .../core/src/wire/rev2026-07-28/schemas.ts | 142 ++++++- packages/core/test/corpus/specCorpus.test.ts | 13 +- .../core/test/shared/inputRequired.test.ts | 89 +++++ .../test/shared/inputRequiredDriver.test.ts | 251 ++++++++++++ .../test/shared/inputRequiredFunnel.test.ts | 162 ++++++++ .../core/test/spec.types.2026-07-28.test.ts | 176 +++++++-- .../core/test/types/errorSurfacePins.test.ts | 1 + 21 files changed, 1887 insertions(+), 49 deletions(-) create mode 100644 .changeset/mrtr-client-engine.md create mode 100644 packages/client/test/client/inputRequiredEngine.test.ts create mode 100644 packages/core/src/shared/inputRequired.ts create mode 100644 packages/core/src/shared/inputRequiredDriver.ts create mode 100644 packages/core/src/wire/rev2026-07-28/inputRequired.ts create mode 100644 packages/core/test/shared/inputRequired.test.ts create mode 100644 packages/core/test/shared/inputRequiredDriver.test.ts create mode 100644 packages/core/test/shared/inputRequiredFunnel.test.ts diff --git a/.changeset/mrtr-client-engine.md b/.changeset/mrtr-client-engine.md new file mode 100644 index 000000000..451f717e1 --- /dev/null +++ b/.changeset/mrtr-client-engine.md @@ -0,0 +1,12 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +--- + +Add the client side of multi round-trip requests (protocol revision 2026-07-28, SEP-2322). The neutral `InputRequest`/`InputResponse`/`InputRequests`/`InputResponses`/`InputRequiredResult` types and the `isInputRequiredResult()` guard ship as the neutral surface (the +`inputRequired()` builder family and the `acceptedContent()` reader are exported by the server package as part of the server-side change); the 2026-07-28 wire codec models the in-band vocabulary (embedded requests and bare responses) and the retry-channel request fields. On the +client, an `input_required` answer to `tools/call`, `prompts/get`, or `resources/read` on a 2026-07-28 connection is now fulfilled automatically by default: the embedded requests are dispatched to the client's already-registered elicitation/sampling/roots handlers, and the +original call is retried with the collected `inputResponses`, a byte-exact echo of the opaque `requestState`, and a fresh request id, up to `inputRequired.maxRounds` rounds (default 10; exhaustion raises a typed `InputRequiredRoundsExceeded` error carrying the last result). +`client.callTool()` and its siblings keep returning their plain result types. `ClientOptions.inputRequired` (`autoFulfill`, `maxRounds`) configures the driver; manual mode is `autoFulfill: false` plus the per-call `allowInputRequired: true` request option and the +`withInputRequired()` schema wrapper. Retried requests surface their `inputResponses` to server handlers as bare response objects — entries in a wrapped `{method, result}` shape are dropped and reported via `ctx.mcpReq.droppedInputResponseKeys`. 2025-era behavior is unchanged: +the legacy wire has no `input_required` vocabulary and the legacy server-to-client request flow is untouched. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 42fc132c2..678bb4d45 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -75,5 +75,11 @@ export { StreamableHTTPClientTransport } from './client/streamableHttp.js'; // runtime-aware wrapper (shadows core/public's fromJsonSchema with optional validator) export { fromJsonSchema } from './fromJsonSchema.js'; +// Multi-round-trip requests (protocol revision 2026-07-28): the client-side +// auto-fulfilment knobs (ClientOptions.inputRequired) and the manual-mode +// schema wrapper for callers that opt out of auto-fulfilment per call. +export type { InputRequiredOptions } from '@modelcontextprotocol/core'; +export { withInputRequired } from '@modelcontextprotocol/core'; + // re-export curated public API from core export * from '@modelcontextprotocol/core/public'; diff --git a/packages/client/test/client/inputRequiredEngine.test.ts b/packages/client/test/client/inputRequiredEngine.test.ts new file mode 100644 index 000000000..f688e57e1 --- /dev/null +++ b/packages/client/test/client/inputRequiredEngine.test.ts @@ -0,0 +1,374 @@ +/** + * The client-side multi-round-trip engine end to end against a scripted + * modern (2026-07-28) server: auto-fulfilment via the already-registered + * handlers, fresh request ids per leg, byte-exact requestState echo, bare + * (never wrapped) inputResponses, multi-round flows, the round cap, manual + * mode, and the synthesized handler context contract. + */ +import type { ElicitResult, JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'; +import { InMemoryTransport, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { Client } from '../../src/client/client.js'; +import type { ClientOptions } from '../../src/client/client.js'; + +const MODERN = '2026-07-28'; + +const ELICIT_ENTRY = { + method: 'elicitation/create', + params: { mode: 'form', message: 'What is your name?', requestedSchema: { type: 'object', properties: { name: { type: 'string' } } } } +}; + +interface ScriptedServer { + clientTx: InMemoryTransport; + written: JSONRPCMessage[]; + toolCalls: JSONRPCRequest[]; +} + +/** + * Scripted modern server: negotiates 2026-07-28 via server/discover and + * answers tools/call from the provided responder. + */ +async function scriptedModernServer(respondToToolCall: (request: JSONRPCRequest, call: number) => unknown): Promise { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + const toolCalls: JSONRPCRequest[] = []; + serverTx.onmessage = message => { + written.push(message); + const request = message as JSONRPCRequest; + if (request.id === undefined) return; + if (request.method === 'server/discover') { + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: {} }, + serverInfo: { name: 'scripted-mrtr-server', version: '1.0.0' } + } + }); + return; + } + if (request.method === 'tools/call') { + toolCalls.push(request); + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + result: respondToToolCall(request, toolCalls.length) + } as Parameters[0]); + } + }; + await serverTx.start(); + return { clientTx, written, toolCalls }; +} + +function makeClient(options?: ClientOptions): Client { + return new Client( + { name: 'mrtr-engine-client', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: MODERN } }, capabilities: { elicitation: { form: {} } }, ...options } + ); +} + +const COMPLETE_RESULT = { resultType: 'complete', content: [{ type: 'text', text: 'deployed' }] }; + +describe('auto-fulfilment (default on)', () => { + it('fulfils an elicitation via the registered handler and retries with a fresh id, bare responses, and a byte-exact requestState echo', async () => { + const { clientTx, toolCalls } = await scriptedModernServer((request, call) => { + if (call === 1) { + return { resultType: 'input_required', inputRequests: { github_login: ELICIT_ENTRY }, requestState: 'opaque-✓-state' }; + } + // The retry must carry the responses; echo checked below. + expect(request.params).toMatchObject({ name: 'deploy' }); + return COMPLETE_RESULT; + }); + + const client = makeClient(); + const handled: unknown[] = []; + client.setRequestHandler('elicitation/create', async request => { + handled.push(request.params); + return { action: 'accept', content: { name: 'octocat' } } satisfies ElicitResult; + }); + await client.connect(clientTx); + + const result = await client.callTool({ name: 'deploy', arguments: { env: 'prod' } }); + expect(result.content).toEqual([{ type: 'text', text: 'deployed' }]); + expect('resultType' in result).toBe(false); + + // The handler saw the embedded request params. + expect(handled).toHaveLength(1); + expect(handled[0]).toMatchObject({ mode: 'form', message: 'What is your name?' }); + + // Two independent wire legs with fresh (different) ids. + expect(toolCalls).toHaveLength(2); + expect(toolCalls[0]!.id).not.toEqual(toolCalls[1]!.id); + + // The retry carries the original params, the BARE response (no + // {method, result} wrapper), and the byte-exact requestState echo. + const retryParams = toolCalls[1]!.params as Record; + expect(retryParams.name).toBe('deploy'); + expect(retryParams.arguments).toEqual({ env: 'prod' }); + expect(retryParams.inputResponses).toEqual({ github_login: { action: 'accept', content: { name: 'octocat' } } }); + expect(retryParams.requestState).toBe('opaque-✓-state'); + + await client.close(); + }); + + it('keeps the loop going across multiple rounds and omits requestState when a round carries none', async () => { + const { clientTx, toolCalls } = await scriptedModernServer((_request, call) => { + if (call === 1) { + return { resultType: 'input_required', inputRequests: { first: ELICIT_ENTRY }, requestState: 'state-1' }; + } + if (call === 2) { + return { resultType: 'input_required', inputRequests: { second: ELICIT_ENTRY } }; + } + return COMPLETE_RESULT; + }); + + const client = makeClient(); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'octocat' } })); + await client.connect(clientTx); + + const result = await client.callTool({ name: 'deploy', arguments: {} }); + expect(result.content).toEqual([{ type: 'text', text: 'deployed' }]); + expect(toolCalls).toHaveLength(3); + + const secondRetry = toolCalls[2]!.params as Record; + expect(Object.keys(secondRetry.inputResponses as Record)).toEqual(['second']); + // The second input_required carried no requestState — the retry MUST NOT include one. + expect('requestState' in secondRetry).toBe(false); + + await client.close(); + }); + + it('exhausting the round cap raises the typed rounds-exceeded error carrying the last result', async () => { + const { clientTx, toolCalls } = await scriptedModernServer(() => ({ + resultType: 'input_required', + inputRequests: { again: ELICIT_ENTRY }, + requestState: 'still-going' + })); + + const client = makeClient({ inputRequired: { maxRounds: 2 } }); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'octocat' } })); + await client.connect(clientTx); + + const outcome = client.callTool({ name: 'deploy', arguments: {} }); + await expect(outcome).rejects.toSatisfy((error: unknown) => { + expect(error).toBeInstanceOf(SdkError); + const typed = error as SdkError; + expect(typed.code).toBe(SdkErrorCode.InputRequiredRoundsExceeded); + expect(typed.data).toMatchObject({ rounds: 2, lastResult: { requestState: 'still-going' } }); + return true; + }); + // Cap 2 ⇒ the original call plus exactly two retries reached the wire... no: + // the cap counts ROUNDS (retries); round 3 is never started, so the wire + // saw the original call + 2 retries. + expect(toolCalls).toHaveLength(3); + + await client.close(); + }); + + it('fails the call with a typed error when a required handler is not registered (reject, do not guess)', async () => { + const { clientTx } = await scriptedModernServer(() => ({ + resultType: 'input_required', + inputRequests: { sample: { method: 'sampling/createMessage', params: { messages: [], maxTokens: 5 } } } + })); + + const client = makeClient(); + await client.connect(clientTx); + + await expect(client.callTool({ name: 'deploy', arguments: {} })).rejects.toMatchObject({ + code: SdkErrorCode.CapabilityNotSupported, + data: { key: 'sample', method: 'sampling/createMessage' } + }); + + await client.close(); + }); + + it('validates a forked, tool-bearing embedded sampling response against the 2026 in-band response schema', async () => { + const SAMPLING_WITH_TOOLS_ENTRY = { + method: 'sampling/createMessage', + params: { + messages: [{ role: 'user', content: { type: 'text', text: 'What is the weather in Berlin?' } }], + maxTokens: 200, + tools: [{ name: 'get_weather', inputSchema: { type: 'object', properties: { city: { type: 'string' } } } }] + } + }; + // Forked 2026 vocabulary: array content with a tool_use block and a + // tool_result block whose structuredContent is NOT an object (the + // 2026 anchor allows any value there; the 2025 result schemas do not). + // This pins that the embedded response is validated against the era's + // in-band response schema, mirroring the request-side selection. + const TOOL_BEARING_RESPONSE = { + model: 'test-model-1', + role: 'assistant' as const, + stopReason: 'toolUse', + content: [ + { type: 'tool_use' as const, name: 'get_weather', id: 'call-1', input: { city: 'Berlin' } }, + { + type: 'tool_result' as const, + toolUseId: 'call-0', + content: [{ type: 'text' as const, text: '21°C' }], + structuredContent: 21 + } + ] + }; + const { clientTx, toolCalls } = await scriptedModernServer((_request, call) => + call === 1 ? { resultType: 'input_required', inputRequests: { weather: SAMPLING_WITH_TOOLS_ENTRY } } : COMPLETE_RESULT + ); + + const client = makeClient({ capabilities: { sampling: { tools: {} } } }); + // The non-object structuredContent is deliberately outside the 2025 + // result types (it is the 2026 fork) — hence the cast. + client.setRequestHandler('sampling/createMessage', async () => TOOL_BEARING_RESPONSE as never); + await client.connect(clientTx); + + const result = await client.callTool({ name: 'deploy', arguments: {} }); + expect(result.content).toEqual([{ type: 'text', text: 'deployed' }]); + + // The retry carries the bare tool-bearing response unchanged. + expect(toolCalls).toHaveLength(2); + const retryParams = toolCalls[1]!.params as { inputResponses?: Record }; + expect(retryParams.inputResponses?.weather).toEqual(TOOL_BEARING_RESPONSE); + + await client.close(); + }); + + it('counts the first wire leg against maxTotalTimeout (the budget bounds the whole flow)', async () => { + let now = 1_000_000; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + try { + const { clientTx, toolCalls } = await scriptedModernServer((_request, call) => { + // The first leg alone "takes" longer than the whole-flow budget. + now += 10_000; + return call === 1 ? { resultType: 'input_required', inputRequests: { github_login: ELICIT_ENTRY } } : COMPLETE_RESULT; + }); + + const client = makeClient(); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'octocat' } })); + await client.connect(clientTx); + + await expect( + client.callTool({ name: 'deploy', arguments: {} }, { timeout: 60_000, maxTotalTimeout: 5_000 }) + ).rejects.toMatchObject({ code: SdkErrorCode.RequestTimeout, data: { maxTotalTimeout: 5_000 } }); + // The flow failed before any retry reached the wire. + expect(toolCalls).toHaveLength(1); + + await client.close(); + } finally { + nowSpy.mockRestore(); + } + }); + + it('fails fast with a typed error when input_required carries neither inputRequests nor requestState', async () => { + const { clientTx, toolCalls } = await scriptedModernServer(() => ({ resultType: 'input_required' })); + + const client = makeClient(); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'octocat' } })); + await client.connect(clientTx); + + await expect(client.callTool({ name: 'deploy', arguments: {} })).rejects.toMatchObject({ + code: SdkErrorCode.InvalidResult, + data: { method: 'tools/call', violation: 'input-required-missing-both' } + }); + // Fail fast: the original params are never resent until the cap runs out. + expect(toolCalls).toHaveLength(1); + + await client.close(); + }); + + it('fails the call with a typed error for an unknown embedded request kind', async () => { + const { clientTx } = await scriptedModernServer(() => ({ + resultType: 'input_required', + inputRequests: { weird: { method: 'tasks/create', params: {} } } + })); + + const client = makeClient(); + await client.connect(clientTx); + + await expect(client.callTool({ name: 'deploy', arguments: {} })).rejects.toMatchObject({ + code: SdkErrorCode.InvalidResult, + data: { key: 'weird', method: 'tasks/create' } + }); + + await client.close(); + }); + + it('gives the embedded handler the synthesized context: correlation-only id, chained signal, send/notify unavailable', async () => { + const { clientTx } = await scriptedModernServer((_request, call) => + call === 1 ? { resultType: 'input_required', inputRequests: { github_login: ELICIT_ENTRY } } : COMPLETE_RESULT + ); + + const client = makeClient(); + const seenCtx: unknown[] = []; + client.setRequestHandler('elicitation/create', async (_request, ctx) => { + seenCtx.push(ctx); + expect(ctx.mcpReq.id).toBe('github_login'); + expect(ctx.mcpReq.method).toBe('elicitation/create'); + expect(ctx.mcpReq.signal.aborted).toBe(false); + expect(() => ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 1, progress: 1 } })).toThrow( + /not available/ + ); + expect(() => ctx.mcpReq.send({ method: 'ping' })).toThrow(/not available/); + return { action: 'accept', content: { name: 'octocat' } }; + }); + await client.connect(clientTx); + + await client.callTool({ name: 'deploy', arguments: {} }); + expect(seenCtx).toHaveLength(1); + + await client.close(); + }); +}); + +describe('manual mode', () => { + it('autoFulfill: false surfaces input_required as a typed error (no retries hit the wire)', async () => { + const { clientTx, toolCalls } = await scriptedModernServer(() => ({ + resultType: 'input_required', + inputRequests: { github_login: ELICIT_ENTRY } + })); + + const client = makeClient({ inputRequired: { autoFulfill: false } }); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'octocat' } })); + await client.connect(clientTx); + + await expect(client.callTool({ name: 'deploy', arguments: {} })).rejects.toMatchObject({ + code: SdkErrorCode.UnsupportedResultType, + data: { resultType: 'input_required', method: 'tools/call' } + }); + expect(toolCalls).toHaveLength(1); + + await client.close(); + }); + + it('allowInputRequired: true hands the input-required value back to the caller, who can retry manually', async () => { + const { clientTx, toolCalls } = await scriptedModernServer((_request, call) => + call === 1 + ? { resultType: 'input_required', inputRequests: { github_login: ELICIT_ENTRY }, requestState: 'manual-state' } + : COMPLETE_RESULT + ); + + const client = makeClient({ inputRequired: { autoFulfill: false } }); + await client.connect(clientTx); + + const first = (await client.callTool({ name: 'deploy', arguments: {} }, { allowInputRequired: true })) as unknown as Record< + string, + unknown + >; + expect(first.resultType).toBe('input_required'); + expect(first.requestState).toBe('manual-state'); + + // The caller drives the retry itself: same params + responses + echo. + const second = await client.callTool({ + name: 'deploy', + arguments: {}, + inputResponses: { github_login: { action: 'accept', content: { name: 'octocat' } } }, + requestState: first.requestState as string + } as Parameters[0]); + expect(second.content).toEqual([{ type: 'text', text: 'deployed' }]); + expect(toolCalls).toHaveLength(2); + expect(toolCalls[0]!.id).not.toEqual(toolCalls[1]!.id); + + await client.close(); + }); +}); diff --git a/packages/client/test/client/modernEraInboundDrop.test.ts b/packages/client/test/client/modernEraInboundDrop.test.ts index c23f5d161..fbdc1f0af 100644 --- a/packages/client/test/client/modernEraInboundDrop.test.ts +++ b/packages/client/test/client/modernEraInboundDrop.test.ts @@ -90,6 +90,42 @@ describe('client inbound-drop on modern-era connections (TS-01)', () => { await client.close(); }); + it('refuses a wire elicitation/create request on a modern connection even when an elicitation handler is registered (the in-band vocabulary grants no wire dispatch)', async () => { + const { clientTx, serverTx, written } = await scriptedServerSide('modern'); + const client = new Client( + { name: 'drop-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { form: {} } } } + ); + const handled: unknown[] = []; + client.setRequestHandler('elicitation/create', async request => { + handled.push(request.params); + return { action: 'accept', content: {} }; + }); + const errors: Error[] = []; + client.onerror = error => void errors.push(error); + await client.connect(clientTx); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + const before = written.length; + // elicitation/create exists on the 2026-07-28 era only as in-band + // (embedded) vocabulary inside input_required results. A wire request + // for it must never reach the registered handler or be answered with a + // result — the era gate is not bypassed by the in-band schema fallback. + await serverTx.send({ + jsonrpc: '2.0', + id: 'rogue-elicit-1', + method: 'elicitation/create', + params: { mode: 'form', message: 'Name?', requestedSchema: { type: 'object', properties: {} } } + }); + await flush(); + + expect(handled).toHaveLength(0); + expect(written).toHaveLength(before); + expect(errors.some(error => error.message.includes('Dropped inbound request'))).toBe(true); + + await client.close(); + }); + it('keeps answering inbound requests on legacy-era connections (control arm)', async () => { const { clientTx, serverTx, written } = await scriptedServerSide('legacy'); const client = new Client({ name: 'legacy-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); diff --git a/packages/core/src/errors/sdkErrors.ts b/packages/core/src/errors/sdkErrors.ts index eec7596cc..ac9435102 100644 --- a/packages/core/src/errors/sdkErrors.ts +++ b/packages/core/src/errors/sdkErrors.ts @@ -34,6 +34,15 @@ export enum SdkErrorCode { * `input_required`. The kind is carried in `data.resultType`. */ UnsupportedResultType = 'UNSUPPORTED_RESULT_TYPE', + /** + * The multi-round-trip auto-fulfilment driver exhausted its round cap + * (`inputRequired.maxRounds`) without the server returning a complete + * result. `data.rounds` carries the cap that was hit and + * `data.lastResult` carries the last `input_required` payload received + * (`{ inputRequests, requestState? }`), so callers can inspect or resume + * the flow manually. + */ + InputRequiredRoundsExceeded = 'INPUT_REQUIRED_ROUNDS_EXCEEDED', /** * The spec method being sent does not exist on the negotiated protocol * version's wire era (e.g. `tasks/get` toward a 2026-07-28 peer, or diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 88b806707..3257f6df2 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -108,6 +108,7 @@ export { isCallToolResult, isInitializedNotification, isInitializeRequest, + isInputRequiredResult, isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, diff --git a/packages/core/src/shared/inputRequired.ts b/packages/core/src/shared/inputRequired.ts new file mode 100644 index 000000000..dfd2cac05 --- /dev/null +++ b/packages/core/src/shared/inputRequired.ts @@ -0,0 +1,186 @@ +/** + * Authoring helpers for multi-round-trip requests (protocol revision + * 2026-07-28). + * + * A handler for one of the multi-round-trip methods (`tools/call`, + * `prompts/get`, `resources/read`) requests additional client input by + * returning an {@linkcode InputRequiredResult} instead of a final result. The + * helpers here build that return value and its embedded requests as NEUTRAL + * values; only the 2026-07-28 wire codec maps them to/from the wire (the + * 2025-era codec has no input-required vocabulary — on a 2025-era request the + * server seam fails such a return loudly; a handler that serves both eras + * branches on the served era and uses the push-style APIs toward 2025-era + * requests). + * + * There is no nominal brand: `resultType: 'input_required'` is the + * discriminator, and hand-built result literals are equally legal — the + * server seam re-checks the at-least-one rule for them. + */ +import { isInputRequiredResult } from '../types/guards.js'; +import type { + CreateMessageRequestParams, + ElicitRequestFormParams, + ElicitRequestURLParams, + ElicitResult, + InputRequest, + InputRequests, + InputRequiredResult, + InputResponses +} from '../types/types.js'; +import type { StandardSchemaV1 } from '../util/standardSchema.js'; + +/** The shape accepted by {@linkcode inputRequired}. */ +export interface InputRequiredSpec { + /** Embedded requests the client must fulfil before retrying. */ + inputRequests?: InputRequests; + /** Opaque server state echoed back verbatim by the client on retry. */ + requestState?: string; +} + +interface InputRequiredBuilder { + /** + * Builds the input-required return value for a multi-round-trip handler. + * + * At least one of `inputRequests` or `requestState` must be provided + * (spec: basic/patterns/mrtr, server requirements) — the builder throws a + * `TypeError` otherwise, and the server seam re-checks the same rule for + * hand-built results. + * + * `requestState` is opaque, server-minted state. It round-trips through + * the client and comes back as attacker-controlled input: a server that + * lets it influence authorization, resource access, or business logic + * MUST integrity-protect it (e.g. HMAC or AEAD) and MUST reject state + * that fails verification. The SDK does not do this for you. + */ + (spec: InputRequiredSpec): InputRequiredResult; + + /** Builds an embedded form-mode elicitation request (`elicitation/create`). */ + elicit(params: Omit & { mode?: 'form' }): InputRequest; + + /** + * Builds an embedded URL-mode elicitation request (`elicitation/create`). + * On the 2026-07-28 revision URL elicitation rides the multi-round-trip + * flow — the `-32042` error of earlier revisions never appears on this + * era's wire. The 2025-era `elicitationId` is not part of the 2026-07-28 + * URL-mode shape; correlation across retries is the server's own + * identifier inside `requestState`. + */ + elicitUrl(params: Omit): InputRequest; + + /** Builds an embedded sampling request (`sampling/createMessage`). */ + createMessage(params: CreateMessageRequestParams): InputRequest; + + /** Builds an embedded roots listing request (`roots/list`). */ + listRoots(): InputRequest; +} + +function buildInputRequired(spec: InputRequiredSpec): InputRequiredResult { + const hasInputRequests = spec.inputRequests !== undefined && Object.keys(spec.inputRequests).length > 0; + const hasRequestState = typeof spec.requestState === 'string'; + if (!hasInputRequests && !hasRequestState) { + throw new TypeError( + 'inputRequired() requires at least one of inputRequests (with at least one entry) or requestState ' + + '(spec: every InputRequiredResult MUST include at least one of the two)' + ); + } + return { + resultType: 'input_required', + ...(spec.inputRequests !== undefined && { inputRequests: spec.inputRequests }), + ...(spec.requestState !== undefined && { requestState: spec.requestState }) + }; +} + +/** + * Builder for the input-required return value of multi-round-trip handlers, + * with per-kind constructors for the embedded requests + * (`inputRequired.elicit`, `inputRequired.elicitUrl`, + * `inputRequired.createMessage`, `inputRequired.listRoots`). + * + * @example Write-once tool requesting confirmation + * ```ts + * server.registerTool('deploy', { inputSchema: z.object({ env: z.string() }) }, async ({ env }, ctx) => { + * const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + * if (!confirmed) { + * return inputRequired({ + * inputRequests: { + * confirm: inputRequired.elicit({ + * message: `Deploy to ${env}?`, + * requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + * }) + * } + * }); + * } + * return { content: [{ type: 'text', text: `deployed to ${env}` }] }; + * }); + * ``` + */ +export const inputRequired: InputRequiredBuilder = Object.assign(buildInputRequired, { + elicit(params: Omit & { mode?: 'form' }): InputRequest { + return { method: 'elicitation/create', params: { ...params, mode: 'form' } }; + }, + elicitUrl(params: Omit): InputRequest { + // The neutral ElicitRequestURLParams keeps `elicitationId` (it is required on the + // frozen 2025-11-25 revision); the 2026-07-28 in-band shape does not carry it. + return { method: 'elicitation/create', params: { ...params, mode: 'url' } as ElicitRequestURLParams }; + }, + createMessage(params: CreateMessageRequestParams): InputRequest { + return { method: 'sampling/createMessage', params }; + }, + listRoots(): InputRequest { + return { method: 'roots/list' }; + } +}); + +/** + * Reads the accepted content of a form-mode elicitation response from a + * retried request's `inputResponses` (`ctx.mcpReq.inputResponses`). + * + * Returns the response's `content` for `key` when the entry is an accepted + * elicitation result, and `undefined` otherwise (missing key, declined or + * cancelled elicitation, or a response of another kind). The values arrive + * from the client and are not re-validated here — treat them as untrusted + * input. + */ +export function acceptedContent = Record>( + responses: InputResponses | Record | undefined, + key: string +): T | undefined { + if (responses === undefined || typeof responses !== 'object' || responses === null) return undefined; + const entry = (responses as Record)[key]; + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return undefined; + const candidate = entry as Partial & Record; + if (candidate.action !== 'accept') return undefined; + if (candidate.content === undefined || typeof candidate.content !== 'object' || candidate.content === null) return undefined; + return candidate.content as T; +} + +/** + * Wraps a result schema so a request issued through `client.request()` / + * `ctx.mcpReq.send()` with `allowInputRequired: true` is typed as either the + * schema's result or an {@linkcode InputRequiredResult}. + * + * The manual multi-round-trip path: pass `{ allowInputRequired: true }` in the + * request options so an `input_required` response is handed back to the + * caller instead of being auto-fulfilled (or rejected), and wrap the result + * schema with `withInputRequired()` so the returned value is typed and + * validated correctly for both outcomes — `input_required` values pass + * through as-is, complete results validate against the wrapped schema. + */ +export function withInputRequired( + schema: S +): StandardSchemaV1 | InputRequiredResult> { + return { + '~standard': { + version: 1, + vendor: 'modelcontextprotocol', + validate: (value: unknown, options?: StandardSchemaV1.Options) => { + if (isInputRequiredResult(value)) { + return { value }; + } + return schema['~standard'].validate(value, options) as + | StandardSchemaV1.Result | InputRequiredResult> + | Promise | InputRequiredResult>>; + } + } + }; +} diff --git a/packages/core/src/shared/inputRequiredDriver.ts b/packages/core/src/shared/inputRequiredDriver.ts new file mode 100644 index 000000000..03810ae8f --- /dev/null +++ b/packages/core/src/shared/inputRequiredDriver.ts @@ -0,0 +1,231 @@ +/** + * The multi-round-trip auto-fulfilment driver (protocol revision 2026-07-28). + * + * When a request to one of the multi-round-trip methods comes back as + * `input_required`, the driver fulfils the embedded input requests by + * dispatching them to the client's already-registered handlers (elicitation, + * sampling, roots — one generic engine, no per-feature API), then retries the + * original request with the collected `inputResponses` and a byte-exact echo + * of `requestState`, on a fresh request id, until the server returns a + * complete result or the round cap is exhausted. + * + * The driver is a LAYER OVER THE MANUAL PATH: each retry is issued with the + * same primitive a manual caller uses (`allowInputRequired` semantics — the + * retry hands back the next `input_required` payload instead of recursing), + * so the loop, the cap, and the pacing live in one place and disabling + * auto-fulfilment (`inputRequired.autoFulfill: false`) simply skips this + * module. Timeouts ride the EXISTING knobs: the per-leg `timeout` applies to + * every wire leg unchanged, and `maxTotalTimeout` bounds the whole flow by + * shrinking the budget passed to each leg — no new timer system. + */ +import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js'; +import { isInputRequiredResult } from '../types/guards.js'; +import type { Progress } from '../types/types.js'; + +/** + * Whether the multi-round-trip driver fulfils `input_required` results + * automatically when the consumer has not configured + * `inputRequired.autoFulfill`. The single switch for the default posture. + */ +export const DEFAULT_INPUT_REQUIRED_AUTO_FULFILL = true; + +/** + * Default round cap for the auto-fulfilment driver (both request legs and + * requestState-only legs count). Aligned with the other SDK client engines. + */ +export const DEFAULT_INPUT_REQUIRED_MAX_ROUNDS = 10; + +/** + * Fixed pacing applied before retrying a requestState-only (load-shedding) + * leg — a leg that carries no embedded input requests, so nothing slows the + * loop down naturally. Counted in the same round cap. + */ +export const REQUEST_STATE_ONLY_LEG_PACING_MS = 250; + +/** + * Multi-round-trip driver options (`inputRequired` on the client options bag). + */ +export interface InputRequiredOptions { + /** + * Fulfil `input_required` results automatically by dispatching the + * embedded requests to the registered handlers and retrying. + * + * Set to `false` for manual mode: an `input_required` response then + * surfaces as a typed error unless the individual call opts in with + * `allowInputRequired: true` (and, for typed results on the explicit + * schema path, `withInputRequired()`). + * + * @default true + */ + autoFulfill?: boolean; + + /** + * Maximum number of rounds (retries) the driver performs for a single + * call before failing with a typed + * {@linkcode SdkErrorCode.InputRequiredRoundsExceeded} error. + * + * @default 10 + */ + maxRounds?: number; +} + +/** The driver configuration with defaults applied. */ +export interface ResolvedInputRequiredDriverConfig { + autoFulfill: boolean; + maxRounds: number; +} + +export function resolveInputRequiredDriverConfig(options: InputRequiredOptions | undefined): ResolvedInputRequiredDriverConfig { + return { + autoFulfill: options?.autoFulfill ?? DEFAULT_INPUT_REQUIRED_AUTO_FULFILL, + maxRounds: options?.maxRounds ?? DEFAULT_INPUT_REQUIRED_MAX_ROUNDS + }; +} + +/** The discriminated `input_required` payload the wire codec hands to the driver. */ +export interface InputRequiredPayload { + inputRequests: Record; + requestState?: string; +} + +/** The slice of per-request options the driver consumes. */ +export interface InputRequiredDriverRequestOptions { + timeout?: number; + maxTotalTimeout?: number; + onprogress?: (progress: Progress) => void; +} + +/** Per-leg options the driver passes back to the funnel for each retry. */ +export interface InputRequiredRetryLegOptions { + timeout?: number; + maxTotalTimeout?: number; +} + +/** The hooks the protocol layer provides to the driver. */ +export interface InputRequiredDriverHooks { + /** + * Dispatches one embedded input request to the locally registered handler + * and resolves with the bare response value. Rejections fail the whole + * call (typed errors: unknown kind, missing handler, handler failure). + */ + dispatchInputRequest(key: string, entry: unknown): Promise; + + /** + * Re-issues the original request with the given params on a fresh request + * id, using the manual primitive: a complete result resolves validated, + * and a further `input_required` response resolves as the raw + * input-required value (never recursing into another driver run). + */ + retry(params: Record | undefined, legOptions: InputRequiredRetryLegOptions): Promise; +} + +/** Builds the retry params: original params + this round's responses + byte-exact requestState echo. */ +export function buildInputRequiredRetryParams( + originalParams: Record | undefined, + responses: Record | undefined, + requestState: string | undefined +): Record | undefined { + const hasResponses = responses !== undefined && Object.keys(responses).length > 0; + if (!hasResponses && requestState === undefined) { + return originalParams; + } + return { + ...originalParams, + ...(hasResponses && { inputResponses: responses }), + // Byte-exact echo: the opaque string is copied verbatim, never parsed. + // When the result carried no requestState, the retry carries none. + ...(requestState !== undefined && { requestState }) + }; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Runs the auto-fulfilment loop for one originating request. Resolves with + * the final complete result (already validated by the retry leg) or rejects + * with a typed error. + * + * `flowStartedAt` is the timestamp the ORIGINAL request was issued at (not + * when the driver started): `maxTotalTimeout` bounds the whole flow, so the + * first wire leg counts against the budget too. When omitted, accounting + * starts when the driver starts. + */ +export async function runInputRequiredDriver(args: { + config: ResolvedInputRequiredDriverConfig; + method: string; + originalParams: Record | undefined; + firstPayload: InputRequiredPayload; + requestOptions: InputRequiredDriverRequestOptions; + hooks: InputRequiredDriverHooks; + flowStartedAt?: number; +}): Promise { + const { config, method, originalParams, requestOptions, hooks } = args; + const startedAt = args.flowStartedAt ?? Date.now(); + let payload = args.firstPayload; + let round = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + round += 1; + if (round > config.maxRounds) { + throw new SdkError( + SdkErrorCode.InputRequiredRoundsExceeded, + `Multi-round-trip request '${method}' still required input after ${config.maxRounds} rounds (inputRequired.maxRounds)`, + { + rounds: config.maxRounds, + lastResult: { + inputRequests: payload.inputRequests, + ...(payload.requestState !== undefined && { requestState: payload.requestState }) + } + } + ); + } + + // Surface the round as synthetic progress: long interactive flows stay + // observable, and consumers composing `resetTimeoutOnProgress`-style + // watchdogs around the call see liveness instead of silence. + requestOptions.onprogress?.({ progress: round, message: `Fulfilling input required by '${method}' (round ${round})` }); + + const entries = Object.entries(payload.inputRequests ?? {}); + let responses: Record | undefined; + if (entries.length > 0) { + // Fulfil concurrently (the embedded requests are independent); a + // single failure fails the call. + const fulfilled = await Promise.all( + entries.map(async ([key, entry]) => [key, await hooks.dispatchInputRequest(key, entry)] as const) + ); + responses = Object.fromEntries(fulfilled); + } else { + // requestState-only (load-shedding) leg: fixed pacing so the loop + // never hot-spins; counted in the same round cap. + await sleep(REQUEST_STATE_ONLY_LEG_PACING_MS); + } + + const legOptions: InputRequiredRetryLegOptions = { + ...(requestOptions.timeout !== undefined && { timeout: requestOptions.timeout }) + }; + if (requestOptions.maxTotalTimeout !== undefined) { + const totalElapsed = Date.now() - startedAt; + const remaining = requestOptions.maxTotalTimeout - totalElapsed; + if (remaining <= 0) { + throw new SdkError(SdkErrorCode.RequestTimeout, 'Maximum total timeout exceeded', { + maxTotalTimeout: requestOptions.maxTotalTimeout, + totalElapsed + }); + } + legOptions.maxTotalTimeout = remaining; + } + + const result = await hooks.retry(buildInputRequiredRetryParams(originalParams, responses, payload.requestState), legOptions); + if (isInputRequiredResult(result)) { + payload = { + inputRequests: result.inputRequests ?? {}, + ...(result.requestState !== undefined && { requestState: result.requestState }) + }; + continue; + } + return result; + } +} diff --git a/packages/core/src/types/guards.ts b/packages/core/src/types/guards.ts index 8091b962c..0a5f4b7cd 100644 --- a/packages/core/src/types/guards.ts +++ b/packages/core/src/types/guards.ts @@ -17,6 +17,7 @@ import type { CompleteRequestResourceTemplate, InitializedNotification, InitializeRequest, + InputRequiredResult, JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, @@ -87,6 +88,24 @@ export const isCallToolResult = (value: unknown): value is CallToolResult => { return CallToolResultSchema.safeParse(value).success; }; +/** + * Checks whether a value is an input-required result (protocol revision + * 2026-07-28): the multi-round-trip return shape discriminated by + * `resultType: 'input_required'`. + * + * This is a discriminator check, not a full validator — the at-least-one rule + * (`inputRequests` or `requestState`) is enforced by the `inputRequired()` + * builder and re-checked by the server seam for hand-built values. + * + * @param value - The value to check. + * @returns True if the value carries the `input_required` discriminator. + */ +export const isInputRequiredResult = (value: unknown): value is InputRequiredResult => + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + (value as { resultType?: unknown }).resultType === 'input_required'; + /** * Checks if a value is a valid {@linkcode TaskAugmentedRequestParams}. * @param value - The value to check. diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index 55b2bf748..94b6408fd 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -436,6 +436,81 @@ export type ListRootsRequest = Infer; export type ListRootsResult = StripWireOnly>; export type RootsListChangedNotification = Infer; +/* Multi round-trip requests (protocol revision 2026-07-28) + * + * On the 2026-07-28 revision the server obtains client input (elicitation, + * sampling, roots) in-band: instead of sending a server→client JSON-RPC + * request, a handler for one of the multi-round-trip methods (`tools/call`, + * `prompts/get`, `resources/read`) returns an input-required result carrying + * de-JSON-RPC'd embedded requests; the client fulfils them and retries the + * original request with the responses. These are the NEUTRAL shapes of that + * surface — handlers author them and the 2026-07-28 wire codec alone maps + * them to/from the wire. + */ + +/** + * A single embedded (de-JSON-RPC'd) input request inside an + * {@linkcode InputRequiredResult}: an elicitation, sampling, or roots request + * object carried in-band rather than sent as a server→client JSON-RPC request. + */ +export type InputRequest = CreateMessageRequest | ListRootsRequest | ElicitRequest; + +/** + * A single embedded (de-JSON-RPC'd) input response inside a retried request's + * `inputResponses`: the bare result object for the corresponding + * {@linkcode InputRequest} (never wrapped in a `{method, result}` envelope). + */ +export type InputResponse = CreateMessageResult | ListRootsResult | ElicitResult; + +/** + * A map of embedded input requests, keyed by server-assigned identifiers that + * are unique within the scope of the request. + */ +export interface InputRequests { + [key: string]: InputRequest; +} + +/** + * A map of embedded input responses. Keys correspond to the keys of the + * {@linkcode InputRequests} map the server sent; values are the client's bare + * result for each request. + */ +export interface InputResponses { + [key: string]: InputResponse; +} + +/** + * The input-required result a handler for a multi-round-trip method + * (`tools/call`, `prompts/get`, `resources/read`) returns to request more + * input from the client (protocol revision 2026-07-28). Build it with the + * `inputRequired()` builder; hand-built literals are equally legal — + * `resultType: 'input_required'` is the discriminator, and the SDK re-checks + * the at-least-one rule at the seam. + * + * This is the one place the wire discriminator `resultType` appears on the + * neutral surface: the handler authors it, the 2026-07-28 codec passes it + * through to the wire, and consumers receiving results never see it (complete + * results are lifted). + * + * At least one of `inputRequests` or `requestState` must be present. + * + * `requestState` is an opaque, server-minted string echoed back verbatim by + * the client on retry. It travels through the client and MUST be treated by + * the server as attacker-controlled input on re-entry: if it influences + * authorization, resource access, or business logic, the server MUST protect + * its integrity (e.g. HMAC or AEAD) and MUST reject state that fails + * verification (spec: basic/patterns/mrtr §Server Requirements). The SDK + * surfaces it raw at `ctx.mcpReq.requestState` and applies no integrity + * protection of its own. + */ +export interface InputRequiredResult extends Result { + resultType: 'input_required'; + /** Embedded requests the client must fulfil before retrying. */ + inputRequests?: InputRequests; + /** Opaque server state the client echoes back verbatim on retry. */ + requestState?: string; +} + /* Client messages */ export type ClientRequest = Infer; export type ClientNotification = Infer; @@ -483,6 +558,23 @@ export type ResultTypeMap = { 'roots/list': ListRootsResult; }; +/** + * The handler-return counterpart of {@linkcode ResultTypeMap}: what a + * registered request handler may RETURN for each method. Identical to + * `ResultTypeMap` except that the multi-round-trip methods (`tools/call`, + * `prompts/get`, `resources/read`) additionally accept an + * {@linkcode InputRequiredResult} (protocol revision 2026-07-28). + * + * `ResultTypeMap` itself — what a *requester* receives — is deliberately NOT + * widened: `client.callTool()` returns a plain {@linkcode CallToolResult} on + * both protocol eras. + */ +export type HandlerResultTypeMap = { + [M in keyof ResultTypeMap]: M extends 'tools/call' | 'prompts/get' | 'resources/read' + ? ResultTypeMap[M] | InputRequiredResult + : ResultTypeMap[M]; +}; + /** * Information about a validated access token, provided to request handlers. */ diff --git a/packages/core/src/wire/codec.ts b/packages/core/src/wire/codec.ts index d98d8e23f..878922db8 100644 --- a/packages/core/src/wire/codec.ts +++ b/packages/core/src/wire/codec.ts @@ -131,6 +131,20 @@ export interface WireCodec { notificationSchema(method: M): z.ZodType | undefined; notificationSchema(method: string): z.ZodType | undefined; + /** + * In-band (de-JSON-RPC'd) input-request vocabulary of this era — the + * embedded requests a multi-round-trip `input_required` result may carry + * and the bare responses that answer them. `undefined` means the method + * is not in-band vocabulary on this era (the 2025-era codec has none: + * elicitation/sampling/roots are wire request methods there). These do + * NOT grant registry membership — a peer sending one of these as a wire + * request on an era that demoted it still gets −32601 by absence. + */ + inputRequestSchema(method: M): z.ZodType | undefined; + inputRequestSchema(method: string): z.ZodType | undefined; + inputResponseSchema(method: M): z.ZodType | undefined; + inputResponseSchema(method: string): z.ZodType | undefined; + /** * Step 1 of result decoding: RAW `resultType` handling BEFORE any schema * validation (V-1's structural home). Era postures (Q1-SD3): diff --git a/packages/core/src/wire/rev2025-11-25/codec.ts b/packages/core/src/wire/rev2025-11-25/codec.ts index 458379d9c..5ca85ccd3 100644 --- a/packages/core/src/wire/rev2025-11-25/codec.ts +++ b/packages/core/src/wire/rev2025-11-25/codec.ts @@ -44,6 +44,15 @@ export const rev2025Codec: WireCodec = { resultSchema: getResultSchema, notificationSchema: getNotificationSchema, + // No in-band input-request vocabulary on this era: elicitation, sampling + // and roots are real wire request methods here (see the registry). + inputRequestSchema: (): undefined => { + return; + }, + inputResponseSchema: (): undefined => { + return; + }, + decodeResult(_method: string, raw: unknown): DecodedResult { // Strip-on-lift (Q1-SD3 ii): a foreign `resultType` on the 2025 leg is // dropped before validation, whatever its value. There is no diff --git a/packages/core/src/wire/rev2026-07-28/codec.ts b/packages/core/src/wire/rev2026-07-28/codec.ts index 4410a0a05..2e8680e21 100644 --- a/packages/core/src/wire/rev2026-07-28/codec.ts +++ b/packages/core/src/wire/rev2026-07-28/codec.ts @@ -31,6 +31,7 @@ import { SdkError, SdkErrorCode } from '../../errors/sdkErrors.js'; import type { Result } from '../../types/types.js'; import type { DecodedResult, LiftedWireMaterial, WireCodec } from '../codec.js'; import { fillCacheFields, stampResultType } from './encodeContract.js'; +import { getInputRequestSchema2026, getInputResponseSchema2026 } from './inputRequired.js'; import { getNotificationSchema2026, getRequestSchema2026, @@ -99,6 +100,12 @@ export const rev2026Codec: WireCodec = { resultSchema: getResultSchema2026, notificationSchema: getNotificationSchema2026, + // In-band multi-round-trip vocabulary: the demoted elicitation/sampling/ + // roots shapes carried inside `input_required` results (NOT wire request + // methods on this era — registry membership is deliberately not granted). + inputRequestSchema: getInputRequestSchema2026, + inputResponseSchema: getInputResponseSchema2026, + decodeResult(method: string, raw: unknown): DecodedResult { if (!isPlainObject(raw)) { return { @@ -132,11 +139,28 @@ export const rev2026Codec: WireCodec = { } if (rawResultType === 'input_required') { // The driver seam (#13 consumes this payload). - const inputRequests = raw['inputRequests']; + const rawInputRequests = raw['inputRequests']; + const inputRequests = isPlainObject(rawInputRequests) ? rawInputRequests : {}; + const requestState = raw['requestState']; + if (Object.keys(inputRequests).length === 0 && typeof requestState !== 'string') { + // At-least-one rule, client side: with neither inputRequests + // nor requestState there is nothing to fulfil and nothing to + // echo — retrying would only resend the original params until + // the round cap is exhausted, so fail fast instead. + return { + kind: 'invalid', + error: new SdkError( + SdkErrorCode.InvalidResult, + `Invalid result for ${method}: input_required carries neither inputRequests nor requestState ` + + `(every input_required result must include at least one of the two)`, + { method, violation: 'input-required-missing-both' } + ) + }; + } return { kind: 'input_required', - inputRequests: isPlainObject(inputRequests) ? inputRequests : {}, - ...(typeof raw['requestState'] === 'string' && { requestState: raw['requestState'] }) + inputRequests, + ...(typeof requestState === 'string' && { requestState }) }; } if (rawResultType !== 'complete') { diff --git a/packages/core/src/wire/rev2026-07-28/inputRequired.ts b/packages/core/src/wire/rev2026-07-28/inputRequired.ts new file mode 100644 index 000000000..365a178d7 --- /dev/null +++ b/packages/core/src/wire/rev2026-07-28/inputRequired.ts @@ -0,0 +1,83 @@ +/** + * In-band input-request vocabulary of the 2026-07-28 revision (SEP-2322 + * multi round-trip requests), dispatch view. + * + * The three former server→client wire requests (`elicitation/create`, + * `sampling/createMessage`, `roots/list`) are NOT wire request methods on + * this revision — they are demoted to de-JSON-RPC'd payloads embedded in an + * `input_required` result. The multi-round-trip driver dispatches those + * embedded payloads to the client's registered handlers through the normal + * handler machinery, and these are the schemas that dispatch parses them + * with: lenient where the anchor's wire-true artifacts are strict (an + * embedded request never carries the per-request `_meta` envelope), exact + * where the vocabulary forks (the sampling shapes compose the forked + * SamplingMessage/Tool payloads). + * + * Registry membership is intentionally NOT granted here — these methods stay + * absent from the 2026-era request registry (a peer sending one as a wire + * request still gets −32601 by absence). Only the codec's + * `inputRequestSchema`/`inputResponseSchema` accessors expose them. + */ +import * as z from 'zod/v4'; + +import type { RequestMethod, RequestTypeMap, ResultTypeMap } from '../../types/types.js'; +import { + CreateMessageRequestParamsSchema, + CreateMessageResultSchema, + ElicitRequestParamsSchema, + ElicitResultSchema, + ListRootsResultSchema +} from './schemas.js'; + +/** The embedded input-request methods of the 2026-07-28 revision. */ +export const INPUT_REQUEST_METHODS_2026 = ['elicitation/create', 'sampling/createMessage', 'roots/list'] as const; + +export type InputRequestMethod2026 = (typeof INPUT_REQUEST_METHODS_2026)[number]; + +/** Dispatch-time (lenient) embedded request schemas, keyed by method. */ +const inputRequestSchemas2026: Record = { + 'elicitation/create': z.object({ + method: z.literal('elicitation/create'), + params: ElicitRequestParamsSchema + }), + 'sampling/createMessage': z.object({ + method: z.literal('sampling/createMessage'), + params: CreateMessageRequestParamsSchema + }), + 'roots/list': z.object({ + method: z.literal('roots/list'), + params: z.looseObject({}).optional() + }) +}; + +/** Embedded (bare) response schemas, keyed by the request method they answer. */ +const inputResponseSchemas2026: Record = { + 'elicitation/create': ElicitResultSchema, + 'sampling/createMessage': CreateMessageResultSchema, + 'roots/list': ListRootsResultSchema +}; + +export function isInputRequestMethod2026(method: string): method is InputRequestMethod2026 { + return (INPUT_REQUEST_METHODS_2026 as readonly string[]).includes(method); +} + +/** + * Gets the dispatch (lenient) schema for an embedded input request, or + * `undefined` for methods that are not in-band vocabulary on this era. + * The typed overload mirrors `WireCodec.inputRequestSchema`. + */ +export function getInputRequestSchema2026(method: M): z.ZodType | undefined; +export function getInputRequestSchema2026(method: string): z.ZodType | undefined; +export function getInputRequestSchema2026(method: string): z.ZodType | undefined { + return isInputRequestMethod2026(method) ? inputRequestSchemas2026[method] : undefined; +} + +/** + * Gets the bare embedded-response schema answering an embedded input request, + * or `undefined` for methods that are not in-band vocabulary on this era. + */ +export function getInputResponseSchema2026(method: M): z.ZodType | undefined; +export function getInputResponseSchema2026(method: string): z.ZodType | undefined; +export function getInputResponseSchema2026(method: string): z.ZodType | undefined { + return isInputRequestMethod2026(method) ? inputResponseSchemas2026[method] : undefined; +} diff --git a/packages/core/src/wire/rev2026-07-28/schemas.ts b/packages/core/src/wire/rev2026-07-28/schemas.ts index d6393d9f5..e7e68c8e1 100644 --- a/packages/core/src/wire/rev2026-07-28/schemas.ts +++ b/packages/core/src/wire/rev2026-07-28/schemas.ts @@ -28,11 +28,14 @@ import { ClientCapabilitiesSchema, ContentBlockSchema, CursorSchema, + ElicitRequestFormParamsSchema, IconsSchema, ImageContentSchema, ImplementationSchema, + JSONObjectSchema, LoggingLevelSchema, LoggingMessageNotificationSchema, + ModelPreferencesSchema, ProgressNotificationSchema, ProgressTokenSchema, PromptListChangedNotificationSchema, @@ -47,10 +50,12 @@ import { ResourceTemplateSchema, ResourceUpdatedNotificationSchema, RoleSchema, + RootSchema, ServerCapabilitiesSchema, TextContentSchema, TextResourceContentsSchema, ToolAnnotationsSchema, + ToolChoiceSchema, ToolListChangedNotificationSchema, ToolUseContentSchema } from '../../types/schemas.js'; @@ -277,6 +282,125 @@ export const DiscoverResultSchema = wireResult({ instructions: z.string().optional() }); +/* ------------------------------------------------------------------------ * + * Multi round-trip requests (SEP-2322). The in-band vocabulary of this + * revision: server→client interactions are carried as de-JSON-RPC'd embedded + * requests inside an `input_required` result, fulfilled by the client, and + * echoed back as embedded responses on the retry. The shapes below are + * anchor-exact wire artifacts (corpus + parity); the lenient dispatch-time + * schemas the multi-round-trip driver parses embedded requests with live in + * `inputRequired.ts`. + * + * The sampling shapes fork here (they compose the forked SamplingMessage / + * Tool payloads); the URL-mode elicitation params fork here (the draft + * removed `elicitationId`; the shared schema keeps it because it is required + * on the frozen 2025-11-25 revision); form-mode elicitation params are + * revision-identical and are composed by reference from the shared schema. + * ------------------------------------------------------------------------ */ + +/** 2026-era CreateMessageRequestParams (anchor-exact: forked SamplingMessage/Tool, no task augmentation). */ +export const CreateMessageRequestParamsSchema = z.object({ + messages: z.array(SamplingMessageSchema), + modelPreferences: ModelPreferencesSchema.optional(), + systemPrompt: z.string().optional(), + includeContext: z.enum(['none', 'thisServer', 'allServers']).optional(), + temperature: z.number().optional(), + maxTokens: z.number().int(), + stopSequences: z.array(z.string()).optional(), + metadata: JSONObjectSchema.optional(), + tools: z.array(ToolSchema).optional(), + toolChoice: ToolChoiceSchema.optional() +}); + +/** 2026-era embedded sampling request (de-JSON-RPC'd). */ +export const CreateMessageRequestSchema = z.object({ + method: z.literal('sampling/createMessage'), + params: CreateMessageRequestParamsSchema +}); + +/** + * 2026-era embedded roots listing request (de-JSON-RPC'd). Embedded input + * requests do NOT carry the per-request `_meta` envelope on this revision — + * the anchor declares a bare optional `_meta` on `params`. + */ +export const ListRootsRequestSchema = z.object({ + method: z.literal('roots/list'), + params: z.object({ _meta: z.record(z.string(), z.unknown()).optional() }).optional() +}); + +/** 2026-era embedded sampling response (anchor-exact: extends the forked SamplingMessage). */ +export const CreateMessageResultSchema = z.object({ + ...SamplingMessageSchema.shape, + model: z.string(), + stopReason: z.string().optional() +}); + +/** 2026-era embedded roots listing response (anchor-exact: bare `roots` array). */ +export const ListRootsResultSchema = z.object({ + roots: z.array(RootSchema) +}); + +/** 2026-era embedded elicitation response (anchor-exact: bare result, restricted content value types). */ +export const ElicitResultSchema = z.object({ + action: z.enum(['accept', 'decline', 'cancel']), + content: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])).optional() +}); + +/** + * 2026-era URL-mode elicitation params (anchor-exact fork): the draft removed + * `elicitationId` (and the `notifications/elicitation/complete` channel it + * keyed) — the shared schema keeps the field because it is required on the + * frozen 2025-11-25 revision. + */ +export const ElicitRequestURLParamsSchema = z.object({ + mode: z.literal('url'), + message: z.string(), + url: z.string().url() +}); + +/** 2026-era elicitation params (form mode is revision-identical; URL mode is the fork above). */ +export const ElicitRequestParamsSchema = z.union([ElicitRequestFormParamsSchema, ElicitRequestURLParamsSchema]); + +/** 2026-era embedded elicitation request (de-JSON-RPC'd; see the URL-mode fork above). */ +export const ElicitRequestSchema = z.object({ + method: z.literal('elicitation/create'), + params: ElicitRequestParamsSchema +}); + +/** A single embedded input request (one of the three demoted server→client requests). */ +export const InputRequestSchema = z.union([CreateMessageRequestSchema, ListRootsRequestSchema, ElicitRequestSchema]); + +/** A single embedded input response — the BARE result union (never a `{method, result}` wrapper). */ +export const InputResponseSchema = z.union([CreateMessageResultSchema, ListRootsResultSchema, ElicitResultSchema]); + +/** Map of embedded input requests, keyed by server-assigned identifiers. */ +export const InputRequestsSchema = z.record(z.string(), InputRequestSchema); + +/** Map of embedded input responses, keyed by the corresponding request identifiers. */ +export const InputResponsesSchema = z.record(z.string(), InputResponseSchema); + +/** + * The wire InputRequiredResult: `resultType: 'input_required'` plus at least + * one of `inputRequests` / `requestState` (the at-least-one rule is enforced + * at the server seam, not by this parse shape). + */ +export const InputRequiredResultSchema = wireResult({ + inputRequests: InputRequestsSchema.optional(), + requestState: z.string().optional() +}); + +/** The retry-channel members carried by client-initiated requests on this revision. */ +const retryParamsShape = { + inputResponses: InputResponsesSchema.optional(), + requestState: z.string().optional() +}; + +/** Anchor InputResponseRequestParams: the retry channel on top of the required request `_meta` envelope. */ +export const InputResponseRequestParamsSchema = z.object({ + _meta: RequestMetaEnvelopeSchema, + ...retryParamsShape +}); + /* ------------------------------------------------------------------------ * * Request side. Two views per method: * - WIRE-TRUE (`RequestSchema`): params `_meta` carries the REQUIRED @@ -310,7 +434,10 @@ function dispatchRequest(meth const callToolParamsShape = { name: z.string(), - arguments: z.record(z.string(), z.unknown()).optional() + arguments: z.record(z.string(), z.unknown()).optional(), + // Multi-round-trip retry channel (the wire-true view models it; dispatch + // never sees it — the protocol layer lifts it before any handler runs). + ...retryParamsShape }; const paginatedParamsShape = { cursor: CursorSchema.optional() }; @@ -319,11 +446,12 @@ export const ListToolsRequestSchema = wireRequest('tools/list', paginatedParamsS export const ListPromptsRequestSchema = wireRequest('prompts/list', paginatedParamsShape); export const GetPromptRequestSchema = wireRequest('prompts/get', { name: z.string(), - arguments: z.record(z.string(), z.string()).optional() + arguments: z.record(z.string(), z.string()).optional(), + ...retryParamsShape }); export const ListResourcesRequestSchema = wireRequest('resources/list', paginatedParamsShape); export const ListResourceTemplatesRequestSchema = wireRequest('resources/templates/list', paginatedParamsShape); -export const ReadResourceRequestSchema = wireRequest('resources/read', { uri: z.string() }); +export const ReadResourceRequestSchema = wireRequest('resources/read', { uri: z.string(), ...retryParamsShape }); const completeParamsShape = { ref: z.union([PromptReferenceSchema, ResourceTemplateReferenceSchema]), argument: z.object({ name: z.string(), value: z.string() }), @@ -517,13 +645,15 @@ const wireResultResponse = (result: T) => .strict(); export const JSONRPCResultResponseSchema = wireResultResponse(ResultSchema); -export const CallToolResultResponseSchema = wireResultResponse(CallToolResultSchema); +// The multi-round-trip methods may answer with either their final result or an +// InputRequiredResult (anchor: `result: CallToolResult | InputRequiredResult`). +export const CallToolResultResponseSchema = wireResultResponse(z.union([CallToolResultSchema, InputRequiredResultSchema])); export const ListToolsResultResponseSchema = wireResultResponse(ListToolsResultSchema); export const ListPromptsResultResponseSchema = wireResultResponse(ListPromptsResultSchema); -export const GetPromptResultResponseSchema = wireResultResponse(GetPromptResultSchema); +export const GetPromptResultResponseSchema = wireResultResponse(z.union([GetPromptResultSchema, InputRequiredResultSchema])); export const ListResourcesResultResponseSchema = wireResultResponse(ListResourcesResultSchema); export const ListResourceTemplatesResultResponseSchema = wireResultResponse(ListResourceTemplatesResultSchema); -export const ReadResourceResultResponseSchema = wireResultResponse(ReadResourceResultSchema); +export const ReadResourceResultResponseSchema = wireResultResponse(z.union([ReadResourceResultSchema, InputRequiredResultSchema])); export const CompleteResultResponseSchema = wireResultResponse(CompleteResultSchema); export const DiscoverResultResponseSchema = wireResultResponse(DiscoverResultSchema); diff --git a/packages/core/test/corpus/specCorpus.test.ts b/packages/core/test/corpus/specCorpus.test.ts index d5bcdecdb..f5b2c5e82 100644 --- a/packages/core/test/corpus/specCorpus.test.ts +++ b/packages/core/test/corpus/specCorpus.test.ts @@ -65,9 +65,6 @@ const ERROR_OBJECT_DIRS = new Set([ * fails loudly. These burn down as the corresponding features land. */ const PENDING_2026: Record = { - InputRequests: 'multi-round-trip request vocabulary (SEP-2322) is not modeled yet', - InputRequiredResult: 'multi-round-trip request vocabulary (SEP-2322) is not modeled yet', - InputResponses: 'multi-round-trip request vocabulary (SEP-2322) is not modeled yet', SubscriptionsAcknowledgedNotification: 'subscriptions/listen vocabulary (SEP-1865) is not modeled yet', SubscriptionsListenRequest: 'subscriptions/listen vocabulary (SEP-1865) is not modeled yet' }; @@ -79,13 +76,9 @@ const PENDING_2026: Record = { * parse, so the entry is removed the moment the widening lands. */ const PENDING_2026_FILES: Record = { - // The draft removed elicitationId from ElicitRequestURLParams; the SDK's - // shared schema keeps it (it is required on the frozen 2025-11-25 - // revision), and the 2026-era in-band elicitation surface that will model - // the new shape is MRTR scope (#13). Until then the upstream example - // (which carries no elicitationId) does not parse. - 'ElicitRequestURLParams/elicit-sensitive-data.json': - 'URL-mode elicitation without elicitationId is modeled with the MRTR in-band surface (SEP-2322, #13)' + // (empty — the elicitationId-less ElicitRequestURLParams example burned + // when the 2026-era wire module landed the URL-mode elicitation fork as + // part of the multi-round-trip in-band vocabulary.) }; type AnyZod = z.ZodType; diff --git a/packages/core/test/shared/inputRequired.test.ts b/packages/core/test/shared/inputRequired.test.ts new file mode 100644 index 000000000..421ed6730 --- /dev/null +++ b/packages/core/test/shared/inputRequired.test.ts @@ -0,0 +1,89 @@ +/** + * The multi-round-trip authoring helpers (M4.1): the `inputRequired()` + * builder family, the `acceptedContent` reader, and the `withInputRequired` + * manual-mode schema wrapper. No nominal brand exists — the builder returns a + * plain `resultType: 'input_required'` value (F-10). + */ +import { describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +import { acceptedContent, inputRequired, withInputRequired } from '../../src/shared/inputRequired.js'; +import { isInputRequiredResult } from '../../src/types/guards.js'; +import { validateStandardSchema } from '../../src/util/standardSchema.js'; + +describe('inputRequired() builder', () => { + test('builds a plain discriminated value (no brand) from inputRequests', () => { + const value = inputRequired({ + inputRequests: { confirm: inputRequired.elicit({ message: 'OK?', requestedSchema: { type: 'object', properties: {} } }) } + }); + expect(value.resultType).toBe('input_required'); + expect(Object.getOwnPropertySymbols(value)).toEqual([]); + expect(isInputRequiredResult(value)).toBe(true); + expect(value.inputRequests?.confirm).toMatchObject({ method: 'elicitation/create', params: { mode: 'form', message: 'OK?' } }); + expect(value.requestState).toBeUndefined(); + }); + + test('builds a requestState-only value (load shedding)', () => { + const value = inputRequired({ requestState: 'opaque-blob' }); + expect(value).toEqual({ resultType: 'input_required', requestState: 'opaque-blob' }); + }); + + test('enforces the at-least-one rule', () => { + expect(() => inputRequired({})).toThrow(TypeError); + expect(() => inputRequired({ inputRequests: {} })).toThrow(/at least one/); + }); + + test('hand-built literals discriminate identically (hand-built results are legal)', () => { + expect(isInputRequiredResult({ resultType: 'input_required', requestState: 's' })).toBe(true); + expect(isInputRequiredResult({ resultType: 'complete' })).toBe(false); + expect(isInputRequiredResult({ content: [] })).toBe(false); + expect(isInputRequiredResult(null)).toBe(false); + }); + + test('per-kind constructors produce the embedded request shapes', () => { + expect(inputRequired.elicitUrl({ message: 'go', url: 'https://example.com/auth' })).toEqual({ + method: 'elicitation/create', + params: { mode: 'url', message: 'go', url: 'https://example.com/auth' } + }); + expect(inputRequired.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], maxTokens: 5 })).toEqual({ + method: 'sampling/createMessage', + params: { messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], maxTokens: 5 } + }); + expect(inputRequired.listRoots()).toEqual({ method: 'roots/list' }); + }); +}); + +describe('acceptedContent()', () => { + test('returns the accepted form content for the key', () => { + const responses = { confirm: { action: 'accept', content: { confirm: true } } }; + expect(acceptedContent<{ confirm: boolean }>(responses, 'confirm')).toEqual({ confirm: true }); + }); + + test('returns undefined for missing keys, declined/cancelled responses, and other kinds', () => { + expect(acceptedContent(undefined, 'confirm')).toBeUndefined(); + expect(acceptedContent({}, 'confirm')).toBeUndefined(); + expect(acceptedContent({ confirm: { action: 'decline' } }, 'confirm')).toBeUndefined(); + expect(acceptedContent({ confirm: { action: 'cancel' } }, 'confirm')).toBeUndefined(); + expect(acceptedContent({ confirm: { action: 'accept' } }, 'confirm')).toBeUndefined(); + expect(acceptedContent({ roots: { roots: [] } }, 'roots')).toBeUndefined(); + }); +}); + +describe('withInputRequired()', () => { + const inner = z.object({ content: z.array(z.unknown()) }); + + test('passes input-required values through untouched', async () => { + const wrapped = withInputRequired(inner); + const value = { resultType: 'input_required', requestState: 'blob' }; + const outcome = await validateStandardSchema(wrapped, value); + expect(outcome).toEqual({ success: true, data: value }); + }); + + test('validates complete results against the wrapped schema', async () => { + const wrapped = withInputRequired(inner); + const ok = await validateStandardSchema(wrapped, { content: [] }); + expect(ok.success).toBe(true); + const bad = await validateStandardSchema(wrapped, { nope: true }); + expect(bad.success).toBe(false); + }); +}); diff --git a/packages/core/test/shared/inputRequiredDriver.test.ts b/packages/core/test/shared/inputRequiredDriver.test.ts new file mode 100644 index 000000000..a5435d1c9 --- /dev/null +++ b/packages/core/test/shared/inputRequiredDriver.test.ts @@ -0,0 +1,251 @@ +/** + * The multi-round-trip auto-fulfilment driver loop in isolation (M4.1): + * round accounting against the configurable cap, retry-param construction + * (byte-exact requestState echo, bare responses), requestState-only pacing, + * the existing-knob total-timeout bound, and the typed rounds-exceeded error + * carrying the last result. + */ +import { describe, expect, test, vi } from 'vitest'; + +import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; +import { + buildInputRequiredRetryParams, + DEFAULT_INPUT_REQUIRED_AUTO_FULFILL, + DEFAULT_INPUT_REQUIRED_MAX_ROUNDS, + REQUEST_STATE_ONLY_LEG_PACING_MS, + resolveInputRequiredDriverConfig, + runInputRequiredDriver +} from '../../src/shared/inputRequiredDriver.js'; + +const ELICIT_ENTRY = { method: 'elicitation/create', params: { mode: 'form', message: 'Name?' } }; + +describe('driver configuration', () => { + test('defaults: auto-fulfilment on, cap 10 rounds', () => { + expect(DEFAULT_INPUT_REQUIRED_AUTO_FULFILL).toBe(true); + expect(DEFAULT_INPUT_REQUIRED_MAX_ROUNDS).toBe(10); + expect(resolveInputRequiredDriverConfig(undefined)).toEqual({ autoFulfill: true, maxRounds: 10 }); + expect(resolveInputRequiredDriverConfig({ autoFulfill: false, maxRounds: 3 })).toEqual({ autoFulfill: false, maxRounds: 3 }); + }); +}); + +describe('retry params', () => { + test('echoes requestState byte-exact and attaches bare responses without touching original params', () => { + const original = { name: 'deploy', arguments: { env: 'prod' } }; + const params = buildInputRequiredRetryParams(original, { confirm: { action: 'accept', content: { ok: true } } }, 'opaqueÿ☃'); + expect(params).toEqual({ + name: 'deploy', + arguments: { env: 'prod' }, + inputResponses: { confirm: { action: 'accept', content: { ok: true } } }, + requestState: 'opaqueÿ☃' + }); + // The original params object is not mutated. + expect(original).toEqual({ name: 'deploy', arguments: { env: 'prod' } }); + }); + + test('omits requestState when the result carried none, and inputResponses when nothing was fulfilled', () => { + expect(buildInputRequiredRetryParams({ name: 'x' }, undefined, 'state')).toEqual({ name: 'x', requestState: 'state' }); + expect(buildInputRequiredRetryParams({ name: 'x' }, {}, undefined)).toEqual({ name: 'x' }); + expect(buildInputRequiredRetryParams(undefined, undefined, undefined)).toBeUndefined(); + }); +}); + +describe('driver loop', () => { + test('fulfils embedded requests, retries, and resolves with the complete result', async () => { + const dispatched: string[] = []; + const retries: Array | undefined> = []; + const result = await runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'tools/call', + originalParams: { name: 'deploy' }, + firstPayload: { inputRequests: { confirm: ELICIT_ENTRY }, requestState: 'round-1' }, + requestOptions: {}, + hooks: { + dispatchInputRequest: (key, _entry) => { + dispatched.push(key); + return Promise.resolve({ action: 'accept', content: { ok: true } }); + }, + retry: params => { + retries.push(params); + return Promise.resolve({ content: [{ type: 'text', text: 'done' }] }); + } + } + }); + + expect(result).toEqual({ content: [{ type: 'text', text: 'done' }] }); + expect(dispatched).toEqual(['confirm']); + expect(retries).toEqual([ + { + name: 'deploy', + inputResponses: { confirm: { action: 'accept', content: { ok: true } } }, + requestState: 'round-1' + } + ]); + }); + + test('keeps looping while retries return input_required and counts every leg against the cap', async () => { + let retryCount = 0; + const result = await runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 3 }, + method: 'tools/call', + originalParams: { name: 'deploy' }, + firstPayload: { inputRequests: { confirm: ELICIT_ENTRY } }, + requestOptions: {}, + hooks: { + dispatchInputRequest: () => Promise.resolve({ action: 'accept', content: {} }), + retry: () => { + retryCount += 1; + if (retryCount < 3) { + return Promise.resolve({ resultType: 'input_required', inputRequests: { confirm: ELICIT_ENTRY } }); + } + return Promise.resolve({ content: [] }); + } + } + }); + expect(result).toEqual({ content: [] }); + expect(retryCount).toBe(3); + }); + + test('round exhaustion raises the typed error carrying the last input_required payload', async () => { + const outcome = runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 2 }, + method: 'prompts/get', + originalParams: { name: 'p' }, + firstPayload: { inputRequests: { confirm: ELICIT_ENTRY }, requestState: 'state-0' }, + requestOptions: {}, + hooks: { + dispatchInputRequest: () => Promise.resolve({ action: 'accept' }), + retry: () => + Promise.resolve({ resultType: 'input_required', inputRequests: { again: ELICIT_ENTRY }, requestState: 'state-n' }) + } + }); + await expect(outcome).rejects.toSatisfy((error: unknown) => { + expect(error).toBeInstanceOf(SdkError); + const typed = error as SdkError; + expect(typed.code).toBe(SdkErrorCode.InputRequiredRoundsExceeded); + expect(typed.data).toMatchObject({ + rounds: 2, + lastResult: { inputRequests: { again: ELICIT_ENTRY }, requestState: 'state-n' } + }); + return true; + }); + }); + + test('a requestState-only leg is paced by the fixed delay and counted in the same cap', async () => { + vi.useFakeTimers(); + try { + let resolved = false; + const run = runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'tools/call', + originalParams: { name: 'x' }, + firstPayload: { inputRequests: {}, requestState: 'only-state' }, + requestOptions: {}, + hooks: { + dispatchInputRequest: () => Promise.reject(new Error('must not dispatch on a state-only leg')), + retry: params => { + expect(params).toEqual({ name: 'x', requestState: 'only-state' }); + return Promise.resolve({ content: [] }); + } + } + }).then(value => { + resolved = true; + return value; + }); + + // Nothing happens before the pacing delay elapses. + await vi.advanceTimersByTimeAsync(REQUEST_STATE_ONLY_LEG_PACING_MS - 1); + expect(resolved).toBe(false); + await vi.advanceTimersByTimeAsync(2); + await expect(run).resolves.toEqual({ content: [] }); + } finally { + vi.useRealTimers(); + } + }); + + test('maxTotalTimeout bounds the whole flow through the existing knob (shrinking per-leg budgets)', async () => { + const legBudgets: Array = []; + let now = 0; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + try { + const outcome = runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'tools/call', + originalParams: { name: 'x' }, + firstPayload: { inputRequests: { confirm: ELICIT_ENTRY } }, + requestOptions: { timeout: 1_000, maxTotalTimeout: 5_000 }, + hooks: { + dispatchInputRequest: () => { + // Handler time counts against the total budget. + now += 3_000; + return Promise.resolve({ action: 'accept' }); + }, + retry: (_params, legOptions) => { + legBudgets.push(legOptions.maxTotalTimeout); + return Promise.resolve({ resultType: 'input_required', inputRequests: { confirm: ELICIT_ENTRY } }); + } + } + }); + await expect(outcome).rejects.toSatisfy((error: unknown) => { + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.RequestTimeout); + return true; + }); + // First leg got the remaining 2 s of the 5 s budget; the second + // round's budget was already exhausted before sending. + expect(legBudgets).toEqual([2_000]); + } finally { + nowSpy.mockRestore(); + } + }); + + test('the total-timeout budget is measured from the flow start (the original request), not the driver start', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => 10_000); + try { + const retries: unknown[] = []; + const outcome = runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'tools/call', + originalParams: { name: 'x' }, + firstPayload: { inputRequests: { confirm: ELICIT_ENTRY } }, + requestOptions: { maxTotalTimeout: 5_000 }, + // The original request went out at t=4s; the first wire leg + // alone already exhausted the 5 s whole-flow budget by t=10s. + flowStartedAt: 4_000, + hooks: { + dispatchInputRequest: () => Promise.resolve({ action: 'accept' }), + retry: params => { + retries.push(params); + return Promise.resolve({ content: [] }); + } + } + }); + await expect(outcome).rejects.toSatisfy((error: unknown) => { + expect(error).toBeInstanceOf(SdkError); + const typed = error as SdkError; + expect(typed.code).toBe(SdkErrorCode.RequestTimeout); + expect(typed.data).toMatchObject({ maxTotalTimeout: 5_000, totalElapsed: 6_000 }); + return true; + }); + // Fail before any retry hits the wire: the budget was already gone. + expect(retries).toHaveLength(0); + } finally { + nowSpy.mockRestore(); + } + }); + + test('each round is surfaced as synthetic progress to the caller', async () => { + const progress: number[] = []; + await runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'resources/read', + originalParams: { uri: 'file:///x' }, + firstPayload: { inputRequests: { confirm: ELICIT_ENTRY } }, + requestOptions: { onprogress: update => progress.push(update.progress) }, + hooks: { + dispatchInputRequest: () => Promise.resolve({ action: 'accept' }), + retry: () => Promise.resolve({ contents: [] }) + } + }); + expect(progress).toEqual([1]); + }); +}); diff --git a/packages/core/test/shared/inputRequiredFunnel.test.ts b/packages/core/test/shared/inputRequiredFunnel.test.ts new file mode 100644 index 000000000..c08904ed7 --- /dev/null +++ b/packages/core/test/shared/inputRequiredFunnel.test.ts @@ -0,0 +1,162 @@ +/** + * Protocol-layer seams of the multi-round-trip flow (M4.1): + * + * - the manual path: `allowInputRequired: true` hands the discriminated + * input-required value back to the caller (the primitive the auto driver is + * layered over), discriminated raw and BEFORE any consumer schema runs; + * - the inbound retry-material partition: only BARE inputResponses entries + * surface to handlers; wrapped `{method, result}` entries are dropped into + * `ctx.mcpReq.droppedInputResponseKeys` (T1/D-059). + */ +import { describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol, setNegotiatedProtocolVersion } from '../../src/shared/protocol.js'; +import type { JSONRPCRequest } from '../../src/types/index.js'; +import { isInputRequiredResult } from '../../src/types/guards.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; +import { rev2026Codec } from '../../src/wire/rev2026-07-28/codec.js'; + +class TestProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +const INPUT_REQUIRED_BODY = { + resultType: 'input_required', + inputRequests: { 'elicit-1': { method: 'elicitation/create', params: { mode: 'form', message: 'Name?' } } }, + requestState: 'opaque-state' +}; + +async function wireWithRawResult(rawResult: unknown): Promise { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = message => { + const request = message as JSONRPCRequest; + void serverTx.send({ jsonrpc: '2.0', id: request.id, result: rawResult } as Parameters[0]); + }; + await serverTx.start(); + const protocol = new TestProtocol(); + await protocol.connect(clientTx); + setNegotiatedProtocolVersion(protocol, '2026-07-28'); + return protocol; +} + +describe('manual mode (allowInputRequired)', () => { + test('hands the discriminated input-required value back to the caller', async () => { + const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY); + + const result = await protocol.request( + { method: 'tools/call', params: { name: 'echo', arguments: {} } }, + { + allowInputRequired: true + } + ); + + expect(isInputRequiredResult(result)).toBe(true); + expect(result).toEqual({ + resultType: 'input_required', + inputRequests: INPUT_REQUIRED_BODY.inputRequests, + requestState: 'opaque-state' + }); + + await protocol.close(); + }); + + test('discrimination happens on the raw body, before the consumer-provided result schema runs', async () => { + const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY); + + let schemaInvoked = false; + const poisonedSchema = z.unknown().transform(value => { + schemaInvoked = true; + return value; + }); + + const result = await protocol.request({ method: 'tools/call', params: { name: 'echo' } }, poisonedSchema, { + allowInputRequired: true + }); + expect(isInputRequiredResult(result)).toBe(true); + expect(schemaInvoked, 'the consumer schema must never see the input_required body').toBe(false); + + await protocol.close(); + }); + + test('without the opt-in (and without a driver) the typed local error is unchanged', async () => { + const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY); + await expect(protocol.request({ method: 'tools/call', params: { name: 'echo' } })).rejects.toMatchObject({ + code: 'UNSUPPORTED_RESULT_TYPE', + data: { resultType: 'input_required', method: 'tools/call' } + }); + await protocol.close(); + }); + + test('an input_required carrying neither inputRequests nor requestState fails fast as an invalid result, even with the opt-in', async () => { + const protocol = await wireWithRawResult({ resultType: 'input_required' }); + await expect( + protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }, { allowInputRequired: true }) + ).rejects.toMatchObject({ + code: 'INVALID_RESULT', + data: { method: 'tools/call', violation: 'input-required-missing-both' } + }); + await protocol.close(); + }); +}); + +describe('era gate (in-band vocabulary grants no registry membership)', () => { + test('the demoted methods are absent from the 2026-07-28 wire-request registry even though their in-band schemas exist', () => { + for (const method of ['elicitation/create', 'sampling/createMessage', 'roots/list']) { + expect(rev2026Codec.inputRequestSchema(method), method).toBeDefined(); + // A peer sending one of these as a wire request on the 2026 era + // still answers −32601 by absence — the in-band fallback used for + // embedded dispatch must never grant wire-request membership. + expect(rev2026Codec.hasRequestMethod(method), method).toBe(false); + } + }); +}); + +describe('inbound retry material (T1/D-059)', () => { + test('bare entries surface on ctx.mcpReq.inputResponses; wrapped entries are dropped into droppedInputResponseKeys', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const receiver = new TestProtocol(); + const seen: Array = []; + receiver.setRequestHandler('tools/call', (_request, ctx) => { + seen.push(ctx.mcpReq); + return { content: [] }; + }); + await receiver.connect(serverTx); + await clientTx.start(); + + const responses = new Promise(resolve => { + clientTx.onmessage = () => resolve(); + }); + await clientTx.send({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'deploy', + arguments: {}, + inputResponses: { + bare: { action: 'accept', content: { ok: true } }, + wrapped: { method: 'elicitation/create', result: { action: 'accept' } }, + 'not-an-object': 42 + }, + requestState: 'echoed-back' + } + } as Parameters[0]); + await responses; + + expect(seen).toHaveLength(1); + const mcpReq = seen[0]!; + expect(mcpReq.inputResponses).toEqual({ bare: { action: 'accept', content: { ok: true } } }); + expect(mcpReq.droppedInputResponseKeys?.sort()).toEqual(['not-an-object', 'wrapped']); + expect(mcpReq.requestState).toBe('echoed-back'); + // The handler-visible params never carry the lifted retry material. + await receiver.close(); + await clientTx.close(); + }); +}); diff --git a/packages/core/test/spec.types.2026-07-28.test.ts b/packages/core/test/spec.types.2026-07-28.test.ts index 02ab03465..23167146a 100644 --- a/packages/core/test/spec.types.2026-07-28.test.ts +++ b/packages/core/test/spec.types.2026-07-28.test.ts @@ -83,6 +83,44 @@ type WCancelledNotification = z4.infer; type WNotificationMeta = z4.infer; +/* Multi-round-trip vocabulary (SEP-2322) — modeled by the 2026-era wire module. */ +type WInputRequest = z4.infer; +type WInputRequests = z4.infer; +type WInputResponse = z4.infer; +type WInputResponses = z4.infer; +type WInputRequiredResult = z4.infer; +type WInputResponseRequestParams = z4.infer; +type WCreateMessageRequest = z4.infer; +type WCreateMessageRequestParams = z4.infer; +type WCreateMessageResult = z4.infer; +type WElicitRequest = z4.infer; +type WElicitRequestParams = z4.infer; +type WElicitRequestURLParams = z4.infer; +type WElicitResult = z4.infer; +type WListRootsRequest = z4.infer; +type WListRootsResult = z4.infer; +type WCallToolRequest = z4.infer; +type WCallToolRequestParams = WCallToolRequest['params']; +type WGetPromptRequest = z4.infer; +type WGetPromptRequestParams = WGetPromptRequest['params']; +type WReadResourceRequestParamsRetry = WReadResourceRequest['params']; +type WCallToolResultResponse = z4.infer; +type WGetPromptResultResponse = z4.infer; +type WReadResourceResultResponse = z4.infer; +// The anchor's ServerResult union, composed from the era module's wire results. +type WServerResult = + | WResult + | WDiscoverResult + | WCompleteResult + | WGetPromptResult + | WListPromptsResult + | WListResourceTemplatesResult + | WListResourcesResult + | WReadResourceResult + | WCallToolResult + | WListToolsResult + | WInputRequiredResult; + const sdkTypeChecks = { JSONValue: (sdk: SDKTypes.JSONValue, spec: SpecTypes.JSONValue) => { sdk = spec; @@ -590,6 +628,111 @@ const wireParityChecks = { ListToolsRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListToolsRequest) => { sdk = spec; spec = sdk; + }, + + /* Multi-round-trip vocabulary (SEP-2322, M4.1) */ + InputRequest: (sdk: WInputRequest, spec: SpecTypes.InputRequest) => { + sdk = spec; + spec = sdk; + }, + InputRequests: (sdk: WInputRequests, spec: SpecTypes.InputRequests) => { + sdk = spec; + spec = sdk; + }, + InputResponse: (sdk: WInputResponse, spec: SpecTypes.InputResponse) => { + sdk = spec; + spec = sdk; + }, + InputResponses: (sdk: WInputResponses, spec: SpecTypes.InputResponses) => { + sdk = spec; + spec = sdk; + }, + InputRequiredResult: (sdk: WInputRequiredResult, spec: SpecTypes.InputRequiredResult) => { + sdk = spec; + spec = sdk; + }, + InputResponseRequestParams: (sdk: WInputResponseRequestParams, spec: SpecTypes.InputResponseRequestParams) => { + sdk = spec; + spec = sdk; + }, + CreateMessageRequest: (sdk: WCreateMessageRequest, spec: SpecTypes.CreateMessageRequest) => { + sdk = spec; + spec = sdk; + }, + CreateMessageRequestParams: (sdk: WCreateMessageRequestParams, spec: SpecTypes.CreateMessageRequestParams) => { + sdk = spec; + spec = sdk; + }, + CreateMessageResult: (sdk: WCreateMessageResult, spec: SpecTypes.CreateMessageResult) => { + sdk = spec; + spec = sdk; + }, + // The 2026-era URL-mode elicitation params drop `elicitationId` (the + // shared schema keeps it required for the frozen 2025-11-25 shape) — + // compared against the wire-module fork. + ElicitRequestURLParams: (sdk: WElicitRequestURLParams, spec: SpecTypes.ElicitRequestURLParams) => { + sdk = spec; + spec = sdk; + }, + ElicitRequestParams: (sdk: WElicitRequestParams, spec: SpecTypes.ElicitRequestParams) => { + sdk = spec; + spec = sdk; + }, + ElicitRequest: (sdk: WElicitRequest, spec: SpecTypes.ElicitRequest) => { + sdk = spec; + spec = sdk; + }, + ElicitResult: (sdk: WElicitResult, spec: SpecTypes.ElicitResult) => { + sdk = spec; + spec = sdk; + }, + ListRootsRequest: (sdk: WListRootsRequest, spec: SpecTypes.ListRootsRequest) => { + sdk = spec; + spec = sdk; + }, + ListRootsResult: (sdk: WListRootsResult, spec: SpecTypes.ListRootsResult) => { + sdk = spec; + spec = sdk; + }, + CallToolRequestParams: (sdk: WCallToolRequestParams, spec: SpecTypes.CallToolRequestParams) => { + sdk = spec; + spec = sdk; + }, + CallToolRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CallToolRequest) => { + sdk = spec; + spec = sdk; + }, + GetPromptRequestParams: (sdk: WGetPromptRequestParams, spec: SpecTypes.GetPromptRequestParams) => { + sdk = spec; + spec = sdk; + }, + GetPromptRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.GetPromptRequest) => { + sdk = spec; + spec = sdk; + }, + ReadResourceRequestParams: (sdk: WReadResourceRequestParamsRetry, spec: SpecTypes.ReadResourceRequestParams) => { + sdk = spec; + spec = sdk; + }, + ReadResourceRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ReadResourceRequest) => { + sdk = spec; + spec = sdk; + }, + CallToolResultResponse: (sdk: WCallToolResultResponse, spec: SpecTypes.CallToolResultResponse) => { + sdk = spec; + spec = sdk; + }, + GetPromptResultResponse: (sdk: WGetPromptResultResponse, spec: SpecTypes.GetPromptResultResponse) => { + sdk = spec; + spec = sdk; + }, + ReadResourceResultResponse: (sdk: WReadResourceResultResponse, spec: SpecTypes.ReadResourceResultResponse) => { + sdk = spec; + spec = sdk; + }, + ServerResult: (sdk: WServerResult, spec: SpecTypes.ServerResult) => { + sdk = spec; + spec = sdk; } }; @@ -608,36 +751,9 @@ const FEATURE_OWNED_PENDING_2026: Record = { // Inlined in the SDK (same as the 2025-11-25 comparison): Error: 'the inner error object of a JSONRPCError is inlined in the SDK', - // M4.1 MRTR (#13): the in-band input-request surface and the demoted - // sampling/elicitation/roots shapes (wire requests in 2025, in-band - // InputRequest payloads in 2026 — the SDK models them when the - // multi-round-trip driver lands): - InputRequest: 'M4.1 MRTR (#13)', - InputRequests: 'M4.1 MRTR (#13)', - InputRequiredResult: 'M4.1 MRTR (#13)', - InputResponse: 'M4.1 MRTR (#13)', - InputResponseRequestParams: 'M4.1 MRTR (#13)', - InputResponses: 'M4.1 MRTR (#13)', - CreateMessageRequest: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', - CreateMessageRequestParams: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', - CreateMessageResult: 'M4.1 MRTR (#13) — in-band response shape', - ElicitRequest: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', - ElicitRequestParams: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', - ElicitRequestURLParams: - 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026; the draft also removed elicitationId from the URL-mode shape', - ElicitResult: 'M4.1 MRTR (#13) — in-band response shape', - ListRootsRequest: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', - ListRootsResult: 'M4.1 MRTR (#13) — in-band response shape', - ServerResult: 'M4.1 MRTR (#13) — the union gains InputRequiredResult', - CallToolRequestParams: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', - CallToolRequest: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', - GetPromptRequestParams: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', - GetPromptRequest: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', - ReadResourceRequestParams: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', - ReadResourceRequest: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', - CallToolResultResponse: 'M4.1 MRTR (#13) — the result union gains InputRequiredResult', - GetPromptResultResponse: 'M4.1 MRTR (#13) — the result union gains InputRequiredResult', - ReadResourceResultResponse: 'M4.1 MRTR (#13) — the result union gains InputRequiredResult', + // (The M4.1 MRTR partition burned down when the multi-round-trip wire + // vocabulary landed in wire/rev2026-07-28 — see the wireParityChecks + // entries for InputRequest/InputRequiredResult/… above.) // M6.1 subscriptions/listen (#14): SubscriptionFilter: 'M6.1 subscriptions/listen (#14)', diff --git a/packages/core/test/types/errorSurfacePins.test.ts b/packages/core/test/types/errorSurfacePins.test.ts index 46003004e..bfd673038 100644 --- a/packages/core/test/types/errorSurfacePins.test.ts +++ b/packages/core/test/types/errorSurfacePins.test.ts @@ -75,6 +75,7 @@ describe('SdkErrorCode', () => { SendFailed: 'SEND_FAILED', InvalidResult: 'INVALID_RESULT', UnsupportedResultType: 'UNSUPPORTED_RESULT_TYPE', + InputRequiredRoundsExceeded: 'INPUT_REQUIRED_ROUNDS_EXCEEDED', MethodNotSupportedByProtocolVersion: 'METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION', EraNegotiationFailed: 'ERA_NEGOTIATION_FAILED', ClientHttpNotImplemented: 'CLIENT_HTTP_NOT_IMPLEMENTED', From dbb2a08b49da02c531afe0f8fa7f3bfa66326b49 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 11:56:50 +0000 Subject: [PATCH 2/2] feat(client): multi-round-trip auto-fulfilment engine and manual mode The client half of multi round-trip requests, factored so the shared `Protocol` base stays generic: - protocol.ts gains only the irreducible input-required branch in the response path (manual mode via `allowInputRequired` plus a single named protected extension point `_resolveNonCompleteResult(decoded, flow)` whose base default is the existing typed UnsupportedResultType error), the type surface (`RequestOptions.allowInputRequired`, the `inputResponses`/`droppedInputResponseKeys`/`requestState` context fields), the `HandlerResultTypeMap` handler-return typing, and a `_getRequestHandler` accessor. - the engine body (driver invocation, embedded-request dispatch, synthesized-ctx construction, the inputResponses partition, and the per-retry-leg options whitelist) lives in `shared/inputRequiredEngine.ts`; the pure driver loop stays in `shared/inputRequiredDriver.ts`. - `Client` owns `ClientOptions.inputRequired` and overrides the hook to wire the engine. Includes three driver hardening fixes: each retry leg's request options are a whitelist (resumptionToken/onresumptiontoken/relatedRequestId never carry over); embedded sibling dispatches share a linked per-round abort so one failure cancels the others; the requestState-only pacing sleep honors the caller's abort signal. --- packages/client/src/client/client.ts | 116 ++++++++- packages/core/src/index.ts | 3 + .../core/src/shared/inputRequiredDriver.ts | 79 +++++- .../core/src/shared/inputRequiredEngine.ts | 238 ++++++++++++++++++ packages/core/src/shared/protocol.ts | 162 ++++++++++-- .../test/shared/inputRequiredDriver.test.ts | 55 ++++ .../test/shared/inputRequiredEngine.test.ts | 77 ++++++ test/e2e/scenarios/raw-result-type.test.ts | 6 +- 8 files changed, 696 insertions(+), 40 deletions(-) create mode 100644 packages/core/src/shared/inputRequiredEngine.ts create mode 100644 packages/core/test/shared/inputRequiredEngine.test.ts diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 0831efb13..0d9d40c6a 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -14,6 +14,7 @@ import type { GetPromptRequest, GetPromptResult, Implementation, + InputRequiredOptions, JSONRPCNotification, JSONRPCRequest, JsonSchemaType, @@ -31,14 +32,17 @@ import type { ListToolsResult, LoggingLevel, MessageExtraInfo, + NonCompleteResultFlow, NotificationMethod, ProtocolOptions, ReadResourceRequest, ReadResourceResult, RequestMethod, RequestOptions, + ResolvedInputRequiredDriverConfig, Result, ServerCapabilities, + StandardSchemaV1, SubscribeRequest, Tool, Transport, @@ -59,6 +63,8 @@ import { Protocol, ProtocolError, ProtocolErrorCode, + resolveInputRequiredDriverConfig, + runInputRequiredFlow, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; @@ -178,6 +184,31 @@ export type ClientOptions = ProtocolOptions & { */ versionNegotiation?: VersionNegotiationOptions; + /** + * Multi-round-trip auto-fulfilment (protocol revision 2026-07-28). + * + * On the 2026-07-28 era, servers obtain client input (elicitation, + * sampling, roots) by answering `tools/call`, `prompts/get`, or + * `resources/read` with an `input_required` result instead of sending a + * server→client request. By default the client fulfils those embedded + * requests automatically through the SAME handlers registered via + * {@linkcode Client.setRequestHandler | setRequestHandler} (e.g. + * `elicitation/create`), then retries the original call with the + * collected `inputResponses` and a byte-exact echo of the opaque + * `requestState`, on a fresh request id, up to `maxRounds` rounds. + * `client.callTool()` (and its siblings) keep returning their plain + * result type — the interactive rounds happen inside the call. + * + * Set `autoFulfill: false` for manual mode: an `input_required` response + * then surfaces as a typed error unless the individual call passes + * `allowInputRequired: true` (pair it with `withInputRequired()` on the + * explicit-schema path to type both outcomes). + * + * Has no effect on 2025-era connections, which have no `input_required` + * vocabulary. + */ + inputRequired?: InputRequiredOptions; + /** * Configure handlers for list changed notifications (tools, prompts, resources). * @@ -253,6 +284,7 @@ export class Client extends Protocol { private _enforceStrictCapabilities: boolean; private _versionNegotiation?: VersionNegotiationOptions; private _supportedProtocolVersionsOption?: string[]; + private _inputRequiredDriverConfig: ResolvedInputRequiredDriverConfig; /** * Initializes this client with the given name and version information. @@ -267,6 +299,9 @@ export class Client extends Protocol { this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; this._versionNegotiation = options?.versionNegotiation; this._supportedProtocolVersionsOption = options?.supportedProtocolVersions; + // Multi-round-trip auto-fulfilment driver (2026-07-28): on by default, + // configurable via ClientOptions.inputRequired. + this._inputRequiredDriverConfig = resolveInputRequiredDriverConfig(options?.inputRequired); // Store list changed config for setup after connection (when we know server capabilities) if (options?.listChanged) { @@ -299,6 +334,42 @@ export class Client extends Protocol { return undefined; } + /** + * Wires the multi-round-trip auto-fulfilment engine (protocol revision + * 2026-07-28) into the response funnel: an `input_required` answer is + * fulfilled through the registered elicitation/sampling/roots handlers + * and the original request retried via `flow.retry`, up to + * `inputRequired.maxRounds` rounds. With auto-fulfilment disabled the + * response surfaces as a typed error steering to manual mode. + */ + protected override _resolveNonCompleteResult( + decoded: { kind: 'input_required'; inputRequests: Record; requestState?: string }, + flow: NonCompleteResultFlow + ): Promise { + if (!this._inputRequiredDriverConfig.autoFulfill) { + return Promise.reject( + new SdkError( + SdkErrorCode.UnsupportedResultType, + `Unsupported result type 'input_required' for ${flow.request.method}: ` + + `multi-round-trip auto-fulfilment is not enabled on this instance — ` + + `pass allowInputRequired: true to handle it manually, or enable inputRequired.autoFulfill`, + { resultType: 'input_required', method: flow.request.method } + ) + ); + } + return runInputRequiredFlow( + { + getRequestHandler: method => + this._getRequestHandler(method) as ((request: JSONRPCRequest, ctx: unknown) => Promise) | undefined, + buildContext: baseCtx => this.buildContext(baseCtx, undefined), + sessionId: this.transport?.sessionId + }, + this._inputRequiredDriverConfig, + decoded, + flow + ); + } + /** * Set up handlers for list changed notifications based on config and server capabilities. * This should only be called after initialization when server capabilities are known. @@ -352,14 +423,16 @@ export class Client extends Protocol { if (method === 'elicitation/create') { return async (request, ctx) => { // Era-exact validation: the schemas are resolved from the - // instance era at dispatch time (the era gate guarantees the - // method exists on the serving era before we get here). + // instance era at dispatch time. On the 2025 era the method + // is a wire request (registry schemas); on the 2026 era it is + // in-band vocabulary reached only via the multi-round-trip + // driver, so the in-band schemas apply. const codec = codecForVersion(this._negotiatedProtocolVersion); - const elicitRequestSchema = codec.requestSchema('elicitation/create'); + const elicitRequestSchema = codec.requestSchema('elicitation/create') ?? codec.inputRequestSchema('elicitation/create'); // The era registry entry IS the plain ElicitResult schema // (the result map is aligned to the typed map — no widened // unions), so no narrower surface is needed. - const elicitResultSchema = codec.resultSchema('elicitation/create'); + const elicitResultSchema = codec.resultSchema('elicitation/create') ?? codec.inputResponseSchema('elicitation/create'); if (!elicitRequestSchema || !elicitResultSchema) { throw new ProtocolError(ProtocolErrorCode.InternalError, 'No wire schema for elicitation/create in the resolved era'); } @@ -416,9 +489,13 @@ export class Client extends Protocol { if (method === 'sampling/createMessage') { return async (request, ctx) => { - // Era-exact validation via the instance era (see above). + // Era-exact validation via the instance era (see above): wire + // request schema on the 2025 era, in-band schema on the 2026 + // era (where sampling reaches the handler only as an embedded + // input request). const codec = codecForVersion(this._negotiatedProtocolVersion); - const samplingRequestSchema = codec.requestSchema('sampling/createMessage'); + const wireSamplingRequestSchema = codec.requestSchema('sampling/createMessage'); + const samplingRequestSchema = wireSamplingRequestSchema ?? codec.inputRequestSchema('sampling/createMessage'); if (!samplingRequestSchema) { throw new ProtocolError( ProtocolErrorCode.InternalError, @@ -436,13 +513,28 @@ export class Client extends Protocol { const result = await handler(request, ctx); - // The result schema depends on the REQUEST params (tools vs - // no tools) — something a method-keyed registry entry cannot - // express, so the pair is picked here. The era gate keeps - // this era-correct: sampling/createMessage is only ever - // dispatched on an era whose registry defines it. + // The result-side schema mirrors the request-side selection so + // both stay on the same era's vocabulary. On the 2025 era the + // schema depends on the REQUEST params (tools vs no tools) — + // something a method-keyed registry entry cannot express, so + // the pair is picked here. When the request schema came from + // the in-band fallback (2026 era, where sampling reaches the + // handler only as an embedded input request), the embedded + // response schema applies — it covers plain and tool-bearing + // responses alike. const hasTools = params.tools || params.toolChoice; - const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema; + const resultSchema = + wireSamplingRequestSchema === undefined + ? codec.inputResponseSchema('sampling/createMessage') + : hasTools + ? CreateMessageResultWithToolsSchema + : CreateMessageResultSchema; + if (!resultSchema) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + 'No result schema for sampling/createMessage in the resolved era' + ); + } const validationResult = parseSchema(resultSchema, result); if (!validationResult.success) { const errorMessage = diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b74b37033..0c9e2a22f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,6 +5,9 @@ export * from './shared/authUtils.js'; export * from './shared/clientCapabilityRequirements.js'; export * from './shared/envelope.js'; export * from './shared/inboundClassification.js'; +export * from './shared/inputRequired.js'; +export * from './shared/inputRequiredDriver.js'; +export * from './shared/inputRequiredEngine.js'; export * from './shared/metadataUtils.js'; export * from './shared/protocol.js'; export * from './shared/protocolEras.js'; diff --git a/packages/core/src/shared/inputRequiredDriver.ts b/packages/core/src/shared/inputRequiredDriver.ts index 03810ae8f..7142f2017 100644 --- a/packages/core/src/shared/inputRequiredDriver.ts +++ b/packages/core/src/shared/inputRequiredDriver.ts @@ -101,14 +101,17 @@ export interface InputRequiredRetryLegOptions { maxTotalTimeout?: number; } -/** The hooks the protocol layer provides to the driver. */ +/** The hooks the engine provides to the driver. */ export interface InputRequiredDriverHooks { /** * Dispatches one embedded input request to the locally registered handler * and resolves with the bare response value. Rejections fail the whole * call (typed errors: unknown kind, missing handler, handler failure). + * The signal is the per-round abort: when one sibling fails (or the + * caller aborts the originating call) the remaining dispatches are + * cancelled. */ - dispatchInputRequest(key: string, entry: unknown): Promise; + dispatchInputRequest(key: string, entry: unknown, signal: AbortSignal): Promise; /** * Re-issues the original request with the given params on a fresh request @@ -138,8 +141,44 @@ export function buildInputRequiredRetryParams( }; } -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); +/** + * Abortable delay: resolves after `ms`, or rejects with the signal's reason + * (wrapped in an `SdkError` when it isn't already one) if the signal aborts + * first. Aborting after resolution is a no-op. + */ +function sleep(ms: number, signal: AbortSignal | undefined): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason instanceof SdkError ? signal.reason : new SdkError(SdkErrorCode.RequestTimeout, String(signal.reason))); + return; + } + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = (): void => { + clearTimeout(timer); + reject(signal?.reason instanceof SdkError ? signal.reason : new SdkError(SdkErrorCode.RequestTimeout, String(signal?.reason))); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +/** + * A per-round abort linked to the caller's signal: the embedded sibling + * dispatches share it, so the first failure (or a caller abort) cancels the + * others instead of leaving them running. + */ +function linkedRoundAbort(outer: AbortSignal | undefined): { signal: AbortSignal; abort: (reason: unknown) => void; dispose: () => void } { + const controller = new AbortController(); + const onOuterAbort = (): void => controller.abort(outer?.reason); + outer?.addEventListener('abort', onOuterAbort, { once: true }); + if (outer?.aborted) controller.abort(outer.reason); + return { + signal: controller.signal, + abort: reason => controller.abort(reason), + dispose: () => outer?.removeEventListener('abort', onOuterAbort) + }; } /** @@ -159,9 +198,11 @@ export async function runInputRequiredDriver(args: { firstPayload: InputRequiredPayload; requestOptions: InputRequiredDriverRequestOptions; hooks: InputRequiredDriverHooks; + /** The originating call's abort signal — chains through every round and the pacing sleep. */ + signal?: AbortSignal; flowStartedAt?: number; }): Promise { - const { config, method, originalParams, requestOptions, hooks } = args; + const { config, method, originalParams, requestOptions, hooks, signal } = args; const startedAt = args.flowStartedAt ?? Date.now(); let payload = args.firstPayload; let round = 0; @@ -192,15 +233,29 @@ export async function runInputRequiredDriver(args: { let responses: Record | undefined; if (entries.length > 0) { // Fulfil concurrently (the embedded requests are independent); a - // single failure fails the call. - const fulfilled = await Promise.all( - entries.map(async ([key, entry]) => [key, await hooks.dispatchInputRequest(key, entry)] as const) - ); - responses = Object.fromEntries(fulfilled); + // single failure fails the call AND aborts the siblings via the + // linked per-round signal so they do not keep running. + const round = linkedRoundAbort(signal); + try { + const fulfilled = await Promise.all( + entries.map(async ([key, entry]) => { + try { + return [key, await hooks.dispatchInputRequest(key, entry, round.signal)] as const; + } catch (error) { + round.abort(error); + throw error; + } + }) + ); + responses = Object.fromEntries(fulfilled); + } finally { + round.dispose(); + } } else { // requestState-only (load-shedding) leg: fixed pacing so the loop - // never hot-spins; counted in the same round cap. - await sleep(REQUEST_STATE_ONLY_LEG_PACING_MS); + // never hot-spins; counted in the same round cap. The sleep + // honors the caller's abort signal. + await sleep(REQUEST_STATE_ONLY_LEG_PACING_MS, signal); } const legOptions: InputRequiredRetryLegOptions = { diff --git a/packages/core/src/shared/inputRequiredEngine.ts b/packages/core/src/shared/inputRequiredEngine.ts new file mode 100644 index 000000000..f71c8d471 --- /dev/null +++ b/packages/core/src/shared/inputRequiredEngine.ts @@ -0,0 +1,238 @@ +/** + * The multi-round-trip auto-fulfilment ENGINE (protocol revision 2026-07-28): + * the wiring between the protocol layer's response funnel, the + * already-registered input handlers, and the pure {@link runInputRequiredDriver} + * loop. The engine is what the `Client` plugs into the funnel's + * `_resolveNonCompleteResult` extension point — `Protocol` itself only knows + * the input-required branch exists. + * + * Relocated here so the shared `Protocol` base stays generic: the only + * MRTR-specific code that remains in `protocol.ts` is the irreducible + * input-required branch in the response path, the type surface (the + * `allowInputRequired` request option and the `inputResponses`/`requestState`/ + * `droppedInputResponseKeys` context fields), and the named extension point. + */ +import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js'; +import type { InputRequiredResult, JSONRPCRequest, RequestMeta, Result } from '../types/types.js'; +import type { StandardSchemaV1 } from '../util/standardSchema.js'; +import type { WireCodec } from '../wire/codec.js'; +import type { + InputRequiredDriverHooks, + InputRequiredPayload, + InputRequiredRetryLegOptions, + ResolvedInputRequiredDriverConfig +} from './inputRequiredDriver.js'; +import { runInputRequiredDriver } from './inputRequiredDriver.js'; +import type { BaseContext, NonCompleteResultFlow, RequestOptions } from './protocol.js'; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Splits a retried request's `inputResponses` map into the BARE response + * entries the spec defines and everything else. The spec's embedded responses + * are the bare result objects (an `ElicitResult`, `CreateMessageResult`, or + * `ListRootsResult`); a wrapped `{method, result}` envelope (a shape some + * peers emit) is never accepted as a response — its key is recorded so the + * handler can re-issue the corresponding input request. + */ +export function partitionInputResponses(inputResponses: unknown): { accepted: Record; droppedKeys: string[] } { + const accepted: Record = {}; + const droppedKeys: string[] = []; + if (!isPlainObject(inputResponses)) { + return { accepted, droppedKeys }; + } + for (const [key, entry] of Object.entries(inputResponses)) { + // Bare responses never carry `method` or `result` members — both are + // the signature of the wrapped (JSON-RPC-shaped) form. + if (!isPlainObject(entry) || 'method' in entry || 'result' in entry) { + droppedKeys.push(key); + continue; + } + accepted[key] = entry; + } + return { accepted, droppedKeys }; +} + +/** + * Related send/notify are unavailable inside an embedded input-request + * handler: the request is fulfilled locally by the multi-round-trip driver, + * so there is no live peer request to relate messages to. + */ +function relatedMessagingUnavailable(member: string): never { + throw new SdkError( + SdkErrorCode.SendFailed, + `ctx.mcpReq.${member} is not available while fulfilling an embedded input request: ` + + `the request is fulfilled locally and has no related peer request` + ); +} + +/** + * The synthesized {@linkcode BaseContext} for an embedded input request: the + * id is the `inputRequests` key (correlation only — it is not a JSON-RPC + * message id), the supplied abort signal chains the originating call's signal + * through, and related `send`/`notify` are unavailable because there is no + * live peer request to relate them to. + */ +export function synthesizeInputRequestContext( + key: string, + method: string, + params: Record | undefined, + signal: AbortSignal, + sessionId: string | undefined +): BaseContext { + return { + sessionId, + mcpReq: { + id: key, + method, + _meta: params?.['_meta'] as RequestMeta | undefined, + signal, + send: (() => relatedMessagingUnavailable('send')) as BaseContext['mcpReq']['send'], + notify: () => relatedMessagingUnavailable('notify') + } + }; +} + +/** + * Hooks the engine needs from the consuming role class (the `Client`): how to + * look up a registered handler and how to enrich a base context. + */ +export interface InputRequiredEngineHost { + /** The handler registered for the given method, or `undefined`. */ + getRequestHandler(method: string): ((request: JSONRPCRequest, ctx: unknown) => Promise) | undefined; + /** Builds the role-specific context from a {@linkcode BaseContext}. */ + buildContext(baseCtx: BaseContext): unknown; + /** The transport's session identifier, when there is one. */ + sessionId: string | undefined; +} + +/** + * Dispatches one embedded (de-JSON-RPC'd) input request to the locally + * registered handler for its method and resolves with the bare response. + * + * The handler runs through the same stored handler chain as a wire request + * (including role-specific validation installed by `_wrapHandler`), with a + * synthesized context (see {@link synthesizeInputRequestContext}). + */ +export async function dispatchInputRequest( + host: InputRequiredEngineHost, + codec: WireCodec, + key: string, + entry: unknown, + signal: AbortSignal +): Promise { + if (!isPlainObject(entry) || typeof entry['method'] !== 'string') { + throw new SdkError( + SdkErrorCode.InvalidResult, + `Invalid input request '${key}': each inputRequests entry must be an embedded request object with a method`, + { key } + ); + } + const method = entry['method']; + if (codec.inputRequestSchema(method) === undefined) { + throw new SdkError( + SdkErrorCode.InvalidResult, + `Invalid input request '${key}': '${method}' is not an embedded request the ${codec.era} revision defines ` + + `(expected elicitation/create, sampling/createMessage, or roots/list)`, + { key, method } + ); + } + const handler = host.getRequestHandler(method); + if (handler === undefined) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Cannot fulfil input request '${key}': no handler is registered for '${method}' on this client. ` + + `Declare the corresponding capability and register a handler, or handle input_required results manually.`, + { key, method } + ); + } + + const params = isPlainObject(entry['params']) ? (entry['params'] as Record) : undefined; + const synthesizedRequest: JSONRPCRequest = { + jsonrpc: '2.0', + id: key, + method, + ...(params !== undefined && { params }) + }; + const ctx = host.buildContext(synthesizeInputRequestContext(key, method, params, signal, host.sessionId)); + return await handler(synthesizedRequest, ctx); +} + +/** + * Builds the per-retry-leg {@linkcode RequestOptions} from the originating + * call's options. + * + * Only the fields that are correct to apply to every leg carry over (a + * deliberate whitelist): the per-leg `timeout`, the (shrinking) total budget + * `maxTotalTimeout`, the caller's `onprogress`/`resetTimeoutOnProgress`, and + * the caller's abort `signal`. Everything else — in particular + * `relatedRequestId`, `resumptionToken`, and `onresumptiontoken` — is scoped + * to the originating wire leg and is NOT inherited by retries. + */ +export function buildRetryLegRequestOptions(options: RequestOptions | undefined, legOptions: InputRequiredRetryLegOptions): RequestOptions { + return { + ...(options?.signal !== undefined && { signal: options.signal }), + ...(options?.onprogress !== undefined && { onprogress: options.onprogress }), + ...(options?.resetTimeoutOnProgress !== undefined && { resetTimeoutOnProgress: options.resetTimeoutOnProgress }), + ...(legOptions.timeout !== undefined && { timeout: legOptions.timeout }), + ...(legOptions.maxTotalTimeout !== undefined && { maxTotalTimeout: legOptions.maxTotalTimeout }), + // The driver re-enters the funnel with the manual primitive: a further + // input_required answer is handed back to the loop instead of + // recursing into another driver run (the round cap is global to the + // flow). + allowInputRequired: true + }; +} + +/** + * Runs the auto-fulfilment flow for one originating request whose response + * came back as `input_required`: builds the driver hooks (embedded-request + * dispatch + retry through the funnel) and hands them to + * {@link runInputRequiredDriver}. Resolves with the final complete result + * (already validated by the retry leg) or rejects with a typed error. + */ +export function runInputRequiredFlow( + host: InputRequiredEngineHost, + config: ResolvedInputRequiredDriverConfig, + decoded: { inputRequests: Record; requestState?: string }, + flow: NonCompleteResultFlow +): Promise { + const { codec, request, options, flowStartedAt } = flow; + const firstPayload: InputRequiredPayload = { + inputRequests: decoded.inputRequests, + ...(decoded.requestState !== undefined && { requestState: decoded.requestState }) + }; + const hooks: InputRequiredDriverHooks = { + dispatchInputRequest: (key, entry, signal) => dispatchInputRequest(host, codec, key, entry, signal), + retry: (params, legOptions) => flow.retry(params, buildRetryLegRequestOptions(options, legOptions)) + }; + return runInputRequiredDriver({ + config, + method: request.method, + originalParams: request.params, + firstPayload, + flowStartedAt, + signal: options?.signal, + requestOptions: { + ...(options?.timeout !== undefined && { timeout: options.timeout }), + ...(options?.maxTotalTimeout !== undefined && { maxTotalTimeout: options.maxTotalTimeout }), + ...(options?.onprogress !== undefined && { onprogress: options.onprogress }) + }, + hooks + }); +} + +/** + * Builds the manual-mode {@linkcode InputRequiredResult} value from the + * codec's decoded payload — what an `allowInputRequired: true` caller + * receives instead of the auto-fulfilled complete result. + */ +export function manualInputRequiredValue(decoded: { inputRequests: Record; requestState?: string }): InputRequiredResult { + return { + resultType: 'input_required', + inputRequests: decoded.inputRequests as InputRequiredResult['inputRequests'], + ...(decoded.requestState !== undefined && { requestState: decoded.requestState }) + }; +} diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 8feffae17..d60bfc423 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -9,6 +9,7 @@ import type { ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, + HandlerResultTypeMap, JSONRPCErrorResponse, JSONRPCNotification, JSONRPCRequest, @@ -49,6 +50,7 @@ import { isStandardSchema, validateStandardSchema } from '../util/standardSchema import { bootstrapOutboundCodec } from '../wire/bootstrap.js'; import type { LiftedWireMaterial, WireCodec } from '../wire/codec.js'; import { classifiedWireEra, codecForVersion, isSpecNotificationMethod, isSpecRequestMethod } from '../wire/codec.js'; +import { manualInputRequiredValue, partitionInputResponses } from './inputRequiredEngine.js'; import type { Transport, TransportSendOptions } from './transport.js'; /** @@ -124,10 +126,46 @@ export type RequestOptions = { * Maximum total time (in milliseconds) to wait for a response. * If exceeded, an {@linkcode SdkError} with code {@linkcode SdkErrorCode.RequestTimeout} will be raised, regardless of progress notifications. * If not specified, there is no maximum total timeout. + * + * For multi-round-trip requests fulfilled by the auto-fulfilment driver + * (protocol revision 2026-07-28), the budget bounds the WHOLE flow: every + * retry leg is given only the time remaining. */ maxTotalTimeout?: number; + + /** + * Manual multi-round-trip mode for this call (protocol revision + * 2026-07-28): when the response is an `input_required` result, hand it + * back to the caller instead of auto-fulfilling it (or raising a typed + * error). The resolved value is the neutral input-required shape + * (`resultType: 'input_required'`, `inputRequests?`, `requestState?`); + * wrap the result schema with `withInputRequired()` on the explicit + * schema path to type both outcomes. The caller is then responsible for + * gathering the requested input and retrying the original request with + * `inputResponses` / `requestState` params and a fresh request. + * + * Default: `false`. + */ + allowInputRequired?: boolean; } & TransportSendOptions; +/** + * Flow context handed to {@linkcode Protocol._resolveNonCompleteResult}: the + * originating request, its options, the wire codec that decoded the response, + * the timestamp the originating leg was issued at (for whole-flow timeout + * accounting), and a `retry` closure that re-enters the request funnel with + * fresh params on a fresh request id. + */ +export interface NonCompleteResultFlow { + codec: WireCodec; + request: Request; + resultSchema: T; + options: RequestOptions | undefined; + flowStartedAt: number; + /** Re-issue the originating request with the given params and per-leg options. */ + retry(params: Record | undefined, legOptions: RequestOptions): Promise; +} + /** * Options that can be given per notification. */ @@ -255,14 +293,36 @@ export type BaseContext = { /** * Multi-round-trip input responses carried by a retried request * (protocol revision 2026-07-28), lifted out of the params the - * handler sees. Driver material — present verbatim when sent. + * handler sees. Entries are the BARE response objects keyed by the + * identifiers the server assigned in `inputRequests`; entries that do + * not look like bare responses (e.g. a `{method, result}` wrapper) + * are dropped and their keys recorded in `droppedInputResponseKeys`. + * + * The values arrive from the client and are NOT validated by the SDK + * — treat them as untrusted input. */ inputResponses?: Record; + /** + * Keys of `inputResponses` entries the SDK dropped because they were + * not bare response objects (for example the wrapped `{method, + * result}` shape some peers emit). Surfaced so a handler can re-issue + * the corresponding input request rather than hard-fail. + */ + droppedInputResponseKeys?: string[]; + /** * Multi-round-trip request state echoed by a retried request * (protocol revision 2026-07-28), lifted out of the params the * handler sees. Driver material — present verbatim when sent. + * + * SECURITY: `requestState` round-trips through the client and MUST be + * treated as attacker-controlled input. The SDK applies no integrity + * protection: if this value influences authorization, resource + * access, or business logic, the server MUST integrity-protect it + * (e.g. HMAC or AEAD) when minting it and MUST verify it here, + * rejecting state that fails verification (spec: + * basic/patterns/mrtr, server requirements 4–5). */ requestState?: string; @@ -509,6 +569,45 @@ export abstract class Protocol { return undefined; } + /** + * Extension point for non-`complete` decoded results in the response + * funnel: a result the wire codec discriminated into a kind other than + * `'complete'` or `'invalid'` is handed here for the role class to + * resolve. The base default surfaces it as a typed + * {@linkcode SdkErrorCode.UnsupportedResultType} error (no retry). + * + * Intended consumers (named so the seam stays accountable): + * - the `Client`'s multi-round-trip auto-fulfilment engine, which fulfils + * `'input_required'` results through the registered + * elicitation/sampling/roots handlers and retries via `flow.retry`; + * - a future client-side terminal-result handler for + * `subscriptions/listen`, when the spec defines one. + * + * `Server` instances never receive `input_required` responses on their + * outbound legs and leave the base behavior in place. + */ + protected _resolveNonCompleteResult( + decoded: ReturnType & { kind: 'input_required' }, + flow: NonCompleteResultFlow + ): Promise { + return Promise.reject( + new SdkError(SdkErrorCode.UnsupportedResultType, `Unsupported result type '${decoded.kind}' for ${flow.request.method}`, { + resultType: decoded.kind, + method: flow.request.method + }) + ); + } + + /** + * Protected accessor for a registered request handler. Used by role + * classes that dispatch synthesized requests through the same stored + * handler chain (e.g. the `Client` fulfilling an embedded multi-round-trip + * input request). + */ + protected _getRequestHandler(method: string): ((request: JSONRPCRequest, ctx: ContextT) => Promise) | undefined { + return this._requestHandlers.get(method); + } + private async _oncancel(notification: CancelledNotification): Promise { if (!notification.params.requestId) { return; @@ -817,6 +916,13 @@ export abstract class Protocol { const abortController = new AbortController(); this._requestHandlerAbortControllers.set(request.id, abortController); + // Multi-round-trip retry material: only BARE response objects are + // surfaced to the handler; entries that look like a wrapped + // `{method, result}` shape (or are not objects at all) are dropped + // and their keys recorded so the handler can re-issue the input + // request instead of hard-failing (D-059 posture). + const partitionedInputResponses = lifted.inputResponses === undefined ? undefined : partitionInputResponses(lifted.inputResponses); + const baseCtx: BaseContext = { sessionId: capturedTransport?.sessionId, mcpReq: { @@ -824,7 +930,11 @@ export abstract class Protocol { method: request.method, _meta: request.params?._meta, ...(lifted.envelope !== undefined && { envelope: lifted.envelope }), - ...(lifted.inputResponses !== undefined && { inputResponses: lifted.inputResponses }), + ...(partitionedInputResponses !== undefined && { inputResponses: partitionedInputResponses.accepted }), + ...(partitionedInputResponses !== undefined && + partitionedInputResponses.droppedKeys.length > 0 && { + droppedInputResponseKeys: partitionedInputResponses.droppedKeys + }), ...(lifted.requestState !== undefined && { requestState: lifted.requestState }), signal: abortController.signal, // BaseContext.mcpReq.send is declared with two overloads (spec-method-keyed and explicit-schema). Arrow @@ -1103,6 +1213,10 @@ export abstract class Protocol { options?: RequestOptions ): Promise> { const { relatedRequestId, resumptionToken, onresumptiontoken } = options ?? {}; + // Flow start for non-complete result resolution: `maxTotalTimeout` + // bounds the WHOLE flow, so the budget is measured from the original + // request, not from when an extension takes over after the first leg. + const flowStartedAt = Date.now(); let onAbort: (() => void) | undefined; let cleanupMessageId: number | undefined; @@ -1209,15 +1323,30 @@ export abstract class Protocol { return reject(decoded.error); } if (decoded.kind === 'input_required') { - // Driver seam: the multi-round-trip driver (M4.1) - // consumes this payload; until it lands, surface the - // discriminated kind as a typed local error, no retry. - return reject( - new SdkError(SdkErrorCode.UnsupportedResultType, `Unsupported result type 'input_required' for ${request.method}`, { - resultType: 'input_required', - method: request.method - }) - ); + // Manual mode (the primitive any driver layers over): + // hand the input-required value back to the caller. + if (options?.allowInputRequired === true) { + return resolve(manualInputRequiredValue(decoded) as StandardSchemaV1.InferOutput); + } + // Non-complete result extension point: the role class may + // resolve the flow itself (the Client wires the + // multi-round-trip auto-fulfilment engine here). The base + // default is the typed UnsupportedResultType error. + const flow: NonCompleteResultFlow = { + codec, + request, + resultSchema, + options, + flowStartedAt, + retry: (params, legOptions) => + this._requestWithSchemaViaCodec( + codec, + params === undefined ? { method: request.method } : { method: request.method, params }, + resultSchema, + legOptions + ) + }; + return resolve(this._resolveNonCompleteResult(decoded, flow) as Promise>); } const result = decoded.result; @@ -1348,7 +1477,7 @@ export abstract class Protocol { */ setRequestHandler( method: M, - handler: (request: RequestTypeMap[M], ctx: ContextT) => ResultTypeMap[M] | Promise + handler: (request: RequestTypeMap[M], ctx: ContextT) => HandlerResultTypeMap[M] | Promise ): void; setRequestHandler

( method: string, @@ -1373,9 +1502,14 @@ export abstract class Protocol { // Dispatch-time schema resolution: the request is parsed with the // schema of the era serving this connection (the instance era at // dispatch time), never with a schema captured at registration - // time. + // time. On the 2026-07-28 era the demoted server→client methods + // (elicitation/sampling/roots) are not wire request methods — + // they reach a handler only as embedded input requests dispatched + // by the multi-round-trip driver, and parse with the era's + // in-band schema instead. stored = (request, ctx) => { - const schema = this._negotiatedWireCodec().requestSchema(method); + const dispatchCodec = this._negotiatedWireCodec(); + const schema = dispatchCodec.requestSchema(method) ?? dispatchCodec.inputRequestSchema(method); if (!schema) { // Unreachable: the dispatch era gate rejects era-mismatched // spec methods with −32601 before any handler runs. diff --git a/packages/core/test/shared/inputRequiredDriver.test.ts b/packages/core/test/shared/inputRequiredDriver.test.ts index a5435d1c9..6f4931106 100644 --- a/packages/core/test/shared/inputRequiredDriver.test.ts +++ b/packages/core/test/shared/inputRequiredDriver.test.ts @@ -248,4 +248,59 @@ describe('driver loop', () => { }); expect(progress).toEqual([1]); }); + + test('a failing embedded dispatch aborts its sibling dispatches via the per-round signal', async () => { + let siblingSignal: AbortSignal | undefined; + const siblingSettled = vi.fn(); + const outcome = runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'tools/call', + originalParams: { name: 't' }, + firstPayload: { inputRequests: { fail: ELICIT_ENTRY, slow: ELICIT_ENTRY } }, + requestOptions: {}, + hooks: { + dispatchInputRequest: (key, _entry, signal) => { + if (key === 'fail') { + return Promise.reject(new SdkError(SdkErrorCode.CapabilityNotSupported, 'no handler')); + } + siblingSignal = signal; + return new Promise((resolve, reject) => { + signal.addEventListener('abort', () => { + siblingSettled(); + reject(signal.reason); + }); + }); + }, + retry: () => Promise.resolve({ content: [] }) + } + }); + await expect(outcome).rejects.toMatchObject({ code: SdkErrorCode.CapabilityNotSupported }); + // The sibling was aborted via the linked per-round signal — it did not + // keep running after the first failure. + expect(siblingSignal?.aborted).toBe(true); + expect(siblingSettled).toHaveBeenCalledOnce(); + }); + + test('the requestState-only pacing sleep honors the caller abort signal', async () => { + const controller = new AbortController(); + const outcome = runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'tools/call', + originalParams: { name: 't' }, + firstPayload: { inputRequests: {}, requestState: 'opaque' }, + requestOptions: {}, + signal: controller.signal, + hooks: { + dispatchInputRequest: () => Promise.resolve({}), + retry: () => Promise.resolve({ content: [] }) + } + }); + // Abort while the loop is in the 250 ms pacing sleep — the call must + // settle without waiting it out. + const aborted = new SdkError(SdkErrorCode.RequestTimeout, 'aborted'); + controller.abort(aborted); + const start = Date.now(); + await expect(outcome).rejects.toBe(aborted); + expect(Date.now() - start).toBeLessThan(REQUEST_STATE_ONLY_LEG_PACING_MS); + }); }); diff --git a/packages/core/test/shared/inputRequiredEngine.test.ts b/packages/core/test/shared/inputRequiredEngine.test.ts new file mode 100644 index 000000000..af4f9eeed --- /dev/null +++ b/packages/core/test/shared/inputRequiredEngine.test.ts @@ -0,0 +1,77 @@ +/** + * The multi-round-trip auto-fulfilment engine wiring (the layer between the + * funnel hook and the driver loop): the per-retry-leg request-options + * whitelist, the input-responses partition, and the synthesized embedded + * dispatch context. + */ +import { describe, expect, test } from 'vitest'; + +import { + buildRetryLegRequestOptions, + partitionInputResponses, + synthesizeInputRequestContext +} from '../../src/shared/inputRequiredEngine.js'; + +describe('per-retry-leg request options whitelist', () => { + test('only the whitelisted fields carry over — resumption tokens and the related-request id never do', () => { + const controller = new AbortController(); + const onprogress = (): void => undefined; + const onresumptiontoken = (): void => undefined; + const built = buildRetryLegRequestOptions( + { + signal: controller.signal, + onprogress, + resetTimeoutOnProgress: true, + timeout: 9_999, + maxTotalTimeout: 99_999, + relatedRequestId: 'outer', + resumptionToken: 'tok-123', + onresumptiontoken + }, + { timeout: 5_000, maxTotalTimeout: 60_000 } + ); + expect(built).toEqual({ + signal: controller.signal, + onprogress, + resetTimeoutOnProgress: true, + timeout: 5_000, + maxTotalTimeout: 60_000, + allowInputRequired: true + }); + // The originating call's transport-send options are scoped to the + // originating wire leg only. + expect('resumptionToken' in built).toBe(false); + expect('onresumptiontoken' in built).toBe(false); + expect('relatedRequestId' in built).toBe(false); + }); + + test('absent caller options yield only the manual primitive opt-in', () => { + expect(buildRetryLegRequestOptions(undefined, {})).toEqual({ allowInputRequired: true }); + }); +}); + +describe('inputResponses partition', () => { + test('bare entries are accepted; wrapped {method, result} entries and non-objects are dropped by key', () => { + const { accepted, droppedKeys } = partitionInputResponses({ + confirm: { action: 'accept', content: { ok: true } }, + wrapped: { method: 'elicitation/create', result: { action: 'accept' } }, + bad: 7 + }); + expect(accepted).toEqual({ confirm: { action: 'accept', content: { ok: true } } }); + expect(droppedKeys.sort()).toEqual(['bad', 'wrapped']); + }); +}); + +describe('synthesized embedded dispatch context', () => { + test('id is the inputRequests key, the supplied signal chains through, and related send/notify are unavailable', () => { + const controller = new AbortController(); + const ctx = synthesizeInputRequestContext('confirm', 'elicitation/create', { _meta: { x: 1 } }, controller.signal, 'sess-1'); + expect(ctx.mcpReq.id).toBe('confirm'); + expect(ctx.mcpReq.method).toBe('elicitation/create'); + expect(ctx.mcpReq.signal).toBe(controller.signal); + expect(ctx.sessionId).toBe('sess-1'); + expect(() => ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 0, progress: 1 } })).toThrowError( + /not available while fulfilling an embedded input request/ + ); + }); +}); diff --git a/test/e2e/scenarios/raw-result-type.test.ts b/test/e2e/scenarios/raw-result-type.test.ts index ee5b45476..230733698 100644 --- a/test/e2e/scenarios/raw-result-type.test.ts +++ b/test/e2e/scenarios/raw-result-type.test.ts @@ -129,11 +129,13 @@ verifies('typescript:client:raw-result-type-first', async ({ transport }: TestAr // ---- Modern negotiation: the client pins the draft revision, the relay // advertises it via server/discover → 2026 era → V-1 discrimination in - // the codec. ---- + // the codec. Auto-fulfilment is disabled here so this requirement keeps + // proving the discrimination surface itself (the typed local error); the + // multi-round-trip driver has its own requirements (typescript:mrtr:*). ---- { const client = new Client( { name: 'raw-result-type-client', version: '0' }, - { versionNegotiation: { mode: { pin: '2026-07-28' } } } + { versionNegotiation: { mode: { pin: '2026-07-28' } }, inputRequired: { autoFulfill: false } } ); await (transport === 'inMemory' ? connectInMemory(client, INPUT_REQUIRED_BODY)