diff --git a/README.md b/README.md index 744e8b22..dfe02808 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/conformance/client.rb b/conformance/client.rb index 7b7ddea7..356bd4d4 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -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" @@ -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. diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index cb397ba1..2e67f184 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -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 diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index fbf04f32..9d0bbf44 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -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 @@ 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`. @@ -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. # diff --git a/lib/mcp/client/elicitation.rb b/lib/mcp/client/elicitation.rb new file mode 100644 index 00000000..52df9337 --- /dev/null +++ b/lib/mcp/client/elicitation.rb @@ -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 diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 92aac795..eb6ca6e0 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -8,9 +8,6 @@ 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 @@ class HTTP 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 @@ class SSEStream 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 @@ def feed(chunk) 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 @@ def initialize(url:, headers: {}, oauth: nil, &block) @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 @@ def connect(client_info: nil, protocol_version: nil, capabilities: {}) end @connected = true + start_listening if @server_request_handlers.any? @server_info end @@ -305,8 +334,13 @@ def send_request(request:) # 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 @@ def capture_session_info(method, response, body) 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 @@ def resolve_response_body(stream, response, method, params) 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? diff --git a/test/mcp/client/elicitation_test.rb b/test/mcp/client/elicitation_test.rb new file mode 100644 index 00000000..a6d936fc --- /dev/null +++ b/test/mcp/client/elicitation_test.rb @@ -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 diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 31044b3d..a0a1fcef 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -853,6 +853,347 @@ def test_sse_stream_parses_buffered_chunks_when_env_is_unavailable 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 @@ def stub_request(method, url) 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 diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 39fc47cf..35f47b10 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -47,6 +47,25 @@ def test_connect_is_noop_when_transport_does_not_respond_to_connect 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)