Skip to content
Merged
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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`)
Expand Down
23 changes: 23 additions & 0 deletions lib/mcp/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
61 changes: 61 additions & 0 deletions test/mcp/client_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down