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
2 changed files with 463 additions and 77 deletions
|
|
@ -162,6 +162,13 @@ end
|
|||
oauth = scenario.start_with?("auth/") ? build_provider_for(scenario, conformance_context) : nil
|
||||
transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth)
|
||||
client = MCP::Client.new(transport: transport)
|
||||
|
||||
# SEP-2322: the MRTR scenario exercises the automatic driver; this elicitation handler accepts
|
||||
# every embedded confirmation request, and the driver echoes `requestState` byte-exactly.
|
||||
# The same registration answers a real `elicitation/create` request, so nothing here is MRTR-specific.
|
||||
if scenario == "sep-2322-client-request-state"
|
||||
client.on_elicitation { |_params| { action: "accept", content: { confirmed: true } } }
|
||||
end
|
||||
capabilities = scenario == "elicitation-sep1034-client-defaults" ? { elicitation: {} } : {}
|
||||
# The conformance harness asserts on the observed protocol exchange, not on the client's exit status,
|
||||
# and echoes the client's output only when the exit status is non-zero. Several negative auth scenarios
|
||||
|
|
@ -171,13 +178,28 @@ capabilities = scenario == "elicitation-sep1034-client-defaults" ? { elicitation
|
|||
# free of noise, while letting any unexpected error (a real SDK bug) still raise with a full backtrace
|
||||
# and a failing exit status.
|
||||
begin
|
||||
# The conformance referee validates the legacy `initialize` handshake, so pin the legacy lifecycle:
|
||||
# the default `:auto` negotiation would prepend a `server/discover` probe the scenario servers do not expect.
|
||||
client.connect(
|
||||
client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION },
|
||||
capabilities: capabilities,
|
||||
mode: :legacy,
|
||||
)
|
||||
# The harness names the wire version per scenario run: the 2026 draft selects the stateless modern lifecycle,
|
||||
# and every dated version through 2025-11-25 keeps the legacy `initialize` handshake
|
||||
# (pinned rather than `:auto`, whose `server/discover` probe the legacy scenario servers do not expect).
|
||||
mode = ENV["MCP_CONFORMANCE_PROTOCOL_VERSION"] == "2026-07-28" ? :modern : :legacy
|
||||
connect = lambda do
|
||||
client.connect(
|
||||
client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION },
|
||||
capabilities: capabilities,
|
||||
mode: mode,
|
||||
)
|
||||
end
|
||||
|
||||
begin
|
||||
connect.call
|
||||
rescue MCP::Client::RequestHandlerError
|
||||
# SEP-2575 version negotiation: a server may reject the first request with `-32022` naming
|
||||
# its supported versions. Retry once so the harness observes a follow-up request carrying
|
||||
# a mutually supported version (this client already speaks the latest draft).
|
||||
raise unless mode == :modern
|
||||
|
||||
connect.call
|
||||
end
|
||||
|
||||
case scenario
|
||||
when "initialize"
|
||||
|
|
@ -190,6 +212,35 @@ begin
|
|||
focal = tools.find { |t| t.name == "json_schema_2020_12_tool" }
|
||||
abort("Tool json_schema_2020_12_tool not found") unless focal
|
||||
client.call_tool(name: "json_schema_echo", arguments: { "schema" => focal.input_schema })
|
||||
when "request-metadata"
|
||||
# SEP-2575: the checks read the envelope and headers of the requests the modern connect
|
||||
# and this listing already produce.
|
||||
client.tools
|
||||
when "sep-2322-client-request-state"
|
||||
# SEP-2322: the driver resumes `test_mrtr_echo_state` automatically (fresh JSON-RPC id,
|
||||
# byte-exact `requestState` echo); `test_mrtr_unrelated` must carry neither retry field;
|
||||
# `test_mrtr_no_state` retries without inventing a state; `test_mrtr_no_result_type`
|
||||
# has no `resultType` and therefore must be treated as complete, with no retry.
|
||||
client.tools
|
||||
client.call_tool(name: "test_mrtr_echo_state", arguments: {})
|
||||
client.call_tool(name: "test_mrtr_unrelated", arguments: {})
|
||||
client.call_tool(name: "test_mrtr_no_state", arguments: {})
|
||||
client.call_tool(name: "test_mrtr_no_result_type", arguments: {})
|
||||
when "http-custom-headers"
|
||||
# SEP-2243: the harness names the exact tool calls (with encoding edge-case values) via context;
|
||||
# listing first teaches the transport the `x-mcp-header` declarations it mirrors.
|
||||
client.tools
|
||||
(conformance_context["toolCalls"] || []).each do |tool_call|
|
||||
client.call_tool(name: tool_call["name"], arguments: tool_call["arguments"])
|
||||
end
|
||||
when "http-invalid-tool-headers"
|
||||
# SEP-2243: the listing carries one valid tool among invalid `x-mcp-header` definitions;
|
||||
# calling only the valid one shows the invalid definitions did not block it.
|
||||
client.tools
|
||||
client.call_tool(name: "valid_tool", arguments: { "message" => "hello" })
|
||||
when "json-schema-ref-no-deref"
|
||||
# SEP-2106: listing tools whose schemas carry `$ref` must not trigger network dereferencing.
|
||||
client.tools
|
||||
when "tools_call"
|
||||
tools = client.tools
|
||||
add_numbers = tools.find { |t| t.name == "add_numbers" }
|
||||
|
|
|
|||
|
|
@ -1,19 +1,6 @@
|
|||
server: []
|
||||
client:
|
||||
# Pending client-side support for the 2026-07-28 stateless lifecycle: the conformance client
|
||||
# still connects through the legacy handshake and has no SEP-2322 MRTR driver yet.
|
||||
- request-metadata
|
||||
- sep-2322-client-request-state
|
||||
# SEP-2243 client-side custom-header mirroring and invalid-tool header gating
|
||||
# are not implemented in `MCP::Client::HTTP` yet.
|
||||
- http-custom-headers
|
||||
- http-invalid-tool-headers
|
||||
# Client-side JSON Schema handling: `$ref` values must reach the caller unresolved,
|
||||
# and the conformance client does not run the scenario over the modern lifecycle yet.
|
||||
- json-schema-ref-no-deref
|
||||
# Unimplemented authorization extensions.
|
||||
- auth/dpop
|
||||
- auth/dpop-nonce
|
||||
- auth/wif-jwt-bearer
|
||||
# Fails one check added to the suite after the 0.2.0-alpha.10 requirements anchor.
|
||||
- auth/authorization-server-migration
|
||||
|
|
|
|||
Loading…
Reference in a new issue