From 2fb9ab8e6a3ca10ee8091ea5b660e7bc1b8833f1 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 8 Aug 2026 20:24:27 +0900 Subject: [PATCH] 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. --- README.md | 8 +- conformance/client.rb | 65 +++++++- conformance/expected_failures.yml | 13 -- lib/mcp/client.rb | 203 ++++++++++++++++++++---- test/mcp/client_test.rb | 251 ++++++++++++++++++++++++++---- 5 files changed, 463 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index a6d001fd..2fd4454d 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,13 @@ It implements the Model Context Protocol specification, handling model context r client-controlled input: pass `MCP::Server::RequestStateSecurity.new(key:)` (a 32-byte key) via `Server.new(request_state_security:)` to have it sealed with AES-256-GCM and bound to a TTL plus the originating method, target, and arguments, all transparently to handlers. Multi-process deployments must share the key across workers; without `request_state_security:` the state crosses the wire exactly as - the handler wrote it and protecting it is the handler author's responsibility + the handler wrote it and protecting it is the handler author's responsibility. On the client, register handlers with + `on_elicitation` / `on_sampling` / `on_roots` - the same registrations that answer a real server-to-client request - and + declare the matching capabilities on `connect` (a server embeds only the request kinds the client declared); + `call_tool` / `get_prompt` / `read_resource` then resume `input_required` results automatically: each embedded request is fulfilled by + the matching handler and the original request is re-issued with `inputResponses` plus the echoed `requestState` + (with exponential backoff for `requestState`-only load-shedding legs). Without a matching handler they raise `MCP::Client::InputRequiredError`, + and the `input_responses:` / `request_state:` keyword arguments support manual driving - `ping` - Simple health check - `logging/setLevel` - Configures the minimum log level for the server - `tools/list` - Lists all registered tools and their schemas diff --git a/conformance/client.rb b/conformance/client.rb index 23403d3b..c04d272e 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -162,6 +162,13 @@ def build_provider_for(scenario, context) 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 @@ def build_provider_for(scenario, context) # 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 @@ def build_provider_for(scenario, context) 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" } diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index 5c1dbec1..93258420 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -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 diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 4db01973..e1de318e 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -57,8 +57,9 @@ class ValidationError < StandardError; end # `inputRequests` (a map of id => `{ "method" => ..., "params" => ... }` request objects with # `sampling/createMessage`, `roots/list`, or `elicitation/create` shapes) and re-issue # the original request with `inputResponses` plus the echoed opaque `requestState`. - # This SDK does not yet drive that resume loop automatically; callers can inspect `input_requests` - # and respond manually. + # With handlers registered through `on_elicitation`, `on_sampling`, or `on_roots`, the resume loop runs + # automatically; this error surfaces when no matching handler exists (manual driving via + # `input_requests` and the `input_responses:`/`request_state:` kwargs), or when the round cap is exhausted. # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322 class InputRequiredError < StandardError attr_reader :input_requests, :request_state, :result @@ -93,16 +94,36 @@ def initialize(message, request, original_error: nil) keyword_init: true, ) + # Rounds the SEP-2322 driver runs before giving up, matching the TypeScript and + # Python SDK defaults. Every leg counts, including `requestState`-only retries. + DEFAULT_INPUT_REQUIRED_MAX_ROUNDS = 10 + + # Backoff for `requestState`-only (load shedding) legs: exponential from 50ms to + # a 250ms cap, matching the Python SDK (the TypeScript SDK uses a fixed 250ms). + STATE_ONLY_BACKOFF_INITIAL_SECONDS = 0.05 + STATE_ONLY_BACKOFF_CAP_SECONDS = 0.25 + # Initializes a new MCP::Client instance. # # @param transport [Object] The transport object to use for communication with the server. # The transport should be a duck type that responds to `send_request`. See the README for more details. + # @param input_required_max_rounds [Integer] Cap on SEP-2322 driver rounds. + # + # Once a handler is registered through `on_elicitation`, `on_sampling`, or `on_roots`, `call_tool`, + # `get_prompt`, and `read_resource` resume `input_required` results automatically; without handlers + # (or when a requested kind has no handler) they raise `InputRequiredError` for manual driving, + # exactly as before. # # @example # transport = MCP::Client::HTTP.new(url: "http://localhost:3000") # client = MCP::Client.new(transport: transport) - def initialize(transport:) + def initialize(transport:, input_required_max_rounds: DEFAULT_INPUT_REQUIRED_MAX_ROUNDS) @transport = transport + # Populated by `on_elicitation`, `on_sampling`, and `on_roots`. The same handler answers both ways + # the server can ask for input: a real server-to-client request, and an embedded request inside + # a SEP-2322 `input_required` result. + @input_required_handlers = {} + @input_required_max_rounds = input_required_max_rounds end # The user may want to access additional transport-specific methods/attributes @@ -431,7 +452,10 @@ def prompts(cancellation: nil) # @note # The exact requirements for `arguments` are determined by the transport layer in use. # Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details. - def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil, cancellation: nil) + # @param input_responses [Hash, nil] SEP-2322 answers to a previous `input_required` result's `inputRequests`, + # keyed identically (manual retry legs). + # @param request_state [String, nil] The opaque `requestState` echoed back byte-exactly. + def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil, cancellation: nil, input_responses: nil, request_state: nil) tool_name = name || tool&.name raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name @@ -442,8 +466,10 @@ def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: n meta_entries[:progressToken] = progress_token end params[:_meta] = meta_entries unless meta_entries.empty? + params[:inputResponses] = input_responses if input_responses + params[:requestState] = request_state if request_state - request(method: "tools/call", params: params, cancellation: cancellation) + drive_input_required(method: "tools/call", params: params, cancellation: cancellation) end # Reads a resource from the server by URI and returns the contents. @@ -453,8 +479,13 @@ def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: n # e.g. SEP-414 trace context (see {MCP::TraceContext}). # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. # @return [Array] An array of resource contents (text or blob). - def read_resource(uri:, meta: nil, cancellation: nil) - response = request(method: "resources/read", params: { uri: uri }, meta: meta, cancellation: cancellation) + def read_resource(uri:, meta: nil, cancellation: nil, input_responses: nil, request_state: nil) + params = { uri: uri } + params = params.merge(_meta: meta) if meta && !meta.empty? + params[:inputResponses] = input_responses if input_responses + params[:requestState] = request_state if request_state + + response = drive_input_required(method: "resources/read", params: params, cancellation: cancellation) response.dig("result", "contents") || [] end @@ -466,8 +497,13 @@ def read_resource(uri:, meta: nil, cancellation: nil) # e.g. SEP-414 trace context (see {MCP::TraceContext}). # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. # @return [Hash] A hash containing the prompt details. - def get_prompt(name:, meta: nil, cancellation: nil) - response = request(method: "prompts/get", params: { name: name }, meta: meta, cancellation: cancellation) + def get_prompt(name:, meta: nil, cancellation: nil, input_responses: nil, request_state: nil) + params = { name: name } + params = params.merge(_meta: meta) if meta && !meta.empty? + params[:inputResponses] = input_responses if input_responses + params[:requestState] = request_state if request_state + + response = drive_input_required(method: "prompts/get", params: params, cancellation: cancellation) response.fetch("result", {}) end @@ -496,8 +532,11 @@ def complete(ref:, argument:, context: nil, meta: nil, cancellation: nil) # (message and `requestedSchema`, string keys) and must return an `ElicitResult`-shaped Hash: # `{ action: "accept" | "decline" | "cancel", content: { ... } }`. # - # Requires a transport that supports server-to-client requests (e.g. `MCP::Client::HTTP`); - # pass `capabilities: { elicitation: {} }` to `connect` so the server knows it may send them. + # The same handler answers both ways a server can ask: a real request mid-call, which needs a transport that + # carries server-to-client requests (e.g. `MCP::Client::HTTP`); and an `elicitation/create` embedded in a SEP-2322 + # `input_required` result, which needs no server-to-client route, and is how the modern lifecycle asks now that it + # forbids server-to-client requests. Both routes need `capabilities: { elicitation: {} }` passed to `connect`: per SEP-2322, + # a server MUST NOT embed input requests of a kind the client has not declared. # # @example Accept with schema defaults applied (SEP-1034) # client.on_elicitation do |params| @@ -508,11 +547,7 @@ def complete(ref:, argument:, context: nil, meta: nil, cancellation: nil) # end # https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation def on_elicitation(&handler) - unless transport.respond_to?(:on_server_request) - raise ArgumentError, "The transport does not support server-to-client requests" - end - - transport.on_server_request(Methods::ELICITATION_CREATE, &handler) + register_input_handler(Methods::ELICITATION_CREATE, &handler) end # Registers a handler for `sampling/createMessage` requests the server sends while one of this client's requests is in flight. @@ -523,8 +558,11 @@ def on_elicitation(&handler) # For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response # before it is returned to the server. To reject, raise `ServerRequestError` with the spec's user-rejection code `-1`. # - # Requires a transport that supports server-to-client requests (e.g. `MCP::Client::HTTP`); pass `capabilities: { sampling: {} }` to - # `connect` (or `{ sampling: { tools: {} } }` to receive tool-enabled sampling requests) so the server knows it may send them. + # The same handler answers both ways a server can ask: a real request mid-call, which needs a transport that + # carries server-to-client requests (e.g. `MCP::Client::HTTP`); and a `sampling/createMessage` embedded in a SEP-2322 + # `input_required` result, which needs no server-to-client route. Both routes need `capabilities: { sampling: {} }` passed to + # `connect` (or `{ sampling: { tools: {} } }` for tool-enabled requests): per SEP-2322, a server MUST NOT embed input requests of + # a kind the client has not declared. # # @example Forward the request to an LLM and return its completion # @@ -546,11 +584,23 @@ def on_elicitation(&handler) # Register this handler only to interoperate with servers that still send sampling requests during the deprecation window; # new servers should call LLM provider APIs directly. def on_sampling(&handler) - unless transport.respond_to?(:on_server_request) - raise ArgumentError, "The transport does not support server-to-client requests" - end + register_input_handler(Methods::SAMPLING_CREATE_MESSAGE, &handler) + end - transport.on_server_request(Methods::SAMPLING_CREATE_MESSAGE, &handler) + # Registers a handler for `roots/list`, answering both a server-to-client request on transports that + # support one and an embedded `roots/list` inside a SEP-2322 `input_required` result. The handler + # receives the request `params` (`nil` for `roots/list`) and must return a `ListRootsResult`-shaped Hash: + # `{ roots: [{ uri: "file:///project", name: "Project" }] }`. + # + # @example + # client.on_roots { { roots: [{ uri: "file:///project", name: "Project" }] } } + # + # @deprecated MCP Roots (`roots/list`) is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). + # Register this handler only to interoperate with servers that still ask for roots. + # + # https://modelcontextprotocol.io/specification/2025-11-25/client/roots + def on_roots(&handler) + register_input_handler(Methods::ROOTS_LIST, &handler) end # Sends a `ping` request to the server to verify the connection is alive. @@ -576,6 +626,19 @@ def ping(meta: nil, cancellation: nil) private + # Records a handler for one of the three kinds of input a server can ask this client for, and wires it to + # the transport when the transport can carry server-to-client requests. One registration serves both routes: + # the real request a 2025-11-25 server sends mid-call, and the request embedded in a SEP-2322 + # `input_required` result, which is how the modern lifecycle asks now that it forbids server-to-client + # requests outright. Registering is therefore valid on a transport with no `on_server_request` (stdio, + # and every modern-lifecycle connection); only the wire route is skipped there. + def register_input_handler(method, &handler) + @input_required_handlers[method] = handler + transport.on_server_request(method, &handler) if transport.respond_to?(:on_server_request) + + handler + end + # SEP-2243: on the modern lifecycle, a tool definition whose `x-mcp-header` annotations violate # the spec constraints MUST be excluded from `tools/list` results, so one malformed definition # does not block the valid tools. The TypeScript and Python SDKs filter their listings the same way. @@ -655,7 +718,7 @@ def fetch_all_pages # without mutating the caller's hashes. Per SEP-414, `_meta` carries # request-specific metadata such as W3C trace context (`traceparent`, # `tracestate`, `baggage`); see {MCP::TraceContext}. - def request(method:, params: nil, meta: nil, cancellation: nil) + def request(method:, params: nil, meta: nil, cancellation: nil, raise_on_input_required: true) params = (params || {}).merge(_meta: meta) if meta && !meta.empty? request_body = { @@ -677,20 +740,104 @@ def request(method:, params: nil, meta: nil, cancellation: nil) raise ServerError.new(error["message"], code: error["code"], data: error["data"]) end - raise_on_input_required(response) + raise_on_input_required(response) if raise_on_input_required response end + # Drives the SEP-2322 multi round-trip loop for `tools/call`, `prompts/get`, and `resources/read`. + # With no configured handlers this degrades to the plain request (an `input_required` result raises + # `InputRequiredError` for manual driving). Otherwise 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. + # A `requestState`-only result (load shedding) retries after an exponential backoff. + # Every leg counts against `input_required_max_rounds`. + def drive_input_required(method:, params:, cancellation:) + response = request( + method: method, + params: params, + cancellation: cancellation, + raise_on_input_required: @input_required_handlers.empty?, + ) + return response unless input_required?(response) + + original_params = params.dup + original_params.delete(:inputResponses) + original_params.delete(:requestState) + rounds = 0 + backoff = STATE_ONLY_BACKOFF_INITIAL_SECONDS + + loop do + result = response["result"] + rounds += 1 + if rounds > @input_required_max_rounds + raise InputRequiredError.new( + "Server still returned `input_required` after #{@input_required_max_rounds} rounds (SEP-2322).", + input_requests: result["inputRequests"] || {}, + request_state: result["requestState"], + result: result, + ) + end + + input_requests = result["inputRequests"] || {} + responses = nil + if input_requests.empty? + sleep(backoff) + backoff = [backoff * 2, STATE_ONLY_BACKOFF_CAP_SECONDS].min + else + backoff = STATE_ONLY_BACKOFF_INITIAL_SECONDS + responses = fulfill_input_requests(input_requests, result) + end + + retry_params = original_params.dup + retry_params[:inputResponses] = responses if responses + retry_params[:requestState] = result["requestState"] if result["requestState"] + + response = request( + method: method, + params: retry_params, + cancellation: cancellation, + raise_on_input_required: false, + ) + return response unless input_required?(response) + end + end + + # Dispatches every embedded request to its configured handler and collects + # the answers under the same keys. A kind without a handler falls back to + # the manual path by raising `InputRequiredError` with the full result. + def fulfill_input_requests(input_requests, result) + input_requests.each_with_object({}) do |(key, entry), responses| + entry_method = entry.is_a?(Hash) ? entry["method"] || entry[:method] : nil + handler = @input_required_handlers[entry_method] + unless handler + raise InputRequiredError.new( + "Server requested #{entry_method.inspect} input (key #{key.inspect}) but no matching handler " \ + "is configured; inspect `input_requests` to respond manually (SEP-2322).", + input_requests: input_requests, + request_state: result["requestState"], + result: result, + ) + end + + responses[key] = handler.call(entry.is_a?(Hash) ? entry["params"] || entry[:params] : nil) + end + end + + def input_required?(response) + result = response.is_a?(Hash) ? response["result"] : nil + result.is_a?(Hash) && result["resultType"] == ResultType::INPUT_REQUIRED + end + # Recognizes a SEP-2322 `input_required` result and raises rather than returning it as if it were a final result. # Servers on stable protocol versions never emit `resultType`, so this is a no-op for them. def raise_on_input_required(response) - result = response.is_a?(Hash) ? response["result"] : nil - return unless result.is_a?(Hash) && result["resultType"] == ResultType::INPUT_REQUIRED + return unless input_required?(response) + result = response["result"] raise InputRequiredError.new( - "Server returned `input_required`; this SDK does not yet resume multi-round-trip requests (SEP-2322). " \ - "Inspect `input_requests` to respond manually.", + "Server returned `input_required` (SEP-2322). Register a handler with `on_elicitation`, " \ + "`on_sampling`, or `on_roots` to resume automatically, or inspect `input_requests` to respond manually.", input_requests: result["inputRequests"] || {}, request_state: result["requestState"], result: result, diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index b97f9f62..d944698f 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -55,15 +55,14 @@ def test_on_elicitation_registers_handler_on_transport 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 @@ def test_on_sampling_registers_handler_on_transport 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 + + 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") - assert_includes(error.message, "does not support server-to-client requests") + Client.new(transport: transport).on_roots(&handler) end # A transport that declares `mode:` on `connect`, recording what it received. @@ -380,16 +383,20 @@ def test_tools_excludes_invalid_x_mcp_header_definitions_on_a_modern_connection # 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 @@ def test_tools_excludes_invalid_x_mcp_header_definitions_on_a_modern_connection 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 @@ def test_list_tools_raises_input_required_error_for_input_required_results 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)