Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2112,6 +2112,30 @@ response = client.call_tool(

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

#### Server-to-Client Requests (Elicitation)

Servers can send requests back to the client while one of the client's own requests is in flight - for example,
[`elicitation/create`](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) to ask the user for additional input during a tool call.
Register a handler and advertise the capability on `connect` to respond to them:

```ruby
client.connect(capabilities: { elicitation: {} })

client.on_elicitation do |params|
{
action: "accept",
# Fill fields omitted by the user with the schema's `default` values (SEP-1034)
content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]),
}
end
```

Registering a handler opens a standalone HTTP GET SSE stream on a background thread
([listening for messages from the server](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server)),
since servers deliver requests that are not tied to a client request on that stream. Server requests with no registered handler are answered with
a JSON-RPC `-32601` (method not found) error. To handle methods other than `elicitation/create`, register directly on the transport with
`http_transport.on_server_request("method/name") { |params| ... }`.

#### HTTP Authorization

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:
Expand Down
19 changes: 18 additions & 1 deletion conformance/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,11 @@ def build_provider_for(scenario, context)
oauth = scenario.start_with?("auth/") ? build_provider_for(scenario, conformance_context) : nil
transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth)
client = MCP::Client.new(transport: transport)
client.connect(client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION })
capabilities = scenario == "elicitation-sep1034-client-defaults" ? { elicitation: {} } : {}
client.connect(
client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION },
capabilities: capabilities,
)

case scenario
when "initialize"
Expand All @@ -144,6 +148,19 @@ def build_provider_for(scenario, context)
test_reconnection = tools.find { |t| t.name == "test_reconnection" }
abort("Tool test_reconnection not found") unless test_reconnection
client.call_tool(tool: test_reconnection, arguments: {})
when "elicitation-sep1034-client-defaults"
# SEP-1034: the server sends `elicitation/create` on the tools/call SSE stream with every property declaring
# a `default` and none required. The handler accepts and fills the omitted fields from those defaults.
client.on_elicitation do |params|
{ action: "accept", content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"] || {}) }
end

tools = client.tools
defaults_tool = tools.find { |t| t.name == "test_client_elicitation_defaults" }

abort("Tool test_client_elicitation_defaults not found") unless defaults_tool

client.call_tool(tool: defaults_tool, arguments: {})
when %r|\Aauth/|
# Auth-only scenarios: the protocol-level checks (PRM/AS metadata, DCR, PKCE, token usage)
# are observed by the conformance server during `connect` and the subsequent request below.
Expand Down
2 changes: 0 additions & 2 deletions conformance/expected_failures.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
server: []
client:
# TODO: Elicitation not implemented in Ruby client.
- elicitation-sep1034-client-defaults
# TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client.
- auth/client-credentials-jwt
- auth/cross-app-access-complete-flow
39 changes: 39 additions & 0 deletions lib/mcp/client.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true

require_relative "client/elicitation"
require_relative "client/oauth"
require_relative "client/stdio"
require_relative "client/http"
Expand Down Expand Up @@ -29,6 +30,20 @@ def initialize(message, request, error_type: :internal_error, original_error: ni
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`.
Expand Down Expand Up @@ -352,6 +367,30 @@ def complete(ref:, argument:, context: nil, meta: nil, cancellation: nil)
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.
#
Expand Down
51 changes: 51 additions & 0 deletions lib/mcp/client/elicitation.rb
Original file line number Diff line number Diff line change
@@ -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
Loading