Session groups
A Client connects to one server. Real applications often want several (a search server, a database server, an internal API) and end up juggling a connection and a tool list for each.
ClientSessionGroup is one object that holds many connections and merges everything they expose into a single view.
Two servers
Start with two ordinary servers. They have nothing to do with each other, so both naturally called their tool search:
from mcp.server import MCPServer
mcp = MCPServer("Library")
@mcp.tool()
def search(query: str) -> str:
"""Search the library catalog."""
return f"3 books match {query!r}."
@mcp.resource("library://hours")
def hours() -> str:
"""When the library is open."""
return "Mon-Fri 09:00-17:00"
from mcp.server import MCPServer
mcp = MCPServer("Web")
@mcp.tool()
def search(query: str) -> str:
"""Search the web."""
return f"12 pages match {query!r}."
One group
Create a ClientSessionGroup and call connect_to_server once per server:
import asyncio
from mcp import ClientSessionGroup, StdioServerParameters
async def main() -> None:
library = StdioServerParameters(command="uv", args=["run", "mcp", "run", "library_server.py"])
web = StdioServerParameters(command="uv", args=["run", "mcp", "run", "web_server.py"])
async with ClientSessionGroup() as group:
await group.connect_to_server(library)
await group.connect_to_server(web)
result = await group.call_tool("search", {"query": "model context protocol"})
print(result.structured_content)
if __name__ == "__main__":
asyncio.run(main())
connect_to_servertakes transport parameters, not a server object:StdioServerParameters(frommcp) to launch a subprocess, orStreamableHttpParameters/SseServerParameters(frommcp.client.session_group) for a server already listening on a URL.group.toolsis adict[str, Tool]of every connected server's tools.group.resourcesandgroup.promptsare the same shape.group.call_tool(name, arguments)looks the name up, finds the session that owns it, and forwards the call. You never say which server.
Check
Put client.py next to the two servers and run it. The second connect_to_server refuses:
mcp.shared.exceptions.MCPError: {'search'} already exist in group tools.
That is an MCPError, raised before anything from the second server is registered. A name must
be unique across the whole group, and two servers you don't control will collide eventually.
component_name_hook
You fix this at the group, not at the servers. Pass a function of (name, server_info) and the group runs it on every name it registers:
import asyncio
from mcp_types import Implementation
from mcp import ClientSessionGroup, StdioServerParameters
def by_server(name: str, server_info: Implementation) -> str:
return f"{server_info.name}.{name}"
async def main() -> None:
library = StdioServerParameters(command="uv", args=["run", "mcp", "run", "library_server.py"])
web = StdioServerParameters(command="uv", args=["run", "mcp", "run", "web_server.py"])
async with ClientSessionGroup(component_name_hook=by_server) as group:
await group.connect_to_server(library)
await group.connect_to_server(web)
print(sorted(group.tools))
result = await group.call_tool("Web.search", {"query": "model context protocol"})
print(result.structured_content)
if __name__ == "__main__":
asyncio.run(main())
Run it again. print(sorted(group.tools)) now shows both:
['Library.search', 'Web.search']
- The key is yours.
by_serverbuilt it fromserver_info.name, the name eachMCPServer(...)was constructed with. - The
Toolinside is untouched:group.tools["Web.search"].nameis still"search", and that is the namecall_toolputs on the wire. The prefix never leaves your process. - It is not only tools. The library's
hoursresource is registered asLibrary.hours.
Tip
The hook runs on every name from every server, not only on conflicts: there is no prefix-on-collision mode. Pick one scheme and let it apply everywhere.
Adding and removing servers
connect_to_server returns the ClientSession it opened. Keep it if you ever want that server gone: await group.disconnect_from_server(session) removes its tools, resources, and prompts from the group.
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.
The classic handshake
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. 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.
Recap
ClientSessionGroupholds many server connections and merges their tools, resources, and prompts into onedicteach.connect_to_server(params)per server. It takes transport parameters, never the server object or URL aClienttakes.group.call_tool(name, arguments)routes to the owning server for you.- Names must be unique across the whole group; two servers with a
searchtool cannot coexist on their own. component_name_hook=rewrites every registered name. The dict key changes, the wire name does not.connect_with_sessionadds a session you already hold;disconnect_from_serverremoves one.
The handshake a group speaks (and the faster one a Client prefers) is the subject of Protocol versions.