Skip to content

Commit 0a10f10

Browse files
committed
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.
1 parent 4913630 commit 0a10f10

5 files changed

Lines changed: 80 additions & 33 deletions

File tree

docs/advanced/pagination.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,11 @@ That loop is the same one in every client that pages, so `Client` ships it. The
6565
`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)**.
6666

6767
!!! warning
68-
A drain trusts the server to advance the cursor. A server that keeps returning the same
69-
`next_cursor` it was handed would page forever, so the drains stop and raise `RuntimeError`
70-
the moment a cursor fails to move. A page that does not advance is a broken server, and a
71-
loud failure beats a silent hang or a half-read list.
68+
A drain trusts the server to advance the cursor. A server that echoes back the
69+
`next_cursor` it was handed, or cycles through a longer loop of them, would page forever,
70+
so the drains remember every cursor they have seen and raise `RuntimeError` the moment one
71+
repeats. A repeated cursor is a broken server, and a loud failure beats a silent hang or a
72+
half-read list.
7273

7374
## The three rules
7475

src/mcp/client/client.py

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -971,19 +971,20 @@ async def iter_all_tools(self, *, meta: RequestParamsMeta | None = None) -> Asyn
971971
materializing the full list in memory.
972972
973973
Raises:
974-
RuntimeError: The server returned a pagination cursor that did not advance.
974+
RuntimeError: The server returned a pagination cursor it already
975+
returned, which would page forever.
975976
"""
977+
seen_cursors: set[str] = set()
976978
cursor: str | None = None
977979
while True:
978980
result = await self.list_tools(cursor=cursor, meta=meta)
979981
for tool in result.tools:
980982
yield tool
981983
if result.next_cursor is None:
982984
return
983-
if result.next_cursor == cursor:
984-
raise RuntimeError(
985-
"Server returned a pagination cursor that did not advance; refusing to page forever."
986-
)
985+
if result.next_cursor in seen_cursors:
986+
raise RuntimeError("Server returned a pagination cursor it already returned; refusing to page forever.")
987+
seen_cursors.add(result.next_cursor)
987988
cursor = result.next_cursor
988989

989990
async def list_all_tools(self, *, meta: RequestParamsMeta | None = None) -> list[Tool]:
@@ -994,61 +995,66 @@ async def list_all_tools(self, *, meta: RequestParamsMeta | None = None) -> list
994995
list.
995996
996997
Raises:
997-
RuntimeError: The server returned a pagination cursor that did not advance.
998+
RuntimeError: The server returned a pagination cursor it already
999+
returned, which would page forever.
9981000
"""
9991001
return [tool async for tool in self.iter_all_tools(meta=meta)]
10001002

10011003
async def iter_all_prompts(self, *, meta: RequestParamsMeta | None = None) -> AsyncIterator[Prompt]:
10021004
"""Yield every prompt from the server, paging through `next_cursor`.
10031005
10041006
Raises:
1005-
RuntimeError: The server returned a pagination cursor that did not advance.
1007+
RuntimeError: The server returned a pagination cursor it already
1008+
returned, which would page forever.
10061009
"""
1010+
seen_cursors: set[str] = set()
10071011
cursor: str | None = None
10081012
while True:
10091013
result = await self.list_prompts(cursor=cursor, meta=meta)
10101014
for prompt in result.prompts:
10111015
yield prompt
10121016
if result.next_cursor is None:
10131017
return
1014-
if result.next_cursor == cursor:
1015-
raise RuntimeError(
1016-
"Server returned a pagination cursor that did not advance; refusing to page forever."
1017-
)
1018+
if result.next_cursor in seen_cursors:
1019+
raise RuntimeError("Server returned a pagination cursor it already returned; refusing to page forever.")
1020+
seen_cursors.add(result.next_cursor)
10181021
cursor = result.next_cursor
10191022

10201023
async def list_all_prompts(self, *, meta: RequestParamsMeta | None = None) -> list[Prompt]:
10211024
"""List every prompt from the server, draining `next_cursor` across pages.
10221025
10231026
Raises:
1024-
RuntimeError: The server returned a pagination cursor that did not advance.
1027+
RuntimeError: The server returned a pagination cursor it already
1028+
returned, which would page forever.
10251029
"""
10261030
return [prompt async for prompt in self.iter_all_prompts(meta=meta)]
10271031

10281032
async def iter_all_resources(self, *, meta: RequestParamsMeta | None = None) -> AsyncIterator[Resource]:
10291033
"""Yield every resource from the server, paging through `next_cursor`.
10301034
10311035
Raises:
1032-
RuntimeError: The server returned a pagination cursor that did not advance.
1036+
RuntimeError: The server returned a pagination cursor it already
1037+
returned, which would page forever.
10331038
"""
1039+
seen_cursors: set[str] = set()
10341040
cursor: str | None = None
10351041
while True:
10361042
result = await self.list_resources(cursor=cursor, meta=meta)
10371043
for resource in result.resources:
10381044
yield resource
10391045
if result.next_cursor is None:
10401046
return
1041-
if result.next_cursor == cursor:
1042-
raise RuntimeError(
1043-
"Server returned a pagination cursor that did not advance; refusing to page forever."
1044-
)
1047+
if result.next_cursor in seen_cursors:
1048+
raise RuntimeError("Server returned a pagination cursor it already returned; refusing to page forever.")
1049+
seen_cursors.add(result.next_cursor)
10451050
cursor = result.next_cursor
10461051

10471052
async def list_all_resources(self, *, meta: RequestParamsMeta | None = None) -> list[Resource]:
10481053
"""List every resource from the server, draining `next_cursor` across pages.
10491054
10501055
Raises:
1051-
RuntimeError: The server returned a pagination cursor that did not advance.
1056+
RuntimeError: The server returned a pagination cursor it already
1057+
returned, which would page forever.
10521058
"""
10531059
return [resource async for resource in self.iter_all_resources(meta=meta)]
10541060

@@ -1058,26 +1064,28 @@ async def iter_all_resource_templates(
10581064
"""Yield every resource template from the server, paging through `next_cursor`.
10591065
10601066
Raises:
1061-
RuntimeError: The server returned a pagination cursor that did not advance.
1067+
RuntimeError: The server returned a pagination cursor it already
1068+
returned, which would page forever.
10621069
"""
1070+
seen_cursors: set[str] = set()
10631071
cursor: str | None = None
10641072
while True:
10651073
result = await self.list_resource_templates(cursor=cursor, meta=meta)
10661074
for template in result.resource_templates:
10671075
yield template
10681076
if result.next_cursor is None:
10691077
return
1070-
if result.next_cursor == cursor:
1071-
raise RuntimeError(
1072-
"Server returned a pagination cursor that did not advance; refusing to page forever."
1073-
)
1078+
if result.next_cursor in seen_cursors:
1079+
raise RuntimeError("Server returned a pagination cursor it already returned; refusing to page forever.")
1080+
seen_cursors.add(result.next_cursor)
10741081
cursor = result.next_cursor
10751082

10761083
async def list_all_resource_templates(self, *, meta: RequestParamsMeta | None = None) -> list[ResourceTemplate]:
10771084
"""List every resource template from the server, draining `next_cursor` across pages.
10781085
10791086
Raises:
1080-
RuntimeError: The server returned a pagination cursor that did not advance.
1087+
RuntimeError: The server returned a pagination cursor it already
1088+
returned, which would page forever.
10811089
"""
10821090
return [template async for template in self.iter_all_resource_templates(meta=meta)]
10831091

src/mcp/client/session_group.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,11 @@ async def _drain_paginated(
7878
the list attribute on the result (e.g. `"tools"`, `"prompts"`).
7979
8080
Raises:
81-
RuntimeError: The server returned a pagination cursor that did not advance.
81+
RuntimeError: The server returned a pagination cursor it already
82+
returned, which would page forever.
8283
"""
8384
items: list[Any] = []
85+
seen_cursors: set[str] = set()
8486
cursor: str | None = None
8587
while True:
8688
params = types.PaginatedRequestParams(cursor=cursor) if cursor is not None else None
@@ -89,8 +91,9 @@ async def _drain_paginated(
8991
next_cursor = getattr(result, "next_cursor", None)
9092
if next_cursor is None:
9193
return items
92-
if next_cursor == cursor:
93-
raise RuntimeError("Server returned a pagination cursor that did not advance; refusing to page forever.")
94+
if next_cursor in seen_cursors:
95+
raise RuntimeError("Server returned a pagination cursor it already returned; refusing to page forever.")
96+
seen_cursors.add(next_cursor)
9497
cursor = next_cursor
9598

9699

tests/client/test_list_all_pagination.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,25 @@ async def handler(_ctx: ServerRequestContext, _params: types.PaginatedRequestPar
7070
return handler
7171

7272

73+
def _cycling_cursor_handler(
74+
make_item: Callable[[str], ItemT],
75+
result_cls: Callable[..., ResultT],
76+
items_field: str,
77+
) -> Callable[[ServerRequestContext, types.PaginatedRequestParams | None], Awaitable[ResultT]]:
78+
"""Build a malformed handler that alternates between two cursors forever.
79+
80+
The cursor always advances relative to the previous page, so a guard that
81+
only compares against the immediately preceding cursor would page forever.
82+
"""
83+
84+
async def handler(_ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ResultT:
85+
cursor = params.cursor if params else None
86+
next_cursor = "b" if cursor == "a" else "a"
87+
return result_cls(**{items_field: [make_item("x")]}, next_cursor=next_cursor)
88+
89+
return handler
90+
91+
7392
def _make_tool(name: str) -> types.Tool:
7493
return types.Tool(name=name, input_schema={"type": "object"})
7594

@@ -269,5 +288,21 @@ async def test_drain_raises_when_cursor_does_not_advance(
269288
server = build_server()
270289

271290
async with Client(server) as client:
272-
with pytest.raises(RuntimeError, match="did not advance"):
291+
with pytest.raises(RuntimeError, match="already returned"):
273292
await getattr(client, client_method)()
293+
294+
295+
async def test_drain_raises_when_cursors_cycle():
296+
"""A server whose cursors cycle (a, b, a, ...) must fail loudly, not loop forever.
297+
298+
Each cursor differs from the one before it, so this specifically exercises
299+
the seen-set guard rather than the simpler stuck-cursor case above.
300+
"""
301+
server = Server(
302+
"cycling-tools",
303+
on_list_tools=_cycling_cursor_handler(_make_tool, types.ListToolsResult, "tools"),
304+
)
305+
306+
async with Client(server) as client:
307+
with pytest.raises(RuntimeError, match="already returned"):
308+
await client.list_all_tools()

tests/client/test_session_group.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,5 +463,5 @@ async def test_client_session_group_rejects_non_advancing_cursor(
463463

464464
group = ClientSessionGroup(exit_stack=mock_exit_stack)
465465
with mock.patch.object(group, "_establish_session", return_value=(mock_server_info, mock_session)):
466-
with pytest.raises(RuntimeError, match="did not advance"):
466+
with pytest.raises(RuntimeError, match="already returned"):
467467
await group.connect_to_server(StdioServerParameters(command="test"))

0 commit comments

Comments
 (0)