Skip to content

Commit 5732e7d

Browse files
committed
fix(client): drain paginated session-group catalogs
1 parent d7845c0 commit 5732e7d

3 files changed

Lines changed: 237 additions & 30 deletions

File tree

‎docs/client/session-groups.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,17 @@ Run it again. `print(sorted(group.tools))` now shows both:
6666

6767
If you already hold a connected `ClientSession` (`Client.session` is one), hand it to `await group.connect_with_session(server_info, session)` instead of opening a new transport. It aggregates the same way. The group never closes a session it didn't open. `server_info` names the server for component prefixes; on a 2026-era connection `client.server_info` can be `None` (identity is optional), so pass your own `Implementation(name=..., version=...)` in that case.
6868

69+
## Paginated catalogs
70+
71+
Connecting a server follows every continuation cursor in its tools, resources, and
72+
prompts listings. Naming hooks and collision checks apply to the complete catalog,
73+
and tools discovered on later pages route to the same owning session.
74+
75+
A failure on a continuation page, a repeated cursor, or a name collision aborts
76+
registration without adding any of that server's components to the group. Existing
77+
servers remain registered. A first-page listing error still leaves that component
78+
kind unavailable while allowing the other kinds to be discovered.
79+
6980
## The classic handshake
7081

7182
`ClientSessionGroup` is built on `ClientSession`, not on `Client`. Each `connect_to_server` runs the classic `initialize` handshake. It never sends the `server/discover` probe described in **[Protocol versions](../protocol-versions.md)**. Every MCP server understands that handshake, so this costs you compatibility with nothing; it only means a group takes the older, slower path to a server that could do better.

‎src/mcp/client/session_group.py‎

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88

99
import contextlib
1010
import logging
11-
from collections.abc import Callable
11+
from collections.abc import AsyncIterator, Awaitable, Callable
1212
from dataclasses import dataclass
1313
from types import TracebackType
14-
from typing import Any, Literal, Self, TypeAlias, overload
14+
from typing import Any, Literal, Self, TypeAlias, TypeVar, overload
1515

1616
import anyio
1717
import httpx2
@@ -27,6 +27,31 @@
2727
from mcp.shared.dispatcher import ProgressFnT
2828
from mcp.shared.exceptions import MCPError
2929

30+
_ListingResult = TypeVar("_ListingResult", bound=types.PaginatedResult)
31+
32+
33+
async def _list_pages(list_page: Callable[..., Awaitable[_ListingResult]], kind: str) -> AsyncIterator[_ListingResult]:
34+
cursor: str | None = None
35+
seen_cursors: set[str] = set()
36+
while True:
37+
try:
38+
if cursor is None:
39+
page = await list_page()
40+
else:
41+
page = await list_page(params=types.PaginatedRequestParams(cursor=cursor))
42+
except MCPError as err:
43+
if cursor is not None:
44+
raise
45+
logging.warning(f"Could not fetch {kind}: {err}")
46+
return
47+
yield page
48+
cursor = page.next_cursor
49+
if cursor is None:
50+
return
51+
if cursor in seen_cursors:
52+
raise MCPError(code=types.INVALID_PARAMS, message=f"Repeated {kind} pagination cursor")
53+
seen_cursors.add(cursor)
54+
3055

3156
class SseServerParameters(BaseModel):
3257
"""Parameters for initializing an sse_client."""
@@ -175,7 +200,7 @@ async def __aexit__(
175200
@property
176201
def sessions(self) -> list[mcp.ClientSession]:
177202
"""Returns the list of sessions being managed."""
178-
return list(self._sessions.keys()) # pragma: no cover
203+
return list(self._sessions.keys())
179204

180205
@property
181206
def prompts(self) -> dict[str, types.Prompt]:
@@ -386,36 +411,24 @@ async def _aggregate_components(self, server_info: types.Implementation, session
386411
tools_temp: dict[str, types.Tool] = {}
387412
tool_to_session_temp: dict[str, mcp.ClientSession] = {}
388413

389-
# Query the server for its prompts and aggregate to list.
390-
try:
391-
prompts = (await session.list_prompts()).prompts
392-
for prompt in prompts:
414+
async for prompt_page in _list_pages(session.list_prompts, "prompts"):
415+
for prompt in prompt_page.prompts:
393416
name = self._component_name(prompt.name, server_info)
394417
prompts_temp[name] = prompt
395418
component_names.prompts.add(name)
396-
except MCPError as err: # pragma: no cover
397-
logging.warning(f"Could not fetch prompts: {err}")
398419

399-
# Query the server for its resources and aggregate to list.
400-
try:
401-
resources = (await session.list_resources()).resources
402-
for resource in resources:
420+
async for resource_page in _list_pages(session.list_resources, "resources"):
421+
for resource in resource_page.resources:
403422
name = self._component_name(resource.name, server_info)
404423
resources_temp[name] = resource
405424
component_names.resources.add(name)
406-
except MCPError as err: # pragma: no cover
407-
logging.warning(f"Could not fetch resources: {err}")
408425

409-
# Query the server for its tools and aggregate to list.
410-
try:
411-
tools = (await session.list_tools()).tools
412-
for tool in tools:
426+
async for tool_page in _list_pages(session.list_tools, "tools"):
427+
for tool in tool_page.tools:
413428
name = self._component_name(tool.name, server_info)
414429
tools_temp[name] = tool
415430
tool_to_session_temp[name] = session
416431
component_names.tools.add(name)
417-
except MCPError as err: # pragma: no cover
418-
logging.warning(f"Could not fetch tools: {err}")
419432

420433
# Clean up exit stack for session if we couldn't retrieve anything
421434
# from the server.

‎tests/client/test_session_group.py‎

Lines changed: 192 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
import contextlib
22
from unittest import mock
33

4+
import anyio
45
import httpx2
56
import mcp_types as types
67
import pytest
78

89
import mcp
10+
from mcp import Client
911
from mcp.client.session_group import (
1012
ClientSessionGroup,
1113
ClientSessionParameters,
1214
SseServerParameters,
1315
StreamableHttpParameters,
1416
)
1517
from mcp.client.stdio import StdioServerParameters
18+
from mcp.server import Server, ServerRequestContext
1619
from mcp.shared.exceptions import MCPError
1720

1821

@@ -116,9 +119,9 @@ async def test_client_session_group_connect_to_server(mock_exit_stack: contextli
116119
mock_resource1.name = "resource_b"
117120
mock_prompt1 = mock.Mock(spec=types.Prompt)
118121
mock_prompt1.name = "prompt_c"
119-
mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool1])
120-
mock_session.list_resources.return_value = mock.AsyncMock(resources=[mock_resource1])
121-
mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[mock_prompt1])
122+
mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool1], next_cursor=None)
123+
mock_session.list_resources.return_value = mock.AsyncMock(resources=[mock_resource1], next_cursor=None)
124+
mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[mock_prompt1], next_cursor=None)
122125

123126
# --- Test Execution ---
124127
group = ClientSessionGroup(exit_stack=mock_exit_stack)
@@ -151,9 +154,9 @@ async def test_client_session_group_connect_to_server_with_name_hook(mock_exit_s
151154
mock_session = mock.AsyncMock(spec=mcp.ClientSession)
152155
mock_tool = mock.Mock(spec=types.Tool)
153156
mock_tool.name = "base_tool"
154-
mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool])
155-
mock_session.list_resources.return_value = mock.AsyncMock(resources=[])
156-
mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[])
157+
mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool], next_cursor=None)
158+
mock_session.list_resources.return_value = mock.AsyncMock(resources=[], next_cursor=None)
159+
mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[], next_cursor=None)
157160

158161
# --- Test Setup ---
159162
def name_hook(name: str, server_info: types.Implementation) -> str:
@@ -262,10 +265,10 @@ async def test_client_session_group_connect_to_server_duplicate_tool_raises_erro
262265
# Configure the new session to return a tool with the *same name*
263266
duplicate_tool = mock.Mock(spec=types.Tool)
264267
duplicate_tool.name = existing_tool_name
265-
mock_session_new.list_tools.return_value = mock.AsyncMock(tools=[duplicate_tool])
268+
mock_session_new.list_tools.return_value = mock.AsyncMock(tools=[duplicate_tool], next_cursor=None)
266269
# Keep other lists empty for simplicity
267-
mock_session_new.list_resources.return_value = mock.AsyncMock(resources=[])
268-
mock_session_new.list_prompts.return_value = mock.AsyncMock(prompts=[])
270+
mock_session_new.list_resources.return_value = mock.AsyncMock(resources=[], next_cursor=None)
271+
mock_session_new.list_prompts.return_value = mock.AsyncMock(prompts=[], next_cursor=None)
269272

270273
# --- Test Execution and Assertion ---
271274
with pytest.raises(MCPError) as excinfo:
@@ -405,3 +408,183 @@ async def test_client_session_group_establish_session_parameterized(
405408
# 3. Assert returned values
406409
assert returned_server_info is mock_initialize_result.server_info
407410
assert returned_session is mock_entered_session
411+
412+
413+
@pytest.mark.anyio
414+
@pytest.mark.parametrize("prefixed", [False, True])
415+
async def test_group_discovers_all_pages_routes_later_tools_and_removes_them(prefixed: bool) -> None:
416+
"""SDK-defined: aggregation drains each catalog and keeps later-page ownership for routing and removal."""
417+
cursors: dict[str, list[str | None]] = {kind: [] for kind in ("tools", "resources", "prompts")}
418+
419+
async def tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> types.ListToolsResult:
420+
cursor = params.cursor if params else None
421+
cursors["tools"].append(cursor)
422+
return types.ListToolsResult(
423+
tools=[types.Tool(name="first" if cursor is None else "second", input_schema={"type": "object"})],
424+
next_cursor="tools-2" if cursor is None else None,
425+
)
426+
427+
async def resources(
428+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
429+
) -> types.ListResourcesResult:
430+
cursor = params.cursor if params else None
431+
cursors["resources"].append(cursor)
432+
name = "first" if cursor is None else "second"
433+
return types.ListResourcesResult(
434+
resources=[types.Resource(name=name, uri=f"memory://{name}")],
435+
next_cursor="resources-2" if cursor is None else None,
436+
)
437+
438+
async def prompts(
439+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
440+
) -> types.ListPromptsResult:
441+
cursor = params.cursor if params else None
442+
cursors["prompts"].append(cursor)
443+
return types.ListPromptsResult(
444+
prompts=[types.Prompt(name="first" if cursor is None else "second")],
445+
next_cursor="prompts-2" if cursor is None else None,
446+
)
447+
448+
async def call(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
449+
assert params.name == "second"
450+
return types.CallToolResult(content=[types.TextContent(text=params.name)])
451+
452+
server = Server(
453+
"catalog", on_list_tools=tools, on_list_resources=resources, on_list_prompts=prompts, on_call_tool=call
454+
)
455+
456+
def hook(name: str, info: types.Implementation) -> str:
457+
return f"{info.name}.{name}"
458+
459+
group = ClientSessionGroup(component_name_hook=hook if prefixed else None)
460+
with anyio.fail_after(5):
461+
async with Client(server) as client:
462+
session = client.session
463+
await group.connect_with_session(types.Implementation(name="catalog", version="1"), session)
464+
expected = {"catalog.first", "catalog.second"} if prefixed else {"first", "second"}
465+
assert set(group.tools) == expected
466+
assert set(group.resources) == expected
467+
assert set(group.prompts) == expected
468+
result = await group.call_tool("catalog.second" if prefixed else "second")
469+
assert result.content == [types.TextContent(text="second")]
470+
await group.disconnect_from_server(session)
471+
assert group.tools == {}
472+
assert group.resources == {}
473+
assert group.prompts == {}
474+
assert group.sessions == []
475+
assert cursors == {kind: [None, f"{kind}-2"] for kind in cursors}
476+
477+
478+
@pytest.mark.anyio
479+
@pytest.mark.parametrize("kind", ["tools", "resources", "prompts"])
480+
@pytest.mark.parametrize("repeated_cursor", [False, True])
481+
async def test_failed_later_catalog_page_leaves_the_group_unchanged(kind: str, repeated_cursor: bool) -> None:
482+
"""SDK-defined: later-page errors and cursor cycles cannot publish a partial server catalog."""
483+
calls: dict[str, list[str | None]] = {key: [] for key in ("tools", "resources", "prompts")}
484+
485+
def cursor_for(key: str, params: types.PaginatedRequestParams | None) -> str | None:
486+
cursor = params.cursor if params else None
487+
calls[key].append(cursor)
488+
if key != kind:
489+
return None
490+
if cursor is not None and not repeated_cursor:
491+
raise MCPError(code=types.INTERNAL_ERROR, message="page failed")
492+
return "repeat"
493+
494+
async def tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> types.ListToolsResult:
495+
return types.ListToolsResult(
496+
tools=[types.Tool(name="candidate", input_schema={"type": "object"})],
497+
next_cursor=cursor_for("tools", params),
498+
)
499+
500+
async def resources(
501+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
502+
) -> types.ListResourcesResult:
503+
return types.ListResourcesResult(
504+
resources=[types.Resource(name="candidate", uri="memory://candidate")],
505+
next_cursor=cursor_for("resources", params),
506+
)
507+
508+
async def prompts(
509+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
510+
) -> types.ListPromptsResult:
511+
return types.ListPromptsResult(
512+
prompts=[types.Prompt(name="candidate")], next_cursor=cursor_for("prompts", params)
513+
)
514+
515+
async def existing_tools(
516+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
517+
) -> types.ListToolsResult:
518+
return types.ListToolsResult(tools=[types.Tool(name="existing", input_schema={"type": "object"})])
519+
520+
existing = Server("existing", on_list_tools=existing_tools)
521+
candidate = Server("candidate", on_list_tools=tools, on_list_resources=resources, on_list_prompts=prompts)
522+
group = ClientSessionGroup()
523+
with anyio.fail_after(5):
524+
async with Client(existing) as first, Client(candidate) as second:
525+
await group.connect_with_session(types.Implementation(name="existing", version="1"), first.session)
526+
before = (dict(group.tools), dict(group.resources), dict(group.prompts), list(group.sessions))
527+
with pytest.raises(MCPError) as error:
528+
await group.connect_with_session(types.Implementation(name="candidate", version="1"), second.session)
529+
assert error.value.code == (types.INVALID_PARAMS if repeated_cursor else types.INTERNAL_ERROR)
530+
assert (group.tools, group.resources, group.prompts, group.sessions) == before
531+
assert calls[kind] == [None, "repeat"]
532+
533+
534+
@pytest.mark.anyio
535+
async def test_tool_collision_on_a_later_page_does_not_register_the_candidate_server() -> None:
536+
"""SDK-defined: cross-server collisions are checked against the complete paginated catalog."""
537+
538+
async def existing_tools(
539+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
540+
) -> types.ListToolsResult:
541+
return types.ListToolsResult(tools=[types.Tool(name="existing", input_schema={"type": "object"})])
542+
543+
async def candidate_tools(
544+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
545+
) -> types.ListToolsResult:
546+
later = params is not None and params.cursor is not None
547+
return types.ListToolsResult(
548+
tools=[types.Tool(name="existing" if later else "new", input_schema={"type": "object"})],
549+
next_cursor=None if later else "second",
550+
)
551+
552+
group = ClientSessionGroup()
553+
with anyio.fail_after(5):
554+
async with (
555+
Client(Server("existing", on_list_tools=existing_tools)) as first,
556+
Client(Server("candidate", on_list_tools=candidate_tools)) as second,
557+
):
558+
await group.connect_with_session(types.Implementation(name="existing", version="1"), first.session)
559+
with pytest.raises(MCPError) as error:
560+
await group.connect_with_session(types.Implementation(name="candidate", version="1"), second.session)
561+
assert error.value.code == types.INVALID_PARAMS
562+
assert set(group.tools) == {"existing"}
563+
assert group.sessions == [first.session]
564+
565+
566+
@pytest.mark.anyio
567+
async def test_group_follows_an_empty_cursor_and_empty_pages_until_cursor_is_absent() -> None:
568+
"""SDK-defined: opaque cursors, not page contents or cursor truthiness, decide when listing ends."""
569+
cursors: list[str | None] = []
570+
571+
async def tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> types.ListToolsResult:
572+
cursor = params.cursor if params else None
573+
cursors.append(cursor)
574+
if cursor is None:
575+
return types.ListToolsResult(tools=[], next_cursor="")
576+
if cursor == "":
577+
return types.ListToolsResult(
578+
tools=[types.Tool(name="found", input_schema={"type": "object"})], next_cursor="last"
579+
)
580+
assert cursor == "last"
581+
return types.ListToolsResult(tools=[])
582+
583+
group = ClientSessionGroup()
584+
with anyio.fail_after(5):
585+
async with Client(Server("empty-pages", on_list_tools=tools)) as client:
586+
await group.connect_with_session(types.Implementation(name="empty-pages", version="1"), client.session)
587+
assert set(group.tools) == {"found"}
588+
await group.disconnect_from_server(client.session)
589+
assert group.tools == {}
590+
assert cursors == [None, "", "last"]

0 commit comments

Comments
 (0)