MCP — Model Context Protocol
The open protocol that turns the M×N tool-integration problem into M+N: how MCP standardizes exposing tools, resources, and prompts to any LLM client. Build a server, consume it from a client, and learn when an MCP server beats a plain in-process function tool.
- Explain the problem MCP solves: turning M×N custom tool integrations into M+N reusable connectors.
- Name MCP's three server primitives — tools, resources, prompts — and the host/client/server architecture.
- Build a minimal MCP server that exposes a tool and a resource.
- Consume an MCP server from a client, and judge when to build a server vs. a plain in-process function tool.
Python for JavaScript developers:
- A decorator
@mcp.tool()— wraps a function to register it, like an annotation; the function stays a normal function. - A docstring (the
"""…"""under a def) becomes the tool's description the model reads. async def/await— same as JSasync/await; MCP clients are async.- JSON-RPC — a simple "call this method with these params, get a result" message format over a transport (stdio or HTTP).
Every code block is commented line-by-line.
The problem: M×N integrations
In Topic 2 you gave an agent tools by writing function schemas inside your app. That works, but every tool is bespoke to that app. Now multiply: M AI apps (Claude Desktop, your agent, an IDE, a chatbot) each want to talk to N systems (GitHub, Postgres, Slack, your CRM). Without a standard, that's M×N custom integrations — every app re-implements every connector.
MCP (Model Context Protocol) — an open standard introduced by Anthropic — collapses that to M+N. Each
system is wrapped once as an MCP server; each app is an MCP client once. Any client can then use any
server. It's "USB-C for AI tools": one protocol, universal connectors. (This is the same protocol behind the
ghostcrab, Gmail, and browser tools you use in this very environment.)
Architecture: host, client, server
Three roles:
- Host — the AI application the user interacts with (Claude Desktop, an IDE, your agent). It embeds one or more clients.
- Client — lives inside the host; maintains a 1:1 connection to one server and speaks the protocol.
- Server — a separate process exposing capabilities (tools/resources/prompts) for a specific system.
They communicate over JSON-RPC on a transport: stdio (the server is a local subprocess — most common for local tools) or HTTP (for remote/networked servers). The host's LLM never talks to servers directly; the client mediates every call.
The three server primitives
An MCP server can expose three kinds of capability — this taxonomy is the heart of the protocol:
| Primitive | What it is | Who controls it | Analogy |
|---|---|---|---|
| Tools | actions the model can invoke (functions with side effects) | model-driven (the LLM decides to call) | POST endpoints |
| Resources | read-only data the client can load into context | app-driven (host decides what to load) | GET endpoints / files |
| Prompts | reusable prompt templates the user can invoke | user-driven (e.g. a slash command) | saved snippets |
The distinction matters: tools are for doing (and the model chooses them), resources are for reading (the app supplies them as context), prompts are user-triggered workflows. Most servers start with tools.
Building a server
The Python SDK's FastMCP makes a server almost as simple as writing functions — decorate them and it
generates the schemas and protocol plumbing (the schema inference is the same idea as Topic 2, automated):
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("weather") # create a server named "weather"
@mcp.tool() # register a TOOL (a model-invocable action)
def get_forecast(city: str) -> str:
"""Get today's weather forecast for a city.""" # docstring -> the tool description the model reads
return f"Sunny, 21°C in {city}" # (a real server would call a weather API)
@mcp.resource("config://units") # register a RESOURCE at a URI (read-only context)
def units() -> str:
"""The unit system this server reports in."""
return "metric (Celsius)" # the host can load this into the model's context
if __name__ == "__main__":
mcp.run() # start the server over stdio (default transport)
The @mcp.tool() decorator inspects the function's signature and docstring to build the JSON schema
automatically — the same tool-schema idea from Topic 2, but now the tool lives in a reusable server any
MCP client can connect to, not hard-wired into one app.
↳ Exercise 1 has you build this server with a tool, and Exercise 2 has you add a resource and see how a read-only resource differs from a model-invoked tool.
Consuming a server from a client
A client connects to the server over a transport, lists what it offers, and calls it. Here's the shape with the Python client SDK talking to a stdio server:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
params = StdioServerParameters(command="python", args=["weather_server.py"]) # how to launch the server process
async def main():
async with stdio_client(params) as (read, write): # spawn the server, get read/write streams
async with ClientSession(read, write) as session: # a protocol session over those streams
await session.initialize() # handshake (exchange capabilities/versions)
tools = await session.list_tools() # discover what the server exposes
print([t.name for t in tools.tools]) # -> ['get_forecast']
result = await session.call_tool("get_forecast", {"city": "Paris"}) # invoke a tool by name + args
print(result.content) # -> "Sunny, 21°C in Paris"
In practice you rarely write this by hand — hosts like Claude Desktop, and agent frameworks (LangChain,
LangGraph adapters), consume MCP servers for you from a config file. But knowing the initialize → list → call handshake demystifies what's happening under the hood.
↳ Exercise 3 has you connect a client to your Exercise-1 server, list its tools, and call one — seeing the full server↔client round trip.
When to build an MCP server vs. a plain tool
MCP is powerful, but it's not free — a server is a separate process, protocol, and deployment. Use the right tool:
- Plain in-process function tool (Topic 2) when the tool is used by one app, is simple, and shares the app's process/lifecycle. No protocol overhead.
- MCP server when the capability should be reused across multiple apps/hosts, needs to run as a separate process (different language, isolation, independent deployment), or you want to plug into the MCP ecosystem (Claude Desktop, IDEs, other agents) without bespoke glue.
The heuristic: one app, one use → function tool; a connector many clients should share → MCP server. The whole point of MCP is reuse and interoperability; if there's nothing to reuse and no other client, a plain function is simpler.
↳ Exercise 4 has you decide, for four capabilities, whether each should be an MCP server or a plain function tool — the build-vs-inline judgment.
Pitfalls
- Reaching for MCP when a function will do. For a single app's single tool, a plain function tool is simpler — MCP's value is cross-app reuse.
- Confusing tools and resources. Tools are model-invoked actions (side effects); resources are app-loaded read-only context. Modeling a data read as a tool (or vice versa) muddies the design.
- Trusting a server blindly. A server runs code and can act on systems — vet third-party servers as you would any dependency, and scope its permissions (Security track).
- Forgetting the transport choice. stdio for local subprocess tools; HTTP for remote/networked servers — picking wrong complicates deployment.
- Weak tool docstrings. As always, the description drives selection — write server tool docstrings carefully.
Recap
- MCP is an open protocol that turns M×N bespoke integrations into M+N reusable connectors — "USB-C for AI tools."
- Architecture: a host embeds clients, each connected 1:1 to a server, over JSON-RPC on stdio or HTTP.
- Servers expose three primitives: tools (model-invoked actions), resources (app-loaded read-only context), prompts (user-invoked templates).
- Build a server by decorating functions (
@mcp.tool(),@mcp.resource()); consume it via the initialize → list → call handshake (usually done for you by the host). - Choose MCP when a connector should be reused across apps or run as a separate process; a plain function tool when it's one app's single use.
- Worked examples map to the exercises: a server tool (Ex 1), a resource (Ex 2), a client round trip (Ex 3), and the server-vs-function decision (Ex 4).
MCP — Model Context Protocol — Exercises
Install the SDK: pip install mcp. You'll write a small server and a client that talks to it. APIs evolve —
check the current MCP Python SDK docs if an import differs; the primitives and handshake are stable.
Exercise 1 — A minimal MCP server with a tool
Write a FastMCP server named notes exposing a search_notes(query) tool (back it with a dict for now).
Run it over stdio.
Reference solution
# notes_server.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("notes")
NOTES = {"pricing": "Pro plan is €49/month.", "security": "Encrypted at rest with AES-256."}
@mcp.tool()
def search_notes(query: str) -> str:
"""Search the notes store and return the best matching note."""
return NOTES.get(query.lower().strip(), "No matching note.")
if __name__ == "__main__":
mcp.run() # stdio transport by default
The @mcp.tool() decorator reads the signature and docstring to auto-generate the tool schema — the same
schema you wrote by hand in the Tool Use topic, now inferred and exposed over the protocol so any MCP client
can call it.
Exercise 2 — Add a resource
Add a read-only resource to the server (e.g. notes://index returning the list of note titles). Note how a
resource is app-loaded context, not a model-invoked action.
Reference solution
@mcp.resource("notes://index")
def note_index() -> str:
"""The list of available note titles."""
return ", ".join(NOTES.keys()) # "pricing, security"
A tool (search_notes) is something the model decides to call to take an action. A resource
(notes://index) is data the host loads into context — it's addressed by a URI and read, not invoked with
arguments by the model. Same server, two different interaction models: doing vs. reading.
Exercise 3 — Consume the server from a client
Write a client that launches notes_server.py, runs the initialize handshake, lists the tools, and calls
search_notes("pricing").
Reference solution
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
params = StdioServerParameters(command="python", args=["notes_server.py"])
async def main():
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize() # handshake
tools = await session.list_tools()
print("tools:", [t.name for t in tools.tools]) # ['search_notes']
res = await session.call_tool("search_notes", {"query": "pricing"})
print("result:", res.content) # Pro plan is €49/month.
asyncio.run(main())
The initialize → list_tools → call_tool sequence is the whole protocol in miniature. In real use a host
(Claude Desktop, an agent framework) does this from a config file — but now you know what it's doing under the
hood, and why any client can reuse this same server.
Exercise 4 — MCP server or plain function tool?
For each capability, decide MCP server or plain in-process function tool, and justify in one line.
- A one-off
word_count(text)helper used only inside your single agent. - A GitHub connector you want usable from Claude Desktop, your IDE, and two different agents.
- A Postgres query tool written in Python that your Node.js chatbot also needs to call.
- A tiny date-formatting helper used in one prompt-building step.
Reference solution
- Function tool. One app, one trivial use, shares the process — MCP's protocol overhead buys nothing.
- MCP server. The entire point: wrap GitHub once, reuse across many hosts/clients without bespoke glue.
- MCP server. Cross-language reuse (Python capability, Node client) is exactly what a separate-process protocol server enables.
- Function tool. Inline, single-use, no cross-app reuse — a plain function is simpler.
The heuristic: one app / one use → function tool; a connector many clients should share (or a separate process / language) → MCP server. MCP's value is reuse and interoperability; without those, inline is simpler.