fix duplicate names between supplied tools and mcp servers (#4649)

This commit is contained in:
Eduard van Valkenburg
2026-03-13 09:22:56 +01:00
committed by GitHub
Unverified
parent 84bae0f42a
commit b7990908fe
8 changed files with 385 additions and 101 deletions
+122 -21
View File
@@ -30,7 +30,7 @@ from agent_framework import (
tool,
)
from agent_framework._agents import _get_tool_name, _merge_options, _sanitize_agent_name
from agent_framework._mcp import MCPTool
from agent_framework._mcp import MCPTool, _build_prefixed_mcp_name, _normalize_mcp_name
class _FixedTokenizer:
@@ -41,6 +41,30 @@ class _FixedTokenizer:
return self.token_count
class _ConnectedMCPTool(MCPTool):
def __init__(self, name: str, function_names: list[str], *, tool_name_prefix: str | None = None) -> None:
super().__init__(name=name, tool_name_prefix=tool_name_prefix)
self.is_connected = True
self._functions = []
for function_name in function_names:
normalized_name = _normalize_mcp_name(function_name)
exposed_name = _build_prefixed_mcp_name(normalized_name, self.tool_name_prefix)
self._functions.append(
FunctionTool(
func=lambda value=function_name: value,
name=exposed_name,
description=f"{function_name} from {name}",
additional_properties={
"_mcp_remote_name": function_name,
"_mcp_normalized_name": normalized_name,
},
)
)
def get_mcp_client(self) -> contextlib.AbstractAsyncContextManager[Any]:
raise NotImplementedError
def test_agent_session_type(agent_session: AgentSession) -> None:
assert isinstance(agent_session, AgentSession)
@@ -953,6 +977,7 @@ async def test_chat_agent_run_with_mcp_tools(client: SupportsChatGetResponse) ->
# Create a mock MCP tool
mock_mcp_tool = MagicMock(spec=MCPTool)
mock_mcp_tool.name = "mock-mcp"
mock_mcp_tool.is_connected = False
mock_mcp_tool.functions = [MagicMock()]
@@ -970,6 +995,7 @@ async def test_chat_agent_with_local_mcp_tools(client: SupportsChatGetResponse)
"""Test agent initialization with local MCP tools."""
# Create a mock MCP tool
mock_mcp_tool = MagicMock(spec=MCPTool)
mock_mcp_tool.name = "mock-mcp"
mock_mcp_tool.is_connected = False
mock_mcp_tool.__aenter__ = AsyncMock(return_value=mock_mcp_tool)
mock_mcp_tool.__aexit__ = AsyncMock(return_value=None)
@@ -1009,6 +1035,7 @@ async def test_mcp_tools_not_duplicated_when_passed_as_runtime_tools(
# Create a mock MCP tool that is already connected (simulates turn 2)
mock_mcp_tool = MagicMock(spec=MCPTool)
mock_mcp_tool.name = "mock-mcp"
mock_mcp_tool.is_connected = True
mock_mcp_tool.functions = [mcp_func_a, mcp_func_b]
mock_mcp_tool.__aenter__ = AsyncMock(return_value=mock_mcp_tool)
@@ -1032,6 +1059,77 @@ async def test_mcp_tools_not_duplicated_when_passed_as_runtime_tools(
assert len(tool_names) == 3
async def test_agent_run_raises_on_local_and_agent_mcp_name_conflict(chat_client_base: Any) -> None:
local_tool = FunctionTool(
func=lambda: "local",
name="delete_all_data",
description="Local protected tool",
approval_mode="always_require",
)
agent = Agent(
client=chat_client_base,
name="TestAgent",
tools=[_ConnectedMCPTool(name="dangerous-mcp", function_names=["delete_all_data"])],
)
with raises(ValueError, match="tool_name_prefix"):
await agent.run("hello", tools=[local_tool])
async def test_agent_run_raises_on_runtime_local_and_runtime_mcp_name_conflict(chat_client_base: Any) -> None:
local_tool = FunctionTool(
func=lambda: "local",
name="delete_all_data",
description="Local protected tool",
approval_mode="always_require",
)
runtime_mcp = _ConnectedMCPTool(name="dangerous-mcp", function_names=["delete_all_data"])
agent = Agent(client=chat_client_base, name="TestAgent")
with raises(ValueError, match="tool_name_prefix"):
await agent.run("hello", tools=[local_tool, runtime_mcp])
async def test_agent_run_raises_on_duplicate_agent_mcp_names(chat_client_base: Any) -> None:
agent = Agent(
client=chat_client_base,
name="TestAgent",
tools=[
_ConnectedMCPTool(name="docs-mcp", function_names=["search"]),
_ConnectedMCPTool(name="github-mcp", function_names=["search"]),
],
)
with raises(ValueError, match="tool_name_prefix"):
await agent.run("hello")
async def test_agent_run_accepts_prefixed_mcp_tools(chat_client_base: Any) -> None:
captured_options: list[dict[str, Any]] = []
original_inner = chat_client_base._inner_get_response
async def capturing_inner(
*, messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any
) -> ChatResponse:
captured_options.append(dict(options))
return await original_inner(messages=messages, options=options, **kwargs)
chat_client_base._inner_get_response = capturing_inner
local_tool = FunctionTool(func=lambda: "local", name="search", description="Local search tool")
agent = Agent(
client=chat_client_base,
name="TestAgent",
tools=[_ConnectedMCPTool(name="docs-mcp", function_names=["search"], tool_name_prefix="docs")],
)
await agent.run("hello", tools=[local_tool])
tool_names = [tool.name for tool in captured_options[0]["tools"]]
assert tool_names == ["search", "docs_search"]
async def test_agent_tool_receives_session_in_kwargs(chat_client_base: Any) -> None:
"""Verify tool execution receives 'session' inside **kwargs when function is called by client."""
@@ -1291,7 +1389,7 @@ def test_merge_options_none_values_ignored():
def test_merge_options_tools_combined():
"""Test _merge_options combines tool lists without duplicates."""
"""Test _merge_options raises when distinct tools share the same name."""
class MockTool:
def __init__(self, name):
@@ -1304,13 +1402,8 @@ def test_merge_options_tools_combined():
base = {"tools": [tool1]}
override = {"tools": [tool2, tool3]}
result = _merge_options(base, override)
# Should have tool1 and tool2, but not duplicate tool3
assert len(result["tools"]) == 2
tool_names = [t.name for t in result["tools"]]
assert "tool1" in tool_names
assert "tool2" in tool_names
with raises(ValueError, match="Duplicate tool name 'tool1'"):
_merge_options(base, override)
def test_merge_options_dict_tools_combined():
@@ -1335,7 +1428,7 @@ def test_merge_options_dict_tools_combined():
def test_merge_options_dict_tools_deduplicates():
"""Test _merge_options deduplicates dict-defined tools by function name."""
"""Test _merge_options raises on duplicate dict-defined tool names."""
base = {
"tools": [
{"type": "function", "function": {"name": "tool_a"}},
@@ -1348,12 +1441,8 @@ def test_merge_options_dict_tools_deduplicates():
]
}
result = _merge_options(base, override)
assert len(result["tools"]) == 2
names = [_get_tool_name(t) for t in result["tools"]]
assert names.count("tool_a") == 1
assert "tool_b" in names
with raises(ValueError, match="Duplicate tool name 'tool_a'"):
_merge_options(base, override)
def test_merge_options_mixed_tools_combined():
@@ -1379,7 +1468,7 @@ def test_merge_options_mixed_tools_combined():
def test_merge_options_mixed_tools_deduplicates():
"""Test _merge_options deduplicates when a dict tool and object tool share the same name."""
"""Test _merge_options raises when a dict tool and object tool share the same name."""
class MockTool:
def __init__(self, name):
@@ -1392,10 +1481,8 @@ def test_merge_options_mixed_tools_deduplicates():
]
}
result = _merge_options(base, override)
assert len(result["tools"]) == 1
assert _get_tool_name(result["tools"][0]) == "tool_a"
with raises(ValueError, match="Duplicate tool name 'tool_a'"):
_merge_options(base, override)
def test_merge_options_nameless_tools_not_deduplicated():
@@ -1417,6 +1504,20 @@ def test_merge_options_nameless_tools_not_deduplicated():
assert len(result["tools"]) == 2
def test_merge_options_same_tool_object_kept_once():
"""Test _merge_options silently keeps a repeated reference to the same tool object once."""
class MockTool:
def __init__(self, name):
self.name = name
tool_a = MockTool("tool_a")
result = _merge_options({"tools": [tool_a]}, {"tools": [tool_a]})
assert result["tools"] == [tool_a]
def test_get_tool_name_dict_no_function_key():
"""_get_tool_name returns None for a dict without a 'function' key."""
assert _get_tool_name({"type": "function"}) is None
@@ -53,6 +53,81 @@ def test_normalize_mcp_name():
assert _normalize_mcp_name("name/with\\slashes") == "name-with-slashes"
def test_mcp_transport_subclasses_accept_tool_name_prefix() -> None:
assert MCPStdioTool(name="stdio", command="python", tool_name_prefix="stdio").tool_name_prefix == "stdio"
assert (
MCPStreamableHTTPTool(
name="http",
url="https://example.com/mcp",
tool_name_prefix="http",
).tool_name_prefix
== "http"
)
assert (
MCPWebsocketTool(
name="ws",
url="wss://example.com/mcp",
tool_name_prefix="ws",
).tool_name_prefix
== "ws"
)
async def test_load_tools_with_tool_name_prefix_preserves_matching_configuration():
"""Prefixed MCP tool names should still honor unprefixed allow/approval configuration."""
tool = MCPTool(
name="docs",
tool_name_prefix="docs",
allowed_tools=["search_docs"],
approval_mode={"always_require_approval": ["search_docs"]},
)
mock_session = AsyncMock()
tool.session = mock_session
tool.load_tools_flag = True
page = Mock()
page.tools = [
types.Tool(
name="search_docs",
description="Search docs",
inputSchema={"type": "object", "properties": {"query": {"type": "string"}}},
),
]
page.nextCursor = None
mock_session.list_tools = AsyncMock(return_value=page)
await tool.load_tools()
assert [function.name for function in tool._functions] == ["docs_search_docs"]
assert [function.name for function in tool.functions] == ["docs_search_docs"]
assert tool.functions[0].approval_mode == "always_require"
async def test_load_prompts_with_tool_name_prefix() -> None:
"""Prefixed MCP prompt names should be exposed with the configured prefix."""
tool = MCPTool(name="docs", tool_name_prefix="docs")
mock_session = AsyncMock()
tool.session = mock_session
tool.load_prompts_flag = True
page = Mock()
page.prompts = [
types.Prompt(
name="summarize docs",
description="Summarize docs",
arguments=[types.PromptArgument(name="topic", description="Topic", required=True)],
),
]
page.nextCursor = None
mock_session.list_prompts = AsyncMock(return_value=page)
await tool.load_prompts()
assert [function.name for function in tool._functions] == ["docs_summarize-docs"]
def test_mcp_prompt_message_to_ai_content():
"""Test conversion from MCP prompt message to AI content."""
mcp_message = types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hello, world!"))