Skip to content

Commit 04bb739

Browse files
committed
Support client-side elicitation with SEP-1034 schema defaults
## Motivation and Context Resolves the `elicitation-sep1034-client-defaults` client conformance scenario (SEP-1034, modelcontextprotocol/modelcontextprotocol#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.
1 parent ab65b91 commit 04bb739

9 files changed

Lines changed: 748 additions & 9 deletions

File tree

‎README.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2112,6 +2112,30 @@ response = client.call_tool(
21122112

21132113
The server will send `notifications/progress` back to the client during execution.
21142114

2115+
#### Server-to-Client Requests (Elicitation)
2116+
2117+
Servers can send requests back to the client while one of the client's own requests is in flight - for example,
2118+
[`elicitation/create`](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) to ask the user for additional input during a tool call.
2119+
Register a handler and advertise the capability on `connect` to respond to them:
2120+
2121+
```ruby
2122+
client.connect(capabilities: { elicitation: {} })
2123+
2124+
client.on_elicitation do |params|
2125+
{
2126+
action: "accept",
2127+
# Fill fields omitted by the user with the schema's `default` values (SEP-1034)
2128+
content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]),
2129+
}
2130+
end
2131+
```
2132+
2133+
Registering a handler opens a standalone HTTP GET SSE stream on a background thread
2134+
([listening for messages from the server](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server)),
2135+
since servers deliver requests that are not tied to a client request on that stream. Server requests with no registered handler are answered with
2136+
a JSON-RPC `-32601` (method not found) error. To handle methods other than `elicitation/create`, register directly on the transport with
2137+
`http_transport.on_server_request("method/name") { |params| ... }`.
2138+
21152139
#### HTTP Authorization
21162140

21172141
By default, the HTTP transport layer provides no authentication to the server, but you can provide custom headers if you need authentication. For example, to use Bearer token authentication:

‎conformance/client.rb‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,11 @@ def build_provider_for(scenario, context)
125125
oauth = scenario.start_with?("auth/") ? build_provider_for(scenario, conformance_context) : nil
126126
transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth)
127127
client = MCP::Client.new(transport: transport)
128-
client.connect(client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION })
128+
capabilities = scenario == "elicitation-sep1034-client-defaults" ? { elicitation: {} } : {}
129+
client.connect(
130+
client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION },
131+
capabilities: capabilities,
132+
)
129133

130134
case scenario
131135
when "initialize"
@@ -144,6 +148,19 @@ def build_provider_for(scenario, context)
144148
test_reconnection = tools.find { |t| t.name == "test_reconnection" }
145149
abort("Tool test_reconnection not found") unless test_reconnection
146150
client.call_tool(tool: test_reconnection, arguments: {})
151+
when "elicitation-sep1034-client-defaults"
152+
# SEP-1034: the server sends `elicitation/create` on the tools/call SSE stream with every property declaring
153+
# a `default` and none required. The handler accepts and fills the omitted fields from those defaults.
154+
client.on_elicitation do |params|
155+
{ action: "accept", content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"] || {}) }
156+
end
157+
158+
tools = client.tools
159+
defaults_tool = tools.find { |t| t.name == "test_client_elicitation_defaults" }
160+
161+
abort("Tool test_client_elicitation_defaults not found") unless defaults_tool
162+
163+
client.call_tool(tool: defaults_tool, arguments: {})
147164
when %r|\Aauth/|
148165
# Auth-only scenarios: the protocol-level checks (PRM/AS metadata, DCR, PKCE, token usage)
149166
# are observed by the conformance server during `connect` and the subsequent request below.

‎conformance/expected_failures.yml‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
server: []
22
client:
3-
# TODO: Elicitation not implemented in Ruby client.
4-
- elicitation-sep1034-client-defaults
53
# TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client.
64
- auth/client-credentials-jwt
75
- auth/cross-app-access-complete-flow

‎lib/mcp/client.rb‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22

3+
require_relative "client/elicitation"
34
require_relative "client/oauth"
45
require_relative "client/stdio"
56
require_relative "client/http"
@@ -29,6 +30,20 @@ def initialize(message, request, error_type: :internal_error, original_error: ni
2930
end
3031
end
3132

33+
# Raised inside a server-to-client request handler (registered via `on_server_request`, e.g. `on_sampling`)
34+
# to answer the request with a specific JSON-RPC error code instead of the default internal error.
35+
# Mirrors the TypeScript SDK's `McpError` and the Python SDK's `ErrorData` return: for example,
36+
# the sampling spec answers a rejected request with code `-1`.
37+
# https://modelcontextprotocol.io/specification/2025-11-25/client/sampling
38+
class ServerRequestError < StandardError
39+
attr_reader :code
40+
41+
def initialize(message, code:)
42+
super(message)
43+
@code = code
44+
end
45+
end
46+
3247
# Raised when a server response fails client-side validation, e.g., a success response
3348
# whose `result` field is missing or has the wrong type. This is distinct from a
3449
# server-returned JSON-RPC error, which is raised as `ServerError`.
@@ -352,6 +367,30 @@ def complete(ref:, argument:, context: nil, meta: nil, cancellation: nil)
352367
response.dig("result", "completion") || { "values" => [], "hasMore" => false }
353368
end
354369

370+
# Registers a handler for `elicitation/create` requests the server sends while one of
371+
# this client's requests is in flight. The handler receives the request `params`
372+
# (message and `requestedSchema`, string keys) and must return an `ElicitResult`-shaped Hash:
373+
# `{ action: "accept" | "decline" | "cancel", content: { ... } }`.
374+
#
375+
# Requires a transport that supports server-to-client requests (e.g. `MCP::Client::HTTP`);
376+
# pass `capabilities: { elicitation: {} }` to `connect` so the server knows it may send them.
377+
#
378+
# @example Accept with schema defaults applied (SEP-1034)
379+
# client.on_elicitation do |params|
380+
# {
381+
# action: "accept",
382+
# content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]),
383+
# }
384+
# end
385+
# https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation
386+
def on_elicitation(&handler)
387+
unless transport.respond_to?(:on_server_request)
388+
raise ArgumentError, "The transport does not support server-to-client requests"
389+
end
390+
391+
transport.on_server_request(Methods::ELICITATION_CREATE, &handler)
392+
end
393+
355394
# Sends a `ping` request to the server to verify the connection is alive.
356395
# Per the MCP spec, the server responds with an empty result.
357396
#

‎lib/mcp/client/elicitation.rb‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# frozen_string_literal: true
2+
3+
module MCP
4+
class Client
5+
# Client-side helpers for the `elicitation/create` server-to-client request.
6+
# https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation
7+
module Elicitation
8+
# Fills fields omitted from `content` with the `default` values declared in
9+
# the elicitation request's `requestedSchema` properties, per SEP-1034.
10+
# Provided values are never overwritten, and properties without a `default`
11+
# are left out so the server can apply its own handling.
12+
# Mirrors the TypeScript SDK's `applyElicitationDefaults`; the Python SDK
13+
# applies defaults in the elicitation callback the same way this helper
14+
# is intended to be used.
15+
#
16+
# @param requested_schema [Hash] The `requestedSchema` from the `elicitation/create`
17+
# request params (string or symbol keys).
18+
# @param content [Hash] Values already collected from the user.
19+
# @return [Hash] `content` (string keys) with defaults filled in.
20+
#
21+
# @example Accept an elicitation request with all defaults
22+
# transport.on_server_request("elicitation/create") do |params|
23+
# {
24+
# action: "accept",
25+
# content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]),
26+
# }
27+
# end
28+
class << self
29+
def apply_defaults(requested_schema, content = {})
30+
filled = content.to_h.transform_keys(&:to_s)
31+
properties = requested_schema["properties"] || requested_schema[:properties]
32+
return filled unless properties.is_a?(Hash)
33+
34+
properties.each do |name, property_schema|
35+
name = name.to_s
36+
next if filled.key?(name)
37+
next unless property_schema.is_a?(Hash)
38+
39+
if property_schema.key?("default")
40+
filled[name] = property_schema["default"]
41+
elsif property_schema.key?(:default)
42+
filled[name] = property_schema[:default]
43+
end
44+
end
45+
46+
filled
47+
end
48+
end
49+
end
50+
end
51+
end

0 commit comments

Comments
 (0)