Merge pull request #505 from koic/bound_client_automatic_pagination
Bound automatic pagination in the MCP client
This commit is contained in:
commit
67779a2e88
3 changed files with 92 additions and 4 deletions
|
|
@ -2386,13 +2386,18 @@ If your application needs the complete collection regardless of how the server i
|
|||
`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.
|
||||
trips per call. Two guards keep that loop finite: it stops when the server returns a `nextCursor`
|
||||
it has already sent, and it stops after `max_pages` pages.
|
||||
|
||||
```ruby
|
||||
tools = client.tools # => Array<MCP::Client::Tool> of every tool on the server.
|
||||
```
|
||||
|
||||
`MCP::Client.new` accepts an optional `max_pages:` keyword that caps how many pages these methods
|
||||
will walk. It defaults to `1_000`; a server that keeps offering a fresh `nextCursor` past that
|
||||
point raises `MCP::Client::PaginationLimitError` rather than being followed indefinitely. Raise it
|
||||
if you legitimately expect more pages than that.
|
||||
|
||||
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).
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@ require_relative "result_type"
|
|||
|
||||
module MCP
|
||||
class Client
|
||||
# Upper bound on the number of pages the all-pages methods (`tools`, `resources`,
|
||||
# `resource_templates`, `prompts`) will walk. The cursor guard in `fetch_all_pages` only
|
||||
# stops a server that repeats or cycles cursors; one that returns a fresh `nextCursor` on
|
||||
# every response would otherwise be followed indefinitely, growing the retained pages with it.
|
||||
MAX_PAGES = 1_000
|
||||
|
||||
class ServerError < StandardError
|
||||
attr_reader :code, :data
|
||||
|
||||
|
|
@ -52,6 +58,11 @@ module MCP
|
|||
# server-returned JSON-RPC error, which is raised as `ServerError`.
|
||||
class ValidationError < StandardError; end
|
||||
|
||||
# Raised when an all-pages method reaches `max_pages` while the server is still offering
|
||||
# another cursor. Use the single-page `list_*` methods to walk such a collection with
|
||||
# a policy of your own.
|
||||
class PaginationLimitError < StandardError; end
|
||||
|
||||
# Raised when a server answers with a SEP-2322 Multi Round-Trip `input_required` result instead of
|
||||
# a final result. The result is not an error on the wire: it asks the client to fulfill the server's
|
||||
# `inputRequests` (a map of id => `{ "method" => ..., "params" => ... }` request objects with
|
||||
|
|
@ -108,6 +119,8 @@ module MCP
|
|||
# @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.
|
||||
# @param input_required_max_rounds [Integer] Cap on SEP-2322 driver rounds.
|
||||
# @param max_pages [Integer] Maximum number of pages the all-pages methods ({#tools}, {#resources},
|
||||
# {#resource_templates}, {#prompts}) will walk before raising {MCP::Client::PaginationLimitError}.
|
||||
#
|
||||
# Once a handler is registered through `on_elicitation`, `on_sampling`, or `on_roots`, `call_tool`,
|
||||
# `get_prompt`, and `read_resource` resume `input_required` results automatically; without handlers
|
||||
|
|
@ -117,8 +130,15 @@ module MCP
|
|||
# @example
|
||||
# transport = MCP::Client::HTTP.new(url: "http://localhost:3000")
|
||||
# client = MCP::Client.new(transport: transport)
|
||||
def initialize(transport:, input_required_max_rounds: DEFAULT_INPUT_REQUIRED_MAX_ROUNDS)
|
||||
def initialize(transport:, input_required_max_rounds: DEFAULT_INPUT_REQUIRED_MAX_ROUNDS, max_pages: MAX_PAGES)
|
||||
# `nil` or a non-positive value would make the pagination unbounded and silently
|
||||
# disable the protection, so reject it up front.
|
||||
unless max_pages.is_a?(Integer) && max_pages > 0
|
||||
raise ArgumentError, "max_pages must be a positive Integer"
|
||||
end
|
||||
|
||||
@transport = transport
|
||||
@max_pages = max_pages
|
||||
# Populated by `on_elicitation`, `on_sampling`, and `on_roots`. The same handler answers both ways
|
||||
# the server can ask for input: a real server-to-client request, and an embedded request inside
|
||||
# a SEP-2322 `input_required` result.
|
||||
|
|
@ -301,6 +321,7 @@ module MCP
|
|||
# Cancelling it aborts whichever page is currently in flight; pages already returned are kept,
|
||||
# but the call raises `MCP::CancelledError` instead of returning the partial set.
|
||||
# @return [Array<MCP::Client::Tool>] An array of available tools.
|
||||
# @raise [MCP::Client::PaginationLimitError] If the server offers more than `max_pages` pages.
|
||||
#
|
||||
# @example
|
||||
# tools = client.tools
|
||||
|
|
@ -342,6 +363,7 @@ module MCP
|
|||
#
|
||||
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
|
||||
# @return [Array<Hash>] An array of available resources.
|
||||
# @raise [MCP::Client::PaginationLimitError] See {#tools}.
|
||||
def resources(cancellation: nil)
|
||||
# TODO: consider renaming to `list_all_resources`.
|
||||
fetch_all_pages { |cursor| list_resources(cursor: cursor, cancellation: cancellation) }.flat_map(&:resources)
|
||||
|
|
@ -377,6 +399,7 @@ module MCP
|
|||
#
|
||||
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
|
||||
# @return [Array<Hash>] An array of available resource templates.
|
||||
# @raise [MCP::Client::PaginationLimitError] See {#tools}.
|
||||
def resource_templates(cancellation: nil)
|
||||
# TODO: consider renaming to `list_all_resource_templates`.
|
||||
fetch_all_pages { |cursor| list_resource_templates(cursor: cursor, cancellation: cancellation) }.flat_map(&:resource_templates)
|
||||
|
|
@ -412,6 +435,7 @@ module MCP
|
|||
#
|
||||
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
|
||||
# @return [Array<Hash>] An array of available prompts.
|
||||
# @raise [MCP::Client::PaginationLimitError] See {#tools}.
|
||||
def prompts(cancellation: nil)
|
||||
# TODO: consider renaming to `list_all_prompts`.
|
||||
fetch_all_pages { |cursor| list_prompts(cursor: cursor, cancellation: cancellation) }.flat_map(&:prompts)
|
||||
|
|
@ -695,7 +719,8 @@ module MCP
|
|||
|
||||
# Walks every page of a list endpoint, following `next_cursor`, and returns
|
||||
# the page results. The `seen` set guards against a server that repeats or
|
||||
# cycles cursors, so the loop always terminates.
|
||||
# cycles cursors, and `@max_pages` bounds one that returns a fresh cursor every
|
||||
# time, so the loop always terminates.
|
||||
def fetch_all_pages
|
||||
pages = []
|
||||
seen = Set.new
|
||||
|
|
@ -707,6 +732,11 @@ module MCP
|
|||
next_cursor = page.next_cursor
|
||||
break if next_cursor.nil? || seen.include?(next_cursor)
|
||||
|
||||
if pages.size >= @max_pages
|
||||
raise PaginationLimitError, "Server returned more than #{@max_pages} pages; pass a larger `max_pages:` to " \
|
||||
"`MCP::Client.new` if this is expected."
|
||||
end
|
||||
|
||||
seen << next_cursor
|
||||
cursor = next_cursor
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1645,6 +1645,59 @@ module MCP
|
|||
assert_equal(2, tools.size)
|
||||
end
|
||||
|
||||
def test_tools_raises_when_the_server_offers_more_pages_than_max_pages
|
||||
transport = mock
|
||||
|
||||
pages = (1..3).map do |n|
|
||||
{
|
||||
"result" => {
|
||||
"tools" => [{ "name" => "tool#{n}", "description" => "tool#{n}", "inputSchema" => {} }],
|
||||
"nextCursor" => "cursor#{n}",
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
# A fresh cursor on every response defeats the repeat guard, so only `max_pages` stops the walk.
|
||||
transport.expects(:send_request).times(3).returns(*pages)
|
||||
|
||||
client = Client.new(transport: transport, max_pages: 3)
|
||||
|
||||
error = assert_raises(Client::PaginationLimitError) { client.tools }
|
||||
assert_equal(
|
||||
"Server returned more than 3 pages; pass a larger `max_pages:` to `MCP::Client.new` if this is expected.",
|
||||
error.message,
|
||||
)
|
||||
end
|
||||
|
||||
def test_tools_returns_every_page_when_the_server_stops_exactly_at_max_pages
|
||||
transport = mock
|
||||
|
||||
page1 = {
|
||||
"result" => {
|
||||
"tools" => [{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} }],
|
||||
"nextCursor" => "cursor1",
|
||||
},
|
||||
}
|
||||
page2 = {
|
||||
"result" => {
|
||||
"tools" => [{ "name" => "tool2", "description" => "tool2", "inputSchema" => {} }],
|
||||
},
|
||||
}
|
||||
|
||||
transport.expects(:send_request).twice.returns(page1, page2)
|
||||
|
||||
client = Client.new(transport: transport, max_pages: 2)
|
||||
tools = client.tools
|
||||
|
||||
assert_equal(["tool1", "tool2"], tools.map(&:name))
|
||||
end
|
||||
|
||||
def test_raises_argument_error_when_max_pages_is_not_positive
|
||||
error = assert_raises(ArgumentError) { Client.new(transport: mock, max_pages: 0) }
|
||||
|
||||
assert_equal("max_pages must be a positive Integer", error.message)
|
||||
end
|
||||
|
||||
def test_tools_breaks_when_server_cycles_between_cursors
|
||||
transport = mock
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue