Jun 7, 2026 6 min read

Build Your First MCP Server in Python (and the Security Section Everyone Skips)

A one-file MCP server in Python with uv and FastMCP, verified on SDK 1.27.2 with real output, plus the path-scoping security section other tutorials skip.

Tested with: Debian 13 (trixie), Python 3.13.5, uv (latest), mcp Python SDK 1.27.2, tested 2026-06-02. The server itself is OS-agnostic; only the client config path differs by platform.

Building an MCP server is the highest-leverage thing you can learn if you use AI agents seriously, because once you can write one, you can wire any tool you already use into any agent you already run. The good news: a working server is a single Python file. The part most tutorials skip, and the part this one ends on, is what that file leaves you exposed to and the one-page fix for it.

MCP (the Model Context Protocol) is the standard for how an AI client and a tool provider talk. If the words "client", "server", and "protocol" still feel tangled, I pulled them apart in a separate what-is-an-MCP-server explainer; this post assumes you just want to build one. We are not building the weather example everyone clones. We are building a personal-notes server with two tools (add_note, search_notes) and one resource, small enough for one file and useful enough that you might keep it.

What the server exposes, in 60 seconds

An MCP server offers up to three kinds of capability. Servers communicate over JSON-RPC 2.0 using one of two transports:

  • Tools are functions the model can choose to call: add_note, search_notes.
  • Resources are read-only data the client can fetch: here, the notes file itself.
  • Prompts are reusable templates. Not our focus today.

For transport we use stdio: the client launches your server as a subprocess and talks to it over standard input and output. It is the simplest way to start and what local desktop clients expect. (Remote servers use Streamable HTTP, which is a later post.)

Set up the project with uv

I used uv, the fast Python package manager. If you do not have it:

curl -LsSf https://astral.sh/uv/install.sh | sh

Restart your shell so uv is on PATH, then create the project and add the MCP SDK:

uv init notes-mcp
cd notes-mcp
uv add "mcp[cli]"

The mcp[cli] extra pulls in the FastMCP framework plus the mcp CLI. On my run that resolved to SDK 1.27.2. Confirm what you got, because this SDK moves fast:

$ uv run python -c "import importlib.metadata as m; print(m.version('mcp'))"
1.27.2

Delete the auto-generated hello.py and create server.py.

The whole server, in one file

from pathlib import Path
import re
from mcp.server.fastmcp import FastMCP

NOTES_FILE = Path.home() / ".notes" / "notes.md"
NOTES_FILE.parent.mkdir(exist_ok=True)
NOTES_FILE.touch(exist_ok=True)

mcp = FastMCP("notes")


@mcp.tool()
def add_note(text: str, tag: str = "") -> str:
    """Append a new note to the personal notes file."""
    text = text.strip()
    if not text:
        return "Refusing to add an empty note."
    line = f"- [{tag}] {text}\n" if tag else f"- {text}\n"
    with NOTES_FILE.open("a") as f:
        f.write(line)
    return f"Added note ({len(text)} chars)."


@mcp.tool()
def search_notes(query: str, limit: int = 10) -> str:
    """Search notes for lines matching a substring (case-insensitive)."""
    if not query.strip():
        return "Provide a non-empty query."
    pattern = re.compile(re.escape(query), re.IGNORECASE)
    matches = []
    with NOTES_FILE.open() as f:
        for line in f:
            if pattern.search(line):
                matches.append(line.rstrip())
                if len(matches) >= limit:
                    break
    return "\n".join(matches) if matches else f"No notes match '{query}'."


@mcp.resource("notes://all")
def all_notes() -> str:
    """Return the entire notes file as a resource."""
    return NOTES_FILE.read_text()


if __name__ == "__main__":
    mcp.run(transport="stdio")

Three things doing the work here:

  • The decorators are the whole trick. @mcp.tool() turns a normal function into an MCP tool. The docstring becomes the description the model reads; the type hints become the input schema. @mcp.resource("notes://all") exposes a fetchable resource at that URI.
  • Minimal but real validation. Refusing empty notes and empty queries keeps the tools well-behaved when the model probes them.
  • Never print to stdout. The stdio transport speaks JSON-RPC on stdout. A stray print() corrupts the stream and breaks the server in a way that is miserable to debug. Log to stderr.

Prove it works before wiring it to an agent

The official way to click around is the Inspector: mcp dev server.py opens it in your browser, where you see the two tools, their schemas, and the one resource. But you do not need a browser to confirm the server is alive. Because it is just JSON-RPC over stdio, you can drive it by hand, which is exactly what I did. Piping a short sequence of messages into the server returns real responses.

The handshake comes back identifying the server:

{"result":{"protocolVersion":"2024-11-05",
  "capabilities":{"tools":{},"resources":{}},
  "serverInfo":{"name":"notes","version":"1.27.2"}}}

Listing tools returns both, with schemas generated from the type hints. Then the actual calls. Adding a note:

-> tools/call add_note {"text":"draft the Q3 retro doc","tag":"work"}
<- {"content":[{"type":"text","text":"Added note (22 chars)."}]}

Searching for it:

-> tools/call search_notes {"query":"retro"}
<- {"content":[{"type":"text","text":"- [work] draft the Q3 retro doc"}]}

And reading the resource returns the whole file:

-> resources/read {"uri":"notes://all"}
<- {"contents":[{"text":"- [work] draft the Q3 retro doc\n"}]}

Meanwhile FastMCP logs each request to stderr (Processing request of type CallToolRequest, and so on), which is where logs belong and why your print() calls must not go to stdout. Seeing those four exchanges return clean is the moment you know the server is correct, before any agent is involved.

Wire it into Claude Code

Stop the Inspector. To let Claude Code use the server, add it to the MCP config (the path varies by platform; the Claude Code docs have the current one):

{
  "mcpServers": {
    "notes": {
      "command": "uv",
      "args": ["--directory", "/ABSOLUTE/PATH/TO/notes-mcp", "run", "python", "server.py"]
    }
  }
}

Use an absolute path (pwd in the project dir); relative paths do not work here. Restart Claude Code, and in a new chat the agent has your add_note and search_notes tools. Ask it to add a note and search for it, and the calls return exactly the payloads shown above, because the client is sending the same tools/call messages we sent by hand. The first time that round-trip works end to end is when MCP clicks as a technology.

The security angle most tutorials skip

An MCP server is, at worst, "I gave a language model the ability to act on my machine." That should make you a little nervous. Five points keep your server boring, in the good way.

1. Constrain the file paths

Our server only ever writes to ~/.notes/notes.md. That narrow scope is deliberate. A hypothetical "file editor" server with an edit_file(path, content) tool could rewrite any file on your machine at the say-so of any prompt it processes. If you build something like that, hard-code a root and refuse anything outside it:

ALLOWED_ROOT = Path.home() / "projects"

@mcp.tool()
def edit_file(path: str, content: str) -> str:
    resolved = (ALLOWED_ROOT / path).resolve()
    if not resolved.is_relative_to(ALLOWED_ROOT):
        return "Path escapes the allowed root."
    resolved.write_text(content)
    return f"Wrote {len(content)} bytes."

The resolve() plus is_relative_to() pair stops the obvious ../../etc/passwd escapes.

2. Reject empty or unreasonable inputs

A model probing your server will call tools with empty strings, nulls, and absurd values. Refuse them and return a useful message. This is the difference between a tool the agent learns to use and one it keeps misfiring.

3. Never echo secrets in tool output

If a tool calls an API with a key, never put that key in the return value, not even in an error string. The return value goes back to the model, which may surface it downstream. Sanitize.

4. Log to stderr, not stdout

Said it twice on purpose, because it is the most common breakage. Anything on stdout corrupts the JSON-RPC stream. Use print(..., file=sys.stderr) or the logging module.

5. Pin the SDK version

The SDK moved through several releases across 2025 and 2026; I built this on 1.27.2. uv add "mcp[cli]" grabs the latest, which is usually fine, but pin it before anything depends on it:

uv add "mcp[cli]==1.27.2"

Then bump deliberately, when you have time to re-test, not when you happen to run uv sync on a Tuesday.

Where to take this

The notes server is a template. Swap the flat file for SQLite in five lines. Add a recent_notes(n) tool so the agent can pull "what have I been thinking about lately" context. Wrap an API you actually use (your task tracker, your calendar) using the same shape as add_note with an HTTP call inside. Or switch the transport to HTTP and host it so any client can reach it. If you want to see a published server driven the same hand-rolled way, the filesystem MCP walkthrough does exactly that, and if you are still deciding whether you even need a server versus plain tool calls, the MCP vs function calling post is the one to read.

Takeaways

  • An MCP server in Python is one file. The @mcp.tool() decorator does the heavy lifting; you write normal functions with type hints and docstrings.
  • You can verify a server without a browser by sending it JSON-RPC directly, or use mcp dev for the Inspector. Both catch dumb errors before an agent is involved.
  • Never write to stdout in a stdio server. This single rule prevents the most common breakage.
  • Constrain file paths to a hard-coded root with resolve() and is_relative_to(). An LLM with unconstrained file-write access is a foot-gun.
  • Pin the SDK version. It moves fast, and a silent bump can change behavior under you.
J
Great! You’ve successfully signed up.
Welcome back! You've successfully signed in.
You've successfully subscribed to LLMbits.
Your link has expired.
Success! Check your email for magic link to sign-in.
Success! Your billing info has been updated.
Your billing was not updated.