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.
This commit is contained in:
Koichi ITO 2026-04-14 00:56:54 +09:00
commit d80fba4fbc
3 changed files with 176 additions and 8 deletions

View file

@ -117,6 +117,8 @@ module MCP
Methods::RESOURCES_LIST => method(:list_resources),
Methods::RESOURCES_READ => method(:read_resource_no_content),
Methods::RESOURCES_TEMPLATES_LIST => method(:list_resource_templates),
Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
Methods::TOOLS_LIST => method(:list_tools),
Methods::TOOLS_CALL => method(:call_tool),
Methods::PROMPTS_LIST => method(:list_prompts),
@ -128,10 +130,6 @@ module MCP
Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED => ->(_) {},
Methods::COMPLETION_COMPLETE => ->(_) { DEFAULT_COMPLETION_RESULT },
Methods::LOGGING_SET_LEVEL => method(:configure_logging_level),
# No op handlers for currently unsupported methods
Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
}
@transport = transport
end
@ -258,6 +256,24 @@ module MCP
@handlers[Methods::COMPLETION_COMPLETE] = block
end
# Sets a custom handler for `resources/subscribe` requests.
# The block receives the parsed request params. The return value is
# ignored; the response is always an empty result `{}` per the MCP specification.
#
# @yield [params] The request params containing `:uri`.
def resources_subscribe_handler(&block)
@handlers[Methods::RESOURCES_SUBSCRIBE] = block
end
# Sets a custom handler for `resources/unsubscribe` requests.
# The block receives the parsed request params. The return value is
# ignored; the response is always an empty result `{}` per the MCP specification.
#
# @yield [params] The request params containing `:uri`.
def resources_unsubscribe_handler(&block)
@handlers[Methods::RESOURCES_UNSUBSCRIBE] = block
end
def build_sampling_params(
capabilities,
messages:,
@ -391,6 +407,9 @@ module MCP
init(params, session: session)
when Methods::RESOURCES_READ
{ contents: @handlers[Methods::RESOURCES_READ].call(params) }
when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
@handlers[method].call(params)
{}
when Methods::TOOLS_CALL
call_tool(params, session: session, related_request_id: related_request_id)
when Methods::COMPLETION_COMPLETE

View file

@ -30,6 +30,15 @@ module MCP
@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)

View file

@ -85,6 +85,13 @@ module MCP
@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 = {