Skip to content

Commit 32d7fa1

Browse files
authored
Python: Normalize MCP function names to allowed tool calling values. Add tests. (#12420)
### Motivation and Context When using some MCP plugins, the tool names or prompt names may contain invalid chars that cause function calling to throw a 400. The following is such a case: ```python async with MCPStdioPlugin( name="nasa", description="nasa Plugin", command="docker", args=["run","-i","--rm","ghcr.io/metorial/mcp-container--programcomputer--nasa-mcp-server--nasa-mcp-server","npm run start"] ) as nasa_plugin: agent = ChatCompletionAgent( service=AzureChatCompletion(), name="NasaAgent", instructions="Answer questions about Nasa.", plugins=[nasa_plugin], ) ``` Some prompt tools with that MCP server contain `/` (forward slashes), which cause the OpenAI model to throw a 400 due to a failed regex. ``` prompt_list.prompts Prompt(name='nasa/get-astronomy-picture', description="Fetch NASA's Astronomy Picture...nail for video content', required=False)]), Prompt(name='nasa/browse-near-earth-objects', description='Find near-Earth asteroids ...rch (YYYY-MM-DD format)', required=True)]), ... ``` We will introduce name normalization when we're loading the kernel functions, and replace any non-allowed values with a hyphen (`-`). This then allows to be called successfully. <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> ### Description - Name normalize any tool or prompt tools from an MCP server. - Closes #12406 - Adds unit test coverage <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [X] The code builds clean without any errors or warnings - [X] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [X] All unit tests pass, and I have added new tests where possible - [X] I didn't break anyone 😄
1 parent b4ed05e commit 32d7fa1

File tree

2 files changed

+74
-4
lines changed

2 files changed

+74
-4
lines changed

python/semantic_kernel/connectors/mcp.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import json
55
import logging
6+
import re
67
import sys
78
from abc import abstractmethod
89
from collections.abc import Callable, Sequence
@@ -177,6 +178,12 @@ def _get_parameter_dicts_from_mcp_tool(tool: types.Tool) -> list[dict[str, Any]]
177178
return params
178179

179180

181+
@experimental
182+
def _normalize_mcp_name(name: str) -> str:
183+
"""Normalize MCP tool/prompt names to allowed identifier pattern (A-Za-z0-9_.-)."""
184+
return re.sub(r"[^A-Za-z0-9_.-]", "-", name)
185+
186+
180187
# region: MCP Plugin
181188

182189

@@ -366,11 +373,12 @@ async def load_prompts(self):
366373
except Exception:
367374
prompt_list = None
368375
for prompt in prompt_list.prompts if prompt_list else []:
369-
func = kernel_function(name=prompt.name, description=prompt.description)(
376+
local_name = _normalize_mcp_name(prompt.name)
377+
func = kernel_function(name=local_name, description=prompt.description)(
370378
partial(self.get_prompt, prompt.name)
371379
)
372380
func.__kernel_function_parameters__ = _get_parameter_dict_from_mcp_prompt(prompt)
373-
setattr(self, prompt.name, func)
381+
setattr(self, local_name, func)
374382

375383
async def load_tools(self):
376384
"""Load tools from the MCP server."""
@@ -380,9 +388,10 @@ async def load_tools(self):
380388
tool_list = None
381389
# Create methods with the kernel_function decorator for each tool
382390
for tool in tool_list.tools if tool_list else []:
383-
func = kernel_function(name=tool.name, description=tool.description)(partial(self.call_tool, tool.name))
391+
local_name = _normalize_mcp_name(tool.name)
392+
func = kernel_function(name=local_name, description=tool.description)(partial(self.call_tool, tool.name))
384393
func.__kernel_function_parameters__ = _get_parameter_dicts_from_mcp_tool(tool)
385-
setattr(self, tool.name, func)
394+
setattr(self, local_name, func)
386395

387396
async def close(self) -> None:
388397
"""Disconnect from the MCP server."""

python/tests/unit/connectors/mcp/test_mcp.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# Copyright (c) Microsoft. All rights reserved.
2+
3+
import re
24
from typing import TYPE_CHECKING
35
from unittest.mock import AsyncMock, MagicMock, patch
46

@@ -12,6 +14,24 @@
1214
from semantic_kernel import Kernel
1315

1416

17+
@pytest.fixture
18+
def list_tool_calls_with_slash() -> ListToolsResult:
19+
return ListToolsResult(
20+
tools=[
21+
Tool(
22+
name="nasa/get-astronomy-picture",
23+
description="func with slash",
24+
inputSchema={"properties": {}, "required": []},
25+
),
26+
Tool(
27+
name="weird\\name with spaces",
28+
description="func with backslash and spaces",
29+
inputSchema={"properties": {}, "required": []},
30+
),
31+
]
32+
)
33+
34+
1535
@pytest.fixture
1636
def list_tool_calls() -> ListToolsResult:
1737
return ListToolsResult(
@@ -230,3 +250,44 @@ async def test_kernel_as_mcp_server(kernel: "Kernel", decorated_native_function,
230250
assert types.ListToolsRequest in server.request_handlers
231251
assert types.CallToolRequest in server.request_handlers
232252
assert server.name == "Semantic Kernel MCP Server"
253+
254+
255+
@patch("semantic_kernel.connectors.mcp.sse_client")
256+
@patch("semantic_kernel.connectors.mcp.ClientSession")
257+
async def test_mcp_tool_name_normalization(mock_session, mock_client, list_tool_calls_with_slash, kernel: "Kernel"):
258+
"""Test that MCP tool names with illegal characters are normalized."""
259+
mock_read = MagicMock()
260+
mock_write = MagicMock()
261+
mock_generator = MagicMock()
262+
mock_generator.__aenter__.return_value = (mock_read, mock_write)
263+
mock_generator.__aexit__.return_value = (mock_read, mock_write)
264+
mock_client.return_value = mock_generator
265+
mock_session.return_value.__aenter__.return_value.list_tools.return_value = list_tool_calls_with_slash
266+
267+
async with MCPSsePlugin(
268+
name="TestMCPPlugin",
269+
description="Test MCP Plugin",
270+
url="http://localhost:8080/sse",
271+
) as plugin:
272+
loaded_plugin = kernel.add_plugin(plugin)
273+
# The normalized names:
274+
assert "nasa-get-astronomy-picture" in loaded_plugin.functions
275+
assert "weird-name-with-spaces" in loaded_plugin.functions
276+
# They should not exist with their original (invalid) names:
277+
assert "nasa/get-astronomy-picture" not in loaded_plugin.functions
278+
assert "weird\\name with spaces" not in loaded_plugin.functions
279+
280+
normalized_names = list(loaded_plugin.functions.keys())
281+
for name in normalized_names:
282+
assert re.match(r"^[A-Za-z0-9_.-]+$", name)
283+
284+
285+
@patch("semantic_kernel.connectors.mcp.ClientSession")
286+
async def test_mcp_normalization_function(mock_session, list_tool_calls_with_slash):
287+
"""Unit test for the normalize_mcp_name function (should exist in codebase)."""
288+
from semantic_kernel.connectors.mcp import _normalize_mcp_name
289+
290+
assert _normalize_mcp_name("nasa/get-astronomy-picture") == "nasa-get-astronomy-picture"
291+
assert _normalize_mcp_name("weird\\name with spaces") == "weird-name-with-spaces"
292+
assert _normalize_mcp_name("simple_name") == "simple_name"
293+
assert _normalize_mcp_name("Name-With.Dots_And-Hyphens") == "Name-With.Dots_And-Hyphens"

0 commit comments

Comments
 (0)