From 4401833333944bc5176ca86eefd23480beb4c8c3 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 30 Jun 2026 11:34:17 +0900 Subject: [PATCH] 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. --- README.md | 10 ++++++ lib/mcp.rb | 1 + lib/mcp/client.rb | 37 ++++++++++++++++++++ lib/mcp/result_type.rb | 18 ++++++++++ test/mcp/client_test.rb | 77 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+) create mode 100644 lib/mcp/result_type.rb diff --git a/README.md b/README.md index b986c262..faf540b8 100644 --- a/README.md +++ b/README.md @@ -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. +### 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 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). diff --git a/lib/mcp.rb b/lib/mcp.rb index 867eb02e..53684e49 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -16,6 +16,7 @@ module MCP autoload :Prompt, "mcp/prompt" autoload :Resource, "mcp/resource" autoload :ResourceTemplate, "mcp/resource_template" + autoload :ResultType, "mcp/result_type" autoload :Server, "mcp/server" autoload :ServerSession, "mcp/server_session" autoload :Tool, "mcp/tool" diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index fbf04f32..0aefad9d 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -5,6 +5,7 @@ require_relative "client/http" require_relative "client/paginated_result" require_relative "client/tool" +require_relative "result_type" module MCP class Client @@ -34,6 +35,25 @@ def initialize(message, request, error_type: :internal_error, original_error: ni # server-returned JSON-RPC error, which is raised as `ServerError`. 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, # indicating the session has expired. Inherits from `RequestHandlerError` for # backward compatibility with callers that rescue the generic error. Per spec, @@ -422,9 +442,26 @@ 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) + response 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. # Ids are an internal concern: the public API never accepts or exposes them, and cancellation is driven through # an `MCP::Cancellation` token instead. diff --git a/lib/mcp/result_type.rb b/lib/mcp/result_type.rb new file mode 100644 index 00000000..9eeb3945 --- /dev/null +++ b/lib/mcp/result_type.rb @@ -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 diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 39fc47cf..b9fa6b09 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -164,6 +164,83 @@ def test_call_tool_by_name assert_equal([{ type: "text", text: "Hello, world!" }], content) 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 client = Client.new(transport: mock)