Support client-side elicitation with SEP-1034 schema defaults

## Motivation and Context

Resolves the `elicitation-sep1034-client-defaults` client conformance scenario
(SEP-1034, https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1034).

The Ruby HTTP client could not receive server-to-client requests at all:
it never opened the spec's standalone GET SSE listening stream, and SSE events that
were not responses were discarded. A server sending `elicitation/create` during a tools/call
(such as the conformance harness, which is built on the TypeScript SDK server)
either dropped the request silently or deadlocked the tool call waiting for an answer that
could never arrive.

This change adds three pieces:

### Server-to-client request dispatch

`MCP::Client::HTTP#on_server_request(method) { |params| ... }` registers a handler;
when a JSON-RPC request (a message with both `method` and `id`) arrives on any SSE stream,
the handler's return value is POSTed back to the server as the JSON-RPC `result`
(the server ACKs with 202 Accepted, per the transport spec).
Methods without a handler are answered with a `-32601` method-not-found error.
A handler may raise `MCP::Client::ServerRequestError` to answer with a specific JSON-RPC error code
(for example `-1` for a rejected sampling request, per the sampling spec),
mirroring the TypeScript SDK's `McpError` and the Python SDK's `ErrorData` return;
any other error is answered with a `-32603` internal error. In every case the server is not left waiting,
matching how the TypeScript and Python SDKs convert handler failures into JSON-RPC error responses.

### A standalone GET SSE listening stream

Registering a handler (once connected) opens the spec's listening stream on a background thread,
because servers deliver requests that are not tied to a client request on that stream -
the TypeScript SDK's `StreamableHTTPServerTransport` silently drops them when no GET stream is open.
Mirrors the TypeScript and Python SDK clients, which start listening right after the `initialize` handshake
without awaiting stream establishment. Reconnection semantics also follow both SDKs:
a gracefully closed stream is retried indefinitely after the server's `retry:` interval,
while consecutive connection failures (HTTP errors and idle read timeouts) stop the listener once
they reach the reconnection attempt cap, with a successful stream resetting the count;
a 405 stops immediately without retrying, since it is the spec's answer from a server that
offers no listening stream. The listening GET uses a 5-minute read timeout matching
the Python SDK's `sse_read_timeout` default. `close` terminates the thread.

### SEP-1034 defaults

`MCP::Client::Elicitation.apply_defaults` fills fields omitted from the elicitation content with
the `default` values declared in `requestedSchema` properties, never overwriting provided values
and skipping properties without a default. This follows the Python SDK, which applies defaults in
the elicitation callback; the TypeScript SDK's automatic `applyElicitationDefaults`
(gated on the `elicitation.form.applyDefaults` capability) is a possible follow-up.
`MCP::Client#on_elicitation` registers a handler for `elicitation/create` as a convenience.

The conformance client gains an `elicitation-sep1034-client-defaults` branch that connects with
`capabilities: { elicitation: {} }`, accepts the elicitation with defaults applied, and calls
the harness's `test_client_elicitation_defaults` tool; the scenario is removed from the expected failures baseline.

## How Has This Been Tested?

New unit tests covering: handler dispatch from a request's POST SSE stream with
the response body and 202 ACK verified, the `-32601` answer for unregistered methods,
the `-32603` answer for a raising handler, the custom-code answer when a handler raises
`ServerRequestError`, listener startup from both registration-after-connect and
connect-with-registered-handlers, listener shutdown after consecutive connection failures with
the count resetting after a successful stream and an immediate stop on 405,
and `apply_defaults` for all five SEP-1034 property types (string, integer, number, enum, boolean),
non-overwriting of provided values, omission of properties without defaults, and symbol-keyed schemas

## Breaking Changes

None. The listening stream and its background thread are created only when a server-request handler is registered;
clients that never call `on_server_request` / `on_elicitation` keep the existing single-request behavior.
This commit is contained in:
Koichi ITO 2026-06-13 00:57:55 +09:00
commit 04bb739c68

View file

@ -8,9 +8,6 @@ require_relative "../version"
module MCP
class Client
# TODO: A standalone HTTP GET listening stream for server-initiated messages is not yet implemented;
# GET is currently used only to resume a request's SSE stream after a disconnect.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server
class HTTP
ACCEPT_HEADER = "application/json, text/event-stream"
SSE_ACCEPT_HEADER = "text/event-stream"
@ -27,6 +24,12 @@ module MCP
DEFAULT_RECONNECTION_DELAY_MS = 1000
MAX_RECONNECTION_ATTEMPTS = 2
# How long the standalone GET listening stream may stay idle before the read times out
# and the connection is counted as a failure and retried. Matches the Python SDK's
# `sse_read_timeout` default of 5 minutes; without this, the adapter's default read timeout
# (60 seconds for Net::HTTP) would recycle quiet streams too eagerly.
SSE_LISTENER_READ_TIMEOUT = 300
# Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor
# a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host
# is trivially observed and stolen.
@ -75,8 +78,9 @@ module MCP
attr_reader :buffer, :response, :last_event_id, :retry_ms
attr_accessor :abortable
def initialize(abortable:)
def initialize(abortable:, on_request: nil)
@abortable = abortable
@on_request = on_request
@buffer = +""
@parser = nil
@response = nil
@ -138,8 +142,14 @@ module MCP
next
end
if parsed.is_a?(Hash) && (parsed.key?("result") || parsed.key?("error"))
next unless parsed.is_a?(Hash)
if parsed.key?("result") || parsed.key?("error")
@response ||= parsed
elsif parsed["method"] && parsed.key?("id")
# A server-to-client request (e.g. `elicitation/create`) delivered
# on the stream while the original request is still pending.
@on_request&.call(parsed)
end
end
end
@ -194,6 +204,24 @@ module MCP
@protocol_version = nil
@server_info = nil
@connected = false
@server_request_handlers = {}
@listener_thread = nil
end
# Registers a handler for a server-to-client request (e.g. `elicitation/create`) delivered on an SSE stream.
# The handler receives the request's `params` (a Hash with string keys, possibly empty) and its return value is
# sent back to the server as the JSON-RPC `result`.
# The handler may raise `MCP::Client::ServerRequestError` to answer with a specific JSON-RPC error code.
# Requests for methods without a registered handler are answered with a JSON-RPC "method not found" (-32601) error.
# Registering a handler opens a standalone GET SSE listening stream (once connected), since servers send requests
# that are not tied to a client request on that stream - matching the TypeScript and Python SDK clients,
# which start listening after the `initialize` handshake.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server
def on_server_request(method, &handler)
raise ArgumentError, "A handler block is required" unless handler
@server_request_handlers[method.to_s] = handler
start_listening if connected?
end
# Performs the MCP `initialize` handshake: sends an `initialize` request
@ -272,6 +300,7 @@ module MCP
end
@connected = true
start_listening if @server_request_handlers.any?
@server_info
end
@ -305,8 +334,13 @@ module MCP
# The response is consumed incrementally so that an SSE stream the server holds open
# (or closes early per SEP-1699) can be handled; `initialize` streams are read to EOF
# so the response object (and its `Mcp-Session-Id` header) is always available for capture.
stream = SSEStream.new(abortable: method.to_s != MCP::Methods::INITIALIZE)
stream = SSEStream.new(
abortable: method.to_s != MCP::Methods::INITIALIZE,
on_request: ->(message) { dispatch_server_request(message) },
)
yield if block_given?
response = begin
client.post("", request, session_headers.merge(request_metadata_headers(method, params))) do |req|
req.options.on_data = stream.on_data
@ -636,12 +670,64 @@ module MCP
end
def clear_session
stop_listening
@session_id = nil
@protocol_version = nil
@server_info = nil
@connected = false
end
# Opens the standalone GET SSE listening stream on a background thread so server-to-client requests
# can arrive while the main thread is blocked on its own request (e.g. a tools/call awaiting elicitation).
# Like the TypeScript and Python SDK clients, the GET is fired without waiting for it to be established.
def start_listening
return if @listener_thread&.alive?
@listener_thread = Thread.new { listen_for_server_requests }
end
def stop_listening
@listener_thread&.kill
@listener_thread = nil
end
# Reconnection semantics match the TypeScript and Python SDK clients: a stream that was established and
# then closed gracefully is retried indefinitely after the server's `retry:` interval (SSE semantics),
# while consecutive connection failures - HTTP errors and idle read timeouts both count - stop the listener
# once they reach `MAX_RECONNECTION_ATTEMPTS`; a successful stream in between resets the count.
# A 405 stops immediately without retrying, since it is the spec's answer from a server that offers no listening stream.
def listen_for_server_requests
stream = SSEStream.new(
abortable: false,
on_request: ->(message) { dispatch_server_request(message) },
)
consecutive_failures = 0
loop do
begin
client.get("") do |request|
request.headers.update(session_headers)
request.headers["Accept"] = SSE_ACCEPT_HEADER
request.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id
request.options.read_timeout = SSE_LISTENER_READ_TIMEOUT
request.options.on_data = stream.on_data
end
consecutive_failures = 0
rescue Faraday::Error => e
break if e.response&.dig(:status) == 405
consecutive_failures += 1
break if consecutive_failures >= MAX_RECONNECTION_ATTEMPTS
end
stream.reset_parser!
sleep((stream.retry_ms || DEFAULT_RECONNECTION_DELAY_MS) / 1000.0)
end
end
def require_faraday!
require "faraday"
rescue LoadError
@ -694,6 +780,56 @@ module MCP
end
end
# Answers a server-to-client request received on an SSE stream by invoking the handler registered via
# `on_server_request` and POSTing its return value back as a JSON-RPC response; the server ACKs with
# 202 Accepted. Requests without a registered handler are answered with a "method not found" error.
# A handler may raise `ServerRequestError` to answer with a specific JSON-RPC error code
# (e.g. `-1` for a rejected sampling request); any other error is answered with an "internal error".
# In every case the server is not left waiting, matching how the TypeScript and Python SDKs convert handler
# failures into JSON-RPC error responses.
def dispatch_server_request(message)
handler = @server_request_handlers[message["method"]]
response = if handler
begin
{
jsonrpc: JsonRpcHandler::Version::V2_0,
id: message["id"],
result: handler.call(message["params"] || {}),
}
rescue ServerRequestError => e
{
jsonrpc: JsonRpcHandler::Version::V2_0,
id: message["id"],
error: { code: e.code, message: e.message },
}
rescue StandardError => e
{
jsonrpc: JsonRpcHandler::Version::V2_0,
id: message["id"],
error: {
code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
message: "Internal error handling #{message["method"]} request: #{e.message}",
},
}
end
else
{
jsonrpc: JsonRpcHandler::Version::V2_0,
id: message["id"],
error: {
code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND,
message: "Method not found: #{message["method"]}",
},
}
end
send_client_response(response)
end
def send_client_response(response)
client.post("", response, session_headers)
end
def parse_json_buffer(buffer, method, params)
return if buffer.empty?