PrefectHQ/fastmcp · error · RuntimeError

ClientGroup is already connected

Error message

ClientGroup is already connected

What it means

ClientGroup is a single-use async context manager owning one AsyncExitStack, set on entry and cleared on exit. Entering `async with group:` a second time before exit (or concurrently) raises this RuntimeError so the first session isn't silently overwritten. The stack is claimed before the first await so a concurrent entry hits the guard instead of racing past it.

Source

Thrown at fastmcp_slim/fastmcp/client/group.py:93

            config if isinstance(config, MCPConfig) else MCPConfig.from_dict(config)
        )
        clients: dict[str, Client[Any]] = {}

        for name, server in parsed.mcpServers.items():
            configured_mode = (server.model_extra or {}).get("mode", default_mode)
            if not isinstance(configured_mode, str):
                raise TypeError(f"Protocol mode for server {name!r} must be a string")
            clients[name] = Client(server.to_transport(), mode=configured_mode)

        return cls(clients)

    @property
    def protocol_versions(self) -> dict[str, str | None]:
        return {name: client.protocol_version for name, client in self._clients.items()}

    async def __aenter__(self) -> ClientGroup:
        if self._exit_stack is not None:
            raise RuntimeError("ClientGroup is already connected")

        # Claim the stack before the first await so a concurrent entry hits the
        # guard above instead of racing past it and overwriting this one.
        stack = contextlib.AsyncExitStack()
        self._exit_stack = stack
        await stack.__aenter__()

        # Connect concurrently: entry latency stays one handshake deep instead
        # of growing linearly with the number of servers. With
        # return_exceptions=True every connection attempt runs to completion,
        # so on partial failure the successes are known and can be unwound.
        clients = list(self._clients.values())
        results = await gather(
            (client.__aenter__() for client in clients), return_exceptions=True
        )
        errors = [result for result in results if isinstance(result, BaseException)]
        if errors:
            for client, result in zip(clients, results, strict=True):

View on GitHub (pinned to 1f02114297)

Solutions

  1. Enter the group once and keep all work inside that single `async with` block
  2. Create a new ClientGroup for each independent scope instead of reusing a connected one
  3. For shared access, enter once in a parent task and pass the connected group down

Example fix

// before
async with group:
    ...
async with group:  # RuntimeError: already connected
    ...

// after
async with group:
    # do all work here
    ...
Defensive patterns

Strategy: try-catch

Validate before calling

def group_is_free(group) -> bool:
    return group._exit_stack is None

Try / catch

try:
    async with group:
        ...
except RuntimeError as e:
    if "already connected" in str(e):
        ...  # reuse the active session instead of re-entering
    else:
        raise

Prevention

When it happens

Trigger: Re-entering an already-entered ClientGroup (e.g. nested `async with group:` blocks); multiple concurrent tasks entering one shared instance; re-entering in a retry loop without exiting first.

Common situations: A shared group stored in module/global state entered in multiple places; retry logic re-entering the same group instance inside a loop; concurrent coroutines entering simultaneously.

Related errors


AI-assisted analysis of PrefectHQ/fastmcp@1f02114297 (2026-08-29). Data as JSON: /api/errors/b88a75ad1db08a08. Report an issue: GitHub.