ruby-sdk/test/mcp/client_test.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

857 lines
28 KiB
Ruby

# frozen_string_literal: true
require "test_helper"
require "securerandom"
module MCP
class ClientTest < Minitest::Test
def test_tools_sends_request_to_transport_and_returns_tools_array
transport = mock
mock_response = {
"result" => {
"tools" => [
{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} },
{ "name" => "tool2", "description" => "tool2", "inputSchema" => {} },
],
},
}
# Only checking for the essential parts of the request
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "tools/list" && args.dig(:request, :jsonrpc) == "2.0"
end.returns(mock_response).once
client = Client.new(transport: transport)
tools = client.tools
assert_equal(2, tools.size)
assert_equal("tool1", tools.first.name)
assert_equal("tool2", tools.last.name)
end
def test_tools_returns_empty_array_when_no_tools
transport = mock
mock_response = { "result" => { "tools" => [] } }
# Only checking for the essential parts of the request
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "tools/list" && args.dig(:request, :jsonrpc) == "2.0"
end.returns(mock_response).once
client = Client.new(transport: transport)
tools = client.tools
assert_equal([], tools)
end
def test_call_tool_sends_request_to_transport_and_returns_content
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
arguments = { foo: "bar" }
mock_response = {
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
}
# Only checking for the essential parts of the request
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "tools/call" &&
args.dig(:request, :jsonrpc) == "2.0" &&
args.dig(:request, :params, :name) == "tool1" &&
args.dig(:request, :params, :arguments) == arguments
end.returns(mock_response).once
client = Client.new(transport: transport)
result = client.call_tool(tool: tool, arguments: arguments)
content = result.dig("result", "content")
assert_equal([{ type: "text", text: "Hello, world!" }], content)
end
def test_call_tool_by_name
transport = mock
arguments = { foo: "bar" }
mock_response = {
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
}
transport.expects(:send_request).with do |args|
args.dig(:request, :params, :name) == "tool1" &&
args.dig(:request, :params, :arguments) == arguments
end.returns(mock_response).once
client = Client.new(transport: transport)
result = client.call_tool(name: "tool1", arguments: arguments)
content = result.dig("result", "content")
assert_equal([{ type: "text", text: "Hello, world!" }], content)
end
def test_call_tool_raises_when_no_name_or_tool
client = Client.new(transport: mock)
error = assert_raises(ArgumentError) { client.call_tool(arguments: { foo: "bar" }) }
assert_equal("Either `name:` or `tool:` must be provided.", error.message)
end
def test_resources_sends_request_to_transport_and_returns_resources_array
transport = mock
mock_response = {
"result" => {
"resources" => [
{ "name" => "resource1", "uri" => "file:///path/to/resource1", "description" => "First resource" },
{ "name" => "resource2", "uri" => "file:///path/to/resource2", "description" => "Second resource" },
],
},
}
# Only checking for the essential parts of the request
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "resources/list" && args.dig(:request, :jsonrpc) == "2.0"
end.returns(mock_response).once
client = Client.new(transport: transport)
resources = client.resources
assert_equal(2, resources.size)
assert_equal("resource1", resources.first["name"])
assert_equal("file:///path/to/resource1", resources.first["uri"])
assert_equal("resource2", resources.last["name"])
assert_equal("file:///path/to/resource2", resources.last["uri"])
end
def test_resources_returns_empty_array_when_no_resources
transport = mock
mock_response = { "result" => { "resources" => [] } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
resources = client.resources
assert_equal([], resources)
end
def test_read_resource_sends_request_to_transport_and_returns_contents
transport = mock
uri = "file:///path/to/resource.txt"
mock_response = {
"result" => {
"contents" => [
{ "uri" => uri, "mimeType" => "text/plain", "text" => "Hello, world!" },
],
},
}
# Only checking for the essential parts of the request
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "resources/read" &&
args.dig(:request, :jsonrpc) == "2.0" &&
args.dig(:request, :params, :uri) == uri
end.returns(mock_response).once
client = Client.new(transport: transport)
contents = client.read_resource(uri: uri)
assert_equal(1, contents.size)
assert_equal(uri, contents.first["uri"])
assert_equal("text/plain", contents.first["mimeType"])
assert_equal("Hello, world!", contents.first["text"])
end
def test_read_resource_returns_empty_array_when_no_contents
transport = mock
uri = "file:///path/to/nonexistent.txt"
mock_response = { "result" => {} }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
contents = client.read_resource(uri: uri)
assert_equal([], contents)
end
def test_resource_templates_sends_request_to_transport_and_returns_resource_templates_array
transport = mock
mock_response = {
"result" => {
"resourceTemplates" => [
{ "name" => "template1", "uriTemplate" => "file:///path/{filename}", "description" => "First template" },
{ "name" => "template2", "uriTemplate" => "http://example.com/{id}", "description" => "Second template" },
],
},
}
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "resources/templates/list" && args.dig(:request, :jsonrpc) == "2.0"
end.returns(mock_response).once
client = Client.new(transport: transport)
resource_templates = client.resource_templates
assert_equal(2, resource_templates.size)
assert_equal("template1", resource_templates.first["name"])
assert_equal("file:///path/{filename}", resource_templates.first["uriTemplate"])
assert_equal("template2", resource_templates.last["name"])
assert_equal("http://example.com/{id}", resource_templates.last["uriTemplate"])
end
def test_resource_templates_returns_empty_array_when_no_resource_templates
transport = mock
mock_response = { "result" => { "resourceTemplates" => [] } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
resource_templates = client.resource_templates
assert_equal([], resource_templates)
end
def test_prompts_sends_request_to_transport_and_returns_prompts_array
transport = mock
mock_response = {
"result" => {
"prompts" => [
{
"name" => "prompt_1",
"description" => "First prompt",
"arguments" => [
{
"name" => "code_1",
"description" => "The code_1 to review",
"required" => true,
},
],
},
{
"name" => "prompt_2",
"description" => "Second prompt",
"arguments" => [
{
"name" => "code_2",
"description" => "The code_2 to review",
"required" => true,
},
],
},
],
},
}
# Only checking for the essential parts of the request
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "prompts/list" && args.dig(:request, :jsonrpc) == "2.0"
end.returns(mock_response).once
client = Client.new(transport: transport)
prompts = client.prompts
assert_equal(2, prompts.size)
assert_equal("prompt_1", prompts.first["name"])
assert_equal("First prompt", prompts.first["description"])
assert_equal("code_1", prompts.first["arguments"].first["name"])
assert_equal("The code_1 to review", prompts.first["arguments"].first["description"])
assert(prompts.first["arguments"].first["required"])
assert_equal("prompt_2", prompts.last["name"])
assert_equal("Second prompt", prompts.last["description"])
assert_equal("code_2", prompts.last["arguments"].first["name"])
assert_equal("The code_2 to review", prompts.last["arguments"].first["description"])
assert(prompts.last["arguments"].first["required"])
end
def test_prompts_returns_empty_array_when_no_prompts
transport = mock
mock_response = { "result" => { "prompts" => [] } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
prompts = client.prompts
assert_equal([], prompts)
end
def test_get_prompt_sends_request_to_transport_and_returns_contents
transport = mock
name = "first_prompt"
mock_response = {
"result" => {
"description" => "First prompt",
"messages" => [
{
"role" => "user",
"content" => {
"text" => "First prompt content",
"type" => "text",
},
},
],
},
}
# Only checking for the essential parts of the request
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "prompts/get" &&
args.dig(:request, :jsonrpc) == "2.0" &&
args.dig(:request, :params, :name) == name
end.returns(mock_response).once
client = Client.new(transport: transport)
contents = client.get_prompt(name: name)
assert_equal("First prompt", contents["description"])
assert_equal("user", contents["messages"].first["role"])
assert_equal("First prompt content", contents["messages"].first["content"]["text"])
end
def test_get_prompt_returns_empty_hash_when_no_contents
transport = mock
name = "nonexistent_prompt"
mock_response = { "result" => {} }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
contents = client.get_prompt(name: name)
assert_equal({}, contents)
end
def test_get_prompt_returns_empty_hash
transport = mock
name = "nonexistent_prompt"
mock_response = {}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
contents = client.get_prompt(name: name)
assert_equal({}, contents)
end
def test_call_tool_includes_meta_progress_token_when_provided
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
arguments = { foo: "bar" }
progress_token = "my-progress-token"
mock_response = {
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
}
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "tools/call" &&
args.dig(:request, :params, :_meta, :progressToken) == progress_token &&
args.dig(:request, :params, :name) == "tool1" &&
args.dig(:request, :params, :arguments) == arguments
end.returns(mock_response).once
client = Client.new(transport: transport)
client.call_tool(tool: tool, arguments: arguments, progress_token: progress_token)
end
def test_call_tool_omits_meta_when_no_progress_token
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
arguments = { foo: "bar" }
mock_response = {
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
}
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "tools/call" &&
args.dig(:request, :params, :name) == "tool1" &&
args.dig(:request, :params).key?(:_meta) == false
end.returns(mock_response).once
client = Client.new(transport: transport)
client.call_tool(tool: tool, arguments: arguments)
end
def test_tools_raises_server_error_on_error_response
transport = mock
mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
error = assert_raises(Client::ServerError) { client.tools }
assert_equal(-32_601, error.code)
assert_equal("Method not found", error.message)
end
def test_resources_raises_server_error_on_error_response
transport = mock
mock_response = { "error" => { "code" => -32_602, "message" => "Invalid params" } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
error = assert_raises(Client::ServerError) { client.resources }
assert_equal(-32_602, error.code)
end
def test_read_resource_raises_server_error_on_error_response
transport = mock
mock_response = { "error" => { "code" => -32_602, "message" => "Resource not found" } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
assert_raises(Client::ServerError) { client.read_resource(uri: "file:///missing") }
end
def test_get_prompt_raises_server_error_on_error_response
transport = mock
mock_response = { "error" => { "code" => -32_602, "message" => "Prompt not found" } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
assert_raises(Client::ServerError) { client.get_prompt(name: "missing") }
end
def test_prompts_raises_server_error_on_error_response
transport = mock
mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
assert_raises(Client::ServerError) { client.prompts }
end
def test_resource_templates_raises_server_error_on_error_response
transport = mock
mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
assert_raises(Client::ServerError) { client.resource_templates }
end
def test_call_tool_raises_server_error_on_error_response
transport = mock
mock_response = { "error" => { "code" => -32_602, "message" => "Tool not found" } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
error = assert_raises(Client::ServerError) { client.call_tool(name: "missing") }
assert_equal(-32_602, error.code)
end
def test_server_error_includes_data_field
transport = mock
mock_response = {
"error" => { "code" => -32_603, "message" => "Internal error", "data" => "extra details" },
}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
error = assert_raises(Client::ServerError) { client.tools }
assert_equal("extra details", error.data)
end
def test_complete_raises_server_error_on_error_response
transport = mock
mock_response = { "error" => { "code" => -32_602, "message" => "Invalid params" } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
error = assert_raises(Client::ServerError) { client.complete(ref: { type: "ref/prompt", name: "missing" }, argument: { name: "arg", value: "" }) }
assert_equal(-32_602, error.code)
end
def test_complete_sends_request_and_returns_completion_result
transport = mock
mock_response = {
"result" => {
"completion" => {
"values" => ["python", "pytorch"],
"hasMore" => false,
},
},
}
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "completion/complete" &&
args.dig(:request, :jsonrpc) == "2.0" &&
args.dig(:request, :params, :ref) == { type: "ref/prompt", name: "code_review" } &&
args.dig(:request, :params, :argument) == { name: "language", value: "py" } &&
!args.dig(:request, :params).key?(:context)
end.returns(mock_response).once
client = Client.new(transport: transport)
result = client.complete(
ref: { type: "ref/prompt", name: "code_review" },
argument: { name: "language", value: "py" },
)
assert_equal(["python", "pytorch"], result["values"])
refute(result["hasMore"])
end
def test_complete_includes_context_when_provided
transport = mock
mock_response = {
"result" => {
"completion" => {
"values" => ["flask"],
"hasMore" => false,
},
},
}
transport.expects(:send_request).with do |args|
args.dig(:request, :params, :context) == { arguments: { language: "python" } }
end.returns(mock_response).once
client = Client.new(transport: transport)
result = client.complete(
ref: { type: "ref/prompt", name: "code_review" },
argument: { name: "framework", value: "fla" },
context: { arguments: { language: "python" } },
)
assert_equal(["flask"], result["values"])
end
def test_complete_returns_default_when_result_is_missing
transport = mock
mock_response = { "result" => {} }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
result = client.complete(
ref: { type: "ref/prompt", name: "test" },
argument: { name: "arg", value: "" },
)
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