Python: Skip MCP tools and prompts whose normalized names collide (#14236)

### Motivation and Context

MCP tool and prompt name normalization is many-to-one, so two remote
names can produce the same local name. The second registration silently
replaced the first.

### Description

Track normalized names already registered by the plugin and skip
colliding tools or prompts with a warning instead of overwriting.
Reloading the same remote item still refreshes it. Adds unit tests.

### Contribution Checklist

- [x] The code builds clean without any errors or warnings
- [x] The PR follows the SK Contribution Guidelines
- [x] All unit tests pass, and I have added new tests where possible

---------

Co-authored-by: Copilot App <[email protected]>
Copilot-Session: 4cab25f4-d987-42b3-bb34-e8d8a7185c80
This commit is contained in:
SergeyMenshykh
2026-07-29 16:36:50 +00:00
committed by GitHub
co-authored by Copilot App
parent 7d885ddac1
commit b5877044eb
2 changed files with 121 additions and 0 deletions
+28
View File
@@ -278,6 +278,7 @@ class MCPPluginBase:
self.sampling_auto_approve = sampling_auto_approve
self._sampling_auto_approved_warning_logged = False
self._mcp_reserved_attribute_names: set[str] | None = None
self._mcp_registered_names: dict[str, tuple[str, str]] = {}
self._current_task: asyncio.Task | None = None
self._stop_event: asyncio.Event | None = None
@@ -538,6 +539,27 @@ class MCPPluginBase:
)
return True
def _is_mcp_local_name_taken(self, item_type: str, remote_name: str, local_name: str) -> bool:
"""Check whether a normalized name is already bound to a different MCP tool or prompt.
Normalization is not injective, so distinct remote names can collapse into the same local
name. Tools and prompts share one attribute namespace, so registering a collision would
silently rebind an already advertised name to a different remote item.
"""
owner = self._mcp_registered_names.get(local_name)
if owner is None or owner == (item_type, remote_name):
return False
owner_type, owner_name = owner
logger.warning(
"Skipping MCP %s '%s' because normalized name '%s' is already registered by %s '%s'.",
item_type,
remote_name,
local_name,
owner_type,
owner_name,
)
return True
async def load_prompts(self):
"""Load prompts from the MCP server."""
try:
@@ -546,8 +568,11 @@ class MCPPluginBase:
prompt_list = None
for prompt in prompt_list.prompts if prompt_list else []:
local_name = _normalize_mcp_name(prompt.name)
if self._is_mcp_local_name_taken("prompt", prompt.name, local_name):
continue
if self._has_mcp_function_name_conflict("prompt", prompt.name, local_name):
continue
self._mcp_registered_names[local_name] = ("prompt", prompt.name)
func = kernel_function(name=local_name, description=prompt.description)(
partial(self.get_prompt, prompt.name)
)
@@ -563,8 +588,11 @@ class MCPPluginBase:
# Create methods with the kernel_function decorator for each tool
for tool in tool_list.tools if tool_list else []:
local_name = _normalize_mcp_name(tool.name)
if self._is_mcp_local_name_taken("tool", tool.name, local_name):
continue
if self._has_mcp_function_name_conflict("tool", tool.name, local_name):
continue
self._mcp_registered_names[local_name] = ("tool", tool.name)
func = kernel_function(name=local_name, description=tool.description)(partial(self.call_tool, tool.name))
func.__kernel_function_parameters__ = _get_parameter_dicts_from_mcp_tool(tool)
setattr(self, local_name, func)
@@ -429,6 +429,99 @@ async def test_mcp_normalization_function(mock_session, list_tool_calls_with_sla
assert _normalize_mcp_name("Name-With.Dots_And-Hyphens") == "Name-With.Dots_And-Hyphens"
async def test_mcp_tool_name_collision_detected(caplog):
"""Test that tools with names that normalize to the same identifier are detected and skipped."""
plugin = MCPSsePlugin(name="TestMCPPlugin", url="http://localhost:8080/sse")
session = AsyncMock(spec=ClientSession)
session.list_tools.return_value = ListToolsResult(
tools=[
Tool(name="read-document", description="first tool", inputSchema={}),
Tool(name="read document", description="second tool", inputSchema={}),
]
)
plugin.session = session
with caplog.at_level(logging.WARNING, logger="semantic_kernel.connectors.mcp"):
await plugin.load_tools()
# Only the first tool should be registered
assert hasattr(plugin, "read-document")
func = getattr(plugin, "read-document")
assert func.__kernel_function_description__ == "first tool"
# Warning should be emitted for the collision
assert "read document" in caplog.text
assert "already registered" in caplog.text
async def test_mcp_prompt_name_collision_detected(caplog):
"""Test that prompts with names that normalize to the same identifier are detected and skipped."""
plugin = MCPSsePlugin(name="TestMCPPlugin", url="http://localhost:8080/sse")
session = AsyncMock(spec=ClientSession)
session.list_tools.return_value = ListToolsResult(tools=[])
session.list_prompts.return_value = types.ListPromptsResult(
prompts=[
types.Prompt(name="get-summary", description="first prompt", arguments=[]),
types.Prompt(name="get summary", description="second prompt", arguments=[]),
]
)
plugin.session = session
with caplog.at_level(logging.WARNING, logger="semantic_kernel.connectors.mcp"):
await plugin.load_prompts()
# Only the first prompt should be registered
assert hasattr(plugin, "get-summary")
func = getattr(plugin, "get-summary")
assert func.__kernel_function_description__ == "first prompt"
# Warning should be emitted for the collision
assert "get summary" in caplog.text
assert "already registered" in caplog.text
async def test_mcp_tool_name_collision_detected_across_reload(caplog):
"""Test that a later tool reload cannot overwrite a previously registered normalized name."""
plugin = MCPSsePlugin(name="TestMCPPlugin", url="http://localhost:8080/sse")
session = AsyncMock(spec=ClientSession)
session.list_tools.side_effect = [
ListToolsResult(tools=[Tool(name="read-document", description="first tool", inputSchema={})]),
ListToolsResult(tools=[Tool(name="read document", description="second tool", inputSchema={})]),
]
plugin.session = session
await plugin.load_tools()
with caplog.at_level(logging.WARNING, logger="semantic_kernel.connectors.mcp"):
await plugin.load_tools()
func = getattr(plugin, "read-document")
assert func.__kernel_function_description__ == "first tool"
assert "read document" in caplog.text
assert "already registered" in caplog.text
async def test_mcp_prompt_does_not_replace_registered_tool_name(caplog):
"""Test that a prompt does not rebind a normalized name already registered by a tool."""
plugin = MCPSsePlugin(name="TestMCPPlugin", url="http://localhost:8080/sse")
session = AsyncMock(spec=ClientSession)
session.list_tools.return_value = ListToolsResult(
tools=[Tool(name="read-document", description="first tool", inputSchema={})]
)
session.list_prompts.return_value = types.ListPromptsResult(
prompts=[types.Prompt(name="read document", description="second item", arguments=[])]
)
plugin.session = session
await plugin.load_tools()
with caplog.at_level(logging.WARNING, logger="semantic_kernel.connectors.mcp"):
await plugin.load_prompts()
func = getattr(plugin, "read-document")
assert func.__kernel_function_description__ == "first tool"
assert "read document" in caplog.text
assert "already registered" in caplog.text
async def test_excluded_function_cannot_be_called(kernel: "Kernel"):
"""Test that excluded functions are rejected at call time, not just hidden from listing."""
from semantic_kernel.connectors.mcp import create_mcp_server_from_kernel