From a7adecdcd0912a6e1a0b1d445955f6d8fc09f4dd Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Fri, 26 Jun 2026 16:35:06 -0400 Subject: [PATCH 1/6] 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 d519106a6..40f315316 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -5,7 +5,7 @@ import hashlib import logging import uuid -from collections.abc import Awaitable, Callable, Mapping, Sequence +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence from contextlib import AbstractAsyncContextManager, AsyncExitStack from dataclasses import KW_ONLY, dataclass, field from typing import Any, Literal, TypeVar, cast @@ -32,12 +32,16 @@ ListToolsResult, LoggingLevel, PaginatedRequestParams, + Prompt, PromptReference, ReadResourceResult, RequestParamsMeta, + Resource, + ResourceTemplate, ResourceTemplateReference, Result, ServerCapabilities, + Tool, ) from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS from typing_extensions import deprecated @@ -580,7 +584,11 @@ async def list_resources( meta: RequestParamsMeta | None = None, cache_mode: CacheMode = "use", ) -> 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._cached_fetch( "resources/list", cursor=cursor, @@ -596,7 +604,12 @@ async def list_resource_templates( meta: RequestParamsMeta | None = None, cache_mode: CacheMode = "use", ) -> 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._cached_fetch( "resources/templates/list", cursor=cursor, @@ -814,7 +827,11 @@ async def list_prompts( meta: RequestParamsMeta | None = None, cache_mode: CacheMode = "use", ) -> 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._cached_fetch( "prompts/list", cursor=cursor, @@ -912,7 +929,11 @@ async def list_tools( meta: RequestParamsMeta | None = None, cache_mode: CacheMode = "use", ) -> 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._cached_fetch( "tools/list", cursor=cursor, @@ -927,6 +948,77 @@ async def list_tools( ), ) + 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 5f26a4336..f0761ef6f 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 b75d22b7a..008c2e75b 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 f0b43cb835893ef4ca3f8f175ae92ffe2b5547ed 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/6] 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 40f315316..6e4947f57 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -953,6 +953,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: @@ -961,6 +964,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]: @@ -969,11 +976,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) @@ -981,14 +995,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) @@ -996,16 +1022,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) @@ -1013,10 +1051,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 36ea094b6e2c90976824697b80c9ea60c58d72c9 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/6] 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 381f7fae0..4a88f73d5 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)** shows for every `list_*` verb, 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: From 36a8bdd86e7d18465fc983c0fa28049284401615 Mon Sep 17 00:00:00 2001 From: Casey Gerena <15313423+CJGjr@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:22:51 -0400 Subject: [PATCH 4/6] Extend the non-advancing cursor guard to ClientSessionGroup drains The pagination guide says every drain fails loudly when a server returns the same cursor it was handed, but the group aggregator's drain loop had no such guard and would page forever. Guard it the same way the Client drains are guarded. --- src/mcp/client/session_group.py | 9 +++++++-- tests/client/test_session_group.py | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index f0761ef6f..91fdd9b18 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -76,6 +76,9 @@ async def _drain_paginated( `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"`). + + Raises: + RuntimeError: The server returned a pagination cursor that did not advance. """ items: list[Any] = [] cursor: str | None = None @@ -86,6 +89,8 @@ async def _drain_paginated( next_cursor = getattr(result, "next_cursor", None) if next_cursor is None: return items + if next_cursor == cursor: + raise RuntimeError("Server returned a pagination cursor that did not advance; refusing to page forever.") cursor = next_cursor @@ -435,8 +440,8 @@ async def _aggregate_components(self, server_info: types.Implementation, session tools_temp[name] = tool tool_to_session_temp[name] = session component_names.tools.add(name) - except MCPError as err: # pragma: no cover - logging.warning(f"Could not fetch tools: {err}") + except MCPError as err: + logging.warning(f"Could not fetch tools: {err}") # pragma: no cover # Clean up exit stack for session if we couldn't retrieve anything # from the server. diff --git a/tests/client/test_session_group.py b/tests/client/test_session_group.py index 008c2e75b..8e198688d 100644 --- a/tests/client/test_session_group.py +++ b/tests/client/test_session_group.py @@ -444,3 +444,24 @@ async def test_client_session_group_aggregates_paginated_tools( 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" + + +@pytest.mark.anyio +async def test_client_session_group_rejects_non_advancing_cursor( + mock_exit_stack: contextlib.AsyncExitStack, +): + """A server that keeps returning the cursor it was handed must fail loudly, not page forever.""" + mock_server_info = mock.Mock(spec=types.Implementation) + mock_server_info.name = "StuckCursorServer" + mock_session = mock.AsyncMock(spec=mcp.ClientSession) + + stuck_tool = mock.Mock(spec=types.Tool) + stuck_tool.name = "tool_a" + mock_session.list_tools.return_value = mock.AsyncMock(tools=[stuck_tool], next_cursor="page-2") + 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)): + with pytest.raises(RuntimeError, match="did not advance"): + await group.connect_to_server(StdioServerParameters(command="test")) From 6e250ac4ccd16f691eede42ffc21f0e96052ce1f Mon Sep 17 00:00:00 2001 From: Casey Gerena <15313423+CJGjr@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:02:31 -0400 Subject: [PATCH 5/6] Fix the session-groups cross-reference in the pagination guide The page lives at client/session-groups.md; the relative link broke when the guide moved under docs/advanced/ during the rebase, and zensical build --strict rejects it. --- docs/advanced/pagination.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/advanced/pagination.md b/docs/advanced/pagination.md index 4a88f73d5..dbf596fe4 100644 --- a/docs/advanced/pagination.md +++ b/docs/advanced/pagination.md @@ -62,7 +62,7 @@ That loop is the same one in every client that pages, so `Client` ships it. The * `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)**. +`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](../client/session-groups.md)**. !!! warning A drain trusts the server to advance the cursor. A server that keeps returning the same From 2b7833846a1cdc91d3f6497fbe729d425568c05d Mon Sep 17 00:00:00 2001 From: Casey Gerena <15313423+CJGjr@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:38:27 -0400 Subject: [PATCH 6/6] Harden the drain guard against cycling pagination cursors Comparing only against the immediately preceding cursor catches a server that echoes the cursor back but not one that alternates between cursors (a, b, a, ...), which would still page forever. Track every cursor seen during the drain and raise on any repeat, in both the Client drains and the ClientSessionGroup aggregation drain. --- docs/advanced/pagination.md | 9 ++-- src/mcp/client/client.py | 56 ++++++++++++++---------- src/mcp/client/session_group.py | 9 ++-- tests/client/test_list_all_pagination.py | 37 +++++++++++++++- tests/client/test_session_group.py | 2 +- 5 files changed, 80 insertions(+), 33 deletions(-) diff --git a/docs/advanced/pagination.md b/docs/advanced/pagination.md index dbf596fe4..c84932901 100644 --- a/docs/advanced/pagination.md +++ b/docs/advanced/pagination.md @@ -65,10 +65,11 @@ That loop is the same one in every client that pages, so `Client` ships it. The `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](../client/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. + A drain trusts the server to advance the cursor. A server that echoes back the + `next_cursor` it was handed, or cycles through a longer loop of them, would page forever, + so the drains remember every cursor they have seen and raise `RuntimeError` the moment one + repeats. A repeated cursor is a broken server, and a loud failure beats a silent hang or a + half-read list. ## The three rules diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index 6e4947f57..dded2819b 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -955,8 +955,10 @@ async def iter_all_tools(self, *, meta: RequestParamsMeta | None = None) -> Asyn materializing the full list in memory. Raises: - RuntimeError: The server returned a pagination cursor that did not advance. + RuntimeError: The server returned a pagination cursor it already + returned, which would page forever. """ + seen_cursors: set[str] = set() cursor: str | None = None while True: result = await self.list_tools(cursor=cursor, meta=meta) @@ -964,10 +966,9 @@ 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." - ) + if result.next_cursor in seen_cursors: + raise RuntimeError("Server returned a pagination cursor it already returned; refusing to page forever.") + seen_cursors.add(result.next_cursor) cursor = result.next_cursor async def list_all_tools(self, *, meta: RequestParamsMeta | None = None) -> list[Tool]: @@ -978,7 +979,8 @@ async def list_all_tools(self, *, meta: RequestParamsMeta | None = None) -> list list. Raises: - RuntimeError: The server returned a pagination cursor that did not advance. + RuntimeError: The server returned a pagination cursor it already + returned, which would page forever. """ return [tool async for tool in self.iter_all_tools(meta=meta)] @@ -986,8 +988,10 @@ async def iter_all_prompts(self, *, meta: RequestParamsMeta | None = None) -> As """Yield every prompt from the server, paging through `next_cursor`. Raises: - RuntimeError: The server returned a pagination cursor that did not advance. + RuntimeError: The server returned a pagination cursor it already + returned, which would page forever. """ + seen_cursors: set[str] = set() cursor: str | None = None while True: result = await self.list_prompts(cursor=cursor, meta=meta) @@ -995,17 +999,17 @@ 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." - ) + if result.next_cursor in seen_cursors: + raise RuntimeError("Server returned a pagination cursor it already returned; refusing to page forever.") + seen_cursors.add(result.next_cursor) 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. Raises: - RuntimeError: The server returned a pagination cursor that did not advance. + RuntimeError: The server returned a pagination cursor it already + returned, which would page forever. """ return [prompt async for prompt in self.iter_all_prompts(meta=meta)] @@ -1013,8 +1017,10 @@ async def iter_all_resources(self, *, meta: RequestParamsMeta | None = None) -> """Yield every resource from the server, paging through `next_cursor`. Raises: - RuntimeError: The server returned a pagination cursor that did not advance. + RuntimeError: The server returned a pagination cursor it already + returned, which would page forever. """ + seen_cursors: set[str] = set() cursor: str | None = None while True: result = await self.list_resources(cursor=cursor, meta=meta) @@ -1022,17 +1028,17 @@ 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." - ) + if result.next_cursor in seen_cursors: + raise RuntimeError("Server returned a pagination cursor it already returned; refusing to page forever.") + seen_cursors.add(result.next_cursor) 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. Raises: - RuntimeError: The server returned a pagination cursor that did not advance. + RuntimeError: The server returned a pagination cursor it already + returned, which would page forever. """ return [resource async for resource in self.iter_all_resources(meta=meta)] @@ -1042,8 +1048,10 @@ async def iter_all_resource_templates( """Yield every resource template from the server, paging through `next_cursor`. Raises: - RuntimeError: The server returned a pagination cursor that did not advance. + RuntimeError: The server returned a pagination cursor it already + returned, which would page forever. """ + seen_cursors: set[str] = set() cursor: str | None = None while True: result = await self.list_resource_templates(cursor=cursor, meta=meta) @@ -1051,17 +1059,17 @@ 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." - ) + if result.next_cursor in seen_cursors: + raise RuntimeError("Server returned a pagination cursor it already returned; refusing to page forever.") + seen_cursors.add(result.next_cursor) 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. Raises: - RuntimeError: The server returned a pagination cursor that did not advance. + RuntimeError: The server returned a pagination cursor it already + returned, which would page forever. """ return [template async for template in self.iter_all_resource_templates(meta=meta)] diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index 91fdd9b18..bdf838d43 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -78,9 +78,11 @@ async def _drain_paginated( the list attribute on the result (e.g. `"tools"`, `"prompts"`). Raises: - RuntimeError: The server returned a pagination cursor that did not advance. + RuntimeError: The server returned a pagination cursor it already + returned, which would page forever. """ items: list[Any] = [] + seen_cursors: set[str] = set() cursor: str | None = None while True: params = types.PaginatedRequestParams(cursor=cursor) if cursor is not None else None @@ -89,8 +91,9 @@ async def _drain_paginated( next_cursor = getattr(result, "next_cursor", None) if next_cursor is None: return items - if next_cursor == cursor: - raise RuntimeError("Server returned a pagination cursor that did not advance; refusing to page forever.") + if next_cursor in seen_cursors: + raise RuntimeError("Server returned a pagination cursor it already returned; refusing to page forever.") + seen_cursors.add(next_cursor) cursor = next_cursor diff --git a/tests/client/test_list_all_pagination.py b/tests/client/test_list_all_pagination.py index 28a80b4d7..709e70bd5 100644 --- a/tests/client/test_list_all_pagination.py +++ b/tests/client/test_list_all_pagination.py @@ -70,6 +70,25 @@ async def handler(_ctx: ServerRequestContext, _params: types.PaginatedRequestPar return handler +def _cycling_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 alternates between two cursors forever. + + The cursor always advances relative to the previous page, so a guard that + only compares against the immediately preceding cursor would page forever. + """ + + async def handler(_ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ResultT: + cursor = params.cursor if params else None + next_cursor = "b" if cursor == "a" else "a" + return result_cls(**{items_field: [make_item("x")]}, next_cursor=next_cursor) + + return handler + + def _make_tool(name: str) -> types.Tool: return types.Tool(name=name, input_schema={"type": "object"}) @@ -269,5 +288,21 @@ async def test_drain_raises_when_cursor_does_not_advance( server = build_server() async with Client(server) as client: - with pytest.raises(RuntimeError, match="did not advance"): + with pytest.raises(RuntimeError, match="already returned"): await getattr(client, client_method)() + + +async def test_drain_raises_when_cursors_cycle(): + """A server whose cursors cycle (a, b, a, ...) must fail loudly, not loop forever. + + Each cursor differs from the one before it, so this specifically exercises + the seen-set guard rather than the simpler stuck-cursor case above. + """ + server = Server( + "cycling-tools", + on_list_tools=_cycling_cursor_handler(_make_tool, types.ListToolsResult, "tools"), + ) + + async with Client(server) as client: + with pytest.raises(RuntimeError, match="already returned"): + await client.list_all_tools() diff --git a/tests/client/test_session_group.py b/tests/client/test_session_group.py index 8e198688d..a25e763ab 100644 --- a/tests/client/test_session_group.py +++ b/tests/client/test_session_group.py @@ -463,5 +463,5 @@ async def test_client_session_group_rejects_non_advancing_cursor( group = ClientSessionGroup(exit_stack=mock_exit_stack) with mock.patch.object(group, "_establish_session", return_value=(mock_server_info, mock_session)): - with pytest.raises(RuntimeError, match="did not advance"): + with pytest.raises(RuntimeError, match="already returned"): await group.connect_to_server(StdioServerParameters(command="test"))