Skip to content

Commit d80fba4

Browse files
committed
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.
1 parent e73c444 commit d80fba4

7 files changed

Lines changed: 176 additions & 8 deletions

File tree

README.md

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ It implements the Model Context Protocol specification, handling model context r
5353
- `resources/list` - Lists all registered resources and their schemas
5454
- `resources/read` - Retrieves a specific resource by name
5555
- `resources/templates/list` - Lists all registered resource templates and their schemas
56+
- `resources/subscribe` - Subscribes to updates for a specific resource
57+
- `resources/unsubscribe` - Unsubscribes from updates for a specific resource
5658
- `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs
5759
- `roots/list` - Requests filesystem roots from the client (server-to-client)
5860
- `sampling/createMessage` - Requests LLM completion from the client (server-to-client)
@@ -958,6 +960,44 @@ end
958960
- Raises `RuntimeError` if client does not support `roots` capability
959961
- Raises `StandardError` if client returns an error response
960962

963+
### Resource Subscriptions
964+
965+
Resource subscriptions allow clients to monitor specific resources for changes.
966+
When a subscribed resource is updated, the server sends a notification to the client.
967+
968+
The SDK does not track subscription state internally.
969+
Server developers register handlers and manage their own subscription state.
970+
Three methods are provided:
971+
972+
- `Server#resources_subscribe_handler` - registers a handler for `resources/subscribe` requests
973+
- `Server#resources_unsubscribe_handler` - registers a handler for `resources/unsubscribe` requests
974+
- `ServerContext#notify_resources_updated` - sends a `notifications/resources/updated` notification to the subscribing client
975+
976+
```ruby
977+
subscribed_uris = Set.new
978+
979+
server = MCP::Server.new(
980+
name: "my_server",
981+
resources: [my_resource],
982+
capabilities: { resources: { subscribe: true } },
983+
)
984+
985+
server.resources_subscribe_handler do |params|
986+
subscribed_uris.add(params[:uri].to_s)
987+
end
988+
989+
server.resources_unsubscribe_handler do |params|
990+
subscribed_uris.delete(params[:uri].to_s)
991+
end
992+
993+
server.define_tool(name: "update_resource") do |server_context:, **args|
994+
if subscribed_uris.include?("test://my-resource")
995+
server_context.notify_resources_updated(uri: "test://my-resource")
996+
end
997+
MCP::Tool::Response.new([MCP::Content::Text.new("Resource updated").to_h])
998+
end
999+
```
1000+
9611001
### Sampling
9621002

9631003
The Model Context Protocol allows servers to request LLM completions from clients through the `sampling/createMessage` method.
@@ -1503,10 +1543,6 @@ end
15031543
- Raises `MCP::Server::MethodAlreadyDefinedError` if trying to override an existing method
15041544
- Supports the same exception reporting and instrumentation as standard methods
15051545

1506-
### Unsupported Features (to be implemented in future versions)
1507-
1508-
- Resource subscriptions
1509-
15101546
## Building an MCP Client
15111547

15121548
The `MCP::Client` class provides an interface for interacting with MCP servers.

conformance/server.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require "rackup"
44
require "json"
5+
require "set"
56
require "uri"
67
require_relative "../lib/mcp"
78

@@ -539,6 +540,7 @@ def configure_handlers(server)
539540
server.server_context = server
540541

541542
configure_resources_read_handler(server)
543+
configure_subscription_handlers(server)
542544
configure_completion_handler(server)
543545
end
544546

@@ -609,6 +611,18 @@ def configure_completion_handler(server)
609611
end
610612
end
611613

614+
def configure_subscription_handlers(server)
615+
subscribed_uris = Set.new
616+
617+
server.resources_subscribe_handler do |params|
618+
subscribed_uris.add(params[:uri].to_s)
619+
end
620+
621+
server.resources_unsubscribe_handler do |params|
622+
subscribed_uris.delete(params[:uri].to_s)
623+
end
624+
end
625+
612626
def build_rack_app(transport)
613627
mcp_app = proc do |env|
614628
request = Rack::Request.new(env)

lib/mcp/server.rb

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ def initialize(
117117
Methods::RESOURCES_LIST => method(:list_resources),
118118
Methods::RESOURCES_READ => method(:read_resource_no_content),
119119
Methods::RESOURCES_TEMPLATES_LIST => method(:list_resource_templates),
120+
Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
121+
Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
120122
Methods::TOOLS_LIST => method(:list_tools),
121123
Methods::TOOLS_CALL => method(:call_tool),
122124
Methods::PROMPTS_LIST => method(:list_prompts),
@@ -128,10 +130,6 @@ def initialize(
128130
Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED => ->(_) {},
129131
Methods::COMPLETION_COMPLETE => ->(_) { DEFAULT_COMPLETION_RESULT },
130132
Methods::LOGGING_SET_LEVEL => method(:configure_logging_level),
131-
132-
# No op handlers for currently unsupported methods
133-
Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
134-
Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
135133
}
136134
@transport = transport
137135
end
@@ -258,6 +256,24 @@ def completion_handler(&block)
258256
@handlers[Methods::COMPLETION_COMPLETE] = block
259257
end
260258

259+
# Sets a custom handler for `resources/subscribe` requests.
260+
# The block receives the parsed request params. The return value is
261+
# ignored; the response is always an empty result `{}` per the MCP specification.
262+
#
263+
# @yield [params] The request params containing `:uri`.
264+
def resources_subscribe_handler(&block)
265+
@handlers[Methods::RESOURCES_SUBSCRIBE] = block
266+
end
267+
268+
# Sets a custom handler for `resources/unsubscribe` requests.
269+
# The block receives the parsed request params. The return value is
270+
# ignored; the response is always an empty result `{}` per the MCP specification.
271+
#
272+
# @yield [params] The request params containing `:uri`.
273+
def resources_unsubscribe_handler(&block)
274+
@handlers[Methods::RESOURCES_UNSUBSCRIBE] = block
275+
end
276+
261277
def build_sampling_params(
262278
capabilities,
263279
messages:,
@@ -391,6 +407,9 @@ def handle_request(request, method, session: nil, related_request_id: nil)
391407
init(params, session: session)
392408
when Methods::RESOURCES_READ
393409
{ contents: @handlers[Methods::RESOURCES_READ].call(params) }
410+
when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
411+
@handlers[method].call(params)
412+
{}
394413
when Methods::TOOLS_CALL
395414
call_tool(params, session: session, related_request_id: related_request_id)
396415
when Methods::COMPLETION_COMPLETE

lib/mcp/server_context.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ def notify_log_message(data:, level:, logger: nil)
3030
@notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id)
3131
end
3232

33+
# Sends a resource updated notification scoped to the originating session.
34+
#
35+
# @param uri [String] The URI of the updated resource.
36+
def notify_resources_updated(uri:)
37+
return unless @notification_target
38+
39+
@notification_target.notify_resources_updated(uri: uri)
40+
end
41+
3342
# Delegates to the session so the request is scoped to the originating client.
3443
def list_roots
3544
if @notification_target.respond_to?(:list_roots)

lib/mcp/server_session.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ def notify_elicitation_complete(elicitation_id:)
8585
@server.report_exception(e, notification: "elicitation_complete")
8686
end
8787

88+
# Sends a resource updated notification to this session only.
89+
def notify_resources_updated(uri:)
90+
send_to_transport(Methods::NOTIFICATIONS_RESOURCES_UPDATED, { "uri" => uri })
91+
rescue => e
92+
@server.report_exception(e, notification: "resources_updated")
93+
end
94+
8895
# Sends a progress notification to this session only.
8996
def notify_progress(progress_token:, progress:, total: nil, message: nil, related_request_id: nil)
9097
params = {

test/mcp/server_context_test.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,23 @@ def context.custom_method
192192
assert_nothing_raised { server_context.notify_log_message(data: "test", level: "info") }
193193
end
194194

195+
test "ServerContext#notify_resources_updated delegates to notification_target" do
196+
notification_target = mock
197+
notification_target.expects(:notify_resources_updated).with(uri: "test://resource-1").once
198+
199+
progress = Progress.new(notification_target: notification_target, progress_token: nil)
200+
server_context = ServerContext.new(nil, progress: progress, notification_target: notification_target)
201+
202+
server_context.notify_resources_updated(uri: "test://resource-1")
203+
end
204+
205+
test "ServerContext#notify_resources_updated is a no-op when notification_target is nil" do
206+
progress = Progress.new(notification_target: nil, progress_token: nil)
207+
server_context = ServerContext.new(nil, progress: progress, notification_target: nil)
208+
209+
assert_nothing_raised { server_context.notify_resources_updated(uri: "test://resource-1") }
210+
end
211+
195212
# Tool without server_context parameter
196213
class SimpleToolWithoutContext < Tool
197214
tool_name "simple_without_context"

test/mcp/server_test.rb

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2233,6 +2233,72 @@ class Example < Tool
22332233
)
22342234
end
22352235

2236+
test "#handle resources/subscribe with custom handler calls the handler" do
2237+
server = Server.new(
2238+
name: "test_server",
2239+
capabilities: { resources: { subscribe: true } },
2240+
)
2241+
2242+
received_params = nil
2243+
server.resources_subscribe_handler do |params|
2244+
received_params = params
2245+
{}
2246+
end
2247+
2248+
server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 })
2249+
server.handle({ jsonrpc: "2.0", method: "notifications/initialized" })
2250+
2251+
response = server.handle({
2252+
jsonrpc: "2.0",
2253+
id: 2,
2254+
method: "resources/subscribe",
2255+
params: { uri: "https://example.com/resource" },
2256+
})
2257+
2258+
assert_equal(
2259+
{
2260+
jsonrpc: "2.0",
2261+
id: 2,
2262+
result: {},
2263+
},
2264+
response,
2265+
)
2266+
assert_equal "https://example.com/resource", received_params[:uri]
2267+
end
2268+
2269+
test "#handle resources/unsubscribe with custom handler calls the handler" do
2270+
server = Server.new(
2271+
name: "test_server",
2272+
capabilities: { resources: { subscribe: true } },
2273+
)
2274+
2275+
received_params = nil
2276+
server.resources_unsubscribe_handler do |params|
2277+
received_params = params
2278+
{}
2279+
end
2280+
2281+
server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 })
2282+
server.handle({ jsonrpc: "2.0", method: "notifications/initialized" })
2283+
2284+
response = server.handle({
2285+
jsonrpc: "2.0",
2286+
id: 2,
2287+
method: "resources/unsubscribe",
2288+
params: { uri: "https://example.com/resource" },
2289+
})
2290+
2291+
assert_equal(
2292+
{
2293+
jsonrpc: "2.0",
2294+
id: 2,
2295+
result: {},
2296+
},
2297+
response,
2298+
)
2299+
assert_equal "https://example.com/resource", received_params[:uri]
2300+
end
2301+
22362302
test "tools/call with no args" do
22372303
server = Server.new(tools: [@tool_with_no_args])
22382304

0 commit comments

Comments
 (0)