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:
parent
ab65b919e2
commit
04bb739c68
3 changed files with 748 additions and 9 deletions
|
|
@ -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.
|
||||
#
|
||||
|
|
|
|||
51
lib/mcp/client/elicitation.rb
Normal file
51
lib/mcp/client/elicitation.rb
Normal 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
|
||||
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue