## Motivation and Context MCP spec (2025-11-25) defines Roots as a mechanism for clients to expose filesystem boundaries (directories and files) to servers: https://modelcontextprotocol.io/specification/2025-11-25/client/roots This enables servers to understand which parts of the filesystem they can operate on. This adds support for the two roots protocol interactions: - `roots/list` -- server-to-client request to retrieve the client's filesystem roots - `notifications/roots/list_changed` -- client-to-server notification when roots change `roots/list` is a one-to-one request between a server and a specific client, so `list_roots` is exposed only at the session level (`ServerSession#list_roots`), matching the Python SDK approach and aligning with PR #311 for sampling. `Server#on_roots_list_changed` registers a callback for the client notification, which is server-wide and does not require session scoping. Also fixes `notifications/roots/list_changed` capability checking: as a client-originated notification, it should not require server-side capability declaration. ## How Has This Been Tested? New test file `test/mcp/server_roots_test.rb` with 8 test cases covering session-scoped `list_roots`, capability validation and fallback, `on_roots_list_changed` callback registration, and client capability storage. Updated `test/mcp/methods_test.rb` to reflect the corrected capability checks. All tests pass (`rake test`), RuboCop is clean. ## Breaking Changes None.
145 lines
5.8 KiB
Ruby
145 lines
5.8 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 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
|