Semantic Kernel adds Model Context Protocol (MCP) support for Python

Microsoft has added first class Model Context Protocol support to Semantic Kernel for Python, starting with version 1.28.1. This is a meaningful update for anyone building agents in Python with SK, because it means your kernel can now talk to any MCP server out there, and equally, you can expose your own SK functions and agents as an MCP server for other clients to consume. Eduard van Valkenburg announced the release on the Microsoft Agent Framework devblog, and the underlying idea is straightforward even though the implementation touches several parts of the SK stack.

If you have not worked with MCP before, think of it as a common wire protocol for tools, prompts, and context sharing between models and agents. Anthropic created it to solve a real problem: every agent framework was inventing its own plugin format, which meant a tool built for one framework could not be reused in another without a rewrite. MCP standardizes the transport (stdio, SSE, and websocket) and the discovery mechanism, so a tool exposed once can be called from any MCP aware client.

SK as an MCP host: consuming external servers

The first half of this release lets SK act as an MCP host, meaning your kernel or agent can connect out to any MCP server and use whatever tools or prompts it exposes as if they were native SK plugins. This works whether the server runs locally as a subprocess, remotely over SSE, or inside a container. In practice this is the more useful direction for most teams, since it means you do not need to hand write a wrapper plugin every time you want to consume a new MCP compatible tool.

Connecting to a local server that communicates over standard input and output looks like this.

from semantic_kernel.connectors.mcp import MCPStdioPlugin
 
async with MCPStdioPlugin(
    name="ReleaseNotes",
    description="SK Release Notes Plugin",
    command="uv",
    args=[
        "--directory=python/samples/demos/mcp_server",
        "run",
        "mcp_server_with_sampling.py",
    ],
) as plugin:
    # Use plugin as a tool in your agent or kernel
    ...

MCPStdioPlugin spawns the server as a child process and talks to it over stdin and stdout, which SK then wraps as an async context manager so the process gets cleaned up correctly when the block exits. The command and args here are just launching a uv managed Python script, so in your own setup this could equally be a compiled binary, a Node script, or anything else that speaks the MCP stdio transport. One thing worth flagging: because this spawns a real process on the machine running your kernel, you need to trust the server you are pointing at. There is no sandboxing built into this call, so treat third party MCP servers the same way you would treat any other untrusted executable before wiring them into a production agent.

For a server that already runs as a network service, you use MCPSsePlugin instead, which connects over Server Sent Events rather than spawning a process.

from semantic_kernel.connectors.mcp import MCPSsePlugin
 
async with MCPSsePlugin(
    name="RemoteTools",
    url="http://localhost:8000/sse",
) as plugin:
    ...

The stdio versus SSE choice is really a deployment decision more than a code decision. Stdio makes sense when the MCP server and your SK application live on the same host or container, since you avoid the overhead of a network hop and you do not need to manage a separate long running process. SSE is the better fit once the server needs to be shared across multiple consumers, or when it runs on infrastructure you do not control directly, such as a hosted tools service. Do not default to SSE just because it feels more production grade. For a single agent talking to a single local tool, stdio is simpler to deploy and easier to debug, since you can just run the script directly and see its output.

Sampling: letting the host do the model calls

Sampling is the part of MCP that took me a moment to fully appreciate on first read, because on the surface it looks unnecessary if SK can already call models directly. The idea is that any MCP plugin added to a kernel automatically gets access to whatever chat completion services are registered on that kernel, and this happens without extra wiring on your part. Where this actually matters is when you run SK as a server in an environment that is not allowed to call out to model endpoints directly, and you want the host application to own that outbound call instead.

You expose a sampling capable function by accepting a server object in the function signature and excluding it from function choice, so the model never sees it as a callable parameter.

from semantic_kernel.functions import kernel_function
from typing import Annotated
from mcp.server.lowlevel import Server
 
@kernel_function(
    name="run_prompt",
    description="Run the prompts for a full set of release notes based on the PR messages given.",
)
async def sampling_function(
    messages: Annotated[str, "The list of PR messages, as a string with newlines"],
    temperature: float = 0.0,
    max_tokens: int = 1000,
    server: Annotated[Server | None, "The server session", {"include_in_function_choices": False}] = None,
) -> str:
    if not server:
        raise ValueError("Request context is required for sampling function.")
    sampling_response = await server.request_context.session.create_message(
        messages=[
            types.SamplingMessage(role="user", content=types.TextContent(type="text", text=messages)),
        ],
        max_tokens=max_tokens,
        temperature=temperature,
        model_preferences=types.ModelPreferences(
            hints=[types.ModelHint(name="gpt-4o-mini")],
        ),
    )
    return sampling_response.content.text

The MCP infrastructure injects the server argument automatically when this function is called through a live session, so it never shows up in a function choice UI or gets exposed to the model as something it needs to fill in. If you forget the include_in_function_choices flag on the server parameter, the model will try to guess a value for it during tool selection, which fails in confusing ways since there is no sensible string or number it could produce for a live session object. This is an easy mistake to make the first time you write a sampling function, so double check that annotation before you ship one.

SK as an MCP server: exposing your own functions

The second half of the release flips the direction. You can now take an existing kernel, with its functions and prompts already registered, and expose the whole thing as an MCP server with one call.

from semantic_kernel import Kernel
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
 
kernel = Kernel()
kernel.add_service(OpenAIChatCompletion(service_id="default"))
 
# Add functions and prompts as usual
 
server = kernel.as_mcp_server(server_name="sk")
 
# Run as stdio server
 
import anyio
from mcp.server.stdio import stdio_server
 
async def handle_stdin():
    async with stdio_server() as (read_stream, write_stream):
        await server.run(read_stream, write_stream, server.create_initialization_options())
 
anyio.run(handle_stdin)

as_mcp_server() takes everything already registered on the kernel and wraps it in an MCP compatible server object, so any function you have written as a normal SK kernel_function is automatically discoverable by MCP clients without a separate manifest or schema definition. Running it over stdio is meant for local testing or for wiring into a desktop client that spawns your script as a subprocess, which is exactly how the Claude Desktop configuration example below works. For a server that other machines need to reach, run the same kernel with the SSE transport instead.

uv --directory=python/samples/demos/mcp_server \
   run sk_mcp_server.py --transport sse --port 8000

This is the same server, just bound to a port instead of stdin and stdout, and it is the version you would put behind a proper reverse proxy and authentication layer if you are exposing SK functions to consumers outside your own process boundary. Once your server is running over stdio, you can point a client like Claude Desktop at it directly through its configuration file.

{
    "mcpServers": {
        "sk": {
            "command": "uv",
            "args": [
                "--directory=/path/to/your/semantic-kernel/python/samples/demos/mcp_server",
                "run",
                "sk_mcp_server.py"
            ],
            "env": {
                "OPENAI_API_KEY": "",
                "OPENAI_CHAT_MODEL_ID": "gpt-4o-mini"
            }
        }
    }
}

Claude Desktop reads this file on startup and launches the sk_mcp_server.py script as a managed subprocess, which means your SK functions become tools that Claude can call directly inside a normal chat session. This is a genuinely convenient way to test a kernel’s functions without writing any client code at all, since Claude Desktop becomes your test harness for free. Just remember that the API key sits in plain text in this config file on disk, so treat it the same way you would treat any other credential in a local config, and do not commit it if this file ends up anywhere near a repository.

Wiring MCP plugins into SK agents

Because MCP plugins behave like any other SK plugin once connected, you can pass several of them into a single ChatCompletionAgent and let the agent chain tool calls across servers as part of its normal reasoning loop. The example in the announcement combines a GitHub MCP server, run through Docker, with a local release notes server.

from semantic_kernel.agents import ChatCompletionAgent
from semantic_kernel.connectors.mcp import MCPStdioPlugin
 
async with (
    MCPStdioPlugin(
        name="Github",
        command="docker",
        args=["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"],
        env={"GITHUB_PERSONAL_ACCESS_TOKEN": os.getenv("GITHUB_PERSONAL_ACCESS_TOKEN")},
    ) as github_plugin,
    MCPStdioPlugin(
        name="ReleaseNotes",
        command="uv",
        args=["--directory=python/samples/demos/mcp_server", "run", "mcp_server_with_prompts.py"],
    ) as release_notes_plugin,
):
    agent = ChatCompletionAgent(
        service=OllamaChatCompletion(),
        name="GithubAgent",
        plugins=[github_plugin, release_notes_plugin],
    )
    ...

Each plugin gets its own subprocess, so this agent is really coordinating two separate MCP servers behind the scenes, one containerized and one running as a local script, and the agent’s reasoning loop decides which tool to call based purely on the function descriptions it sees. This is convenient during development, but worth pausing on before you deploy it: every additional MCP server in this list is another subprocess whose failure mode you need to handle, and a container that fails to pull or a script that crashes on startup will surface as a confusing agent error rather than a clear connection failure. Test each plugin in isolation before you combine them in an async with block like this one.

Exposing whole agents as MCP servers

The last piece extends as_mcp_server() from kernels to agents, so an entire agent, including its instructions and its own plugins, can be published as an MCP server that other agents or clients can call into.

from semantic_kernel.agents import ChatCompletionAgent
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
 
agent = ChatCompletionAgent(
    service=OpenAIChatCompletion(),
    name="ReleaseNotesAgent",
    instructions="You are a release notes generator agent. Use the run_prompt function to generate release notes.",
    plugins=[release_notes_plugin],
)
 
server = agent.as_mcp_server(server_name="release_notes_agent")
 
# Now you can run this server using stdio or sse as shown above

This is the piece that enables agent to agent composition without a custom integration layer, since a calling agent just sees another MCP server and does not need to know it is actually talking to a full agent underneath. It is a neat capability, but it is also the easiest one to overuse. Chaining three or four agents together this way, each exposed as an MCP server consumed by the next, adds a network or subprocess hop and a full reasoning cycle at every layer, and debugging a failure two or three agents deep in that chain is considerably harder than debugging a single agent with a flat list of tools. Reach for this pattern when you genuinely need separately owned or separately deployed agents to interoperate, not as the default way to organize a single team’s agent logic.

Where this fits and what is still missing

This release requires Semantic Kernel Python 1.28.1 or higher, so check your pinned version before assuming any of the above is available in your environment. The announcement itself does not mention a parallel .NET release, and at the time of writing the C# side of SK has its own separate MCP plugin story that predates this Python work, so if your team runs both stacks, do not assume the API surface lines up between languages. Compare the two explicitly before you write shared documentation or onboarding material that treats them as equivalent.

For teams already invested in SK for Python, this closes a real gap. Before this release, consuming a third party tool meant hand writing a plugin wrapper for every new integration, and there was no clean way to expose SK functions to other frameworks without building a custom API on top. Now both directions are handled by the framework itself. The trade off is the operational surface area MCP introduces: every stdio server is a subprocess you own the lifecycle of, every SSE server is a network dependency with its own availability story, and sampling adds a layer of indirection between a function call and the model that actually answers it. None of that is a reason to avoid MCP support in SK, but plan for it the way you would plan for any other distributed dependency, with health checks, timeouts, and a clear owner for each server in the chain.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading