Skip to content

Commit 28361c0

Browse files
committed
feat(client): add list_all helpers for paginated MCP operations
Add list_all_tools, list_all_resources, list_all_resource_templates, and list_all_prompts methods to Client that automatically follow next_cursor and return all items across all pages. Closes #2556
1 parent ac96f88 commit 28361c0

2 files changed

Lines changed: 247 additions & 0 deletions

File tree

src/mcp/client/client.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,14 @@
2626
ListToolsResult,
2727
LoggingLevel,
2828
PaginatedRequestParams,
29+
Prompt,
2930
PromptReference,
3031
ReadResourceResult,
3132
RequestParamsMeta,
33+
Resource,
34+
ResourceTemplate,
3235
ResourceTemplateReference,
36+
Tool,
3337
)
3438

3539

@@ -302,6 +306,79 @@ async def list_tools(self, *, cursor: str | None = None, meta: RequestParamsMeta
302306
"""List available tools from the server."""
303307
return await self.session.list_tools(params=PaginatedRequestParams(cursor=cursor, _meta=meta))
304308

309+
async def list_all_tools(self, *, meta: RequestParamsMeta | None = None) -> ListToolsResult:
310+
"""List all available tools from the server, draining pagination automatically.
311+
312+
Follows ``next_cursor`` until the server returns no more pages and
313+
returns a single :class:`ListToolsResult` whose ``tools`` list contains
314+
every tool across all pages.
315+
316+
The tool output-schema cache is populated as a side effect (same as
317+
:meth:`list_tools`).
318+
"""
319+
all_tools: list[Tool] = []
320+
cursor: str | None = None
321+
while True:
322+
result = await self.list_tools(cursor=cursor, meta=meta)
323+
all_tools.extend(result.tools)
324+
if result.next_cursor is None:
325+
break
326+
cursor = result.next_cursor
327+
return ListToolsResult(tools=all_tools)
328+
329+
async def list_all_resources(self, *, meta: RequestParamsMeta | None = None) -> ListResourcesResult:
330+
"""List all available resources from the server, draining pagination automatically.
331+
332+
Follows ``next_cursor`` until the server returns no more pages and
333+
returns a single :class:`ListResourcesResult` whose ``resources`` list
334+
contains every resource across all pages.
335+
"""
336+
all_resources: list[Resource] = []
337+
cursor: str | None = None
338+
while True:
339+
result = await self.list_resources(cursor=cursor, meta=meta)
340+
all_resources.extend(result.resources)
341+
if result.next_cursor is None:
342+
break
343+
cursor = result.next_cursor
344+
return ListResourcesResult(resources=all_resources)
345+
346+
async def list_all_resource_templates(
347+
self, *, meta: RequestParamsMeta | None = None
348+
) -> ListResourceTemplatesResult:
349+
"""List all available resource templates from the server, draining pagination automatically.
350+
351+
Follows ``next_cursor`` until the server returns no more pages and
352+
returns a single :class:`ListResourceTemplatesResult` whose
353+
``resource_templates`` list contains every template across all pages.
354+
"""
355+
all_templates: list[ResourceTemplate] = []
356+
cursor: str | None = None
357+
while True:
358+
result = await self.list_resource_templates(cursor=cursor, meta=meta)
359+
all_templates.extend(result.resource_templates)
360+
if result.next_cursor is None:
361+
break
362+
cursor = result.next_cursor
363+
return ListResourceTemplatesResult(resource_templates=all_templates)
364+
365+
async def list_all_prompts(self, *, meta: RequestParamsMeta | None = None) -> ListPromptsResult:
366+
"""List all available prompts from the server, draining pagination automatically.
367+
368+
Follows ``next_cursor`` until the server returns no more pages and
369+
returns a single :class:`ListPromptsResult` whose ``prompts`` list
370+
contains every prompt across all pages.
371+
"""
372+
all_prompts: list[Prompt] = []
373+
cursor: str | None = None
374+
while True:
375+
result = await self.list_prompts(cursor=cursor, meta=meta)
376+
all_prompts.extend(result.prompts)
377+
if result.next_cursor is None:
378+
break
379+
cursor = result.next_cursor
380+
return ListPromptsResult(prompts=all_prompts)
381+
305382
async def send_roots_list_changed(self) -> None:
306383
"""Send a notification that the roots list has changed."""
307384
# TODO(Marcelo): Currently, there is no way for the server to handle this. We should add support.
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""Tests for the list_all_* helpers on Client that drain pagination automatically."""
2+
3+
import pytest
4+
5+
from mcp import types
6+
from mcp.client.client import Client
7+
from mcp.server import Server, ServerRequestContext
8+
from mcp.types import (
9+
ListPromptsResult,
10+
ListResourcesResult,
11+
ListResourceTemplatesResult,
12+
ListToolsResult,
13+
Prompt,
14+
Resource,
15+
ResourceTemplate,
16+
Tool,
17+
)
18+
19+
pytestmark = pytest.mark.anyio
20+
21+
22+
async def test_list_all_tools_drains_pagination() -> None:
23+
"""list_all_tools follows next_cursor and returns all tools across pages."""
24+
pages: dict[str | None, tuple[list[str], str | None]] = {
25+
None: (["alpha", "beta"], "page-2"),
26+
"page-2": (["gamma"], None),
27+
}
28+
29+
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
30+
assert params is not None
31+
names, next_cursor = pages[params.cursor]
32+
return ListToolsResult(
33+
tools=[Tool(name=n, input_schema={"type": "object"}) for n in names],
34+
next_cursor=next_cursor,
35+
)
36+
37+
server = Server("paginated", on_list_tools=list_tools)
38+
39+
async with Client(server) as client:
40+
result = await client.list_all_tools()
41+
42+
assert [t.name for t in result.tools] == ["alpha", "beta", "gamma"]
43+
assert result.next_cursor is None
44+
45+
46+
async def test_list_all_tools_single_page() -> None:
47+
"""list_all_tools works when the server returns all tools in a single page."""
48+
49+
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
50+
return ListToolsResult(
51+
tools=[
52+
Tool(name="only", input_schema={"type": "object"}),
53+
]
54+
)
55+
56+
server = Server("single", on_list_tools=list_tools)
57+
58+
async with Client(server) as client:
59+
result = await client.list_all_tools()
60+
61+
assert [t.name for t in result.tools] == ["only"]
62+
63+
64+
async def test_list_all_tools_empty() -> None:
65+
"""list_all_tools returns an empty list when the server has no tools."""
66+
67+
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
68+
return ListToolsResult(tools=[])
69+
70+
server = Server("empty", on_list_tools=list_tools)
71+
72+
async with Client(server) as client:
73+
result = await client.list_all_tools()
74+
75+
assert result.tools == []
76+
77+
78+
async def test_list_all_resources_drains_pagination() -> None:
79+
"""list_all_resources follows next_cursor and returns all resources across pages."""
80+
pages: dict[str | None, tuple[list[str], str | None]] = {
81+
None: (["res-a"], "page-2"),
82+
"page-2": (["res-b", "res-c"], None),
83+
}
84+
85+
async def list_resources(
86+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
87+
) -> ListResourcesResult:
88+
assert params is not None
89+
names, next_cursor = pages[params.cursor]
90+
return ListResourcesResult(
91+
resources=[Resource(uri=f"test://{n}", name=n) for n in names],
92+
next_cursor=next_cursor,
93+
)
94+
95+
server = Server("paginated", on_list_resources=list_resources)
96+
97+
async with Client(server) as client:
98+
result = await client.list_all_resources()
99+
100+
assert [r.name for r in result.resources] == ["res-a", "res-b", "res-c"]
101+
102+
103+
async def test_list_all_resource_templates_drains_pagination() -> None:
104+
"""list_all_resource_templates follows next_cursor and returns all templates across pages."""
105+
pages: dict[str | None, tuple[list[str], str | None]] = {
106+
None: (["tmpl-a"], "page-2"),
107+
"page-2": (["tmpl-b"], None),
108+
}
109+
110+
async def list_resource_templates(
111+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
112+
) -> ListResourceTemplatesResult:
113+
assert params is not None
114+
names, next_cursor = pages[params.cursor]
115+
return ListResourceTemplatesResult(
116+
resource_templates=[ResourceTemplate(name=n, uri_template=f"{n}://{{id}}") for n in names],
117+
next_cursor=next_cursor,
118+
)
119+
120+
server = Server("paginated", on_list_resource_templates=list_resource_templates)
121+
122+
async with Client(server) as client:
123+
result = await client.list_all_resource_templates()
124+
125+
assert [t.name for t in result.resource_templates] == ["tmpl-a", "tmpl-b"]
126+
127+
128+
async def test_list_all_prompts_drains_pagination() -> None:
129+
"""list_all_prompts follows next_cursor and returns all prompts across pages."""
130+
pages: dict[str | None, tuple[list[str], str | None]] = {
131+
None: (["greet", "farewell"], "page-2"),
132+
"page-2": (["summarize"], None),
133+
}
134+
135+
async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult:
136+
assert params is not None
137+
names, next_cursor = pages[params.cursor]
138+
return ListPromptsResult(
139+
prompts=[Prompt(name=n) for n in names],
140+
next_cursor=next_cursor,
141+
)
142+
143+
server = Server("paginated", on_list_prompts=list_prompts)
144+
145+
async with Client(server) as client:
146+
result = await client.list_all_prompts()
147+
148+
assert [p.name for p in result.prompts] == ["greet", "farewell", "summarize"]
149+
150+
151+
async def test_list_all_tools_populates_output_schema_cache() -> None:
152+
"""list_all_tools populates the tool output-schema cache (same as list_tools)."""
153+
154+
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
155+
return ListToolsResult(
156+
tools=[
157+
Tool(
158+
name="cached_tool",
159+
input_schema={"type": "object"},
160+
output_schema={"type": "object", "properties": {"x": {"type": "integer"}}},
161+
),
162+
]
163+
)
164+
165+
server = Server("schema-cache", on_list_tools=list_tools)
166+
167+
async with Client(server) as client:
168+
await client.list_all_tools()
169+
# The cache should be populated
170+
assert "cached_tool" in client.session._tool_output_schemas

0 commit comments

Comments
 (0)