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:
parent
b7bd9bb556
commit
4401833333
1 changed files with 143 additions and 0 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue