Drive multi round-trip input_required results on the client per SEP-2322 - #500
Merged
koic merged 1 commit intoAug 13, 2026
Merged
Conversation
koic
force-pushed
the
client_input_required_driver
branch
from
August 10, 2026 03:39
2d30e14 to
54fcff7
Compare
input_required Results on the Legacy Wire per SEP-2322input_required results on the client per SEP-2322
koic
force-pushed
the
client_input_required_driver
branch
6 times, most recently
from
August 13, 2026 16:51
56e8f5e to
5b54957
Compare
…-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
force-pushed
the
client_input_required_driver
branch
from
August 13, 2026 17:04
5b54957 to
2fb9ab8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_requiredresults by raisingInputRequiredError; this adds the resume loop, mirroring the Python SDK's high-levelClientdriver and the TypeScript SDK'srunInputRequiredDriver.on_elicitation,on_sampling, and the newon_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 aninput_requiredresult, 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_elicitationandon_samplingno longer refuse a transport withouton_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.newgainsinput_required_max_rounds:(default 10, the TypeScript and Python default).call_tool,get_prompt, andread_resourceresume automatically: eachinputRequestsentry is fulfilled by the matching handler and the ORIGINAL request is re-issued withinputResponsesunder the same keys plus the byte-exact echoedrequestState, 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). ArequestState-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 raisesInputRequiredErrorcarrying the last result.InputRequiredError, and clients with no handlers keep the exact pre-existing behavior. For manual driving,call_tool/get_prompt/read_resourcegaininput_responses:andrequest_state:keyword arguments that ride at the params top level. Non-MRTR methods such astools/listkeep 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 withmode: :modernfor 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-32022naming its supported versions. New scenario branches drivesep-2322-client-request-statethrough this driver (an acceptingon_elicitationhandler plus the fourtest_mrtr_*tools),request-metadata, both SEP-2243 header scenarios, andjson-schema-ref-no-deref. The expected-failures baseline drops the six scenarios this stack resolves, leaving only the not-scored extensions and the post-anchorjson-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 returnsInputRequiredResultunderRequestStateSecuritysealing completes in two legs with the handler supplying the answer, and a handler-less client receivesInputRequiredErrorwith 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-matchedinputResponses, byte-exactrequestStateecho, and a fresh id per leg; the exponential backoff sequence forrequestState-only legs (stubbedsleep); round-cap exhaustion raisingInputRequiredErrorwith the last state; the unhandled-kind fallback; the manualinput_responses:/request_state:keyword arguments; and theprompts/getdriver 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_requestat all, anon_rootshandler answering an embeddedroots/list, andon_elicitationandon_samplingaccepting 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-state5/5,request-metadata5/5 with no version-retry warning,http-custom-headers18/18,http-invalid-tool-headers11/11), and the--requirementsclient 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_elicitationandon_samplingno longer raiseArgumentErroron a transport withouton_server_request; they register the handler for theinput_requiredroute instead. Code that relied on the refusal to detect an unsuitable transport needs another check. Everything else is additive: the new keyword arguments default tonil, and with no handler registered every code path is identical to before.Types of changes
Checklist