Skip to content

Commit 931e9b6

Browse files
committed
mcp/client: add list_all_* helpers with pagination safety guards
The Python SDK lacked auto-pagination helpers for the four list verbs (tools, resources, resource_templates, prompts). Callers had to write their own cursor-draining loops, and those loops had no protection against infinite pagination or cursor cycles. Add four new methods to the high-level Client: - list_all_tools() -> ListToolsResult - list_all_resources() -> ListResourcesResult - list_all_resource_templates() -> ListResourceTemplatesResult - list_all_prompts() -> ListPromptsResult Each drains all pages into a single aggregated result via a shared _drain_all_pages helper with two safety guards (matching the TypeScript SDK's _listAllPages behavior): 1. Page cap: Client.list_max_pages (default 64, configurable). Exceeding the cap raises PaginationExceededError. Setting to 0 or a negative value disables the cap (unlimited). 2. Cursor cycle detection: a seen-set tracks all cursors returned by the server. A repeated cursor raises CursorCycleError, preventing infinite loops from buggy servers. Both exception types are exported from mcp.client for caller catch. Tests cover: normal multi-item drain, empty server, max pages exceeded, cursor cycle detection, unlimited mode (list_max_pages=0), and terminal cursor stripping. Signed-off-by: dongjiang <dongjiang1989@126.com>
1 parent 837ef90 commit 931e9b6

3 files changed

Lines changed: 327 additions & 1 deletion

File tree

‎src/mcp/client/__init__.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
InMemoryResponseCacheStore,
1111
ResponseCacheStore,
1212
)
13-
from mcp.client.client import Client
13+
from mcp.client.client import Client, CursorCycleError, PaginationExceededError
1414
from mcp.client.context import ClientRequestContext
1515
from mcp.client.extension import (
1616
ClaimContext,
@@ -32,9 +32,11 @@
3232
"ClientExtension",
3333
"ClientRequestContext",
3434
"ClientSession",
35+
"CursorCycleError",
3536
"InMemoryResponseCacheStore",
3637
"InputRequiredRoundsExceededError",
3738
"NotificationBinding",
39+
"PaginationExceededError",
3840
"ResponseCacheStore",
3941
"ResultClaim",
4042
"Transport",

‎src/mcp/client/client.py‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
ListToolsResult,
3333
LoggingLevel,
3434
PaginatedRequestParams,
35+
PaginatedResult,
3536
PromptReference,
3637
ReadResourceResult,
3738
RequestParamsMeta,
@@ -81,6 +82,39 @@
8182
_T = TypeVar("_T")
8283
_ResultT = TypeVar("_ResultT")
8384
_CacheableT = TypeVar("_CacheableT", bound=CacheableResult)
85+
_PaginatedCacheableT = TypeVar("_PaginatedCacheableT", bound=PaginatedResult)
86+
87+
_DEFAULT_LIST_MAX_PAGES = 64
88+
89+
90+
class PaginationExceededError(RuntimeError):
91+
"""A ``list_all_*`` walk exceeded the configured page limit.
92+
93+
Raised when automatic pagination does not terminate within
94+
``Client.list_max_pages`` pages, indicating a server that keeps
95+
returning ``next_cursor`` without end.
96+
"""
97+
98+
def __init__(self, method: str, max_pages: int) -> None:
99+
super().__init__(
100+
f"{method}: exceeded list_max_pages ({max_pages});"
101+
" server pagination did not terminate"
102+
)
103+
self.method = method
104+
self.max_pages = max_pages
105+
106+
107+
class CursorCycleError(RuntimeError):
108+
"""A ``list_all_*`` walk detected a repeated pagination cursor.
109+
110+
Raised when the server returns a ``next_cursor`` that was already
111+
seen during the current pagination walk, indicating a cursor cycle.
112+
"""
113+
114+
def __init__(self, method: str, cursor: str) -> None:
115+
super().__init__(f"{method}: pagination detected cursor cycle: {cursor!r}")
116+
self.method = method
117+
self.cursor = cursor
84118

85119
_Connector = Callable[[AsyncExitStack, ConnectMode, bool], Awaitable["Dispatcher[Any]"]]
86120
"""Resolved at ``__post_init__`` from the shape of ``server`` alone: enter whatever resources
@@ -358,6 +392,14 @@ async def main():
358392
`meta` always reach the server. A `CacheConfig` with a custom `store` requires
359393
`target_id` when the server is not a URL (no identity can be derived)."""
360394

395+
list_max_pages: int = _DEFAULT_LIST_MAX_PAGES
396+
"""Maximum number of pages to fetch during ``list_all_*`` auto-pagination.
397+
398+
Defaults to 64 (matching the TypeScript SDK). A value of 0 disables the cap
399+
(unlimited pagination). A negative value also means unlimited. Raises
400+
`PaginationExceededError` when the limit is exceeded, and `CursorCycleError`
401+
when a repeated cursor is detected."""
402+
361403
_entered: bool = field(init=False, default=False)
362404
_session: ClientSession | None = field(init=False, default=None)
363405
_exit_stack: AsyncExitStack | None = field(init=False, default=None)
@@ -933,6 +975,96 @@ async def list_tools(
933975
),
934976
)
935977

978+
async def _drain_all_pages(
979+
self,
980+
method: str,
981+
fetch_page: Callable[[str | None], Awaitable[_PaginatedCacheableT]],
982+
get_items: Callable[[_PaginatedCacheableT], list[Any]],
983+
set_items: Callable[[_PaginatedCacheableT, list[Any]], None],
984+
) -> _PaginatedCacheableT:
985+
"""Drain all pages of a paginated list method into a single aggregated result.
986+
987+
Safety guards:
988+
- Cursor cycle detection via a seen-set; raises `CursorCycleError`.
989+
- Page cap enforced via ``self.list_max_pages``; raises `PaginationExceededError`.
990+
"""
991+
first = await fetch_page(None)
992+
cursor = first.next_cursor
993+
seen: set[str] = set()
994+
pages = 1
995+
max_pages = self.list_max_pages
996+
997+
while cursor is not None:
998+
if cursor in seen:
999+
raise CursorCycleError(method, cursor)
1000+
seen.add(cursor)
1001+
if max_pages > 0 and pages >= max_pages:
1002+
raise PaginationExceededError(method, max_pages)
1003+
page = await fetch_page(cursor)
1004+
set_items(first, get_items(first) + get_items(page))
1005+
cursor = page.next_cursor
1006+
pages += 1
1007+
1008+
# Strip the terminal cursor from the aggregated result.
1009+
first.next_cursor = None
1010+
return first
1011+
1012+
async def list_all_tools(self) -> ListToolsResult:
1013+
"""Fetch all tools from the server, draining pagination automatically.
1014+
1015+
Raises:
1016+
PaginationExceededError: The walk exceeded ``list_max_pages``.
1017+
CursorCycleError: The server returned a repeated cursor.
1018+
"""
1019+
return await self._drain_all_pages(
1020+
"tools/list",
1021+
fetch_page=lambda c: self.list_tools(cursor=c, cache_mode="bypass"),
1022+
get_items=lambda r: list(r.tools),
1023+
set_items=lambda r, items: setattr(r, "tools", items), # noqa: B010
1024+
)
1025+
1026+
async def list_all_resources(self) -> ListResourcesResult:
1027+
"""Fetch all resources from the server, draining pagination automatically.
1028+
1029+
Raises:
1030+
PaginationExceededError: The walk exceeded ``list_max_pages``.
1031+
CursorCycleError: The server returned a repeated cursor.
1032+
"""
1033+
return await self._drain_all_pages(
1034+
"resources/list",
1035+
fetch_page=lambda c: self.list_resources(cursor=c, cache_mode="bypass"),
1036+
get_items=lambda r: list(r.resources),
1037+
set_items=lambda r, items: setattr(r, "resources", items), # noqa: B010
1038+
)
1039+
1040+
async def list_all_resource_templates(self) -> ListResourceTemplatesResult:
1041+
"""Fetch all resource templates from the server, draining pagination automatically.
1042+
1043+
Raises:
1044+
PaginationExceededError: The walk exceeded ``list_max_pages``.
1045+
CursorCycleError: The server returned a repeated cursor.
1046+
"""
1047+
return await self._drain_all_pages(
1048+
"resources/templates/list",
1049+
fetch_page=lambda c: self.list_resource_templates(cursor=c, cache_mode="bypass"),
1050+
get_items=lambda r: list(r.resource_templates),
1051+
set_items=lambda r, items: setattr(r, "resource_templates", items), # noqa: B010
1052+
)
1053+
1054+
async def list_all_prompts(self) -> ListPromptsResult:
1055+
"""Fetch all prompts from the server, draining pagination automatically.
1056+
1057+
Raises:
1058+
PaginationExceededError: The walk exceeded ``list_max_pages``.
1059+
CursorCycleError: The server returned a repeated cursor.
1060+
"""
1061+
return await self._drain_all_pages(
1062+
"prompts/list",
1063+
fetch_page=lambda c: self.list_prompts(cursor=c, cache_mode="bypass"),
1064+
get_items=lambda r: list(r.prompts),
1065+
set_items=lambda r, items: setattr(r, "prompts", items), # noqa: B010
1066+
)
1067+
9361068
@deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
9371069
async def send_roots_list_changed(self) -> None:
9381070
"""Send a notification that the roots list has changed."""
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""Tests for list_all_* auto-pagination helpers with safety guards."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
from mcp_types import (
7+
ListPromptsResult,
8+
ListResourcesResult,
9+
ListResourceTemplatesResult,
10+
ListToolsResult,
11+
)
12+
13+
from mcp.client.client import Client, CursorCycleError, PaginationExceededError
14+
from mcp.server.mcpserver import MCPServer
15+
16+
pytestmark = pytest.mark.anyio
17+
18+
19+
@pytest.fixture
20+
def multi_item_server() -> MCPServer:
21+
"""Server with several tools, resources, prompts, and templates."""
22+
server = MCPServer("paginated-test")
23+
24+
@server.tool()
25+
def tool_a() -> str: # pragma: no cover
26+
"""Tool A."""
27+
return "a"
28+
29+
@server.tool()
30+
def tool_b() -> str: # pragma: no cover
31+
"""Tool B."""
32+
return "b"
33+
34+
@server.tool()
35+
def tool_c() -> str: # pragma: no cover
36+
"""Tool C."""
37+
return "c"
38+
39+
@server.resource("test://r1")
40+
def res1() -> str: # pragma: no cover
41+
"""Resource 1."""
42+
return "r1"
43+
44+
@server.resource("test://r2")
45+
def res2() -> str: # pragma: no cover
46+
"""Resource 2."""
47+
return "r2"
48+
49+
@server.prompt()
50+
def prompt_a() -> str: # pragma: no cover
51+
"""Prompt A."""
52+
return "pa"
53+
54+
@server.resource("test://tmpl/{id}")
55+
def tmpl(id: str) -> str: # pragma: no cover
56+
"""Template."""
57+
return f"t-{id}"
58+
59+
return server
60+
61+
62+
async def test_list_all_tools_returns_all(multi_item_server: MCPServer) -> None:
63+
"""list_all_tools drains all pages and returns a flat result."""
64+
async with Client(multi_item_server, mode="legacy") as client:
65+
result = await client.list_all_tools()
66+
assert isinstance(result, ListToolsResult)
67+
assert len(result.tools) == 3
68+
assert result.next_cursor is None
69+
70+
71+
async def test_list_all_resources_returns_all(multi_item_server: MCPServer) -> None:
72+
"""list_all_resources drains all pages."""
73+
async with Client(multi_item_server, mode="legacy") as client:
74+
result = await client.list_all_resources()
75+
assert isinstance(result, ListResourcesResult)
76+
assert len(result.resources) == 2
77+
assert result.next_cursor is None
78+
79+
80+
async def test_list_all_prompts_returns_all(multi_item_server: MCPServer) -> None:
81+
"""list_all_prompts drains all pages."""
82+
async with Client(multi_item_server, mode="legacy") as client:
83+
result = await client.list_all_prompts()
84+
assert isinstance(result, ListPromptsResult)
85+
assert len(result.prompts) == 1
86+
assert result.next_cursor is None
87+
88+
89+
async def test_list_all_resource_templates_returns_all(multi_item_server: MCPServer) -> None:
90+
"""list_all_resource_templates drains all pages."""
91+
async with Client(multi_item_server, mode="legacy") as client:
92+
result = await client.list_all_resource_templates()
93+
assert isinstance(result, ListResourceTemplatesResult)
94+
assert len(result.resource_templates) == 1
95+
assert result.next_cursor is None
96+
97+
98+
async def test_list_all_empty_server() -> None:
99+
"""list_all_* on a server with no items returns empty lists."""
100+
server = MCPServer("empty-test")
101+
async with Client(server, mode="legacy") as client:
102+
tools = await client.list_all_tools()
103+
assert tools.tools == []
104+
assert tools.next_cursor is None
105+
106+
resources = await client.list_all_resources()
107+
assert resources.resources == []
108+
109+
prompts = await client.list_all_prompts()
110+
assert prompts.prompts == []
111+
112+
templates = await client.list_all_resource_templates()
113+
assert templates.resource_templates == []
114+
115+
116+
async def test_list_all_max_pages_exceeded() -> None:
117+
"""PaginationExceededError when the server doesn't terminate within max_pages."""
118+
server = MCPServer("infinite-test")
119+
120+
@server.tool()
121+
def dummy() -> str: # pragma: no cover
122+
"""A dummy tool."""
123+
return "x"
124+
125+
async with Client(server, mode="legacy", list_max_pages=2) as client:
126+
original = client.list_tools
127+
call_count = 0
128+
129+
async def infinite_list_tools(*, cursor: str | None = None, **kwargs: object) -> ListToolsResult:
130+
nonlocal call_count
131+
call_count += 1
132+
result = await original(cursor=cursor, **kwargs)
133+
result.next_cursor = f"cursor-{call_count}"
134+
return result
135+
136+
client.list_tools = infinite_list_tools # type: ignore[method-assign]
137+
with pytest.raises(PaginationExceededError, match=r"exceeded list_max_pages \(2\)") as exc_info:
138+
await client.list_all_tools()
139+
assert exc_info.value.method == "tools/list"
140+
assert exc_info.value.max_pages == 2
141+
142+
143+
async def test_list_all_cursor_cycle_detected() -> None:
144+
"""CursorCycleError when the server returns a repeated cursor."""
145+
server = MCPServer("cycle-test")
146+
147+
@server.tool()
148+
def dummy() -> str: # pragma: no cover
149+
"""A dummy tool."""
150+
return "x"
151+
152+
async with Client(server, mode="legacy", list_max_pages=0) as client:
153+
original = client.list_tools
154+
cursors = ["cursorA", "cursorB", "cursorA"] # cycle on 3rd call
155+
call_count = 0
156+
157+
async def cycling_list_tools(*, cursor: str | None = None, **kwargs: object) -> ListToolsResult:
158+
nonlocal call_count
159+
result = await original(cursor=cursor, **kwargs)
160+
if call_count < len(cursors):
161+
result.next_cursor = cursors[call_count]
162+
call_count += 1
163+
return result
164+
165+
client.list_tools = cycling_list_tools # type: ignore[method-assign]
166+
with pytest.raises(CursorCycleError, match=r"cursor cycle.*cursorA") as exc_info:
167+
await client.list_all_tools()
168+
assert exc_info.value.method == "tools/list"
169+
assert exc_info.value.cursor == "cursorA"
170+
171+
172+
async def test_list_all_unlimited_with_zero_max_pages() -> None:
173+
"""list_max_pages=0 disables the page cap (unlimited)."""
174+
server = MCPServer("unlimited-test")
175+
176+
@server.tool()
177+
def dummy() -> str: # pragma: no cover
178+
"""A dummy tool."""
179+
return "x"
180+
181+
async with Client(server, mode="legacy", list_max_pages=0) as client:
182+
result = await client.list_all_tools()
183+
assert isinstance(result, ListToolsResult)
184+
assert len(result.tools) == 1
185+
186+
187+
async def test_list_all_strips_terminal_cursor() -> None:
188+
"""The aggregated result has next_cursor=None."""
189+
server = MCPServer("strip-test")
190+
async with Client(server, mode="legacy") as client:
191+
result = await client.list_all_tools()
192+
assert result.next_cursor is None

0 commit comments

Comments
 (0)