Merge pull request #320 from koic/feature_pagination
Support pagination per MCP specification
This commit is contained in:
commit
e73c444aad
8 changed files with 970 additions and 35 deletions
85
README.md
85
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<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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
13
lib/mcp/client/paginated_result.rb
Normal file
13
lib/mcp/client/paginated_result.rb
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
42
lib/mcp/server/pagination.rb
Normal file
42
lib/mcp/server/pagination.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
166
test/mcp/server/pagination_test.rb
Normal file
166
test/mcp/server/pagination_test.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue