ruby-sdk/lib/mcp/server_session.rb
Koichi ITO d80fba4fbc Support resource subscriptions per MCP specification
## Motivation and Context

The MCP specification defines `resources/subscribe`, `resources/unsubscribe`,
and `notifications/resources/updated` for clients to monitor resource changes:
https://modelcontextprotocol.io/specification/2025-03-26/server/resources#subscriptions

The Ruby SDK had stub (no-op) handlers but provided no way for server developers to
customize subscription behavior or send update notifications.

Following the Python SDK approach, the SDK does not track subscription state internally.
Server developers register handler blocks and manage their own subscription state,
allowing flexibility for different subscription semantics
(per-session tracking, persistence, debouncing, etc.).

Three methods are added:

- `Server#resources_subscribe_handler`: registers a handler for
  `resources/subscribe` requests
- `Server#resources_unsubscribe_handler`: registers a handler for
  `resources/unsubscribe` requests
- `ServerSession#notify_resources_updated`: sends a
  `notifications/resources/updated` notification to the subscribing client

`ServerContext#notify_resources_updated` is also added so that tool handlers
can send the notification scoped to the originating session.

## How Has This Been Tested?

All tests pass (`rake test`), RuboCop is clean. New tests cover custom handler registration
for `resources/subscribe` and `resources/unsubscribe`, session-scoped `notify_resources_updated` notifications,
error handling, and `ServerContext` delegation.

## Breaking Changes

None.
2026-04-19 03:48:46 +09:00

152 lines
6.1 KiB
Ruby

# frozen_string_literal: true
require_relative "methods"
module MCP
# Holds per-connection state for a single client session.
# Created by the transport layer; delegates request handling to the shared `Server`.
class ServerSession
attr_reader :session_id, :client, :logging_message_notification
def initialize(server:, transport:, session_id: nil)
@server = server
@transport = transport
@session_id = session_id
@client = nil
@client_capabilities = nil
@logging_message_notification = nil
end
def handle(request)
@server.handle(request, session: self)
end
def handle_json(request_json)
@server.handle_json(request_json, session: self)
end
# Called by `Server#init` during the initialization handshake.
def store_client_info(client:, capabilities: nil)
@client = client
@client_capabilities = capabilities
end
# Called by `Server#configure_logging_level`.
def configure_logging(logging_message_notification)
@logging_message_notification = logging_message_notification
end
# Returns per-session client capabilities, falling back to global.
def client_capabilities
@client_capabilities || @server.client_capabilities
end
# Sends a `roots/list` request scoped to this session.
def list_roots(related_request_id: nil)
unless client_capabilities&.dig(:roots)
raise "Client does not support roots."
end
send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id)
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)
send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params, related_request_id: related_request_id)
end
# Sends an `elicitation/create` request (form mode) scoped to this session.
def create_form_elicitation(message:, requested_schema:, related_request_id: nil)
unless client_capabilities&.dig(:elicitation)
raise "Client does not support elicitation. " \
"The client must declare the `elicitation` capability during initialization."
end
params = { mode: "form", message: message, requestedSchema: requested_schema }
send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id)
end
# Sends an `elicitation/create` request (URL mode) scoped to this session.
def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil)
unless client_capabilities&.dig(:elicitation, :url)
raise "Client does not support URL mode elicitation. " \
"The client must declare the `elicitation.url` capability during initialization."
end
params = { mode: "url", message: message, url: url, elicitationId: elicitation_id }
send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id)
end
# Sends an elicitation complete notification scoped to this session.
def notify_elicitation_complete(elicitation_id:)
send_to_transport(Methods::NOTIFICATIONS_ELICITATION_COMPLETE, { elicitationId: elicitation_id })
rescue => e
@server.report_exception(e, notification: "elicitation_complete")
end
# Sends a resource updated notification to this session only.
def notify_resources_updated(uri:)
send_to_transport(Methods::NOTIFICATIONS_RESOURCES_UPDATED, { "uri" => uri })
rescue => e
@server.report_exception(e, notification: "resources_updated")
end
# Sends a progress notification to this session only.
def notify_progress(progress_token:, progress:, total: nil, message: nil, related_request_id: nil)
params = {
"progressToken" => progress_token,
"progress" => progress,
"total" => total,
"message" => message,
}.compact
send_to_transport(Methods::NOTIFICATIONS_PROGRESS, params, related_request_id: related_request_id)
rescue => e
@server.report_exception(e, notification: "progress")
end
# Sends a log message notification to this session only.
def notify_log_message(data:, level:, logger: nil, related_request_id: nil)
effective_logging = @logging_message_notification || @server.logging_message_notification
return unless effective_logging&.should_notify?(level)
params = { "data" => data, "level" => level }
params["logger"] = logger if logger
send_to_transport(Methods::NOTIFICATIONS_MESSAGE, params, related_request_id: related_request_id)
rescue => e
@server.report_exception(e, { notification: "log_message" })
end
private
# Branches on `@session_id` because `StdioTransport` creates a `ServerSession` without
# a `session_id` (`session_id: nil`), while `StreamableHTTPTransport` always provides one.
#
# TODO: When Ruby 2.7 support is dropped, replace with a direct call:
# `@transport.send_notification(method, params, session_id: @session_id)` and
# add `**` to `Transport#send_notification` and `StdioTransport#send_notification`.
def send_to_transport(method, params, related_request_id: nil)
if @session_id
@transport.send_notification(method, params, session_id: @session_id, related_request_id: related_request_id)
else
@transport.send_notification(method, params)
end
end
# Branches on `@session_id` because `StdioTransport` creates a `ServerSession` without
# a `session_id` (`session_id: nil`), while `StreamableHTTPTransport` always provides one.
#
# TODO: When Ruby 2.7 support is dropped, replace with a direct call:
# `@transport.send_request(method, params, session_id: @session_id)` and
# add `**` to `Transport#send_request` and `StdioTransport#send_request`.
def send_to_transport_request(method, params, related_request_id: nil)
if @session_id
@transport.send_request(method, params, session_id: @session_id, related_request_id: related_request_id)
else
@transport.send_request(method, params)
end
end
end
end