# 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`:

```python title="library_server.py" hl_lines="7"
# docs_src/session_groups/tutorial001.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"
```

```python title="web_server.py" hl_lines="7"
# docs_src/session_groups/tutorial002.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:

```python title="client.py" hl_lines="10-12"
# docs_src/session_groups/tutorial003.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:

    ```text
    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:

```python title="client.py" hl_lines="7-8 15"
# docs_src/session_groups/tutorial004.py
import asyncio

from mcp import ClientSessionGroup, StdioServerParameters
from mcp.types import Implementation


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:

```text
['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. `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.

## 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](https://py.sdk.modelcontextprotocol.io/protocol-versions/index.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.

## 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](https://py.sdk.modelcontextprotocol.io/protocol-versions/index.md)**.
