Skip to content

fix(tools): reap ADK sessions of dead MCP connections in to_mcp_server - #7155

Open
harshal-96 wants to merge 2 commits into
google:mainfrom
harshal-96:fix/mcp-agent-server-session-reaping
Open

harshal-96 wants to merge 2 commits into
google:mainfrom
harshal-96:fix/mcp-agent-server-session-reaping

Conversation

@harshal-96

Copy link
Copy Markdown

Fixes #7154

Description

to_mcp_server keeps one ADK session per MCP connection in a weakref.WeakKeyDictionary. When a connection is garbage collected the map entry disappears, but the ADK session it pointed to stays in the session service forever, with its full event history. A long running server therefore accumulates one dead conversation per closed connection, and a stateless streamable HTTP deployment, where the MCP SDK builds a fresh transport for every request, leaks one session per tool call.

This PR tracks the id of every session entered into the connection map and, at the start of each tool call, deletes the sessions whose connection is no longer reachable through the weak map.

Design notes:

  • Reaping runs lazily from the tool call rather than from a GC finalizer, because finalizers can fire without a running event loop.
  • No timer, TTL, or policy surface is added. Connection lifetime is already governed by the MCP transport layer (client disconnects, the SDK's idle session timeout, stateless per request teardown); the fix makes ADK sessions follow that lifetime instead of outliving it. A session in use by an in-flight call is always strongly referenced through the call context, so an active conversation can never be reaped.
  • A delete_session failure is logged and retried on a later call, and never fails the live tool call. This matters for caller supplied runners with database or Vertex backed session services.
  • Behavior change to be aware of: with a persistent session service, orphaned conversations were previously retained forever and are now deleted. I believe cleanup is the right default, since the module creates these sessions under its internal mcp_user id, but I can add an opt-out flag if retention is wanted for some deployments.
  • The to_mcp_server docstring now documents stateless streamable HTTP behavior: each call is a fresh single turn conversation whose session is reclaimed.

Testing plan

5 new unit tests in tests/unittests/tools/mcp_tool/test_agent_to_mcp.py:

  • test_reap_deletes_only_sessions_no_longer_reachable: the reap deletes exactly the unreachable ids.
  • test_session_of_a_collected_connection_is_reaped: a conversation does not outlive its connection.
  • test_per_request_connections_do_not_accumulate_sessions: the stateless per request pattern stays flat instead of leaking one session per call.
  • test_reap_failure_does_not_raise_and_is_retried: a session service outage does not fail the live tool call, and the orphan is deleted once the service recovers.
  • test_call_tool_reaps_conversation_of_closed_connection: end to end through a real in-memory MCP client and server; this test fails on main (nothing is ever deleted) and passes with the fix.

Results:

  • pytest tests/unittests/tools/mcp_tool/test_agent_to_mcp.py: 18 passed on each of mcp 1.24.0, 1.26.0, and 2.2.0 (the pin admits 1.x and 2.x).
  • pytest tests/unittests/tools/mcp_tool: 376 passed.
  • The three GC sensitive tests repeated 20 times in a loop: stable.
  • Real transport check: server run with run_streamable_http_async(stateless_http=True) on localhost, one fresh streamable_http_client connection per call. 9 calls leave 9 sessions in the service on main and 1 with this PR (the most recent call's session, reclaimed on the next call).
  • Repro script from the issue: sessions left in the service: 10 on main, 1 with this PR.

Formatting: pyink 25.12 and isort 8.0.1 (the pinned versions) produce no changes.

to_mcp_server keeps one ADK session per MCP connection in a
WeakKeyDictionary. When a connection is garbage-collected the map entry
disappears, but the ADK session it pointed to stays in the session
service forever, with its full event history. A long-running server
therefore accumulates one dead conversation per closed connection, and a
stateless streamable HTTP deployment, where the SDK builds a fresh
transport for every request, leaks one session per tool call.

Track the id of every session entered into the connection map and, at
the start of each tool call, delete the sessions whose connection is no
longer reachable. Reaping runs lazily from the tool call rather than a
GC callback because finalizers may fire without a running event loop.

Also document how a stateless streamable HTTP deployment behaves: each
call is a fresh single-turn conversation whose session is reclaimed.

Tested with mcp 1.26.0 and 2.2.0: 17 passed each.
@google-cla

google-cla Bot commented Sep 17, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@harshal-96

Copy link
Copy Markdown
Author

@googlebot I signed it!

Callers who wire to_mcp_server to a persistent session service may want
finished conversations to remain readable after their connection dies,
e.g. for audit. delete_orphaned_sessions=False disables the reaping and
leaves session lifecycle to the caller. The default stays True so a
long-running server's memory is bounded out of the box.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

to_mcp_server never deletes the ADK session of a dead MCP connection; a stateless streamable HTTP deployment leaks one session per tool call

2 participants