Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions lib/mcp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
37 changes: 37 additions & 0 deletions lib/mcp/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions lib/mcp/result_type.rb
Original file line number Diff line number Diff line change
@@ -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
77 changes: 77 additions & 0 deletions test/mcp/client_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down