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
5 changed files with 463 additions and 77 deletions

View file

@ -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

View file

@ -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" }

View file

@ -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

View file

@ -57,8 +57,9 @@ module MCP
# `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 @@ module MCP
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 @@ module MCP
# @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 @@ module MCP
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 @@ module MCP
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
# @return [Array<Hash>] 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 @@ module MCP
# 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 @@ module MCP
# (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 @@ module MCP
# 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 @@ module MCP
# 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 @@ module MCP
# 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 @@ module MCP
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 @@ module MCP
# 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 @@ module MCP
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,

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)