From 493321ca46151aa697d7d4abbf50c8275c7d0338 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 19 Apr 2026 18:37:56 +0900 Subject: [PATCH] Support `ping` client API per MCP specification ## Motivation and Context The MCP specification defines the `ping` utility for verifying that the peer of a connection is still responsive. The Ruby SDK server side already replies to incoming `ping` requests with an empty result, but `MCP::Client` had no public API to send a `ping` request. Users had to build the JSON-RPC payload themselves and call the transport directly. This adds a client-side counterpart to the existing server handler and aligns the Ruby SDK in spirit with the Python and TypeScript SDKs, which expose `ClientSession.send_ping` and `Client.ping` respectively. Those SDKs validate the response envelope via type schemas (`EmptyResult` / `EmptyResultSchema`) and raise their validation library's generic exception (Pydantic's `ValidationError` / Zod's `ZodError`). The Ruby client applies a Hash type check on `result` (matching Python's behavior of requiring a dict while allowing arbitrary contents) and raises a new `MCP::Client::ValidationError` for malformed responses, keeping `MCP::Client::ServerError` reserved for JSON-RPC error responses. ## How Has This Been Tested? Client tests cover: - request structure (method is `ping`, no `params`) - empty result on success - `ServerError` on JSON-RPC error response - `ValidationError` on a response missing `result` - `ValidationError` on a `result` of the wrong type (e.g., String) - propagation of transport-level errors raised by `transport.send_request` The existing server-side `ping` handler tests continue to pass unchanged. ## Breaking Changes None. `MCP::Client#ping` and `MCP::Client::ValidationError` are purely additive. The server-side `ping` handler behavior is unchanged. --- README.md | 28 +++++++++++++++++++ lib/mcp/client.rb | 23 ++++++++++++++++ test/mcp/client_test.rb | 61 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+) diff --git a/README.md b/README.md index 202b4013..bd6e504b 100644 --- a/README.md +++ b/README.md @@ -1099,6 +1099,33 @@ Notifications follow the JSON-RPC 2.0 specification and use these method names: - `notifications/progress` - `notifications/message` +### Ping + +The MCP Ruby SDK supports the +[MCP `ping` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping), +which allows either side of the connection to verify that the peer is still responsive. +A `ping` request has no parameters, and the receiver MUST respond promptly with an empty result. + +#### Server-Side + +Servers respond to incoming `ping` requests automatically - no setup is required. +Any `MCP::Server` instance replies with an empty result. + +#### Client-Side + +`MCP::Client` exposes `ping` to send a ping to the server: + +```ruby +client = MCP::Client.new(transport: transport) +client.ping # => {} on success +``` + +`#ping` raises `MCP::Client::ServerError` when the server returns a JSON-RPC error. +It raises `MCP::Client::ValidationError` when the response `result` is missing or +is not a Hash (matching the spec requirement that `result` be an object). +Transport-level errors (for example, `MCP::Client::Stdio`'s `read_timeout:` firing) +propagate as exceptions raised by the transport layer. + ### Progress The MCP Ruby SDK supports progress tracking for long-running tool operations, @@ -1549,6 +1576,7 @@ The `MCP::Client` class provides an interface for interacting with MCP servers. This class supports: +- Liveness check via the `ping` method (`MCP::Client#ping`) - Tool listing via the `tools/list` method (`MCP::Client#tools`) - Tool invocation via the `tools/call` method (`MCP::Client#call_tools`) - Resource listing via the `resources/list` method (`MCP::Client#resources`) diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index e4cfd027..e7c2b8cd 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -28,6 +28,11 @@ def initialize(message, request, error_type: :internal_error, original_error: ni 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`. + class ValidationError < StandardError; end + # Initializes a new MCP::Client instance. # # @param transport [Object] The transport object to use for communication with the server. @@ -303,6 +308,24 @@ def complete(ref:, argument:, context: nil) response.dig("result", "completion") || { "values" => [], "hasMore" => false } 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. + # + # @return [Hash] An empty hash on success. + # @raise [ServerError] If the server returns a JSON-RPC error. + # @raise [ValidationError] If the response `result` is missing or not a Hash. + # + # @example + # client.ping # => {} + # + # @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping + def ping + result = request(method: Methods::PING)["result"] + raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash) + + result + end + private def request(method:, params: nil) diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 54144690..4142ffd8 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -538,6 +538,67 @@ def test_complete_returns_default_when_result_is_missing refute(result["hasMore"]) end + def test_ping_sends_request_and_returns_empty_hash + transport = mock + mock_response = { "result" => {} } + + transport.expects(:send_request).with do |args| + req = args[:request] + req[:method] == "ping" && + req[:jsonrpc] == "2.0" && + !req.key?(:params) + end.returns(mock_response).once + + client = Client.new(transport: transport) + result = client.ping + + assert_equal({}, result) + end + + def test_ping_raises_server_error_on_error_response + transport = mock + mock_response = { "error" => { "code" => -32_603, "message" => "Internal error" } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ServerError) { client.ping } + assert_equal(-32_603, error.code) + assert_equal("Internal error", error.message) + end + + def test_ping_raises_validation_error_when_result_is_missing + transport = mock + mock_response = {} + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ValidationError) { client.ping } + assert_equal("Response validation failed: missing or invalid `result`", error.message) + end + + def test_ping_raises_validation_error_when_result_is_wrong_type + transport = mock + mock_response = { "result" => "ok" } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ValidationError) { client.ping } + assert_equal("Response validation failed: missing or invalid `result`", error.message) + end + + def test_ping_propagates_transport_errors + transport = mock + transport_error = StandardError.new("read timeout") + transport.expects(:send_request).raises(transport_error).once + + client = Client.new(transport: transport) + error = assert_raises(StandardError) { client.ping } + assert_equal("read timeout", error.message) + end + def test_tools_auto_paginates_across_multiple_pages transport = mock