mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
fix duplicate names between supplied tools and mcp servers (#4649)
This commit is contained in:
committed by
GitHub
Unverified
parent
84bae0f42a
commit
b7990908fe
@@ -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!"))
|
||||
|
||||
Reference in New Issue
Block a user