ruby-sdk/lib/mcp/client.rb
Koichi ITO b97b922a36 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.
2026-04-18 18:23:30 +09:00

331 lines
11 KiB
Ruby

# frozen_string_literal: true
require_relative "client/stdio"
require_relative "client/http"
require_relative "client/paginated_result"
require_relative "client/tool"
module MCP
class Client
class ServerError < StandardError
attr_reader :code, :data
def initialize(message, code:, data: nil)
super(message)
@code = code
@data = data
end
end
class RequestHandlerError < StandardError
attr_reader :error_type, :original_error, :request
def initialize(message, request, error_type: :internal_error, original_error: nil)
super(message)
@request = request
@error_type = error_type
@original_error = original_error
end
end
# Initializes a new MCP::Client instance.
#
# @param transport [Object] The transport object to use for communication with the server.
# The transport should be a duck type that responds to `send_request`. See the README for more details.
#
# @example
# transport = MCP::Client::HTTP.new(url: "http://localhost:3000")
# client = MCP::Client.new(transport: transport)
def initialize(transport:)
@transport = transport
end
# The user may want to access additional transport-specific methods/attributes
# So keeping it public
attr_reader :transport
# 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.
#
# @example
# tools = client.tools
# tools.each do |tool|
# puts tool.name
# end
def tools
# TODO: consider renaming to `list_all_tools`.
all_tools = []
seen = Set.new
cursor = nil
loop do
page = list_tools(cursor: cursor)
all_tools.concat(page.tools)
next_cursor = page.next_cursor
break if next_cursor.nil? || seen.include?(next_cursor)
seen << next_cursor
cursor = next_cursor
end
all_tools
end
# Returns a single page of resources from the server.
#
# @param cursor [String, nil] Cursor from a previous page response.
# @return [MCP::Client::ListResourcesResult] Result with `resources` (Array<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
# TODO: consider renaming to `list_all_resources`.
all_resources = []
seen = Set.new
cursor = nil
loop do
page = list_resources(cursor: cursor)
all_resources.concat(page.resources)
next_cursor = page.next_cursor
break if next_cursor.nil? || seen.include?(next_cursor)
seen << next_cursor
cursor = next_cursor
end
all_resources
end
# 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
# TODO: consider renaming to `list_all_resource_templates`.
all_templates = []
seen = Set.new
cursor = nil
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 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
# TODO: consider renaming to `list_all_prompts`.
all_prompts = []
seen = Set.new
cursor = nil
loop do
page = list_prompts(cursor: cursor)
all_prompts.concat(page.prompts)
next_cursor = page.next_cursor
break if next_cursor.nil? || seen.include?(next_cursor)
seen << next_cursor
cursor = next_cursor
end
all_prompts
end
# Calls a tool via the transport layer and returns the full response from the server.
#
# @param name [String] The name of the tool to call.
# @param tool [MCP::Client::Tool] The tool to be called.
# @param arguments [Object, nil] The arguments to pass to the tool.
# @param progress_token [String, Integer, nil] A token to request progress notifications from the server during tool execution.
# @return [Hash] The full JSON-RPC response from the transport.
#
# @example Call by name
# response = client.call_tool(name: "my_tool", arguments: { foo: "bar" })
# content = response.dig("result", "content")
#
# @example Call with a tool object
# tool = client.tools.first
# response = client.call_tool(tool: tool, arguments: { foo: "bar" })
# structured_content = response.dig("result", "structuredContent")
#
# @note
# The exact requirements for `arguments` are determined by the transport layer in use.
# Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details.
def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil)
tool_name = name || tool&.name
raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name
params = { name: tool_name, arguments: arguments }
if progress_token
params[:_meta] = { progressToken: progress_token }
end
request(method: "tools/call", params: params)
end
# Reads a resource from the server by URI and returns the contents.
#
# @param uri [String] The URI of the resource to read.
# @return [Array<Hash>] An array of resource contents (text or blob).
def read_resource(uri:)
response = request(method: "resources/read", params: { uri: uri })
response.dig("result", "contents") || []
end
# Gets a prompt from the server by name and returns its details.
#
# @param name [String] The name of the prompt to get.
# @return [Hash] A hash containing the prompt details.
def get_prompt(name:)
response = request(method: "prompts/get", params: { name: name })
response.fetch("result", {})
end
# Requests completion suggestions from the server for a prompt argument or resource template URI.
#
# @param ref [Hash] The reference, e.g. `{ type: "ref/prompt", name: "my_prompt" }`
# or `{ type: "ref/resource", uri: "file:///{path}" }`.
# @param argument [Hash] The argument being completed, e.g. `{ name: "language", value: "py" }`.
# @param context [Hash, nil] Optional context with previously resolved arguments.
# @return [Hash] The completion result with `"values"`, `"hasMore"`, and optionally `"total"`.
def complete(ref:, argument:, context: nil)
params = { ref: ref, argument: argument }
params[:context] = context if context
response = request(method: "completion/complete", params: params)
response.dig("result", "completion") || { "values" => [], "hasMore" => false }
end
private
def request(method:, params: nil)
request_body = {
jsonrpc: JsonRpcHandler::Version::V2_0,
id: request_id,
method: method,
}
request_body[:params] = params if params
response = transport.send_request(request: request_body)
# Guard with `is_a?(Hash)` because custom transports may return non-Hash values.
if response.is_a?(Hash) && response.key?("error")
error = response["error"]
raise ServerError.new(error["message"], code: error["code"], data: error["data"])
end
response
end
def request_id
SecureRandom.uuid
end
end
end