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).
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 ValueErrorError Messages / Stack Traces
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).