Skip to content

Commit 00a5e3b

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 88248fc commit 00a5e3b

File tree

7 files changed

+176
-3
lines changed

7 files changed

+176
-3
lines changed

README.md

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ It implements the Model Context Protocol specification, handling model context r
5151
- `resources/list` - Lists all registered resources and their schemas
5252
- `resources/read` - Retrieves a specific resource by name
5353
- `resources/templates/list` - Lists all registered resource templates and their schemas
54+
- `resources/subscribe` - Subscribes to updates for a specific resource
55+
- `resources/unsubscribe` - Unsubscribes from updates for a specific resource
5456
- `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs
5557
- `sampling/createMessage` - Requests LLM completion from the client (server-to-client)
5658

@@ -808,6 +810,44 @@ server = MCP::Server.new(
808810
)
809811
```
810812

813+
### Resource Subscriptions
814+
815+
Resource subscriptions allow clients to monitor specific resources for changes.
816+
When a subscribed resource is updated, the server sends a notification to the client.
817+
818+
The SDK does not track subscription state internally.
819+
Server developers register handlers and manage their own subscription state.
820+
Three methods are provided:
821+
822+
- `Server#resources_subscribe_handler` - registers a handler for `resources/subscribe` requests
823+
- `Server#resources_unsubscribe_handler` - registers a handler for `resources/unsubscribe` requests
824+
- `ServerContext#notify_resources_updated` - sends a `notifications/resources/updated` notification to the subscribing client
825+
826+
```ruby
827+
subscribed_uris = Set.new
828+
829+
server = MCP::Server.new(
830+
name: "my_server",
831+
resources: [my_resource],
832+
capabilities: { resources: { subscribe: true } },
833+
)
834+
835+
server.resources_subscribe_handler do |params|
836+
subscribed_uris.add(params[:uri].to_s)
837+
end
838+
839+
server.resources_unsubscribe_handler do |params|
840+
subscribed_uris.delete(params[:uri].to_s)
841+
end
842+
843+
server.define_tool(name: "update_resource") do |server_context:, **args|
844+
if subscribed_uris.include?("test://my-resource")
845+
server_context.notify_resources_updated(uri: "test://my-resource")
846+
end
847+
MCP::Tool::Response.new([MCP::Content::Text.new("Resource updated").to_h])
848+
end
849+
```
850+
811851
### Sampling
812852

813853
The Model Context Protocol allows servers to request LLM completions from clients through the `sampling/createMessage` method.
@@ -1249,7 +1289,6 @@ end
12491289

12501290
### Unsupported Features (to be implemented in future versions)
12511291

1252-
- Resource subscriptions
12531292
- Elicitation
12541293

12551294
## Building an MCP Client

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

@@ -485,6 +486,7 @@ def configure_handlers(server)
485486
server.server_context = server
486487

487488
configure_resources_read_handler(server)
489+
configure_subscription_handlers(server)
488490
configure_completion_handler(server)
489491
end
490492

@@ -555,6 +557,18 @@ def configure_completion_handler(server)
555557
end
556558
end
557559

560+
def configure_subscription_handlers(server)
561+
subscribed_uris = Set.new
562+
563+
server.resources_subscribe_handler do |params|
564+
subscribed_uris.add(params[:uri].to_s)
565+
end
566+
567+
server.resources_unsubscribe_handler do |params|
568+
subscribed_uris.delete(params[:uri].to_s)
569+
end
570+
end
571+
558572
def build_rack_app(transport)
559573
mcp_app = proc do |env|
560574
request = Rack::Request.new(env)

lib/mcp/server.rb

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ def initialize(
100100
Methods::RESOURCES_LIST => method(:list_resources),
101101
Methods::RESOURCES_READ => method(:read_resource_no_content),
102102
Methods::RESOURCES_TEMPLATES_LIST => method(:list_resource_templates),
103+
Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
104+
Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
103105
Methods::TOOLS_LIST => method(:list_tools),
104106
Methods::TOOLS_CALL => method(:call_tool),
105107
Methods::PROMPTS_LIST => method(:list_prompts),
@@ -112,8 +114,6 @@ def initialize(
112114
Methods::LOGGING_SET_LEVEL => method(:configure_logging_level),
113115

114116
# No op handlers for currently unsupported methods
115-
Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
116-
Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
117117
Methods::ELICITATION_CREATE => ->(_) {},
118118
}
119119
@transport = transport
@@ -263,6 +263,24 @@ def completion_handler(&block)
263263
@handlers[Methods::COMPLETION_COMPLETE] = block
264264
end
265265

266+
# Sets a custom handler for `resources/subscribe` requests.
267+
# The block receives the parsed request params. The return value is
268+
# ignored; the response is always an empty result `{}` per the MCP specification.
269+
#
270+
# @yield [params] The request params containing `:uri`.
271+
def resources_subscribe_handler(&block)
272+
@handlers[Methods::RESOURCES_SUBSCRIBE] = block
273+
end
274+
275+
# Sets a custom handler for `resources/unsubscribe` requests.
276+
# The block receives the parsed request params. The return value is
277+
# ignored; the response is always an empty result `{}` per the MCP specification.
278+
#
279+
# @yield [params] The request params containing `:uri`.
280+
def resources_unsubscribe_handler(&block)
281+
@handlers[Methods::RESOURCES_UNSUBSCRIBE] = block
282+
end
283+
266284
def build_sampling_params(
267285
capabilities,
268286
messages:,
@@ -399,6 +417,9 @@ def handle_request(request, method, session: nil, related_request_id: nil)
399417
{ contents: @handlers[Methods::RESOURCES_READ].call(params) }
400418
when Methods::RESOURCES_TEMPLATES_LIST
401419
{ resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) }
420+
when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
421+
@handlers[method].call(params)
422+
{}
402423
when Methods::TOOLS_CALL
403424
call_tool(params, session: session, related_request_id: related_request_id)
404425
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
# Falls back to `@context` (via `method_missing`) when `@notification_target`
3544
# does not support sampling.

lib/mcp/server_session.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ def create_sampling_message(related_request_id: nil, **kwargs)
4747
send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params, related_request_id: related_request_id)
4848
end
4949

50+
# Sends a resource updated notification to this session only.
51+
def notify_resources_updated(uri:)
52+
send_to_transport(Methods::NOTIFICATIONS_RESOURCES_UPDATED, { "uri" => uri })
53+
rescue => e
54+
@server.report_exception(e, notification: "resources_updated")
55+
end
56+
5057
# Sends a progress notification to this session only.
5158
def notify_progress(progress_token:, progress:, total: nil, message: nil, related_request_id: nil)
5259
params = {

test/mcp/server_context_test.rb

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

113+
test "ServerContext#notify_resources_updated delegates to notification_target" do
114+
notification_target = mock
115+
notification_target.expects(:notify_resources_updated).with(uri: "test://resource-1").once
116+
117+
progress = Progress.new(notification_target: notification_target, progress_token: nil)
118+
server_context = ServerContext.new(nil, progress: progress, notification_target: notification_target)
119+
120+
server_context.notify_resources_updated(uri: "test://resource-1")
121+
end
122+
123+
test "ServerContext#notify_resources_updated is a no-op when notification_target is nil" do
124+
progress = Progress.new(notification_target: nil, progress_token: nil)
125+
server_context = ServerContext.new(nil, progress: progress, notification_target: nil)
126+
127+
assert_nothing_raised { server_context.notify_resources_updated(uri: "test://resource-1") }
128+
end
129+
113130
# Tool without server_context parameter
114131
class SimpleToolWithoutContext < Tool
115132
tool_name "simple_without_context"

test/mcp/server_test.rb

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1976,6 +1976,72 @@ class Example < Tool
19761976
)
19771977
end
19781978

1979+
test "#handle resources/subscribe with custom handler calls the handler" do
1980+
server = Server.new(
1981+
name: "test_server",
1982+
capabilities: { resources: { subscribe: true } },
1983+
)
1984+
1985+
received_params = nil
1986+
server.resources_subscribe_handler do |params|
1987+
received_params = params
1988+
{}
1989+
end
1990+
1991+
server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 })
1992+
server.handle({ jsonrpc: "2.0", method: "notifications/initialized" })
1993+
1994+
response = server.handle({
1995+
jsonrpc: "2.0",
1996+
id: 2,
1997+
method: "resources/subscribe",
1998+
params: { uri: "https://example.com/resource" },
1999+
})
2000+
2001+
assert_equal(
2002+
{
2003+
jsonrpc: "2.0",
2004+
id: 2,
2005+
result: {},
2006+
},
2007+
response,
2008+
)
2009+
assert_equal "https://example.com/resource", received_params[:uri]
2010+
end
2011+
2012+
test "#handle resources/unsubscribe with custom handler calls the handler" do
2013+
server = Server.new(
2014+
name: "test_server",
2015+
capabilities: { resources: { subscribe: true } },
2016+
)
2017+
2018+
received_params = nil
2019+
server.resources_unsubscribe_handler do |params|
2020+
received_params = params
2021+
{}
2022+
end
2023+
2024+
server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 })
2025+
server.handle({ jsonrpc: "2.0", method: "notifications/initialized" })
2026+
2027+
response = server.handle({
2028+
jsonrpc: "2.0",
2029+
id: 2,
2030+
method: "resources/unsubscribe",
2031+
params: { uri: "https://example.com/resource" },
2032+
})
2033+
2034+
assert_equal(
2035+
{
2036+
jsonrpc: "2.0",
2037+
id: 2,
2038+
result: {},
2039+
},
2040+
response,
2041+
)
2042+
assert_equal "https://example.com/resource", received_params[:uri]
2043+
end
2044+
19792045
test "tools/call with no args" do
19802046
server = Server.new(tools: [@tool_with_no_args])
19812047

0 commit comments

Comments
 (0)