Skip to content

Commit 2e6fb6d

Browse files
Add list_all_* helpers that drain pagination on the client
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 #2556.
1 parent e8e6484 commit 2e6fb6d

4 files changed

Lines changed: 383 additions & 18 deletions

File tree

src/mcp/client/client.py

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from collections.abc import AsyncIterator
56
from contextlib import AsyncExitStack
67
from dataclasses import KW_ONLY, dataclass, field
78
from typing import Any
@@ -26,10 +27,14 @@
2627
ListToolsResult,
2728
LoggingLevel,
2829
PaginatedRequestParams,
30+
Prompt,
2931
PromptReference,
3032
ReadResourceResult,
3133
RequestParamsMeta,
34+
Resource,
35+
ResourceTemplate,
3236
ResourceTemplateReference,
37+
Tool,
3338
)
3439

3540

@@ -195,7 +200,11 @@ async def list_resources(
195200
cursor: str | None = None,
196201
meta: RequestParamsMeta | None = None,
197202
) -> ListResourcesResult:
198-
"""List available resources from the server."""
203+
"""List a single page of available resources from the server.
204+
205+
Returns one page only. The result may include a `next_cursor` if more
206+
pages are available. Use `list_all_resources` to drain every page.
207+
"""
199208
return await self.session.list_resources(params=PaginatedRequestParams(cursor=cursor, _meta=meta))
200209

201210
async def list_resource_templates(
@@ -204,7 +213,12 @@ async def list_resource_templates(
204213
cursor: str | None = None,
205214
meta: RequestParamsMeta | None = None,
206215
) -> ListResourceTemplatesResult:
207-
"""List available resource templates from the server."""
216+
"""List a single page of available resource templates from the server.
217+
218+
Returns one page only. The result may include a `next_cursor` if more
219+
pages are available. Use `list_all_resource_templates` to drain every
220+
page.
221+
"""
208222
return await self.session.list_resource_templates(params=PaginatedRequestParams(cursor=cursor, _meta=meta))
209223

210224
async def read_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> ReadResourceResult:
@@ -262,7 +276,11 @@ async def list_prompts(
262276
cursor: str | None = None,
263277
meta: RequestParamsMeta | None = None,
264278
) -> ListPromptsResult:
265-
"""List available prompts from the server."""
279+
"""List a single page of available prompts from the server.
280+
281+
Returns one page only. The result may include a `next_cursor` if more
282+
pages are available. Use `list_all_prompts` to drain every page.
283+
"""
266284
return await self.session.list_prompts(params=PaginatedRequestParams(cursor=cursor, _meta=meta))
267285

268286
async def get_prompt(
@@ -299,9 +317,84 @@ async def complete(
299317
return await self.session.complete(ref=ref, argument=argument, context_arguments=context_arguments)
300318

301319
async def list_tools(self, *, cursor: str | None = None, meta: RequestParamsMeta | None = None) -> ListToolsResult:
302-
"""List available tools from the server."""
320+
"""List a single page of available tools from the server.
321+
322+
Returns one page only. The result may include a `next_cursor` if more
323+
pages are available. Use `list_all_tools` to drain every page.
324+
"""
303325
return await self.session.list_tools(params=PaginatedRequestParams(cursor=cursor, _meta=meta))
304326

327+
async def iter_all_tools(self, *, meta: RequestParamsMeta | None = None) -> AsyncIterator[Tool]:
328+
"""Yield every tool from the server, paging through `next_cursor`.
329+
330+
Useful for streaming consumers that want to process tools without
331+
materializing the full list in memory.
332+
"""
333+
cursor: str | None = None
334+
while True:
335+
result = await self.list_tools(cursor=cursor, meta=meta)
336+
for tool in result.tools:
337+
yield tool
338+
if result.next_cursor is None:
339+
return
340+
cursor = result.next_cursor
341+
342+
async def list_all_tools(self, *, meta: RequestParamsMeta | None = None) -> list[Tool]:
343+
"""List every tool from the server, draining `next_cursor` across pages.
344+
345+
Unlike `list_tools`, which returns one page, this walks pagination
346+
until the server reports no further pages and returns the combined
347+
list.
348+
"""
349+
return [tool async for tool in self.iter_all_tools(meta=meta)]
350+
351+
async def iter_all_prompts(self, *, meta: RequestParamsMeta | None = None) -> AsyncIterator[Prompt]:
352+
"""Yield every prompt from the server, paging through `next_cursor`."""
353+
cursor: str | None = None
354+
while True:
355+
result = await self.list_prompts(cursor=cursor, meta=meta)
356+
for prompt in result.prompts:
357+
yield prompt
358+
if result.next_cursor is None:
359+
return
360+
cursor = result.next_cursor
361+
362+
async def list_all_prompts(self, *, meta: RequestParamsMeta | None = None) -> list[Prompt]:
363+
"""List every prompt from the server, draining `next_cursor` across pages."""
364+
return [prompt async for prompt in self.iter_all_prompts(meta=meta)]
365+
366+
async def iter_all_resources(self, *, meta: RequestParamsMeta | None = None) -> AsyncIterator[Resource]:
367+
"""Yield every resource from the server, paging through `next_cursor`."""
368+
cursor: str | None = None
369+
while True:
370+
result = await self.list_resources(cursor=cursor, meta=meta)
371+
for resource in result.resources:
372+
yield resource
373+
if result.next_cursor is None:
374+
return
375+
cursor = result.next_cursor
376+
377+
async def list_all_resources(self, *, meta: RequestParamsMeta | None = None) -> list[Resource]:
378+
"""List every resource from the server, draining `next_cursor` across pages."""
379+
return [resource async for resource in self.iter_all_resources(meta=meta)]
380+
381+
async def iter_all_resource_templates(
382+
self, *, meta: RequestParamsMeta | None = None
383+
) -> AsyncIterator[ResourceTemplate]:
384+
"""Yield every resource template from the server, paging through `next_cursor`."""
385+
cursor: str | None = None
386+
while True:
387+
result = await self.list_resource_templates(cursor=cursor, meta=meta)
388+
for template in result.resource_templates:
389+
yield template
390+
if result.next_cursor is None:
391+
return
392+
cursor = result.next_cursor
393+
394+
async def list_all_resource_templates(self, *, meta: RequestParamsMeta | None = None) -> list[ResourceTemplate]:
395+
"""List every resource template from the server, draining `next_cursor` across pages."""
396+
return [template async for template in self.iter_all_resource_templates(meta=meta)]
397+
305398
async def send_roots_list_changed(self) -> None:
306399
"""Send a notification that the roots list has changed."""
307400
# TODO(Marcelo): Currently, there is no way for the server to handle this. We should add support.

src/mcp/client/session_group.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import contextlib
1010
import logging
11-
from collections.abc import Callable
11+
from collections.abc import Awaitable, Callable
1212
from dataclasses import dataclass
1313
from types import TracebackType
1414
from typing import Any, TypeAlias
@@ -67,6 +67,28 @@ class StreamableHttpParameters(BaseModel):
6767
ServerParameters: TypeAlias = StdioServerParameters | SseServerParameters | StreamableHttpParameters
6868

6969

70+
async def _drain_paginated(
71+
fetch_page: Callable[..., Awaitable[Any]],
72+
attribute: str,
73+
) -> list[Any]:
74+
"""Drain a paginated `session.list_*` call across `next_cursor` pages.
75+
76+
`fetch_page` is one of the ClientSession `list_*` methods that takes a
77+
`params=PaginatedRequestParams(...)` keyword. `attribute` is the name of
78+
the list attribute on the result (e.g. `"tools"`, `"prompts"`).
79+
"""
80+
items: list[Any] = []
81+
cursor: str | None = None
82+
while True:
83+
params = types.PaginatedRequestParams(cursor=cursor) if cursor is not None else None
84+
result = await fetch_page(params=params)
85+
items.extend(getattr(result, attribute))
86+
next_cursor = getattr(result, "next_cursor", None)
87+
if next_cursor is None:
88+
return items
89+
cursor = next_cursor
90+
91+
7092
# Use dataclass instead of Pydantic BaseModel
7193
# because Pydantic BaseModel cannot handle Protocol fields.
7294
@dataclass
@@ -344,9 +366,11 @@ async def _aggregate_components(self, server_info: types.Implementation, session
344366
tools_temp: dict[str, types.Tool] = {}
345367
tool_to_session_temp: dict[str, mcp.ClientSession] = {}
346368

347-
# Query the server for its prompts and aggregate to list.
369+
# Query the server for its prompts and aggregate to list. Drain
370+
# pagination so we don't drop later pages on servers that split
371+
# results across multiple `next_cursor` responses.
348372
try:
349-
prompts = (await session.list_prompts()).prompts
373+
prompts = await _drain_paginated(session.list_prompts, "prompts")
350374
for prompt in prompts:
351375
name = self._component_name(prompt.name, server_info)
352376
prompts_temp[name] = prompt
@@ -356,7 +380,7 @@ async def _aggregate_components(self, server_info: types.Implementation, session
356380

357381
# Query the server for its resources and aggregate to list.
358382
try:
359-
resources = (await session.list_resources()).resources
383+
resources = await _drain_paginated(session.list_resources, "resources")
360384
for resource in resources:
361385
name = self._component_name(resource.name, server_info)
362386
resources_temp[name] = resource
@@ -366,7 +390,7 @@ async def _aggregate_components(self, server_info: types.Implementation, session
366390

367391
# Query the server for its tools and aggregate to list.
368392
try:
369-
tools = (await session.list_tools()).tools
393+
tools = await _drain_paginated(session.list_tools, "tools")
370394
for tool in tools:
371395
name = self._component_name(tool.name, server_info)
372396
tools_temp[name] = tool

0 commit comments

Comments
 (0)