Skip to content

Commit f0b43cb

Browse files
committed
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.
1 parent a7adecd commit f0b43cb

2 files changed

Lines changed: 119 additions & 7 deletions

File tree

src/mcp/client/client.py

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -953,6 +953,9 @@ async def iter_all_tools(self, *, meta: RequestParamsMeta | None = None) -> Asyn
953953
954954
Useful for streaming consumers that want to process tools without
955955
materializing the full list in memory.
956+
957+
Raises:
958+
RuntimeError: The server returned a pagination cursor that did not advance.
956959
"""
957960
cursor: str | None = None
958961
while True:
@@ -961,6 +964,10 @@ async def iter_all_tools(self, *, meta: RequestParamsMeta | None = None) -> Asyn
961964
yield tool
962965
if result.next_cursor is None:
963966
return
967+
if result.next_cursor == cursor:
968+
raise RuntimeError(
969+
"Server returned a pagination cursor that did not advance; refusing to page forever."
970+
)
964971
cursor = result.next_cursor
965972

966973
async def list_all_tools(self, *, meta: RequestParamsMeta | None = None) -> list[Tool]:
@@ -969,54 +976,93 @@ async def list_all_tools(self, *, meta: RequestParamsMeta | None = None) -> list
969976
Unlike `list_tools`, which returns one page, this walks pagination
970977
until the server reports no further pages and returns the combined
971978
list.
979+
980+
Raises:
981+
RuntimeError: The server returned a pagination cursor that did not advance.
972982
"""
973983
return [tool async for tool in self.iter_all_tools(meta=meta)]
974984

975985
async def iter_all_prompts(self, *, meta: RequestParamsMeta | None = None) -> AsyncIterator[Prompt]:
976-
"""Yield every prompt from the server, paging through `next_cursor`."""
986+
"""Yield every prompt from the server, paging through `next_cursor`.
987+
988+
Raises:
989+
RuntimeError: The server returned a pagination cursor that did not advance.
990+
"""
977991
cursor: str | None = None
978992
while True:
979993
result = await self.list_prompts(cursor=cursor, meta=meta)
980994
for prompt in result.prompts:
981995
yield prompt
982996
if result.next_cursor is None:
983997
return
998+
if result.next_cursor == cursor:
999+
raise RuntimeError(
1000+
"Server returned a pagination cursor that did not advance; refusing to page forever."
1001+
)
9841002
cursor = result.next_cursor
9851003

9861004
async def list_all_prompts(self, *, meta: RequestParamsMeta | None = None) -> list[Prompt]:
987-
"""List every prompt from the server, draining `next_cursor` across pages."""
1005+
"""List every prompt from the server, draining `next_cursor` across pages.
1006+
1007+
Raises:
1008+
RuntimeError: The server returned a pagination cursor that did not advance.
1009+
"""
9881010
return [prompt async for prompt in self.iter_all_prompts(meta=meta)]
9891011

9901012
async def iter_all_resources(self, *, meta: RequestParamsMeta | None = None) -> AsyncIterator[Resource]:
991-
"""Yield every resource from the server, paging through `next_cursor`."""
1013+
"""Yield every resource from the server, paging through `next_cursor`.
1014+
1015+
Raises:
1016+
RuntimeError: The server returned a pagination cursor that did not advance.
1017+
"""
9921018
cursor: str | None = None
9931019
while True:
9941020
result = await self.list_resources(cursor=cursor, meta=meta)
9951021
for resource in result.resources:
9961022
yield resource
9971023
if result.next_cursor is None:
9981024
return
1025+
if result.next_cursor == cursor:
1026+
raise RuntimeError(
1027+
"Server returned a pagination cursor that did not advance; refusing to page forever."
1028+
)
9991029
cursor = result.next_cursor
10001030

10011031
async def list_all_resources(self, *, meta: RequestParamsMeta | None = None) -> list[Resource]:
1002-
"""List every resource from the server, draining `next_cursor` across pages."""
1032+
"""List every resource from the server, draining `next_cursor` across pages.
1033+
1034+
Raises:
1035+
RuntimeError: The server returned a pagination cursor that did not advance.
1036+
"""
10031037
return [resource async for resource in self.iter_all_resources(meta=meta)]
10041038

10051039
async def iter_all_resource_templates(
10061040
self, *, meta: RequestParamsMeta | None = None
10071041
) -> AsyncIterator[ResourceTemplate]:
1008-
"""Yield every resource template from the server, paging through `next_cursor`."""
1042+
"""Yield every resource template from the server, paging through `next_cursor`.
1043+
1044+
Raises:
1045+
RuntimeError: The server returned a pagination cursor that did not advance.
1046+
"""
10091047
cursor: str | None = None
10101048
while True:
10111049
result = await self.list_resource_templates(cursor=cursor, meta=meta)
10121050
for template in result.resource_templates:
10131051
yield template
10141052
if result.next_cursor is None:
10151053
return
1054+
if result.next_cursor == cursor:
1055+
raise RuntimeError(
1056+
"Server returned a pagination cursor that did not advance; refusing to page forever."
1057+
)
10161058
cursor = result.next_cursor
10171059

10181060
async def list_all_resource_templates(self, *, meta: RequestParamsMeta | None = None) -> list[ResourceTemplate]:
1019-
"""List every resource template from the server, draining `next_cursor` across pages."""
1061+
"""List every resource template from the server, draining `next_cursor` across pages.
1062+
1063+
Raises:
1064+
RuntimeError: The server returned a pagination cursor that did not advance.
1065+
"""
10201066
return [template async for template in self.iter_all_resource_templates(meta=meta)]
10211067

10221068
@deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)

tests/client/test_list_all_pagination.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"""
99

1010
from collections.abc import Awaitable, Callable
11-
from typing import TypeVar
11+
from typing import Any, TypeVar
1212

1313
import mcp_types as types
1414
import pytest
@@ -54,6 +54,22 @@ async def handler(_ctx: ServerRequestContext, params: types.PaginatedRequestPara
5454
return handler
5555

5656

57+
def _stuck_cursor_handler(
58+
make_item: Callable[[str], ItemT],
59+
result_cls: Callable[..., ResultT],
60+
items_field: str,
61+
) -> Callable[[ServerRequestContext, types.PaginatedRequestParams | None], Awaitable[ResultT]]:
62+
"""Build a malformed handler that always returns the same non-null cursor.
63+
64+
A drain loop that trusts the cursor would page forever against this server.
65+
"""
66+
67+
async def handler(_ctx: ServerRequestContext, _params: types.PaginatedRequestParams | None) -> ResultT:
68+
return result_cls(**{items_field: [make_item("x")]}, next_cursor="stuck")
69+
70+
return handler
71+
72+
5773
def _make_tool(name: str) -> types.Tool:
5874
return types.Tool(name=name, input_schema={"type": "object"})
5975

@@ -205,3 +221,53 @@ async def test_list_all_resource_templates_drains_all_pages(
205221
templates = await client.list_all_resource_templates()
206222
assert [t.name for t in templates] == ["t1", "t2", "t3"]
207223
assert len(spies.get_client_requests(method="resources/templates/list")) == 2
224+
225+
226+
# ---- malformed server: non-advancing cursor --------------------------------
227+
228+
229+
@pytest.mark.parametrize(
230+
"build_server,client_method",
231+
[
232+
(
233+
lambda: Server(
234+
"stuck-tools",
235+
on_list_tools=_stuck_cursor_handler(_make_tool, types.ListToolsResult, "tools"),
236+
),
237+
"list_all_tools",
238+
),
239+
(
240+
lambda: Server(
241+
"stuck-prompts",
242+
on_list_prompts=_stuck_cursor_handler(_make_prompt, types.ListPromptsResult, "prompts"),
243+
),
244+
"list_all_prompts",
245+
),
246+
(
247+
lambda: Server(
248+
"stuck-resources",
249+
on_list_resources=_stuck_cursor_handler(_make_resource, types.ListResourcesResult, "resources"),
250+
),
251+
"list_all_resources",
252+
),
253+
(
254+
lambda: Server(
255+
"stuck-templates",
256+
on_list_resource_templates=_stuck_cursor_handler(
257+
_make_resource_template, types.ListResourceTemplatesResult, "resource_templates"
258+
),
259+
),
260+
"list_all_resource_templates",
261+
),
262+
],
263+
)
264+
async def test_drain_raises_when_cursor_does_not_advance(
265+
build_server: Callable[[], Server[Any]],
266+
client_method: str,
267+
):
268+
"""A server that keeps returning the same cursor must fail loudly, not loop forever."""
269+
server = build_server()
270+
271+
async with Client(server) as client:
272+
with pytest.raises(RuntimeError, match="did not advance"):
273+
await getattr(client, client_method)()

0 commit comments

Comments
 (0)