Repository navigation
ClientSessionGroup raises KeyError when a server exposes no tools, resources or prompts #3384
Description
Initial Checks
- I confirm that I'm using the newest release of my line (the latest 2.x, or the latest 1.x if I'm still on v1)
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
Release line
2.x (current stable), and it reproduces on the latest 1.x too.
Description
Connecting a ClientSessionGroup to a server that registers nothing fails with a raw KeyError. A server that registers even one tool, resource or prompt is fine.
_aggregate_components ends with this cleanup step, which only runs when all three list calls came back empty (session_group.py:420 on 2.1.0):
# Clean up exit stack for session if we couldn't retrieve anything
# from the server.
if not any((prompts_temp, resources_temp, tools_temp)):
del self._session_exit_stacks[session] # pragma: no coverTwo different things go wrong depending on how you got there.
Via connect_with_session(), the key was never inserted, so the del raises KeyError. That is the traceback below.
Via connect_to_server(), the key does exist, so the del succeeds. It removes the bookkeeping entry without closing the stack. The next lines then register the session in self._sessions anyway, so the group reports the session as connected while holding no way to close it. Calling disconnect_from_server() afterwards takes the session_known_for_stack is False branch and returns without closing anything, so the transport stays open until the whole group is torn down. I confirmed this by pre-registering a stack with a close callback and watching the callback never fire.
What I expected: connecting to a server that exposes nothing should either succeed with an empty component set, or fail with a clear error. If the group decides to drop the session, the exit stack should be closed rather than forgotten.
Two smaller things in the same condition, for whoever picks this up. The comment says "clean up exit stack" but the code only forgets it. And not any(...) treats "the server legitimately exposes nothing" and "all three list calls raised" as the same case, which may deserve separating.
Example Code
import anyio
import mcp_types as types
from mcp.client.client import Client
from mcp.client.session_group import ClientSessionGroup
from mcp.server.mcpserver import MCPServer
INFO = types.Implementation(name="empty", version="1.0")
async def main() -> None:
# A server that registers no tools, no resources and no prompts.
async with Client(MCPServer("empty")) as client:
group = ClientSessionGroup()
await group.connect_with_session(INFO, client.session)
anyio.run(main)Output:
File ".../mcp/client/session_group.py", line 420, in _aggregate_components
del self._session_exit_stacks[session] # pragma: no cover
~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
KeyError: <mcp.client.session.ClientSession object at 0x77e8450e16a0>
Python & MCP Python SDK
Python 3.13.11, Linux.
mcp 2.1.0 (latest 2.x): fails at session_group.py:420
mcp 1.29.1 (latest 1.x): fails at session_group.py:409
Also reproduces on main at d8b63830.
Reported with AI assistance; I ran the reproduction above and read the output.
Activity
jayhemnani9910 commentedon Aug 25, 2026
I have a patch for this ready locally, if it is useful.
The approach: drop the cleanup block, so a server that exposes nothing is treated as a server with no components. Its exit stack stays tracked, which is what lets
disconnect_from_serverclose it later. That also makes the two stack-closing lines indisconnect_from_serverreachable from tests, so theirpragma: no covercomes off.The other reading is that the block was meant to drop the session and close its transport. I could not settle that from the code, because the lines immediately after it register the session anyway, so the two intentions are already in conflict. Removing the block is the smaller change and keeps the group's behaviour uniform, but I am happy to redo it as a real drop-and-close if that is what was intended.
Two tests: an empty server aggregates to an empty group, and disconnecting one closes its exit stack.
./scripts/testpasses on d8b6383, 100% coverage andstrict-no-coverclean.I would like to fix this one myself if you are taking an outside PR for it.
AI-assisted; I ran the reproduction and the suite.
mikemikimike commentedon Aug 29, 2026
I am taking this issue. I will add regression coverage for empty-component sessions and ensure session bookkeeping is cleaned up without leaving the transport exit stack unclosed.
Fix KeyError and exit stack leak on empty servers in ClientSessionGro…
inanna-malick commentedon Oct 14, 2026
Confirmed the caller-owned empty-catalog path on latest stable 2.3.0 (
2118f14f8a19bc158d8a1cf90af58d85d187f849) and main754254c08fe1c3eda18b69da3d336106fe8d72d6, using Python 3.14.7 on Linux x86_64.Self-contained deterministic failing test (test-only branch). All three list handlers return successfully with empty lists;
connect_with_session()raisesKeyErroratdel self._session_exit_stacks[session]. The test uses an in-memoryClient(server)and adds no production changes. The other 12 session-group tests pass against both revisions.After checking out the test branch:
This confirms the existing caller-owned-session report; it does not independently validate the group-owned transport case.
AI-assisted investigation and test reproduction, with a human in the loop.