Skip to content

Add list_all_* helpers that drain pagination on the client - #3021

Open
CJGjr wants to merge 7 commits into
modelcontextprotocol:mainfrom
CJGjr:list-all-pagination-v2
Open

Add list_all_* helpers that drain pagination on the client#3021
CJGjr wants to merge 7 commits into
modelcontextprotocol:mainfrom
CJGjr:list-all-pagination-v2

Conversation

@CJGjr

@CJGjrCJGjr commented Jun 29, 2026

Copy link
Copy Markdown

Closes#2556.

This continues #2658 by @adityasingh2400, which implemented the helpers but went stale against the v2 rework on main. I opened the original issue, so I rebased the work onto current main, added a guard, and am putting it up as a ready-to-merge PR. Aditya's authorship is preserved on the feature commit. Happy to close this in favor of an updated #2658 if he would rather carry it forward.

What this adds

  • list_all_tools / list_all_prompts / list_all_resources / list_all_resource_templates on Client: each walks next_cursor until the server reports no more pages and returns the combined list.
  • iter_all_tools / iter_all_prompts / iter_all_resources / iter_all_resource_templates: async iterators for streaming consumers that do not want to materialize every page.
  • The single-page list_* methods get docstrings pointing at the new drains.
  • ClientSessionGroup aggregation drains pagination, so multi-server consumers see the full collection instead of only page 0.

The single-page primitives are unchanged; the drains are opt-in.

Non-advancing cursor guard

A server that keeps returning the same cursor it was handed would make a naive drain loop page forever. The loops raise RuntimeError when a cursor repeats, rather than looping or silently truncating (silent truncation being the exact failure the issue describes). Documented in each helper's Raises: section and covered by a parametrized test across tools, prompts, resources, and templates, plus a cycling-cursor case. This addresses the malformed-server concern @agaonker raised on the issue. Easy to switch to a quiet stop instead if that is preferred.

Interaction with the SEP-2549 response cache

#3164 landed the SEP-2549 response cache while this PR was open, and it interacts badly with draining. Client.list_* can now serve a first page from cache, but _cached_fetch returns early for any request carrying a cursor, so continuation pages always go to the wire. A drain starting from a cached first page therefore pairs that page's stale next_cursor with freshly fetched later pages and returns a stitched-together listing that never existed on the server. That is the same silent-wrong-list failure this PR exists to prevent.

It needs no client configuration to hit: a stock Client against a server that advertises ttlMs on tools/list, which is what SEP-2549 asks servers to do, is enough.

So the four list_all_* / iter_all_* pairs take cache_mode and default it to "refresh" rather than inheriting the single-page "use". "refresh" still writes the fetched page back to the cache, so single-page callers keep the SEP-2549 benefit and the drain pays only the one request it cannot avoid. cache_mode="use" opts back into a cached first page.

Considered and rejected: refreshing only when the cached first page carries a next_cursor, since a cached single-page listing is internally coherent. It saves a request in the common case but costs two first-page fetches on a cache miss with pagination.

ClientSessionGroup is unaffected here: _drain_paginated calls session.list_* directly and never passes through the response cache.

Notes on the v2 rebase

The original PR predated the v2 rework. Bringing it current meant: types import from mcp_types, the cursor-spy tests run with mode="legacy" (the default client path is now streamless, so the wire spy has nothing to observe otherwise), and the test Tool carries a valid input_schema now that type is required. The helper logic itself is unchanged.

Note that mode="legacy" is also why the cache interaction above went unnoticed at first: ttlMs is a 2026-07-28 field, so the legacy wire path strips the server's hint and nothing is ever cached. The two cache tests run on the default 2026-07-28 path and assert on returned items rather than the wire spy.

Docs

docs/advanced/pagination.md gets a "Draining in one call" section covering list_all_*/iter_all_*, the ClientSessionGroup behavior, and the cursor guard, plus a "Drains and the response cache" subsection on the cache_mode default. It is backed by a runnable docs_src/pagination/tutorial003.py snippet with matching tests, so the page keeps proving every claim against the SDK.

Verification

  • uv run pytest: 5603 passed, 10 skipped, 1 xfailed
  • ./scripts/test: 100% coverage (branch), strict-no-cover clean
  • uv run pyright clean
  • uv run ruff check . && uv run ruff format --check . clean
  • pre-commit clean (prettier, markdownlint, ruff)

AI disclosure: developed with AI assistance (Claude, Fable 5 and Opus 5). I have read and tested every change and can speak to all of it myself.

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 7 files

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment threadsrc/mcp/client/session_group.py
Comment threaddocs/advanced/pagination.md Outdated
Comment threadsrc/mcp/client/client.py
adityasingh2400and others added 7 commits August 6, 2026 08:36
Currently Client.list_tools / list_prompts / list_resources /
list_resource_templates return a single page and the caller has to loop
on next_cursor manually. Add list_all_tools / list_all_prompts /
list_all_resources / list_all_resource_templates that walk next_cursor
until exhausted, plus iter_all_* async iterators for streaming
consumers. The single-page methods get a docstring update pointing at
the new drains. ClientSessionGroup switches its tool/prompt/resource
aggregation to the drain helper so its consumers always see the full
collection across multi-page servers.
Implements the helper maxisbey endorsed in modelcontextprotocol#2556.
Rebased onto the v2 rework: types import from mcp_types, the stream-spy
tests run in legacy mode, and the test Tool carries a valid input_schema.
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.
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.
The pagination guide says every drain fails loudly when a server returns
the same cursor it was handed, but the group aggregator's drain loop had
no such guard and would page forever. Guard it the same way the Client
drains are guarded.
The page lives at client/session-groups.md; the relative link broke when
the guide moved under docs/advanced/ during the rebase, and zensical
build --strict rejects it.
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.
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).
@CJGjr
CJGjrforce-pushed the list-all-pagination-v2 branch from 2b78338 to 64e46d9CompareAugust 7, 2026 18:41
@CJGjr

CJGjr commented Aug 7, 2026

Copy link
Copy Markdown
Author

Rebased onto current main (a4f4ccd0), which surfaced a real bug. Fixed in the commit on top.

list_all_* could return a listing the server never served

#3164 landed the SEP-2549 response cache while this PR was open. Client.list_* can now serve a first page from cache, but _cached_fetch returns early for any request carrying a cursor, so continuation pages always go to the wire. A drain starting from a cached first page therefore pairs that page's stale next_cursor with freshly fetched later pages and returns a stitched-together listing that never existed on the server. That is the same silent-wrong-list failure #2556 is about.

It needs no client configuration to hit. A stock Client against a server that advertises ttlMs on tools/list, which is exactly what SEP-2549 asks servers to do:

negotiated = 2026-07-28
warm page 1 = ['a', 'b'] next_cursor='1'
drain = ['a', 'b', 'c'] # the server's current listing is ['x', 'y', 'z']

The four list_all_* / iter_all_* pairs now take cache_mode and default it to "refresh" rather than inheriting the single-page "use". "refresh" still writes the fetched page back to the cache, so single-page callers keep the SEP-2549 benefit and the drain pays only the one request it cannot avoid. cache_mode="use" opts back into a cached first page.

Considered and rejected: refreshing only when the cached first page carries a next_cursor, since a cached single-page listing is internally coherent. It saves a request in the common case but costs two first-page fetches on a cache miss with pagination, which did not seem worth the extra state.

Two notes for reviewers:

  • The existing drain tests run with mode="legacy", where ttlMs is stripped on the wire so nothing is ever cached. They are structurally blind to this path, which is why they stayed green. The two new tests run on the default 2026-07-28 path and assert on returned items rather than the wire spy.
  • ClientSessionGroup is unaffected. _drain_paginated calls session.list_* directly and never passes through the response cache.

Verification

  • uv run pytest: 5603 passed, 10 skipped, 1 xfailed
  • ./scripts/test: 100% coverage, strict-no-cover clean
  • uv run pyright and uv run ruff check . && uv run ruff format --check . clean
  • pre-commit clean (prettier, markdownlint, ruff)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add list_all_* helpers to drain pagination

2 participants

@CJGjr@adityasingh2400