You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
ClientSessionGroup: a rejected connect_to_server leaves its transport running — the session is established before its components are validated #3490
2.x (current stable), reproduced at 9972c21a on mcp 2.2.0.
Description
ClientSessionGroup.connect_to_server opens the transport first and validates the server's components second. When the duplicate-name check rejects the server, the transport it already opened is never closed, and the caller is never handed the session, so nothing else can close it either.
The order in session_group.py is:
_establish_session launches the transport, runs initialize, stores the stack in self._session_exit_stacks[session], and enters it into self._exit_stack.
_aggregate_components lists the components and hits raise MCPError(..., message=f"{matching_tools} already exist in group tools.").
self._sessions[session] = component_names is on the line after that raise, so it never runs.
That leaves the connection live but unreachable:
group.sessions reads self._sessions, which the rejected server never reached, so it is not listed.
disconnect_from_server(session) would close it, but it needs the ClientSession object and connect_to_server raised instead of returning one.
self._exit_stack still holds the stack, so it is released only when the whole group tears down.
For stdio that is a live child process. For streamable HTTP it is an initialized session on a server that counts sessions against max_sessions. Both grow by one per rejection.
This is the case the docs treat as ordinary rather than exotic. docs/client/session-groups.md says two servers you don't control "will collide eventually", and its !!! check fence tells the reader to run exactly this and see the MCPError. The same page says the error is "raised before anything from the second server is registered" — true of the three component dicts, but not of the connection that was opened to read them.
It costs a long-lived host that connects servers dynamically, or retries a failed connect: one more process or session per attempt, none of them reclaimable until the group closes.
Expected: a connect_to_server that raises should close the transport it opened before the exception leaves, so "nothing from the second server is registered" covers its connection too. If holding it is deliberate, the session needs to be reachable — attached to the raised MCPError, or listed by group.sessions — so the caller can close it.
Two notes to save review time:
connect_with_session, the other caller of _aggregate_components, is unaffected: the caller owns that session and still holds it. That is also why existing coverage misses this. tests/docs_src/test_session_groups.py says so directly — "connect_to_server opens a real transport (a subprocess or a socket), so these tests drive the exact same aggregation path through connect_with_session with in-memory sessions instead." The leak exists only on the path the tests substitute away.
#3228 looks like the same shape one layer down — a request the server refuses still leaves a registered session behind because the session is created before validation.
rejection #1: {'search'} already exist in group tools.
live subprocesses left behind : 1
group.sessions : 1
group.tools : ['search']
rejection #2: {'search'} already exist in group tools.
live subprocesses left behind : 2
group.sessions : 1
group.tools : ['search']
rejection #3: {'search'} already exist in group tools.
live subprocesses left behind : 3
group.sessions : 1
group.tools : ['search']
Same growth on StreamableHttpParameters against two HTTP servers, where what accumulates is an initialized session rather than a process — len(group._session_exit_stacks) - len(group._sessions) goes 1, 2, 3 while group.sessions stays at 1.
origin/v1.x has the same ordering — stack stored at session_group.py:348, entered into _exit_stack at :351, the duplicate check raises at :433, and self._sessions[session] is set at :438 — so 1.x looks affected too, though I only ran the reproduction on 2.2.0.
Python & MCP Python SDK
Python 3.10.20, mcp 2.2.0, starlette via httpx2, macOS 26.5.2 (arm64)
I used AI assistance to narrow this down and to build the reproduction; I ran it myself and can walk through the code path. If you'd like an outside PR for it, I'd like to take the fix.
Opened #3491 with the fix and a regression test: the transport is closed in connect_to_server, the only caller that owns one, so connect_with_session keeps its "the group never closes a session it didn't open" contract.
The new test fails on main (assert closed) and passes with the change. Locally: 5968 tests pass, coverage stays at 100.00% with no new pragma: no cover, and ruff/pyright/uv lock --check are clean.
I'm not assigned here, so the intake gate will close it until you decide — that's fine, it's on the table rather than jumping the queue, and I'll push any changes as new commits so it can reopen cleanly.
I'd like to pick this up if someone can assign me.
#3491 and #3502 both proposed the same fix and were auto-closed by the linked-issue bot rather than reviewed, so nobody currently holds the assignment.
I reproduced the leak independently against 9972c21. After connect_to_server() raises on a tool name collision, the rejected server's stdio child is still running, because the session's exit stack was already registered with the group's long-lived stack before _aggregate_components() raised. Ran it 10 times unpatched (child survives 10/10) and 10 times with #3502's patch applied (clean 10/10).
If assigned, I'd send the fix with a regression test that checks the child process is actually gone, rather than asserting on a mocked exit stack. The existing test in #3502 mocks AsyncExitStack, so it passes whether or not the real transport dies.
Hi — I independently reproduced this on current main and have a local fix with regression coverage that verifies the rejected connection is actually cleaned up before the exception escapes. I also checked the earlier closed PRs (#3491, #3502, #3523) and have not opened another PR because I see there are already contributors interested in the issue.
If maintainers would like another outside contribution here, I'm happy to take it and submit the cleaned patch/tests. Please assign #3490 to me if so; otherwise I’ll leave the existing queue alone.
Initial Checks
Release line
2.x (current stable), reproduced at
9972c21aon mcp 2.2.0.Description
ClientSessionGroup.connect_to_serveropens the transport first and validates the server's components second. When the duplicate-name check rejects the server, the transport it already opened is never closed, and the caller is never handed the session, so nothing else can close it either.The order in
session_group.pyis:_establish_sessionlaunches the transport, runsinitialize, stores the stack inself._session_exit_stacks[session], and enters it intoself._exit_stack._aggregate_componentslists the components and hitsraise MCPError(..., message=f"{matching_tools} already exist in group tools.").self._sessions[session] = component_namesis on the line after that raise, so it never runs.That leaves the connection live but unreachable:
group.sessionsreadsself._sessions, which the rejected server never reached, so it is not listed.disconnect_from_server(session)would close it, but it needs theClientSessionobject andconnect_to_serverraised instead of returning one.self._exit_stackstill holds the stack, so it is released only when the whole group tears down.For stdio that is a live child process. For streamable HTTP it is an initialized session on a server that counts sessions against
max_sessions. Both grow by one per rejection.This is the case the docs treat as ordinary rather than exotic.
docs/client/session-groups.mdsays two servers you don't control "will collide eventually", and its!!! checkfence tells the reader to run exactly this and see theMCPError. The same page says the error is "raised before anything from the second server is registered" — true of the three component dicts, but not of the connection that was opened to read them.It costs a long-lived host that connects servers dynamically, or retries a failed connect: one more process or session per attempt, none of them reclaimable until the group closes.
Expected: a
connect_to_serverthat raises should close the transport it opened before the exception leaves, so "nothing from the second server is registered" covers its connection too. If holding it is deliberate, the session needs to be reachable — attached to the raisedMCPError, or listed bygroup.sessions— so the caller can close it.Two notes to save review time:
connect_with_session, the other caller of_aggregate_components, is unaffected: the caller owns that session and still holds it. That is also why existing coverage misses this.tests/docs_src/test_session_groups.pysays so directly — "connect_to_serveropens a real transport (a subprocess or a socket), so these tests drive the exact same aggregation path throughconnect_with_sessionwith in-memory sessions instead." The leak exists only on the path the tests substitute away.KeyErrorfromdel self._session_exit_stacks[session]in the empty-server branch. This is the duplicate-name branch, which raisesMCPErrorby design — the defect is what stays running afterwards. The three PRs written for ClientSessionGroup raises KeyError when a server exposes no tools, resources or prompts #3384 (fix(client): allow ClientSessionGroup to connect to servers without components #3386, Fix KeyError and exit stack leak on empty servers in ClientSessionGroup (#3384) #3419, fix(client): handle empty servers in ClientSessionGroup without KeyError or leaked stacks #3428) all delete that one block and leave this path untouched, so fixing ClientSessionGroup raises KeyError when a server exposes no tools, resources or prompts #3384 does not fix this.#3228 looks like the same shape one layer down — a request the server refuses still leaves a registered session behind because the session is created before validation.
Example Code
Output on 2.2.0:
Same growth on
StreamableHttpParametersagainst two HTTP servers, where what accumulates is an initialized session rather than a process —len(group._session_exit_stacks) - len(group._sessions)goes 1, 2, 3 whilegroup.sessionsstays at 1.origin/v1.xhas the same ordering — stack stored atsession_group.py:348, entered into_exit_stackat:351, the duplicate check raises at:433, andself._sessions[session]is set at:438— so 1.x looks affected too, though I only ran the reproduction on 2.2.0.Python & MCP Python SDK
I used AI assistance to narrow this down and to build the reproduction; I ran it myself and can walk through the code path. If you'd like an outside PR for it, I'd like to take the fix.