Skip to content

Python: Normalize MCP function names to allowed tool calling values. Add tests. #12420

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions python/semantic_kernel/connectors/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import asyncio
import json
import logging
import re
import sys
from abc import abstractmethod
from collections.abc import Callable, Sequence
Expand Down Expand Up @@ -177,6 +178,12 @@ def _get_parameter_dicts_from_mcp_tool(tool: types.Tool) -> list[dict[str, Any]]
return params


@experimental
def _normalize_mcp_name(name: str) -> str:
"""Normalize MCP tool/prompt names to allowed identifier pattern (A-Za-z0-9_.-)."""
return re.sub(r"[^A-Za-z0-9_.-]", "-", name)


# region: MCP Plugin


Expand Down Expand Up @@ -366,11 +373,12 @@ async def load_prompts(self):
except Exception:
prompt_list = None
for prompt in prompt_list.prompts if prompt_list else []:
func = kernel_function(name=prompt.name, description=prompt.description)(
local_name = _normalize_mcp_name(prompt.name)
func = kernel_function(name=local_name, description=prompt.description)(
partial(self.get_prompt, prompt.name)
)
func.__kernel_function_parameters__ = _get_parameter_dict_from_mcp_prompt(prompt)
setattr(self, prompt.name, func)
setattr(self, local_name, func)

async def load_tools(self):
"""Load tools from the MCP server."""
Expand All @@ -380,9 +388,10 @@ async def load_tools(self):
tool_list = None
# Create methods with the kernel_function decorator for each tool
for tool in tool_list.tools if tool_list else []:
func = kernel_function(name=tool.name, description=tool.description)(partial(self.call_tool, tool.name))
local_name = _normalize_mcp_name(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, tool.name, func)
setattr(self, local_name, func)

async def close(self) -> None:
"""Disconnect from the MCP server."""
Expand Down
61 changes: 61 additions & 0 deletions python/tests/unit/connectors/mcp/test_mcp.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.

import re
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock, patch

Expand All @@ -12,6 +14,24 @@
from semantic_kernel import Kernel


@pytest.fixture
def list_tool_calls_with_slash() -> ListToolsResult:
return ListToolsResult(
tools=[
Tool(
name="nasa/get-astronomy-picture",
description="func with slash",
inputSchema={"properties": {}, "required": []},
),
Tool(
name="weird\\name with spaces",
description="func with backslash and spaces",
inputSchema={"properties": {}, "required": []},
),
]
)


@pytest.fixture
def list_tool_calls() -> ListToolsResult:
return ListToolsResult(
Expand Down Expand Up @@ -230,3 +250,44 @@ async def test_kernel_as_mcp_server(kernel: "Kernel", decorated_native_function,
assert types.ListToolsRequest in server.request_handlers
assert types.CallToolRequest in server.request_handlers
assert server.name == "Semantic Kernel MCP Server"


@patch("semantic_kernel.connectors.mcp.sse_client")
@patch("semantic_kernel.connectors.mcp.ClientSession")
async def test_mcp_tool_name_normalization(mock_session, mock_client, list_tool_calls_with_slash, kernel: "Kernel"):
"""Test that MCP tool names with illegal characters are normalized."""
mock_read = MagicMock()
mock_write = MagicMock()
mock_generator = MagicMock()
mock_generator.__aenter__.return_value = (mock_read, mock_write)
mock_generator.__aexit__.return_value = (mock_read, mock_write)
mock_client.return_value = mock_generator
mock_session.return_value.__aenter__.return_value.list_tools.return_value = list_tool_calls_with_slash

async with MCPSsePlugin(
name="TestMCPPlugin",
description="Test MCP Plugin",
url="http://localhost:8080/sse",
) as plugin:
loaded_plugin = kernel.add_plugin(plugin)
# The normalized names:
assert "nasa-get-astronomy-picture" in loaded_plugin.functions
assert "weird-name-with-spaces" in loaded_plugin.functions
# They should not exist with their original (invalid) names:
assert "nasa/get-astronomy-picture" not in loaded_plugin.functions
assert "weird\\name with spaces" not in loaded_plugin.functions

normalized_names = list(loaded_plugin.functions.keys())
for name in normalized_names:
assert re.match(r"^[A-Za-z0-9_.-]+$", name)


@patch("semantic_kernel.connectors.mcp.ClientSession")
async def test_mcp_normalization_function(mock_session, list_tool_calls_with_slash):
"""Unit test for the normalize_mcp_name function (should exist in codebase)."""
from semantic_kernel.connectors.mcp import _normalize_mcp_name

assert _normalize_mcp_name("nasa/get-astronomy-picture") == "nasa-get-astronomy-picture"
assert _normalize_mcp_name("weird\\name with spaces") == "weird-name-with-spaces"
assert _normalize_mcp_name("simple_name") == "simple_name"
assert _normalize_mcp_name("Name-With.Dots_And-Hyphens") == "Name-With.Dots_And-Hyphens"
Loading