"""Cursor pagination of the list operations against the low-level Server. The cursor is an opaque string chosen by the server: the suite only asserts that whatever the handler returns as next_cursor comes back verbatim on the client's next call, not any particular pagination scheme. """ import mcp_types as types import pytest from inline_snapshot import snapshot from mcp_types import ( INVALID_PARAMS, ListPromptsResult, ListResourcesResult, ListResourceTemplatesResult, ListToolsResult, Prompt, Resource, ResourceTemplate, Tool, ) from mcp import MCPError from mcp.server import Server, ServerRequestContext from tests.interaction._connect import Connect from tests.interaction._requirements import requirement pytestmark = pytest.mark.anyio @requirement("tools:list:pagination") async def test_next_cursor_round_trips_through_the_client(connect: Connect) -> None: """The next_cursor a list handler returns reaches the client, and the cursor the client sends back on the following call reaches the handler verbatim. """ cursor = "page-2" seen_cursors: list[str | None] = [] async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: assert params is not None # the client always sends params, even without a cursor seen_cursors.append(params.cursor) if params.cursor is None: return ListToolsResult( tools=[Tool(name="alpha", input_schema={"type": "object"})], next_cursor=cursor, ) return ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})]) server = Server("paginated", on_list_tools=list_tools) async with connect(server) as client: first_page = await client.list_tools() second_page = await client.list_tools(cursor=first_page.next_cursor) assert first_page.next_cursor == cursor assert seen_cursors == [None, cursor] assert [tool.name for tool in first_page.tools] == ["alpha"] assert second_page == snapshot(ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})])) @requirement("pagination:exhaustion") @requirement("tools:list:pagination") async def test_paginating_until_next_cursor_is_absent_yields_every_page(connect: Connect) -> None: """Following next_cursor until it is absent visits every page exactly once, in order.""" pages: dict[str | None, tuple[str, str | None]] = { None: ("alpha", "page-2"), "page-2": ("beta", "page-3"), "page-3": ("gamma", None), } async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: assert params is not None tool_name, next_cursor = pages[params.cursor] return ListToolsResult(tools=[Tool(name=tool_name, input_schema={"type": "object"})], next_cursor=next_cursor) server = Server("paginated", on_list_tools=list_tools) collected: list[str] = [] cursor: str | None = None requests_made = 0 async with connect(server) as client: while True: result = await client.list_tools(cursor=cursor) requests_made += 1 assert requests_made <= len(pages), "the server kept returning next_cursor past the last page" collected.extend(tool.name for tool in result.tools) if result.next_cursor is None: break cursor = result.next_cursor assert collected == snapshot(["alpha", "beta", "gamma"]) assert requests_made == len(pages) @requirement("pagination:client:cursor-handling") async def test_the_client_follows_opaque_cursors_through_pages_of_varying_sizes(connect: Connect) -> None: """The client passes a server-issued cursor back byte-for-byte and follows pages of varying sizes. The cursors are deliberately base64-looking strings (with padding and URL-unsafe characters) to show the client treats them as opaque tokens; the page sizes [3, 1, 2] show the loop relies only on next_cursor, not on a fixed page size. """ cursor_to_page_2 = "YWxwaGE+YnJhdm8/Y2hhcmxpZQ==" cursor_to_page_3 = "ZGVsdGE=" pages: dict[str | None, tuple[list[str], str | None]] = { None: (["alpha", "beta", "gamma"], cursor_to_page_2), cursor_to_page_2: (["delta"], cursor_to_page_3), cursor_to_page_3: (["epsilon", "zeta"], None), } received_cursors: list[str | None] = [] async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: assert params is not None received_cursors.append(params.cursor) names, next_cursor = pages[params.cursor] return ListToolsResult( tools=[Tool(name=name, input_schema={"type": "object"}) for name in names], next_cursor=next_cursor ) server = Server("paginated", on_list_tools=list_tools) page_sizes: list[int] = [] cursor: str | None = None async with connect(server) as client: while True: result = await client.list_tools(cursor=cursor) page_sizes.append(len(result.tools)) if result.next_cursor is None: break cursor = result.next_cursor # Identity, not a snapshot: what arrived at the handler is exactly what the handler issued. assert received_cursors == [None, cursor_to_page_2, cursor_to_page_3] assert page_sizes == [3, 1, 2] @requirement("pagination:invalid-cursor") async def test_an_unrecognized_pagination_cursor_is_rejected_with_invalid_params(connect: Connect) -> None: """A list request with a cursor the server did not issue is answered with -32602 Invalid params. The lowlevel server does not validate cursors itself (they are opaque to it); rejecting an unrecognized cursor is the handler's job, and this test pins the spec-recommended way to do it. """ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: assert params is not None assert params.cursor == "never-issued" raise MCPError(code=INVALID_PARAMS, message=f"Unknown cursor: {params.cursor!r}") server = Server("paginated", on_list_tools=list_tools) async with connect(server) as client: with pytest.raises(MCPError) as exc_info: await client.list_tools(cursor="never-issued") assert exc_info.value.error.code == INVALID_PARAMS @requirement("resources:list:pagination") async def test_resources_list_supports_cursor_pagination(connect: Connect) -> None: """resources/list round-trips the cursor like every other list operation.""" cursor = "page-2" seen_cursors: list[str | None] = [] async def list_resources( ctx: ServerRequestContext, params: types.PaginatedRequestParams | None ) -> ListResourcesResult: assert params is not None seen_cursors.append(params.cursor) if params.cursor is None: return ListResourcesResult(resources=[Resource(uri="memo://1", name="first")], next_cursor=cursor) return ListResourcesResult(resources=[Resource(uri="memo://2", name="second")]) server = Server("paginated", on_list_resources=list_resources) async with connect(server) as client: first_page = await client.list_resources() second_page = await client.list_resources(cursor=first_page.next_cursor) assert first_page.next_cursor == cursor assert seen_cursors == [None, cursor] assert [resource.name for resource in first_page.resources] == ["first"] assert [resource.name for resource in second_page.resources] == ["second"] assert second_page.next_cursor is None @requirement("resources:templates:pagination") async def test_resource_templates_list_supports_cursor_pagination(connect: Connect) -> None: """resources/templates/list round-trips the cursor like every other list operation.""" cursor = "page-2" seen_cursors: list[str | None] = [] async def list_resource_templates( ctx: ServerRequestContext, params: types.PaginatedRequestParams | None ) -> ListResourceTemplatesResult: assert params is not None seen_cursors.append(params.cursor) if params.cursor is None: return ListResourceTemplatesResult( resource_templates=[ResourceTemplate(name="first", uri_template="users://{id}")], next_cursor=cursor, ) return ListResourceTemplatesResult( resource_templates=[ResourceTemplate(name="second", uri_template="teams://{id}")] ) server = Server("paginated", on_list_resource_templates=list_resource_templates) async with connect(server) as client: first_page = await client.list_resource_templates() second_page = await client.list_resource_templates(cursor=first_page.next_cursor) assert first_page.next_cursor == cursor assert seen_cursors == [None, cursor] assert [template.name for template in first_page.resource_templates] == ["first"] assert [template.name for template in second_page.resource_templates] == ["second"] assert second_page.next_cursor is None @requirement("prompts:list:pagination") async def test_prompts_list_supports_cursor_pagination(connect: Connect) -> None: """prompts/list round-trips the cursor like every other list operation.""" cursor = "page-2" seen_cursors: list[str | None] = [] async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult: assert params is not None seen_cursors.append(params.cursor) if params.cursor is None: return ListPromptsResult(prompts=[Prompt(name="first")], next_cursor=cursor) return ListPromptsResult(prompts=[Prompt(name="second")]) server = Server("paginated", on_list_prompts=list_prompts) async with connect(server) as client: first_page = await client.list_prompts() second_page = await client.list_prompts(cursor=first_page.next_cursor) assert first_page.next_cursor == cursor assert seen_cursors == [None, cursor] assert [prompt.name for prompt in first_page.prompts] == ["first"] assert [prompt.name for prompt in second_page.prompts] == ["second"] assert second_page.next_cursor is None