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

@ -0,0 +1,100 @@
# frozen_string_literal: true
require "test_helper"
require "mcp/client/elicitation"
module MCP
class Client
class ElicitationTest < Minitest::Test
REQUESTED_SCHEMA = {
"type" => "object",
"properties" => {
"name" => { "type" => "string", "default" => "John Doe" },
"age" => { "type" => "integer", "default" => 30 },
"score" => { "type" => "number", "default" => 95.5 },
"status" => { "type" => "string", "enum" => ["active", "inactive", "pending"], "default" => "active" },
"verified" => { "type" => "boolean", "default" => true },
},
"required" => [],
}.freeze
def test_apply_defaults_fills_all_omitted_fields
content = Elicitation.apply_defaults(REQUESTED_SCHEMA)
assert_equal(
{
"name" => "John Doe",
"age" => 30,
"score" => 95.5,
"status" => "active",
"verified" => true,
},
content,
)
end
def test_apply_defaults_does_not_overwrite_provided_values
content = Elicitation.apply_defaults(REQUESTED_SCHEMA, { "name" => "Alice", "verified" => false })
assert_equal(
{
"name" => "Alice",
"verified" => false,
"age" => 30,
"score" => 95.5,
"status" => "active",
},
content,
)
end
def test_apply_defaults_skips_properties_without_a_default
schema = {
"type" => "object",
"properties" => {
"name" => { "type" => "string", "default" => "John Doe" },
"email" => { "type" => "string" },
},
}
content = Elicitation.apply_defaults(schema)
assert_equal({ "name" => "John Doe" }, content)
refute(content.key?("email"))
end
def test_apply_defaults_applies_a_false_default
schema = {
"type" => "object",
"properties" => {
"verified" => { "type" => "boolean", "default" => false },
},
}
content = Elicitation.apply_defaults(schema)
assert_equal({ "verified" => false }, content)
end
def test_apply_defaults_supports_symbol_keyed_schemas_and_content
schema = {
type: "object",
properties: {
name: { type: "string", default: "John Doe" },
age: { type: "integer", default: 30 },
},
}
content = Elicitation.apply_defaults(schema, { age: 42 })
assert_equal({ "age" => 42, "name" => "John Doe" }, content)
end
def test_apply_defaults_returns_content_when_schema_has_no_properties
content = Elicitation.apply_defaults({ "type" => "object" }, { "name" => "Alice" })
assert_equal({ "name" => "Alice" }, content)
end
end
end
end

View file

@ -853,6 +853,347 @@ module MCP
assert_empty(stream.buffer)
end
def test_send_request_dispatches_server_request_to_registered_handler
request = {
jsonrpc: "2.0",
id: "test_id",
method: "tools/call",
params: { name: "test_client_elicitation_defaults", arguments: {} },
}
elicitation_request = {
jsonrpc: "2.0",
id: 0,
method: "elicitation/create",
params: {
message: "Please accept with defaults",
requestedSchema: {
type: "object",
properties: { name: { type: "string", default: "John Doe" } },
required: [],
},
},
}
tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } }
sse_body = "event: message\ndata: #{elicitation_request.to_json}\n\n" \
"event: message\ndata: #{tool_result.to_json}\n\n"
stub_request(:post, url).with(
body: request.to_json,
).to_return(
status: 200,
headers: { "Content-Type" => "text/event-stream" },
body: sse_body,
)
expected_response = {
jsonrpc: "2.0",
id: 0,
result: { action: "accept", content: { name: "John Doe" } },
}
response_stub = stub_request(:post, url).with(
body: expected_response.to_json,
).to_return(
status: 202, body: "",
)
received_params = nil
client.on_server_request("elicitation/create") do |params|
received_params = params
{
action: "accept",
content: { name: params.dig("requestedSchema", "properties", "name", "default") },
}
end
response = client.send_request(request: request)
assert_equal({ "content" => [] }, response["result"])
assert_requested(response_stub)
assert_equal("Please accept with defaults", received_params["message"])
end
def test_send_request_answers_unregistered_server_request_with_method_not_found
request = {
jsonrpc: "2.0",
id: "test_id",
method: "tools/call",
params: { name: "some_tool", arguments: {} },
}
server_request = {
jsonrpc: "2.0",
id: 5,
method: "sampling/createMessage",
params: {},
}
tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } }
sse_body = "event: message\ndata: #{server_request.to_json}\n\n" \
"event: message\ndata: #{tool_result.to_json}\n\n"
stub_request(:post, url).with(
body: request.to_json,
).to_return(
status: 200,
headers: { "Content-Type" => "text/event-stream" },
body: sse_body,
)
expected_error_response = {
jsonrpc: "2.0",
id: 5,
error: { code: -32601, message: "Method not found: sampling/createMessage" },
}
error_stub = stub_request(:post, url).with(
body: expected_error_response.to_json,
).to_return(
status: 202, body: "",
)
response = client.send_request(request: request)
assert_equal({ "content" => [] }, response["result"])
assert_requested(error_stub)
end
def test_send_request_answers_raising_handler_with_internal_error
request = {
jsonrpc: "2.0",
id: "test_id",
method: "tools/call",
params: { name: "some_tool", arguments: {} },
}
server_request = {
jsonrpc: "2.0",
id: 9,
method: "elicitation/create",
params: {},
}
tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } }
sse_body = "event: message\ndata: #{server_request.to_json}\n\n" \
"event: message\ndata: #{tool_result.to_json}\n\n"
stub_request(:post, url).with(
body: request.to_json,
).to_return(
status: 200,
headers: { "Content-Type" => "text/event-stream" },
body: sse_body,
)
expected_error_response = {
jsonrpc: "2.0",
id: 9,
error: { code: -32603, message: "Internal error handling elicitation/create request: boom" },
}
error_stub = stub_request(:post, url).with(
body: expected_error_response.to_json,
).to_return(
status: 202, body: "",
)
client.on_server_request("elicitation/create") { raise "boom" }
response = client.send_request(request: request)
assert_equal({ "content" => [] }, response["result"])
assert_requested(error_stub)
end
def test_send_request_answers_server_request_error_with_its_code
request = {
jsonrpc: "2.0",
id: "test_id",
method: "tools/call",
params: { name: "ask_llm", arguments: {} },
}
server_request = {
jsonrpc: "2.0",
id: 11,
method: "sampling/createMessage",
params: {},
}
tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } }
sse_body = "event: message\ndata: #{server_request.to_json}\n\n" \
"event: message\ndata: #{tool_result.to_json}\n\n"
stub_request(:post, url).with(
body: request.to_json,
).to_return(
status: 200,
headers: { "Content-Type" => "text/event-stream" },
body: sse_body,
)
expected_error_response = {
jsonrpc: "2.0",
id: 11,
error: { code: -1, message: "User rejected sampling request" },
}
error_stub = stub_request(:post, url).with(
body: expected_error_response.to_json,
).to_return(
status: 202, body: "",
)
client.on_server_request("sampling/createMessage") do
raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1)
end
response = client.send_request(request: request)
assert_equal({ "content" => [] }, response["result"])
assert_requested(error_stub)
end
def test_on_server_request_requires_a_block
assert_raises(ArgumentError) do
client.on_server_request("elicitation/create")
end
end
def test_registering_a_handler_after_connect_listens_on_standalone_get_stream
stub_initialize
stub_notification
stub_request(:delete, url).to_return(status: 200)
elicitation_request = {
jsonrpc: "2.0",
id: 7,
method: "elicitation/create",
params: {
message: "Please accept with defaults",
requestedSchema: {
type: "object",
properties: { name: { type: "string", default: "John Doe" } },
required: [],
},
},
}
stub_request(:get, url)
.with(
headers: { "Accept" => "text/event-stream", "Mcp-Session-Id" => "session-abc" },
).to_return(
status: 200,
headers: { "Content-Type" => "text/event-stream" },
body: "event: message\ndata: #{elicitation_request.to_json}\n\n",
)
expected_response = {
jsonrpc: "2.0",
id: 7,
result: { action: "accept", content: { name: "John Doe" } },
}
response_stub = stub_request(:post, url).with(
body: expected_response.to_json,
).to_return(
status: 202, body: "",
)
client.connect
client.on_server_request("elicitation/create") do |params|
{
action: "accept",
content: { name: params.dig("requestedSchema", "properties", "name", "default") },
}
end
wait_until { requested?(response_stub) }
assert_requested(response_stub)
ensure
client.close
end
def test_connect_listens_on_standalone_get_stream_when_a_handler_is_registered
stub_initialize
stub_notification
stub_request(:delete, url).to_return(status: 200)
get_stub = stub_request(:get, url).with(
headers: { "Accept" => "text/event-stream" },
).to_return(
status: 200,
headers: { "Content-Type" => "text/event-stream" },
body: "",
)
client.on_server_request("elicitation/create") { { action: "decline" } }
assert_not_requested(:get, url)
client.connect
wait_until { requested?(get_stub) }
assert_requested(get_stub)
ensure
client.close
end
def test_listener_stops_after_consecutive_connection_failures
stub_initialize
stub_notification
stub_request(:delete, url).to_return(status: 200)
stub_request(:get, url).to_return(status: 500)
client.stubs(:sleep)
client.connect
client.on_server_request("elicitation/create") { { action: "decline" } }
listener = client.instance_variable_get(:@listener_thread)
wait_until { !listener.alive? }
assert_requested(:get, url, times: HTTP::MAX_RECONNECTION_ATTEMPTS)
ensure
client.close
end
def test_listener_failure_count_resets_after_a_successful_stream
stub_initialize
stub_notification
stub_request(:delete, url).to_return(status: 200)
stub_request(:get, url).to_return(
{ status: 500 },
{ status: 200, headers: { "Content-Type" => "text/event-stream" }, body: "" },
{ status: 500 },
{ status: 500 },
)
client.stubs(:sleep)
client.connect
client.on_server_request("elicitation/create") { { action: "decline" } }
listener = client.instance_variable_get(:@listener_thread)
wait_until { !listener.alive? }
# Without the reset, the second failure (the third request) would
# already reach the cap and the fourth request would never be made.
assert_requested(:get, url, times: 4)
ensure
client.close
end
def test_listener_stops_immediately_when_server_does_not_offer_a_get_stream
stub_initialize
stub_notification
stub_request(:delete, url).to_return(status: 200)
stub_request(:get, url).to_return(status: 405)
client.stubs(:sleep)
client.connect
client.on_server_request("elicitation/create") { { action: "decline" } }
listener = client.instance_variable_get(:@listener_thread)
wait_until { !listener.alive? }
assert_requested(:get, url, times: 1)
ensure
client.close
end
def test_captures_session_id_and_protocol_version_on_initialize
stub_request(:post, url)
.to_return(
@ -1404,6 +1745,20 @@ module MCP
WebMock.stub_request(method, url)
end
# Polls until the block is truthy; the listener runs on a background
# thread, so its requests are observed asynchronously.
def wait_until(timeout: 5)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
until yield
flunk("Timed out waiting for condition") if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
sleep(0.01)
end
end
def requested?(stub)
WebMock::RequestRegistry.instance.times_executed(stub.request_pattern).positive?
end
def url
"http://example.com"
end

View file

@ -47,6 +47,25 @@ module MCP
assert_nil(client.server_info)
end
def test_on_elicitation_registers_handler_on_transport
transport = mock
handler = proc { { action: "accept", content: {} } }
transport.expects(:on_server_request).with("elicitation/create")
Client.new(transport: transport).on_elicitation(&handler)
end
def test_on_elicitation_raises_when_transport_does_not_support_server_requests
transport = mock
transport.stubs(:respond_to?).with(:on_server_request).returns(false)
error = assert_raises(ArgumentError) do
Client.new(transport: transport).on_elicitation { { action: "accept", content: {} } }
end
assert_includes(error.message, "does not support server-to-client requests")
end
def test_connected_delegates_to_transport_when_supported
transport = mock
transport.expects(:connected?).returns(true)