Skip to content

Commit 15d146f

Browse files
committed
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.
1 parent 24d13df commit 15d146f

3 files changed

Lines changed: 69 additions & 1 deletion

File tree

docs/advanced/pagination.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,26 @@ Run its `main()` and it prints `100 resources`: ten pages of ten, stitched toget
5050

5151
This is the same loop **[The Client](../client/index.md)** chapter showed you, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once.
5252

53+
## Draining in one call
54+
55+
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:
56+
57+
```python title="client.py" hl_lines="27 31"
58+
--8<-- "docs_src/pagination/tutorial003.py"
59+
```
60+
61+
* `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`.
62+
* `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.
63+
* 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.
64+
65+
`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)**.
66+
67+
!!! 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.
72+
5373
## The three rules
5474

5575
**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.

docs_src/pagination/tutorial003.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from typing import Any
2+
3+
from mcp_types import ListResourcesResult, PaginatedRequestParams, Resource
4+
5+
from mcp import Client
6+
from mcp.server import Server, ServerRequestContext
7+
8+
BOOKS = [f"book-{n}" for n in range(1, 101)]
9+
10+
PAGE_SIZE = 10
11+
12+
13+
async def list_books(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListResourcesResult:
14+
start = 0 if params is None or params.cursor is None else int(params.cursor)
15+
end = start + PAGE_SIZE
16+
page = [Resource(uri=f"books://catalog/{name}", name=name) for name in BOOKS[start:end]]
17+
next_cursor = str(end) if end < len(BOOKS) else None
18+
return ListResourcesResult(resources=page, next_cursor=next_cursor)
19+
20+
21+
server = Server("Bookshop", on_list_resources=list_books)
22+
23+
24+
async def main() -> None:
25+
async with Client(server) as client:
26+
# Every page, stitched into one list.
27+
resources = await client.list_all_resources()
28+
print(f"{len(resources)} resources")
29+
30+
# Or stream them, and stop as soon as you have what you need.
31+
async for resource in client.iter_all_resources():
32+
print(f"first: {resource.name}")
33+
break

tests/docs_src/test_pagination.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pytest
44
from mcp_types import Resource
55

6-
from docs_src.pagination import tutorial001, tutorial002
6+
from docs_src.pagination import tutorial001, tutorial002, tutorial003
77
from mcp import Client, MCPError
88
from mcp.server import MCPServer
99
from mcp.server.mcpserver.resources import TextResource
@@ -71,6 +71,21 @@ async def test_the_client_program_on_the_page_runs(capsys: pytest.CaptureFixture
7171
assert capsys.readouterr().out == "100 resources\n"
7272

7373

74+
async def test_list_all_stitches_the_whole_catalog() -> None:
75+
"""tutorial003: `list_all_resources` drains every page into one list, no cursor handling."""
76+
async with Client(tutorial003.server) as client:
77+
resources = await client.list_all_resources()
78+
assert len(resources) == 100
79+
assert resources[0].name == "book-1"
80+
assert resources[-1].name == "book-100"
81+
82+
83+
async def test_the_drain_helpers_program_runs(capsys: pytest.CaptureFixture[str]) -> None:
84+
"""tutorial003: `main()` stitches all pages, then streams and stops at the first."""
85+
await tutorial003.main()
86+
assert capsys.readouterr().out == "100 resources\nfirst: book-1\n"
87+
88+
7489
async def test_an_invented_cursor_is_an_error() -> None:
7590
"""Cursors are opaque: a string the server never minted blows up inside the handler."""
7691
async with Client(tutorial001.server) as client:

0 commit comments

Comments
 (0)