Skip to content

Commit e73c444

Browse files
authored
Merge pull request #320 from koic/feature_pagination
Support pagination per MCP specification
2 parents 7b5533c + b97b922 commit e73c444

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)