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, running over stdio and HTTP and connected to Claude Code.
New to MCP? What is an MCP server? covers the basics first.
This builds a notes server with the official Python SDK. list_notes and read_note only read, and add_note writes a file, which matters later when you decide which agents get which tools.
Every step was run on the versions in the header. The SDK changed a lot between versions 1 and 2, so if something 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 used in step 4. You can delete the main.py uv creates.
Older examples written for version 1 import FastMCP. In version 2 the class is MCPServer.
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()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, so ../../etc/passwd becomes etc-passwd.md inside the notes folder. Whatever a model passes to a tool may have come from a document or issue it just read, so 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 counts as a crash. The client sees only Error executing tool add_note, so a plain ValueError with a helpful message never reaches the model.
Annotations are hints. read_only_hint=True tells clients the tool changes nothing, which a client can use when deciding what to confirm with you. It enforces nothing, and 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 launch. Standard output carries the protocol there, so a stray print() in a tool breaks it. Log to standard error instead.
Step 3: Test it with a client
The same package includes a client. 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 started server.py as a subprocess and talked to it over stdin and stdout, the same way Claude Code will. A plain string passed to Client is treated as a URL, which is why a local command needs StdioServerParameters.
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
To serve over the network instead of stdio, 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 comes back with the ToolError message. 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. Put authentication in front of it before it goes anywhere public, or it 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. Claude Code asks before running a project server, because .mcp.json gets committed and cloning a repository shouldn't run a command on your machine. 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 gets all three tools. Once the server is shared, 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, a new server is reachable by nobody until you grant it, and grants are per tool. Here you would grant list_notes and read_note to the agents that look things up, and add_note only to the one that should write. The gateway checks the tool on every call, so a reading agent that tries add_note is refused and the refusal is recorded. Clients then connect through Statio instead of 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 now have a Python MCP server that keeps untrusted input away from the filesystem and returns errors the model can read, running over stdio or Streamable HTTP and connected 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.