Support pagination per MCP specification

## Motivation and Context

The MCP specification defines cursor-based pagination for list operations that
may return large result sets:
https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination

Pagination allows servers to yield results in smaller chunks rather than all at once,
which is especially important when connecting to external services over the internet.

The Ruby SDK previously returned complete arrays for all list endpoints
(`tools/list`, `resources/list`, `prompts/list`, `resources/templates/list`)
without pagination support. This adds cursor-based pagination.

### Server-side

A new `MCP::Server::Pagination` module is introduced (mixed into `MCP::Server`)
that provides the `paginate` helper for slicing a collection by cursor and
a `cursor_from` helper that extracts the cursor from the request params
while rejecting non-Hash inputs with `-32602 Invalid params` per the specification.

`MCP::Server.new` accepts a new `page_size:` keyword argument.
When `nil` (the default), all items are returned in a single response,
preserving the existing behavior. When set to a positive integer,
list responses include a `nextCursor` field if more pages are available.

`Server#page_size=` is a validating setter that rejects anything other than
`nil` or a positive `Integer`, raising `ArgumentError` on invalid inputs.
The constructor routes through the setter, so both `Server.new(page_size: 0)`
and `server.page_size = -1` raise.

Cursors are string tokens carrying a zero-based offset, treated as opaque by
clients. Cursor inputs are validated to be strings per the MCP spec;
invalid cursors (non-string, non-numeric, negative, or out-of-range) raise
`RequestHandlerError` with error code `-32602 (Invalid params)` per the spec.

### Client-side: two complementary APIs

The SDK exposes two API shapes for listing, each serving a different use case:

1. **Single-page with cursor** (`client.list_tools(cursor:)`,
   `list_resources`, `list_resource_templates`, `list_prompts`): each call issues
   one JSON-RPC request and returns a result object (`MCP::Client::ListToolsResult` etc.)
   carrying the page items and an optional `next_cursor`.
   These methods match the single-page-with-cursor convention used by the Python SDK
   (`session.list_tools(params=PaginatedRequestParams(cursor=...))`)
   and the TypeScript SDK (`client.listTools({ cursor })`). The method name and
   the `cursor` parameter name are identical across the three SDKs,
   so pagination code translates directly between languages.

2. **Whole-collection** (`client.tools`, `client.resources`, `client.resource_templates`,
   `client.prompts`): auto-iterates through all pages and returns a plain array of items,
   guaranteeing the full collection regardless of the server's `page_size` setting.
   The auto-pagination loop tracks previously seen cursors in a set and exits if the server
   revisits any of them, guarding against both immediate repeats and multi-cursor cycles
   (e.g. `A -> B -> A`). This mirrors Rust SDK's `list_all_*` convenience methods,
   though the Ruby names are preserved from the pre-pagination 0.13.0 API for backward compatibility.
   Future rename to `list_all_*` is left as a TODO in the source.

Result classes are `Struct`s named to mirror Python SDK's `ListToolsResult` /
`ListPromptsResult` / etc. to align naming with other SDKs. Each struct exposes
its page items (e.g. `result.tools`), an optional `next_cursor` for continuation,
and an optional `meta` field mirroring the MCP `Result` type's `_meta` response field
so servers that decorate list responses do not lose metadata through the client.

### Ruby-specific ergonomics

`Server.new(page_size:)` and the `Pagination` module are Ruby-specific.
The Python and TypeScript SDKs do not expose a built-in `page_size` helper;
developers there re-implement cursor slicing inside each handler.
In Ruby, setting `page_size:` on the server is enough for the SDK to handle
the slicing across the four built-in list endpoints.

### README

A Pagination section documents both the server-side `page_size:` option and
the client-side iteration patterns, including an explicit note that each `list_*` call
is a single JSON-RPC round trip whose response size depends on the server's `page_size`,
and that `client.tools` / `.resources` / `.resource_templates` / `.prompts` return
the complete collection when needed.

## How Has This Been Tested?

- Added tests for the `Pagination` module (`test/mcp/server/pagination_test.rb`),
  server-side pagination (`test/mcp/server_test.rb`), and client-side pagination
  (`test/mcp/client_test.rb`).
- Added and existing tests pass. `rake rubocop` is clean and `rake conformance` passes.

## Breaking Changes

None. This is a new feature addition. Existing code continues to work unchanged:
`page_size` defaults to `nil` on the server (returns all items, no `nextCursor`),
and the client's existing `tools` / `resources` / `resource_templates` / `prompts` methods
continue to return complete arrays by transparently iterating through pages.
This commit is contained in:
Koichi ITO 2026-04-17 02:24:03 +09:00
commit b97b922a36
8 changed files with 970 additions and 35 deletions

View file

@ -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<MCP::Client::Tool> 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

View file

@ -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 @@ module MCP
# 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<MCP::Client::Tool>)
# 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<MCP::Client::Tool>] An array of available tools.
#
@ -54,45 +88,151 @@ module MCP
# 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 the list of resources available from the server.
# Each call will make a new request – the result is not cached.
# 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<Hash>)
# 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 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<Hash>] 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
response.dig("result", "resources") || []
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
# Returns the list of resource templates available from the server.
# Each call will make a new request – the result is not cached.
# 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<Hash>) 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 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<Hash>] 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<Hash>)
# 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<Hash>] 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
response.dig("result", "prompts") || []
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
all_prompts
end
# Calls a tool via the transport layer and returns the full response from the server.

View file

@ -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

View file

@ -6,6 +6,7 @@ require_relative "methods"
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 @@ module MCP
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 @@ module MCP
server_context: nil,
configuration: nil,
capabilities: nil,
page_size: nil,
transport: nil
)
@description = description
@ -100,6 +103,7 @@ module MCP
@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 @@ module MCP
@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 @@ module MCP
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 @@ module MCP
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 @@ module MCP
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 @@ module MCP
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 @@ module MCP
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)

View file

@ -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

View file

@ -537,5 +537,321 @@ module MCP
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

View file

@ -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

View file

@ -2331,5 +2331,166 @@ module MCP
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