← Back to Payloads
Tutorial2026-06-18

Build an MCP Server in 60 Lines of Python (stdio transport)

You already have the Python tools. Wiring them up to Claude Code, Cursor, or any MCP client is one FastMCP decorator away — here is the whole stdio server in ~60 lines, including the three traps that bite every first build.
Quick Access
Install command
$ mrt install tutorial
Browse related skills
Build an MCP Server in 60 Lines of Python (stdio transport)

Build an MCP Server in 60 Lines of Python (stdio transport)

Hey guys, Mr. Technology here.

You have a pile of Python — a SQL helper, a Slack poster, an internal API client. You want Claude Code or Cursor to call those tools without copy-pasting the implementation into every chat. That is what Model Context Protocol is for. And the boring truth is the Python server side is one decorator and one entry point. Here is the entire thing, stdio transport, ~60 lines.

The Code

python
#!/usr/bin/env python3
"""mcp_server.py — minimal MCP server exposing three tools."""
from mcp.server.fastmcp import FastMCP
import sqlite3, pathlib, datetime
mcp = FastMCP("local-tools")
DB = pathlib.Path.home() / ".local-tools.db"
@mcp.tool()
def list_files(directory: str, pattern: str = "*") -> list[str]:
    """List files under a directory matching a glob."""
    from glob import glob
    return sorted(glob(f"{directory.rstrip('/')}/{pattern}"))
@mcp.tool()
def save_note(key: str, value: str) -> str:
    """Persist a key/value note to a local SQLite file."""
    with sqlite3.connect(DB) as conn:
        conn.execute(
            "CREATE TABLE IF NOT EXISTS notes (k TEXT PRIMARY KEY, v TEXT, ts TEXT)"
        )
        conn.execute(
            "INSERT OR REPLACE INTO notes VALUES (?, ?, ?)",
            (key, value, datetime.datetime.utcnow().isoformat()),
        )
    return f"saved {key}"
@mcp.tool()
def get_note(key: str) -> str:
    """Retrieve a previously saved note by key."""
    with sqlite3.connect(DB) as conn:
        row = conn.execute("SELECT v FROM notes WHERE k=?", (key,)).fetchone()
    return row[0] if row else "not found"
@mcp.resource("config://server")
def server_config() -> str:
    """Expose server config as a readable resource."""
    return f"Local tools server. DB at {DB}. Tools: list_files, save_note, get_note."
if __name__ == "__main__":
    mcp.run(transport="stdio")

That is the entire server. FastMCP wires up the JSON-RPC framing, schema generation, and stdio transport. You write functions, the SDK writes the protocol.

Hook It Up To A Client

In ~/.config/claude-code/mcp.json (or the Cursor equivalent):

json
{
  "mcpServers": {
    "local-tools": {
      "command": "python3",
      "args": ["/abs/path/to/mcp_server.py"]
    }
  }
}

Restart the client. The model now sees list_files, save_note, get_note as first-class tools. Type "save a note that my favorite color is blue" and watch it call save_note and persist. That is the whole loop.

The Three Traps That Bite Every First Build

1. stdio is not for humans. The server speaks JSON-RPC over stdin/stdout. If you print() anything to stdout in your tools, you corrupt the protocol and the client silently disconnects. Use print(..., file=sys.stderr) for any debug noise, or a logging handler pointed at a file. Nothing on stdout except protocol frames.

2. Absolute paths only. MCP clients spawn your server as a child process with cwd set to who-knows-where. Relative paths in args or inside your tools resolve to nonsense. Use absolute paths in the config and resolve any filesystem arguments with pathlib.Path(...).expanduser().resolve() before use.

3. Tool descriptions are the prompt. The model picks tools by reading the docstring + the type hints. "Process the data" gets ignored. "Read rows from a SQLite table where column X equals Y, returns JSON list" gets called. Write the docstring the way you would write a tool description for a junior engineer who has never seen your codebase, because that is exactly what the model is.

What To Expect After

The first save_note call is the magic moment. You realise any Python you have ever written is now callable from your coding agent with zero copy-paste. The MCP Python SDK has been stable since late 2024, the stdio transport is the recommended default for local tools, and FastMCP cuts the boilerplate to nothing. Sixty lines is the whole distance between "I have a script" and "my agent has a tool."

Ship the file, wire the config, restart the client. You are running MCP.

Mr. Technology


*Tested June 2026 with mcp Python SDK >= 1.2 and Claude Code 1.x over stdio transport. For HTTP/SSE transport (remote servers, multi-client), swap mcp.run(transport="stdio") for mcp.run(transport="sse", host="0.0.0.0", port=8000) and point clients at the URL with an mcpServers config entry of {"url": "http://host:8000/sse"}. Resource registration uses the config://, file://, or db:// URI scheme as a hint to clients — clients filter which resources they list, so pick descriptive schemes. Python 3.10+ required (uses list[str] and str | None syntax).*

Related Dispatches