Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/advanced/pagination.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,48 @@ Run its `main()` and it prints `100 resources`: ten pages of ten, stitched toget

This is the same loop **[The Client](../client/index.md)** shows for every `list_*` verb, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once.

## Draining in one call

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:

```python title="client.py" hl_lines="27 31"
--8<-- "docs_src/pagination/tutorial003.py"
```

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

`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)**.

!!! warning
A drain trusts the server to advance the cursor. A server that echoes back the
`next_cursor` it was handed, or cycles through a longer loop of them, would page forever,
so the drains remember every cursor they have seen and raise `RuntimeError` the moment one
repeats. A repeated cursor is a broken server, and a loud failure beats a silent hang or a
half-read list.

### Drains and the response cache

A server may attach a `ttlMs` freshness hint to a list result (**[Caching](../client/caching.md)**), and the
client will serve a later `list_*` call for that method from cache instead of going back to the
server. Only the first page is ever cached; a call carrying a cursor always goes to the wire.

That split matters for a drain. If it started from a cached first page, it would take that
page's `next_cursor` — minted against a listing that may since have changed — and pair it with
freshly fetched later pages, returning a stitched-together listing the server never served. So
the drains default to `cache_mode="refresh"`: the first page is re-fetched, and the fresh copy
is written back to the cache for later single-page callers.

```python
async def list_the_tools(client: Client) -> None:
fresh = await client.list_all_tools() # re-fetches the first page: always current
saved = await client.list_all_tools(cache_mode="use") # one fewer request, may be stale
```

Pass `cache_mode="use"` when you would rather have the saved copy than the current one. The
single-page `list_*` methods still default to `"use"`, unchanged.

## The three rules

**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.
Expand Down
33 changes: 33 additions & 0 deletions docs_src/pagination/tutorial003.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from typing import Any

from mcp_types import ListResourcesResult, PaginatedRequestParams, Resource

from mcp import Client
from mcp.server import Server, ServerRequestContext

BOOKS = [f"book-{n}" for n in range(1, 101)]

PAGE_SIZE = 10


async def list_books(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListResourcesResult:
start = 0 if params is None or params.cursor is None else int(params.cursor)
end = start + PAGE_SIZE
page = [Resource(uri=f"books://catalog/{name}", name=name) for name in BOOKS[start:end]]
next_cursor = str(end) if end < len(BOOKS) else None
return ListResourcesResult(resources=page, next_cursor=next_cursor)


server = Server("Bookshop", on_list_resources=list_books)


async def main() -> None:
async with Client(server) as client:
# Every page, stitched into one list.
resources = await client.list_all_resources()
print(f"{len(resources)} resources")

# Or stream them, and stop as soon as you have what you need.
async for resource in client.iter_all_resources():
print(f"first: {resource.name}")
break
214 changes: 209 additions & 5 deletions src/mcp/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import hashlib
import logging
import uuid
from collections.abc import Awaitable, Callable, Mapping, Sequence
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
from contextlib import AbstractAsyncContextManager, AsyncExitStack
from dataclasses import KW_ONLY, dataclass, field
from typing import Any, Literal, TypeVar, cast
Expand All @@ -32,12 +32,16 @@
ListToolsResult,
LoggingLevel,
PaginatedRequestParams,
Prompt,
PromptReference,
ReadResourceResult,
RequestParamsMeta,
Resource,
ResourceTemplate,
ResourceTemplateReference,
Result,
ServerCapabilities,
Tool,
)
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
from typing_extensions import deprecated
Expand Down Expand Up @@ -596,7 +600,11 @@ async def list_resources(
meta: RequestParamsMeta | None = None,
cache_mode: CacheMode = "use",
) -> ListResourcesResult:
"""List available resources from the server."""
"""List a single page of available resources from the server.

Returns one page only. The result may include a `next_cursor` if more
pages are available. Use `list_all_resources` to drain every page.
"""
return await self._cached_fetch(
"resources/list",
cursor=cursor,
Expand All @@ -612,7 +620,12 @@ async def list_resource_templates(
meta: RequestParamsMeta | None = None,
cache_mode: CacheMode = "use",
) -> ListResourceTemplatesResult:
"""List available resource templates from the server."""
"""List a single page of available resource templates from the server.

Returns one page only. The result may include a `next_cursor` if more
pages are available. Use `list_all_resource_templates` to drain every
page.
"""
return await self._cached_fetch(
"resources/templates/list",
cursor=cursor,
Expand Down Expand Up @@ -830,7 +843,11 @@ async def list_prompts(
meta: RequestParamsMeta | None = None,
cache_mode: CacheMode = "use",
) -> ListPromptsResult:
"""List available prompts from the server."""
"""List a single page of available prompts from the server.

Returns one page only. The result may include a `next_cursor` if more
pages are available. Use `list_all_prompts` to drain every page.
"""
return await self._cached_fetch(
"prompts/list",
cursor=cursor,
Expand Down Expand Up @@ -928,7 +945,11 @@ async def list_tools(
meta: RequestParamsMeta | None = None,
cache_mode: CacheMode = "use",
) -> ListToolsResult:
"""List available tools from the server."""
"""List a single page of available tools from the server.

Returns one page only. The result may include a `next_cursor` if more
pages are available. Use `list_all_tools` to drain every page.
"""
return await self._cached_fetch(
"tools/list",
cursor=cursor,
Expand All @@ -943,6 +964,189 @@ async def list_tools(
),
)

async def iter_all_tools(
self, *, meta: RequestParamsMeta | None = None, cache_mode: CacheMode = "refresh"
) -> AsyncIterator[Tool]:
"""Yield every tool from the server, paging through `next_cursor`.

Useful for streaming consumers that want to process tools without
materializing the full list in memory.

Args:
meta: Additional metadata for the request.
cache_mode: Cache behavior for the first page (see `CacheMode`).
Defaults to `"refresh"`, unlike the single-page `list_tools`:
continuation pages bypass the cache unconditionally, so a
cached first page would pair a stale cursor with freshly
fetched later pages and silently return a listing the server
never served. Pass `"use"` to accept a cached first page.

Raises:
RuntimeError: The server returned a pagination cursor it already
returned, which would page forever.
"""
seen_cursors: set[str] = set()
cursor: str | None = None
while True:
result = await self.list_tools(cursor=cursor, meta=meta, cache_mode=cache_mode)
for tool in result.tools:
yield tool
if result.next_cursor is None:
return
if result.next_cursor in seen_cursors:
raise RuntimeError("Server returned a pagination cursor it already returned; refusing to page forever.")
seen_cursors.add(result.next_cursor)
cursor = result.next_cursor

async def list_all_tools(
self, *, meta: RequestParamsMeta | None = None, cache_mode: CacheMode = "refresh"
) -> list[Tool]:
"""List every tool from the server, draining `next_cursor` across pages.

Unlike `list_tools`, which returns one page, this walks pagination
until the server reports no further pages and returns the combined
list.

Args:
meta: Additional metadata for the request.
cache_mode: Cache behavior for the first page (see
`iter_all_tools`); defaults to `"refresh"`.

Raises:
RuntimeError: The server returned a pagination cursor it already
returned, which would page forever.
"""
return [tool async for tool in self.iter_all_tools(meta=meta, cache_mode=cache_mode)]

async def iter_all_prompts(
self, *, meta: RequestParamsMeta | None = None, cache_mode: CacheMode = "refresh"
) -> AsyncIterator[Prompt]:
"""Yield every prompt from the server, paging through `next_cursor`.

Args:
meta: Additional metadata for the request.
cache_mode: Cache behavior for the first page (see
`iter_all_tools`); defaults to `"refresh"`.

Raises:
RuntimeError: The server returned a pagination cursor it already
returned, which would page forever.
"""
seen_cursors: set[str] = set()
cursor: str | None = None
while True:
result = await self.list_prompts(cursor=cursor, meta=meta, cache_mode=cache_mode)
for prompt in result.prompts:
yield prompt
if result.next_cursor is None:
return
if result.next_cursor in seen_cursors:
raise RuntimeError("Server returned a pagination cursor it already returned; refusing to page forever.")
seen_cursors.add(result.next_cursor)
cursor = result.next_cursor

async def list_all_prompts(
self, *, meta: RequestParamsMeta | None = None, cache_mode: CacheMode = "refresh"
) -> list[Prompt]:
"""List every prompt from the server, draining `next_cursor` across pages.

Args:
meta: Additional metadata for the request.
cache_mode: Cache behavior for the first page (see
`iter_all_tools`); defaults to `"refresh"`.

Raises:
RuntimeError: The server returned a pagination cursor it already
returned, which would page forever.
"""
return [prompt async for prompt in self.iter_all_prompts(meta=meta, cache_mode=cache_mode)]

async def iter_all_resources(
self, *, meta: RequestParamsMeta | None = None, cache_mode: CacheMode = "refresh"
) -> AsyncIterator[Resource]:
"""Yield every resource from the server, paging through `next_cursor`.

Args:
meta: Additional metadata for the request.
cache_mode: Cache behavior for the first page (see
`iter_all_tools`); defaults to `"refresh"`.

Raises:
RuntimeError: The server returned a pagination cursor it already
returned, which would page forever.
"""
seen_cursors: set[str] = set()
cursor: str | None = None
while True:
result = await self.list_resources(cursor=cursor, meta=meta, cache_mode=cache_mode)
for resource in result.resources:
yield resource
if result.next_cursor is None:
return
if result.next_cursor in seen_cursors:
raise RuntimeError("Server returned a pagination cursor it already returned; refusing to page forever.")
seen_cursors.add(result.next_cursor)
cursor = result.next_cursor

async def list_all_resources(
self, *, meta: RequestParamsMeta | None = None, cache_mode: CacheMode = "refresh"
) -> list[Resource]:
"""List every resource from the server, draining `next_cursor` across pages.

Args:
meta: Additional metadata for the request.
cache_mode: Cache behavior for the first page (see
`iter_all_tools`); defaults to `"refresh"`.

Raises:
RuntimeError: The server returned a pagination cursor it already
returned, which would page forever.
"""
return [resource async for resource in self.iter_all_resources(meta=meta, cache_mode=cache_mode)]

async def iter_all_resource_templates(
self, *, meta: RequestParamsMeta | None = None, cache_mode: CacheMode = "refresh"
) -> AsyncIterator[ResourceTemplate]:
"""Yield every resource template from the server, paging through `next_cursor`.

Args:
meta: Additional metadata for the request.
cache_mode: Cache behavior for the first page (see
`iter_all_tools`); defaults to `"refresh"`.

Raises:
RuntimeError: The server returned a pagination cursor it already
returned, which would page forever.
"""
seen_cursors: set[str] = set()
cursor: str | None = None
while True:
result = await self.list_resource_templates(cursor=cursor, meta=meta, cache_mode=cache_mode)
for template in result.resource_templates:
yield template
if result.next_cursor is None:
return
if result.next_cursor in seen_cursors:
raise RuntimeError("Server returned a pagination cursor it already returned; refusing to page forever.")
seen_cursors.add(result.next_cursor)
cursor = result.next_cursor

async def list_all_resource_templates(
self, *, meta: RequestParamsMeta | None = None, cache_mode: CacheMode = "refresh"
) -> list[ResourceTemplate]:
"""List every resource template from the server, draining `next_cursor` across pages.

Args:
meta: Additional metadata for the request.
cache_mode: Cache behavior for the first page (see
`iter_all_tools`); defaults to `"refresh"`.

Raises:
RuntimeError: The server returned a pagination cursor it already
returned, which would page forever.
"""
return [template async for template in self.iter_all_resource_templates(meta=meta, cache_mode=cache_mode)]

@deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
async def send_roots_list_changed(self) -> None:
"""Send a notification that the roots list has changed."""
Expand Down
Loading
Loading