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

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",