Skip to content

Commit 64e46d9

Browse files
committed
Refresh the first page when draining paginated listings
The SEP-2549 response cache (2026-07-28) can serve a list verb's first page from cache, while continuation pages always go to the wire. A drain that started from a cached first page would pair that page's stale cursor with freshly fetched later pages and return a listing the server never served, which is the same silent-wrong-list failure the drains exist to prevent. The four list_all_* / iter_all_* pairs now take cache_mode and default it to "refresh" rather than inheriting the single-page "use", so a drain always starts from a current first page. "refresh" still writes that page back to the cache, so single-page callers keep the freshness-hint benefit. Pass cache_mode="use" to accept a cached first page instead. The existing drain tests run with mode="legacy", where the ttlMs hint is stripped on the wire and nothing is ever cached, so the two new tests run on the default 2026-07-28 path. AI disclosure: developed with AI assistance (Claude, Opus 5).
1 parent 0a10f10 commit 64e46d9

3 files changed

Lines changed: 164 additions & 16 deletions

File tree

docs/advanced/pagination.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,27 @@ That loop is the same one in every client that pages, so `Client` ships it. The
7171
repeats. A repeated cursor is a broken server, and a loud failure beats a silent hang or a
7272
half-read list.
7373

74+
### Drains and the response cache
75+
76+
A server may attach a `ttlMs` freshness hint to a list result (**[Caching](../client/caching.md)**), and the
77+
client will serve a later `list_*` call for that method from cache instead of going back to the
78+
server. Only the first page is ever cached; a call carrying a cursor always goes to the wire.
79+
80+
That split matters for a drain. If it started from a cached first page, it would take that
81+
page's `next_cursor` — minted against a listing that may since have changed — and pair it with
82+
freshly fetched later pages, returning a stitched-together listing the server never served. So
83+
the drains default to `cache_mode="refresh"`: the first page is re-fetched, and the fresh copy
84+
is written back to the cache for later single-page callers.
85+
86+
```python
87+
async def list_the_tools(client: Client) -> None:
88+
fresh = await client.list_all_tools() # re-fetches the first page: always current
89+
saved = await client.list_all_tools(cache_mode="use") # one fewer request, may be stale
90+
```
91+
92+
Pass `cache_mode="use"` when you would rather have the saved copy than the current one. The
93+
single-page `list_*` methods still default to `"use"`, unchanged.
94+
7495
## The three rules
7596

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

src/mcp/client/client.py

Lines changed: 74 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -964,20 +964,31 @@ async def list_tools(
964964
),
965965
)
966966

967-
async def iter_all_tools(self, *, meta: RequestParamsMeta | None = None) -> AsyncIterator[Tool]:
967+
async def iter_all_tools(
968+
self, *, meta: RequestParamsMeta | None = None, cache_mode: CacheMode = "refresh"
969+
) -> AsyncIterator[Tool]:
968970
"""Yield every tool from the server, paging through `next_cursor`.
969971
970972
Useful for streaming consumers that want to process tools without
971973
materializing the full list in memory.
972974
975+
Args:
976+
meta: Additional metadata for the request.
977+
cache_mode: Cache behavior for the first page (see `CacheMode`).
978+
Defaults to `"refresh"`, unlike the single-page `list_tools`:
979+
continuation pages bypass the cache unconditionally, so a
980+
cached first page would pair a stale cursor with freshly
981+
fetched later pages and silently return a listing the server
982+
never served. Pass `"use"` to accept a cached first page.
983+
973984
Raises:
974985
RuntimeError: The server returned a pagination cursor it already
975986
returned, which would page forever.
976987
"""
977988
seen_cursors: set[str] = set()
978989
cursor: str | None = None
979990
while True:
980-
result = await self.list_tools(cursor=cursor, meta=meta)
991+
result = await self.list_tools(cursor=cursor, meta=meta, cache_mode=cache_mode)
981992
for tool in result.tools:
982993
yield tool
983994
if result.next_cursor is None:
@@ -987,30 +998,44 @@ async def iter_all_tools(self, *, meta: RequestParamsMeta | None = None) -> Asyn
987998
seen_cursors.add(result.next_cursor)
988999
cursor = result.next_cursor
9891000

990-
async def list_all_tools(self, *, meta: RequestParamsMeta | None = None) -> list[Tool]:
1001+
async def list_all_tools(
1002+
self, *, meta: RequestParamsMeta | None = None, cache_mode: CacheMode = "refresh"
1003+
) -> list[Tool]:
9911004
"""List every tool from the server, draining `next_cursor` across pages.
9921005
9931006
Unlike `list_tools`, which returns one page, this walks pagination
9941007
until the server reports no further pages and returns the combined
9951008
list.
9961009
1010+
Args:
1011+
meta: Additional metadata for the request.
1012+
cache_mode: Cache behavior for the first page (see
1013+
`iter_all_tools`); defaults to `"refresh"`.
1014+
9971015
Raises:
9981016
RuntimeError: The server returned a pagination cursor it already
9991017
returned, which would page forever.
10001018
"""
1001-
return [tool async for tool in self.iter_all_tools(meta=meta)]
1019+
return [tool async for tool in self.iter_all_tools(meta=meta, cache_mode=cache_mode)]
10021020

1003-
async def iter_all_prompts(self, *, meta: RequestParamsMeta | None = None) -> AsyncIterator[Prompt]:
1021+
async def iter_all_prompts(
1022+
self, *, meta: RequestParamsMeta | None = None, cache_mode: CacheMode = "refresh"
1023+
) -> AsyncIterator[Prompt]:
10041024
"""Yield every prompt from the server, paging through `next_cursor`.
10051025
1026+
Args:
1027+
meta: Additional metadata for the request.
1028+
cache_mode: Cache behavior for the first page (see
1029+
`iter_all_tools`); defaults to `"refresh"`.
1030+
10061031
Raises:
10071032
RuntimeError: The server returned a pagination cursor it already
10081033
returned, which would page forever.
10091034
"""
10101035
seen_cursors: set[str] = set()
10111036
cursor: str | None = None
10121037
while True:
1013-
result = await self.list_prompts(cursor=cursor, meta=meta)
1038+
result = await self.list_prompts(cursor=cursor, meta=meta, cache_mode=cache_mode)
10141039
for prompt in result.prompts:
10151040
yield prompt
10161041
if result.next_cursor is None:
@@ -1020,26 +1045,40 @@ async def iter_all_prompts(self, *, meta: RequestParamsMeta | None = None) -> As
10201045
seen_cursors.add(result.next_cursor)
10211046
cursor = result.next_cursor
10221047

1023-
async def list_all_prompts(self, *, meta: RequestParamsMeta | None = None) -> list[Prompt]:
1048+
async def list_all_prompts(
1049+
self, *, meta: RequestParamsMeta | None = None, cache_mode: CacheMode = "refresh"
1050+
) -> list[Prompt]:
10241051
"""List every prompt from the server, draining `next_cursor` across pages.
10251052
1053+
Args:
1054+
meta: Additional metadata for the request.
1055+
cache_mode: Cache behavior for the first page (see
1056+
`iter_all_tools`); defaults to `"refresh"`.
1057+
10261058
Raises:
10271059
RuntimeError: The server returned a pagination cursor it already
10281060
returned, which would page forever.
10291061
"""
1030-
return [prompt async for prompt in self.iter_all_prompts(meta=meta)]
1062+
return [prompt async for prompt in self.iter_all_prompts(meta=meta, cache_mode=cache_mode)]
10311063

1032-
async def iter_all_resources(self, *, meta: RequestParamsMeta | None = None) -> AsyncIterator[Resource]:
1064+
async def iter_all_resources(
1065+
self, *, meta: RequestParamsMeta | None = None, cache_mode: CacheMode = "refresh"
1066+
) -> AsyncIterator[Resource]:
10331067
"""Yield every resource from the server, paging through `next_cursor`.
10341068
1069+
Args:
1070+
meta: Additional metadata for the request.
1071+
cache_mode: Cache behavior for the first page (see
1072+
`iter_all_tools`); defaults to `"refresh"`.
1073+
10351074
Raises:
10361075
RuntimeError: The server returned a pagination cursor it already
10371076
returned, which would page forever.
10381077
"""
10391078
seen_cursors: set[str] = set()
10401079
cursor: str | None = None
10411080
while True:
1042-
result = await self.list_resources(cursor=cursor, meta=meta)
1081+
result = await self.list_resources(cursor=cursor, meta=meta, cache_mode=cache_mode)
10431082
for resource in result.resources:
10441083
yield resource
10451084
if result.next_cursor is None:
@@ -1049,28 +1088,40 @@ async def iter_all_resources(self, *, meta: RequestParamsMeta | None = None) ->
10491088
seen_cursors.add(result.next_cursor)
10501089
cursor = result.next_cursor
10511090

1052-
async def list_all_resources(self, *, meta: RequestParamsMeta | None = None) -> list[Resource]:
1091+
async def list_all_resources(
1092+
self, *, meta: RequestParamsMeta | None = None, cache_mode: CacheMode = "refresh"
1093+
) -> list[Resource]:
10531094
"""List every resource from the server, draining `next_cursor` across pages.
10541095
1096+
Args:
1097+
meta: Additional metadata for the request.
1098+
cache_mode: Cache behavior for the first page (see
1099+
`iter_all_tools`); defaults to `"refresh"`.
1100+
10551101
Raises:
10561102
RuntimeError: The server returned a pagination cursor it already
10571103
returned, which would page forever.
10581104
"""
1059-
return [resource async for resource in self.iter_all_resources(meta=meta)]
1105+
return [resource async for resource in self.iter_all_resources(meta=meta, cache_mode=cache_mode)]
10601106

10611107
async def iter_all_resource_templates(
1062-
self, *, meta: RequestParamsMeta | None = None
1108+
self, *, meta: RequestParamsMeta | None = None, cache_mode: CacheMode = "refresh"
10631109
) -> AsyncIterator[ResourceTemplate]:
10641110
"""Yield every resource template from the server, paging through `next_cursor`.
10651111
1112+
Args:
1113+
meta: Additional metadata for the request.
1114+
cache_mode: Cache behavior for the first page (see
1115+
`iter_all_tools`); defaults to `"refresh"`.
1116+
10661117
Raises:
10671118
RuntimeError: The server returned a pagination cursor it already
10681119
returned, which would page forever.
10691120
"""
10701121
seen_cursors: set[str] = set()
10711122
cursor: str | None = None
10721123
while True:
1073-
result = await self.list_resource_templates(cursor=cursor, meta=meta)
1124+
result = await self.list_resource_templates(cursor=cursor, meta=meta, cache_mode=cache_mode)
10741125
for template in result.resource_templates:
10751126
yield template
10761127
if result.next_cursor is None:
@@ -1080,14 +1131,21 @@ async def iter_all_resource_templates(
10801131
seen_cursors.add(result.next_cursor)
10811132
cursor = result.next_cursor
10821133

1083-
async def list_all_resource_templates(self, *, meta: RequestParamsMeta | None = None) -> list[ResourceTemplate]:
1134+
async def list_all_resource_templates(
1135+
self, *, meta: RequestParamsMeta | None = None, cache_mode: CacheMode = "refresh"
1136+
) -> list[ResourceTemplate]:
10841137
"""List every resource template from the server, draining `next_cursor` across pages.
10851138
1139+
Args:
1140+
meta: Additional metadata for the request.
1141+
cache_mode: Cache behavior for the first page (see
1142+
`iter_all_tools`); defaults to `"refresh"`.
1143+
10861144
Raises:
10871145
RuntimeError: The server returned a pagination cursor it already
10881146
returned, which would page forever.
10891147
"""
1090-
return [template async for template in self.iter_all_resource_templates(meta=meta)]
1148+
return [template async for template in self.iter_all_resource_templates(meta=meta, cache_mode=cache_mode)]
10911149

10921150
@deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
10931151
async def send_roots_list_changed(self) -> None:

tests/client/test_list_all_pagination.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,3 +306,72 @@ async def test_drain_raises_when_cursors_cycle():
306306
async with Client(server) as client:
307307
with pytest.raises(RuntimeError, match="already returned"):
308308
await client.list_all_tools()
309+
310+
311+
# ---- interaction with the SEP-2549 response cache --------------------------
312+
#
313+
# These run on the default (2026-07-28) client path rather than `mode="legacy"`
314+
# used above: `ttlMs` is a 2026-07-28 field, so the legacy wire path strips the
315+
# server's freshness hint and nothing is ever cached.
316+
317+
318+
def _relisting_tools_server(state: dict[str, int]) -> Server[Any]:
319+
"""Build a server whose tool listing is replaced when `state["version"]` flips to 2.
320+
321+
Version 1 serves [a, b] with cursor "1"; version 2 serves [x, y] -> [z].
322+
Version 2 still honors version 1's cursor, so a drain that starts from a
323+
stale first page gets a plausible answer rather than an error. Both tests
324+
flip to version 2 before draining, so version 1 only ever serves page one.
325+
"""
326+
# A freshness hint is what makes the client cache the first page at all (SEP-2549).
327+
hint: dict[str, Any] = {"ttl_ms": 60_000, "cache_scope": "private"}
328+
329+
async def handle_list_tools(
330+
_ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
331+
) -> types.ListToolsResult:
332+
cursor = params.cursor if params else None
333+
if state["version"] == 1:
334+
assert cursor is None
335+
return types.ListToolsResult(tools=[_make_tool("a"), _make_tool("b")], next_cursor="1", **hint)
336+
if cursor is None:
337+
return types.ListToolsResult(tools=[_make_tool("x"), _make_tool("y")], next_cursor="2", **hint)
338+
if cursor == "1": # version 1's cursor, carried over on a stale first page
339+
return types.ListToolsResult(tools=[_make_tool("c")], **hint)
340+
assert cursor == "2"
341+
return types.ListToolsResult(tools=[_make_tool("z")], **hint)
342+
343+
return Server("relisting-tools", on_list_tools=handle_list_tools)
344+
345+
346+
async def test_drain_refetches_a_cached_first_page():
347+
"""A drain returns the current listing even when the first page is already cached.
348+
349+
Continuation pages bypass the response cache, so serving a cached first page
350+
would pair its stale cursor with freshly fetched later pages and return a
351+
listing the server never served. SDK-defined: the drains default to
352+
`cache_mode="refresh"` to avoid that.
353+
"""
354+
state = {"version": 1}
355+
server = _relisting_tools_server(state)
356+
357+
async with Client(server) as client:
358+
await client.list_tools() # caches page 0 of the first listing
359+
state["version"] = 2
360+
361+
assert [t.name for t in await client.list_all_tools()] == ["x", "y", "z"]
362+
363+
364+
async def test_drain_reuses_a_cached_first_page_when_asked():
365+
"""`cache_mode="use"` opts back into serving the drain's first page from cache.
366+
367+
SDK-defined escape hatch: the caller accepts a stale first page (and the
368+
stale cursor it carries) in exchange for one fewer request.
369+
"""
370+
state = {"version": 1}
371+
server = _relisting_tools_server(state)
372+
373+
async with Client(server) as client:
374+
await client.list_tools() # caches page 0 of the first listing
375+
state["version"] = 2
376+
377+
assert [t.name for t in await client.list_all_tools(cache_mode="use")] == ["a", "b", "c"]

0 commit comments

Comments
 (0)