Merge pull request #433 from koic/mrtr_result_type

Recognize multi round-trip `input_required` results per SEP-2322
This commit is contained in:
Koichi ITO 2026-07-12 02:15:01 +09:00 • committed by GitHub
commit c43ea6e51b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 143 additions and 0 deletions

View file

@ -2464,6 +2464,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

@ -17,6 +17,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,
@ -434,9 +454,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)