|
1 | 1 | import contextlib |
2 | 2 | from unittest import mock |
3 | 3 |
|
| 4 | +import anyio |
4 | 5 | import httpx2 |
5 | 6 | import mcp_types as types |
6 | 7 | import pytest |
7 | 8 |
|
8 | 9 | import mcp |
| 10 | +from mcp import Client |
9 | 11 | from mcp.client.session_group import ( |
10 | 12 | ClientSessionGroup, |
11 | 13 | ClientSessionParameters, |
12 | 14 | SseServerParameters, |
13 | 15 | StreamableHttpParameters, |
14 | 16 | ) |
15 | 17 | from mcp.client.stdio import StdioServerParameters |
| 18 | +from mcp.server import Server, ServerRequestContext |
16 | 19 | from mcp.shared.exceptions import MCPError |
17 | 20 |
|
18 | 21 |
|
@@ -116,9 +119,9 @@ async def test_client_session_group_connect_to_server(mock_exit_stack: contextli |
116 | 119 | mock_resource1.name = "resource_b" |
117 | 120 | mock_prompt1 = mock.Mock(spec=types.Prompt) |
118 | 121 | 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) |
122 | 125 |
|
123 | 126 | # --- Test Execution --- |
124 | 127 | 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 |
151 | 154 | mock_session = mock.AsyncMock(spec=mcp.ClientSession) |
152 | 155 | mock_tool = mock.Mock(spec=types.Tool) |
153 | 156 | 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) |
157 | 160 |
|
158 | 161 | # --- Test Setup --- |
159 | 162 | 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 |
262 | 265 | # Configure the new session to return a tool with the *same name* |
263 | 266 | duplicate_tool = mock.Mock(spec=types.Tool) |
264 | 267 | 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) |
266 | 269 | # 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) |
269 | 272 |
|
270 | 273 | # --- Test Execution and Assertion --- |
271 | 274 | with pytest.raises(MCPError) as excinfo: |
@@ -405,3 +408,183 @@ async def test_client_session_group_establish_session_parameterized( |
405 | 408 | # 3. Assert returned values |
406 | 409 | assert returned_server_info is mock_initialize_result.server_info |
407 | 410 | 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