Drive multi round-trip input_required results on the client per SEP-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 #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.
This commit is contained in:
Koichi ITO 2026-08-08 20:24:27 +09:00
commit 2fb9ab8e6a

View file

@ -55,15 +55,14 @@ module MCP
Client.new(transport: transport).on_elicitation(&handler)
end
def test_on_elicitation_raises_when_transport_does_not_support_server_requests
def test_on_elicitation_is_accepted_when_transport_does_not_support_server_requests
# The handler still answers embedded `elicitation/create` requests of SEP-2322 `input_required` results,
# which need no server-to-client route. Refusing to register it would lock stdio clients,
# and every modern-lifecycle connection, out of the only way the spec leaves for asking.
transport = mock
transport.stubs(:respond_to?).with(:on_server_request).returns(false)
error = assert_raises(ArgumentError) do
Client.new(transport: transport).on_elicitation { { action: "accept", content: {} } }
end
assert_includes(error.message, "does not support server-to-client requests")
Client.new(transport: transport).on_elicitation { { action: "accept", content: {} } }
end
def test_on_sampling_registers_handler_on_transport
@ -74,15 +73,19 @@ module MCP
Client.new(transport: transport).on_sampling(&handler)
end
def test_on_sampling_raises_when_transport_does_not_support_server_requests
def test_on_sampling_is_accepted_when_transport_does_not_support_server_requests
transport = mock
transport.stubs(:respond_to?).with(:on_server_request).returns(false)
error = assert_raises(ArgumentError) do
Client.new(transport: transport).on_sampling { { role: "assistant", content: { type: "text", text: "hi" } } }
end
Client.new(transport: transport).on_sampling { { role: "assistant", content: { type: "text", text: "hi" } } }
end
assert_includes(error.message, "does not support server-to-client requests")
def test_on_roots_registers_handler_on_transport
transport = mock
handler = proc { { roots: [{ uri: "file:///project", name: "Project" }] } }
transport.expects(:on_server_request).with("roots/list")
Client.new(transport: transport).on_roots(&handler)
end
# A transport that declares `mode:` on `connect`, recording what it received.
@ -380,16 +383,20 @@ module MCP
# constraints MUST be excluded from `tools/list` results, with a warning naming the tool.
transport = mock
transport.stubs(:modern?).returns(true)
mock_response = { "result" => { "tools" => [
{ "name" => "valid_tool", "inputSchema" => { "type" => "object" } },
{
"name" => "invalid_tool",
"inputSchema" => {
"type" => "object",
"properties" => { "value" => { "type" => "string", "x-mcp-header" => "bad name" } },
},
mock_response = {
"result" => {
"tools" => [
{ "name" => "valid_tool", "inputSchema" => { "type" => "object" } },
{
"name" => "invalid_tool",
"inputSchema" => {
"type" => "object",
"properties" => { "value" => { "type" => "string", "x-mcp-header" => "bad name" } },
},
},
],
},
] } }
}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
@ -408,15 +415,19 @@ module MCP
def test_tools_keeps_invalid_x_mcp_header_definitions_on_a_legacy_connection
transport = mock
transport.stubs(:modern?).returns(false)
mock_response = { "result" => { "tools" => [
{
"name" => "invalid_tool",
"inputSchema" => {
"type" => "object",
"properties" => { "value" => { "type" => "string", "x-mcp-header" => "bad name" } },
},
mock_response = {
"result" => {
"tools" => [
{
"name" => "invalid_tool",
"inputSchema" => {
"type" => "object",
"properties" => { "value" => { "type" => "string", "x-mcp-header" => "bad name" } },
},
},
],
},
] } }
}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
@ -542,6 +553,190 @@ module MCP
assert_raises(Client::InputRequiredError) { client.list_tools }
end
def test_call_tool_drives_the_input_required_loop_with_a_configured_handler
transport = mock
sent_requests = []
input_required_response = {
"result" => {
"resultType" => "input_required",
"inputRequests" => {
"region" => { "method" => "elicitation/create", "params" => { "message" => "Which region?" } },
},
"requestState" => "state-1",
},
}
final_response = { "result" => { "content" => [{ "type" => "text", "text" => "done" }] } }
transport.expects(:send_request).twice.with do |args|
sent_requests << args[:request]
true
end.returns(input_required_response, final_response)
handled_params = nil
client = Client.new(transport: transport)
client.on_elicitation do |params|
handled_params = params
{ action: "accept", content: { value: "us-east-1" } }
end
response = client.call_tool(name: "my_tool", arguments: { city: "Tokyo" })
assert_equal("done", response.dig("result", "content", 0, "text"))
assert_equal({ "message" => "Which region?" }, handled_params)
first_leg, retry_leg = sent_requests
# The ORIGINAL params are preserved; the answers ride under the same keys with
# the byte-exact `requestState` echo, on a fresh JSON-RPC id.
assert_equal("my_tool", retry_leg.dig(:params, :name))
assert_equal({ city: "Tokyo" }, retry_leg.dig(:params, :arguments))
assert_equal(
{ "region" => { action: "accept", content: { value: "us-east-1" } } },
retry_leg.dig(:params, :inputResponses),
)
assert_equal("state-1", retry_leg.dig(:params, :requestState))
refute_equal(first_leg[:id], retry_leg[:id])
end
def test_input_required_loop_backs_off_on_request_state_only_legs
transport = mock
state_only_response = {
"result" => { "resultType" => "input_required", "requestState" => "busy-state" },
}
final_response = { "result" => { "content" => [] } }
transport.stubs(:send_request).returns(state_only_response, state_only_response, final_response)
client = Client.new(transport: transport)
client.on_elicitation { |_params| {} }
slept = []
client.stubs(:sleep).with { |seconds| slept << seconds }
response = client.call_tool(name: "my_tool")
assert_equal(final_response, response)
# Exponential from 50ms, matching the Python SDK.
assert_equal([0.05, 0.1], slept)
end
def test_input_required_loop_raises_after_max_rounds
transport = mock
input_required_response = {
"result" => {
"resultType" => "input_required",
"inputRequests" => { "q" => { "method" => "elicitation/create", "params" => {} } },
"requestState" => "state-1",
},
}
transport.stubs(:send_request).returns(input_required_response)
client = Client.new(transport: transport, input_required_max_rounds: 2)
client.on_elicitation { |_params| { action: "decline" } }
error = assert_raises(Client::InputRequiredError) { client.call_tool(name: "my_tool") }
assert_includes(error.message, "after 2 rounds")
assert_equal("state-1", error.request_state)
end
def test_input_required_loop_falls_back_to_the_error_for_unhandled_request_kinds
transport = mock
input_required_response = {
"result" => {
"resultType" => "input_required",
"inputRequests" => { "llm" => { "method" => "sampling/createMessage", "params" => {} } },
"requestState" => "state-1",
},
}
transport.expects(:send_request).returns(input_required_response).once
client = Client.new(transport: transport)
client.on_elicitation { |_params| {} }
error = assert_raises(Client::InputRequiredError) { client.call_tool(name: "my_tool") }
assert_includes(error.message, "sampling/createMessage")
assert_equal("state-1", error.request_state)
end
def test_input_required_loop_runs_on_a_transport_without_server_request_support
# The modern lifecycle forbids server-to-client requests, so an `input_required` result is the only
# way a server can ask. A transport with no `on_server_request` must still drive the loop.
transport = mock
transport.stubs(:respond_to?).with(:on_server_request).returns(false)
input_required_response = {
"result" => {
"resultType" => "input_required",
"inputRequests" => { "q" => { "method" => "elicitation/create", "params" => { "message" => "Region?" } } },
"requestState" => "state-1",
},
}
final_response = { "result" => { "content" => [{ "text" => "done" }] } }
transport.expects(:send_request).twice.returns(input_required_response, final_response)
client = Client.new(transport: transport)
client.on_elicitation { |_params| { action: "accept", content: { value: "us-east-1" } } }
response = client.call_tool(name: "my_tool")
assert_equal("done", response.dig("result", "content", 0, "text"))
end
def test_input_required_loop_uses_a_roots_handler_registered_with_on_roots
transport = mock
input_required_response = {
"result" => {
"resultType" => "input_required",
"inputRequests" => { "r" => { "method" => "roots/list" } },
"requestState" => "state-1",
},
}
final_response = { "result" => { "content" => [{ "text" => "done" }] } }
transport.expects(:on_server_request).with("roots/list")
transport.expects(:send_request).twice.returns(input_required_response, final_response)
client = Client.new(transport: transport)
client.on_roots { |_params| { roots: [{ uri: "file:///project", name: "Project" }] } }
response = client.call_tool(name: "my_tool")
assert_equal("done", response.dig("result", "content", 0, "text"))
end
def test_call_tool_sends_manual_retry_fields
transport = mock
transport.expects(:send_request).with do |args|
params = args[:request][:params]
params[:inputResponses] == { "region" => { action: "accept" } } && params[:requestState] == "state-1" && params[:name] == "my_tool"
end.returns({ "result" => { "content" => [] } }).once
client = Client.new(transport: transport)
client.call_tool(name: "my_tool", input_responses: { "region" => { action: "accept" } }, request_state: "state-1")
end
def test_get_prompt_drives_the_input_required_loop
transport = mock
input_required_response = {
"result" => {
"resultType" => "input_required",
"inputRequests" => { "q" => { "method" => "elicitation/create", "params" => { "message" => "?" } } },
"requestState" => "prompt-state",
},
}
final_response = { "result" => { "messages" => [] } }
sent_requests = []
transport.expects(:send_request).twice.with do |args|
sent_requests << args[:request]
true
end.returns(input_required_response, final_response)
client = Client.new(transport: transport)
client.on_elicitation { |_params| { action: "accept", content: {} } }
result = client.get_prompt(name: "my_prompt")
assert_equal({ "messages" => [] }, result)
assert_equal("prompt-state", sent_requests[1].dig(:params, :requestState))
end
def test_call_tool_raises_when_no_name_or_tool
client = Client.new(transport: mock)