to_mcp_server never deletes the ADK session of a dead MCP connection; a stateless streamable HTTP deployment leaks one session per tool call
メンテナーはふだん 5 日以内に返信
@surajksharma07 がすでに取り組んでいます。
2026年9月17日 から。
評価
この issue はまだ評価されていません。
説明
Describe the bug
to_mcp_server keeps one ADK session per MCP connection in a weakref.WeakKeyDictionary (src/google/adk/tools/mcp_tool/_agent_to_mcp.py). When a connection is garbage collected, the weak map drops the entry, but nothing ever calls session_service.delete_session, so the ADK session, with its full event history, stays in the session service forever. The default runner built by to_mcp_server uses InMemorySessionService, which is a plain dict with no eviction.
Two consequences:
- Stateful serving: a long running server accumulates one dead conversation per closed connection.
- Stateless streamable HTTP, which is the mode you want behind an autoscaler such as Cloud Run: the MCP SDK builds a completely fresh transport for every request (
StreamableHTTPSessionManagerwithstateless=True), so_connection_keyreturns a per request object and every tool call creates a new ADK session that is never deleted. That is one leaked session per tool call, growing linearly with traffic, and the sessions hold full conversation content.
To Reproduce
The script below makes 10 short lived in-memory MCP connections with one tool call each, which is exactly the connection pattern a stateless streamable HTTP deployment produces, then counts the sessions left in the service.
import asyncio
from contextlib import asynccontextmanager
import gc
from typing import AsyncGenerator
import anyio
from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.invocation_context import InvocationContext
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
from google.adk.auth.credential_service.in_memory_credential_service import (
InMemoryCredentialService,
)
from google.adk.events.event import Event
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.tools.mcp_tool import to_mcp_server
from google.genai import types
from mcp.client.session import ClientSession
from mcp.shared.memory import create_client_server_memory_streams
@asynccontextmanager
async def connected_client(lowlevel_server):
"""One in-memory MCP connection, the same shape a network client makes."""
async with create_client_server_memory_streams() as (
client_streams,
server_streams,
):
client_read, client_write = client_streams
server_read, server_write = server_streams
async with anyio.create_task_group() as tg:
tg.start_soon(
lambda: lowlevel_server.run(
server_read,
server_write,
lowlevel_server.create_initialization_options(),
)
)
try:
async with ClientSession(
read_stream=client_read, write_stream=client_write
) as session:
await session.initialize()
yield session
finally:
tg.cancel_scope.cancel()
class EchoAgent(BaseAgent):
async def _run_async_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
yield Event(
author=self.name,
content=types.Content(role="model", parts=[types.Part(text="ok")]),
)
async def main():
agent = EchoAgent(name="echo")
session_service = InMemorySessionService()
runner = Runner(
app_name="echo",
agent=agent,
session_service=session_service,
artifact_service=InMemoryArtifactService(),
memory_service=InMemoryMemoryService(),
credential_service=InMemoryCredentialService(),
)
server = to_mcp_server(agent, runner=runner)
lowlevel = getattr(server, "_mcp_server", None) or getattr(
server, "_lowlevel_server", server
)
for i in range(10):
async with connected_client(lowlevel) as client:
await client.call_tool("echo", {"request": f"call {i}"})
gc.collect()
remaining = await session_service.list_sessions(
app_name="echo", user_id="mcp_user"
)
print(f"sessions left in the service: {len(remaining.sessions)}")
asyncio.run(main())
Output on main: sessions left in the service: 10
Also reproduced over a real transport: running the server with run_streamable_http_async(host="127.0.0.1", port=8765, stateless_http=True) and making each call over a fresh streamable_http_client connection, 9 calls leave 9 sessions in the service.
Expected behavior
Sessions whose connection is gone are deleted from the session service. With the fix below, the same runs leave 1 session (the most recent call's, reclaimed on the next call).
Environment
google-adk main (7ae1c9b), mcp 2.2.0 (also reproduces on 1.24.0 and 1.26.0), Python 3.12.
Proposed fix (PR to follow)
Record the id of every session entered into the connection map, and at the start of each tool call delete the sessions that are no longer reachable through the weak map. Reaping runs lazily from the tool call rather than from a GC finalizer, because finalizers can fire without a running event loop. This adds no TTL or retention policy of its own: connection lifetime is already governed by the MCP transport layer (client disconnects, the SDK's idle session timeout, stateless per request teardown), and the fix makes ADK sessions follow that lifetime instead of outliving it.
One design note for review: with a caller supplied Runner backed by a persistent session service, deleting orphaned sessions is a behavior change, since conversations would no longer remain readable after their connection dies. I think cleanup is the right default here, because the module creates these sessions under its internal mcp_user id and unbounded growth is the worse failure, but I am happy to add an opt-out flag if retention matters for some deployments.
Possible follow-ups, feedback welcome
- A
stateless=Trueoption onto_mcp_serverthat skips the connection map and deletes each session eagerly after its call, as explicit support for deployments that run the server withstateless_http=True. - An optional conversation id argument on the generated tool, so a client of a stateless deployment can keep multi-turn conversations by passing the id back with each call. Today stateless mode silently degrades to one single turn conversation per call, because there is no connection identity to thread a conversation on.
I have the first ready and would build the second if there is interest.
- 主要言語
- Python
- スター
- 21.6k
- フォーク
- 4k
- 平均マージ
- 12時間 6分
- マージ済み PR(30日)
- 4
環境構築
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
google/adk-python のほかの issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
google/adk-python#7341 · リアクション 2 件 ·
メンテナーはふだん 5 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 84/100
google/adk-python#7334 · コメント 2 件 · 担当者 1 名 ·
メンテナーはふだん 5 日以内に返信
-
GoogleOidcVerifier treats string "false" as a verified email claim対応中かも @surajksharma07 が 2 日前に担当しました。 オープンcore
難易度 2/5 1〜3時間 初心者へのやさしさ 82/100
google/adk-python#7289 · コメント 5 件 · 担当者 1 名 ·
メンテナーはふだん 5 日以内に返信
-
RestApiTool raises uncaught KeyError when a required path param is omitted対応中かも @llalitkumarrr が 2 日前に担当しました。 オープンrequest clarification tools
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
google/adk-python#7282 · コメント 3 件 · 担当者 1 名 ·
メンテナーはふだん 5 日以内に返信
-
CredentialsManager should also extract scopes when populating auth schemes対応中かも @sanketpatil06 が 5 日前に担当しました。 オープンcore needs review
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
google/adk-python#7266 · コメント 2 件 · 担当者 1 名 ·
メンテナーはふだん 5 日以内に返信
google/adk-python の issue をすべて見る
似ている issue
-
namespace operations
難易度 1/5 1時間未満 初心者へのやさしさ 82/100
EclipseFdn/open-vsx.org#13573 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
collective/icalendar#1854 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
rancher/rancher-ai-agent#412 ·
メンテナーはふだん 6 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 84/100
TUDelftGeodesy/DePSI#134 ·
-
難易度 2/5 1〜3時間 初心者へのやさしさ 88/100
HenriquesLab/rxiv-maker#335 ·