Leverage MCP tools with Semantic Kernel Agents

In an earlier piece in this series, we looked at the Model Context Protocol (MCP) at a basic level: standing up a small MCP server and calling it from Claude Desktop as the host. That demo is useful to understand the moving parts, but it does not tell you how MCP fits into a real application. This article goes one step further and builds a Semantic Kernel (SK) agent that consumes an MCP server as its tool source, using a Text-to-SQL agent as the working example.

Semantic Kernel in one paragraph

Semantic Kernel is a lightweight orchestration framework for building AI powered applications, in the same broad category as LangChain, LlamaIndex, or AutoGen. It sits as a middleware layer between your traditional application code and the LLM, in C#, Python, or Java. What it actually gives you is a set of abstractions: plugins to wrap functions as callable tools, planners to sequence those tools, memory to persist state across turns, and a consistent function calling interface regardless of which model provider sits behind it.

Model Context Protocol in one paragraph

MCP is a standard for how an LLM discovers and invokes external tools and data sources, the same way HTTP and REST standardized how browsers talk to web servers. Before MCP, every framework built its own tool integration layer: a LangChain tool is not portable to Semantic Kernel without rewriting it, and a provider changing its API meant updating every integration that touched it separately. MCP fixes this by defining one contract that any host, client, or server can implement, so a tool written once becomes callable from any MCP aware application.

  • MCP Host: the application managing the LLM interaction, for example Claude Desktop, GitHub Copilot, or your own Semantic Kernel app.
  • MCP Client: the middleware inside the host that speaks the protocol to external servers.
  • MCP Server: the actual tool, prompt, or data source that gets called, running as its own process.

A framework and a protocol are not competing things

It is worth being precise about where SK and MCP each sit, because they get compared as if you have to pick one. Semantic Kernel is developer facing: you use its planners, plugins, and memory abstractions inside your application code, in whichever language you have chosen. MCP is a wire level contract: it defines how a model discovers and calls a tool at runtime, independent of what language or framework built that tool.

The practical consequence is that you can keep your business logic anywhere, including in a completely different framework, wrap it as an MCP server, and call it from an SK agent without SK ever knowing LangChain was involved. That is the actual value MCP adds on top of a framework like SK, not a replacement for it.

What we are building

The example reuses a Text-to-SQL agent originally built with LangChain, running against the Chinook sample database, a small SQLite schema of artists, albums, tracks, and customers modelled on a music store. Instead of calling that agent directly from SK code, we wrap it inside an MCP server using FastMCP, and connect a Semantic Kernel ChatCompletionAgent to it through the MCPStdioPlugin. The LangChain agent never needs to know it is being called through MCP, and the SK agent never needs to know the tool underneath is LangChain.

Wrapping the LangChain SQL agent as an MCP server

The server script below does four things: it loads the Azure OpenAI configuration from environment variables, builds a LangChain SQL toolkit against chinook.db, wires an AgentExecutor with chat history around that toolkit, and finally exposes a single function, run_sql_query, as an MCP tool using the @mcp.tool() decorator. Everything before that decorator is ordinary LangChain code you would write with or without MCP in the picture.

import os
from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP
from semantic_kernel.functions import kernel_function
from langchain_community.utilities.sql_database import SQLDatabase
from langchain_community.agent_toolkits.sql.toolkit import SQLDatabaseToolkit
from langchain_openai import AzureChatOpenAI
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain.agents import AgentExecutor, create_openai_tools_agent
from langchain_community.chat_message_histories import ChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory
 
# -------------------------------
# Load environment variables
# -------------------------------
load_dotenv()
AZURE_OPENAI_API_KEY = os.getenv("AZURE_OPENAI_API_KEY")
AZURE_OPENAI_ENDPOINT = os.getenv("AZURE_OPENAI_ENDPOINT")
AZURE_OPENAI_DEPLOYMENT = os.getenv("AZURE_OPENAI_DEPLOYMENT")
AZURE_OPENAI_API_VERSION = os.getenv("AZURE_OPENAI_API_VERSION", "2025-01-01-preview")
 
# -------------------------------
# Initialize MCP Server
# -------------------------------
mcp = FastMCP("SQLAgentServer")
 
# -------------------------------
# Global components: LLM, DB, Tools, Prompt, Agent
# -------------------------------
llm = AzureChatOpenAI(
    openai_api_version=AZURE_OPENAI_API_VERSION,
    azure_deployment=AZURE_OPENAI_DEPLOYMENT,
)
db = SQLDatabase.from_uri("sqlite:///chinook.db")
toolkit = SQLDatabaseToolkit(db=db, llm=llm)
tools = toolkit.get_tools()
prompt = ChatPromptTemplate.from_messages(
    [
        ("system", """You are an agent designed to interact with a SQL database.
Given an input question, create a syntactically correct SQLite query to run, then look at the results and return the answer.
Unless the user specifies a number of examples, always limit your query to at most 5 results.
Order results by a relevant column to return the most interesting examples.
Never query all columns from a table, only the relevant ones.
Only use the tools provided, and only the information they return, to build your final answer.
You MUST double check your query before executing it, and rewrite it if it errors.
DO NOT make any DML statements (INSERT, UPDATE, DELETE, DROP etc.) to the database.
Always look at the tables in the database first. Do NOT skip this step.
        """),
        MessagesPlaceholder("chat_history", optional=True),
        ("human", "{input}"),
        MessagesPlaceholder("agent_scratchpad"),
    ]
)
chat_history = ChatMessageHistory()
agent = create_openai_tools_agent(llm, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
agent_with_memory = RunnableWithMessageHistory(
    executor,
    lambda session_id: chat_history,
    input_messages_key="input",
    history_messages_key="chat_history",
)
 
# -------------------------------
# MCP Tool: SQL Agent Query Runner
# -------------------------------
@mcp.tool()
def run_sql_query(user_input: str) -> str:
    """
    Tool specialized in retrieving information from the chinook database using SQL queries.
    The chinook database is a sample database with artists, albums, tracks, and customers.
    """
    result = agent_with_memory.invoke(
        {"input": user_input},
        config={"configurable": {"session_id": "<foo>"}},
    )
    return result["output"]
 
# -------------------------------
# MCP Entry Point
# -------------------------------
if __name__ == "__main__":
    mcp.run(transport="stdio")

Two things in this script matter more once you move past a toy example. First, AgentExecutor(verbose=True) is genuinely useful while you are debugging which tool calls the agent is making, but leave it enabled in a STDIO based MCP server and you will run into trouble: STDIO transport uses stdout to carry the actual JSON-RPC protocol messages between client and server, so any stray print or verbose log line written to stdout can corrupt that stream from the client’s point of view. Route logging to stderr or a file instead once this leaves your laptop. Second, the system prompt explicitly forbids INSERT, UPDATE, DELETE, and DROP, which is good practice, but it is a prompt level restriction, not a database level one. Pair it with a read-only database user so a prompt injection or a model mistake cannot actually change data, not just get told not to.

Turning a function into an MCP tool

The decorator is what actually registers a function on the MCP server as something a model can call.

@mcp.tool()
def func():
  return

A single server can host as many of these as you need, each with its own name and description. That description is not a comment for the next developer, it is what the model reads to decide whether this tool is relevant to the current question, so write the docstring the way you would write API documentation, not a one-line reminder to yourself.

Choosing a transport: STDIO or SSE

MCP servers support two transport mechanisms. STDIO spawns the server as a subprocess and talks to it over standard input and output, which is simple and works well for local development or a desktop hosted agent. SSE (Server-Sent Events) runs over HTTP instead, letting a server push updates to clients that connect remotely, which is what you want once the tool needs to run somewhere other than the same machine as the agent, or needs to serve more than one client at a time. This example sticks to STDIO, which is the right default until you actually need a remote or shared server.

if __name__ == "__main__":
    mcp.run(transport="stdio")

Switching to SSE later is mostly a one-line change on the server side, but it also means you now own a persistent HTTP process that needs its own authentication and network exposure decisions, which STDIO avoids entirely by never leaving the local machine.

Connecting the MCP server to a Semantic Kernel agent

On the SK side, MCPStdioPlugin is the Python SDK component that talks to a local MCP server over STDIO. You give it a name, the command to run, and the arguments, and it takes care of spawning the subprocess.

mcp_plugin = MCPStdioPlugin(
    name="SQLServer",
    command="python",
    args=[str(Path("your_path_to_mcp_server"))],
)

The command does not have to be python. It can be node, npx, or any executable, because MCP servers are language agnostic by design. That is the actual interoperability payoff: your SK agent can consume a tool written in a completely different language without any bridging code beyond this plugin configuration.

Initializing the plugin object does not start it. You still need to connect it explicitly, register it on the kernel, and then create the agent that will use it.

try:
    await mcp_plugin.connect()
    print("MCP plugin connected.")
    kernel.add_plugin(mcp_plugin, plugin_name="sqltool")
    print("MCP plugin registered with kernel.")
except Exception as e:
    print(f"Error: Could not register the MCP plugin: {str(e)}")
    await mcp_plugin.close()
    sys.exit(1)
 
agent = ChatCompletionAgent(
    kernel=kernel,
    name="Assistant",
    instructions="You are a helpful assistant."
)

connect() is asynchronous because it is launching a subprocess and waiting for it to signal it is ready, which also lets the connection support long-lived, streaming exchanges rather than a single request-response pair. The try/except around it matters more than it looks: if the path is wrong, or the server’s own dependencies are not installed in whatever Python environment command=”python” resolves to, connect() raises, and calling mcp_plugin.close() in the except block stops you from leaving an orphaned subprocess running in the background. It is also worth remembering that MCPStdioPlugin spawns one full subprocess per server, interpreter startup included, so this pattern is comfortable for a handful of tools and starts getting expensive if you are wiring up dozens of them.

Running the agent

With the plugin connected and registered, running the test script sends a couple of turns through the agent: one plain greeting, and one that requires the SQL tool.

python test_mcp_sql.py
Terminal log showing the agent invoking the MCP-registered SQL tool and returning the result.
Terminal log showing the agent invoking the MCP-registered SQL tool and returning the result.

The log is worth reading closely because it shows the actual decision boundary at work. For the greeting turn, the agent replies directly, no tool call happens. For the SQL question, the log shows the model deciding to call sqltool-run_sql_query, the function invoking, and then “Function completed. Duration: 5.878180s” before the final answer, a ranked list of albums by track count pulled from Chinook, comes back. That duration line is useful in its own right: Semantic Kernel emits per-function timing automatically, so you get basic instrumentation on every tool call without adding anything yourself.

Where this pattern actually pays off

It is fair to ask whether wrapping a tool in MCP is worth the extra subprocess and plugin ceremony versus just registering it as a native SK plugin. If the tool lives in your codebase and is only ever going to be called from this one SK application, a plain SK function is simpler: no subprocess, no serialization overhead, one less moving part to debug. MCP earns its keep when the same tool needs to be reusable across multiple hosts, for instance the same SQL tool being callable from Claude Desktop, GitHub Copilot, and this SK agent without three separate integrations, or when the tool is owned and maintained by a different team or repository than the agent consuming it.

In a single team, single application setup, skip the extra layer and use SK’s own function calling. Reach for MCP once reuse across hosts, teams, or languages is a real requirement rather than a hypothetical one.

Conclusion

The core idea here is straightforward once you strip away the plumbing: by wrapping the existing LangChain SQL agent behind the MCP contract, it becomes callable from any MCP aware host, not just this one Semantic Kernel application. That portability, not any change in model behaviour, is the entire value MCP adds on top of a framework like SK. Combining the two gives you SK’s orchestration inside your app and MCP’s interoperability at the boundary, which is a reasonable default for any tool you expect to reuse beyond a single project.

Leave a Reply

Discover more from Behind the Stack

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

Continue reading