From 27044b00472fc51c5b4ce41afbc93ebe35d7b301 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 13 Aug 2026 00:10:27 +0900 Subject: [PATCH] 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. --- README.md | 16 +++++++++++++- lib/mcp/server.rb | 37 +++++++++++++++++++++++++++++-- test/mcp/server_test.rb | 48 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0d7a5278..d1681138 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index a95c651d..4d8702a8 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -182,6 +182,7 @@ def initialize( @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 @@ def roots_list_changed_handler(&block) @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] 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 @@ def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil 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. diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 23acceaa..0b828153 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -1356,6 +1356,54 @@ class Example < Tool 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",