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.
This commit is contained in:
parent
7b5533c809
commit
b97b922a36
3 changed files with 970 additions and 35 deletions
|
|
@ -537,5 +537,321 @@ module MCP
|
|||
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
|
||||
|
|
|
|||
166
test/mcp/server/pagination_test.rb
Normal file
166
test/mcp/server/pagination_test.rb
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
module MCP
|
||||
class Server
|
||||
class PaginationTest < ActiveSupport::TestCase
|
||||
class PaginationHost
|
||||
include Pagination
|
||||
|
||||
# Expose private methods for testing.
|
||||
public :paginate, :cursor_from
|
||||
|
||||
# RequestHandlerError must be accessible.
|
||||
RequestHandlerError = Server::RequestHandlerError
|
||||
end
|
||||
|
||||
setup do
|
||||
@host = PaginationHost.new
|
||||
@items = [{ name: "a" }, { name: "b" }, { name: "c" }, { name: "d" }, { name: "e" }]
|
||||
@request = { method: "tools/list" }
|
||||
end
|
||||
|
||||
test "paginate returns all items when page_size is nil and no cursor" do
|
||||
result = @host.paginate(@items, cursor: nil, page_size: nil, request: @request)
|
||||
|
||||
assert_equal @items, result[:items]
|
||||
assert_nil result[:next_cursor]
|
||||
end
|
||||
|
||||
test "paginate returns first page with nextCursor when page_size is set" do
|
||||
result = @host.paginate(@items, cursor: nil, page_size: 2, request: @request)
|
||||
|
||||
assert_equal [{ name: "a" }, { name: "b" }], result[:items]
|
||||
assert_not_nil result[:next_cursor]
|
||||
end
|
||||
|
||||
test "paginate returns correct second page when cursor is provided" do
|
||||
first = @host.paginate(@items, cursor: nil, page_size: 2, request: @request)
|
||||
second = @host.paginate(@items, cursor: first[:next_cursor], page_size: 2, request: @request)
|
||||
|
||||
assert_equal [{ name: "c" }, { name: "d" }], second[:items]
|
||||
assert_not_nil second[:next_cursor]
|
||||
end
|
||||
|
||||
test "paginate returns last page without nextCursor" do
|
||||
first = @host.paginate(@items, cursor: nil, page_size: 2, request: @request)
|
||||
second = @host.paginate(@items, cursor: first[:next_cursor], page_size: 2, request: @request)
|
||||
third = @host.paginate(@items, cursor: second[:next_cursor], page_size: 2, request: @request)
|
||||
|
||||
assert_equal [{ name: "e" }], third[:items]
|
||||
assert_nil third[:next_cursor]
|
||||
end
|
||||
|
||||
test "paginate with page_size equal to items count returns all without nextCursor" do
|
||||
result = @host.paginate(@items, cursor: nil, page_size: 5, request: @request)
|
||||
|
||||
assert_equal @items, result[:items]
|
||||
assert_nil result[:next_cursor]
|
||||
end
|
||||
|
||||
test "paginate with page_size larger than items count returns all without nextCursor" do
|
||||
result = @host.paginate(@items, cursor: nil, page_size: 100, request: @request)
|
||||
|
||||
assert_equal @items, result[:items]
|
||||
assert_nil result[:next_cursor]
|
||||
end
|
||||
|
||||
test "paginate with empty items returns empty array" do
|
||||
result = @host.paginate([], cursor: nil, page_size: 2, request: @request)
|
||||
|
||||
assert_equal [], result[:items]
|
||||
assert_nil result[:next_cursor]
|
||||
end
|
||||
|
||||
test "paginate raises RequestHandlerError for non-numeric cursor" do
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
@host.paginate(@items, cursor: "not_a_number", page_size: 2, request: @request)
|
||||
end
|
||||
assert_equal :invalid_params, error.error_type
|
||||
end
|
||||
|
||||
test "paginate raises RequestHandlerError for Integer cursor (spec requires string)" do
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
@host.paginate(@items, cursor: 2, page_size: 2, request: @request)
|
||||
end
|
||||
assert_equal :invalid_params, error.error_type
|
||||
end
|
||||
|
||||
test "paginate raises RequestHandlerError for Float cursor (spec requires string)" do
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
@host.paginate(@items, cursor: 1.5, page_size: 2, request: @request)
|
||||
end
|
||||
assert_equal :invalid_params, error.error_type
|
||||
end
|
||||
|
||||
test "paginate raises RequestHandlerError for negative offset cursor" do
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
@host.paginate(@items, cursor: "-1", page_size: 2, request: @request)
|
||||
end
|
||||
assert_equal :invalid_params, error.error_type
|
||||
end
|
||||
|
||||
test "paginate raises RequestHandlerError for out-of-range cursor" do
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
@host.paginate(@items, cursor: "100", page_size: 2, request: @request)
|
||||
end
|
||||
assert_equal :invalid_params, error.error_type
|
||||
end
|
||||
|
||||
test "paginate returns all items from cursor offset when page_size is nil" do
|
||||
result = @host.paginate(@items, cursor: "2", page_size: nil, request: @request)
|
||||
|
||||
assert_equal [{ name: "c" }, { name: "d" }, { name: "e" }], result[:items]
|
||||
assert_nil result[:next_cursor]
|
||||
end
|
||||
|
||||
test "paginate raises RequestHandlerError when cursor points exactly at items.size" do
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
@host.paginate(@items, cursor: @items.size.to_s, page_size: 2, request: @request)
|
||||
end
|
||||
assert_equal :invalid_params, error.error_type
|
||||
end
|
||||
|
||||
test "paginate on empty items with any cursor raises RequestHandlerError" do
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
@host.paginate([], cursor: "0", page_size: 2, request: @request)
|
||||
end
|
||||
assert_equal :invalid_params, error.error_type
|
||||
end
|
||||
|
||||
test "cursor_from returns nil for nil request" do
|
||||
assert_nil @host.cursor_from(nil)
|
||||
end
|
||||
|
||||
test "cursor_from returns cursor for Hash request" do
|
||||
assert_equal "abc", @host.cursor_from(cursor: "abc")
|
||||
end
|
||||
|
||||
test "cursor_from returns nil when Hash request has no cursor" do
|
||||
assert_nil @host.cursor_from({})
|
||||
end
|
||||
|
||||
test "cursor_from raises RequestHandlerError for non-Hash request" do
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
@host.cursor_from([1, 2, 3])
|
||||
end
|
||||
assert_equal :invalid_params, error.error_type
|
||||
end
|
||||
|
||||
test "paginate with page_size 1 iterates one item at a time" do
|
||||
results = []
|
||||
cursor = nil
|
||||
|
||||
loop do
|
||||
page = @host.paginate(@items, cursor: cursor, page_size: 1, request: @request)
|
||||
results.concat(page[:items])
|
||||
cursor = page[:next_cursor]
|
||||
break unless cursor
|
||||
end
|
||||
|
||||
assert_equal @items, results
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -2331,5 +2331,166 @@ module MCP
|
|||
server.handle(request)
|
||||
assert_equal "from_accessor", received_context[:custom]
|
||||
end
|
||||
|
||||
test "#handle tools/list returns paginated results when page_size is set" do
|
||||
tool_a = Tool.define(name: "tool_a", title: "Tool A", description: "Tool A")
|
||||
tool_b = Tool.define(name: "tool_b", title: "Tool B", description: "Tool B")
|
||||
tool_c = Tool.define(name: "tool_c", title: "Tool C", description: "Tool C")
|
||||
|
||||
server = Server.new(
|
||||
name: "pagination_test",
|
||||
tools: [tool_a, tool_b, tool_c],
|
||||
page_size: 2,
|
||||
)
|
||||
|
||||
first_request = { jsonrpc: "2.0", method: "tools/list", id: 1 }
|
||||
first_response = server.handle(first_request)
|
||||
first_result = first_response[:result]
|
||||
|
||||
assert_equal 2, first_result[:tools].size
|
||||
assert_equal "tool_a", first_result[:tools][0][:name]
|
||||
assert_equal "tool_b", first_result[:tools][1][:name]
|
||||
assert_not_nil first_result[:nextCursor]
|
||||
|
||||
second_request = { jsonrpc: "2.0", method: "tools/list", id: 2, params: { cursor: first_result[:nextCursor] } }
|
||||
second_response = server.handle(second_request)
|
||||
second_result = second_response[:result]
|
||||
|
||||
assert_equal 1, second_result[:tools].size
|
||||
assert_equal "tool_c", second_result[:tools][0][:name]
|
||||
# Final page omits the nextCursor key entirely (not just sets it to nil).
|
||||
refute second_result.key?(:nextCursor)
|
||||
end
|
||||
|
||||
test "#handle tools/list returns all tools when page_size is not set" do
|
||||
response = @server.handle({ jsonrpc: "2.0", method: "tools/list", id: 1 })
|
||||
result = response[:result]
|
||||
|
||||
assert_kind_of Array, result[:tools]
|
||||
assert_nil result[:nextCursor]
|
||||
end
|
||||
|
||||
test "#handle tools/list returns error for invalid cursor" do
|
||||
server = Server.new(name: "pagination_test", tools: [@tool], page_size: 1)
|
||||
|
||||
request = { jsonrpc: "2.0", method: "tools/list", id: 1, params: { cursor: "!!!invalid!!!" } }
|
||||
response = server.handle(request)
|
||||
|
||||
assert_not_nil response[:error]
|
||||
assert_equal(-32602, response[:error][:code])
|
||||
end
|
||||
|
||||
test "#handle prompts/list returns paginated results when page_size is set" do
|
||||
prompt_a = Prompt.define(name: "prompt_a", title: "Prompt A", description: "A") { Prompt::Result.new(description: "A", messages: []) }
|
||||
prompt_b = Prompt.define(name: "prompt_b", title: "Prompt B", description: "B") { Prompt::Result.new(description: "B", messages: []) }
|
||||
|
||||
server = Server.new(name: "pagination_test", prompts: [prompt_a, prompt_b], page_size: 1)
|
||||
|
||||
first_response = server.handle({ jsonrpc: "2.0", method: "prompts/list", id: 1 })
|
||||
first_result = first_response[:result]
|
||||
|
||||
assert_equal 1, first_result[:prompts].size
|
||||
assert_equal "prompt_a", first_result[:prompts][0][:name]
|
||||
assert_not_nil first_result[:nextCursor]
|
||||
|
||||
second_response = server.handle({ jsonrpc: "2.0", method: "prompts/list", id: 2, params: { cursor: first_result[:nextCursor] } })
|
||||
second_result = second_response[:result]
|
||||
|
||||
assert_equal 1, second_result[:prompts].size
|
||||
assert_equal "prompt_b", second_result[:prompts][0][:name]
|
||||
assert_nil second_result[:nextCursor]
|
||||
end
|
||||
|
||||
test "#handle resources/list returns paginated results when page_size is set" do
|
||||
resource_a = Resource.new(uri: "https://a.invalid", name: "a", description: "A", mime_type: "text/plain")
|
||||
resource_b = Resource.new(uri: "https://b.invalid", name: "b", description: "B", mime_type: "text/plain")
|
||||
|
||||
server = Server.new(name: "pagination_test", resources: [resource_a, resource_b], page_size: 1)
|
||||
|
||||
first_response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 })
|
||||
first_result = first_response[:result]
|
||||
|
||||
assert_equal 1, first_result[:resources].size
|
||||
assert_equal "a", first_result[:resources][0][:name]
|
||||
assert_not_nil first_result[:nextCursor]
|
||||
|
||||
second_response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 2, params: { cursor: first_result[:nextCursor] } })
|
||||
second_result = second_response[:result]
|
||||
|
||||
assert_equal 1, second_result[:resources].size
|
||||
assert_equal "b", second_result[:resources][0][:name]
|
||||
assert_nil second_result[:nextCursor]
|
||||
end
|
||||
|
||||
test "Server.new raises ArgumentError when page_size is zero" do
|
||||
assert_raises(ArgumentError) do
|
||||
Server.new(name: "test", page_size: 0)
|
||||
end
|
||||
end
|
||||
|
||||
test "Server.new raises ArgumentError when page_size is negative" do
|
||||
assert_raises(ArgumentError) do
|
||||
Server.new(name: "test", page_size: -1)
|
||||
end
|
||||
end
|
||||
|
||||
test "Server.new raises ArgumentError when page_size is non-Integer" do
|
||||
assert_raises(ArgumentError) do
|
||||
Server.new(name: "test", page_size: "10")
|
||||
end
|
||||
end
|
||||
|
||||
test "page_size= raises ArgumentError for invalid values" do
|
||||
server = Server.new(name: "test")
|
||||
|
||||
assert_raises(ArgumentError) { server.page_size = 0 }
|
||||
assert_raises(ArgumentError) { server.page_size = -1 }
|
||||
assert_raises(ArgumentError) { server.page_size = "5" }
|
||||
|
||||
server.page_size = nil
|
||||
server.page_size = 10
|
||||
assert_equal 10, server.page_size
|
||||
end
|
||||
|
||||
test "#handle tools/list returns -32602 for non-Hash params" do
|
||||
server = Server.new(name: "test", tools: [@tool], page_size: 1)
|
||||
|
||||
request = { jsonrpc: "2.0", method: "tools/list", id: 1, params: [1, 2, 3] }
|
||||
response = server.handle(request)
|
||||
|
||||
assert_not_nil response[:error]
|
||||
assert_equal(-32602, response[:error][:code])
|
||||
end
|
||||
|
||||
test "#handle_json tools/list returns -32602 for numeric cursor (spec requires string)" do
|
||||
server = Server.new(name: "test", tools: [@tool], page_size: 1)
|
||||
|
||||
request_json = '{"jsonrpc":"2.0","method":"tools/list","id":1,"params":{"cursor":0}}'
|
||||
response = JSON.parse(server.handle_json(request_json), symbolize_names: true)
|
||||
|
||||
assert_not_nil response[:error]
|
||||
assert_equal(-32602, response[:error][:code])
|
||||
end
|
||||
|
||||
test "#handle resources/templates/list returns paginated results when page_size is set" do
|
||||
template_a = ResourceTemplate.new(uri_template: "https://a.invalid/{id}", name: "a", description: "A", mime_type: "text/plain")
|
||||
template_b = ResourceTemplate.new(uri_template: "https://b.invalid/{id}", name: "b", description: "B", mime_type: "text/plain")
|
||||
|
||||
server = Server.new(name: "pagination_test", resource_templates: [template_a, template_b], page_size: 1)
|
||||
|
||||
first_response = server.handle({ jsonrpc: "2.0", method: "resources/templates/list", id: 1 })
|
||||
first_result = first_response[:result]
|
||||
|
||||
assert_equal 1, first_result[:resourceTemplates].size
|
||||
assert_equal "a", first_result[:resourceTemplates][0][:name]
|
||||
assert_not_nil first_result[:nextCursor]
|
||||
|
||||
second_response = server.handle({ jsonrpc: "2.0", method: "resources/templates/list", id: 2, params: { cursor: first_result[:nextCursor] } })
|
||||
second_result = second_response[:result]
|
||||
|
||||
assert_equal 1, second_result[:resourceTemplates].size
|
||||
assert_equal "b", second_result[:resourceTemplates][0][:name]
|
||||
assert_nil second_result[:nextCursor]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in a new issue