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

View file

@ -164,6 +164,83 @@ module MCP
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)