Skip to content
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
33 changes: 30 additions & 3 deletions python/samples/demos/mcp_server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions python/semantic_kernel/agents/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand All @@ -585,6 +587,7 @@ def as_mcp_server(
version=version,
instructions=instructions,
lifespan=lifespan,
use_plugin_names=use_plugin_names,
)


Expand Down
70 changes: 54 additions & 16 deletions python/semantic_kernel/connectors/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -1026,6 +1032,7 @@ def create_mcp_server_from_functions(
version=version,
instructions=instructions,
lifespan=lifespan,
use_plugin_names=use_plugin_names,
**kwargs,
)

Expand All @@ -1040,15 +1047,21 @@ 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.

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 `<registered_plugin_name>-<function_name>` 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.
Expand All @@ -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:
Expand All @@ -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",
Expand All @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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)

Expand Down
13 changes: 10 additions & 3 deletions python/semantic_kernel/kernel.py
Original file line number Diff line number Diff line change
Expand Up @@ -584,16 +584,20 @@ 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.

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 `<registered_plugin_name>-<function_name>` 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.
Expand All @@ -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:
Expand All @@ -621,5 +627,6 @@ def as_mcp_server(
instructions=instructions,
lifespan=lifespan,
excluded_functions=excluded_functions,
use_plugin_names=use_plugin_names,
**kwargs,
)
10 changes: 10 additions & 0 deletions python/tests/unit/agents/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading