diff --git a/README.md b/README.md index c96b04cc..866c213a 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ It implements the Model Context Protocol specification, handling model context r - Supports notifications for list changes (tools, prompts, resources) - Supports roots (server-to-client filesystem boundary queries) - Supports sampling (server-to-client LLM completion requests) +- Supports cursor-based pagination for list operations ### Supported Methods @@ -1365,6 +1366,90 @@ When configured, sessions that receive no HTTP requests for this duration are au transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 1800) ``` +### Pagination + +The MCP Ruby SDK supports [pagination](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination) +for list operations that may return large result sets. Pagination uses string cursor tokens carrying a zero-based offset, +treated as opaque by clients: the server decides page size, and the client follows `nextCursor` until the server omits it. + +Pagination applies to `tools/list`, `prompts/list`, `resources/list`, and `resources/templates/list`. + +#### Server-Side: Enabling Pagination + +Pass `page_size:` to `MCP::Server.new` to split list responses into pages. When `page_size` is omitted (the default), +list responses contain all items in a single response, preserving the pre-pagination behavior. + +```ruby +server = MCP::Server.new( + name: "my_server", + tools: tools, + page_size: 50, +) +``` + +When `page_size` is set, list responses include a `nextCursor` field whenever more pages are available: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "tools": [ + { "name": "example_tool" } + ], + "nextCursor": "50" + } +} +``` + +Invalid cursors (e.g. non-numeric, negative, or out-of-range) are rejected with JSON-RPC error code `-32602 (Invalid params)` per the MCP specification. + +#### Client-Side: Iterating Pages + +`MCP::Client` exposes `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`. +**Each call issues exactly one `*/list` JSON-RPC request and returns exactly one page** — not the full collection. +The returned result object (`MCP::Client::ListToolsResult` etc.) exposes the page items and the next cursor as method accessors: + +```ruby +client = MCP::Client.new(transport: transport) + +cursor = nil +loop do + page = client.list_tools(cursor: cursor) + page.tools.each { |tool| process(tool) } + cursor = page.next_cursor + break unless cursor +end +``` + +The same pattern applies to `list_prompts` (`page.prompts`), `list_resources` (`page.resources`), and +`list_resource_templates` (`page.resource_templates`). `next_cursor` is `nil` on the final page. + +Because a single call returns a single page, how many items come back depends on the server's `page_size` configuration: + +| Server `page_size` | `client.list_tools(cursor: nil)` | +|--------------------|---------------------------------------------------------------------| +| Not set (default) | Returns every item in one response. `next_cursor` is `nil`. | +| Set to `N` | Returns the first `N` items. `next_cursor` is set for continuation. | + +If your application needs the complete collection regardless of how the server is configured, either loop on +`next_cursor` as shown above, or use the whole-collection methods described below. + +#### Fetching the Complete Collection + +`client.tools`, `client.resources`, `client.resource_templates`, and `client.prompts` auto-iterate +through all pages and return a plain array of items, guaranteeing the full collection regardless +of the server's `page_size` setting. When a server paginates, they issue multiple JSON-RPC round +trips per call and break out of the pagination loop if the server returns the same `nextCursor` +twice in a row as a safety measure. + +```ruby +tools = client.tools # => Array of every tool on the server. +``` + +Use these when you want the complete list; use `list_tools(cursor:)` etc. when you need +fine-grained iteration (e.g. to stream-process pages without loading everything into memory). + ### Advanced #### Custom Methods diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index d37b24a8..e4cfd027 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -2,6 +2,7 @@ require_relative "client/stdio" require_relative "client/http" +require_relative "client/paginated_result" require_relative "client/tool" module MCP @@ -43,8 +44,41 @@ def initialize(transport:) # So keeping it public attr_reader :transport - # Returns the list of tools available from the server. - # Each call will make a new request – the result is not cached. + # Returns a single page of tools from the server. + # + # @param cursor [String, nil] Cursor from a previous page response. + # @return [MCP::Client::ListToolsResult] Result with `tools` (Array) + # and `next_cursor` (String or nil). + # + # @example Iterate all pages + # cursor = nil + # loop do + # page = client.list_tools(cursor: cursor) + # page.tools.each { |tool| puts tool.name } + # cursor = page.next_cursor + # break unless cursor + # end + def list_tools(cursor: nil) + params = cursor ? { cursor: cursor } : nil + response = request(method: "tools/list", params: params) + result = response["result"] || {} + + tools = (result["tools"] || []).map do |tool| + Tool.new( + name: tool["name"], + description: tool["description"], + input_schema: tool["inputSchema"], + ) + end + + ListToolsResult.new(tools: tools, next_cursor: result["nextCursor"], meta: result["_meta"]) + end + + # Returns every tool available on the server. Iterates through all pages automatically + # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting. + # Use {#list_tools} when you need fine-grained cursor control. + # + # Each call will make a new request - the result is not cached. # # @return [Array] An array of available tools. # @@ -54,45 +88,151 @@ def initialize(transport:) # puts tool.name # end def tools - response = request(method: "tools/list") + # TODO: consider renaming to `list_all_tools`. + all_tools = [] + seen = Set.new + cursor = nil - response.dig("result", "tools")&.map do |tool| - Tool.new( - name: tool["name"], - description: tool["description"], - input_schema: tool["inputSchema"], - ) - end || [] + loop do + page = list_tools(cursor: cursor) + all_tools.concat(page.tools) + next_cursor = page.next_cursor + break if next_cursor.nil? || seen.include?(next_cursor) + + seen << next_cursor + cursor = next_cursor + end + + all_tools + end + + # Returns a single page of resources from the server. + # + # @param cursor [String, nil] Cursor from a previous page response. + # @return [MCP::Client::ListResourcesResult] Result with `resources` (Array) + # and `next_cursor` (String or nil). + def list_resources(cursor: nil) + params = cursor ? { cursor: cursor } : nil + response = request(method: "resources/list", params: params) + result = response["result"] || {} + + ListResourcesResult.new( + resources: result["resources"] || [], + next_cursor: result["nextCursor"], + meta: result["_meta"], + ) end - # Returns the list of resources available from the server. - # Each call will make a new request – the result is not cached. + # Returns every resource available on the server. Iterates through all pages automatically + # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting. + # Use {#list_resources} when you need fine-grained cursor control. + # + # Each call will make a new request - the result is not cached. # # @return [Array] An array of available resources. def resources - response = request(method: "resources/list") + # TODO: consider renaming to `list_all_resources`. + all_resources = [] + seen = Set.new + cursor = nil + + loop do + page = list_resources(cursor: cursor) + all_resources.concat(page.resources) + next_cursor = page.next_cursor + break if next_cursor.nil? || seen.include?(next_cursor) + + seen << next_cursor + cursor = next_cursor + end + + all_resources + end - response.dig("result", "resources") || [] + # Returns a single page of resource templates from the server. + # + # @param cursor [String, nil] Cursor from a previous page response. + # @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates` + # (Array) and `next_cursor` (String or nil). + def list_resource_templates(cursor: nil) + params = cursor ? { cursor: cursor } : nil + response = request(method: "resources/templates/list", params: params) + result = response["result"] || {} + + ListResourceTemplatesResult.new( + resource_templates: result["resourceTemplates"] || [], + next_cursor: result["nextCursor"], + meta: result["_meta"], + ) end - # Returns the list of resource templates available from the server. - # Each call will make a new request – the result is not cached. + # Returns every resource template available on the server. Iterates through all pages automatically + # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting. + # Use {#list_resource_templates} when you need fine-grained cursor control. + # + # Each call will make a new request - the result is not cached. # # @return [Array] An array of available resource templates. def resource_templates - response = request(method: "resources/templates/list") + # TODO: consider renaming to `list_all_resource_templates`. + all_templates = [] + seen = Set.new + cursor = nil - response.dig("result", "resourceTemplates") || [] + loop do + page = list_resource_templates(cursor: cursor) + all_templates.concat(page.resource_templates) + next_cursor = page.next_cursor + break if next_cursor.nil? || seen.include?(next_cursor) + + seen << next_cursor + cursor = next_cursor + end + + all_templates end - # Returns the list of prompts available from the server. - # Each call will make a new request – the result is not cached. + # Returns a single page of prompts from the server. + # + # @param cursor [String, nil] Cursor from a previous page response. + # @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array) + # and `next_cursor` (String or nil). + def list_prompts(cursor: nil) + params = cursor ? { cursor: cursor } : nil + response = request(method: "prompts/list", params: params) + result = response["result"] || {} + + ListPromptsResult.new( + prompts: result["prompts"] || [], + next_cursor: result["nextCursor"], + meta: result["_meta"], + ) + end + + # Returns every prompt available on the server. Iterates through all pages automatically + # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting. + # Use {#list_prompts} when you need fine-grained cursor control. + # + # Each call will make a new request - the result is not cached. # # @return [Array] An array of available prompts. def prompts - response = request(method: "prompts/list") + # TODO: consider renaming to `list_all_prompts`. + all_prompts = [] + seen = Set.new + cursor = nil + + loop do + page = list_prompts(cursor: cursor) + all_prompts.concat(page.prompts) + next_cursor = page.next_cursor + break if next_cursor.nil? || seen.include?(next_cursor) + + seen << next_cursor + cursor = next_cursor + end - response.dig("result", "prompts") || [] + all_prompts end # Calls a tool via the transport layer and returns the full response from the server. diff --git a/lib/mcp/client/paginated_result.rb b/lib/mcp/client/paginated_result.rb new file mode 100644 index 00000000..18bd26b6 --- /dev/null +++ b/lib/mcp/client/paginated_result.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module MCP + class Client + # Result objects returned by `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`. + # Each carries the page items, an optional opaque `next_cursor` string for continuing pagination, + # and an optional `meta` hash mirroring the MCP `_meta` response field. + ListToolsResult = Struct.new(:tools, :next_cursor, :meta, keyword_init: true) + ListPromptsResult = Struct.new(:prompts, :next_cursor, :meta, keyword_init: true) + ListResourcesResult = Struct.new(:resources, :next_cursor, :meta, keyword_init: true) + ListResourceTemplatesResult = Struct.new(:resource_templates, :next_cursor, :meta, keyword_init: true) + end +end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index e1fd2c44..241002c3 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -6,6 +6,7 @@ require_relative "logging_message_notification" require_relative "progress" require_relative "server_context" +require_relative "server/pagination" require_relative "server/transports" module MCP @@ -65,9 +66,10 @@ def initialize(method_name) end include Instrumentation + include Pagination attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification - attr_reader :client_capabilities + attr_reader :page_size, :client_capabilities def initialize( description: nil, @@ -84,6 +86,7 @@ def initialize( server_context: nil, configuration: nil, capabilities: nil, + page_size: nil, transport: nil ) @description = description @@ -100,6 +103,7 @@ def initialize( @resource_templates = resource_templates @resource_index = index_resources_by_uri(resources) @server_context = server_context + self.page_size = page_size @configuration = MCP.configuration.merge(configuration) @client = nil @@ -183,6 +187,14 @@ def define_custom_method(method_name:, &block) @handlers[method_name] = block end + def page_size=(page_size) + unless page_size.nil? || (page_size.is_a?(Integer) && page_size > 0) + raise ArgumentError, "page_size must be nil or a positive integer" + end + + @page_size = page_size + end + def notify_tools_list_changed return unless @transport @@ -377,16 +389,8 @@ def handle_request(request, method, session: nil, related_request_id: nil) result = case method when Methods::INITIALIZE init(params, session: session) - when Methods::TOOLS_LIST - { tools: @handlers[Methods::TOOLS_LIST].call(params) } - when Methods::PROMPTS_LIST - { prompts: @handlers[Methods::PROMPTS_LIST].call(params) } - when Methods::RESOURCES_LIST - { resources: @handlers[Methods::RESOURCES_LIST].call(params) } when Methods::RESOURCES_READ { contents: @handlers[Methods::RESOURCES_READ].call(params) } - when Methods::RESOURCES_TEMPLATES_LIST - { resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) } when Methods::TOOLS_CALL call_tool(params, session: session, related_request_id: related_request_id) when Methods::COMPLETION_COMPLETE @@ -488,7 +492,9 @@ def configure_logging_level(request, session: nil) end def list_tools(request) - @tools.values.map(&:to_h) + page = paginate(@tools.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) + + { tools: page[:items], nextCursor: page[:next_cursor] }.compact end def call_tool(request, session: nil, related_request_id: nil) @@ -536,7 +542,9 @@ def call_tool(request, session: nil, related_request_id: nil) end def list_prompts(request) - @prompts.values.map(&:to_h) + page = paginate(@prompts.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) + + { prompts: page[:items], nextCursor: page[:next_cursor] }.compact end def get_prompt(request) @@ -556,7 +564,9 @@ def get_prompt(request) end def list_resources(request) - @resources.map(&:to_h) + page = paginate(@resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) + + { resources: page[:items], nextCursor: page[:next_cursor] }.compact end # Server implementation should set `resources_read_handler` to override no-op default @@ -566,7 +576,9 @@ def read_resource_no_content(request) end def list_resource_templates(request) - @resource_templates.map(&:to_h) + page = paginate(@resource_templates, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) + + { resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact end def complete(params) diff --git a/lib/mcp/server/pagination.rb b/lib/mcp/server/pagination.rb new file mode 100644 index 00000000..aa26b217 --- /dev/null +++ b/lib/mcp/server/pagination.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module MCP + class Server + module Pagination + private + + def cursor_from(request) + return if request.nil? + + unless request.is_a?(Hash) + raise RequestHandlerError.new("Invalid params", request, error_type: :invalid_params) + end + + request[:cursor] + end + + def paginate(items, cursor:, page_size:, request:, &block) + start_index = 0 + + if cursor + unless cursor.is_a?(String) + raise RequestHandlerError.new("Invalid cursor", request, error_type: :invalid_params) + end + + start_index = Integer(cursor, exception: false) + if start_index.nil? || start_index < 0 || start_index >= items.size + raise RequestHandlerError.new("Invalid cursor", request, error_type: :invalid_params) + end + end + + end_index = page_size ? start_index + page_size : items.size + page = items[start_index...end_index] + page = page.map(&block) if block + + result = { items: page } + result[:next_cursor] = end_index.to_s if end_index < items.size + result + end + end + end +end diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 0252e0e0..54144690 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -537,5 +537,321 @@ def test_complete_returns_default_when_result_is_missing assert_equal([], result["values"]) refute(result["hasMore"]) end + + def test_tools_auto_paginates_across_multiple_pages + transport = mock + + page1_response = { + "result" => { + "tools" => [{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} }], + "nextCursor" => "cursor1", + }, + } + page2_response = { + "result" => { + "tools" => [{ "name" => "tool2", "description" => "tool2", "inputSchema" => {} }], + }, + } + + call_count = 0 + transport.expects(:send_request).twice.with do |args| + call_count += 1 + req = args[:request] + if call_count == 1 + req[:method] == "tools/list" && req[:params].nil? + else + req[:method] == "tools/list" && req[:params] == { cursor: "cursor1" } + end + end.returns(page1_response).then.returns(page2_response) + + client = Client.new(transport: transport) + tools = client.tools + + assert_equal(2, tools.size) + assert_equal("tool1", tools[0].name) + assert_equal("tool2", tools[1].name) + end + + def test_list_tools_returns_single_page_with_cursor + transport = mock + + mock_response = { + "result" => { + "tools" => [{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} }], + "nextCursor" => "cursor1", + }, + } + + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == "tools/list" + end.returns(mock_response).once + + client = Client.new(transport: transport) + result = client.list_tools + + assert_equal(1, result.tools.size) + assert_equal("tool1", result.tools[0].name) + assert_equal("cursor1", result.next_cursor) + end + + def test_list_tools_with_cursor_param + transport = mock + + mock_response = { + "result" => { + "tools" => [{ "name" => "tool2", "description" => "tool2", "inputSchema" => {} }], + }, + } + + transport.expects(:send_request).with do |args| + args.dig(:request, :params) == { cursor: "cursor1" } + end.returns(mock_response).once + + client = Client.new(transport: transport) + result = client.list_tools(cursor: "cursor1") + + assert_equal(1, result.tools.size) + assert_equal("tool2", result.tools[0].name) + assert_nil(result.next_cursor) + end + + def test_resources_auto_paginates_across_multiple_pages + transport = mock + + page1_response = { + "result" => { + "resources" => [{ "uri" => "https://a.invalid", "name" => "a" }], + "nextCursor" => "cursor1", + }, + } + page2_response = { + "result" => { + "resources" => [{ "uri" => "https://b.invalid", "name" => "b" }], + }, + } + + transport.expects(:send_request).twice.returns(page1_response).then.returns(page2_response) + + client = Client.new(transport: transport) + resources = client.resources + + assert_equal(2, resources.size) + assert_equal("a", resources[0]["name"]) + assert_equal("b", resources[1]["name"]) + end + + def test_list_resources_returns_single_page_with_cursor + transport = mock + + mock_response = { + "result" => { + "resources" => [{ "uri" => "https://a.invalid", "name" => "a" }], + "nextCursor" => "cursor1", + }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + result = client.list_resources + + assert_equal(1, result.resources.size) + assert_equal("cursor1", result.next_cursor) + end + + def test_resource_templates_auto_paginates_across_multiple_pages + transport = mock + + page1_response = { + "result" => { + "resourceTemplates" => [{ "uriTemplate" => "https://a.invalid/{id}", "name" => "a" }], + "nextCursor" => "cursor1", + }, + } + page2_response = { + "result" => { + "resourceTemplates" => [{ "uriTemplate" => "https://b.invalid/{id}", "name" => "b" }], + }, + } + + transport.expects(:send_request).twice.returns(page1_response).then.returns(page2_response) + + client = Client.new(transport: transport) + templates = client.resource_templates + + assert_equal(2, templates.size) + assert_equal("a", templates[0]["name"]) + assert_equal("b", templates[1]["name"]) + end + + def test_list_resource_templates_returns_single_page_with_cursor + transport = mock + + mock_response = { + "result" => { + "resourceTemplates" => [{ "uriTemplate" => "https://a.invalid/{id}", "name" => "a" }], + "nextCursor" => "cursor1", + }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + result = client.list_resource_templates + + assert_equal(1, result.resource_templates.size) + assert_equal("cursor1", result.next_cursor) + end + + def test_prompts_auto_paginates_across_multiple_pages + transport = mock + + page1_response = { + "result" => { + "prompts" => [{ "name" => "prompt_a", "description" => "A" }], + "nextCursor" => "cursor1", + }, + } + page2_response = { + "result" => { + "prompts" => [{ "name" => "prompt_b", "description" => "B" }], + }, + } + + transport.expects(:send_request).twice.returns(page1_response).then.returns(page2_response) + + client = Client.new(transport: transport) + prompts = client.prompts + + assert_equal(2, prompts.size) + assert_equal("prompt_a", prompts[0]["name"]) + assert_equal("prompt_b", prompts[1]["name"]) + end + + def test_tools_breaks_when_server_returns_same_cursor_repeatedly + transport = mock + + stuck_response = { + "result" => { + "tools" => [{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} }], + "nextCursor" => "stuck_cursor", + }, + } + + # If the server keeps returning the same cursor, the client must not loop forever. + # Expect at most 2 calls: the initial request (cursor=nil) and one retry (cursor="stuck_cursor") + # that detects the repeat and breaks out. + transport.expects(:send_request).twice.returns(stuck_response) + + client = Client.new(transport: transport) + tools = client.tools + + assert_equal(2, tools.size) + end + + def test_tools_breaks_when_server_cycles_between_cursors + transport = mock + + page_a = { + "result" => { + "tools" => [{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} }], + "nextCursor" => "A", + }, + } + page_b = { + "result" => { + "tools" => [{ "name" => "tool2", "description" => "tool2", "inputSchema" => {} }], + "nextCursor" => "B", + }, + } + # Server cycles A -> B -> A. Client must detect the revisited cursor and break. + page_a_again = { + "result" => { + "tools" => [{ "name" => "tool3", "description" => "tool3", "inputSchema" => {} }], + "nextCursor" => "A", + }, + } + + transport.expects(:send_request).times(3).returns(page_a, page_b, page_a_again) + + client = Client.new(transport: transport) + tools = client.tools + + assert_equal(3, tools.size) + assert_equal(["tool1", "tool2", "tool3"], tools.map(&:name)) + end + + def test_resources_breaks_when_server_returns_same_cursor_repeatedly + transport = mock + + stuck_response = { + "result" => { + "resources" => [{ "uri" => "https://a.invalid", "name" => "a" }], + "nextCursor" => "stuck_cursor", + }, + } + + transport.expects(:send_request).twice.returns(stuck_response) + + client = Client.new(transport: transport) + resources = client.resources + + assert_equal(2, resources.size) + end + + def test_resource_templates_breaks_when_server_returns_same_cursor_repeatedly + transport = mock + + stuck_response = { + "result" => { + "resourceTemplates" => [{ "uriTemplate" => "https://a.invalid/{id}", "name" => "a" }], + "nextCursor" => "stuck_cursor", + }, + } + + transport.expects(:send_request).twice.returns(stuck_response) + + client = Client.new(transport: transport) + templates = client.resource_templates + + assert_equal(2, templates.size) + end + + def test_prompts_breaks_when_server_returns_same_cursor_repeatedly + transport = mock + + stuck_response = { + "result" => { + "prompts" => [{ "name" => "prompt_a", "description" => "A" }], + "nextCursor" => "stuck_cursor", + }, + } + + transport.expects(:send_request).twice.returns(stuck_response) + + client = Client.new(transport: transport) + prompts = client.prompts + + assert_equal(2, prompts.size) + end + + def test_list_prompts_returns_single_page_with_cursor + transport = mock + + mock_response = { + "result" => { + "prompts" => [{ "name" => "prompt_a", "description" => "A" }], + "nextCursor" => "cursor1", + }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + result = client.list_prompts + + assert_equal(1, result.prompts.size) + assert_equal("cursor1", result.next_cursor) + end end end diff --git a/test/mcp/server/pagination_test.rb b/test/mcp/server/pagination_test.rb new file mode 100644 index 00000000..4885459b --- /dev/null +++ b/test/mcp/server/pagination_test.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class Server + class PaginationTest < ActiveSupport::TestCase + class PaginationHost + include Pagination + + # Expose private methods for testing. + public :paginate, :cursor_from + + # RequestHandlerError must be accessible. + RequestHandlerError = Server::RequestHandlerError + end + + setup do + @host = PaginationHost.new + @items = [{ name: "a" }, { name: "b" }, { name: "c" }, { name: "d" }, { name: "e" }] + @request = { method: "tools/list" } + end + + test "paginate returns all items when page_size is nil and no cursor" do + result = @host.paginate(@items, cursor: nil, page_size: nil, request: @request) + + assert_equal @items, result[:items] + assert_nil result[:next_cursor] + end + + test "paginate returns first page with nextCursor when page_size is set" do + result = @host.paginate(@items, cursor: nil, page_size: 2, request: @request) + + assert_equal [{ name: "a" }, { name: "b" }], result[:items] + assert_not_nil result[:next_cursor] + end + + test "paginate returns correct second page when cursor is provided" do + first = @host.paginate(@items, cursor: nil, page_size: 2, request: @request) + second = @host.paginate(@items, cursor: first[:next_cursor], page_size: 2, request: @request) + + assert_equal [{ name: "c" }, { name: "d" }], second[:items] + assert_not_nil second[:next_cursor] + end + + test "paginate returns last page without nextCursor" do + first = @host.paginate(@items, cursor: nil, page_size: 2, request: @request) + second = @host.paginate(@items, cursor: first[:next_cursor], page_size: 2, request: @request) + third = @host.paginate(@items, cursor: second[:next_cursor], page_size: 2, request: @request) + + assert_equal [{ name: "e" }], third[:items] + assert_nil third[:next_cursor] + end + + test "paginate with page_size equal to items count returns all without nextCursor" do + result = @host.paginate(@items, cursor: nil, page_size: 5, request: @request) + + assert_equal @items, result[:items] + assert_nil result[:next_cursor] + end + + test "paginate with page_size larger than items count returns all without nextCursor" do + result = @host.paginate(@items, cursor: nil, page_size: 100, request: @request) + + assert_equal @items, result[:items] + assert_nil result[:next_cursor] + end + + test "paginate with empty items returns empty array" do + result = @host.paginate([], cursor: nil, page_size: 2, request: @request) + + assert_equal [], result[:items] + assert_nil result[:next_cursor] + end + + test "paginate raises RequestHandlerError for non-numeric cursor" do + error = assert_raises(RequestHandlerError) do + @host.paginate(@items, cursor: "not_a_number", page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate raises RequestHandlerError for Integer cursor (spec requires string)" do + error = assert_raises(RequestHandlerError) do + @host.paginate(@items, cursor: 2, page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate raises RequestHandlerError for Float cursor (spec requires string)" do + error = assert_raises(RequestHandlerError) do + @host.paginate(@items, cursor: 1.5, page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate raises RequestHandlerError for negative offset cursor" do + error = assert_raises(RequestHandlerError) do + @host.paginate(@items, cursor: "-1", page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate raises RequestHandlerError for out-of-range cursor" do + error = assert_raises(RequestHandlerError) do + @host.paginate(@items, cursor: "100", page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate returns all items from cursor offset when page_size is nil" do + result = @host.paginate(@items, cursor: "2", page_size: nil, request: @request) + + assert_equal [{ name: "c" }, { name: "d" }, { name: "e" }], result[:items] + assert_nil result[:next_cursor] + end + + test "paginate raises RequestHandlerError when cursor points exactly at items.size" do + error = assert_raises(RequestHandlerError) do + @host.paginate(@items, cursor: @items.size.to_s, page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate on empty items with any cursor raises RequestHandlerError" do + error = assert_raises(RequestHandlerError) do + @host.paginate([], cursor: "0", page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "cursor_from returns nil for nil request" do + assert_nil @host.cursor_from(nil) + end + + test "cursor_from returns cursor for Hash request" do + assert_equal "abc", @host.cursor_from(cursor: "abc") + end + + test "cursor_from returns nil when Hash request has no cursor" do + assert_nil @host.cursor_from({}) + end + + test "cursor_from raises RequestHandlerError for non-Hash request" do + error = assert_raises(RequestHandlerError) do + @host.cursor_from([1, 2, 3]) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate with page_size 1 iterates one item at a time" do + results = [] + cursor = nil + + loop do + page = @host.paginate(@items, cursor: cursor, page_size: 1, request: @request) + results.concat(page[:items]) + cursor = page[:next_cursor] + break unless cursor + end + + assert_equal @items, results + end + end + end +end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 14b91828..73b924b8 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -2331,5 +2331,166 @@ def server_context server.handle(request) assert_equal "from_accessor", received_context[:custom] end + + test "#handle tools/list returns paginated results when page_size is set" do + tool_a = Tool.define(name: "tool_a", title: "Tool A", description: "Tool A") + tool_b = Tool.define(name: "tool_b", title: "Tool B", description: "Tool B") + tool_c = Tool.define(name: "tool_c", title: "Tool C", description: "Tool C") + + server = Server.new( + name: "pagination_test", + tools: [tool_a, tool_b, tool_c], + page_size: 2, + ) + + first_request = { jsonrpc: "2.0", method: "tools/list", id: 1 } + first_response = server.handle(first_request) + first_result = first_response[:result] + + assert_equal 2, first_result[:tools].size + assert_equal "tool_a", first_result[:tools][0][:name] + assert_equal "tool_b", first_result[:tools][1][:name] + assert_not_nil first_result[:nextCursor] + + second_request = { jsonrpc: "2.0", method: "tools/list", id: 2, params: { cursor: first_result[:nextCursor] } } + second_response = server.handle(second_request) + second_result = second_response[:result] + + assert_equal 1, second_result[:tools].size + assert_equal "tool_c", second_result[:tools][0][:name] + # Final page omits the nextCursor key entirely (not just sets it to nil). + refute second_result.key?(:nextCursor) + end + + test "#handle tools/list returns all tools when page_size is not set" do + response = @server.handle({ jsonrpc: "2.0", method: "tools/list", id: 1 }) + result = response[:result] + + assert_kind_of Array, result[:tools] + assert_nil result[:nextCursor] + end + + test "#handle tools/list returns error for invalid cursor" do + server = Server.new(name: "pagination_test", tools: [@tool], page_size: 1) + + request = { jsonrpc: "2.0", method: "tools/list", id: 1, params: { cursor: "!!!invalid!!!" } } + response = server.handle(request) + + assert_not_nil response[:error] + assert_equal(-32602, response[:error][:code]) + end + + test "#handle prompts/list returns paginated results when page_size is set" do + prompt_a = Prompt.define(name: "prompt_a", title: "Prompt A", description: "A") { Prompt::Result.new(description: "A", messages: []) } + prompt_b = Prompt.define(name: "prompt_b", title: "Prompt B", description: "B") { Prompt::Result.new(description: "B", messages: []) } + + server = Server.new(name: "pagination_test", prompts: [prompt_a, prompt_b], page_size: 1) + + first_response = server.handle({ jsonrpc: "2.0", method: "prompts/list", id: 1 }) + first_result = first_response[:result] + + assert_equal 1, first_result[:prompts].size + assert_equal "prompt_a", first_result[:prompts][0][:name] + assert_not_nil first_result[:nextCursor] + + second_response = server.handle({ jsonrpc: "2.0", method: "prompts/list", id: 2, params: { cursor: first_result[:nextCursor] } }) + second_result = second_response[:result] + + assert_equal 1, second_result[:prompts].size + assert_equal "prompt_b", second_result[:prompts][0][:name] + assert_nil second_result[:nextCursor] + end + + test "#handle resources/list returns paginated results when page_size is set" do + resource_a = Resource.new(uri: "https://a.invalid", name: "a", description: "A", mime_type: "text/plain") + resource_b = Resource.new(uri: "https://b.invalid", name: "b", description: "B", mime_type: "text/plain") + + server = Server.new(name: "pagination_test", resources: [resource_a, resource_b], page_size: 1) + + first_response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + first_result = first_response[:result] + + assert_equal 1, first_result[:resources].size + assert_equal "a", first_result[:resources][0][:name] + assert_not_nil first_result[:nextCursor] + + second_response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 2, params: { cursor: first_result[:nextCursor] } }) + second_result = second_response[:result] + + assert_equal 1, second_result[:resources].size + assert_equal "b", second_result[:resources][0][:name] + assert_nil second_result[:nextCursor] + end + + test "Server.new raises ArgumentError when page_size is zero" do + assert_raises(ArgumentError) do + Server.new(name: "test", page_size: 0) + end + end + + test "Server.new raises ArgumentError when page_size is negative" do + assert_raises(ArgumentError) do + Server.new(name: "test", page_size: -1) + end + end + + test "Server.new raises ArgumentError when page_size is non-Integer" do + assert_raises(ArgumentError) do + Server.new(name: "test", page_size: "10") + end + end + + test "page_size= raises ArgumentError for invalid values" do + server = Server.new(name: "test") + + assert_raises(ArgumentError) { server.page_size = 0 } + assert_raises(ArgumentError) { server.page_size = -1 } + assert_raises(ArgumentError) { server.page_size = "5" } + + server.page_size = nil + server.page_size = 10 + assert_equal 10, server.page_size + end + + test "#handle tools/list returns -32602 for non-Hash params" do + server = Server.new(name: "test", tools: [@tool], page_size: 1) + + request = { jsonrpc: "2.0", method: "tools/list", id: 1, params: [1, 2, 3] } + response = server.handle(request) + + assert_not_nil response[:error] + assert_equal(-32602, response[:error][:code]) + end + + test "#handle_json tools/list returns -32602 for numeric cursor (spec requires string)" do + server = Server.new(name: "test", tools: [@tool], page_size: 1) + + request_json = '{"jsonrpc":"2.0","method":"tools/list","id":1,"params":{"cursor":0}}' + response = JSON.parse(server.handle_json(request_json), symbolize_names: true) + + assert_not_nil response[:error] + assert_equal(-32602, response[:error][:code]) + end + + test "#handle resources/templates/list returns paginated results when page_size is set" do + template_a = ResourceTemplate.new(uri_template: "https://a.invalid/{id}", name: "a", description: "A", mime_type: "text/plain") + template_b = ResourceTemplate.new(uri_template: "https://b.invalid/{id}", name: "b", description: "B", mime_type: "text/plain") + + server = Server.new(name: "pagination_test", resource_templates: [template_a, template_b], page_size: 1) + + first_response = server.handle({ jsonrpc: "2.0", method: "resources/templates/list", id: 1 }) + first_result = first_response[:result] + + assert_equal 1, first_result[:resourceTemplates].size + assert_equal "a", first_result[:resourceTemplates][0][:name] + assert_not_nil first_result[:nextCursor] + + second_response = server.handle({ jsonrpc: "2.0", method: "resources/templates/list", id: 2, params: { cursor: first_result[:nextCursor] } }) + second_result = second_response[:result] + + assert_equal 1, second_result[:resourceTemplates].size + assert_equal "b", second_result[:resourceTemplates][0][:name] + assert_nil second_result[:nextCursor] + end end end