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
3 changed files with 748 additions and 9 deletions

View file

@ -1,5 +1,6 @@
# frozen_string_literal: true
require_relative "client/elicitation"
require_relative "client/oauth"
require_relative "client/stdio"
require_relative "client/http"
@ -29,6 +30,20 @@ module MCP
end
end
# Raised inside a server-to-client request handler (registered via `on_server_request`, e.g. `on_sampling`)
# to answer the request with a specific JSON-RPC error code instead of the default internal error.
# Mirrors the TypeScript SDK's `McpError` and the Python SDK's `ErrorData` return: for example,
# the sampling spec answers a rejected request with code `-1`.
# https://modelcontextprotocol.io/specification/2025-11-25/client/sampling
class ServerRequestError < StandardError
attr_reader :code
def initialize(message, code:)
super(message)
@code = code
end
end
# Raised when a server response fails client-side validation, e.g., a success response
# whose `result` field is missing or has the wrong type. This is distinct from a
# server-returned JSON-RPC error, which is raised as `ServerError`.
@ -352,6 +367,30 @@ module MCP
response.dig("result", "completion") || { "values" => [], "hasMore" => false }
end
# Registers a handler for `elicitation/create` requests the server sends while one of
# this client's requests is in flight. The handler receives the request `params`
# (message and `requestedSchema`, string keys) and must return an `ElicitResult`-shaped Hash:
# `{ action: "accept" | "decline" | "cancel", content: { ... } }`.
#
# Requires a transport that supports server-to-client requests (e.g. `MCP::Client::HTTP`);
# pass `capabilities: { elicitation: {} }` to `connect` so the server knows it may send them.
#
# @example Accept with schema defaults applied (SEP-1034)
# client.on_elicitation do |params|
# {
# action: "accept",
# content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]),
# }
# end
# https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation
def on_elicitation(&handler)
unless transport.respond_to?(:on_server_request)
raise ArgumentError, "The transport does not support server-to-client requests"
end
transport.on_server_request(Methods::ELICITATION_CREATE, &handler)
end
# Sends a `ping` request to the server to verify the connection is alive.
# Per the MCP spec, the server responds with an empty result.
#

View file

@ -0,0 +1,51 @@
# frozen_string_literal: true
module MCP
class Client
# Client-side helpers for the `elicitation/create` server-to-client request.
# https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation
module Elicitation
# Fills fields omitted from `content` with the `default` values declared in
# the elicitation request's `requestedSchema` properties, per SEP-1034.
# Provided values are never overwritten, and properties without a `default`
# are left out so the server can apply its own handling.
# Mirrors the TypeScript SDK's `applyElicitationDefaults`; the Python SDK
# applies defaults in the elicitation callback the same way this helper
# is intended to be used.
#
# @param requested_schema [Hash] The `requestedSchema` from the `elicitation/create`
# request params (string or symbol keys).
# @param content [Hash] Values already collected from the user.
# @return [Hash] `content` (string keys) with defaults filled in.
#
# @example Accept an elicitation request with all defaults
# transport.on_server_request("elicitation/create") do |params|
# {
# action: "accept",
# content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]),
# }
# end
class << self
def apply_defaults(requested_schema, content = {})
filled = content.to_h.transform_keys(&:to_s)
properties = requested_schema["properties"] || requested_schema[:properties]
return filled unless properties.is_a?(Hash)
properties.each do |name, property_schema|
name = name.to_s
next if filled.key?(name)
next unless property_schema.is_a?(Hash)
if property_schema.key?("default")
filled[name] = property_schema["default"]
elsif property_schema.key?(:default)
filled[name] = property_schema[:default]
end
end
filled
end
end
end
end
end

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?