Add a resources_list_handler for context-dependent resource lists

## Motivation and Context

`resources/read` can be taken over with `resources_read_handler`, but `resources/list` had no equivalent:
it was bound to the internal `list_resources`, which paginates the constructor-provided `resources` array,
and `define_custom_method` refuses to rebind a method the server already handles.
An application whose visible resources depend on the request - authenticated versus anonymous sessions,
or an OAuth-scoped connector that must see a scope-filtered set - had to build a fresh `Server` per request
or override a private method.

Both reference SDKs let the list vary per request. The TypeScript SDK exposes `resources/list` through
`setRequestHandler` and gives resource templates a per-context `listCallback(ctx)`; the Python SDK takes
an `on_list_resources` handler. This adds the Ruby equivalent.

`resources_list_handler(&block)` registers a block that returns the resource collection to serve.
It feeds `list_resources` rather than replacing it, so the framework still paginates the returned array
and stamps the SEP-2549 cache hints; the block returns only the array, matching how `resources_read_handler` returns
only the contents and the framework wraps them. A block that declares a `server_context:` keyword receives
an `MCP::ServerContext`, reusing the same opt-in rule the read handler uses, so the list can be filtered by
the authenticated principal or granted scope. The block is invoked once per page and must return
a stable ordering across the pages of one query, since the cursor is a positional offset. When no handler is set,
the constructor-provided resources are served unchanged.

The `resources` capability is advertised whether or not the constructor array is populated, so a server that
provides only a `resources_list_handler` still answers `resources/list`.

Fixes #507.

## How Has This Been Tested?

New tests in `test/mcp/server_test.rb` cover a handler replacing the served collection, a handler receiving `server_context:`
and returning different sets for authenticated and anonymous requests, pagination and cache hints still applying to
the handler-provided array, and a server with no constructor-provided resources served entirely by the handler.
The existing `resources/list` test, which exercises the no-handler default path, is unchanged.

## Breaking Changes

None. When no `resources_list_handler` is set, `resources/list` serves the constructor-provided resources exactly as before.
This commit is contained in:
Koichi ITO 2026-08-13 00:10:27 +09:00
commit 27044b0047
3 changed files with 98 additions and 3 deletions

View file

@ -1244,6 +1244,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:
@ -1613,7 +1627,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

@ -182,6 +182,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
@ -387,6 +388,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.
@ -1088,12 +1105,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

@ -1356,6 +1356,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",