From 1b3d445d8387fb7684c9c79854ea8bc77eadd483 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Wed, 30 Sep 2026 14:31:10 +0100 Subject: [PATCH 1/3] Python: Make MCP server tool names consistent Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- python/samples/demos/mcp_server/README.md | 26 ++- python/semantic_kernel/agents/agent.py | 3 + python/semantic_kernel/connectors/mcp.py | 54 +++++-- python/semantic_kernel/kernel.py | 13 +- python/tests/unit/agents/test_agent.py | 10 ++ python/tests/unit/connectors/mcp/test_mcp.py | 161 ++++++++++++++++++- 6 files changed, 245 insertions(+), 22 deletions(-) diff --git a/python/samples/demos/mcp_server/README.md b/python/samples/demos/mcp_server/README.md index 1aef1595c1d2..19f026c7b3d0 100644 --- a/python/samples/demos/mcp_server/README.md +++ b/python/samples/demos/mcp_server/README.md @@ -83,12 +83,32 @@ In both cases, `uv` will ensure that `semantic-kernel` is installed with the `mc The *sk_mcp_server* sample creates two functions: -- `echo-echo_function`: A simple function that echoes back the input. -- `prompt-prompt`: a function that uses a Semantic Kernel prompt to generate a response. +- `echo_function`: A simple function that echoes back the input. +- `prompt`: a function that uses a Semantic Kernel prompt to generate a response. The *agent_mcp_server* sample creates a simple agent that uses the Azure OpenAI service to generate a response. It exposes a single function: -- `mcp-host`: A function that uses the Azure OpenAI service to generate a response. +- `Host`: A function that uses the Azure OpenAI service to generate a response. Once the server is created, you get a `mcp.server.lowlevel.Server` object, which you can then extend to add further functionality, like resources or prompts. + +## Tool names + +By default, tools use bare function names. When functions in different plugins have the same +name, the server exposes the first function and logs a warning for each skipped duplicate. +Discovery and invocation use the same retained function. + +To expose both functions, enable plugin-qualified names: + +```python +server = kernel.as_mcp_server(use_plugin_names=True) +``` + +For example, the sample's tools become `echo-echo_function` and `prompt-prompt`. +The option is also available on `create_mcp_server_from_kernel`, +`create_mcp_server_from_functions`, and `Agent.as_mcp_server`. +Tool names longer than MCP's recommended 128 characters are exposed with a warning. +Clients may impose stricter name-length limits. +Enabling the option changes public tool names, so existing callers must use the new names. +The `excluded_functions` option continues to use bare function names in either mode. diff --git a/python/semantic_kernel/agents/agent.py b/python/semantic_kernel/agents/agent.py index 3210dc51de02..f9d236ab26c9 100644 --- a/python/semantic_kernel/agents/agent.py +++ b/python/semantic_kernel/agents/agent.py @@ -564,6 +564,7 @@ def as_mcp_server( version: str | None = None, instructions: str | None = None, lifespan: Callable[["Server[LifespanResultT]"], AbstractAsyncContextManager["LifespanResultT"]] | None = None, + use_plugin_names: bool = False, ) -> "Server[LifespanResultT]": """Convert the agent to an MCP server. @@ -572,6 +573,7 @@ def as_mcp_server( By default, the server name will be the same as the agent name. If a server name is provided, it will be used instead. + Set `use_plugin_names=True` to prefix the tool name with its plugin name. Returns: The MCP server instance. @@ -585,6 +587,7 @@ def as_mcp_server( version=version, instructions=instructions, lifespan=lifespan, + use_plugin_names=use_plugin_names, ) diff --git a/python/semantic_kernel/connectors/mcp.py b/python/semantic_kernel/connectors/mcp.py index 789461b2c805..ff04f9fd40a3 100644 --- a/python/semantic_kernel/connectors/mcp.py +++ b/python/semantic_kernel/connectors/mcp.py @@ -978,6 +978,7 @@ def create_mcp_server_from_functions( instructions: str | None = None, lifespan: Callable[[Server["LifespanResultT"]], AbstractAsyncContextManager["LifespanResultT"]] | None = None, plugin_name: str = "mcp", + use_plugin_names: bool = False, **kwargs: Any, ) -> Server["LifespanResultT"]: """Create an MCP server from a function(s) or plugin(s). @@ -997,6 +998,8 @@ def create_mcp_server_from_functions( instructions: The instructions to use for the server. lifespan: The lifespan of the server. plugin_name: The name of the plugin to use. + use_plugin_names: Whether to prefix tool names with their plugin name, separated by a hyphen. + Defaults to False. kwargs: Any extra arguments to pass to the server creation. Returns: @@ -1026,6 +1029,7 @@ def create_mcp_server_from_functions( version=version, instructions=instructions, lifespan=lifespan, + use_plugin_names=use_plugin_names, **kwargs, ) @@ -1040,6 +1044,7 @@ def create_mcp_server_from_kernel( instructions: str | None = None, lifespan: Callable[[Server["LifespanResultT"]], AbstractAsyncContextManager["LifespanResultT"]] | None = None, excluded_functions: OptionalOneOrMany[str] = None, + use_plugin_names: bool = False, **kwargs: Any, ) -> Server["LifespanResultT"]: """Create an MCP server from a kernel instance. @@ -1047,8 +1052,13 @@ def create_mcp_server_from_kernel( This function automatically creates a MCP server from a kernel instance, it uses the provided arguments to configure the server and expose functions as tools and prompts, see the mcp documentation for more details. - By default, all functions are exposed as Tools, you can control this by using use the `excluded_functions` argument. - These need to be set to the function name, without the plugin_name. + By default, functions are exposed as Tools using their bare function names. If names collide, + the first function is exposed and subsequent functions are skipped with a warning. + Set `use_plugin_names=True` to expose names as `-` instead. + The same function is used for each tool's metadata and invocation. + Tool names over MCP's recommended 128 characters are exposed with a warning. + + Exclude functions using `excluded_functions`, with bare function names regardless of `use_plugin_names`. Args: kernel: The kernel instance to use. @@ -1059,6 +1069,8 @@ def create_mcp_server_from_kernel( lifespan: The lifespan of the server. excluded_functions: The list of function names to exclude from the server. if None, no functions will be excluded. + use_plugin_names: Whether to prefix tool names with their plugin name, separated by a hyphen. + Defaults to False. kwargs: Any extra arguments to pass to the server creation. Returns: @@ -1081,19 +1093,38 @@ def create_mcp_server_from_kernel( server: Server["LifespanResultT"] = Server(**server_args) # type: ignore[call-arg] - functions_to_expose = [ - func for func in kernel.get_full_list_of_function_metadata() if func.name not in (excluded_functions or []) - ] - exposed_names = frozenset(func.name for func in functions_to_expose) + # Map each public tool name to the exact function used for both listing and calls. + functions_to_expose: dict[str, KernelFunction] = {} + for metadata in kernel.get_full_list_of_function_metadata(): + if metadata.name in (excluded_functions or []): + continue + + tool_name = metadata.fully_qualified_name if use_plugin_names else metadata.name + + # Keep the first function if multiple plugins expose the same bare name. + if tool_name in functions_to_expose: + logger.warning( + "Skipping function '%s' because MCP tool name '%s' is already registered by '%s'.", + metadata.fully_qualified_name, + tool_name, + functions_to_expose[tool_name].fully_qualified_name, + ) + continue - if len(functions_to_expose) > 0: + if len(tool_name) > 128: + logger.warning("MCP tool name '%s' exceeds the recommended 128 characters.", tool_name) + + functions_to_expose[tool_name] = kernel.get_function(metadata.plugin_name, metadata.name) + + if functions_to_expose: @server.list_tools() async def _list_tools() -> list[types.Tool]: """List all tools in the kernel.""" + # The public name may be plugin-qualified while func.name stays bare. tools = [ types.Tool( - name=func.name, + name=tool_name, description=func.description, inputSchema={ "type": "object", @@ -1109,7 +1140,7 @@ async def _list_tools() -> list[types.Tool]: ], }, ) - for func in functions_to_expose + for tool_name, func in functions_to_expose.items() ] await _log(level="debug", data=f"List of tools: {tools}") await asyncio.sleep(0.0) @@ -1121,7 +1152,7 @@ async def _call_tool( ) -> Sequence[types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource]: """Call a tool in the kernel.""" function_name, arguments = args[0], args[1] - if function_name not in exposed_names: + if function_name not in functions_to_expose: raise McpError( error=types.ErrorData( code=types.METHOD_NOT_FOUND, @@ -1232,7 +1263,8 @@ async def _set_logging_level(level: types.LoggingLevel) -> None: await _log(level=level, data=f"Log level set to {level}") async def _call_kernel_function(function_name: str, arguments: Any) -> FunctionResult | None: - function = kernel.get_function(plugin_name=None, function_name=function_name) + function = functions_to_expose[function_name] + arguments["server"] = server return await function.invoke(kernel=kernel, **arguments) diff --git a/python/semantic_kernel/kernel.py b/python/semantic_kernel/kernel.py index 5d74e42c4e26..f5c02f610872 100644 --- a/python/semantic_kernel/kernel.py +++ b/python/semantic_kernel/kernel.py @@ -584,6 +584,8 @@ def as_mcp_server( instructions: str | None = None, lifespan: Callable[["Server[LifespanResultT]"], AbstractAsyncContextManager["LifespanResultT"]] | None = None, excluded_functions: OptionalOneOrMany[str] = None, + *, + use_plugin_names: bool = False, **kwargs: Any, ) -> "Server": """Create a MCP server from this kernel. @@ -591,9 +593,11 @@ def as_mcp_server( This function automatically creates a MCP server from a kernel instance, it uses the provided arguments to configure the server and expose functions as tools and prompts, see the mcp documentation for more details. - By default, all functions are exposed as Tools, you can control this by - using use the `excluded_functions` argument. - These need to be set to the function name, without the plugin_name. + By default, functions are exposed as Tools using bare function names. + Duplicate names retain the first function and produce a warning. + Set `use_plugin_names=True` to expose `-` tool names. + The `excluded_functions` argument always uses bare function names. + Tool names over MCP's recommended 128 characters are exposed with a warning. Args: kernel: The kernel instance to use. @@ -604,6 +608,8 @@ def as_mcp_server( lifespan: The lifespan of the server. excluded_functions: The list of function names to exclude from the server. if None, no functions will be excluded. + use_plugin_names: Whether to prefix tool names with their plugin name, separated by a hyphen. + Defaults to False. kwargs: Any extra arguments to pass to the server creation. Returns: @@ -621,5 +627,6 @@ def as_mcp_server( instructions=instructions, lifespan=lifespan, excluded_functions=excluded_functions, + use_plugin_names=use_plugin_names, **kwargs, ) diff --git a/python/tests/unit/agents/test_agent.py b/python/tests/unit/agents/test_agent.py index ec4ec8c937f3..e47d2c7b10f4 100644 --- a/python/tests/unit/agents/test_agent.py +++ b/python/tests/unit/agents/test_agent.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock import pytest +from mcp.shared.memory import create_connected_server_and_client_session from semantic_kernel.agents.agent import AGENT_TYPE_REGISTRY, AgentRegistry, DeclarativeSpecMixin, register_agent_type from semantic_kernel.exceptions.agent_exceptions import AgentInitializationException @@ -85,6 +86,15 @@ async def test_agent_default_id(): assert isinstance(uuid.UUID(agent.id), uuid.UUID) +@pytest.mark.parametrize("use_plugin_names", [False, True]) +async def test_agent_as_mcp_server_tool_name(use_plugin_names): + agent = MockAgent() + server = agent.as_mcp_server(use_plugin_names=use_plugin_names) + async with create_connected_server_and_client_session(server) as client: + expected_name = f"mcp-{agent.name}" if use_plugin_names else agent.name + assert [tool.name for tool in (await client.list_tools()).tools] == [expected_name] + + def test_get_channel_keys(): agent = MockAgent() keys = agent.get_channel_keys() diff --git a/python/tests/unit/connectors/mcp/test_mcp.py b/python/tests/unit/connectors/mcp/test_mcp.py index b4623489de12..49a8abd8c589 100644 --- a/python/tests/unit/connectors/mcp/test_mcp.py +++ b/python/tests/unit/connectors/mcp/test_mcp.py @@ -7,9 +7,18 @@ import pytest from mcp import ClientSession, ListToolsResult, StdioServerParameters, Tool, types - -from semantic_kernel.connectors.mcp import MCPSsePlugin, MCPStdioPlugin, MCPStreamableHttpPlugin, MCPWebsocketPlugin +from mcp.shared.memory import create_connected_server_and_client_session + +from semantic_kernel.connectors.mcp import ( + MCPSsePlugin, + MCPStdioPlugin, + MCPStreamableHttpPlugin, + MCPWebsocketPlugin, + create_mcp_server_from_functions, + create_mcp_server_from_kernel, +) from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError +from semantic_kernel.functions import KernelFunction, kernel_function if TYPE_CHECKING: from semantic_kernel import Kernel @@ -524,9 +533,6 @@ async def test_mcp_prompt_does_not_replace_registered_tool_name(caplog): 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 - from semantic_kernel.functions.kernel_function_decorator import kernel_function - side_effect_called = False @kernel_function(name="public_echo") @@ -571,3 +577,148 @@ async def _fake_get_cached_tool_definition(tool_name): f"Expected 'Unknown tool' error, got: {result.root.content}" ) assert not side_effect_called, "Excluded function's side effect should not have fired" + + +@pytest.mark.parametrize("use_plugin_names", [False, True]) +@pytest.mark.parametrize("reverse_registration", [False, True]) +async def test_mcp_server_duplicate_names(kernel, caplog, use_plugin_names, reverse_registration): + calls = [] + + @kernel_function(name="process_document", description="First schema") + def first(first_id: str) -> str: + calls.append("first") + return first_id + + @kernel_function(name="process_document", description="Second schema") + def second(second_id: int) -> str: + calls.append("second") + return str(second_id) + + registrations = [ + ("First", first, "first_id", "one", "first"), + ("Second", second, "second_id", 2, "second"), + ] + if reverse_registration: + registrations.reverse() + for plugin_name, function, *_ in registrations: + kernel.add_function(plugin_name, function) + + with caplog.at_level(logging.WARNING, logger="semantic_kernel.connectors.mcp"): + server = kernel.as_mcp_server(**({"use_plugin_names": True} if use_plugin_names else {})) + + if use_plugin_names: + assert not caplog.records + else: + assert "Skipping function" in caplog.text + assert "First-process_document" in caplog.text + assert "Second-process_document" in caplog.text + + retained = registrations if use_plugin_names else registrations[:1] + async with create_connected_server_and_client_session(server) as client: + tools = (await client.list_tools()).tools + assert len(tools) == len(retained) + for tool, (plugin_name, function, parameter, value, marker) in zip(tools, retained): + expected_name = f"{plugin_name}-process_document" if use_plugin_names else "process_document" + assert tool.name == expected_name + assert tool.description == function.__kernel_function_description__ + assert tool.inputSchema["required"] == [parameter] + assert set(tool.inputSchema["properties"]) == {parameter} + result = await client.call_tool(tool.name, {parameter: value}) + assert not result.isError + assert result.content == [types.TextContent(type="text", text=str(value))] + assert calls[-1] == marker + + unexposed_name = "process_document" if use_plugin_names else "First-process_document" + result = await client.call_tool(unexposed_name, {}) + assert result.isError + assert "Unknown tool" in result.content[0].text + assert calls == [registration[-1] for registration in retained] + + +@pytest.mark.parametrize("use_plugin_names", [False, True]) +@pytest.mark.parametrize("excluded_functions", ["secret", ["secret"]]) +async def test_mcp_server_exclusions_use_bare_names(kernel, use_plugin_names, excluded_functions): + calls = [] + + @kernel_function + def public() -> str: + return "public" + + @kernel_function + def secret() -> str: + calls.append("secret") + return "secret" + + kernel.add_function("First", public) + kernel.add_function("First", secret) + kernel.add_function("Second", secret) + server = create_mcp_server_from_kernel( + kernel, use_plugin_names=use_plugin_names, excluded_functions=excluded_functions + ) + + async with create_connected_server_and_client_session(server) as client: + tools = (await client.list_tools()).tools + public_name = "First-public" if use_plugin_names else "public" + assert [tool.name for tool in tools] == [public_name] + for name in ("secret", "First-secret", "Second-secret"): + result = await client.call_tool(name, {}) + assert result.isError + result = await client.call_tool(public_name, {}) + assert not result.isError + assert not calls + + +@pytest.mark.parametrize("use_plugin_names", [False, True]) +async def test_mcp_server_from_functions_naming(use_plugin_names): + @kernel_function + def echo(message: str) -> str: + return message + + server = create_mcp_server_from_functions( + KernelFunction.from_method(echo), plugin_name="Demo", use_plugin_names=use_plugin_names + ) + async with create_connected_server_and_client_session(server) as client: + name = "Demo-echo" if use_plugin_names else "echo" + assert [tool.name for tool in (await client.list_tools()).tools] == [name] + result = await client.call_tool(name, {"message": "hello"}) + assert not result.isError + assert result.content == [types.TextContent(type="text", text="hello")] + + +@pytest.mark.parametrize("tool_name_length", [128, 129]) +@pytest.mark.parametrize("use_plugin_names", [False, True]) +async def test_mcp_server_tool_name_length(kernel, caplog, tool_name_length, use_plugin_names): + @kernel_function(name="f" * (tool_name_length - (2 if use_plugin_names else 0))) + def echo() -> str: + return "hello" + + kernel.add_function("p", echo) + with caplog.at_level(logging.WARNING, logger="semantic_kernel.connectors.mcp"): + server = kernel.as_mcp_server(use_plugin_names=use_plugin_names) + assert ("exceeds the recommended 128 characters" in caplog.text) == (tool_name_length > 128) + async with create_connected_server_and_client_session(server) as client: + tool = (await client.list_tools()).tools[0] + assert len(tool.name) == tool_name_length + result = await client.call_tool(tool.name, {}) + assert not result.isError + + +async def test_mcp_server_retains_advertised_function(kernel): + @kernel_function(name="echo") + def original(message: str) -> str: + return f"original: {message}" + + @kernel_function(name="echo") + def replacement(other: int) -> str: + pytest.fail("The replacement was not advertised.") + + kernel.add_function("Demo", original) + server = kernel.as_mcp_server() + kernel.add_function("Demo", replacement) + + async with create_connected_server_and_client_session(server) as client: + tool = (await client.list_tools()).tools[0] + assert tool.inputSchema["required"] == ["message"] + result = await client.call_tool("echo", {"message": "hello"}) + assert not result.isError + assert result.content == [types.TextContent(type="text", text="original: hello")] From 2ee268d1d3184d0f029b6666de72c60a0e40d549 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Wed, 30 Sep 2026 15:19:26 +0100 Subject: [PATCH 2/3] Python: Use registered plugin aliases for MCP tool names Retain concrete functions during tool enumeration and use the shared fully qualified name separator. Cover alias naming, duplicate ordering, exclusions, retained dispatch, and name-length boundaries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- python/samples/demos/mcp_server/README.md | 4 +- python/semantic_kernel/connectors/mcp.py | 44 ++++--- python/semantic_kernel/kernel.py | 4 +- python/tests/unit/connectors/mcp/test_mcp.py | 122 ++++++++++++++++--- 4 files changed, 136 insertions(+), 38 deletions(-) diff --git a/python/samples/demos/mcp_server/README.md b/python/samples/demos/mcp_server/README.md index 19f026c7b3d0..7640b604b89d 100644 --- a/python/samples/demos/mcp_server/README.md +++ b/python/samples/demos/mcp_server/README.md @@ -105,7 +105,9 @@ To expose both functions, enable plugin-qualified names: server = kernel.as_mcp_server(use_plugin_names=True) ``` -For example, the sample's tools become `echo-echo_function` and `prompt-prompt`. +The prefix is the name under which the plugin is registered with the kernel, even if the +plugin object has a different name. For example, the sample's tools become +`echo-echo_function` and `prompt-prompt`. The option is also available on `create_mcp_server_from_kernel`, `create_mcp_server_from_functions`, and `Agent.as_mcp_server`. Tool names longer than MCP's recommended 128 characters are exposed with a warning. diff --git a/python/semantic_kernel/connectors/mcp.py b/python/semantic_kernel/connectors/mcp.py index ff04f9fd40a3..b0e10847555b 100644 --- a/python/semantic_kernel/connectors/mcp.py +++ b/python/semantic_kernel/connectors/mcp.py @@ -26,6 +26,7 @@ from semantic_kernel import Kernel from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase +from semantic_kernel.const import DEFAULT_FULLY_QUALIFIED_NAME_SEPARATOR from semantic_kernel.contents.audio_content import AudioContent from semantic_kernel.contents.binary_content import BinaryContent from semantic_kernel.contents.chat_history import ChatHistory @@ -1054,7 +1055,7 @@ def create_mcp_server_from_kernel( By default, functions are exposed as Tools using their bare function names. If names collide, the first function is exposed and subsequent functions are skipped with a warning. - Set `use_plugin_names=True` to expose names as `-` instead. + Set `use_plugin_names=True` to expose names as `-` instead. The same function is used for each tool's metadata and invocation. Tool names over MCP's recommended 128 characters are exposed with a warning. @@ -1069,7 +1070,7 @@ def create_mcp_server_from_kernel( lifespan: The lifespan of the server. excluded_functions: The list of function names to exclude from the server. if None, no functions will be excluded. - use_plugin_names: Whether to prefix tool names with their plugin name, separated by a hyphen. + use_plugin_names: Whether to prefix tool names with their registered plugin name, separated by a hyphen. Defaults to False. kwargs: Any extra arguments to pass to the server creation. @@ -1095,26 +1096,29 @@ def create_mcp_server_from_kernel( # Map each public tool name to the exact function used for both listing and calls. functions_to_expose: dict[str, KernelFunction] = {} - for metadata in kernel.get_full_list_of_function_metadata(): - if metadata.name in (excluded_functions or []): - continue - - tool_name = metadata.fully_qualified_name if use_plugin_names else metadata.name - - # Keep the first function if multiple plugins expose the same bare name. - if tool_name in functions_to_expose: - logger.warning( - "Skipping function '%s' because MCP tool name '%s' is already registered by '%s'.", - metadata.fully_qualified_name, - tool_name, - functions_to_expose[tool_name].fully_qualified_name, - ) - continue + registered_names: dict[str, str] = {} + for plugin_name, plugin in kernel.plugins.items(): + for function in plugin: + if function.name in (excluded_functions or []): + continue + + registered_name = f"{plugin_name}{DEFAULT_FULLY_QUALIFIED_NAME_SEPARATOR}{function.name}" + tool_name = registered_name if use_plugin_names else function.name + + if tool_name in functions_to_expose: + logger.warning( + "Skipping function '%s' because MCP tool name '%s' is already registered by '%s'.", + registered_name, + tool_name, + registered_names[tool_name], + ) + continue - if len(tool_name) > 128: - logger.warning("MCP tool name '%s' exceeds the recommended 128 characters.", tool_name) + if len(tool_name) > 128: + logger.warning("MCP tool name '%s' exceeds the recommended 128 characters.", tool_name) - functions_to_expose[tool_name] = kernel.get_function(metadata.plugin_name, metadata.name) + functions_to_expose[tool_name] = function + registered_names[tool_name] = registered_name if functions_to_expose: diff --git a/python/semantic_kernel/kernel.py b/python/semantic_kernel/kernel.py index f5c02f610872..cff7f78a903e 100644 --- a/python/semantic_kernel/kernel.py +++ b/python/semantic_kernel/kernel.py @@ -595,7 +595,7 @@ def as_mcp_server( By default, functions are exposed as Tools using bare function names. Duplicate names retain the first function and produce a warning. - Set `use_plugin_names=True` to expose `-` tool names. + Set `use_plugin_names=True` to expose `-` tool names. The `excluded_functions` argument always uses bare function names. Tool names over MCP's recommended 128 characters are exposed with a warning. @@ -608,7 +608,7 @@ def as_mcp_server( lifespan: The lifespan of the server. excluded_functions: The list of function names to exclude from the server. if None, no functions will be excluded. - use_plugin_names: Whether to prefix tool names with their plugin name, separated by a hyphen. + use_plugin_names: Whether to prefix tool names with their registered plugin name, separated by a hyphen. Defaults to False. kwargs: Any extra arguments to pass to the server creation. diff --git a/python/tests/unit/connectors/mcp/test_mcp.py b/python/tests/unit/connectors/mcp/test_mcp.py index 49a8abd8c589..0d66a0e90dae 100644 --- a/python/tests/unit/connectors/mcp/test_mcp.py +++ b/python/tests/unit/connectors/mcp/test_mcp.py @@ -2,13 +2,13 @@ import logging import re -from typing import TYPE_CHECKING from unittest.mock import AsyncMock, MagicMock, patch import pytest from mcp import ClientSession, ListToolsResult, StdioServerParameters, Tool, types from mcp.shared.memory import create_connected_server_and_client_session +from semantic_kernel import Kernel from semantic_kernel.connectors.mcp import ( MCPSsePlugin, MCPStdioPlugin, @@ -18,10 +18,7 @@ create_mcp_server_from_kernel, ) from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError -from semantic_kernel.functions import KernelFunction, kernel_function - -if TYPE_CHECKING: - from semantic_kernel import Kernel +from semantic_kernel.functions import KernelFunction, KernelPlugin, kernel_function @pytest.fixture @@ -635,9 +632,70 @@ def second(second_id: int) -> str: assert calls == [registration[-1] for registration in retained] +@pytest.mark.parametrize("use_plugin_names", [False, True]) +@pytest.mark.parametrize("reverse_registration", [False, True]) +async def test_mcp_server_uses_registered_plugin_alias(caplog, use_plugin_names, reverse_registration): + calls = [] + + @kernel_function(name="echo", description="First schema") + def first(message: str) -> str: + calls.append("first") + return message + + @kernel_function(name="echo", description="Second schema") + def second(count: int) -> str: + calls.append("second") + return str(count) + + registrations = [ + ("first_alias", first, "message", "hello", "first"), + ("second_alias", second, "count", 2, "second"), + ] + if reverse_registration: + registrations.reverse() + kernel = Kernel( + plugins={alias: KernelPlugin(name="actual", functions=[function]) for alias, function, *_ in registrations} + ) + with caplog.at_level(logging.WARNING, logger="semantic_kernel.connectors.mcp"): + server = kernel.as_mcp_server(use_plugin_names=use_plugin_names) + + if use_plugin_names: + assert not caplog.records + else: + assert [record.getMessage() for record in caplog.records] == [ + ( + f"Skipping function '{registrations[1][0]}-echo' because MCP tool name 'echo' " + f"is already registered by '{registrations[0][0]}-echo'." + ) + ] + + retained = registrations if use_plugin_names else registrations[:1] + expected_names = [f"{alias}-echo" if use_plugin_names else "echo" for alias, *_ in retained] + async with create_connected_server_and_client_session(server) as client: + tools = (await client.list_tools()).tools + assert [tool.name for tool in tools] == expected_names + for tool, (_, function, parameter, value, marker) in zip(tools, retained): + assert tool.description == function.__kernel_function_description__ + assert tool.inputSchema["required"] == [parameter] + assert set(tool.inputSchema["properties"]) == {parameter} + result = await client.call_tool(tool.name, {parameter: value}) + assert not result.isError + assert result.content == [types.TextContent(type="text", text=str(value))] + assert calls[-1] == marker + + unexposed_names = ["actual-echo", "echo"] if use_plugin_names else ["actual-echo", "first_alias-echo"] + for name in unexposed_names: + result = await client.call_tool(name, {}) + assert result.isError + assert "Unknown tool" in result.content[0].text + + assert calls == [registration[-1] for registration in retained] + + @pytest.mark.parametrize("use_plugin_names", [False, True]) @pytest.mark.parametrize("excluded_functions", ["secret", ["secret"]]) -async def test_mcp_server_exclusions_use_bare_names(kernel, use_plugin_names, excluded_functions): +@pytest.mark.parametrize("use_registered_alias", [False, True]) +async def test_mcp_server_exclusions_use_bare_names(use_plugin_names, excluded_functions, use_registered_alias): calls = [] @kernel_function @@ -649,9 +707,12 @@ def secret() -> str: calls.append("secret") return "secret" - kernel.add_function("First", public) - kernel.add_function("First", secret) - kernel.add_function("Second", secret) + kernel = Kernel( + plugins={ + "First": KernelPlugin(name="actual" if use_registered_alias else "First", functions=[public, secret]), + "Second": KernelPlugin(name="actual" if use_registered_alias else "Second", functions=[secret]), + } + ) server = create_mcp_server_from_kernel( kernel, use_plugin_names=use_plugin_names, excluded_functions=excluded_functions ) @@ -660,7 +721,7 @@ def secret() -> str: tools = (await client.list_tools()).tools public_name = "First-public" if use_plugin_names else "public" assert [tool.name for tool in tools] == [public_name] - for name in ("secret", "First-secret", "Second-secret"): + for name in ("secret", "First-secret", "Second-secret", "actual-secret"): result = await client.call_tool(name, {}) assert result.isError result = await client.call_tool(public_name, {}) @@ -703,7 +764,9 @@ def echo() -> str: assert not result.isError -async def test_mcp_server_retains_advertised_function(kernel): +@pytest.mark.parametrize("use_plugin_names", [False, True]) +@pytest.mark.parametrize("use_registered_alias", [False, True]) +async def test_mcp_server_retains_advertised_function(use_plugin_names, use_registered_alias): @kernel_function(name="echo") def original(message: str) -> str: return f"original: {message}" @@ -712,13 +775,42 @@ def original(message: str) -> str: def replacement(other: int) -> str: pytest.fail("The replacement was not advertised.") - kernel.add_function("Demo", original) - server = kernel.as_mcp_server() - kernel.add_function("Demo", replacement) + kernel = Kernel( + plugins={"Demo": KernelPlugin(name="actual" if use_registered_alias else "Demo", functions=[original])} + ) + server = kernel.as_mcp_server(use_plugin_names=use_plugin_names) + kernel.plugins["Demo"] = KernelPlugin(name="actual" if use_registered_alias else "Demo", functions=[replacement]) async with create_connected_server_and_client_session(server) as client: tool = (await client.list_tools()).tools[0] + expected_name = "Demo-echo" if use_plugin_names else "echo" + assert tool.name == expected_name assert tool.inputSchema["required"] == ["message"] - result = await client.call_tool("echo", {"message": "hello"}) + result = await client.call_tool(expected_name, {"message": "hello"}) assert not result.isError assert result.content == [types.TextContent(type="text", text="original: hello")] + + +@pytest.mark.parametrize("use_plugin_names", [False, True]) +@pytest.mark.parametrize( + "alias,plugin_name,exceeds_limit", + [("p", "a" * 124, False), ("a" * 123, "actual", False), ("a" * 124, "actual", True)], +) +async def test_mcp_server_tool_name_length_uses_registered_alias( + caplog, use_plugin_names, alias, plugin_name, exceeds_limit +): + @kernel_function + def echo() -> str: + return "hello" + + kernel = Kernel(plugins={alias: KernelPlugin(name=plugin_name, functions=[echo])}) + expected_name = f"{alias}-echo" if use_plugin_names else "echo" + with caplog.at_level(logging.WARNING, logger="semantic_kernel.connectors.mcp"): + server = kernel.as_mcp_server(use_plugin_names=use_plugin_names) + assert ("exceeds the recommended 128 characters" in caplog.text) == (use_plugin_names and exceeds_limit) + + async with create_connected_server_and_client_session(server) as client: + assert [tool.name for tool in (await client.list_tools()).tools] == [expected_name] + result = await client.call_tool(expected_name, {}) + assert not result.isError + assert result.content == [types.TextContent(type="text", text="hello")] From 7f24493a5eb84b4863ec4627248840aa102d37cf Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Wed, 30 Sep 2026 15:51:36 +0100 Subject: [PATCH 3/3] Python: Clarify MCP factory naming for existing plugins Preserve Kernel.add_plugin behavior for existing KernelPlugin instances. Document the plugin_name exception and cover retained names, dispatch, and distinct plugin namespaces with regression tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- python/samples/demos/mcp_server/README.md | 5 ++ python/semantic_kernel/connectors/mcp.py | 12 +-- python/tests/unit/connectors/mcp/test_mcp.py | 90 +++++++++++++++++++- 3 files changed, 100 insertions(+), 7 deletions(-) diff --git a/python/samples/demos/mcp_server/README.md b/python/samples/demos/mcp_server/README.md index 7640b604b89d..bd2c806f49fe 100644 --- a/python/samples/demos/mcp_server/README.md +++ b/python/samples/demos/mcp_server/README.md @@ -110,6 +110,11 @@ plugin object has a different name. For example, the sample's tools become `echo-echo_function` and `prompt-prompt`. The option is also available on `create_mcp_server_from_kernel`, `create_mcp_server_from_functions`, and `Agent.as_mcp_server`. +In `create_mcp_server_from_functions`, `plugin_name` applies to individual functions and +objects that are not `KernelPlugin` instances. Existing `KernelPlugin` instances keep their +names even when `plugin_name` is supplied, matching `Kernel.add_plugin`. +For example, a plugin named `Real` exposes `Real-echo` with `use_plugin_names=True`, +even if the factory is called with `plugin_name="Alias"`. Tool names longer than MCP's recommended 128 characters are exposed with a warning. Clients may impose stricter name-length limits. Enabling the option changes public tool names, so existing callers must use the new names. diff --git a/python/semantic_kernel/connectors/mcp.py b/python/semantic_kernel/connectors/mcp.py index b0e10847555b..b2eaa84d19bc 100644 --- a/python/semantic_kernel/connectors/mcp.py +++ b/python/semantic_kernel/connectors/mcp.py @@ -984,10 +984,11 @@ def create_mcp_server_from_functions( ) -> Server["LifespanResultT"]: """Create an MCP server from a function(s) or plugin(s). - This function automatically creates a MCP server from single or multiple functions or plugins, - all functions are added under the plugin_name that can be set by using the `plugin_name` argument. - It further uses the provided arguments to - configure the server and expose functions as tools, see the mcp documentation for more details. + This function automatically creates a MCP server from single or multiple functions or plugins. + Individual functions and objects that are not KernelPlugin instances are registered under `plugin_name`. + Existing KernelPlugin instances retain their names, regardless of `plugin_name`, as in Kernel.add_plugin. + It uses the provided arguments to configure the server and expose functions as tools, + see the mcp documentation for more details. Args: functions: The function(s) or plugin(s) instance to use. @@ -998,7 +999,8 @@ def create_mcp_server_from_functions( version: The version of the server. instructions: The instructions to use for the server. lifespan: The lifespan of the server. - plugin_name: The name of the plugin to use. + plugin_name: The plugin name for individual functions and objects that are not KernelPlugin instances. + Ignored for existing KernelPlugin instances, which retain their names. use_plugin_names: Whether to prefix tool names with their plugin name, separated by a hyphen. Defaults to False. kwargs: Any extra arguments to pass to the server creation. diff --git a/python/tests/unit/connectors/mcp/test_mcp.py b/python/tests/unit/connectors/mcp/test_mcp.py index 0d66a0e90dae..8cf15275aaef 100644 --- a/python/tests/unit/connectors/mcp/test_mcp.py +++ b/python/tests/unit/connectors/mcp/test_mcp.py @@ -730,13 +730,21 @@ def secret() -> str: @pytest.mark.parametrize("use_plugin_names", [False, True]) -async def test_mcp_server_from_functions_naming(use_plugin_names): +@pytest.mark.parametrize("input_type", ["function", "object"]) +async def test_mcp_server_from_functions_naming(use_plugin_names, input_type): @kernel_function def echo(message: str) -> str: return message + class EchoPlugin: + @kernel_function + def echo(self, message: str) -> str: + return message + server = create_mcp_server_from_functions( - KernelFunction.from_method(echo), plugin_name="Demo", use_plugin_names=use_plugin_names + KernelFunction.from_method(echo) if input_type == "function" else EchoPlugin(), + plugin_name="Demo", + use_plugin_names=use_plugin_names, ) async with create_connected_server_and_client_session(server) as client: name = "Demo-echo" if use_plugin_names else "echo" @@ -746,6 +754,84 @@ def echo(message: str) -> str: assert result.content == [types.TextContent(type="text", text="hello")] +@pytest.mark.parametrize("use_plugin_names", [False, True]) +@pytest.mark.parametrize("plugin_name", [None, "Alias"]) +async def test_mcp_server_from_functions_retains_existing_plugin_name(use_plugin_names, plugin_name): + @kernel_function + def echo(message: str) -> str: + return message + + plugin = KernelPlugin(name="Real", functions=[echo]) + original_function = plugin["echo"] + server = create_mcp_server_from_functions( + plugin, use_plugin_names=use_plugin_names, **({"plugin_name": plugin_name} if plugin_name else {}) + ) + + assert plugin.name == "Real" + assert plugin["echo"] is original_function + assert original_function.plugin_name == "Real" + async with create_connected_server_and_client_session(server) as client: + name = "Real-echo" if use_plugin_names else "echo" + assert [tool.name for tool in (await client.list_tools()).tools] == [name] + result = await client.call_tool(name, {"message": "hello"}) + assert not result.isError + assert result.content == [types.TextContent(type="text", text="hello")] + for unexposed_name in ("Alias-echo", "mcp-echo"): + result = await client.call_tool(unexposed_name, {"message": "hello"}) + assert result.isError + assert "Unknown tool" in result.content[0].text + + +@pytest.mark.parametrize("use_plugin_names", [False, True]) +@pytest.mark.parametrize("reverse_registration", [False, True]) +async def test_mcp_server_from_functions_preserves_plugin_namespaces(caplog, use_plugin_names, reverse_registration): + calls = [] + + @kernel_function(name="echo", description="First schema") + def first(message: str) -> str: + calls.append("first") + return message + + @kernel_function(name="echo", description="Second schema") + def second(count: int) -> str: + calls.append("second") + return str(count) + + registrations = [ + (KernelPlugin(name="First", functions=[first]), "message", "hello", "first"), + (KernelPlugin(name="Second", functions=[second]), "count", 2, "second"), + ] + if reverse_registration: + registrations.reverse() + with caplog.at_level(logging.WARNING, logger="semantic_kernel.connectors.mcp"): + server = create_mcp_server_from_functions( + [plugin for plugin, *_ in registrations], plugin_name="Alias", use_plugin_names=use_plugin_names + ) + + if use_plugin_names: + assert not caplog.records + else: + assert "Skipping function" in caplog.text + assert "First-echo" in caplog.text + assert "Second-echo" in caplog.text + + retained = registrations if use_plugin_names else registrations[:1] + async with create_connected_server_and_client_session(server) as client: + tools = (await client.list_tools()).tools + assert [tool.name for tool in tools] == [ + f"{plugin.name}-echo" if use_plugin_names else "echo" for plugin, *_ in retained + ] + for tool, (plugin, parameter, value, marker) in zip(tools, retained): + assert tool.description == plugin["echo"].description + assert tool.inputSchema["required"] == [parameter] + assert set(tool.inputSchema["properties"]) == {parameter} + result = await client.call_tool(tool.name, {parameter: value}) + assert not result.isError + assert result.content == [types.TextContent(type="text", text=str(value))] + assert calls[-1] == marker + assert calls == [registration[-1] for registration in retained] + + @pytest.mark.parametrize("tool_name_length", [128, 129]) @pytest.mark.parametrize("use_plugin_names", [False, True]) async def test_mcp_server_tool_name_length(kernel, caplog, tool_name_length, use_plugin_names):