Skip to content

Commit aee8a0b

Browse files
authored
Merge pull request #358 from koic/ping_server_to_client
Support server-to-client `ping` per MCP specification
2 parents 4074985 + ac7598a commit aee8a0b

6 files changed

Lines changed: 171 additions & 0 deletions

File tree

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,6 +1253,25 @@ A `ping` request has no parameters, and the receiver MUST respond promptly with
12531253
Servers respond to incoming `ping` requests automatically - no setup is required.
12541254
Any `MCP::Server` instance replies with an empty result.
12551255

1256+
Servers can also send `ping` requests to the client via `ServerSession#ping`.
1257+
Inside a tool handler that receives `server_context:`, call `ping` on it:
1258+
1259+
```ruby
1260+
class HealthCheckTool < MCP::Tool
1261+
description "Verifies the client is still responsive"
1262+
1263+
def self.call(server_context:)
1264+
server_context.ping # => {} on success
1265+
1266+
MCP::Tool::Response.new([{ type: "text", text: "client is alive" }])
1267+
end
1268+
end
1269+
```
1270+
1271+
`#ping` raises `MCP::Server::ValidationError` when the client returns a `result`
1272+
that is not a Hash. Transport-level errors (e.g., the client returning a JSON-RPC error)
1273+
propagate as exceptions raised by the transport layer.
1274+
12561275
#### Client-Side
12571276

12581277
`MCP::Client` exposes `ping` to send a ping to the server:

lib/mcp/server.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ def initialize(method_name)
6767
end
6868
end
6969

70+
# Raised when a client response fails server-side validation, e.g., a success response
71+
# whose `result` field is missing or has the wrong type. This is distinct from a
72+
# client-returned JSON-RPC error.
73+
class ValidationError < StandardError; end
74+
7075
include Instrumentation
7176
include Pagination
7277

lib/mcp/server_context.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,28 @@ def list_roots
5959
end
6060
end
6161

62+
# Sends a `ping` request to the originating client to verify it is still responsive.
63+
# Per the MCP spec, the client MUST respond promptly with an empty result.
64+
#
65+
# @return [Hash] An empty hash on success.
66+
# @raise [Server::ValidationError] If the response `result` is not a Hash.
67+
# @raise [NoMethodError] If the session does not support sending pings.
68+
#
69+
# @example
70+
# def self.call(server_context:)
71+
# server_context.ping # => {}
72+
# # ...
73+
# end
74+
#
75+
# @see https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping
76+
def ping
77+
if @notification_target.respond_to?(:ping)
78+
@notification_target.ping(related_request_id: @related_request_id)
79+
else
80+
raise NoMethodError, "undefined method 'ping' for #{self}"
81+
end
82+
end
83+
6284
# Delegates to the session so the request is scoped to the originating client.
6385
# Falls back to `@context` (via `method_missing`) when `@notification_target`
6486
# does not support sampling.

lib/mcp/server_session.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ def list_roots(related_request_id: nil)
106106
send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id)
107107
end
108108

109+
# Sends a `ping` request scoped to this session.
110+
def ping(related_request_id: nil)
111+
result = send_to_transport_request(Methods::PING, nil, related_request_id: related_request_id)
112+
raise Server::ValidationError, "Response validation failed: invalid `result`" unless result.is_a?(Hash)
113+
114+
result
115+
end
116+
109117
# Sends a `sampling/createMessage` request scoped to this session.
110118
def create_sampling_message(related_request_id: nil, **kwargs)
111119
params = @server.build_sampling_params(client_capabilities, **kwargs)

test/mcp/server_context_test.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,30 @@ class ServerContextTest < ActiveSupport::TestCase
6767
assert_raises(NoMethodError) { server_context.list_roots }
6868
end
6969

70+
test "ServerContext#ping delegates to notification_target" do
71+
notification_target = mock
72+
notification_target.expects(:ping).with(related_request_id: nil).returns({})
73+
74+
context = mock
75+
progress = Progress.new(notification_target: notification_target, progress_token: nil)
76+
77+
server_context = ServerContext.new(context, progress: progress, notification_target: notification_target)
78+
79+
result = server_context.ping
80+
81+
assert_equal({}, result)
82+
end
83+
84+
test "ServerContext#ping raises NoMethodError when notification_target does not respond" do
85+
notification_target = mock
86+
context = mock
87+
progress = Progress.new(notification_target: notification_target, progress_token: nil)
88+
89+
server_context = ServerContext.new(context, progress: progress, notification_target: notification_target)
90+
91+
assert_raises(NoMethodError) { server_context.ping }
92+
end
93+
7094
test "ServerContext#create_sampling_message delegates to notification_target over context" do
7195
notification_target = mock
7296
notification_target.expects(:create_sampling_message).with(
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
module MCP
6+
class ServerSessionPingTest < ActiveSupport::TestCase
7+
class MockTransport < Transport
8+
attr_reader :requests
9+
attr_accessor :response, :error_to_raise
10+
11+
def initialize(server)
12+
super
13+
@requests = []
14+
@response = {}
15+
@error_to_raise = nil
16+
end
17+
18+
# Matches real transports: returns the `result` body directly (not the full envelope).
19+
def send_request(method, params = nil, session_id: nil, related_request_id: nil)
20+
@requests << { method: method, params: params, session_id: session_id, related_request_id: related_request_id }
21+
raise @error_to_raise if @error_to_raise
22+
23+
@response
24+
end
25+
26+
def send_response(response); end
27+
def send_notification(method, params = nil); end
28+
def open; end
29+
def close; end
30+
def handle_request(request); end
31+
end
32+
33+
setup do
34+
@server = Server.new(name: "test_server", version: "1.0.0")
35+
@mock_transport = MockTransport.new(@server)
36+
@server.transport = @mock_transport
37+
end
38+
39+
test "#ping sends request through transport and returns the result hash" do
40+
session = ServerSession.new(server: @server, transport: @mock_transport)
41+
42+
result = session.ping
43+
44+
assert_equal({}, result)
45+
assert_equal(1, @mock_transport.requests.size)
46+
47+
request = @mock_transport.requests.first
48+
assert_equal(Methods::PING, request[:method])
49+
assert_nil(request[:params])
50+
end
51+
52+
test "#ping passes related_request_id through when session_id is set" do
53+
session = ServerSession.new(server: @server, transport: @mock_transport, session_id: "session-1")
54+
55+
session.ping(related_request_id: "req-abc")
56+
57+
request = @mock_transport.requests.first
58+
assert_equal("session-1", request[:session_id])
59+
assert_equal("req-abc", request[:related_request_id])
60+
end
61+
62+
test "#ping raises ValidationError when result is nil" do
63+
@mock_transport.response = nil
64+
session = ServerSession.new(server: @server, transport: @mock_transport)
65+
66+
error = assert_raises(Server::ValidationError) { session.ping }
67+
assert_equal("Response validation failed: invalid `result`", error.message)
68+
end
69+
70+
test "#ping raises ValidationError when result is the wrong type" do
71+
@mock_transport.response = "ok"
72+
session = ServerSession.new(server: @server, transport: @mock_transport)
73+
74+
error = assert_raises(Server::ValidationError) { session.ping }
75+
assert_equal("Response validation failed: invalid `result`", error.message)
76+
end
77+
78+
test "#ping propagates transport-level errors" do
79+
@mock_transport.error_to_raise = StandardError.new("read timeout")
80+
session = ServerSession.new(server: @server, transport: @mock_transport)
81+
82+
error = assert_raises(StandardError) { session.ping }
83+
assert_equal("read timeout", error.message)
84+
end
85+
86+
test "#ping succeeds without a client capability declaration" do
87+
session = ServerSession.new(server: @server, transport: @mock_transport)
88+
assert_nil(session.client_capabilities)
89+
90+
assert_equal({}, session.ping)
91+
end
92+
end
93+
end

0 commit comments

Comments
 (0)