Merge pull request #509 from koic/add_resources_list_handler

Add a `resources_list_handler` for context-dependent resource lists
This commit is contained in:
Koichi ITO 2026-08-20 01:15:27 +09:00 • committed by GitHub
commit 34473282b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 98 additions and 3 deletions

View file

@ -1261,6 +1261,20 @@ end
otherwise `resources/read` requests will be a no-op. Note that a `resources_read_handler` fully replaces
the default `resources/read` handling, including the automatic routing to class-based resources described above.
To make the resource *list* depend on the request, register a `resources_list_handler`. The block returns the resource collection to serve,
so the visible resources can vary by the authenticated principal or the granted scope. The framework paginates the returned array
and stamps the same cache hints it applies to the constructor-provided resources, so the block returns only the array.
A block that declares `server_context:` receives it:
```ruby
server.resources_list_handler do |params, server_context:|
server_context[:authenticated] ? real_resources : demo_resources
end
```
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
into the returned collection. When no handler is set, the resources passed to `MCP::Server.new` are served unchanged.
For unknown URIs, raise `MCP::Server::ResourceNotFoundError` from the handler.
Per SEP-2164, the server then responds with the standard JSON-RPC Invalid Params error (`-32602`)
carrying the requested URI in the error `data` member:
@ -1646,7 +1660,7 @@ Client-initiated cancellation is also supported: see [Client-Side: Cancelling an
#### Server-Side: Handlers that Check for Cancellation
Any handler that opts in to `server_context:` - tools (`Tool.call`), prompt templates,
`resources_read_handler`, `completion_handler`, `resources_subscribe_handler`,
`resources_read_handler`, `resources_list_handler`, `completion_handler`, `resources_subscribe_handler`,
`resources_unsubscribe_handler`, and `define_custom_method` blocks - receives
an `MCP::ServerContext` wired to the in-flight request's cancellation token.
Handlers check `cancelled?` in their work loop, or call `raise_if_cancelled!` to raise

View file

@ -219,6 +219,7 @@ module MCP
@resources = resources
@resource_templates = resource_templates
@resource_index = index_resources_by_uri(resources)
@resources_list_handler = nil
@server_context = server_context
self.page_size = page_size
self.ttl_ms = ttl_ms
@ -426,6 +427,22 @@ module MCP
@handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block
end
# Sets a custom handler for `resources/list` requests, letting the visible list depend on request context such as
# the authenticated principal or granted scope. The block returns the resource collection to serve;
# the framework paginates it and stamps SEP-2549 cache hints exactly as it does for the constructor-provided resources,
# so the block returns only the array, not the paginated result.
# A block that declares a `server_context:` keyword receives an `MCP::ServerContext`. When no handler is set,
# the constructor-provided `resources` array is served unchanged.
#
# The block is invoked once per page, so it must return a stable ordering across the pages of one logical query;
# the cursor is a positional offset into the returned collection.
#
# @yield [params, server_context:] The request params, and an `MCP::ServerContext` when declared.
# @yieldreturn [Array<MCP::Resource>] The resources to paginate.
def resources_list_handler(&block)
@resources_list_handler = block
end
# Sets a custom handler for `resources/read` requests.
# The block receives the parsed request params and should return resource
# contents. The return value is set as the `contents` field of the response.
@ -1292,12 +1309,28 @@ module MCP
call_prompt_template_with_args(prompt, prompt_args, server_context)
end
def list_resources(request)
page = paginate(@resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
def list_resources(request, server_context: nil)
resources = if @resources_list_handler
invoke_resources_list_handler(request, server_context)
else
@resources
end
page = paginate(resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
apply_cache_metadata({ resources: page[:items], nextCursor: page[:next_cursor] }.compact)
end
# Calls the `resources_list_handler` block, forwarding `server_context:` only when the block opts in
# by declaring the keyword (the same rule `dispatch_optional_context_handler` applies).
def invoke_resources_list_handler(request, server_context)
if handler_declares_server_context?(@resources_list_handler)
@resources_list_handler.call(request, server_context: server_context)
else
@resources_list_handler.call(request)
end
end
# Default `resources/read` handler: routes to class-based resources and resource templates.
# Fully replaced when `resources_read_handler` is set. When no class-based resource or template is registered,
# unknown URIs keep the historical no-op `[]` response instead of raising.

View file

@ -1437,6 +1437,54 @@ module MCP
assert_instrumentation_data({ method: "resources/list" })
end
test "#resources_list_handler replaces the served resource collection" do
other = Resource.new(uri: "https://other.invalid", name: "other", mime_type: "text/plain")
@server.resources_list_handler { |_params| [other] }
response = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 })
assert_equal({ resources: [other.to_h] }, response[:result])
end
test "#resources_list_handler receives server_context when it opts in" do
real = Resource.new(uri: "https://real.invalid", name: "real", mime_type: "text/plain")
demo = Resource.new(uri: "https://demo.invalid", name: "demo", mime_type: "text/plain")
@server.resources_list_handler do |_params, server_context:|
server_context[:authenticated] ? [real] : [demo]
end
@server.server_context = { authenticated: false }
anon = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 })
@server.server_context = { authenticated: true }
authed = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 })
assert_equal({ resources: [demo.to_h] }, anon[:result])
assert_equal({ resources: [real.to_h] }, authed[:result])
end
test "#resources_list_handler paginates and stamps cache hints on the returned collection" do
first = Resource.new(uri: "https://first.invalid", name: "first", mime_type: "text/plain")
second = Resource.new(uri: "https://second.invalid", name: "second", mime_type: "text/plain")
server = Server.new(name: @server_name, resources: [], page_size: 1, ttl_ms: 60_000)
server.resources_list_handler { |_params| [first, second] }
response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 })
assert_equal([first.to_h], response[:result][:resources])
assert_equal("1", response[:result][:nextCursor])
assert_equal(60_000, response[:result][:ttlMs])
end
test "#resources_list_handler serves a server with no constructor-provided resources" do
only = Resource.new(uri: "https://only.invalid", name: "only", mime_type: "text/plain")
server = Server.new(name: @server_name, resources: [])
server.resources_list_handler { |_params| [only] }
response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 })
assert_equal({ resources: [only.to_h] }, response[:result])
end
test "#handle resources/read returns an empty array of contents by default" do
request = {
jsonrpc: "2.0",