Skip to content

Commit 3447328

Browse files
authored
Merge pull request #509 from koic/add_resources_list_handler
Add a `resources_list_handler` for context-dependent resource lists
2 parents 4eb6615 + 27044b0 commit 3447328

3 files changed

Lines changed: 98 additions & 3 deletions

File tree

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1261,6 +1261,20 @@ end
12611261
otherwise `resources/read` requests will be a no-op. Note that a `resources_read_handler` fully replaces
12621262
the default `resources/read` handling, including the automatic routing to class-based resources described above.
12631263

1264+
To make the resource *list* depend on the request, register a `resources_list_handler`. The block returns the resource collection to serve,
1265+
so the visible resources can vary by the authenticated principal or the granted scope. The framework paginates the returned array
1266+
and stamps the same cache hints it applies to the constructor-provided resources, so the block returns only the array.
1267+
A block that declares `server_context:` receives it:
1268+
1269+
```ruby
1270+
server.resources_list_handler do |params, server_context:|
1271+
server_context[:authenticated] ? real_resources : demo_resources
1272+
end
1273+
```
1274+
1275+
The block is invoked once per page, so it must return a stable ordering across the pages of one query; the cursor is a positional offset
1276+
into the returned collection. When no handler is set, the resources passed to `MCP::Server.new` are served unchanged.
1277+
12641278
For unknown URIs, raise `MCP::Server::ResourceNotFoundError` from the handler.
12651279
Per SEP-2164, the server then responds with the standard JSON-RPC Invalid Params error (`-32602`)
12661280
carrying the requested URI in the error `data` member:
@@ -1646,7 +1660,7 @@ Client-initiated cancellation is also supported: see [Client-Side: Cancelling an
16461660
#### Server-Side: Handlers that Check for Cancellation
16471661

16481662
Any handler that opts in to `server_context:` - tools (`Tool.call`), prompt templates,
1649-
`resources_read_handler`, `completion_handler`, `resources_subscribe_handler`,
1663+
`resources_read_handler`, `resources_list_handler`, `completion_handler`, `resources_subscribe_handler`,
16501664
`resources_unsubscribe_handler`, and `define_custom_method` blocks - receives
16511665
an `MCP::ServerContext` wired to the in-flight request's cancellation token.
16521666
Handlers check `cancelled?` in their work loop, or call `raise_if_cancelled!` to raise

lib/mcp/server.rb

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ def initialize(
219219
@resources = resources
220220
@resource_templates = resource_templates
221221
@resource_index = index_resources_by_uri(resources)
222+
@resources_list_handler = nil
222223
@server_context = server_context
223224
self.page_size = page_size
224225
self.ttl_ms = ttl_ms
@@ -426,6 +427,22 @@ def roots_list_changed_handler(&block)
426427
@handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block
427428
end
428429

430+
# Sets a custom handler for `resources/list` requests, letting the visible list depend on request context such as
431+
# the authenticated principal or granted scope. The block returns the resource collection to serve;
432+
# the framework paginates it and stamps SEP-2549 cache hints exactly as it does for the constructor-provided resources,
433+
# so the block returns only the array, not the paginated result.
434+
# A block that declares a `server_context:` keyword receives an `MCP::ServerContext`. When no handler is set,
435+
# the constructor-provided `resources` array is served unchanged.
436+
#
437+
# The block is invoked once per page, so it must return a stable ordering across the pages of one logical query;
438+
# the cursor is a positional offset into the returned collection.
439+
#
440+
# @yield [params, server_context:] The request params, and an `MCP::ServerContext` when declared.
441+
# @yieldreturn [Array<MCP::Resource>] The resources to paginate.
442+
def resources_list_handler(&block)
443+
@resources_list_handler = block
444+
end
445+
429446
# Sets a custom handler for `resources/read` requests.
430447
# The block receives the parsed request params and should return resource
431448
# contents. The return value is set as the `contents` field of the response.
@@ -1292,12 +1309,28 @@ def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil
12921309
call_prompt_template_with_args(prompt, prompt_args, server_context)
12931310
end
12941311

1295-
def list_resources(request)
1296-
page = paginate(@resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
1312+
def list_resources(request, server_context: nil)
1313+
resources = if @resources_list_handler
1314+
invoke_resources_list_handler(request, server_context)
1315+
else
1316+
@resources
1317+
end
1318+
1319+
page = paginate(resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
12971320

12981321
apply_cache_metadata({ resources: page[:items], nextCursor: page[:next_cursor] }.compact)
12991322
end
13001323

1324+
# Calls the `resources_list_handler` block, forwarding `server_context:` only when the block opts in
1325+
# by declaring the keyword (the same rule `dispatch_optional_context_handler` applies).
1326+
def invoke_resources_list_handler(request, server_context)
1327+
if handler_declares_server_context?(@resources_list_handler)
1328+
@resources_list_handler.call(request, server_context: server_context)
1329+
else
1330+
@resources_list_handler.call(request)
1331+
end
1332+
end
1333+
13011334
# Default `resources/read` handler: routes to class-based resources and resource templates.
13021335
# Fully replaced when `resources_read_handler` is set. When no class-based resource or template is registered,
13031336
# unknown URIs keep the historical no-op `[]` response instead of raising.

test/mcp/server_test.rb

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1437,6 +1437,54 @@ class Example < Tool
14371437
assert_instrumentation_data({ method: "resources/list" })
14381438
end
14391439

1440+
test "#resources_list_handler replaces the served resource collection" do
1441+
other = Resource.new(uri: "https://other.invalid", name: "other", mime_type: "text/plain")
1442+
@server.resources_list_handler { |_params| [other] }
1443+
1444+
response = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 })
1445+
1446+
assert_equal({ resources: [other.to_h] }, response[:result])
1447+
end
1448+
1449+
test "#resources_list_handler receives server_context when it opts in" do
1450+
real = Resource.new(uri: "https://real.invalid", name: "real", mime_type: "text/plain")
1451+
demo = Resource.new(uri: "https://demo.invalid", name: "demo", mime_type: "text/plain")
1452+
@server.resources_list_handler do |_params, server_context:|
1453+
server_context[:authenticated] ? [real] : [demo]
1454+
end
1455+
1456+
@server.server_context = { authenticated: false }
1457+
anon = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 })
1458+
@server.server_context = { authenticated: true }
1459+
authed = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 })
1460+
1461+
assert_equal({ resources: [demo.to_h] }, anon[:result])
1462+
assert_equal({ resources: [real.to_h] }, authed[:result])
1463+
end
1464+
1465+
test "#resources_list_handler paginates and stamps cache hints on the returned collection" do
1466+
first = Resource.new(uri: "https://first.invalid", name: "first", mime_type: "text/plain")
1467+
second = Resource.new(uri: "https://second.invalid", name: "second", mime_type: "text/plain")
1468+
server = Server.new(name: @server_name, resources: [], page_size: 1, ttl_ms: 60_000)
1469+
server.resources_list_handler { |_params| [first, second] }
1470+
1471+
response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 })
1472+
1473+
assert_equal([first.to_h], response[:result][:resources])
1474+
assert_equal("1", response[:result][:nextCursor])
1475+
assert_equal(60_000, response[:result][:ttlMs])
1476+
end
1477+
1478+
test "#resources_list_handler serves a server with no constructor-provided resources" do
1479+
only = Resource.new(uri: "https://only.invalid", name: "only", mime_type: "text/plain")
1480+
server = Server.new(name: @server_name, resources: [])
1481+
server.resources_list_handler { |_params| [only] }
1482+
1483+
response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 })
1484+
1485+
assert_equal({ resources: [only.to_h] }, response[:result])
1486+
end
1487+
14401488
test "#handle resources/read returns an empty array of contents by default" do
14411489
request = {
14421490
jsonrpc: "2.0",

0 commit comments

Comments
 (0)