Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
184 changes: 162 additions & 22 deletions lib/mcp/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require_relative "client/stdio"
require_relative "client/http"
require_relative "client/paginated_result"
require_relative "client/tool"

module MCP
Expand Down Expand Up @@ -43,8 +44,41 @@ def initialize(transport:)
# 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.
#
Expand All @@ -54,45 +88,151 @@ def initialize(transport:)
# 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 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 the list of resources available from the server.
# Each call will make a new request – the result is not cached.
# 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

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

response.dig("result", "resources") || []
# 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 the list of resource templates available from the server.
# Each call will make a new request – the result is not cached.
# 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

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

response.dig("result", "prompts") || []
all_prompts
end

# Calls a tool via the transport layer and returns the full response from the server.
Expand Down
13 changes: 13 additions & 0 deletions lib/mcp/client/paginated_result.rb
Original file line number Diff line number Diff line change
@@ -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
Loading