Skip to content

MCP Toolset cleanup race condition when running parallel evaluations #4155

Closed

Description

@alexhermida

Description

When running agent eval set with multiple eval cases and MCPToolset, the evaluation fails most of the time with CancelledError and warnings about cancel scope violations. This fails in version 1.22.1 also.

This happens from the UI, adk eval or pytest. The root cause is a race condition where multiple parallel Runner instances share the same MCPToolset (via a shared root_agent), and each Runner independently calls close() on exit.

As mentioned this fails randomly, since depending on your agent/tools used and eval set.

I think this also relates to #3161

Bug Behavior

The evaluation fails intermittently with errors like:

asyncio.exceptions.CancelledError: Cancelled via cancel scope <id> by <Task ...>

And warnings:

WARNING - Toolset MCPToolset cleanup cancelled: Cancelled via cancel scope...
Warning: Error during MCP session cleanup for stdio_session: Attempted to exit cancel scope in a different task than it was entered in

Steps to Reproduce

  1. Create an agent that uses MCPToolset, you can either use stdio or streamable-http
  2. Create a eval set with at least two eval cases, if you only have one is not going to parellallise anything so won't fail.
  3. Run evaluation with adk eval or pytest using AgentEvaluator.evaluate(), or from the UI selecting both eval cases
  4. The test fails randomly, depending on the test most times will fail.

Inspired on the example shared in PR #3161 in the following comment

And following https://google.github.io/adk-docs/evaluate/#recommendations-on-criteria approach.

Files needed:

  • test_agent/__init__.py
  • test_agent/agent.py
  • test_agent/eval/test_eval.py
  • mcp_server.py
  • and your eval_sets

Example agent (agent.py):

from google.adk.agents import Agent
from google.adk.tools.mcp_tool.mcp_toolset import MCPToolset, StdioServerParameters

root_agent = Agent(
    name="test_agent",
    model="gemini-2.5-flash",
    instruction="You are a calculator assistant.",
    tools=[
        MCPToolset(
            connection_params=StdioServerParameters(
                command="python",
                args=["mcp_server.py"],
            ),
        )
    ],
)
from fastmcp import FastMCP

# Initialize the MCP server
mcp = FastMCP("Addition Server")


# Define a tool to add two numbers
@mcp.tool()
def add_two_numbers(a: int, b: int) -> int:
    """
    Adds two numbers together.
    """
    return a + b


# Run the MCP server
if __name__ == "__main__":
    mcp.run()

You can create a evalset and two eval cases using the adk web UI.

Then, either run both eval cases in the UI or create the followin test:

Example test (test_agent/eval/test_eval.py):

@pytest.mark.asyncio
async def test_basic_mcp_connection():
    await AgentEvaluator.evaluate(
        "test_agent",
        "test_agent/eval/eval_data/basic_test.evalset.json",
        num_runs=1,
    )

Root Cause Analysis

The issue is in the evaluation flow:

  1. LocalEvalService.perform_inference() (local_eval_service.py#L175-L189) runs multiple inferences in parallel (default parallelism=4) using the same root_agent

  2. EvaluationGenerator._generate_inferences_from_root_agent() (evaluation_generator.py#L236-L243) creates a new Runner for each parallel inference, but passes the shared root_agent

  3. Each Runner's close() (runners.py#L1489-L1493) calls toolset.close() on all toolsets from the shared agent

  4. Race condition: When Runner 1 finishes first, it closes the shared MCPToolset. Runners 2, 3, 4 are still running and either:

    • Try to use the now-closed MCP connection
    • Also try to close the already-closed toolset
    • Violate anyio's CancelScope task context rules

Proposed Solution

I'm not very familiar with the code base yet, but I think my approach would be to update the LocalEvalService and MCPToolset, make McpToolset.close() idempotent and manage lifecycle explicitly in LocalEvalService since that would have minimum impact.

I know the project is very active and not sure if there is any related progress on this issue, but happy to open a PR to fix it if that's helpful.


Environment

  • Python: 3.13.0
  • OS: macOS (Darwin)
  • ADK version: 1.22.1
  • Related packages: anyio, fastmcp

Additional Context

  • For few tools and eval cases, the bug sometimes tests pass if the parallel tasks happen to complete in a "safe" order
  • Setting parallelism=1 in evaluation config works around the issue but defeats the purpose of parallel evaluation
  • The issue affects both adk eval CLI and AgentEvaluator.evaluate() in pytest

Activity

  1. added
    mcp[Component] This issues is related to MCP support
    tools[Component] This issue is related to tools
    on Jan 14, 2026
  2. added theissue type on Jan 14, 2026
  3. challenger71498 commented on Jan 16, 2026

    @challenger71498

    Hi @alexhermida, I also encountered a similar issue you have experienced when I tried to run agent as a tool in parallel.

    It seems like the issue happens if:

    • multiple runners run concurrently
    • all runners share the same agent
    • the agent contains a stateful toolset (MCPToolset has a state since MCP itself requires a session)

    Both my case and yours match the case above.

    For my case:

    • AgentTool creates a runner to execute agent as a tool.
    • Run multiple AgentTools which share its agent triggers the issue.

    For your case:

    • Multiple 'eval's trigger multiple runners to run in a same time, triggers the issue.

    I created an issue for this, take a look at #4045 if you are interested in.

  4. challenger71498 commented on Jan 16, 2026

    @challenger71498

    For the case with running AgentTools in parallel, I created a PR #4046 to resolve the issue (only my case though).

    At the PR,

    • I introduced a reference counter(AgentToolManager) to count runner reference to an agent when using AgentTool.
    • Right after AgentTool initializes the runner, it registers the runner to the ref counter.
    • When runner finished its work, AgentTool deregisters the runner from the ref counter.
    • Runner clears the toolset ONLY IF the ref count is zero.

    You may also look into the PR if you are interested in. :)

  5. challenger71498 commented on Jan 16, 2026

    @challenger71498

    And just one more thing..

    The PR above only addresses AgentTool case though, not your case. It does not fix the original issue: concurrency issue when executing runner in parallel with a same agent. This is on purpose since I thought only my case would be a practical issue at that time. (Well, now we do have another actual issue: your case 👍)

    I tried to resolve the original issue, but found out that this is rather an architectural issue and seems like the issue cannot be resolved by simple PRs. I think we need to discuss the design of resource management of the toolset in runner with maintainers.

    From what I have researched about the architecture about the ADK is that:

    • Agent, Runner and Toolset have different lifecycles.
      • Agent is rather a blueprint; it does not have a lifecycle(Basically it is just a pydantic model).
      • Runner is a stateful object. An agent is injected to the runner.
      • Toolset is a group of tools, and it may have state, depends on its implementation. It is injected to the Agent.

    So if Toolset is stateful, should runner cleanup the toolset when the runner is closing?

    IMO this is NO. Even if a Runner and a Toolset may have states and should be cleanup when their life is done, it should be handled seperately. Since a Toolset is injected to the Agent, a code scope where injects the Toolset also has resposibility to cleanup the toolset.

    I have not researched further to determine where should we put this Toolset management though, before we go I think we need to have enough conversation to whether should we consider this approach or try something else.


    Please let me know if there is a correction needed.
    Your opinions are greatly appreciated :)
    Thank you.

  6. alexhermida commented on Jan 16, 2026

    @alexhermida
    Author

    Hi @challenger71498! Thank you for all your detailed comments. After a quick look to your PR, as you said, I think that doesn't directly address the problem with the original MCPToolset but looks progress and might made the fix needed for this simpler, since you're already implementing cleanup_toolsets in the Runner.close()

    To be honest, I've already encountered this issue just running multiple agents with tools throught the Runner, but in that case I have more control and I workarounded it. I've opened this issue focused on fixing the out-of-the-box evals.

    I agree with you on the more fundamental arquitectural decision here. I think your change in the Runner.close() looks good short term but that way assumes that owns the toolsets and closes them, but toolsets are being injected into the Agent, not the Runner, so should be the agent responsible of closing the tools.

    Personally, would be great to see this issues fixed soon but also having more medium/long term view on the approach. Happy to contribute, at the same time I know that this kind of changes might need several reviews 😄

  7. challenger71498 commented on Jan 16, 2026

    @challenger71498

    Thank you for your reply, @alexhermida!

    To be honest, I've already encountered this issue just running multiple agents with tools throught the Runner, but in that case I have more control and I workarounded it.

    Glad to hear that you have found a workaround. Just out of curiosity, I wonder how you workarounded the issue. Was it the same way I've done, or something else?

    that way assumes that owns the toolsets and closes them, but toolsets are being injected into the Agent, not the Runner, so should be the agent responsible of closing the tools.

    Totally agree with your view. The PR could fix the issue temporarily, but the root cause should be resolved as you mentioned.

  8. alexhermida commented on Jan 16, 2026

    @alexhermida
    Author

    Was it the same way I've done, or something else?

    It's kind of simple approach, in the agent I have a factory function that returns the agent and the toolset, like

    def get_agent_and_toolset() -> tuple[Agent, list[McpToolset]]:
    

    In my runners module, I use asyncio.gather to run all tasks concurrently so I create a new agent instance, that ensures each parallel task has its own independent agent and MCP connections.

  9. surajksharma07 commented on Jan 29, 2026

    @surajksharma07
    Collaborator

    The root cause is in runners.py:1528-1532 — each parallel Runner independently calls _cleanup_toolsets() on the shared agent's toolsets, so when Runner 1 finishes it tears down the MCPToolset connection while Runners 2-4 are still using it.

    A ref-counting guard on Runner.__aenter__/__aexit__ that only calls toolset.close() when the last Runner exits would fix this.

    As a workaround, you can use an agent factory that returns a fresh Agent + MCPToolset instance per eval case instead of sharing one agent across parallel inferences — @challenger71498's comment above describes the same pattern.

  10. alexhermida commented on Jan 29, 2026

    @alexhermida
    Author

    Thanks @surajksharma07

    As a workaround, you can use an agent factory that returns a fresh Agent + MCPToolset instance per eval case instead of sharing one agent across parallel inferences —

    Yes, that's what we did 😄

    Great to see this moving forward, thanks again.

  11. surajksharma07 commented on Feb 8, 2026

    @surajksharma07
    Collaborator

    Still unresolved as of v1.24.1 — runners.py _cleanup_toolsets() has no ref-counting or idempotent-close guard, and local_eval_service.py:174 still shares the same agent instance across parallel inferences.

    Commit cce430da fixed cancel-scope task issues but not this race condition.

    The agent-factory workaround (fresh Agent+MCPToolset per eval case) remains the recommended approach until this is addressed architecturally.

  12. added
    needs review[Status] The PR/issue is awaiting review from the maintainer
    on Feb 8, 2026
  13. surajksharma07 commented on Feb 8, 2026

    @surajksharma07
    Collaborator

    @seanzhou1023 Please have a look into it once.

  14. removed their assignment
    on Feb 24, 2026
  15. surajksharma07 commented on Apr 28, 2026

    @surajksharma07
    Collaborator

    The agent-factory workaround (fresh Agent + MCPToolset per eval case) you confirmed in January remains the recommended path until a ref-counting fix lands.

    Closing this out since there's been no follow-up for a few months — feel free to reopen or link a PR here if you'd like to contribute the fix, happy to review it!

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

Metadata

Metadata

Labels

mcp[Component] This issues is related to MCP supportneeds review[Status] The PR/issue is awaiting review from the maintainertools[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