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
21 changes: 21 additions & 0 deletions docs/advanced/pagination.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,27 @@ 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.

## 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
156 changes: 151 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 @@ -580,7 +584,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 @@ -596,7 +604,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 @@ -814,7 +827,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 @@ -912,7 +929,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 @@ -927,6 +948,131 @@ async def list_tools(
),
)

async def iter_all_tools(self, *, meta: RequestParamsMeta | None = None) -> 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.

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

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)]

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

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)
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) -> list[Prompt]:
"""List every prompt from the server, draining `next_cursor` across pages.

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)]

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

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)
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) -> list[Resource]:
"""List every resource from the server, draining `next_cursor` across pages.

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)]

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

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)
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) -> list[ResourceTemplate]:
"""List every resource template from the server, draining `next_cursor` across pages.

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)]

@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
46 changes: 39 additions & 7 deletions src/mcp/client/session_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import contextlib
import logging
from collections.abc import Callable
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from types import TracebackType
from typing import Any, Literal, TypeAlias, overload
Expand Down Expand Up @@ -67,6 +67,36 @@ class StreamableHttpParameters(BaseModel):
ServerParameters: TypeAlias = StdioServerParameters | SseServerParameters | StreamableHttpParameters


async def _drain_paginated(
fetch_page: Callable[..., Awaitable[Any]],
attribute: str,
) -> list[Any]:
"""Drain a paginated `session.list_*` call across `next_cursor` pages.

`fetch_page` is one of the ClientSession `list_*` methods that takes a
`params=PaginatedRequestParams(...)` keyword. `attribute` is the name of
the list attribute on the result (e.g. `"tools"`, `"prompts"`).

Raises:
RuntimeError: The server returned a pagination cursor it already
returned, which would page forever.
"""
items: list[Any] = []
seen_cursors: set[str] = set()
cursor: str | None = None
while True:
params = types.PaginatedRequestParams(cursor=cursor) if cursor is not None else None
result = await fetch_page(params=params)
items.extend(getattr(result, attribute))
next_cursor = getattr(result, "next_cursor", None)
if next_cursor is None:
return items
if next_cursor in seen_cursors:
raise RuntimeError("Server returned a pagination cursor it already returned; refusing to page forever.")
seen_cursors.add(next_cursor)
cursor = next_cursor
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.


# Use dataclass instead of Pydantic BaseModel
# because Pydantic BaseModel cannot handle Protocol fields.
@dataclass
Expand Down Expand Up @@ -383,9 +413,11 @@ async def _aggregate_components(self, server_info: types.Implementation, session
tools_temp: dict[str, types.Tool] = {}
tool_to_session_temp: dict[str, mcp.ClientSession] = {}

# Query the server for its prompts and aggregate to list.
# Query the server for its prompts and aggregate to list. Drain
# pagination so we don't drop later pages on servers that split
# results across multiple `next_cursor` responses.
try:
prompts = (await session.list_prompts()).prompts
prompts = await _drain_paginated(session.list_prompts, "prompts")
for prompt in prompts:
name = self._component_name(prompt.name, server_info)
prompts_temp[name] = prompt
Expand All @@ -395,7 +427,7 @@ async def _aggregate_components(self, server_info: types.Implementation, session

# Query the server for its resources and aggregate to list.
try:
resources = (await session.list_resources()).resources
resources = await _drain_paginated(session.list_resources, "resources")
for resource in resources:
name = self._component_name(resource.name, server_info)
resources_temp[name] = resource
Expand All @@ -405,14 +437,14 @@ async def _aggregate_components(self, server_info: types.Implementation, session

# Query the server for its tools and aggregate to list.
try:
tools = (await session.list_tools()).tools
tools = await _drain_paginated(session.list_tools, "tools")
for tool in tools:
name = self._component_name(tool.name, server_info)
tools_temp[name] = tool
tool_to_session_temp[name] = session
component_names.tools.add(name)
except MCPError as err: # pragma: no cover
logging.warning(f"Could not fetch tools: {err}")
except MCPError as err:
logging.warning(f"Could not fetch tools: {err}") # pragma: no cover

# Clean up exit stack for session if we couldn't retrieve anything
# from the server.
Expand Down
Loading
Loading