Skip to content

Commit 24d13df

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 5fa6700 commit 24d13df

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
@@ -595,6 +595,9 @@ async def iter_all_tools(self, *, meta: RequestParamsMeta | None = None) -> Asyn
595595
596596
Useful for streaming consumers that want to process tools without
597597
materializing the full list in memory.
598+
599+
Raises:
600+
RuntimeError: The server returned a pagination cursor that did not advance.
598601
"""
599602
cursor: str | None = None
600603
while True:
@@ -603,6 +606,10 @@ async def iter_all_tools(self, *, meta: RequestParamsMeta | None = None) -> Asyn
603606
yield tool
604607
if result.next_cursor is None:
605608
return
609+
if result.next_cursor == cursor:
610+
raise RuntimeError(
611+
"Server returned a pagination cursor that did not advance; refusing to page forever."
612+
)
606613
cursor = result.next_cursor
607614

608615
async def list_all_tools(self, *, meta: RequestParamsMeta | None = None) -> list[Tool]:
@@ -611,54 +618,93 @@ async def list_all_tools(self, *, meta: RequestParamsMeta | None = None) -> list
611618
Unlike `list_tools`, which returns one page, this walks pagination
612619
until the server reports no further pages and returns the combined
613620
list.
621+
622+
Raises:
623+
RuntimeError: The server returned a pagination cursor that did not advance.
614624
"""
615625
return [tool async for tool in self.iter_all_tools(meta=meta)]
616626

617627
async def iter_all_prompts(self, *, meta: RequestParamsMeta | None = None) -> AsyncIterator[Prompt]:
618-
"""Yield every prompt from the server, paging through `next_cursor`."""
628+
"""Yield every prompt from the server, paging through `next_cursor`.
629+
630+
Raises:
631+
RuntimeError: The server returned a pagination cursor that did not advance.
632+
"""
619633
cursor: str | None = None
620634
while True:
621635
result = await self.list_prompts(cursor=cursor, meta=meta)
622636
for prompt in result.prompts:
623637
yield prompt
624638
if result.next_cursor is None:
625639
return
640+
if result.next_cursor == cursor:
641+
raise RuntimeError(
642+
"Server returned a pagination cursor that did not advance; refusing to page forever."
643+
)
626644
cursor = result.next_cursor
627645

628646
async def list_all_prompts(self, *, meta: RequestParamsMeta | None = None) -> list[Prompt]:
629-
"""List every prompt from the server, draining `next_cursor` across pages."""
647+
"""List every prompt from the server, draining `next_cursor` across pages.
648+
649+
Raises:
650+
RuntimeError: The server returned a pagination cursor that did not advance.
651+
"""
630652
return [prompt async for prompt in self.iter_all_prompts(meta=meta)]
631653

632654
async def iter_all_resources(self, *, meta: RequestParamsMeta | None = None) -> AsyncIterator[Resource]:
633-
"""Yield every resource from the server, paging through `next_cursor`."""
655+
"""Yield every resource from the server, paging through `next_cursor`.
656+
657+
Raises:
658+
RuntimeError: The server returned a pagination cursor that did not advance.
659+
"""
634660
cursor: str | None = None
635661
while True:
636662
result = await self.list_resources(cursor=cursor, meta=meta)
637663
for resource in result.resources:
638664
yield resource
639665
if result.next_cursor is None:
640666
return
667+
if result.next_cursor == cursor:
668+
raise RuntimeError(
669+
"Server returned a pagination cursor that did not advance; refusing to page forever."
670+
)
641671
cursor = result.next_cursor
642672

643673
async def list_all_resources(self, *, meta: RequestParamsMeta | None = None) -> list[Resource]:
644-
"""List every resource from the server, draining `next_cursor` across pages."""
674+
"""List every resource from the server, draining `next_cursor` across pages.
675+
676+
Raises:
677+
RuntimeError: The server returned a pagination cursor that did not advance.
678+
"""
645679
return [resource async for resource in self.iter_all_resources(meta=meta)]
646680

647681
async def iter_all_resource_templates(
648682
self, *, meta: RequestParamsMeta | None = None
649683
) -> AsyncIterator[ResourceTemplate]:
650-
"""Yield every resource template from the server, paging through `next_cursor`."""
684+
"""Yield every resource template from the server, paging through `next_cursor`.
685+
686+
Raises:
687+
RuntimeError: The server returned a pagination cursor that did not advance.
688+
"""
651689
cursor: str | None = None
652690
while True:
653691
result = await self.list_resource_templates(cursor=cursor, meta=meta)
654692
for template in result.resource_templates:
655693
yield template
656694
if result.next_cursor is None:
657695
return
696+
if result.next_cursor == cursor:
697+
raise RuntimeError(
698+
"Server returned a pagination cursor that did not advance; refusing to page forever."
699+
)
658700
cursor = result.next_cursor
659701

660702
async def list_all_resource_templates(self, *, meta: RequestParamsMeta | None = None) -> list[ResourceTemplate]:
661-
"""List every resource template from the server, draining `next_cursor` across pages."""
703+
"""List every resource template from the server, draining `next_cursor` across pages.
704+
705+
Raises:
706+
RuntimeError: The server returned a pagination cursor that did not advance.
707+
"""
662708
return [template async for template in self.iter_all_resource_templates(meta=meta)]
663709

664710
@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)