Skip to content

Commit 66a12d4

Browse files
committed
feat(client): add list_all_* helpers that drain pagination
Closes #2556. The single-page list_tools / list_resources / list_resource_templates / list_prompts methods return the server's first page plus a next_cursor; callers that don't loop on the cursor silently see only page zero. Four list_all_* helpers on ClientSession drain the cursor until the server stops sending one and return the flattened list: list_all_tools() -> list[Tool] list_all_resources() -> list[Resource] list_all_resource_templates() -> list[ResourceTemplate] list_all_prompts() -> list[Prompt] Each helper reuses the existing public list_* method, so per-page post-processing (e.g. the 2026 x-mcp-header filter in _absorb_tool_listing) keeps running across the drain. Single-page and empty-list cases work without a loop. Tests cover: multi-page drain, single-page short-circuit, empty result, helpers-dont-share-cursor-state, terminal-empty-page-stops-loop, and cursor-roundtrip (the server's next_cursor comes back unchanged as params.cursor on the next request). 9 new tests pass alongside the existing 112 in tests/client.
1 parent 74a242a commit 66a12d4

2 files changed

Lines changed: 270 additions & 0 deletions

File tree

src/mcp/client/session.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,6 +1183,64 @@ def _absorb_tool_listing(self, result: types.ListToolsResult, *, complete: bool)
11831183

11841184
return result
11851185

1186+
# ---------- Pagination-draining helpers ----------
1187+
#
1188+
# The single-page `list_*` methods return the server's first page plus a
1189+
# `next_cursor`; callers who don't loop on the cursor silently get only
1190+
# page zero. The four `list_all_*` helpers drain all pages and return a
1191+
# flat list, so the common "give me everything" use case stops hiding
1192+
# pagination behind what looks like a single call. The single-page
1193+
# methods are unchanged.
1194+
1195+
async def list_all_tools(self) -> list[types.Tool]:
1196+
"""Drain pagination across `tools/list` and return the full tool list.
1197+
1198+
Each page flows through the 2026 x-mcp-header per-page filter via
1199+
`list_tools` -> `_absorb_tool_listing`, so the returned list is exactly
1200+
what the cached state would expose.
1201+
"""
1202+
tools: list[types.Tool] = []
1203+
cursor: str | None = None
1204+
while True:
1205+
result = await self.list_tools(params=types.PaginatedRequestParams(cursor=cursor))
1206+
tools.extend(result.tools)
1207+
cursor = result.next_cursor
1208+
if cursor is None:
1209+
return tools
1210+
1211+
async def list_all_resources(self) -> list[types.Resource]:
1212+
"""Drain pagination across `resources/list` and return the full resource list."""
1213+
items: list[types.Resource] = []
1214+
cursor: str | None = None
1215+
while True:
1216+
result = await self.list_resources(params=types.PaginatedRequestParams(cursor=cursor))
1217+
items.extend(result.resources)
1218+
cursor = result.next_cursor
1219+
if cursor is None:
1220+
return items
1221+
1222+
async def list_all_resource_templates(self) -> list[types.ResourceTemplate]:
1223+
"""Drain pagination across `resources/templates/list` and return the full template list."""
1224+
templates: list[types.ResourceTemplate] = []
1225+
cursor: str | None = None
1226+
while True:
1227+
result = await self.list_resource_templates(params=types.PaginatedRequestParams(cursor=cursor))
1228+
templates.extend(result.resource_templates)
1229+
cursor = result.next_cursor
1230+
if cursor is None:
1231+
return templates
1232+
1233+
async def list_all_prompts(self) -> list[types.Prompt]:
1234+
"""Drain pagination across `prompts/list` and return the full prompt list."""
1235+
items: list[types.Prompt] = []
1236+
cursor: str | None = None
1237+
while True:
1238+
result = await self.list_prompts(params=types.PaginatedRequestParams(cursor=cursor))
1239+
items.extend(result.prompts)
1240+
cursor = result.next_cursor
1241+
if cursor is None:
1242+
return items
1243+
11861244
@deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
11871245
async def send_roots_list_changed(self) -> None:
11881246
"""Send a roots/list_changed notification."""

tests/client/test_client.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -946,3 +946,215 @@ async def elicitation_callback(
946946
assert result == snapshot(
947947
ReadResourceResult(contents=[TextResourceContents(uri="memory://gated", text="unlocked")])
948948
)
949+
950+
951+
# ---------- list_all_* pagination-draining helpers ----------
952+
#
953+
# Each helper drives the existing single-page list_* method through a
954+
# cursor loop and returns the flattened list. The fixtures return one
955+
# page per call in order; the helper under test must dispatch until the
956+
# server stops sending a next_cursor.
957+
958+
959+
def _make_tools(names: list[str]) -> list[types.Tool]:
960+
return [types.Tool(name=n, input_schema={"type": "object"}) for n in names]
961+
962+
963+
def _make_resources(uris: list[str]) -> list[types.Resource]:
964+
return [types.Resource(uri=u, name=u.rsplit("/", 1)[-1]) for u in uris]
965+
966+
967+
def _make_resource_templates(uris: list[str]) -> list[types.ResourceTemplate]:
968+
return [types.ResourceTemplate(uri_template=u, name=u.rsplit("/", 1)[-1]) for u in uris]
969+
970+
971+
def _make_prompts(names: list[str]) -> list[types.Prompt]:
972+
return [types.Prompt(name=n) for n in names]
973+
974+
975+
async def test_list_all_tools_drains_pagination() -> None:
976+
"""`list_all_tools` keeps calling `tools/list` with the previous next_cursor until the
977+
server returns a page with `next_cursor=None`, and returns the flattened list."""
978+
979+
pages: list[types.ListToolsResult] = [
980+
types.ListToolsResult(tools=_make_tools(["a", "b"]), next_cursor="p2"),
981+
types.ListToolsResult(tools=_make_tools(["c"]), next_cursor="p3"),
982+
types.ListToolsResult(tools=_make_tools(["d", "e"])),
983+
]
984+
985+
async def on_list_tools(
986+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
987+
) -> types.ListToolsResult:
988+
cursor = (params.cursor if params else None) or "p1"
989+
idx = {"p1": 0, "p2": 1, "p3": 2}[cursor]
990+
return pages[idx]
991+
992+
server = Server("test", on_list_tools=on_list_tools)
993+
994+
with anyio.fail_after(5):
995+
async with Client(server) as client:
996+
tools = await client.session.list_all_tools()
997+
assert [t.name for t in tools] == ["a", "b", "c", "d", "e"]
998+
999+
1000+
async def test_list_all_tools_single_page_works() -> None:
1001+
"""`list_all_tools` returns the single page's items when no cursor is involved."""
1002+
1003+
async def on_list_tools(
1004+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
1005+
) -> types.ListToolsResult:
1006+
return types.ListToolsResult(tools=_make_tools(["only"]))
1007+
1008+
server = Server("test", on_list_tools=on_list_tools)
1009+
1010+
with anyio.fail_after(5):
1011+
async with Client(server) as client:
1012+
tools = await client.session.list_all_tools()
1013+
assert [t.name for t in tools] == ["only"]
1014+
1015+
1016+
async def test_list_all_tools_empty_returns_empty_list() -> None:
1017+
async def on_list_tools(
1018+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
1019+
) -> types.ListToolsResult:
1020+
return types.ListToolsResult(tools=[])
1021+
1022+
server = Server("test", on_list_tools=on_list_tools)
1023+
1024+
with anyio.fail_after(5):
1025+
async with Client(server) as client:
1026+
tools = await client.session.list_all_tools()
1027+
assert tools == []
1028+
1029+
1030+
async def test_list_all_resources_drains_pagination() -> None:
1031+
pages: list[types.ListResourcesResult] = [
1032+
types.ListResourcesResult(resources=_make_resources(["memory://r1", "memory://r2"]), next_cursor="p2"),
1033+
types.ListResourcesResult(resources=_make_resources(["memory://r3"])),
1034+
]
1035+
1036+
async def on_list_resources(
1037+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
1038+
) -> types.ListResourcesResult:
1039+
cursor = (params.cursor if params else None) or "p1"
1040+
return pages[0] if cursor == "p1" else pages[1]
1041+
1042+
server = Server("test", on_list_resources=on_list_resources)
1043+
1044+
with anyio.fail_after(5):
1045+
async with Client(server) as client:
1046+
resources = await client.session.list_all_resources()
1047+
assert [r.uri for r in resources] == ["memory://r1", "memory://r2", "memory://r3"]
1048+
1049+
1050+
async def test_list_all_resource_templates_drains_pagination() -> None:
1051+
pages: list[types.ListResourceTemplatesResult] = [
1052+
types.ListResourceTemplatesResult(
1053+
resource_templates=_make_resource_templates(["tmpl://{a}"]),
1054+
next_cursor="p2",
1055+
),
1056+
types.ListResourceTemplatesResult(resource_templates=_make_resource_templates(["tmpl://{b}"])),
1057+
]
1058+
1059+
async def on_list_resource_templates(
1060+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
1061+
) -> types.ListResourceTemplatesResult:
1062+
cursor = (params.cursor if params else None) or "p1"
1063+
return pages[0] if cursor == "p1" else pages[1]
1064+
1065+
server = Server("test", on_list_resource_templates=on_list_resource_templates)
1066+
1067+
with anyio.fail_after(5):
1068+
async with Client(server) as client:
1069+
templates = await client.session.list_all_resource_templates()
1070+
assert [t.uri_template for t in templates] == ["tmpl://{a}", "tmpl://{b}"]
1071+
1072+
1073+
async def test_list_all_prompts_drains_pagination() -> None:
1074+
pages: list[types.ListPromptsResult] = [
1075+
types.ListPromptsResult(prompts=_make_prompts(["p1"]), next_cursor="p2"),
1076+
types.ListPromptsResult(prompts=_make_prompts(["p2", "p3"])),
1077+
]
1078+
1079+
async def on_list_prompts(
1080+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
1081+
) -> types.ListPromptsResult:
1082+
cursor = (params.cursor if params else None) or "p1"
1083+
return pages[0] if cursor == "p1" else pages[1]
1084+
1085+
server = Server("test", on_list_prompts=on_list_prompts)
1086+
1087+
with anyio.fail_after(5):
1088+
async with Client(server) as client:
1089+
prompts = await client.session.list_all_prompts()
1090+
assert [p.name for p in prompts] == ["p1", "p2", "p3"]
1091+
1092+
1093+
async def test_list_all_helpers_do_not_share_cursor_state() -> None:
1094+
"""Each `list_all_*` helper starts at cursor=None; calling one does not affect another's
1095+
pagination state (the helpers read no shared cursor, only the server's per-page next_cursor)."""
1096+
1097+
async def on_list_tools(
1098+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
1099+
) -> types.ListToolsResult:
1100+
return types.ListToolsResult(tools=_make_tools(["t1"]))
1101+
1102+
async def on_list_prompts(
1103+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
1104+
) -> types.ListPromptsResult:
1105+
return types.ListPromptsResult(prompts=_make_prompts(["only-prompt"]))
1106+
1107+
server = Server("test", on_list_tools=on_list_tools, on_list_prompts=on_list_prompts)
1108+
1109+
with anyio.fail_after(5):
1110+
async with Client(server) as client:
1111+
tools = await client.session.list_all_tools()
1112+
prompts = await client.session.list_all_prompts()
1113+
assert [t.name for t in tools] == ["t1"]
1114+
assert [p.name for p in prompts] == ["only-prompt"]
1115+
1116+
1117+
async def test_list_all_tools_stops_when_server_sends_terminal_empty_page() -> None:
1118+
"""A terminal page with cursor=None (even if zero items) ends the drain loop."""
1119+
1120+
pages: list[types.ListToolsResult] = [
1121+
types.ListToolsResult(tools=_make_tools(["first"]), next_cursor="p2"),
1122+
types.ListToolsResult(tools=[], next_cursor=None),
1123+
types.ListToolsResult(tools=_make_tools(["never-returned"])),
1124+
]
1125+
1126+
async def on_list_tools(
1127+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
1128+
) -> types.ListToolsResult:
1129+
cursor = (params.cursor if params else None) or "p1"
1130+
return pages[0] if cursor == "p1" else pages[1]
1131+
1132+
server = Server("test", on_list_tools=on_list_tools)
1133+
1134+
with anyio.fail_after(5):
1135+
async with Client(server) as client:
1136+
tools = await client.session.list_all_tools()
1137+
assert [t.name for t in tools] == ["first"]
1138+
1139+
1140+
async def test_list_all_helpers_pass_cursor_through_paginated_request_params() -> None:
1141+
"""The helpers must hand the server's `next_cursor` back as `params.cursor` on the next call
1142+
(not as a positional arg or otherwise mangled). Server-side capture proves it."""
1143+
1144+
captured: list[str | None] = []
1145+
1146+
async def on_list_tools(
1147+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
1148+
) -> types.ListToolsResult:
1149+
captured.append(params.cursor if params else None)
1150+
if len(captured) == 1:
1151+
return types.ListToolsResult(tools=_make_tools(["page1-item"]), next_cursor="next-cursor")
1152+
return types.ListToolsResult(tools=_make_tools(["page2-item"]), next_cursor=None)
1153+
1154+
server = Server("test", on_list_tools=on_list_tools)
1155+
1156+
with anyio.fail_after(5):
1157+
async with Client(server) as client:
1158+
tools = await client.session.list_all_tools()
1159+
assert captured == [None, "next-cursor"]
1160+
assert [t.name for t in tools] == ["page1-item", "page2-item"]

0 commit comments

Comments
 (0)