Build MCP servers
How to build an MCP server in Python
A notes server in about fifty lines of Python, with two tools that read and one that writes. You will run it over stdio and over HTTP, call it from a test client, and connect it to Claude Code.
Most MCP tutorials stop at a tool that adds two numbers. That proves the wiring works, but it skips the decisions you actually make on a real server: which tools only read, which ones change something, and what happens when a caller sends input you did not expect.
So this one builds a notes server. It has three tools: list_notes and read_note only read, and add_note writes a file. That split matters later, when you decide which agents get which tools. By the end you will have a server that runs over stdio for local clients and over Streamable HTTP for remote ones, a script that tests it, and an entry in Claude Code.
Everything below was run on the versions in the header. The SDK changed a lot between versions 1 and 2, so if a step fails, check mcp is at 2.x first.
Prerequisites
- Python 3.10 or newer. I used 3.14.7.
- uv for the project and dependencies. Plain
venvandpipwork too. - Claude Code, if you want to try the server there in step 5.
Step 1: Create the project
Make a folder, start a uv project in it, and add the SDK with its CLI extra:
mkdir notes-server && cd notes-server
uv init --name notes-server --no-readme .
uv add "mcp[cli]"Check the version:
uv run python -c "import importlib.metadata as m; print(m.version('mcp'))"That printed 2.3.0 for me. The cli extra adds the mcp command, which you will use in step 4 to serve over HTTP. uv also creates a main.py you can delete.
One thing to know before you read other examples: tutorials written for version 1 of the SDK import FastMCP. In version 2 the class is MCPServer, and that is what this tutorial uses.
Step 2: Write the server
Create server.py:
import os
import re
from pathlib import Path
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
NOTES_DIR = Path(os.environ.get("NOTES_DIR", "notes")).resolve()
NOTES_DIR.mkdir(parents=True, exist_ok=True)
mcp = MCPServer("notes")
def note_path(title: str) -> Path:
"""Map a title to a file inside NOTES_DIR, and refuse anything that would escape it."""
slug = re.sub(r"[^a-z0-9-]+", "-", title.lower()).strip("-")
if not slug:
raise ToolError("A note needs a title with at least one letter or number.")
return NOTES_DIR / f"{slug}.md"
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
def list_notes() -> list[str]:
"""List the titles of every saved note."""
return sorted(p.stem for p in NOTES_DIR.glob("*.md"))
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
def read_note(title: str) -> str:
"""Return the text of one note."""
path = note_path(title)
if not path.exists():
raise ToolError(f"No note called {title!r}.")
return path.read_text()
@mcp.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False))
def add_note(title: str, body: str) -> str:
"""Save a note. Fails if a note with that title already exists."""
path = note_path(title)
if path.exists():
raise ToolError(f"A note called {title!r} already exists.")
path.write_text(body)
return f"Saved {path.stem}."
if __name__ == "__main__":
mcp.run()A few things in there are worth knowing about.
The type hints are the schema. read_note(title: str) becomes a tool whose input is an object with a required string title. The docstring becomes the tool description the model reads when it decides what to call, so write it for that reader.
Titles never reach the filesystem as typed. note_path turns every title into a slug of lowercase letters, numbers and dashes, so a title like ../../etc/passwd becomes etc-passwd.md inside the notes folder. Anything a model passes to a tool came from somewhere you do not control, often a document or an issue it just read. Treat it like form input on a website.
Raise ToolError for failures you expect. The model gets your message back and can correct itself. Any other exception is treated as a crash: the client sees only Error executing tool add_note, and the details stay in your server log. That is a sensible default for bugs, but it means a plain ValueError with a helpful message is never shown to anyone.
Annotations are hints. read_only_hint=True tells clients the tool does not change anything, and a client can use that when deciding what to confirm with the user. It enforces nothing. A tool marked read-only can still delete files if its code does.
mcp.run() with no arguments serves over stdio, which is what local clients like Claude Code and Cursor launch. On stdio, standard output carries the protocol, so a stray print() in a tool corrupts the stream. Log to standard error instead.
Step 3: Test it with a client
The same package includes a client, which makes a quick end-to-end test easy. Create client.py:
import asyncio
from mcp import Client, StdioServerParameters
server = StdioServerParameters(command="uv", args=["run", "server.py"])
async def main() -> None:
async with Client(server) as client:
tools = await client.list_tools()
print([tool.name for tool in tools.tools])
await client.call_tool("add_note", {"title": "Release checklist", "body": "Tag, build, announce."})
listed = await client.call_tool("list_notes", {})
print(listed.structured_content)
note = await client.call_tool("read_note", {"title": "release checklist"})
print(note.structured_content)
asyncio.run(main())Run it:
uv run client.pyYou should see the three tools, then the saved note:
['list_notes', 'read_note', 'add_note']
{'result': ['release-checklist']}
{'result': 'Tag, build, announce.'}The client launched server.py as a subprocess and talked to it over stdin and stdout, which is exactly what Claude Code will do. Passing a plain string to Client means a URL, so use StdioServerParameters for a local command.
If you prefer clicking through it, uv run mcp dev server.py opens the server in the MCP Inspector.
Step 4: Serve it over Streamable HTTP
Stdio is for a server running on the same machine as the client. To serve it over the network, use the CLI:
uv run mcp run server.py --transport streamable-httpIt listens on http://127.0.0.1:8000/mcp. Leave it running and, in a second terminal, create http_client.py to check the failure paths:
import asyncio
from mcp import Client
async def main() -> None:
async with Client("http://localhost:8000/mcp") as client:
dup = await client.call_tool("add_note", {"title": "Release checklist", "body": "again"})
print(dup.is_error, dup.content[0].text)
odd = await client.call_tool("read_note", {"title": "../../etc/passwd"})
print(odd.is_error, odd.content[0].text)
asyncio.run(main())uv run http_client.pyTrue Error executing tool add_note: A note called 'Release checklist' already exists.
True Error executing tool read_note: No note called '../../etc/passwd'.The duplicate is refused with the message from ToolError, and the path that tried to climb out of the folder found nothing, because it was looked up as etc-passwd.md inside it.
The server binds to 127.0.0.1, so only your machine can reach it. Before you put it anywhere public, put authentication in front of it. An MCP server with no auth on the internet will run its tools for anyone who finds the URL.
Step 5: Add it to Claude Code
From the project folder, register the stdio version for this project:
claude mcp add --scope project notes -- uv run --directory "$PWD" server.pyThat writes a .mcp.json file in the folder:
{
"mcpServers": {
"notes": {
"type": "stdio",
"command": "uv",
"args": [
"run",
"--directory",
"/path/to/notes-server",
"server.py"
],
"env": {}
}
}
}claude mcp get notes shows it as pending approval. Project servers have to be approved before Claude Code runs them, because .mcp.json is meant to be committed and shared, and nobody should get a command run on their machine just by cloning a repository. Start claude in the folder, approve it, and ask it to save a note.
To use the HTTP version instead, with the server from step 4 running:
claude mcp add --scope project --transport http notes http://127.0.0.1:8000/mcpClaude Code reads its servers when a session starts, so open a new session after adding one.
Step 6: Put it behind Statio
Right now any client that connects to this server gets all three tools. That is fine on your laptop. It stops being fine when the server is shared, because an agent that only needs to look things up can also write.
Statio is a gateway that decides access one tool at a time. There are two ways to put a server you built behind it:
- Host it yourself and connect it by URL. Deploy the HTTP version somewhere Statio can reach, then add it as an external server. This works on every plan, including Free.
- Give Statio a Docker image. From the Team plan up, Statio runs an image of your server as it is.
Either way, nothing is granted by default. A new server is reachable by nobody until you grant it, and grants are per tool. For this server you would grant list_notes and read_note to the agents that look things up, and give add_note only to the one that should write. The gateway checks the specific tool on every call, so the reading agents are refused if they try add_note, and the refusal is recorded. Clients then connect through Statio rather than to the server directly. How to point a client at the gateway is in Connect a client, and how grants work is in Permissions.
Conclusion
You built an MCP server in Python with three tools, kept untrusted input away from the filesystem, returned errors the model can read, and ran it over both stdio and Streamable HTTP. You also tested it with the SDK's own client and connected it to Claude Code.
Where to go next:
- How to build an MCP server in TypeScript, the same server with the TypeScript SDK.
- How to turn an OpenAPI spec into an MCP server, when the API already exists and you would rather not write the tools by hand.
- How to add MCP servers to Claude Code and how to set up MCP servers in Cursor, for the client side in more depth.
- How to give an AI agent read-only GitHub access, which applies the same read and write split to a server you did not build.