forked from modelcontextprotocol/python-sdk
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_session_groups.py
More file actions
104 lines (81 loc) · 5.5 KB
/
Copy pathtest_session_groups.py
File metadata and controls
104 lines (81 loc) · 5.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
"""`docs/client/session-groups.md`: every claim the page makes, proved against the real SDK.
`connect_to_server` opens a real transport (a subprocess or a socket), so these tests drive the
exact same aggregation path through `connect_with_session` with in-memory sessions instead.
"""
import traceback
import pytest
from mcp_types import INVALID_PARAMS, Implementation
from docs_src.session_groups import tutorial001, tutorial002, tutorial004
from mcp import Client, ClientSessionGroup, MCPError
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
def _server_info(client: Client) -> Implementation:
"""Narrow `client.server_info` for `connect_with_session`: these servers all identify themselves."""
assert client.server_info is not None
return client.server_info
async def test_both_servers_call_their_tool_search() -> None:
"""tutorial001 + tutorial002: two unrelated servers, one colliding tool name."""
async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web:
(library_tool,) = (await library.list_tools()).tools
(web_tool,) = (await web.list_tools()).tools
assert library_tool.name == "search"
assert web_tool.name == "search"
async def test_a_connected_server_is_aggregated_into_the_group() -> None:
"""tutorial003: the group exposes every component of every connected server as a dict."""
async with Client(tutorial001.mcp) as library:
group = ClientSessionGroup()
await group.connect_with_session(_server_info(library), library.session)
assert sorted(group.tools) == ["search"]
assert sorted(group.resources) == ["hours"]
assert group.prompts == {}
assert group.tools["search"].description == "Search the library catalog."
async def test_colliding_names_are_rejected() -> None:
"""tutorial003: without a hook the second `search` raises, and nothing from `Web` is kept."""
async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web:
group = ClientSessionGroup()
await group.connect_with_session(_server_info(library), library.session)
with pytest.raises(MCPError) as exc_info:
await group.connect_with_session(_server_info(web), web.session)
assert str(exc_info.value) == "{'search'} already exist in group tools."
assert exc_info.value.error.code == INVALID_PARAMS
assert sorted(group.tools) == ["search"]
# The page's `!!! check` fence is the last line of the traceback, verbatim.
assert traceback.format_exception_only(exc_info.value) == [
"mcp.shared.exceptions.MCPError: {'search'} already exist in group tools.\n"
]
async def test_component_name_hook_prefixes_every_name() -> None:
"""tutorial004: the hook rewrites every registered name, so both servers coexist."""
async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web:
group = ClientSessionGroup(component_name_hook=tutorial004.by_server)
await group.connect_with_session(_server_info(library), library.session)
await group.connect_with_session(_server_info(web), web.session)
assert sorted(group.tools) == ["Library.search", "Web.search"]
assert sorted(group.resources) == ["Library.hours"]
def test_the_hook_is_a_plain_function_of_name_and_server_info() -> None:
"""tutorial004: `by_server` builds the key from `server_info.name`."""
assert tutorial004.by_server("search", Implementation(name="Web", version="1.0.0")) == "Web.search"
async def test_the_key_is_prefixed_but_the_wire_name_is_not() -> None:
"""tutorial004: the dict key is yours; the `Tool` inside keeps the name the server declared."""
async with Client(tutorial002.mcp) as web:
group = ClientSessionGroup(component_name_hook=tutorial004.by_server)
await group.connect_with_session(_server_info(web), web.session)
assert group.tools["Web.search"].name == "search"
async def test_call_tool_routes_to_the_owning_server() -> None:
"""tutorial004: `group.call_tool` resolves the prefixed name to the session that owns it."""
async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web:
group = ClientSessionGroup(component_name_hook=tutorial004.by_server)
await group.connect_with_session(_server_info(library), library.session)
await group.connect_with_session(_server_info(web), web.session)
web_result = await group.call_tool("Web.search", {"query": "model context protocol"})
assert web_result.structured_content == {"result": "12 pages match 'model context protocol'."}
library_result = await group.call_tool("Library.search", {"query": "dune"})
assert library_result.structured_content == {"result": "3 books match 'dune'."}
async def test_disconnect_removes_every_component_of_that_server() -> None:
"""tutorial004: `disconnect_from_server` takes the session back out of all three dicts."""
async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web:
group = ClientSessionGroup(component_name_hook=tutorial004.by_server)
await group.connect_with_session(_server_info(library), library.session)
web_session = await group.connect_with_session(_server_info(web), web.session)
await group.disconnect_from_server(web_session)
assert sorted(group.tools) == ["Library.search"]
assert sorted(group.resources) == ["Library.hours"]