## Motivation and Context The MCP specification defines `ping` as a bidirectional utility: either party may issue it. https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping The previous PR added `MCP::Client#ping`. This PR adds the symmetric server-side send capability, `ServerSession#ping`. This aligns the Ruby SDK with the Python SDK (`ServerSession.send_ping`) and the TypeScript SDK (`Server.ping()`), both of which expose a server-initiated ping. In practice this enables a Ruby MCP server to health-check non-Ruby clients (e.g., Claude Desktop, the Python or TypeScript SDK client) during long-running tool operations. Scope is intentionally server-only: this PR does not add client-side inbound request dispatch, which is a larger refactor outside the ping utility. A Ruby `MCP::Client` therefore still cannot receive a server-initiated ping; that gap is tracked as a separate follow-up. ## How Has This Been Tested? New file `test/mcp/server_session_ping_test.rb` covers: - success (empty `result` returned) - `related_request_id` propagated through the transport when a session ID is set - `ValidationError` when the transport returns `nil` - `ValidationError` when the transport returns a non-Hash `result` - transport-level errors propagate unchanged - ping succeeds without any client capability declaration (per spec, ping has no capability gate) `test/mcp/server_context_test.rb` adds two tests for `ServerContext#ping` delegation (happy path and `NoMethodError` when the session lacks `#ping`). The existing server-side `ping` handler tests continue to pass unchanged. ## Breaking Changes None. `MCP::ServerContext#ping` and `MCP::Server::ValidationError` are purely additive.
145 lines
5.6 KiB
Ruby
145 lines
5.6 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module MCP
|
|
class ServerContext
|
|
attr_reader :cancellation
|
|
|
|
def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil)
|
|
@context = context
|
|
@progress = progress
|
|
@notification_target = notification_target
|
|
@related_request_id = related_request_id
|
|
@cancellation = cancellation
|
|
end
|
|
|
|
def cancelled?
|
|
!!@cancellation&.cancelled?
|
|
end
|
|
|
|
def raise_if_cancelled!
|
|
@cancellation&.raise_if_cancelled!
|
|
end
|
|
|
|
# Reports progress for the current tool operation.
|
|
# The notification is automatically scoped to the originating session.
|
|
#
|
|
# @param progress [Numeric] Current progress value.
|
|
# @param total [Numeric, nil] Total expected value.
|
|
# @param message [String, nil] Human-readable status message.
|
|
def report_progress(progress, total: nil, message: nil)
|
|
@progress.report(progress, total: total, message: message)
|
|
end
|
|
|
|
# Sends a log message notification scoped to the originating session.
|
|
#
|
|
# @param data [Object] The log data to send.
|
|
# @param level [String] Log level (e.g., `"debug"`, `"info"`, `"error"`).
|
|
# @param logger [String, nil] Logger name.
|
|
def notify_log_message(data:, level:, logger: nil)
|
|
return unless @notification_target
|
|
|
|
@notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id)
|
|
end
|
|
|
|
# Sends a resource updated notification scoped to the originating session.
|
|
#
|
|
# @param uri [String] The URI of the updated resource.
|
|
def notify_resources_updated(uri:)
|
|
return unless @notification_target
|
|
|
|
@notification_target.notify_resources_updated(uri: uri)
|
|
end
|
|
|
|
# Delegates to the session so the request is scoped to the originating client.
|
|
def list_roots
|
|
if @notification_target.respond_to?(:list_roots)
|
|
@notification_target.list_roots(related_request_id: @related_request_id)
|
|
else
|
|
raise NoMethodError, "undefined method 'list_roots' for #{self}"
|
|
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.
|
|
def create_sampling_message(**kwargs)
|
|
if @notification_target.respond_to?(:create_sampling_message)
|
|
@notification_target.create_sampling_message(**kwargs, related_request_id: @related_request_id)
|
|
elsif @context.respond_to?(:create_sampling_message)
|
|
@context.create_sampling_message(**kwargs, related_request_id: @related_request_id)
|
|
else
|
|
raise NoMethodError, "undefined method 'create_sampling_message' 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 elicitation.
|
|
def create_form_elicitation(**kwargs)
|
|
if @notification_target.respond_to?(:create_form_elicitation)
|
|
@notification_target.create_form_elicitation(**kwargs, related_request_id: @related_request_id)
|
|
elsif @context.respond_to?(:create_form_elicitation)
|
|
@context.create_form_elicitation(**kwargs, related_request_id: @related_request_id)
|
|
else
|
|
raise NoMethodError, "undefined method 'create_form_elicitation' for #{self}"
|
|
end
|
|
end
|
|
|
|
# Delegates to the session so the request is scoped to the originating client.
|
|
# Falls back to `@context` when `@notification_target` does not support URL mode elicitation.
|
|
def create_url_elicitation(**kwargs)
|
|
if @notification_target.respond_to?(:create_url_elicitation)
|
|
@notification_target.create_url_elicitation(**kwargs, related_request_id: @related_request_id)
|
|
elsif @context.respond_to?(:create_url_elicitation)
|
|
@context.create_url_elicitation(**kwargs, related_request_id: @related_request_id)
|
|
else
|
|
raise NoMethodError, "undefined method 'create_url_elicitation' for #{self}"
|
|
end
|
|
end
|
|
|
|
# Delegates to the session so the notification is scoped to the originating client.
|
|
def notify_elicitation_complete(**kwargs)
|
|
if @notification_target.respond_to?(:notify_elicitation_complete)
|
|
@notification_target.notify_elicitation_complete(**kwargs)
|
|
elsif @context.respond_to?(:notify_elicitation_complete)
|
|
@context.notify_elicitation_complete(**kwargs)
|
|
else
|
|
raise NoMethodError, "undefined method 'notify_elicitation_complete' for #{self}"
|
|
end
|
|
end
|
|
|
|
def method_missing(name, ...)
|
|
if @context.respond_to?(name)
|
|
@context.public_send(name, ...)
|
|
else
|
|
super
|
|
end
|
|
end
|
|
|
|
def respond_to_missing?(name, include_private = false)
|
|
@context.respond_to?(name) || super
|
|
end
|
|
end
|
|
end
|