The Model Context Protocol is a single, open way for an AI app to talk to outside tools, files, and data, so you stop writing custom glue for every new integration. Think of it like the USB port on your laptop. Before USB, every device had its own weird plug. After USB, one shape fits the mouse, the drive, the keyboard. MCP does the same thing for AI: one protocol, and any tool that speaks it plugs straight in.
You already built a raw agent loop by hand in the Large Language Model (LLM) tool calling tutorial, where your own Python code ran each tool. MCP is the next layer up: instead of hard-coding those tools inside your app, you connect to tool servers that run separately and expose their tools over a standard wire. This post explains the idea first, then connects a real Python client to a real server and drives its tools from the same loop you already know. Everything here runs locally, no Application Programming Interface (API) key needed.
“Write the integration once, use it everywhere. That is the whole promise of a protocol.”
Last Updated: July 2026 | Tested on: Python 3.14.6, mcp 1.28.1, Node.js 22 (current LTS) | Difficulty: Intermediate | Reading Time: 19 minutes
- LLM tool calling tutorial (the raw agent loop)
- Python AI agents tutorial
- pip install mcp
Table of Contents
Why Model Context Protocol Exists
Say you have three AI apps: a chat app, a coding assistant in your editor, and a background agent. Each one needs the same three tools: your files, GitHub, and a database. Without a shared protocol you write that plumbing nine times, once for every app-and-tool pair. Add a fourth tool and you write it three more times. This is the classic N times M problem, and it is exactly the mess spreadsheets of custom API wrappers used to be.
MCP flips it. Each tool is written once as a server. Each app is a host that connects through a small client. Now the file server, the GitHub server, and the database server each get built one time, and any host that speaks MCP can use all of them. Nine integrations collapse to three servers plus a bit of config. The diagram below shows the before and after side by side.
Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.
Three roles do all the work, so it is worth naming them clearly. The host is the app the user actually sees (a desktop assistant, your IDE, an agent script). The client is a thin connector living inside the host, one per server, that speaks the protocol. The server is the separate process that owns a real capability and exposes it. At the time of writing MCP is stewarded as a vendor-neutral open standard under the Linux Foundation (announced late 2025), and the major model providers including Anthropic, OpenAI, and Google have shipped support for it.
If you would rather not adopt a protocol, the alternative is each framework’s own built-in tool API, like the LangChain or OpenAI Agents tool definitions from the AI agents tutorial. Those work fine, they just do not travel across apps.
The Core Primitives: Tools, Resources, Prompts
An MCP server can expose three kinds of things, and the difference between them matters. Picture a restaurant. The tools are actions the kitchen can perform on request, like cook this dish. Resources are read-only information you can look at, like today’s menu on the wall. Prompts are the pre-written order templates the staff hand you so you ask for things the right way.
| Primitive | What it is | Who triggers it |
|---|---|---|
| Tool | An action with side effects (run code, query a DB, send a request) | The model decides to call it |
| Resource | Read-only data the host can load into context (a file, a menu, a record) | The host or user picks it |
| Prompt | A reusable, parameterised message template the server offers | The user selects it |
All three travel over a transport, which is just the pipe the messages flow through. Two are common. stdio runs the server as a local subprocess and talks over its standard input and output, perfect for tools on your own machine. Streamable HTTP talks to a server over the network, which is how you reach a hosted, remote server. The protocol itself is identical either way; only the pipe changes. Everything below uses stdio because it needs nothing but Python.
Your First MCP Client: Connect and Discover
Time to make it real. First we need a server. This one, written with the official Python SDK’s FastMCP helper, exposes two tools and one resource for a small vegetarian kitchen. Notice there is no LLM here at all; a server is just plain functions with a decorator that publishes them.
📄 calc_server.py: a tiny MCP server with two tools and a resource
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("veggie-kitchen", log_level="WARNING")
@mcp.tool()
def price_quote(item: str, kg: float) -> str:
"""Quote the price for a vegetable order in rupees."""
rates = {"spinach": 40, "okra": 60, "paneer": 320, "tomato": 30}
rate = rates.get(item.lower(), 50)
return f"{kg} kg of {item} = Rs {rate * kg:.0f} (at Rs {rate}/kg)"
@mcp.tool()
def total_calories(paneer_g: int, rice_g: int) -> int:
"""Estimate calories for a paneer-and-rice plate."""
return round(paneer_g * 2.65 + rice_g * 1.3)
@mcp.resource("menu://today")
def todays_menu() -> str:
"""The day's vegetarian menu, exposed as a resource."""
return "Today: palak paneer, veg pulao, masala okra, cucumber raita"
if __name__ == "__main__":
mcp.run() # defaults to the stdio transport
Now the client. Say a developer named Aditi wants her app to use that server. The client launches the server as a subprocess, runs the MCP handshake, then asks it what it can do. This is the discovery step that makes the protocol work: the host never hard-codes the tool list, it asks for it at runtime.
📄 mcp_client.py: connect over stdio, list tools, call them, read a resource
import asyncio
import sys
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
# Launch the server as a subprocess and talk to it over stdin/stdout.
server = StdioServerParameters(
command=sys.executable, # this same Python
args=["calc_server.py"],
)
async def main():
async with stdio_client(server) as (read, write):
async with ClientSession(read, write) as session:
info = await session.initialize()
print(f"Connected to: {info.serverInfo.name} v{info.serverInfo.version}")
# 1. Discovery: ask the server what tools it has.
tools = await session.list_tools()
print("\nTools the server offers:")
for t in tools.tools:
print(f" - {t.name}: {t.description}")
# 2. Call a tool by name with a dict of arguments.
r1 = await session.call_tool("price_quote", {"item": "paneer", "kg": 1.5})
print("\nprice_quote ->", r1.content[0].text)
r2 = await session.call_tool("total_calories", {"paneer_g": 150, "rice_g": 200})
print("total_calories ->", r2.content[0].text)
# 3. Resources are read-only data, not actions.
res = await session.list_resources()
print("\nResources:", [str(r.uri) for r in res.resources])
menu = await session.read_resource("menu://today")
print("menu://today ->", menu.contents[0].text)
asyncio.run(main())
▶ Output
Connected to: veggie-kitchen v1.28.1 Tools the server offers: - price_quote: Quote the price for a vegetable order in rupees. - total_calories: Estimate calories for a paneer-and-rice plate. price_quote -> 1.5 kg of paneer = Rs 480 (at Rs 320/kg) total_calories -> 658 Resources: ['menu://today'] menu://today -> Today: palak paneer, veg pulao, masala okra, cucumber raita
What happened here: The client started the server, called initialize to shake hands, then list_tools to learn the two tools it never knew about ahead of time. It then called each one by name with a plain dictionary of arguments and read a result back. The resource read is separate: menu://today is data, not an action, so it comes back through read_resource, not call_tool. Swap the two lines that point at calc_server.py for the official filesystem or GitHub server and this exact client talks to those instead, because the protocol is the same. That is the payoff of the standard.
What Crosses the Pipe: The JSON-RPC Wire Format
The SDK hides the messages, but they are worth seeing once so the protocol stops feeling like magic. Under the hood, every MCP call is a JSON-RPC 2.0 message: a small JSON object with a method name, some params, and an id. It is the same idea as ordering by ticket number at a counter; the id on your ticket matches the id they call out, so replies never get mixed up. Here is the exact shape of the tools/call you just ran, printed with the standard library alone.
📄 wire_format.py: the raw JSON-RPC request and response for a tool call
import json
# The client asks the server to run a tool. This is the exact envelope.
request = {
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {"name": "price_quote", "arguments": {"item": "paneer", "kg": 1.5}},
}
# The server answers with a matching id and a content list.
response = {
"jsonrpc": "2.0",
"id": 7,
"result": {
"content": [{"type": "text", "text": "1.5 kg of paneer = Rs 480"}],
"isError": False,
},
}
print("--> client sends:")
print(json.dumps(request, indent=2))
print("\n<-- server replies:")
print(json.dumps(response, indent=2))
print(f"\nSame id on both ({request['id']} == {response['id']}): the reply is matched to the request.")
▶ Output
--> client sends:
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "price_quote",
"arguments": {
"item": "paneer",
"kg": 1.5
}
}
}
<-- server replies:
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"content": [
{
"type": "text",
"text": "1.5 kg of paneer = Rs 480"
}
],
"isError": false
}
}
Same id on both (7 == 7): the reply is matched to the request.
What happened here: This is the whole protocol in miniature. A request carries a method (here tools/call), the params (which tool and what arguments), and an id. The reply carries the same id so the client can match it, plus a result with a list of content blocks and an isError flag. Discovery uses the same envelope with method set to tools/list or resources/list. Because it is plain JSON-RPC, any language can implement a client or server, which is exactly why servers written in TypeScript, Go, or Rust all work with this Python client.
Wiring MCP Tools Into the Agent Loop
Now connect the two halves. In the tool calling tutorial you ran a loop where the model asked for a tool and your code ran it. The only change with MCP is where the tool list comes from and where the tool runs: both now live on the server. We convert the discovered MCP tools into the schema shape an LLM API expects, then feed them into the same loop. The model is scripted here so it runs offline, but the tool schema and the actual call are real, straight from the server.
📄 mcp_agent_loop.py: drive real MCP tools from the offline agent loop
import asyncio, sys
from dataclasses import dataclass
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
server = StdioServerParameters(command=sys.executable, args=["calc_server.py"])
# --- a scripted stand-in for a real LLM, like the tool-calling tutorial ---
@dataclass
class ToolUse:
name: str; input: dict; type: str = "tool_use"
@dataclass
class Text:
text: str; type: str = "text"
@dataclass
class Reply:
content: list; stop_reason: str
class ScriptedModel:
"""Pretend LLM: asks for a tool, then answers. Swap for anthropic.Anthropic()."""
def __init__(self, script): self._script, self._i = script, 0
def next(self):
r = self._script[min(self._i, len(self._script) - 1)]; self._i += 1; return r
def mcp_tool_to_schema(t):
"""Turn an MCP tool listing into the shape an LLM API expects."""
return {"name": t.name, "description": t.description, "input_schema": t.inputSchema}
async def main():
async with stdio_client(server) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
listing = await session.list_tools()
tools = [mcp_tool_to_schema(t) for t in listing.tools]
print(f"Loaded {len(tools)} MCP tools into the loop: {[t['name'] for t in tools]}")
model = ScriptedModel([
Reply([ToolUse("price_quote", {"item": "okra", "kg": 2})], "tool_use"),
Reply([Text("2 kg of okra costs Rs 120. Anything else?")], "end_turn"),
])
messages = [{"role": "user", "content": "What does 2 kg of okra cost?"}]
for step in range(1, 6): # max-iteration guard
reply = model.next()
if reply.stop_reason != "tool_use":
answer = "".join(b.text for b in reply.content if b.type == "text")
print(f"[done in {step} round(s)] {answer}")
break
for block in reply.content:
if block.type != "tool_use":
continue
print(f" [round {step}] model calls {block.name}({block.input})")
# The real tool runs on the MCP server, not in this file.
result = await session.call_tool(block.name, block.input)
print(f" -> {result.content[0].text}")
messages.append({"role": "tool", "content": result.content[0].text})
asyncio.run(main())
▶ Output
Loaded 2 MCP tools into the loop: ['price_quote', 'total_calories']
[round 1] model calls price_quote({'item': 'okra', 'kg': 2})
-> 2.0 kg of okra = Rs 120 (at Rs 60/kg)
[done in 2 round(s)] 2 kg of okra costs Rs 120. Anything else?
What happened here: The loop is the same observe-think-act cycle from before. The one new piece is mcp_tool_to_schema, which reshapes each MCP tool listing (name, description, and the inputSchema the server already gave us) into the tool format an LLM expects. When the scripted model asks for price_quote, the call goes out to the server over stdio and the real function runs there. Replace ScriptedModel with a real Anthropic or OpenAI client and this becomes a genuine agent that can use any MCP server you point it at, without you writing a single tool by hand. That is the moment MCP earns its keep.
Adding an MCP Server to a Desktop Host
You will not always write the client yourself. Desktop AI apps like Claude Desktop, and coding tools like Cursor and VS Code, are already MCP hosts. You add a server by editing a small JSON config that tells the host how to launch it, then the host handles the connecting and discovery for you. The config below wires in the official filesystem server (run through npx) and our own kitchen server side by side.
📄 claude_desktop_config.json: registering two MCP servers with a host
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "C:/Users/aditi/projects"]
},
"veggie-kitchen": {
"command": "python",
"args": ["C:/Users/aditi/mcp/calc_server.py"]
}
}
}
What happened here: Each entry under mcpServers is a named server with a command and its args, which is exactly what the host runs as a subprocess to start that server over stdio. The filesystem entry pulls a published server from npm and scopes it to one folder, so the model can read and write files only there. The veggie-kitchen entry launches the same Python server you built above. After a restart, the host lists all these tools automatically and the model can call them. This is the same command plus args pattern as StdioServerParameters in code, just written as config. The exact file location differs per app, so check that tool’s docs for where the config lives.
Security: Tool Servers Are a Supply Chain
Here is the part people skip and regret. An MCP server is a program that runs on your machine and gets handed real capabilities: your files, your shell, your database. Installing a random server from the internet is exactly like pip install-ing an unknown package or plugging in a stranger’s USB stick. If it is malicious, it can read secrets, exfiltrate data, or run commands, and a cleverly worded tool description can even trick the model into calling it with bad arguments (a prompt-injection path through the tool layer).
- Allowlist, do not auto-install. Only run servers you have read or that come from a source you trust, and pin the exact version.
- Scope the blast radius. Give the filesystem server one folder, not your whole drive; give a DB server a read-only account.
- Sandbox the process. Run untrusted servers in a container or VM with no secrets in the environment. See the AI guardrails tutorial for the sandboxing and safety patterns.
- Keep a human in the loop for any tool that writes, deletes, or sends money, so a bad call needs your approval before it runs.
None of this is a reason to avoid MCP. It is the same care you already take with dependencies. The protocol makes tools easy to share, which is great, and that same ease is exactly why you vet what you connect.
Common Mistakes
- Forgetting the async context managers: The client uses
async withfor bothstdio_clientandClientSession. Skip them and the subprocess leaks or the session never initialises. Always nest both. - Confusing tools and resources: A tool is an action you call; a resource is data you read. Calling
read_resourceon a tool name, orcall_toolon a resource URI, just fails. Match the primitive to the method. - Running an untrusted server unsandboxed: A tool server has real power on your machine. Do not point a host at a server you have not vetted, and never give one broader file or network access than the job needs.
- Assuming MCP replaces your framework: MCP is how tools connect, not an agent runtime. You still need the loop and the model. It sits alongside LangGraph or the Agents SDK, it does not replace them.
Best Practices
- Write clear tool descriptions. The model picks tools by their description, so a precise one-liner per tool improves accuracy far more than clever code does.
- Prefer stdio for local, HTTP for remote. Run tools on your own machine over stdio; reach shared or hosted servers over Streamable HTTP. Do not expose a local stdio server to the network.
- Discover, do not hard-code. Call
list_toolsat startup so your app adapts when a server adds or renames a tool, instead of breaking on the next update. - Return structured, small results. Keep tool output tight and machine-readable; huge blobs get re-sent to the model every turn and quietly run up your token bill.
- Pin server versions. Treat servers like any dependency: pin them, read the changelog, and update deliberately.
Interview Corner
Q: In one line, what problem does the Model Context Protocol solve?
It removes the N times M integration problem. Instead of writing custom glue for every app-and-tool pair, you build each tool once as an MCP server, and any host that speaks the protocol can use it. One standard replaces many one-off connectors, the same way USB replaced a drawer full of proprietary cables.
More in this series:
- RLHF vs DPO: How LLMs Learn Human Preferences
- Python: LangChain vs LlamaIndex vs CrewAI, AI Agent Frameworks Compared
- AI Agent Evaluation: Trajectories, Tool Errors, Success
Frequently Asked Questions
What is the Model Context Protocol?
The Model Context Protocol (MCP) is an open standard for connecting AI apps to external tools, data, and files. A tool is built once as an MCP server, and any host that speaks the protocol (a chat app, an IDE, an agent) can use it through a small client. It uses JSON-RPC 2.0 messages over a stdio or HTTP transport, and at the time of writing it is stewarded as a vendor-neutral standard under the Linux Foundation.
How is MCP different from function calling?
Function calling is how a single model asks your code to run a tool inside one app. MCP is the layer that lets that tool live in a separate, reusable server so many apps can share it. In practice you still use function calling in the loop; MCP just supplies the tools and runs them on the server side instead of inside your app.
Do I need an LLM or API key to use MCP?
No. MCP is just a protocol between a client and a server, so you can list and call tools with no model at all, which is exactly what the runnable examples in this post do offline. You only need a model (and its API key) when you want an agent to decide which tool to call on its own.
What transports does MCP support?
The two common transports are stdio, which runs the server as a local subprocess and talks over standard input and output, and Streamable HTTP, which talks to a remote server over the network. The protocol messages are identical on both; only the pipe changes. Use stdio for local tools and HTTP for hosted or shared servers.
Can MCP servers be written in languages other than Python?
Yes. Because MCP is plain JSON-RPC, servers and clients exist for TypeScript, Python, Go, Rust, Java, and more. A Python client can talk to a server written in any of them, which is the whole point of a shared protocol. Pick whichever language fits the tool you are building.
Is it safe to install third-party MCP servers?
Only ones you trust. An MCP server is real code that runs on your machine with real capabilities, so treat it like any dependency: read it or vet the source, pin the version, scope its access to the minimum, and sandbox untrusted servers in a container. A malicious server or a poisoned tool description can read secrets or run commands, so never point a host at a server you have not checked.
Interview Questions on Model Context Protocol
These come from real screens and onsites. Practice answering before you read each answer.
Q: A candidate says MCP replaces LangGraph and the OpenAI Agents SDK. Are they right?
No, and this is a common mix-up. MCP is a connection protocol for tools and data; it defines how a host discovers and calls tools on a server. It is not an agent runtime, so it does not give you the observe-think-act loop, memory, or orchestration. Those still come from your framework or your own loop. MCP sits underneath as the tool layer: LangGraph or the Agents SDK can consume MCP servers, and the two solve different problems.
Q: Walk me through what happens between calling list_tools and call_tool.
After initialize shakes hands, list_tools sends a tools/list JSON-RPC request and the server replies with each tool’s name, description, and input schema. The host (or the model) picks a tool from that list. call_tool then sends a tools/call request with the tool name and an arguments dict, the server runs the real function, and it replies with a content list and an isError flag, all matched by the request id. The client never hard-codes the tool list; it learns it at runtime.
Q: When would you choose the stdio transport over Streamable HTTP?
Use stdio when the server runs on the same machine as the host, for example a filesystem, git, or local database tool. The host launches it as a subprocess and talks over standard input and output, which is simple and needs no network. Use Streamable HTTP when the server is remote or shared across users. The protocol messages are the same; only the pipe changes. A local stdio server should not be exposed to the network directly.
Q: What is the security model, and where is the biggest risk?
An MCP server is code running on your machine with whatever access you grant it, so the biggest risk is a supply-chain one: installing a malicious or compromised server, exactly like a bad pip package. A second risk is a poisoned tool description that manipulates the model into calling a tool with harmful arguments, a prompt-injection path. Mitigate both by allowlisting vetted servers, pinning versions, scoping each server to the minimum access, sandboxing untrusted ones, and keeping a human approval step for any destructive or paid action.
Q: Why is MCP built on JSON-RPC rather than a custom binary format?
JSON-RPC 2.0 is simple, text-based, and already understood in every language, so a server in Go or TypeScript and a client in Python interoperate with no special tooling. Each message has a method, params, and an id, and replies echo the id so they match cleanly. That low barrier is why the ecosystem grew fast across languages. A custom binary format would be marginally smaller but would kill the easy cross-language interop that makes a shared protocol worth adopting.
What’s Next?
You now know what the Model Context Protocol is, why it exists, and how to use it: the host, client, and server roles, the tools, resources, and prompts primitives, the stdio and HTTP transports, and the JSON-RPC messages underneath. You connected a real Python client to a real server, drove its tools from the agent loop, added servers to a desktop host through config, and saw why every tool server is a piece of your supply chain to vet.
Next, put these tools to work inside a full framework in the LangChain vs LlamaIndex comparison, where the same tool-calling ideas scale up into real applications. For the full roadmap and every tutorial in order, head back to the Python + AI/ML tutorial series home.
Go deeper: when you outgrow this post, Model Context Protocol documentation is the next stop.
Related Posts
Previous: GenAI: Building AI Agents with LangGraph, CrewAI, and Beyond
Next: Build an MCP Server in Python (FastMCP, Step by Step)
Series Home: Python + AI/ML Tutorial Series

No comment