Skip to content

MCPToolset "Attempted to exit cancel scope in a different task" under to_a2a() multi-agent (A2A) β€” scales with concurrent McpToolset count; SSE transport is race-freeΒ #5729

Closed

Description

@LiuYuWei

πŸ”΄ Required Information

Describe the Bug:
When an LlmAgent is served via to_a2a() and holds one or more McpToolsets using StreamableHTTPServerParams, MCP session creation during tool discovery intermittently fails with:

Failed to create MCP session: ... unhandled errors in a TaskGroup (1 sub-exception) whose inner exception is RuntimeError: Attempted to exit cancel scope in a different task than it was entered in.

ADK retries get_tools, but under load the agent proceeds with an incomplete tool list β€” the agent's own MCP tools silently disappear from the spec, the LLM then calls a now-missing tool and the A2A request hard-fails (ValueError: Tool '<name>' not found).

This is the same root cause as #4454, but on a different and very common surface: not adk web + Cloud Run, but to_a2a() ASGI agents in a hub-and-spoke multi-agent system (a manager dispatches to A2A sub-agents; each sub-agent owns its MCP server). We also add quantified new findings: the failure rate is ~linear in the number of concurrent McpToolset sessions per process, and the SSE transport does not exhibit the race at all when there is exactly one session.

Steps to Reproduce:

  1. Stand up an MCP server with Streamable HTTP transport.
  2. Build an LlmAgent whose tools include N McpToolset(connection_params=StreamableHTTPServerParams(url=...)) (N β‰₯ 1; the rate rises sharply with N β‰₯ 2).
  3. Serve it with to_a2a(agent) (uvicorn ASGI).
  4. Drive it with repeated A2A requests from a separate manager agent (each request triggers tool discovery / get_tools).
  5. Observe intermittent MCP-session-creation failures; with N β‰₯ 2 the agent regularly proceeds with a truncated tool list and the request fails with Tool '<own-tool>' not found.

Expected Behavior:
McpToolset consistently creates its MCP session and returns the full tool list per A2A request, regardless of how many McpToolsets the agent holds or how it is served.

Observed Behavior:
RuntimeError: Attempted to exit cancel scope in a different task than it was entered in, surfaced as unhandled errors in a TaskGroup (1 sub-exception), in a tight Retrying get_tools loop. Occurrence count per single A2A task, swept by concurrent session count (real production measurements, identical workload):

concurrent McpToolset sessions in the process race occurrences / task task outcome
3 (streamable-http) 84 survived only via ADK retry
2 (streamable-http) 40 HARD FAIL β€” own tools dropped, Tool 'run_python_code' not found
1 (SSE transport) 0 clean, reproduced 0 across many runs
1 (streamable-http) still races β€”

Environment Details:

  • ADK Library Version (pip show google-adk): 1.33.0
  • Desktop OS: Linux (Debian 13 / Docker container; host macOS)
  • Python Version (python -V): 3.12.13

Model Information:

  • Are you using LiteLLM: No
  • Which model is being used: gemini-2.5-flash (model-independent β€” this is a tool-discovery transport bug; it reproduces regardless of model)

🟑 Optional Information

Regression:
Per #4454, worked on ADK 1.14.0; broken from 1.24.x onward. We confirm it is still present on 1.33.0. Bisecting to ADK 1.31.1 did not help.

Logs:

INFO  mcp_session_manager: Retrying get_tools due to error: Failed to
      create MCP session: Failed to create MCP session: unhandled errors
      in a TaskGroup (1 sub-exception)
WARN  session_context: Error on session runner task: unhandled errors in
      a TaskGroup (1 sub-exception)
WARN  llm_agent: Failed to get tools from toolset McpToolset: Failed to
      create MCP session: ... (1 sub-exception)
ERROR a2a_agent_executor_impl: Error handling A2A request:
      Tool 'run_python_code' not found.
ValueError: Tool 'run_python_code' not found.

(The inner RuntimeError: Attempted to exit cancel scope in a different task is collapsed by the task-group exception-squashing; the message string matches #4454 exactly.)

Additional Context:

Minimal Reproduction Code:

from google.adk.agents import LlmAgent
from google.adk.a2a.utils.agent_to_a2a import to_a2a
from google.adk.tools.mcp_tool import McpToolset
from google.adk.tools.mcp_tool.mcp_session_manager import (
    StreamableHTTPServerParams,
)

agent = LlmAgent(
    name="leaf",
    model="gemini-2.5-flash",
    tools=[
        McpToolset(connection_params=StreamableHTTPServerParams(
            url="http://mcp-a:8082/mcp")),
        McpToolset(connection_params=StreamableHTTPServerParams(
            url="http://mcp-b:8081/mcp")),  # 2+ sessions -> high rate
    ],
    disallow_transfer_to_parent=True,
    disallow_transfer_to_peers=True,
)

a2a_app = to_a2a(agent)  # serve with uvicorn; drive with repeated A2A requests

How often has this issue occurred?:

  • Intermittently per attempt, but effectively Always under realistic multi-agent concurrency (84 occurrences in a single task at 3 concurrent sessions).

Activity

  1. sanketpatil06 commented on May 18, 2026

    @sanketpatil06

    Hi @LiuYuWei , Thanks for reporting and providing details.
    When deploying an agent via to_a2a() and subjecting it to concurrent A2A requests, globally instantiated McpToolset objects encounter asynchronous context management conflicts. This causes the tools to fail during initialization and the agent falls back to answering the prompt using only its baseline LLM capabilities.

    Workaround: You can bypass this by avoiding global McpToolset instantiation. wrap the MCP interaction inside a standard ADK FunctionTool so the toolset initializes dynamically per request. This safely isolates the session to the active request's execution context.

    import uvicorn
    from google.adk.agents.llm_agent import LlmAgent
    from google.adk.tools import FunctionTool
    from google.adk.a2a.utils.agent_to_a2a import to_a2a
    from google.adk.tools.mcp_tool import McpToolset
    from google.adk.tools.mcp_tool.mcp_session_manager import StreamableHTTPServerParams
    
    # WORKAROUND: Defer MCP initialization to request-time
    async def dynamic_mcp_caller(prompt: str) -> str:
        """Use this tool to interact with the external MCP system dynamically."""
        # 1. Initialize strictly inside the async request context
        mcp_toolset = McpToolset(
            connection_params=StreamableHTTPServerParams(url="http://localhost:8081/mcp")
        )
        
        # 2. Spin up a localized agent instance to process the MCP toolset internally
        temp_agent = LlmAgent(
            model="gemini-2.5-flash",
            tools=[mcp_toolset]
        )
        
        # 3. Execute and cleanly return the response
        response = await temp_agent.run(prompt)
        return response.text
    
    # 4. Wrap the async function securely using FunctionTool
    mcp_wrapper_tool = FunctionTool(dynamic_mcp_caller)
    
    # 5. Expose the safe wrapper to A2A instead of the global McpToolset
    root_agent = LlmAgent(
        model="gemini-2.5-flash",
        name="a2a_mcp_wrapper_agent",
        tools=[mcp_wrapper_tool]
    )
    
    app = to_a2a(root_agent)
    
    if __name__ == "__main__":
        uvicorn.run(app, host="0.0.0.0", port=8000)
    
    

    Could you please give it a try and let us know if this resolves the issue at your end? Thanks.

  2. LiuYuWei commented on May 19, 2026

    @LiuYuWei
    Author

    Results from testing your per-request workaround (production: to_a2a() multi-agent, stock ADK 1.33, anyio 4.x, MCP streamable-http, with one 3rd-party streamable-http hub we don't control).

    Setup of a clean A/B β€” one agent, same task, same tool surface; the only variable is how the 3rd-party hub's MCP session is held. (We adapted your snippet: instead of a global McpToolset, and instead of a nested temp-LlmAgent+nl-prompt β€” which would lose the precise tool args our workflow needs β€” each tool is a native FunctionTool that opens and closes its own streamable-http ClientSession strictly inside the awaited call body, i.e. one task. Tool names/schemas unchanged.)

    metric (1 real to_a2a task) A: single global McpToolset B: per-request ClientSession (your pattern)
    unhandled errors in a TaskGroup 0 0
    Failed to create MCP session 0 0
    Failed to get tools from MCP server: (empty msg) ~10 / run 0
    external data actually fetched βœ— (empty β†’ task failed) βœ“ (real rows, file produced)
    model re-entries (thrash) 4 1 (clean single pass)
    wall time 30+ min earlier multi-session / stalled ~292 s

    Two distinct failure classes, and your workaround clears both:

    1. The anyio cross-task cancel-scope race (unhandled errors in a TaskGroup) β€” scales with concurrent global McpToolset count (we measured 3 β†’ ~84, 2 β†’ ~40 still fatal, 1 β†’ ~0; transport-independent). Collapsing to a single global McpToolset already drives this to ~0.
    2. A separate residual: even at a single global McpToolset, McpToolset._execute_with_session repeatedly fails with Failed to get tools from MCP server: (empty message, ~10Γ—/run) while the underlying POST /mcp/ returns 200 and an out-of-band tools/list with the same bearer returns 200 + the full tool list (the hub is healthy). This is not the cancel-scope race, and single-session does not fix it.

    Constructing the session per request inside the awaited tool body (your recommendation) eliminated both (1) and (2), and the agent fetched real data on a clean single pass. So the actionable lever is request-task-scoped construction, not merely reducing global McpToolset count β€” a global McpToolset under to_a2a() is problematic even at count = 1 (count only modulates failure (1); failure (2) persists until the session is request-scoped). This aligns with the use_isolated_event_loop direction in #5509.

    (The single residual Traceback in B was an unrelated bug in our own in-process port, not ADK/MCP.)

    Cross-refs: #4454, #5509, modelcontextprotocol/python-sdk #577 / #1805.

  3. LiuYuWei commented on May 21, 2026

    @LiuYuWei
    Author

    Follow-up data: tested under ADK v2.0.0 GA (released 2026-05-19; PR #5509 did not merge into it). I re-ran the same controlled session-count experiment on 2.0 in our setup (to_a2a() multi-agent, streamable-http, the same 3rd-party hub).

    concurrent global McpToolset / agent (Manager) 1.33 (memory baseline) 2.0 GA (this test)
    1 ~0 ~0 (already confirmed in prior comment)
    2 ~40 / run (fatal, tools dropped) 54 unhandled TaskGroup + 27 Failed to create MCP session in ~12 min; only the first sub-agent ever armed, downstream tasks never picked up; [pc-inject] fired 4/5 times (race disrupted injection on one task). Aborted at 12 min, restored single-session, race count dropped back to ~0 immediately.

    So the cancel-scope race scaling with concurrent global McpToolset count is unchanged in 2.0 GA. Single-session (or the per-request FunctionTool pattern you suggested) remains the load-bearing mitigation; reducing global toolset count alone is what keeps our system race-free, not the version bump.

    One genuine improvement in 2.0 worth flagging: the graceful-handling path is noticeably softer. The same race storm under 1.33 + 2-McpToolset surfaced as Failed to get tools from MCP server: (empty message, ~10Γ—/run) + HTTP 500 errors propagating to the channel (users saw "system busy"). Under 2.0 + 2-McpToolset we observed 0 Tracebacks, 0 HTTP 500s, 0 container crash-loops, and 0 Failed to get tools β€” just a continuous stream of unhandled errors in a TaskGroup warnings + retry. The underlying cancel scope is still being crossed across tasks; the surfaced failure mode is now "continuously warn and retry" rather than hard-fail. Tasks no longer crash the channel β€” they just stop progressing silently.

    Net: PR #5509's use_isolated_event_loop direction and the per-request FunctionTool workaround both remain necessary. We're keeping all our workarounds in place on 2.0 (single own McpToolset per agent, the 3rd-party hub via per-request FunctionTool, plus httpx shim for our shared core tools). Happy to share the exact reproducer / harness if useful.

    Cross-refs: #4454, #5509, modelcontextprotocol/python-sdk #577 / #1805.

  4. added
    spam[Status] Issues suspected of having comments which are spam
    on Jul 21, 2026
  5. deleted a comment from adk-bot on Jul 22, 2026
  6. removed
    spam[Status] Issues suspected of having comments which are spam
    on Jul 22, 2026
  7. sanketpatil06 commented on Jul 22, 2026

    @sanketpatil06

    Hi @LiuYuWei , Thank you for the thorough follow-up and confirming that the FunctionTool workaround resolves the issue!
    It is incredibly helpful to know this approach successfully stabilizes tool fetching and mitigates the context leak across both ADK 1.33 and 2.0.0 GA

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

tools[Component] This issue is related to tools

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions