MCP, explained by building a server
What the Model Context Protocol is, how hosts, clients and servers fit together, and a small Python MCP server built, run and locked down with the SDK.
- MCP
- AI Engineering
- Python
- Security
An AI assistant gets far more useful once it can read your files, search a database or open a ticket. Until recently, every assistant wired up each of those systems in its own way. The Model Context Protocol (MCP) is an attempt to make it one plug that fits everywhere. The quickest way I know to understand a protocol is to implement a small piece of it, so this post explains MCP, then builds a real server in under 100 lines of Python, runs it, and reads what goes over the wire.
The problem MCP solves
Say you have three AI apps (a desktop chat app, an IDE assistant, an agent you're writing) and four systems you want them to reach (your files, GitHub, Postgres, Slack). Without a shared protocol that's twelve integrations, each written against one app's plugin format. With one, each app implements the client side once and each system gets one server: seven pieces. N×M becomes N+M.
Anthropic introduced MCP on 25 November 2024 as an open standard. The launch included the spec and SDKs, support for local MCP servers in the Claude Desktop apps, and an open-source repository of ready-made servers for systems such as Google Drive, Slack, GitHub, Git, Postgres and Puppeteer. The announcement also named developer-tool companies, including Zed, Replit, Codeium and Sourcegraph, as working with it.
The spec names the Language Server Protocol as an inspiration, and the comparison helps. LSP lets a language ship one server that every editor can use. MCP aims for the same shape, with AI apps on one side and tools and data on the other.
What changed in the last few weeks is who else signed up. On 26 March 2025, OpenAI's Sam Altman said OpenAI would add MCP support across its products, available that day in its Agents SDK, with the ChatGPT desktop app and the Responses API to follow. On 9 April, Google DeepMind's Demis Hassabis said Google would support MCP in its Gemini models and SDK, without giving a timeline. A protocol is worth learning when more than one vendor speaks it, and MCP now does.
Hosts, clients and servers
MCP names three roles, and the naming trips people up because "client" doesn't mean the app you use.
- Host: the application, such as Claude Desktop or an IDE. It owns the conversation and the model, decides what the model gets to see, and is responsible for asking you before a tool runs.
- Client: a connector inside the host. The host creates one client per server, and each client keeps a single stateful session with its server.
- Server: a program that exposes capabilities. It can be a local process or a remote service. By design it sees only what it's sent: not the whole conversation, and not the other servers.
- 01Host app and its model
- 02MCP client
- 03MCP server
- 04Tool function
- 05Result back to the model
So when you add an MCP server to Claude Desktop, Claude Desktop is the host, it creates a client for that server, and the server is the small program you wrote.
What a server can offer
The spec defines three kinds of thing a server can expose, and the difference is who decides to use them:
| Primitive | Who decides | In the notes server |
|---|---|---|
| Tools | The model | search_notes, read_note, add_note |
| Resources | The application | notes://index, a list of every note |
| Prompts | The user, for example as a slash command | none |
The Python SDK's README has a handy analogy: resources are a bit like GET endpoints that load information into the model's context, and tools are a bit like POST endpoints that run code or cause side effects. Features also flow the other way: a client can offer sampling, which lets a server ask the host's model for a completion with the user's approval, and roots, the folders a server is meant to stay within.
Every message is JSON-RPC 2.0
There are three kinds of message. A request has an id and expects a response. A response carries the same id and either a result or an error. A notification has no id, and nobody replies to it.
A session starts with a handshake. The client sends initialize with the protocol version it wants and its capabilities. The server answers with the version it will use and its own capabilities. The client then sends a notifications/initialized notification, and normal traffic begins. Protocol versions are dates.
Two transports
stdio
The host launches the server as a subprocess, writes messages to its stdin and reads replies from its stdout, one JSON object per line. The server can log to stderr. This is how desktop apps run local servers, and what this post uses.
Streamable HTTP
New in the 2025-03-26 revision. One HTTP endpoint takes POST and GET. The server can answer with plain JSON or open a Server-Sent Events stream when it has more to say, and can issue an Mcp-Session-Id header to keep a session together.
Streamable HTTP replaces the HTTP+SSE transport from the launch revision, 2024-11-05, which needed one endpoint for the event stream and another for the client's POSTs. The same revision added an authorisation framework for HTTP based on OAuth 2.1, JSON-RPC batching, tool annotations that say whether a tool is read-only or destructive, and audio content alongside text and images.
Building a notes server
The server gives an assistant one folder of Markdown notes: search them, read one, add a new one. It's small, but it's the shape of most useful servers: a thin layer over something you already have, with a hard boundary around it.
FastMCP is the high-level API in the official Python SDK. It began as a separate open-source project and was folded into the SDK in version 1.2.0, released on 3 January 2025. The SDK needs Python 3.10 or later. I used Python 3.11 and the latest release at the time of writing:
python3 -m venv .venv
.venv/bin/pip install "mcp[cli]==1.6.0"Here's the whole server:
"""An MCP server that lets an assistant search, read and add Markdown notes."""
import os
import re
import sys
from pathlib import Path
from mcp.server.fastmcp import FastMCP
NOTES_DIR = Path(os.environ.get("NOTES_DIR", "")).expanduser().resolve()
MAX_NOTE_BYTES = 50_000
if not os.environ.get("NOTES_DIR") or not NOTES_DIR.is_dir():
# stdout belongs to the protocol, so complaints go to stderr.
print("Set NOTES_DIR to an existing folder", file=sys.stderr)
sys.exit(1)
mcp = FastMCP("notes")
def note_path(name: str) -> Path:
"""Turn a note name into a path inside NOTES_DIR, or refuse."""
filename = name if name.endswith(".md") else f"{name}.md"
path = (NOTES_DIR / filename).resolve()
if not path.is_relative_to(NOTES_DIR):
raise ValueError(f"{name!r} is outside the notes folder")
return path
def all_notes():
"""Every .md file under NOTES_DIR, skipping symlinks that point outside it."""
for path in sorted(NOTES_DIR.rglob("*.md")):
if path.resolve().is_relative_to(NOTES_DIR):
yield path
@mcp.tool()
def search_notes(query: str, limit: int = 10) -> str:
"""Find lines in any note that contain the query (case-insensitive)."""
query = query.strip().lower()
if not query:
raise ValueError("query is empty")
limit = max(1, min(limit, 50))
hits = []
for path in all_notes():
lines = path.read_text(encoding="utf-8").splitlines()
for number, line in enumerate(lines, start=1):
if query in line.lower():
name = path.relative_to(NOTES_DIR).as_posix()
hits.append(f"{name}:{number}: {line.strip()}")
if len(hits) == limit:
return "\n".join(hits)
return "\n".join(hits) or f"No notes mention {query!r}."
@mcp.tool()
def read_note(name: str) -> str:
"""Return the full text of one note, e.g. 'hampi-trip'."""
path = note_path(name)
if not path.is_file():
raise ValueError(f"No note called {name!r}")
if path.stat().st_size > MAX_NOTE_BYTES:
raise ValueError(f"{name!r} is larger than {MAX_NOTE_BYTES} bytes")
return path.read_text(encoding="utf-8")
@mcp.tool()
def add_note(title: str, body: str) -> str:
"""Create a new note. Never overwrites an existing one."""
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")[:60]
if not slug:
raise ValueError("title needs at least one letter or digit")
if len(body.encode("utf-8")) > MAX_NOTE_BYTES:
raise ValueError("body is too long")
path = note_path(slug)
try:
with path.open("x", encoding="utf-8") as f: # "x" fails if it exists
f.write(f"# {title.strip()}\n\n{body.strip()}\n")
except FileExistsError:
raise ValueError(f"{path.name} already exists") from None
return f"Saved {path.name}"
@mcp.resource("notes://index", mime_type="text/markdown")
def index() -> str:
"""Every note's file name and first line."""
rows = []
for path in all_notes():
first = path.read_text(encoding="utf-8").partition("\n")[0]
rows.append(f"- {path.relative_to(NOTES_DIR).as_posix()}: {first}")
return "\n".join(rows) or "No notes yet."
if __name__ == "__main__":
mcp.run() # stdio by defaultA few things in there are doing more than they look.
Type hints become the schema. FastMCP reads each function's signature and docstring and turns them into the tool definition the model sees. query: str becomes a required string, limit: int = 10 an optional integer with a default, and the docstring becomes the description. That docstring isn't a comment for you. It's what the model reads when deciding whether to call the tool, so write it for that reader.
Errors are results the model can read. When a tool raises, FastMCP sends back an ordinary result marked isError: true, with the exception's message as text. The spec asks for exactly this: failures inside a tool go in the result, where the model can see them and try something else, and JSON-RPC errors are kept for protocol problems such as an unknown tool. A ValueError with a clear message is all the error handling this server needs.
add_note never takes a path from the model. It builds the file name from the title, keeping only lowercase letters, digits and hyphens, so there's nothing to traverse with. It opens the file in "x" mode, which fails if the file exists. That's one atomic step rather than a check followed by a write that something else could slip between.
Running it from a Python client
Before involving any AI app, I drove the server from a short script using the SDK's client side. stdio_client launches the server as a subprocess, just as a desktop host would, and ClientSession does the handshake and the JSON-RPC bookkeeping.
import asyncio
import sys
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from pydantic import AnyUrl
server = StdioServerParameters(
command=sys.executable, # the same venv's Python
args=["notes_server.py"],
env={"NOTES_DIR": "demo/notes"},
)
CALLS = [
("search_notes", {"query": "book"}),
("add_note", {"title": "Questions for the landlord", "body": "Renewal date?"}),
("add_note", {"title": "questions for the LANDLORD", "body": "Same note again"}),
("read_note", {"name": "../secrets.txt"}),
]
async def main() -> None:
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!r}, {info.protocolVersion}")
tools = await session.list_tools()
for tool in tools.tools:
print(f"tool {tool.name}{list(tool.inputSchema['properties'])}")
index = await session.read_resource(AnyUrl("notes://index"))
print("\n" + index.contents[0].text + "\n")
for name, arguments in CALLS:
result = await session.call_tool(name, arguments)
flag = "ERROR " if result.isError else ""
print(flag + result.content[0].text)
asyncio.run(main())I ran it on an M2 MacBook with 8 GB of RAM, next to a demo/notes folder holding three illustrative notes, with a demo/secrets.txt sitting just outside it:
Connected to 'notes', 2024-11-05
tool search_notes['query', 'limit']
tool read_note['name']
tool add_note['title', 'body']
- hampi-trip.md: # Hampi trip
- reading-list.md: # Reading list
- side-projects.md: # Side-project ideas
hampi-trip.md:4: Hire bicycles near the bazaar; book the coracle ride before noon.
Saved questions-for-the-landlord.md
ERROR Error executing tool add_note: questions-for-the-landlord.md already exists
ERROR Error executing tool read_note: '../secrets.txt' is outside the notes folderThe second add_note normalised to the same file name and was refused, and the path traversal attempt came back as a readable error, not a file. There's no model anywhere in that script, which is the point. A client is plumbing; choosing which tool to call, and with what, is the host's job. That makes servers easy to test: a script like this is an integration test.
One detail worth knowing: the SDK's stdio client doesn't hand the server your whole environment. On macOS and Linux the subprocess inherits only HOME, LOGNAME, PATH, SHELL, TERM and USER, plus whatever you pass in env. Your API keys stay out of servers that didn't ask for them.
What actually crossed the pipe
To see the protocol without the SDK in the way, I wrote a short script that starts the server with subprocess and writes raw JSON lines to its stdin. Here's the handshake and one tool call. Lines starting >>> are what the script sent; <<< is what the server printed back. I've left out the tools/list exchange for length.
>>> {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "wire-demo", "version": "0.1"}}}
<<< {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"experimental":{},"prompts":{"listChanged":false},"resources":{"subscribe":false,"listChanged":false},"tools":{"listChanged":false}},"serverInfo":{"name":"notes","version":"1.6.0"}}}
>>> {"jsonrpc": "2.0", "method": "notifications/initialized"}
>>> {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "search_notes", "arguments": {"query": "coracle"}}}
<<< {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"hampi-trip.md:4: Hire bicycles near the bazaar; book the coracle ride before noon."}],"isError":false}}The client asked for 2024-11-05 and the server agreed. FastMCP declared prompts, resources and tools, even though this server has no prompts. The initialized notification has no id and got no reply. The tool result is a list of content blocks plus an isError flag; text here, though the spec also allows images, audio and embedded resources. The tools/list reply I left out described search_notes with query as a required string, limit as an integer defaulting to 10, and my docstring, word for word, as the description.
Registering it with Claude Desktop
Claude Desktop reads its servers from claude_desktop_config.json. Open it from Settings → Developer → Edit Config, or find it here:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
Each entry under mcpServers has a name, a command, its arguments and optional environment variables. Use absolute paths everywhere, because the app doesn't start the server from your project folder:
{
"mcpServers": {
"notes": {
"command": "/Users/you/mcp-notes/.venv/bin/python",
"args": ["/Users/you/mcp-notes/notes_server.py"],
"env": {
"NOTES_DIR": "/Users/you/Documents/notes"
}
}
}
}Then quit and restart the app. According to the official quickstart, a hammer icon appears in the message box listing the server's tools, and Claude asks for your approval before it uses one. If the server doesn't appear, the logs on macOS are in ~/Library/Logs/Claude/mcp*.log. Run the same command in a terminal first: most failures are a relative path or a missing environment variable.
I checked this entry without a desktop app, by loading the JSON and launching the server from it as a host would, from another working directory. It started and listed its three tools. The SDK's README also documents mcp install, which writes an entry for you, and mcp dev, which opens the server in the MCP Inspector, a browser UI for calling tools by hand.
Security: a server is your code running as you
Least privilege
Scope by configuration. NOTES_DIR is one folder, not your home directory. If an assistant only needs to search, ship the server without add_note. A tool that doesn't exist can't be misused.
The spec says there should always be a human in the loop who can deny a tool call, and Claude Desktop asks before running one. Approval prompts only protect you if you read them, though, and fewer, narrower tools make each prompt easier to read.
For credentials, the spec's guidance is that stdio servers should take them from the environment, not from the OAuth flow that's meant for HTTP. Give each server only the variables it needs.
Validate inputs after you resolve them
The schema guarantees name is a string. It doesn't stop that string being ../secrets.txt, an absolute path, or the name of a symlink that points out of the folder. The spec is plain about it: servers must validate all tool inputs. Two details in note_path matter.
First, resolve, then check. Path.resolve() collapses .. and follows symlinks, so the check runs on where the file really is. That's also why all_notes() checks each file again: a symlink inside the folder can point anywhere.
Second, compare paths, not strings. A startswith check is a classic mistake:
from pathlib import Path
root = Path("/Users/me/notes")
p = Path("/Users/me/notes-private/diary.md")
print(str(p).startswith(str(root)), p.is_relative_to(root))True FalseThe string check would have let the server into a sibling folder. is_relative_to compares path components and gets it right. Cap sizes too. The model can ask for a limit of 100000 or send a 10 MB body, which is why limit is clamped and oversized notes are refused.
Treat tool results as untrusted text
Whatever a tool returns goes into the model's context, and models can't reliably tell data from instructions. If a note, web page, email or GitHub issue contains text telling the model to read your SSH key and send it somewhere, that's a prompt injection delivered through a tool result. This server only reads your own notes, but once the same host also has a tool that can send data out, you have the dangerous mix: private data, untrusted text and a way out. Simon Willison set out this problem for MCP on 9 April 2025.
Tool definitions are input too. On 1 April 2025, Invariant Labs described tool poisoning: hidden instructions in a tool's description, which the model reads but users usually don't see, steering it to read sensitive files and pass them along. Willison's post adds that a server can change its tool definitions after you've approved it.
If you expose it over HTTP
The Streamable HTTP section of the spec has its own warnings. Servers must validate the Origin header to block DNS rebinding, should bind to 127.0.0.1 rather than every interface when running locally, and should authenticate every connection. The same thinking applies to the older SSE transport, and in mcp 1.6.0 FastMCP's SSE mode defaults to host 0.0.0.0. On a laptop, pass host="127.0.0.1" to FastMCP(...) or set FASTMCP_HOST=127.0.0.1.
What I'd take away
MCP itself is small: JSON-RPC, a handshake, three primitives and two transports. You can read a whole session in a terminal, and doing it once makes the rest of the protocol far less mysterious.
The hard decisions are about what your tools may do, not about the protocol. Start read-only, inside one folder, and add a write tool only when you need it, with the care you'd give a public API endpoint.
Test with a script before a desktop app. It's faster, it shows errors plainly, and it turns into your regression test.
And pin the SDK. The spec had a significant revision in March and the SDKs are still catching up. Everything here was written and run against mcp 1.6.0.