Skip to content

Drive multi round-trip input_required results on the client per SEP-2322 - #500

Merged
koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:client_input_required_driver
Aug 13, 2026
Merged

Drive multi round-trip input_required results on the client per SEP-2322#500
koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:client_input_required_driver

Conversation

@koic

@koic koic commented Aug 10, 2026

Copy link
Copy Markdown
Member

Motivation and Context

Final step of SEP-2322 (modelcontextprotocol/modelcontextprotocol#2322) for the 2026-07-28 MCP spec release. The client so far only recognized input_required results by raising InputRequiredError; this adds the resume loop, mirroring the Python SDK's high-level Client driver and the TypeScript SDK's runInputRequiredDriver.

  • The driver answers embedded requests with the handlers already registered through on_elicitation, on_sampling, and the new on_roots. There is no second registration path: a server can ask for input two ways, as a real request mid-call and as an entry in an input_required result, and one registration covers both. This follows the TypeScript driver, which dispatches "to the client's already-registered handlers (elicitation, sampling, roots - one generic engine, no per-feature API)".
  • on_elicitation and on_sampling no longer refuse a transport without on_server_request. They record the handler either way and wire it to the transport only when it can carry server-to-client requests. Refusing would have locked stdio clients, and every 2026-07-28 connection, out of the only route the spec leaves: the modern lifecycle forbids server-to-client requests, so an embedded request is how it asks.
  • MCP::Client.new gains input_required_max_rounds: (default 10, the TypeScript and Python default).
  • Once a handler is registered, call_tool, get_prompt, and read_resource resume automatically: each inputRequests entry is fulfilled by the matching handler and the ORIGINAL request is re-issued with inputResponses under the same keys plus the byte-exact echoed requestState, on a fresh JSON-RPC id per leg (modern envelope stamping happens in the transports per send, so every leg carries the SEP-2575 triple). A requestState-only result (load shedding) retries after an exponential backoff from 50ms to a 250ms cap, matching the Python SDK; every leg counts against the round cap, whose exhaustion raises InputRequiredError carrying the last result.
  • A requested kind without a matching handler falls back to the manual path by raising InputRequiredError, and clients with no handlers keep the exact pre-existing behavior. For manual driving, call_tool / get_prompt / read_resource gain input_responses: and request_state: keyword arguments that ride at the params top level. Non-MRTR methods such as tools/list keep raising unconditionally.

The conformance client rides the same change: it derives its lifecycle from the wire version the harness names per run (MCP_CONFORMANCE_PROTOCOL_VERSION), connecting with mode: :modern for the 2026 draft and keeping the pinned legacy handshake through 2025-11-25, retrying a modern connect once when the server rejects the first request with -32022 naming its supported versions. New scenario branches drive sep-2322-client-request-state through this driver (an accepting on_elicitation handler plus the four test_mrtr_* tools), request-metadata, both SEP-2243 header scenarios, and json-schema-ref-no-deref. The expected-failures baseline drops the six scenarios this stack resolves, leaving only the not-scored extensions and the post-anchor json-schema-2020-12-preservation.

Verified end-to-end over stdio: a modern connection (connect(mode: :modern) with declared form-elicitation capability) against a server whose tool returns InputRequiredResult under RequestStateSecurity sealing completes in two legs with the handler supplying the answer, and a handler-less client receives InputRequiredError with the sealed opaque state.

Part of #382.

How Has This Been Tested?

New tests in test/mcp/client_test.rb (Mocha sequenced transports) cover: the two-leg happy path asserting preserved original params, key-matched inputResponses, byte-exact requestState echo, and a fresh id per leg; the exponential backoff sequence for requestState-only legs (stubbed sleep); round-cap exhaustion raising InputRequiredError with the last state; the unhandled-kind fallback; the manual input_responses:/request_state: keyword arguments; and the prompts/get driver path. The existing SEP-2322 recognition tests pin the no-handler behavior unchanged.

Three tests cover the shared-registration design specifically: the driver resolving an embedded request on a transport that has no on_server_request at all, an on_roots handler answering an embedded roots/list, and on_elicitation and on_sampling accepting a registration on such a transport rather than raising, which replaces the two tests that pinned the old refusal.

bundle exec rake (tests, RuboCop, and conformance baseline) passes, plus the stdio end-to-end script described above.

With the separate SEP-2243 mirroring change applied alongside, every scored scenario of the frozen 2026-07-28 client leg passes (sep-2322-client-request-state 5/5, request-metadata 5/5 with no version-retry warning, http-custom-headers 18/18, http-invalid-tool-headers 11/11), and the --requirements client legs at both revisions report failures only in not-scored scenarios. The full conformance task passes its baseline check on both legs.

Breaking Changes

on_elicitation and on_sampling no longer raise ArgumentError on a transport without on_server_request; they register the handler for the input_required route instead. Code that relied on the refusal to detect an unsuitable transport needs another check. Everything else is additive: the new keyword arguments default to nil, and with no handler registered every code path is identical to before.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@koic
koic force-pushed the client_input_required_driver branch from 2d30e14 to 54fcff7 Compare August 10, 2026 03:39
@koic koic changed the title Fulfill input_required Results on the Legacy Wire per SEP-2322 Drive multi round-trip input_required results on the client per SEP-2322 Aug 10, 2026
@koic
koic force-pushed the client_input_required_driver branch 6 times, most recently from 56e8f5e to 5b54957 Compare August 13, 2026 16:51
…-2322

## Motivation and Context

Final step of SEP-2322 (modelcontextprotocol/modelcontextprotocol#2322) for the 2026-07-28 MCP spec release.
The client so far only recognized `input_required` results by raising `InputRequiredError`; this adds the resume loop,
mirroring the Python SDK's high-level `Client` driver and the TypeScript SDK's `runInputRequiredDriver`.

- The driver answers embedded requests with the handlers already registered through `on_elicitation`,
  `on_sampling`, and the new `on_roots`. There is no second registration path: a server can ask for input two
  ways, as a real request mid-call and as an entry in an `input_required` result, and one registration covers
  both. This follows the TypeScript driver, which dispatches "to the client's already-registered handlers
  (elicitation, sampling, roots - one generic engine, no per-feature API)".
- `on_elicitation` and `on_sampling` no longer refuse a transport without `on_server_request`. They record
  the handler either way and wire it to the transport only when it can carry server-to-client requests. Refusing
  would have locked stdio clients, and every 2026-07-28 connection, out of the only route the spec leaves:
  the modern lifecycle forbids server-to-client requests, so an embedded request is how it asks.
- `MCP::Client.new` gains `input_required_max_rounds:` (default 10, the TypeScript and Python default).
- Once a handler is registered, `call_tool`, `get_prompt`, and `read_resource` resume automatically:
  each `inputRequests` entry is fulfilled by the matching handler and the ORIGINAL request is re-issued with
  `inputResponses` under the same keys plus the byte-exact echoed `requestState`, on a fresh JSON-RPC id per leg
  (modern envelope stamping happens in the transports per send, so every leg carries the SEP-2575 triple).
  A `requestState`-only result (load shedding) retries after an exponential backoff from 50ms to a 250ms cap,
  matching the Python SDK; every leg counts against the round cap, whose exhaustion raises `InputRequiredError`
  carrying the last result.
- A requested kind without a matching handler falls back to the manual path by raising `InputRequiredError`,
  and clients with no handlers keep the exact pre-existing behavior. For manual driving,
  `call_tool` / `get_prompt` / `read_resource` gain `input_responses:` and `request_state:` keyword arguments
  that ride at the params top level. Non-MRTR methods such as `tools/list` keep raising unconditionally.

The conformance client rides the same change: it derives its lifecycle from the wire version the harness names per run
(`MCP_CONFORMANCE_PROTOCOL_VERSION`), connecting with `mode: :modern` for the 2026 draft and keeping the pinned legacy
handshake through 2025-11-25, retrying a modern connect once when the server rejects the first request with `-32022`
naming its supported versions. New scenario branches drive `sep-2322-client-request-state` through this driver
(an accepting `on_elicitation` handler plus the four `test_mrtr_*` tools), `request-metadata`, both SEP-2243 header
scenarios, and `json-schema-ref-no-deref`. The expected-failures baseline drops the six scenarios this stack resolves,
leaving only the not-scored extensions and the post-anchor `json-schema-2020-12-preservation`.

Verified end-to-end over stdio: a modern connection (`connect(mode: :modern)` with declared form-elicitation capability)
against a server whose tool returns `InputRequiredResult` under `RequestStateSecurity` sealing completes in two legs with
the handler supplying the answer, and a handler-less client receives `InputRequiredError` with the sealed opaque state.

Part of modelcontextprotocol#382.

## How Has This Been Tested?

New tests in `test/mcp/client_test.rb` (Mocha sequenced transports) cover: the two-leg happy path asserting preserved original params,
key-matched `inputResponses`, byte-exact `requestState` echo, and a fresh id per leg; the exponential backoff sequence for
`requestState`-only legs (stubbed `sleep`); round-cap exhaustion raising `InputRequiredError` with the last state; the unhandled-kind
fallback; the manual `input_responses:`/`request_state:` keyword arguments; and the `prompts/get` driver path. The existing SEP-2322
recognition tests pin the no-handler behavior unchanged.

Three tests cover the shared-registration design specifically: the driver resolving an embedded request on a transport that
has no `on_server_request` at all, an `on_roots` handler answering an embedded `roots/list`, and `on_elicitation` and
`on_sampling` accepting a registration on such a transport rather than raising, which replaces the two tests that pinned
the old refusal.

`bundle exec rake` (tests, RuboCop, and conformance baseline) passes, plus the stdio end-to-end script described above.

With the separate SEP-2243 mirroring change applied alongside, every scored scenario of the frozen 2026-07-28 client leg passes
(`sep-2322-client-request-state` 5/5, `request-metadata` 5/5 with no version-retry warning, `http-custom-headers` 18/18,
`http-invalid-tool-headers` 11/11), and the `--requirements` client legs at both revisions report failures only
in not-scored scenarios. The full conformance task passes its baseline check on both legs.

## Breaking Changes

`on_elicitation` and `on_sampling` no longer raise `ArgumentError` on a transport without `on_server_request`; they
register the handler for the `input_required` route instead. Code that relied on the refusal to detect an unsuitable
transport needs another check. Everything else is additive: the new keyword arguments default to `nil`, and with no
handler registered every code path is identical to before.
@koic
koic force-pushed the client_input_required_driver branch from 5b54957 to 2fb9ab8 Compare August 13, 2026 17:04
@koic
koic merged commit 33ed4a8 into modelcontextprotocol:main Aug 13, 2026
11 checks passed
@koic
koic deleted the client_input_required_driver branch August 13, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant