Skip to content

Python: [Bug]: MCPStreamableHTTPTool can produce duplicate FunctionTool entries in _functions when initial load_tools and a notifications/tools/list_changed reload interleave #5911

@manjunathshiva

Description

@manjunathshiva

Description

When an MCP server sends a notifications/tools/list_changed notification shortly after connect, MCPStreamableHTTPTool._functions can end up with two FunctionTool entries that share the same .name.

Subsequent agent runs fail at _append_unique_tools in _tools.py:914 with ValueError: Duplicate tool name '...'.
tool_name_prefix is already set. Setting allowed_tools=[...] does not resolve the issue — the .functions property keeps every matching entry, so both duplicate copies pass through to _append_unique_tools.

Hypothesis — race between initial load and scheduled reload:
load_tools in _mcp.py:1017 does dedup within a single call by snapshotting existing_names at function entry:

existing_names = {func.name for func in self._functions} # _mcp.py:1029
...
if local_name in existing_names: # _mcp.py:1047
continue

But this snapshot is taken once. If two load_tools calls run concurrently — the initial one from connect() at _mcp.py:766 and one scheduled by _schedule_reload after a notifications/tools/list_changed arrives — both can read an empty existing_names, both fetch the tool list, and both append the same FunctionTool to self._functions.
_schedule_reload only cancels other tasks with the same reload_name:

reload_name = f"mcp-reload:{self.name}:{coro.qualname}"
for existing in list(self._pending_reload_tasks):
if existing.get_name() == reload_name and not existing.done():
existing.cancel()

The initial load_tools call from connect() is not tracked in _pending_reload_tasks, so it is not cancelled when a reload is scheduled. The two can interleave on a single event loop's await boundaries inside the per-page await self.session.list_tools(...) calls.
I have not captured a deterministic repro, but the symptom (duplicate _functions entries with identical .name) and the timing (only fires when an MCP server sends notifications/tools/list_changed) point at this path.
Suggested fix directions (maintainer's call — listed in order of increasing scope):

Serialize load_tools with an asyncio.Lock so initial and reload-triggered calls cannot interleave.
Re-check self._functions immediately before append at _mcp.py:1082 rather than relying only on the entry-time snapshot.
Track the initial load_tools task in _pending_reload_tasks so _schedule_reload can cancel-and-supersede it.
Add a public tool_filter or dedup_strategy hook on MCPStreamableHTTPTool so callers can defend against misbehaving MCP servers without touching private attributes.

load_prompts uses the same existing_names snapshot pattern (_mcp.py:979) and is theoretically subject to the same race.
_append_unique_tools uses an identity check (existing_tool is tool_item) before raising, which is why same-object passes through .functions filtering don't trigger the error — only different FunctionTool objects with the same name do.

Code Sample

from agent_framework import Agent, MCPStreamableHTTPTool
from agent_framework.foundry import FoundryChatClient

mcp = MCPStreamableHTTPTool(
    name="MongoDB",
    url="<mcp-server-url>",
    tool_name_prefix="myprefix",
    load_prompts=False,
    request_timeout=180,
)

async with mcp:
    agent = Agent(client=FoundryChatClient(...), name="x", tools=[mcp])
    response = await agent.run("anything")  # raises ValueError

Error Messages / Stack Traces

ERROR:agent_framework.a2a:A2AExecutor encountered an error during execution.
Traceback (most recent call last):
  File ".../agent_framework_a2a/_a2a_executor.py", line 167, in execute
    await self._run(query, session, updater)
  File ".../agent_framework_a2a/_a2a_executor.py", line 192, in _run
    response = await self._agent.run(query, session=session, stream=False, **self._run_kwargs)
  File ".../agent_framework/_agents.py", line 953, in _run_non_streaming
    ctx = await _prepare_run_context()
  File ".../agent_framework/_agents.py", line 939, in _prepare_run_context
    return await self._prepare_run_context(...)
  File ".../agent_framework/_agents.py", line 1231, in _prepare_run_context
    _append_unique_tools(
        final_tools,
        mcp_server.functions,
        duplicate_error_message=mcp_duplicate_message,
    )
  File ".../agent_framework/_tools.py", line 914, in _append_unique_tools
    _raise_duplicate_tool_name(tool_name, duplicate_error_message)
  File ".../agent_framework/_tools.py", line 885, in _raise_duplicate_tool_name
    raise ValueError(f"Duplicate tool name '{tool_name}'. {message}")
ValueError: Duplicate tool name 'taxagent_aggregate'. Tool names must be unique. Consider setting `tool_name_prefix` on the MCPTool.

Package Versions

agent-framework==1.4.0
agent-framework-a2a==1.0.0b260514
a2a-sdk==1.0.3
azure-ai-projects==2.1.0
azure-identity==1.25.3
fastapi==0.133.0
uvicorn[standard]==0.41.0
pydantic==2.13.4
pydantic-settings==2.14.1
python-dotenv==1.2.2

Python Version

3.14

Additional Context

  • MCP server in use: mongodb-mcp-server (latest). Confirmed to send notifications/tools/list_changed after initial connect.

  • Issue first observed immediately after upgrading from a pre-1.4 version of agent-framework. Pre-upgrade, the same MCP server worked — likely because _append_unique_tools either didn't exist or was more permissive in the prior release.

  • We have been operating with the DedupingMCPStreamableHTTPTool workaround in production for several days without recurrence, which is consistent with the race-condition hypothesis (forced post-load dedup neutralizes both the initial-call append and the reload-call append).

Metadata

Metadata

Labels

Type

No fields configured for Bug.

Projects

Status

No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions