Skip to content

Commit f9d7afe

Browse files
committed
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.
1 parent 7b5533c commit f9d7afe

8 files changed

Lines changed: 970 additions & 35 deletions

File tree

README.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ It implements the Model Context Protocol specification, handling model context r
4040
- Supports notifications for list changes (tools, prompts, resources)
4141
- Supports roots (server-to-client filesystem boundary queries)
4242
- Supports sampling (server-to-client LLM completion requests)
43+
- Supports cursor-based pagination for list operations
4344

4445
### Supported Methods
4546

@@ -1365,6 +1366,90 @@ When configured, sessions that receive no HTTP requests for this duration are au
13651366
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 1800)
13661367
```
13671368

1369+
### Pagination
1370+
1371+
The MCP Ruby SDK supports [pagination](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination)
1372+
for list operations that may return large result sets. Pagination uses string cursor tokens carrying a zero-based offset,
1373+
treated as opaque by clients: the server decides page size, and the client follows `nextCursor` until the server omits it.
1374+
1375+
Pagination applies to `tools/list`, `prompts/list`, `resources/list`, and `resources/templates/list`.
1376+
1377+
#### Server-Side: Enabling Pagination
1378+
1379+
Pass `page_size:` to `MCP::Server.new` to split list responses into pages. When `page_size` is omitted (the default),
1380+
list responses contain all items in a single response, preserving the pre-pagination behavior.
1381+
1382+
```ruby
1383+
server = MCP::Server.new(
1384+
name: "my_server",
1385+
tools: tools,
1386+
page_size: 50,
1387+
)
1388+
```
1389+
1390+
When `page_size` is set, list responses include a `nextCursor` field whenever more pages are available:
1391+
1392+
```json
1393+
{
1394+
"jsonrpc": "2.0",
1395+
"id": 1,
1396+
"result": {
1397+
"tools": [
1398+
{ "name": "example_tool" }
1399+
],
1400+
"nextCursor": "50"
1401+
}
1402+
}
1403+
```
1404+
1405+
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.
1406+
1407+
#### Client-Side: Iterating Pages
1408+
1409+
`MCP::Client` exposes `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`.
1410+
**Each call issues exactly one `*/list` JSON-RPC request and returns exactly one page** — not the full collection.
1411+
The returned result object (`MCP::Client::ListToolsResult` etc.) exposes the page items and the next cursor as method accessors:
1412+
1413+
```ruby
1414+
client = MCP::Client.new(transport: transport)
1415+
1416+
cursor = nil
1417+
loop do
1418+
page = client.list_tools(cursor: cursor)
1419+
page.tools.each { |tool| process(tool) }
1420+
cursor = page.next_cursor
1421+
break unless cursor
1422+
end
1423+
```
1424+
1425+
The same pattern applies to `list_prompts` (`page.prompts`), `list_resources` (`page.resources`), and
1426+
`list_resource_templates` (`page.resource_templates`). `next_cursor` is `nil` on the final page.
1427+
1428+
Because a single call returns a single page, how many items come back depends on the server's `page_size` configuration:
1429+
1430+
| Server `page_size` | `client.list_tools(cursor: nil)` |
1431+
|--------------------|---------------------------------------------------------------------|
1432+
| Not set (default) | Returns every item in one response. `next_cursor` is `nil`. |
1433+
| Set to `N` | Returns the first `N` items. `next_cursor` is set for continuation. |
1434+
1435+
If your application needs the complete collection regardless of how the server is configured, either loop on
1436+
`next_cursor` as shown above, or use the whole-collection methods described below.
1437+
1438+
#### Fetching the Complete Collection
1439+
1440+
`client.tools`, `client.resources`, `client.resource_templates`, and `client.prompts` auto-iterate
1441+
through all pages and return a plain array of items, guaranteeing the full collection regardless
1442+
of the server's `page_size` setting. When a server paginates, they issue multiple JSON-RPC round
1443+
trips per call and break out of the pagination loop if the server returns the same `nextCursor`
1444+
twice in a row as a safety measure.
1445+
1446+
```ruby
1447+
tools = client.tools # => Array<MCP::Client::Tool> of every tool on the server.
1448+
```
1449+
1450+
Use these when you want the complete list; use `list_tools(cursor:)` etc. when you need
1451+
fine-grained iteration (e.g. to stream-process pages without loading everything into memory).
1452+
13681453
### Advanced
13691454

13701455
#### Custom Methods

lib/mcp/client.rb

Lines changed: 162 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require_relative "client/stdio"
44
require_relative "client/http"
5+
require_relative "client/paginated_result"
56
require_relative "client/tool"
67

78
module MCP
@@ -43,8 +44,41 @@ def initialize(transport:)
4344
# So keeping it public
4445
attr_reader :transport
4546

46-
# Returns the list of tools available from the server.
47-
# Each call will make a new request – the result is not cached.
47+
# Returns a single page of tools from the server.
48+
#
49+
# @param cursor [String, nil] Cursor from a previous page response.
50+
# @return [MCP::Client::ListToolsResult] Result with `tools` (Array<MCP::Client::Tool>)
51+
# and `next_cursor` (String or nil).
52+
#
53+
# @example Iterate all pages
54+
# cursor = nil
55+
# loop do
56+
# page = client.list_tools(cursor: cursor)
57+
# page.tools.each { |tool| puts tool.name }
58+
# cursor = page.next_cursor
59+
# break unless cursor
60+
# end
61+
def list_tools(cursor: nil)
62+
params = cursor ? { cursor: cursor } : nil
63+
response = request(method: "tools/list", params: params)
64+
result = response["result"] || {}
65+
66+
tools = (result["tools"] || []).map do |tool|
67+
Tool.new(
68+
name: tool["name"],
69+
description: tool["description"],
70+
input_schema: tool["inputSchema"],
71+
)
72+
end
73+
74+
ListToolsResult.new(tools: tools, next_cursor: result["nextCursor"], meta: result["_meta"])
75+
end
76+
77+
# Returns every tool available on the server. Iterates through all pages automatically
78+
# when the server paginates, so the full collection is returned regardless of the server's `page_size` setting.
79+
# Use {#list_tools} when you need fine-grained cursor control.
80+
#
81+
# Each call will make a new request - the result is not cached.
4882
#
4983
# @return [Array<MCP::Client::Tool>] An array of available tools.
5084
#
@@ -54,45 +88,151 @@ def initialize(transport:)
5488
# puts tool.name
5589
# end
5690
def tools
57-
response = request(method: "tools/list")
91+
# TODO: consider renaming to `list_all_tools`.
92+
all_tools = []
93+
seen = Set.new
94+
cursor = nil
5895

59-
response.dig("result", "tools")&.map do |tool|
60-
Tool.new(
61-
name: tool["name"],
62-
description: tool["description"],
63-
input_schema: tool["inputSchema"],
64-
)
65-
end || []
96+
loop do
97+
page = list_tools(cursor: cursor)
98+
all_tools.concat(page.tools)
99+
next_cursor = page.next_cursor
100+
break if next_cursor.nil? || seen.include?(next_cursor)
101+
102+
seen << next_cursor
103+
cursor = next_cursor
104+
end
105+
106+
all_tools
107+
end
108+
109+
# Returns a single page of resources from the server.
110+
#
111+
# @param cursor [String, nil] Cursor from a previous page response.
112+
# @return [MCP::Client::ListResourcesResult] Result with `resources` (Array<Hash>)
113+
# and `next_cursor` (String or nil).
114+
def list_resources(cursor: nil)
115+
params = cursor ? { cursor: cursor } : nil
116+
response = request(method: "resources/list", params: params)
117+
result = response["result"] || {}
118+
119+
ListResourcesResult.new(
120+
resources: result["resources"] || [],
121+
next_cursor: result["nextCursor"],
122+
meta: result["_meta"],
123+
)
66124
end
67125

68-
# Returns the list of resources available from the server.
69-
# Each call will make a new request – the result is not cached.
126+
# Returns every resource available on the server. Iterates through all pages automatically
127+
# when the server paginates, so the full collection is returned regardless of the server's `page_size` setting.
128+
# Use {#list_resources} when you need fine-grained cursor control.
129+
#
130+
# Each call will make a new request - the result is not cached.
70131
#
71132
# @return [Array<Hash>] An array of available resources.
72133
def resources
73-
response = request(method: "resources/list")
134+
# TODO: consider renaming to `list_all_resources`.
135+
all_resources = []
136+
seen = Set.new
137+
cursor = nil
138+
139+
loop do
140+
page = list_resources(cursor: cursor)
141+
all_resources.concat(page.resources)
142+
next_cursor = page.next_cursor
143+
break if next_cursor.nil? || seen.include?(next_cursor)
144+
145+
seen << next_cursor
146+
cursor = next_cursor
147+
end
148+
149+
all_resources
150+
end
74151

75-
response.dig("result", "resources") || []
152+
# Returns a single page of resource templates from the server.
153+
#
154+
# @param cursor [String, nil] Cursor from a previous page response.
155+
# @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates`
156+
# (Array<Hash>) and `next_cursor` (String or nil).
157+
def list_resource_templates(cursor: nil)
158+
params = cursor ? { cursor: cursor } : nil
159+
response = request(method: "resources/templates/list", params: params)
160+
result = response["result"] || {}
161+
162+
ListResourceTemplatesResult.new(
163+
resource_templates: result["resourceTemplates"] || [],
164+
next_cursor: result["nextCursor"],
165+
meta: result["_meta"],
166+
)
76167
end
77168

78-
# Returns the list of resource templates available from the server.
79-
# Each call will make a new request – the result is not cached.
169+
# Returns every resource template available on the server. Iterates through all pages automatically
170+
# when the server paginates, so the full collection is returned regardless of the server's `page_size` setting.
171+
# Use {#list_resource_templates} when you need fine-grained cursor control.
172+
#
173+
# Each call will make a new request - the result is not cached.
80174
#
81175
# @return [Array<Hash>] An array of available resource templates.
82176
def resource_templates
83-
response = request(method: "resources/templates/list")
177+
# TODO: consider renaming to `list_all_resource_templates`.
178+
all_templates = []
179+
seen = Set.new
180+
cursor = nil
84181

85-
response.dig("result", "resourceTemplates") || []
182+
loop do
183+
page = list_resource_templates(cursor: cursor)
184+
all_templates.concat(page.resource_templates)
185+
next_cursor = page.next_cursor
186+
break if next_cursor.nil? || seen.include?(next_cursor)
187+
188+
seen << next_cursor
189+
cursor = next_cursor
190+
end
191+
192+
all_templates
86193
end
87194

88-
# Returns the list of prompts available from the server.
89-
# Each call will make a new request – the result is not cached.
195+
# Returns a single page of prompts from the server.
196+
#
197+
# @param cursor [String, nil] Cursor from a previous page response.
198+
# @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array<Hash>)
199+
# and `next_cursor` (String or nil).
200+
def list_prompts(cursor: nil)
201+
params = cursor ? { cursor: cursor } : nil
202+
response = request(method: "prompts/list", params: params)
203+
result = response["result"] || {}
204+
205+
ListPromptsResult.new(
206+
prompts: result["prompts"] || [],
207+
next_cursor: result["nextCursor"],
208+
meta: result["_meta"],
209+
)
210+
end
211+
212+
# Returns every prompt available on the server. Iterates through all pages automatically
213+
# when the server paginates, so the full collection is returned regardless of the server's `page_size` setting.
214+
# Use {#list_prompts} when you need fine-grained cursor control.
215+
#
216+
# Each call will make a new request - the result is not cached.
90217
#
91218
# @return [Array<Hash>] An array of available prompts.
92219
def prompts
93-
response = request(method: "prompts/list")
220+
# TODO: consider renaming to `list_all_prompts`.
221+
all_prompts = []
222+
seen = Set.new
223+
cursor = nil
224+
225+
loop do
226+
page = list_prompts(cursor: cursor)
227+
all_prompts.concat(page.prompts)
228+
next_cursor = page.next_cursor
229+
break if next_cursor.nil? || seen.include?(next_cursor)
230+
231+
seen << next_cursor
232+
cursor = next_cursor
233+
end
94234

95-
response.dig("result", "prompts") || []
235+
all_prompts
96236
end
97237

98238
# Calls a tool via the transport layer and returns the full response from the server.

lib/mcp/client/paginated_result.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# frozen_string_literal: true
2+
3+
module MCP
4+
class Client
5+
# Result objects returned by `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`.
6+
# Each carries the page items, an optional opaque `next_cursor` string for continuing pagination,
7+
# and an optional `meta` hash mirroring the MCP `_meta` response field.
8+
ListToolsResult = Struct.new(:tools, :next_cursor, :meta, keyword_init: true)
9+
ListPromptsResult = Struct.new(:prompts, :next_cursor, :meta, keyword_init: true)
10+
ListResourcesResult = Struct.new(:resources, :next_cursor, :meta, keyword_init: true)
11+
ListResourceTemplatesResult = Struct.new(:resource_templates, :next_cursor, :meta, keyword_init: true)
12+
end
13+
end

0 commit comments

Comments
 (0)