## 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.
857 lines
28 KiB
Ruby
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
|