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
- Enter the group once and keep all work inside that single `async with` block
- Create a new ClientGroup for each independent scope instead of reusing a connected one
- 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
- Enter each ClientGroup exactly once per scope
- Never store a connected group in shared/global state for re-entry
- Use per-task fresh ClientGroup instances or a single owning task
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
- Client is not connected. Use the 'async with client:' contex
- FastMCP instance is no longer available
- FastMCP instance is no longer available
- '{name}' uses a sync function but has task execution enabled
- module {__name__!r} has no attribute {name!r}
AI-assisted analysis of PrefectHQ/fastmcp@1f02114297 (2026-08-29).
Data as JSON: /api/errors/b88a75ad1db08a08.
Report an issue: GitHub.