## 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.
51 lines
2 KiB
Ruby
51 lines
2 KiB
Ruby
# 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
|