Skip to content

Commit 2edba77

Browse files
authored
Merge pull request modelcontextprotocol#313 from koic/support_resource_subscription
Support resource subscriptions per MCP specification
2 parents e73c444 + d80fba4 commit 2edba77

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)