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.
This commit is contained in:
Koichi ITO 2026-04-19 18:37:56 +09:00
commit 493321ca46
3 changed files with 112 additions and 0 deletions

View file

@ -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`)

View file

@ -28,6 +28,11 @@ module MCP
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 @@ module MCP
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)

View file

@ -538,6 +538,67 @@ module MCP
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