Skip to content

Commit 4401833

Browse files
committed
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 modelcontextprotocol#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.
1 parent b7bd9bb commit 4401833

5 files changed

Lines changed: 143 additions & 0 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2232,6 +2232,16 @@ The client provides a wrapper class for tools returned by the server:
22322232

22332233
This class provides easy access to tool properties like name, description, input schema, and output schema.
22342234

2235+
### Multi-Round-Trip Results (Experimental, SEP-2322)
2236+
2237+
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`,
2238+
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
2239+
and an opaque `requestState`; the client fulfills the requests and re-issues the original request with `inputResponses` and the echoed `requestState`.
2240+
2241+
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`,
2242+
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`
2243+
are provided for forward compatibility. Servers on stable protocol versions never send `resultType`, so existing behavior is unchanged.
2244+
22352245
## Conformance Testing
22362246

22372247
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).

lib/mcp.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ module MCP
1616
autoload :Prompt, "mcp/prompt"
1717
autoload :Resource, "mcp/resource"
1818
autoload :ResourceTemplate, "mcp/resource_template"
19+
autoload :ResultType, "mcp/result_type"
1920
autoload :Server, "mcp/server"
2021
autoload :ServerSession, "mcp/server_session"
2122
autoload :Tool, "mcp/tool"

lib/mcp/client.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
require_relative "client/http"
66
require_relative "client/paginated_result"
77
require_relative "client/tool"
8+
require_relative "result_type"
89

910
module MCP
1011
class Client
@@ -34,6 +35,25 @@ def initialize(message, request, error_type: :internal_error, original_error: ni
3435
# server-returned JSON-RPC error, which is raised as `ServerError`.
3536
class ValidationError < StandardError; end
3637

38+
# Raised when a server answers with a SEP-2322 Multi Round-Trip `input_required` result instead of
39+
# a final result. The result is not an error on the wire: it asks the client to fulfill the server's
40+
# `inputRequests` (a map of id => `{ "method" => ..., "params" => ... }` request objects with
41+
# `sampling/createMessage`, `roots/list`, or `elicitation/create` shapes) and re-issue
42+
# the original request with `inputResponses` plus the echoed opaque `requestState`.
43+
# This SDK does not yet drive that resume loop automatically; callers can inspect `input_requests`
44+
# and respond manually.
45+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322
46+
class InputRequiredError < StandardError
47+
attr_reader :input_requests, :request_state, :result
48+
49+
def initialize(message, input_requests:, request_state: nil, result: nil)
50+
super(message)
51+
@input_requests = input_requests
52+
@request_state = request_state
53+
@result = result
54+
end
55+
end
56+
3757
# Raised when the server responds 404 to a request containing a session ID,
3858
# indicating the session has expired. Inherits from `RequestHandlerError` for
3959
# backward compatibility with callers that rescue the generic error. Per spec,
@@ -422,9 +442,26 @@ def request(method:, params: nil, meta: nil, cancellation: nil)
422442
raise ServerError.new(error["message"], code: error["code"], data: error["data"])
423443
end
424444

445+
raise_on_input_required(response)
446+
425447
response
426448
end
427449

450+
# Recognizes a SEP-2322 `input_required` result and raises rather than returning it as if it were a final result.
451+
# Servers on stable protocol versions never emit `resultType`, so this is a no-op for them.
452+
def raise_on_input_required(response)
453+
result = response.is_a?(Hash) ? response["result"] : nil
454+
return unless result.is_a?(Hash) && result["resultType"] == ResultType::INPUT_REQUIRED
455+
456+
raise InputRequiredError.new(
457+
"Server returned `input_required`; this SDK does not yet resume multi-round-trip requests (SEP-2322). " \
458+
"Inspect `input_requests` to respond manually.",
459+
input_requests: result["inputRequests"] || {},
460+
request_state: result["requestState"],
461+
result: result,
462+
)
463+
end
464+
428465
# Generates a fresh JSON-RPC request id for an outgoing request.
429466
# Ids are an internal concern: the public API never accepts or exposes them, and cancellation is driven through
430467
# an `MCP::Cancellation` token instead.

lib/mcp/result_type.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# frozen_string_literal: true
2+
3+
module MCP
4+
# Values of the `resultType` result field introduced by SEP-2322 (Multi Round-Trip Requests)
5+
# for the MCP 2026-07-28 draft.
6+
#
7+
# A result with `resultType: "input_required"` is not a final answer: it carries an `inputRequests` map
8+
# of server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create` shapes) plus
9+
# an opaque `requestState` string, and the client is expected to fulfill the requests and re-issue
10+
# the original request with `inputResponses` and the echoed `requestState`. A missing `resultType`
11+
# or `"complete"` is a final result.
12+
#
13+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322
14+
module ResultType
15+
COMPLETE = "complete"
16+
INPUT_REQUIRED = "input_required"
17+
end
18+
end

test/mcp/client_test.rb

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,83 @@ def test_call_tool_by_name
164164
assert_equal([{ type: "text", text: "Hello, world!" }], content)
165165
end
166166

167+
def test_call_tool_raises_input_required_error_when_result_type_is_input_required
168+
# Per SEP-2322, a result with `resultType: "input_required"` is not a final result;
169+
# surface it instead of returning it as a normal result.
170+
transport = mock
171+
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
172+
mock_response = {
173+
"result" => {
174+
"resultType" => "input_required",
175+
"inputRequests" => {
176+
"1" => { "method" => "elicitation/create", "params" => { "mode" => "form", "message" => "Name?" } },
177+
},
178+
"requestState" => "opaque-state",
179+
},
180+
}
181+
182+
transport.expects(:send_request).returns(mock_response).once
183+
184+
client = Client.new(transport: transport)
185+
error = assert_raises(Client::InputRequiredError) do
186+
client.call_tool(tool: tool, arguments: {})
187+
end
188+
189+
assert_equal(
190+
{ "1" => { "method" => "elicitation/create", "params" => { "mode" => "form", "message" => "Name?" } } },
191+
error.input_requests,
192+
)
193+
assert_equal("opaque-state", error.request_state)
194+
assert_equal(mock_response["result"], error.result)
195+
end
196+
197+
def test_call_tool_returns_normally_when_result_type_is_complete
198+
transport = mock
199+
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
200+
mock_response = {
201+
"result" => {
202+
"resultType" => "complete",
203+
"content" => [{ "type" => "text", "text" => "done" }],
204+
},
205+
}
206+
207+
transport.expects(:send_request).returns(mock_response).once
208+
209+
client = Client.new(transport: transport)
210+
result = client.call_tool(tool: tool, arguments: {})
211+
212+
assert_equal("done", result.dig("result", "content", 0, "text"))
213+
end
214+
215+
def test_call_tool_returns_normally_when_result_type_is_absent
216+
# Regression guard: stable-protocol servers never send `resultType`.
217+
transport = mock
218+
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
219+
mock_response = {
220+
"result" => { "content" => [{ "type" => "text", "text" => "done" }] },
221+
}
222+
223+
transport.expects(:send_request).returns(mock_response).once
224+
225+
client = Client.new(transport: transport)
226+
result = client.call_tool(tool: tool, arguments: {})
227+
228+
assert_equal("done", result.dig("result", "content", 0, "text"))
229+
end
230+
231+
def test_list_tools_raises_input_required_error_for_input_required_results
232+
# The recognition lives in the shared request path, so every client method is covered, not only tools/call.
233+
transport = mock
234+
mock_response = {
235+
"result" => { "resultType" => "input_required", "inputRequests" => {} },
236+
}
237+
238+
transport.expects(:send_request).returns(mock_response).once
239+
240+
client = Client.new(transport: transport)
241+
assert_raises(Client::InputRequiredError) { client.list_tools }
242+
end
243+
167244
def test_call_tool_raises_when_no_name_or_tool
168245
client = Client.new(transport: mock)
169246

0 commit comments

Comments
 (0)