Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/mrtr-client-engine.md
Original file line number Diff line number Diff line change
@@ -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.
75 changes: 63 additions & 12 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
GetPromptRequest,
GetPromptResult,
Implementation,
InputRequiredOptions,
JSONRPCNotification,
JSONRPCRequest,
JsonSchemaType,
Expand Down Expand Up @@ -59,6 +60,7 @@
Protocol,
ProtocolError,
ProtocolErrorCode,
resolveInputRequiredDriverConfig,
SdkError,
SdkErrorCode
} from '@modelcontextprotocol/core';
Expand Down Expand Up @@ -178,6 +180,31 @@
*/
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).
*
Expand Down Expand Up @@ -267,6 +294,9 @@
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);

Check failure on line 299 in packages/client/src/client/client.ts

View check run for this annotation

Claude / Claude Code Review

docs/migration.md still says input_required rejects — contradicts default-on auto-fulfilment

The migration guide (docs/migration.md, not touched by this PR) still documents the old input_required behavior and now contradicts the shipped default. Line 921 says input_required 'rejects with a typed local error — SdkErrorCode.UnsupportedResultType ... Full multi-round-trip support will replace that error arm', and line 950 says 'input_required / unknown kinds reject with UnsupportedResultType / InvalidResult' — but this PR sets DEFAULT_INPUT_REQUIRED_AUTO_FULFILL = true, so on 2026-07-28 co
Comment on lines +297 to +299

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The migration guide (docs/migration.md, not touched by this PR) still documents the old input_required behavior and now contradicts the shipped default. Line 921 says input_required 'rejects with a typed local error — SdkErrorCode.UnsupportedResultType ... Full multi-round-trip support will replace that error arm', and line 950 says 'input_required / unknown kinds reject with UnsupportedResultType / InvalidResult' — but this PR sets DEFAULT_INPUT_REQUIRED_AUTO_FULFILL = true, so on 2026-07-28 connections input_required is now auto-fulfilled by default and only rejects when autoFulfill:false (without allowInputRequired). The guide should be updated to document the new default auto-fulfilment, ClientOptions.inputRequired (autoFulfill/maxRounds), manual mode (allowInputRequired + withInputRequired), and SdkErrorCode.InputRequiredRoundsExceeded.

Extended reasoning...

What the bug is

This PR ships the client half of multi round-trip requests and flips the default 2026-era posture for input_required results from reject to auto-fulfil. The migration guide, docs/migration.md, still describes the pre-PR reject behavior and even forward-references the exact work this PR delivers as future work. Because docs/migration.md is not among the 25 files this PR changed, the consumer-facing migration guide now actively misstates the default behavior of the version it documents.

The code path that triggers it

packages/client/src/client/client.ts:297-299 wires the driver into the Client constructor:

this._inputRequiredDriverConfig = resolveInputRequiredDriverConfig(options?.inputRequired);

resolveInputRequiredDriverConfig (in inputRequiredDriver.ts) defaults autoFulfill to DEFAULT_INPUT_REQUIRED_AUTO_FULFILL = true. In protocol.ts _requestWithSchemaViaCodec, when a response decodes to input_required, the funnel now routes to the auto-fulfilment driver whenever driverConfig.autoFulfill is true; the old UnsupportedResultType reject arm is only reached when there is no driver or autoFulfill: false and no per-call allowInputRequired: true.

Why the existing prose is now wrong

Two passages in docs/migration.md describe the superseded behavior:

  • Line 921 (Raw-first result discrimination): '...any other kind (e.g. input_required) rejects with a typed local error — SdkError with the new code SdkErrorCode.UnsupportedResultType ... Full multi-round-trip support will replace that error arm.' This PR is that replacement, yet the sentence still frames it as future work and asserts that input_required rejects.
  • Line 950 (per-era codec): 'On a 2026-era exchange the discrimination is stricter than before: resultType is REQUIRED, an absent value is a spec violation ... and input_required / unknown kinds reject with UnsupportedResultType / InvalidResult.' After this PR, input_required no longer rejects by default — it is auto-fulfilled.

The PR added .changeset/mrtr-client-engine.md documenting the new behavior, but a changeset is a release-note artifact, not the migration guide consumers read to understand era behavior. The repo's REVIEW.md tests-and-docs convention mandates exactly this flag: on a behavior change, check whether docs/**/*.md describes the old behavior and flag prose that now contradicts the implementation; breaking changes are to be documented in docs/migration.md.

Impact

A reader of the migration guide will believe a 2026-era input_required answer surfaces as an UnsupportedResultType error and that they must handle it themselves, when in fact the SDK now dispatches the embedded requests to their registered handlers and retries automatically. They will also miss the new configuration surface entirely: ClientOptions.inputRequired (autoFulfill, maxRounds), manual mode (autoFulfill: false + per-call allowInputRequired: true + the withInputRequired() schema wrapper), and the new SdkErrorCode.InputRequiredRoundsExceeded error raised on round-cap exhaustion. This is documentation-only — no runtime defect — but the guide is the primary reference for these era behavior changes and currently misleads about the default.

How to fix

Update the two passages in docs/migration.md to state the new default: on 2026-07-28 connections input_required is auto-fulfilled by default through the already-registered elicitation/sampling/roots handlers, with the original call retried (fresh id, byte-exact requestState echo, collected inputResponses) up to inputRequired.maxRounds (default 10). Document the UnsupportedResultType reject arm as the manual path (autoFulfill: false and no allowInputRequired), and add the new SdkErrorCode.InputRequiredRoundsExceeded and the ClientOptions.inputRequired / allowInputRequired / withInputRequired() surface.

Step-by-step proof

  1. A consumer constructs new Client(info) with no inputRequired option and connects to a 2026-07-28 server, registering an elicitation/create handler.
  2. They call client.callTool({ name: 'deploy', arguments: {} }).
  3. The server answers with { resultType: 'input_required', inputRequests: { github_login: { method: 'elicitation/create', ... } } }.
  4. resolveInputRequiredDriverConfig(undefined) produced { autoFulfill: true, maxRounds: 10 } at construction (client.ts:297-299), so in protocol.ts the decoded.kind === 'input_required' branch finds driverConfig.autoFulfill === true and calls _runInputRequiredDriver — it does not reject with UnsupportedResultType.
  5. The driver dispatches the embedded elicitation to the registered handler and retries; callTool resolves to the plain CallToolResult. (This is exactly what inputRequiredEngine.test.ts 'auto-fulfilment (default on)' asserts.)
  6. The consumer, reading docs/migration.md:921/:950, expected step 4 to reject with UnsupportedResultType and 'full multi-round-trip support' to still be pending. The guide directly contradicts the observed default behavior — confirming the stale-docs bug.


// Store list changed config for setup after connection (when we know server capabilities)
if (options?.listChanged) {
Expand Down Expand Up @@ -352,14 +382,16 @@
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');
}
Expand Down Expand Up @@ -416,9 +448,13 @@

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,
Expand All @@ -436,13 +472,28 @@

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 =
Expand Down
6 changes: 6 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading