Merge pull request #358 from koic/ping_server_to_client
Support server-to-client `ping` per MCP specification
This commit is contained in:
commit
aee8a0bfb6
6 changed files with 171 additions and 0 deletions
19
README.md
19
README.md
|
|
@ -1253,6 +1253,25 @@ A `ping` request has no parameters, and the receiver MUST respond promptly with
|
|||
Servers respond to incoming `ping` requests automatically - no setup is required.
|
||||
Any `MCP::Server` instance replies with an empty result.
|
||||
|
||||
Servers can also send `ping` requests to the client via `ServerSession#ping`.
|
||||
Inside a tool handler that receives `server_context:`, call `ping` on it:
|
||||
|
||||
```ruby
|
||||
class HealthCheckTool < MCP::Tool
|
||||
description "Verifies the client is still responsive"
|
||||
|
||||
def self.call(server_context:)
|
||||
server_context.ping # => {} on success
|
||||
|
||||
MCP::Tool::Response.new([{ type: "text", text: "client is alive" }])
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
`#ping` raises `MCP::Server::ValidationError` when the client returns a `result`
|
||||
that is not a Hash. Transport-level errors (e.g., the client returning a JSON-RPC error)
|
||||
propagate as exceptions raised by the transport layer.
|
||||
|
||||
#### Client-Side
|
||||
|
||||
`MCP::Client` exposes `ping` to send a ping to the server:
|
||||
|
|
|
|||
|
|
@ -67,6 +67,11 @@ module MCP
|
|||
end
|
||||
end
|
||||
|
||||
# Raised when a client response fails server-side validation, e.g., a success response
|
||||
# whose `result` field is missing or has the wrong type. This is distinct from a
|
||||
# client-returned JSON-RPC error.
|
||||
class ValidationError < StandardError; end
|
||||
|
||||
include Instrumentation
|
||||
include Pagination
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,28 @@ module MCP
|
|||
end
|
||||
end
|
||||
|
||||
# Sends a `ping` request to the originating client to verify it is still responsive.
|
||||
# Per the MCP spec, the client MUST respond promptly with an empty result.
|
||||
#
|
||||
# @return [Hash] An empty hash on success.
|
||||
# @raise [Server::ValidationError] If the response `result` is not a Hash.
|
||||
# @raise [NoMethodError] If the session does not support sending pings.
|
||||
#
|
||||
# @example
|
||||
# def self.call(server_context:)
|
||||
# server_context.ping # => {}
|
||||
# # ...
|
||||
# end
|
||||
#
|
||||
# @see https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping
|
||||
def ping
|
||||
if @notification_target.respond_to?(:ping)
|
||||
@notification_target.ping(related_request_id: @related_request_id)
|
||||
else
|
||||
raise NoMethodError, "undefined method 'ping' for #{self}"
|
||||
end
|
||||
end
|
||||
|
||||
# Delegates to the session so the request is scoped to the originating client.
|
||||
# Falls back to `@context` (via `method_missing`) when `@notification_target`
|
||||
# does not support sampling.
|
||||
|
|
|
|||
|
|
@ -106,6 +106,14 @@ module MCP
|
|||
send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id)
|
||||
end
|
||||
|
||||
# Sends a `ping` request scoped to this session.
|
||||
def ping(related_request_id: nil)
|
||||
result = send_to_transport_request(Methods::PING, nil, related_request_id: related_request_id)
|
||||
raise Server::ValidationError, "Response validation failed: invalid `result`" unless result.is_a?(Hash)
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
# Sends a `sampling/createMessage` request scoped to this session.
|
||||
def create_sampling_message(related_request_id: nil, **kwargs)
|
||||
params = @server.build_sampling_params(client_capabilities, **kwargs)
|
||||
|
|
|
|||
|
|
@ -67,6 +67,30 @@ module MCP
|
|||
assert_raises(NoMethodError) { server_context.list_roots }
|
||||
end
|
||||
|
||||
test "ServerContext#ping delegates to notification_target" do
|
||||
notification_target = mock
|
||||
notification_target.expects(:ping).with(related_request_id: nil).returns({})
|
||||
|
||||
context = mock
|
||||
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
||||
|
||||
server_context = ServerContext.new(context, progress: progress, notification_target: notification_target)
|
||||
|
||||
result = server_context.ping
|
||||
|
||||
assert_equal({}, result)
|
||||
end
|
||||
|
||||
test "ServerContext#ping raises NoMethodError when notification_target does not respond" do
|
||||
notification_target = mock
|
||||
context = mock
|
||||
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
||||
|
||||
server_context = ServerContext.new(context, progress: progress, notification_target: notification_target)
|
||||
|
||||
assert_raises(NoMethodError) { server_context.ping }
|
||||
end
|
||||
|
||||
test "ServerContext#create_sampling_message delegates to notification_target over context" do
|
||||
notification_target = mock
|
||||
notification_target.expects(:create_sampling_message).with(
|
||||
|
|
|
|||
93
test/mcp/server_session_ping_test.rb
Normal file
93
test/mcp/server_session_ping_test.rb
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
module MCP
|
||||
class ServerSessionPingTest < ActiveSupport::TestCase
|
||||
class MockTransport < Transport
|
||||
attr_reader :requests
|
||||
attr_accessor :response, :error_to_raise
|
||||
|
||||
def initialize(server)
|
||||
super
|
||||
@requests = []
|
||||
@response = {}
|
||||
@error_to_raise = nil
|
||||
end
|
||||
|
||||
# Matches real transports: returns the `result` body directly (not the full envelope).
|
||||
def send_request(method, params = nil, session_id: nil, related_request_id: nil)
|
||||
@requests << { method: method, params: params, session_id: session_id, related_request_id: related_request_id }
|
||||
raise @error_to_raise if @error_to_raise
|
||||
|
||||
@response
|
||||
end
|
||||
|
||||
def send_response(response); end
|
||||
def send_notification(method, params = nil); end
|
||||
def open; end
|
||||
def close; end
|
||||
def handle_request(request); end
|
||||
end
|
||||
|
||||
setup do
|
||||
@server = Server.new(name: "test_server", version: "1.0.0")
|
||||
@mock_transport = MockTransport.new(@server)
|
||||
@server.transport = @mock_transport
|
||||
end
|
||||
|
||||
test "#ping sends request through transport and returns the result hash" do
|
||||
session = ServerSession.new(server: @server, transport: @mock_transport)
|
||||
|
||||
result = session.ping
|
||||
|
||||
assert_equal({}, result)
|
||||
assert_equal(1, @mock_transport.requests.size)
|
||||
|
||||
request = @mock_transport.requests.first
|
||||
assert_equal(Methods::PING, request[:method])
|
||||
assert_nil(request[:params])
|
||||
end
|
||||
|
||||
test "#ping passes related_request_id through when session_id is set" do
|
||||
session = ServerSession.new(server: @server, transport: @mock_transport, session_id: "session-1")
|
||||
|
||||
session.ping(related_request_id: "req-abc")
|
||||
|
||||
request = @mock_transport.requests.first
|
||||
assert_equal("session-1", request[:session_id])
|
||||
assert_equal("req-abc", request[:related_request_id])
|
||||
end
|
||||
|
||||
test "#ping raises ValidationError when result is nil" do
|
||||
@mock_transport.response = nil
|
||||
session = ServerSession.new(server: @server, transport: @mock_transport)
|
||||
|
||||
error = assert_raises(Server::ValidationError) { session.ping }
|
||||
assert_equal("Response validation failed: invalid `result`", error.message)
|
||||
end
|
||||
|
||||
test "#ping raises ValidationError when result is the wrong type" do
|
||||
@mock_transport.response = "ok"
|
||||
session = ServerSession.new(server: @server, transport: @mock_transport)
|
||||
|
||||
error = assert_raises(Server::ValidationError) { session.ping }
|
||||
assert_equal("Response validation failed: invalid `result`", error.message)
|
||||
end
|
||||
|
||||
test "#ping propagates transport-level errors" do
|
||||
@mock_transport.error_to_raise = StandardError.new("read timeout")
|
||||
session = ServerSession.new(server: @server, transport: @mock_transport)
|
||||
|
||||
error = assert_raises(StandardError) { session.ping }
|
||||
assert_equal("read timeout", error.message)
|
||||
end
|
||||
|
||||
test "#ping succeeds without a client capability declaration" do
|
||||
session = ServerSession.new(server: @server, transport: @mock_transport)
|
||||
assert_nil(session.client_capabilities)
|
||||
|
||||
assert_equal({}, session.ping)
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue