Skip to content

fix(contrib): pool and idle-evict MCP connections in google_adk_agents#1664

Open
wankhede04 wants to merge 2 commits into
temporalio:mainfrom
wankhede04:fix/google-adk-agents-mcp-connection-leak
Open

fix(contrib): pool and idle-evict MCP connections in google_adk_agents#1664
wankhede04 wants to merge 2 commits into
temporalio:mainfrom
wankhede04:fix/google-adk-agents-mcp-connection-leak

Conversation

@wankhede04

Copy link
Copy Markdown

What was changed

TemporalMcpToolSetProvider's {name}-list-tools and {name}-call-tool activities in temporalio/contrib/google_adk_agents/_mcp.py now pool and reuse a single McpToolset connection per activity name, instead of constructing a brand-new one on every single invocation.

  • Added _ConnectionRecord + a module-level connection pool keyed by activity name.
  • get_connection() reuses an existing live connection (refcounted) or lazily opens a new one via the existing toolset_factory.
  • Idle eviction: once no calls are in flight, a timer (default 5 minutes) closes and evicts the connection. Configurable via a new mcp_connection_idle_timeout parameter on TemporalMcpToolSetProvider.
  • Error-triggered eviction: if get_tools()/run_async() raises, the connection is evicted immediately so the next call gets a fresh one. A "no matching tool" business-logic error does not evict a healthy connection.
  • Added regression tests in tests/contrib/google_adk_agents/test_mcp_pool.py covering connection reuse, list-tools/call-tool sharing a connection, idle eviction, in-flight calls blocking eviction, and error eviction.

Why?

Every activity invocation called self._toolset_factory(args.factory_argument), constructing a new ADK McpToolset (and therefore a new MCPSessionManager) with no .close()/cleanup ever called on it. For stdio-transport MCP servers this spawns a new child process on every single activity execution and never cleans it up — a genuine subprocess leak under sustained load, not just a missed optimization.

This brings google_adk_agents to parity with the pooling already implemented in the strands (_temporal_mcp_client.py) and google_genai (_mcp.py) contribs, both of which pool and idle-evict connections the same way.

How tested

  • Added 7 unit tests in tests/contrib/google_adk_agents/test_mcp_pool.py exercising the pool directly against a fake McpToolset (no real MCP server needed): connection reuse across N calls, list-tools/call-tool sharing one connection, idle eviction after timeout, in-flight calls blocking premature eviction, and eviction-then-reconnect on both get_tools() and run_async() failures.
  • Ran ruff check / ruff format --check and mypy --namespace-packages --check-untyped-defs against the changed file — clean.
  • Ran the existing non-integration tests in tests/contrib/google_adk_agents/test_google_adk_agents.py to confirm no regressions.

Risks

  • factory_argument is only consulted the first time a connection opens for a given activity name; a warm connection is reused regardless of later calls' factory_argument values. This matches how strands/google_genai pool a single connection per name (they have no per-call factory_argument concept at all), but is a behavior change if any caller relied on factory_argument selecting a different backend on every call for the same provider name.
  • No worker-shutdown drain hook is added — checking the strands/google_genai implementations directly, neither actually wires one in either (despite that being suggested in the tracking issue); idle + error eviction is the actual existing pattern, which this PR now matches.

Closes #1663

TemporalMcpToolSetProvider's list-tools and call-tool activities called
self._toolset_factory(...) on every invocation, constructing a brand-new
McpToolset (and therefore a new MCPSessionManager/subprocess for stdio
servers) each time with no cleanup. This leaked a spawned process per
activity execution under sustained load.

Pool one McpToolset per activity name, reused across calls and refcounted
so idle eviction (default 5 minutes, overridable via the new
mcp_connection_idle_timeout constructor parameter) only fires once no
calls are in flight. A failed get_tools()/run_async() call evicts the
connection so the next call reconnects instead of reusing a dead session.
This brings google_adk_agents to parity with the pooling already shipped
in the strands and google_genai contribs.

Closes temporalio#1663
…viction in google_adk_agents

Regression tests against a fake McpToolset asserting: N sequential
call_tool executions against the same name reuse one toolset instead of
creating N; list-tools and call-tool share a connection; idle connections
close after the configured timeout but not while a call is in flight; and
a failed call evicts the broken connection so the next call reconnects.
@wankhede04
wankhede04 requested review from a team as code owners July 21, 2026 08:23
@CLAassistant

CLAassistant commented Jul 21, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

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.

contrib.google_adk_agents: MCP toolset connections are never closed (resource/subprocess leak), unlike sibling contribs

2 participants