diff --git a/python/samples/demos/mcp_server/README.md b/python/samples/demos/mcp_server/README.md index 1aef1595c1d2..bd2c806f49fe 100644 --- a/python/samples/demos/mcp_server/README.md +++ b/python/samples/demos/mcp_server/README.md @@ -83,12 +83,39 @@ 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) +``` + +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`. +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. +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..b2eaa84d19bc 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 @@ -978,14 +979,16 @@ 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). - 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. @@ -996,7 +999,10 @@ 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. Returns: @@ -1026,6 +1032,7 @@ def create_mcp_server_from_functions( version=version, instructions=instructions, lifespan=lifespan, + use_plugin_names=use_plugin_names, **kwargs, ) @@ -1040,6 +1047,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 +1055,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 +1072,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 registered plugin name, separated by a hyphen. + Defaults to False. kwargs: Any extra arguments to pass to the server creation. Returns: @@ -1081,19 +1096,41 @@ 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] = {} + 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 - if len(functions_to_expose) > 0: + 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) + + functions_to_expose[tool_name] = function + registered_names[tool_name] = registered_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 +1146,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 +1158,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 +1269,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..cff7f78a903e 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 registered 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..8cf15275aaef 100644 --- a/python/tests/unit/connectors/mcp/test_mcp.py +++ b/python/tests/unit/connectors/mcp/test_mcp.py @@ -2,17 +2,23 @@ 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 semantic_kernel.connectors.mcp import MCPSsePlugin, MCPStdioPlugin, MCPStreamableHttpPlugin, MCPWebsocketPlugin +from mcp.shared.memory import create_connected_server_and_client_session + +from semantic_kernel import Kernel +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 - -if TYPE_CHECKING: - from semantic_kernel import Kernel +from semantic_kernel.functions import KernelFunction, KernelPlugin, kernel_function @pytest.fixture @@ -524,9 +530,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 +574,329 @@ 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("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"]]) +@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 + def public() -> str: + return "public" + + @kernel_function + def secret() -> str: + calls.append("secret") + return "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 + ) + + 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", "actual-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]) +@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) 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" + 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("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): + @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 + + +@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}" + + @kernel_function(name="echo") + def replacement(other: int) -> str: + pytest.fail("The replacement was not advertised.") + + 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(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")]