Skip to content

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:

library_server.py
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"
web_server.py
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:

client.py
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_server takes transport parameters, not a server object: StdioServerParameters (from mcp) to launch a subprocess, or StreamableHttpParameters / SseServerParameters (from mcp.client.session_group) for a server already listening on a URL.
  • group.tools is a dict[str, Tool] of every connected server's tools. group.resources and group.prompts are 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:

client.py
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_server built it from server_info.name, the name each MCPServer(...) was constructed with.
  • The Tool inside is untouched: group.tools["Web.search"].name is still "search", and that is the name call_tool puts on the wire. The prefix never leaves your process.
  • It is not only tools. The library's hours resource is registered as Library.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

  • ClientSessionGroup holds many server connections and merges their tools, resources, and prompts into one dict each.
  • connect_to_server(params) per server. It takes transport parameters, never the server object or URL a Client takes.
  • group.call_tool(name, arguments) routes to the owning server for you.
  • Names must be unique across the whole group; two servers with a search tool cannot coexist on their own.
  • component_name_hook= rewrites every registered name. The dict key changes, the wire name does not.
  • connect_with_session adds a session you already hold; disconnect_from_server removes one.

The handshake a group speaks (and the faster one a Client prefers) is the subject of Protocol versions.