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:
parent
c3ca40f770
commit
2fb9ab8e6a
1 changed files with 463 additions and 77 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue