Recognize multi round-trip input_required results per SEP-2322

## Motivation and Context

SEP-2322 (modelcontextprotocol/modelcontextprotocol#2322, merged for the 2026-07-28 spec release)
introduces Multi Round-Trip Requests: instead of issuing in-flight server-to-client JSON-RPC requests,
a server answers with a result whose `resultType` is `"input_required"`, carrying an `inputRequests` map
(of `sampling/createMessage`, `roots/list`, and `elicitation/create` request shapes) and
an opaque `requestState`; the client fulfills the requests and re-issues the original request with
`inputResponses` and the echoed `requestState`.

The wire contract (the `resultType` discriminator and the `inputRequests`/`requestState` shape)
stayed stable across all three closed TypeScript prototype iterations (typescript-sdk#2062/#2065,
the v2-stateless stack, and #2251) and the Python draft (python-sdk#2322), but the server-side
suspend/resume mechanism is still unsettled in both SDKs (typescript-sdk#2251 was put on hold on 2026-06-08).
This change therefore implements only the stable, additive vocabulary and the client-side recognition,
leaving server emission and automatic resumption for a follow-up once the reference design lands:

- New `MCP::ResultType` module with `COMPLETE` and `INPUT_REQUIRED` constants documenting the `resultType` values.
- `MCP::Client` raises the new `MCP::Client::InputRequiredError` (exposing `input_requests`, `request_state`,
  and the raw `result`) when any response carries `resultType: "input_required"`, instead of silently returning
  a non-final result as if it were the answer. The check lives in the shared request path,
  so every client method is covered. Servers on stable protocol versions never emit `resultType`,
  so default behavior is unchanged.

Part of #382.

## How Has This Been Tested?

New tests in `test/mcp/client_test.rb`:

- `call_tool` raises `InputRequiredError` for an `input_required` result and exposes `input_requests`,
  `request_state`, and the full raw result
- `call_tool` returns normally when `resultType` is `"complete"` and when it is absent
  (wire-compat regression for stable-protocol servers)
- `list_tools` also raises for `input_required` results, proving the recognition covers the shared request path

`bundle exec rake` (tests, RuboCop, and conformance baseline) passes.

## Breaking Changes

None for spec-compliant stable servers, which never send `resultType`. A response that does carry
`resultType: "input_required"` now raises `MCP::Client::InputRequiredError` instead of being returned as
a final result, which was always a misinterpretation of the draft semantics.
This commit is contained in:
Koichi ITO 2026-06-30 11:34:17 +09:00
commit 4401833333
5 changed files with 143 additions and 0 deletions

View file

@ -2232,6 +2232,16 @@ The client provides a wrapper class for tools returned by the server:
This class provides easy access to tool properties like name, description, input schema, and output schema. This class provides easy access to tool properties like name, description, input schema, and output schema.
### Multi-Round-Trip Results (Experimental, SEP-2322)
The MCP 2026-07-28 draft replaces in-flight server-to-client requests with Multi Round-Trip Requests: instead of issuing `sampling/createMessage`, `roots/list`,
or `elicitation/create` while a request is being processed, a server may answer with a result whose `resultType` is `"input_required"`, carrying an `inputRequests` map
and an opaque `requestState`; the client fulfills the requests and re-issues the original request with `inputResponses` and the echoed `requestState`.
The Ruby client recognizes such results and raises `MCP::Client::InputRequiredError` instead of returning them as if they were final. The error exposes `input_requests`, `request_state`,
and the raw `result`; automatic resumption is not implemented yet, so callers respond manually if they opt into the draft flow. `MCP::ResultType::COMPLETE` and `MCP::ResultType::INPUT_REQUIRED`
are provided for forward compatibility. Servers on stable protocol versions never send `resultType`, so existing behavior is unchanged.
## Conformance Testing ## Conformance Testing
The `conformance/` directory contains a test server and runner that validate the SDK against the MCP specification using [`@modelcontextprotocol/conformance`](https://github.com/modelcontextprotocol/conformance). The `conformance/` directory contains a test server and runner that validate the SDK against the MCP specification using [`@modelcontextprotocol/conformance`](https://github.com/modelcontextprotocol/conformance).

View file

@ -16,6 +16,7 @@ module MCP
autoload :Prompt, "mcp/prompt" autoload :Prompt, "mcp/prompt"
autoload :Resource, "mcp/resource" autoload :Resource, "mcp/resource"
autoload :ResourceTemplate, "mcp/resource_template" autoload :ResourceTemplate, "mcp/resource_template"
autoload :ResultType, "mcp/result_type"
autoload :Server, "mcp/server" autoload :Server, "mcp/server"
autoload :ServerSession, "mcp/server_session" autoload :ServerSession, "mcp/server_session"
autoload :Tool, "mcp/tool" autoload :Tool, "mcp/tool"

View file

@ -5,6 +5,7 @@ require_relative "client/stdio"
require_relative "client/http" require_relative "client/http"
require_relative "client/paginated_result" require_relative "client/paginated_result"
require_relative "client/tool" require_relative "client/tool"
require_relative "result_type"
module MCP module MCP
class Client class Client
@ -34,6 +35,25 @@ module MCP
# server-returned JSON-RPC error, which is raised as `ServerError`. # server-returned JSON-RPC error, which is raised as `ServerError`.
class ValidationError < StandardError; end class ValidationError < StandardError; end
# Raised when a server answers with a SEP-2322 Multi Round-Trip `input_required` result instead of
# a final result. The result is not an error on the wire: it asks the client to fulfill the server's
# `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.
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322
class InputRequiredError < StandardError
attr_reader :input_requests, :request_state, :result
def initialize(message, input_requests:, request_state: nil, result: nil)
super(message)
@input_requests = input_requests
@request_state = request_state
@result = result
end
end
# Raised when the server responds 404 to a request containing a session ID, # Raised when the server responds 404 to a request containing a session ID,
# indicating the session has expired. Inherits from `RequestHandlerError` for # indicating the session has expired. Inherits from `RequestHandlerError` for
# backward compatibility with callers that rescue the generic error. Per spec, # backward compatibility with callers that rescue the generic error. Per spec,
@ -422,9 +442,26 @@ module MCP
raise ServerError.new(error["message"], code: error["code"], data: error["data"]) raise ServerError.new(error["message"], code: error["code"], data: error["data"])
end end
raise_on_input_required(response)
response response
end 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
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.",
input_requests: result["inputRequests"] || {},
request_state: result["requestState"],
result: result,
)
end
# Generates a fresh JSON-RPC request id for an outgoing request. # Generates a fresh JSON-RPC request id for an outgoing request.
# Ids are an internal concern: the public API never accepts or exposes them, and cancellation is driven through # Ids are an internal concern: the public API never accepts or exposes them, and cancellation is driven through
# an `MCP::Cancellation` token instead. # an `MCP::Cancellation` token instead.

18
lib/mcp/result_type.rb Normal file
View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
module MCP
# Values of the `resultType` result field introduced by SEP-2322 (Multi Round-Trip Requests)
# for the MCP 2026-07-28 draft.
#
# A result with `resultType: "input_required"` is not a final answer: it carries an `inputRequests` map
# of server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create` shapes) plus
# an opaque `requestState` string, and the client is expected to fulfill the requests and re-issue
# the original request with `inputResponses` and the echoed `requestState`. A missing `resultType`
# or `"complete"` is a final result.
#
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322
module ResultType
COMPLETE = "complete"
INPUT_REQUIRED = "input_required"
end
end

View file

@ -164,6 +164,83 @@ module MCP
assert_equal([{ type: "text", text: "Hello, world!" }], content) assert_equal([{ type: "text", text: "Hello, world!" }], content)
end end
def test_call_tool_raises_input_required_error_when_result_type_is_input_required
# Per SEP-2322, a result with `resultType: "input_required"` is not a final result;
# surface it instead of returning it as a normal result.
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
mock_response = {
"result" => {
"resultType" => "input_required",
"inputRequests" => {
"1" => { "method" => "elicitation/create", "params" => { "mode" => "form", "message" => "Name?" } },
},
"requestState" => "opaque-state",
},
}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
error = assert_raises(Client::InputRequiredError) do
client.call_tool(tool: tool, arguments: {})
end
assert_equal(
{ "1" => { "method" => "elicitation/create", "params" => { "mode" => "form", "message" => "Name?" } } },
error.input_requests,
)
assert_equal("opaque-state", error.request_state)
assert_equal(mock_response["result"], error.result)
end
def test_call_tool_returns_normally_when_result_type_is_complete
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
mock_response = {
"result" => {
"resultType" => "complete",
"content" => [{ "type" => "text", "text" => "done" }],
},
}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
result = client.call_tool(tool: tool, arguments: {})
assert_equal("done", result.dig("result", "content", 0, "text"))
end
def test_call_tool_returns_normally_when_result_type_is_absent
# Regression guard: stable-protocol servers never send `resultType`.
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
mock_response = {
"result" => { "content" => [{ "type" => "text", "text" => "done" }] },
}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
result = client.call_tool(tool: tool, arguments: {})
assert_equal("done", result.dig("result", "content", 0, "text"))
end
def test_list_tools_raises_input_required_error_for_input_required_results
# The recognition lives in the shared request path, so every client method is covered, not only tools/call.
transport = mock
mock_response = {
"result" => { "resultType" => "input_required", "inputRequests" => {} },
}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
assert_raises(Client::InputRequiredError) { client.list_tools }
end
def test_call_tool_raises_when_no_name_or_tool def test_call_tool_raises_when_no_name_or_tool
client = Client.new(transport: mock) client = Client.new(transport: mock)