From 5fa6700de1e0cb8965db95433e7395febd623fd1 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Fri, 26 Jun 2026 16:35:06 -0400 Subject: [PATCH 1/3] Add list_all_* helpers that drain pagination on the client Currently Client.list_tools / list_prompts / list_resources / list_resource_templates return a single page and the caller has to loop on next_cursor manually. Add list_all_tools / list_all_prompts / list_all_resources / list_all_resource_templates that walk next_cursor until exhausted, plus iter_all_* async iterators for streaming consumers. The single-page methods get a docstring update pointing at the new drains. ClientSessionGroup switches its tool/prompt/resource aggregation to the drain helper so its consumers always see the full collection across multi-page servers. Implements the helper maxisbey endorsed in #2556. Rebased onto the v2 rework: types import from mcp_types, the stream-spy tests run in legacy mode, and the test Tool carries a valid input_schema. --- src/mcp/client/client.py | 102 ++++++++++- src/mcp/client/session_group.py | 34 +++- tests/client/test_list_all_pagination.py | 207 +++++++++++++++++++++++ tests/client/test_session_group.py | 60 ++++++- 4 files changed, 384 insertions(+), 19 deletions(-) create mode 100644 tests/client/test_list_all_pagination.py diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index d3290f308..1be67d96d 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Mapping +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping from contextlib import AsyncExitStack from dataclasses import KW_ONLY, dataclass, field from typing import Any, Literal, TypeVar @@ -26,11 +26,15 @@ ListToolsResult, LoggingLevel, PaginatedRequestParams, + Prompt, PromptReference, ReadResourceResult, RequestParamsMeta, + Resource, + ResourceTemplate, ResourceTemplateReference, ServerCapabilities, + Tool, ) from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS from typing_extensions import deprecated @@ -367,7 +371,11 @@ async def list_resources( cursor: str | None = None, meta: RequestParamsMeta | None = None, ) -> ListResourcesResult: - """List available resources from the server.""" + """List a single page of available resources from the server. + + Returns one page only. The result may include a `next_cursor` if more + pages are available. Use `list_all_resources` to drain every page. + """ return await self.session.list_resources(params=PaginatedRequestParams(cursor=cursor, _meta=meta)) async def list_resource_templates( @@ -376,7 +384,12 @@ async def list_resource_templates( cursor: str | None = None, meta: RequestParamsMeta | None = None, ) -> ListResourceTemplatesResult: - """List available resource templates from the server.""" + """List a single page of available resource templates from the server. + + Returns one page only. The result may include a `next_cursor` if more + pages are available. Use `list_all_resource_templates` to drain every + page. + """ return await self.session.list_resource_templates(params=PaginatedRequestParams(cursor=cursor, _meta=meta)) async def read_resource( @@ -482,7 +495,11 @@ async def list_prompts( cursor: str | None = None, meta: RequestParamsMeta | None = None, ) -> ListPromptsResult: - """List available prompts from the server.""" + """List a single page of available prompts from the server. + + Returns one page only. The result may include a `next_cursor` if more + pages are available. Use `list_all_prompts` to drain every page. + """ return await self.session.list_prompts(params=PaginatedRequestParams(cursor=cursor, _meta=meta)) async def get_prompt( @@ -566,9 +583,84 @@ async def complete( return await self.session.complete(ref=ref, argument=argument, context_arguments=context_arguments) async def list_tools(self, *, cursor: str | None = None, meta: RequestParamsMeta | None = None) -> ListToolsResult: - """List available tools from the server.""" + """List a single page of available tools from the server. + + Returns one page only. The result may include a `next_cursor` if more + pages are available. Use `list_all_tools` to drain every page. + """ return await self.session.list_tools(params=PaginatedRequestParams(cursor=cursor, _meta=meta)) + async def iter_all_tools(self, *, meta: RequestParamsMeta | None = None) -> AsyncIterator[Tool]: + """Yield every tool from the server, paging through `next_cursor`. + + Useful for streaming consumers that want to process tools without + materializing the full list in memory. + """ + cursor: str | None = None + while True: + result = await self.list_tools(cursor=cursor, meta=meta) + for tool in result.tools: + yield tool + if result.next_cursor is None: + return + cursor = result.next_cursor + + async def list_all_tools(self, *, meta: RequestParamsMeta | None = None) -> list[Tool]: + """List every tool from the server, draining `next_cursor` across pages. + + Unlike `list_tools`, which returns one page, this walks pagination + until the server reports no further pages and returns the combined + list. + """ + return [tool async for tool in self.iter_all_tools(meta=meta)] + + async def iter_all_prompts(self, *, meta: RequestParamsMeta | None = None) -> AsyncIterator[Prompt]: + """Yield every prompt from the server, paging through `next_cursor`.""" + cursor: str | None = None + while True: + result = await self.list_prompts(cursor=cursor, meta=meta) + for prompt in result.prompts: + yield prompt + if result.next_cursor is None: + return + cursor = result.next_cursor + + async def list_all_prompts(self, *, meta: RequestParamsMeta | None = None) -> list[Prompt]: + """List every prompt from the server, draining `next_cursor` across pages.""" + return [prompt async for prompt in self.iter_all_prompts(meta=meta)] + + async def iter_all_resources(self, *, meta: RequestParamsMeta | None = None) -> AsyncIterator[Resource]: + """Yield every resource from the server, paging through `next_cursor`.""" + cursor: str | None = None + while True: + result = await self.list_resources(cursor=cursor, meta=meta) + for resource in result.resources: + yield resource + if result.next_cursor is None: + return + cursor = result.next_cursor + + async def list_all_resources(self, *, meta: RequestParamsMeta | None = None) -> list[Resource]: + """List every resource from the server, draining `next_cursor` across pages.""" + return [resource async for resource in self.iter_all_resources(meta=meta)] + + async def iter_all_resource_templates( + self, *, meta: RequestParamsMeta | None = None + ) -> AsyncIterator[ResourceTemplate]: + """Yield every resource template from the server, paging through `next_cursor`.""" + cursor: str | None = None + while True: + result = await self.list_resource_templates(cursor=cursor, meta=meta) + for template in result.resource_templates: + yield template + if result.next_cursor is None: + return + cursor = result.next_cursor + + async def list_all_resource_templates(self, *, meta: RequestParamsMeta | None = None) -> list[ResourceTemplate]: + """List every resource template from the server, draining `next_cursor` across pages.""" + return [template async for template in self.iter_all_resource_templates(meta=meta)] + @deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) async def send_roots_list_changed(self) -> None: """Send a notification that the roots list has changed.""" diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index 40f023259..9387c1f64 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -8,7 +8,7 @@ import contextlib import logging -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass from types import TracebackType from typing import Any, Literal, TypeAlias, overload @@ -67,6 +67,28 @@ class StreamableHttpParameters(BaseModel): ServerParameters: TypeAlias = StdioServerParameters | SseServerParameters | StreamableHttpParameters +async def _drain_paginated( + fetch_page: Callable[..., Awaitable[Any]], + attribute: str, +) -> list[Any]: + """Drain a paginated `session.list_*` call across `next_cursor` pages. + + `fetch_page` is one of the ClientSession `list_*` methods that takes a + `params=PaginatedRequestParams(...)` keyword. `attribute` is the name of + the list attribute on the result (e.g. `"tools"`, `"prompts"`). + """ + items: list[Any] = [] + cursor: str | None = None + while True: + params = types.PaginatedRequestParams(cursor=cursor) if cursor is not None else None + result = await fetch_page(params=params) + items.extend(getattr(result, attribute)) + next_cursor = getattr(result, "next_cursor", None) + if next_cursor is None: + return items + cursor = next_cursor + + # Use dataclass instead of Pydantic BaseModel # because Pydantic BaseModel cannot handle Protocol fields. @dataclass @@ -383,9 +405,11 @@ async def _aggregate_components(self, server_info: types.Implementation, session tools_temp: dict[str, types.Tool] = {} tool_to_session_temp: dict[str, mcp.ClientSession] = {} - # Query the server for its prompts and aggregate to list. + # Query the server for its prompts and aggregate to list. Drain + # pagination so we don't drop later pages on servers that split + # results across multiple `next_cursor` responses. try: - prompts = (await session.list_prompts()).prompts + prompts = await _drain_paginated(session.list_prompts, "prompts") for prompt in prompts: name = self._component_name(prompt.name, server_info) prompts_temp[name] = prompt @@ -395,7 +419,7 @@ async def _aggregate_components(self, server_info: types.Implementation, session # Query the server for its resources and aggregate to list. try: - resources = (await session.list_resources()).resources + resources = await _drain_paginated(session.list_resources, "resources") for resource in resources: name = self._component_name(resource.name, server_info) resources_temp[name] = resource @@ -405,7 +429,7 @@ async def _aggregate_components(self, server_info: types.Implementation, session # Query the server for its tools and aggregate to list. try: - tools = (await session.list_tools()).tools + tools = await _drain_paginated(session.list_tools, "tools") for tool in tools: name = self._component_name(tool.name, server_info) tools_temp[name] = tool diff --git a/tests/client/test_list_all_pagination.py b/tests/client/test_list_all_pagination.py new file mode 100644 index 000000000..89ac0bb2a --- /dev/null +++ b/tests/client/test_list_all_pagination.py @@ -0,0 +1,207 @@ +"""Tests for client `list_all_*` and `iter_all_*` pagination helpers. + +These helpers drain `next_cursor` across pages, so a server can split +its tools/prompts/resources/resource_templates across multiple list +calls and the client still sees the full collection. + +See: https://github.com/modelcontextprotocol/python-sdk/issues/2556 +""" + +from collections.abc import Awaitable, Callable +from typing import TypeVar + +import mcp_types as types +import pytest + +from mcp import Client +from mcp.server import Server, ServerRequestContext + +from .conftest import StreamSpyCollection + +pytestmark = pytest.mark.anyio + +ItemT = TypeVar("ItemT") +ResultT = TypeVar("ResultT") + + +def _paginated_handler( + pages: list[list[str]], + make_item: Callable[[str], ItemT], + result_cls: Callable[..., ResultT], + items_field: str, +) -> Callable[[ServerRequestContext, types.PaginatedRequestParams | None], Awaitable[ResultT]]: + """Build a lowlevel-server handler that serves `pages` of items. + + Each page advances `next_cursor` from `"1"` ... `"N-1"` and the last + page returns no cursor. The handler is keyed by the cursor it receives + in the request, so cursor handling on both sides is exercised. + """ + # Map incoming cursor (None for first page) to the page index to return. + cursor_to_page: dict[str | None, int] = {None: 0} + for index in range(len(pages) - 1): + cursor_to_page[str(index + 1)] = index + 1 + + async def handler(_ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ResultT: + cursor = params.cursor if params else None + page_index = cursor_to_page[cursor] + page = pages[page_index] + next_cursor = str(page_index + 1) if page_index + 1 < len(pages) else None + return result_cls( + **{items_field: [make_item(name) for name in page]}, + next_cursor=next_cursor, + ) + + return handler + + +def _make_tool(name: str) -> types.Tool: + return types.Tool(name=name, input_schema={"type": "object"}) + + +def _make_prompt(name: str) -> types.Prompt: + return types.Prompt(name=name) + + +def _make_resource(name: str) -> types.Resource: + return types.Resource(name=name, uri=f"test://{name}") + + +def _make_resource_template(name: str) -> types.ResourceTemplate: + return types.ResourceTemplate(name=name, uri_template=f"test://{name}/{{id}}") + + +# ---- list_all_tools / iter_all_tools --------------------------------------- + + +async def test_list_all_tools_drains_all_pages( + stream_spy: Callable[[], StreamSpyCollection], +): + """list_all_tools follows `next_cursor` and returns the union of pages.""" + pages = [["a", "b"], ["c", "d"], ["e"]] + server = Server( + "paginated-tools", + on_list_tools=_paginated_handler(pages, _make_tool, types.ListToolsResult, "tools"), + ) + + async with Client(server, mode="legacy") as client: + spies = stream_spy() + tools = await client.list_all_tools() + + assert [t.name for t in tools] == ["a", "b", "c", "d", "e"] + # One request per page. + requests = spies.get_client_requests(method="tools/list") + assert len(requests) == 3 + # First request has no cursor; subsequent ones carry the previous cursor. + assert requests[0].params is None or "cursor" not in requests[0].params + assert requests[1].params is not None and requests[1].params["cursor"] == "1" + assert requests[2].params is not None and requests[2].params["cursor"] == "2" + + +async def test_list_all_tools_single_page(): + """A server that returns one page (no cursor) should give back one list.""" + + async def handle_list_tools( + _ctx: ServerRequestContext, _params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[_make_tool("only")]) + + server = Server("single-page-tools", on_list_tools=handle_list_tools) + + async with Client(server) as client: + tools = await client.list_all_tools() + assert [t.name for t in tools] == ["only"] + + +async def test_list_all_tools_empty_server(): + """An empty server should yield an empty list, not raise.""" + + async def handle_list_tools( + _ctx: ServerRequestContext, _params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[]) + + server = Server("no-tools", on_list_tools=handle_list_tools) + + async with Client(server) as client: + tools = await client.list_all_tools() + assert tools == [] + + +async def test_iter_all_tools_streams_pages( + stream_spy: Callable[[], StreamSpyCollection], +): + """iter_all_tools yields one tool at a time and only pages when needed.""" + pages = [["a", "b"], ["c"]] + server = Server( + "stream-tools", + on_list_tools=_paginated_handler(pages, _make_tool, types.ListToolsResult, "tools"), + ) + + async with Client(server, mode="legacy") as client: + spies = stream_spy() + seen = [tool.name async for tool in client.iter_all_tools()] + + assert seen == ["a", "b", "c"] + assert len(spies.get_client_requests(method="tools/list")) == 2 + + +# ---- list_all_prompts ------------------------------------------------------ + + +async def test_list_all_prompts_drains_all_pages( + stream_spy: Callable[[], StreamSpyCollection], +): + pages = [["p1", "p2"], ["p3"]] + server = Server( + "paginated-prompts", + on_list_prompts=_paginated_handler(pages, _make_prompt, types.ListPromptsResult, "prompts"), + ) + + async with Client(server, mode="legacy") as client: + spies = stream_spy() + prompts = await client.list_all_prompts() + assert [p.name for p in prompts] == ["p1", "p2", "p3"] + assert len(spies.get_client_requests(method="prompts/list")) == 2 + + +# ---- list_all_resources ---------------------------------------------------- + + +async def test_list_all_resources_drains_all_pages( + stream_spy: Callable[[], StreamSpyCollection], +): + pages = [["r1", "r2"], ["r3"], ["r4"]] + server = Server( + "paginated-resources", + on_list_resources=_paginated_handler(pages, _make_resource, types.ListResourcesResult, "resources"), + ) + + async with Client(server, mode="legacy") as client: + spies = stream_spy() + resources = await client.list_all_resources() + assert [r.name for r in resources] == ["r1", "r2", "r3", "r4"] + assert len(spies.get_client_requests(method="resources/list")) == 3 + + +# ---- list_all_resource_templates ------------------------------------------ + + +async def test_list_all_resource_templates_drains_all_pages( + stream_spy: Callable[[], StreamSpyCollection], +): + pages = [["t1"], ["t2", "t3"]] + server = Server( + "paginated-templates", + on_list_resource_templates=_paginated_handler( + pages, + _make_resource_template, + types.ListResourceTemplatesResult, + "resource_templates", + ), + ) + + async with Client(server, mode="legacy") as client: + spies = stream_spy() + templates = await client.list_all_resource_templates() + assert [t.name for t in templates] == ["t1", "t2", "t3"] + assert len(spies.get_client_requests(method="resources/templates/list")) == 2 diff --git a/tests/client/test_session_group.py b/tests/client/test_session_group.py index dae076616..9f5f7764b 100644 --- a/tests/client/test_session_group.py +++ b/tests/client/test_session_group.py @@ -116,9 +116,9 @@ async def test_client_session_group_connect_to_server(mock_exit_stack: contextli mock_resource1.name = "resource_b" mock_prompt1 = mock.Mock(spec=types.Prompt) mock_prompt1.name = "prompt_c" - mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool1]) - mock_session.list_resources.return_value = mock.AsyncMock(resources=[mock_resource1]) - mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[mock_prompt1]) + mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool1], next_cursor=None) + mock_session.list_resources.return_value = mock.AsyncMock(resources=[mock_resource1], next_cursor=None) + mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[mock_prompt1], next_cursor=None) # --- Test Execution --- group = ClientSessionGroup(exit_stack=mock_exit_stack) @@ -151,9 +151,9 @@ async def test_client_session_group_connect_to_server_with_name_hook(mock_exit_s mock_session = mock.AsyncMock(spec=mcp.ClientSession) mock_tool = mock.Mock(spec=types.Tool) mock_tool.name = "base_tool" - mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool]) - mock_session.list_resources.return_value = mock.AsyncMock(resources=[]) - mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[]) + mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool], next_cursor=None) + mock_session.list_resources.return_value = mock.AsyncMock(resources=[], next_cursor=None) + mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[], next_cursor=None) # --- Test Setup --- def name_hook(name: str, server_info: types.Implementation) -> str: @@ -262,10 +262,10 @@ async def test_client_session_group_connect_to_server_duplicate_tool_raises_erro # Configure the new session to return a tool with the *same name* duplicate_tool = mock.Mock(spec=types.Tool) duplicate_tool.name = existing_tool_name - mock_session_new.list_tools.return_value = mock.AsyncMock(tools=[duplicate_tool]) + mock_session_new.list_tools.return_value = mock.AsyncMock(tools=[duplicate_tool], next_cursor=None) # Keep other lists empty for simplicity - mock_session_new.list_resources.return_value = mock.AsyncMock(resources=[]) - mock_session_new.list_prompts.return_value = mock.AsyncMock(prompts=[]) + mock_session_new.list_resources.return_value = mock.AsyncMock(resources=[], next_cursor=None) + mock_session_new.list_prompts.return_value = mock.AsyncMock(prompts=[], next_cursor=None) # --- Test Execution and Assertion --- with pytest.raises(MCPError) as excinfo: @@ -402,3 +402,45 @@ async def test_client_session_group_establish_session_parameterized( # 3. Assert returned values assert returned_server_info is mock_initialize_result.server_info assert returned_session is mock_entered_session + + +@pytest.mark.anyio +async def test_client_session_group_aggregates_paginated_tools( + mock_exit_stack: contextlib.AsyncExitStack, +): + """ClientSessionGroup must drain `next_cursor` so it sees every page. + + Regression for https://github.com/modelcontextprotocol/python-sdk/issues/2556: + aggregators across multiple MCP servers are the most likely place to hit + pagination, so the group should not stop at page one. + """ + mock_server_info = mock.Mock(spec=types.Implementation) + mock_server_info.name = "PaginatedServer" + mock_session = mock.AsyncMock(spec=mcp.ClientSession) + + tool_page1_a = mock.Mock(spec=types.Tool) + tool_page1_a.name = "tool_a" + tool_page1_b = mock.Mock(spec=types.Tool) + tool_page1_b.name = "tool_b" + tool_page2 = mock.Mock(spec=types.Tool) + tool_page2.name = "tool_c" + + list_tools_responses = [ + mock.AsyncMock(tools=[tool_page1_a, tool_page1_b], next_cursor="page-2"), + mock.AsyncMock(tools=[tool_page2], next_cursor=None), + ] + mock_session.list_tools.side_effect = list_tools_responses + mock_session.list_resources.return_value = mock.AsyncMock(resources=[], next_cursor=None) + mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[], next_cursor=None) + + group = ClientSessionGroup(exit_stack=mock_exit_stack) + with mock.patch.object(group, "_establish_session", return_value=(mock_server_info, mock_session)): + await group.connect_to_server(StdioServerParameters(command="test")) + + assert set(group.tools.keys()) == {"tool_a", "tool_b", "tool_c"} + # Two pages -> two `list_tools` calls. + assert mock_session.list_tools.await_count == 2 + # Second call should have supplied the cursor returned by the first. + second_call_kwargs = mock_session.list_tools.await_args_list[1].kwargs + assert second_call_kwargs["params"] is not None + assert second_call_kwargs["params"].cursor == "page-2" From 24d13dfea0384bcbd73dcfe9c0a8abe6c6ba76cf Mon Sep 17 00:00:00 2001 From: Casey Gerena <15313423+CJGjr@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:35:17 -0400 Subject: [PATCH 2/3] Guard the drain loops against a non-advancing cursor A server that returns the same next_cursor it was given would make the list_all_*/iter_all_* loops page forever. Raise RuntimeError when the cursor does not advance instead of silently looping or truncating, and document it in the Raises section of each helper. Covered by a parametrized test across tools, prompts, resources, and templates. --- src/mcp/client/client.py | 58 +++++++++++++++++--- tests/client/test_list_all_pagination.py | 68 +++++++++++++++++++++++- 2 files changed, 119 insertions(+), 7 deletions(-) diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index 1be67d96d..5afe26c72 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -595,6 +595,9 @@ async def iter_all_tools(self, *, meta: RequestParamsMeta | None = None) -> Asyn Useful for streaming consumers that want to process tools without materializing the full list in memory. + + Raises: + RuntimeError: The server returned a pagination cursor that did not advance. """ cursor: str | None = None while True: @@ -603,6 +606,10 @@ async def iter_all_tools(self, *, meta: RequestParamsMeta | None = None) -> Asyn yield tool if result.next_cursor is None: return + if result.next_cursor == cursor: + raise RuntimeError( + "Server returned a pagination cursor that did not advance; refusing to page forever." + ) cursor = result.next_cursor async def list_all_tools(self, *, meta: RequestParamsMeta | None = None) -> list[Tool]: @@ -611,11 +618,18 @@ async def list_all_tools(self, *, meta: RequestParamsMeta | None = None) -> list Unlike `list_tools`, which returns one page, this walks pagination until the server reports no further pages and returns the combined list. + + Raises: + RuntimeError: The server returned a pagination cursor that did not advance. """ return [tool async for tool in self.iter_all_tools(meta=meta)] async def iter_all_prompts(self, *, meta: RequestParamsMeta | None = None) -> AsyncIterator[Prompt]: - """Yield every prompt from the server, paging through `next_cursor`.""" + """Yield every prompt from the server, paging through `next_cursor`. + + Raises: + RuntimeError: The server returned a pagination cursor that did not advance. + """ cursor: str | None = None while True: result = await self.list_prompts(cursor=cursor, meta=meta) @@ -623,14 +637,26 @@ async def iter_all_prompts(self, *, meta: RequestParamsMeta | None = None) -> As yield prompt if result.next_cursor is None: return + if result.next_cursor == cursor: + raise RuntimeError( + "Server returned a pagination cursor that did not advance; refusing to page forever." + ) cursor = result.next_cursor async def list_all_prompts(self, *, meta: RequestParamsMeta | None = None) -> list[Prompt]: - """List every prompt from the server, draining `next_cursor` across pages.""" + """List every prompt from the server, draining `next_cursor` across pages. + + Raises: + RuntimeError: The server returned a pagination cursor that did not advance. + """ return [prompt async for prompt in self.iter_all_prompts(meta=meta)] async def iter_all_resources(self, *, meta: RequestParamsMeta | None = None) -> AsyncIterator[Resource]: - """Yield every resource from the server, paging through `next_cursor`.""" + """Yield every resource from the server, paging through `next_cursor`. + + Raises: + RuntimeError: The server returned a pagination cursor that did not advance. + """ cursor: str | None = None while True: result = await self.list_resources(cursor=cursor, meta=meta) @@ -638,16 +664,28 @@ async def iter_all_resources(self, *, meta: RequestParamsMeta | None = None) -> yield resource if result.next_cursor is None: return + if result.next_cursor == cursor: + raise RuntimeError( + "Server returned a pagination cursor that did not advance; refusing to page forever." + ) cursor = result.next_cursor async def list_all_resources(self, *, meta: RequestParamsMeta | None = None) -> list[Resource]: - """List every resource from the server, draining `next_cursor` across pages.""" + """List every resource from the server, draining `next_cursor` across pages. + + Raises: + RuntimeError: The server returned a pagination cursor that did not advance. + """ return [resource async for resource in self.iter_all_resources(meta=meta)] async def iter_all_resource_templates( self, *, meta: RequestParamsMeta | None = None ) -> AsyncIterator[ResourceTemplate]: - """Yield every resource template from the server, paging through `next_cursor`.""" + """Yield every resource template from the server, paging through `next_cursor`. + + Raises: + RuntimeError: The server returned a pagination cursor that did not advance. + """ cursor: str | None = None while True: result = await self.list_resource_templates(cursor=cursor, meta=meta) @@ -655,10 +693,18 @@ async def iter_all_resource_templates( yield template if result.next_cursor is None: return + if result.next_cursor == cursor: + raise RuntimeError( + "Server returned a pagination cursor that did not advance; refusing to page forever." + ) cursor = result.next_cursor async def list_all_resource_templates(self, *, meta: RequestParamsMeta | None = None) -> list[ResourceTemplate]: - """List every resource template from the server, draining `next_cursor` across pages.""" + """List every resource template from the server, draining `next_cursor` across pages. + + Raises: + RuntimeError: The server returned a pagination cursor that did not advance. + """ return [template async for template in self.iter_all_resource_templates(meta=meta)] @deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) diff --git a/tests/client/test_list_all_pagination.py b/tests/client/test_list_all_pagination.py index 89ac0bb2a..28a80b4d7 100644 --- a/tests/client/test_list_all_pagination.py +++ b/tests/client/test_list_all_pagination.py @@ -8,7 +8,7 @@ """ from collections.abc import Awaitable, Callable -from typing import TypeVar +from typing import Any, TypeVar import mcp_types as types import pytest @@ -54,6 +54,22 @@ async def handler(_ctx: ServerRequestContext, params: types.PaginatedRequestPara return handler +def _stuck_cursor_handler( + make_item: Callable[[str], ItemT], + result_cls: Callable[..., ResultT], + items_field: str, +) -> Callable[[ServerRequestContext, types.PaginatedRequestParams | None], Awaitable[ResultT]]: + """Build a malformed handler that always returns the same non-null cursor. + + A drain loop that trusts the cursor would page forever against this server. + """ + + async def handler(_ctx: ServerRequestContext, _params: types.PaginatedRequestParams | None) -> ResultT: + return result_cls(**{items_field: [make_item("x")]}, next_cursor="stuck") + + return handler + + def _make_tool(name: str) -> types.Tool: return types.Tool(name=name, input_schema={"type": "object"}) @@ -205,3 +221,53 @@ async def test_list_all_resource_templates_drains_all_pages( templates = await client.list_all_resource_templates() assert [t.name for t in templates] == ["t1", "t2", "t3"] assert len(spies.get_client_requests(method="resources/templates/list")) == 2 + + +# ---- malformed server: non-advancing cursor -------------------------------- + + +@pytest.mark.parametrize( + "build_server,client_method", + [ + ( + lambda: Server( + "stuck-tools", + on_list_tools=_stuck_cursor_handler(_make_tool, types.ListToolsResult, "tools"), + ), + "list_all_tools", + ), + ( + lambda: Server( + "stuck-prompts", + on_list_prompts=_stuck_cursor_handler(_make_prompt, types.ListPromptsResult, "prompts"), + ), + "list_all_prompts", + ), + ( + lambda: Server( + "stuck-resources", + on_list_resources=_stuck_cursor_handler(_make_resource, types.ListResourcesResult, "resources"), + ), + "list_all_resources", + ), + ( + lambda: Server( + "stuck-templates", + on_list_resource_templates=_stuck_cursor_handler( + _make_resource_template, types.ListResourceTemplatesResult, "resource_templates" + ), + ), + "list_all_resource_templates", + ), + ], +) +async def test_drain_raises_when_cursor_does_not_advance( + build_server: Callable[[], Server[Any]], + client_method: str, +): + """A server that keeps returning the same cursor must fail loudly, not loop forever.""" + server = build_server() + + async with Client(server) as client: + with pytest.raises(RuntimeError, match="did not advance"): + await getattr(client, client_method)() From 15d146f46e89c8f078367d0a09d361c1fc24fba3 Mon Sep 17 00:00:00 2001 From: Casey Gerena <15313423+CJGjr@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:44:16 -0400 Subject: [PATCH 3/3] Document the drain helpers in the pagination guide Add a Draining in one call section to docs/advanced/pagination.md covering list_all_*/iter_all_*, the ClientSessionGroup behavior, and the non-advancing cursor guard. Backed by a runnable tutorial003 snippet and matching tests, in keeping with the page proving every claim. --- docs/advanced/pagination.md | 20 ++++++++++++++++++ docs_src/pagination/tutorial003.py | 33 ++++++++++++++++++++++++++++++ tests/docs_src/test_pagination.py | 17 ++++++++++++++- 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 docs_src/pagination/tutorial003.py diff --git a/docs/advanced/pagination.md b/docs/advanced/pagination.md index aac63f4c7..d17ca4706 100644 --- a/docs/advanced/pagination.md +++ b/docs/advanced/pagination.md @@ -50,6 +50,26 @@ Run its `main()` and it prints `100 resources`: ten pages of ten, stitched toget This is the same loop **[The Client](../client/index.md)** chapter showed you, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once. +## Draining in one call + +That loop is the same one in every client that pages, so `Client` ships it. The server here is the bookshop from before; only the client changed: + +```python title="client.py" hl_lines="27 31" +--8<-- "docs_src/pagination/tutorial003.py" +``` + +* `list_all_resources()` walks `next_cursor` for you and hands back every page stitched into one list. There is one per pageable list: `list_all_tools`, `list_all_prompts`, `list_all_resources`, `list_all_resource_templates`. +* `iter_all_resources()` yields one resource at a time and only fetches the next page when you ask for it, so you can stop early without dragging down the whole catalog. Same four: `iter_all_tools`, `iter_all_prompts`, and so on. +* The single-page `list_*` methods are unchanged. Use them when you want one page and the cursor; use the drains when you want everything and don't want to own the loop. + +`ClientSessionGroup` aggregation drains the same way, so a group fronting several servers reports the full collection instead of each server's first page. That aggregator is **[Session groups](session-groups.md)**. + +!!! warning + A drain trusts the server to advance the cursor. A server that keeps returning the same + `next_cursor` it was handed would page forever, so the drains stop and raise `RuntimeError` + the moment a cursor fails to move. A page that does not advance is a broken server, and a + loud failure beats a silent hang or a half-read list. + ## The three rules **Cursors are opaque.** A client must never parse, build, or guess one. The only legal source of a cursor is the previous page's `next_cursor`, verbatim. diff --git a/docs_src/pagination/tutorial003.py b/docs_src/pagination/tutorial003.py new file mode 100644 index 000000000..8b7fcde76 --- /dev/null +++ b/docs_src/pagination/tutorial003.py @@ -0,0 +1,33 @@ +from typing import Any + +from mcp_types import ListResourcesResult, PaginatedRequestParams, Resource + +from mcp import Client +from mcp.server import Server, ServerRequestContext + +BOOKS = [f"book-{n}" for n in range(1, 101)] + +PAGE_SIZE = 10 + + +async def list_books(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListResourcesResult: + start = 0 if params is None or params.cursor is None else int(params.cursor) + end = start + PAGE_SIZE + page = [Resource(uri=f"books://catalog/{name}", name=name) for name in BOOKS[start:end]] + next_cursor = str(end) if end < len(BOOKS) else None + return ListResourcesResult(resources=page, next_cursor=next_cursor) + + +server = Server("Bookshop", on_list_resources=list_books) + + +async def main() -> None: + async with Client(server) as client: + # Every page, stitched into one list. + resources = await client.list_all_resources() + print(f"{len(resources)} resources") + + # Or stream them, and stop as soon as you have what you need. + async for resource in client.iter_all_resources(): + print(f"first: {resource.name}") + break diff --git a/tests/docs_src/test_pagination.py b/tests/docs_src/test_pagination.py index ab5949df9..8aa4a7495 100644 --- a/tests/docs_src/test_pagination.py +++ b/tests/docs_src/test_pagination.py @@ -3,7 +3,7 @@ import pytest from mcp_types import Resource -from docs_src.pagination import tutorial001, tutorial002 +from docs_src.pagination import tutorial001, tutorial002, tutorial003 from mcp import Client, MCPError from mcp.server import MCPServer from mcp.server.mcpserver.resources import TextResource @@ -71,6 +71,21 @@ async def test_the_client_program_on_the_page_runs(capsys: pytest.CaptureFixture assert capsys.readouterr().out == "100 resources\n" +async def test_list_all_stitches_the_whole_catalog() -> None: + """tutorial003: `list_all_resources` drains every page into one list, no cursor handling.""" + async with Client(tutorial003.server) as client: + resources = await client.list_all_resources() + assert len(resources) == 100 + assert resources[0].name == "book-1" + assert resources[-1].name == "book-100" + + +async def test_the_drain_helpers_program_runs(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial003: `main()` stitches all pages, then streams and stops at the first.""" + await tutorial003.main() + assert capsys.readouterr().out == "100 resources\nfirst: book-1\n" + + async def test_an_invented_cursor_is_an_error() -> None: """Cursors are opaque: a string the server never minted blows up inside the handler.""" async with Client(tutorial001.server) as client: