7 min readRishi

Building Your First MCP Server in Python: A Hands-On Walkthrough

The Model Context Protocol is the part of the AI tooling stack that finally standardized how models talk to external systems. Before MCP, every agent framework reinvented the same shape: a list of tools, a schema per tool, a way to return a result, a way to signal an error. MCP pulled that shape out of each framework and put it behind a JSON-RPC interface any client can speak. The practical upshot: write one server and any MCP-aware client — Claude Desktop, Claude Code, third-party agent frameworks — can use it.

This post walks through building a useful MCP server in Python. Not the "hello world" tutorial, but one that does real work: query a local SQLite database, expose a few tools, return a resource. By the end you will have a server you can wire into Claude Desktop or Claude Code and see working end-to-end.

What an MCP Server Actually Exposes

Three primitives, and they are worth understanding before you write any code:

  1. Tools — functions the model can call. These are the most common thing you will implement. Each tool has a name, a description, an input schema, and a handler.
  2. Resources — addressable data the model can read. A file, a database row, a URL. Unlike tools, resources are identified by URI and the client pulls them when it decides it needs them.
  3. Prompts — reusable prompt templates the client can surface to the user. Less common for backend-style servers, but useful for workflow kits.

Most of the value for a first server comes from tools. We will focus there and touch resources at the end.

Project Setup

The official Python SDK is mcp. It ships with a high-level FastMCP API that handles the protocol boilerplate and a low-level server for when you need more control. Start with FastMCP.

mkdir tasklog-mcp && cd tasklog-mcp
python -m venv .venv && source .venv/bin/activate
pip install "mcp[cli]"

We will build a toy "task log" server — a SQLite-backed log where the model can add, query, and complete tasks. The shape generalizes to any internal system: ticketing, inventory, CRM, logs.

The Server

Single file, roughly 100 lines. Create server.py:

import sqlite3
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path

from mcp.server.fastmcp import FastMCP

DB_PATH = Path.home() / ".tasklog.db"

mcp = FastMCP("tasklog")

@contextmanager
def db():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    try:
        yield conn
        conn.commit()
    finally:
        conn.close()

def init_db():
    with db() as conn:
        conn.execute("""
            CREATE TABLE IF NOT EXISTS tasks (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                title TEXT NOT NULL,
                status TEXT NOT NULL DEFAULT 'open',
                created_at TEXT NOT NULL,
                completed_at TEXT
            )
        """)

init_db()

@mcp.tool()
def add_task(title: str) -> dict:
    """Add a new task to the log. Returns the created task's id and title."""
    with db() as conn:
        cur = conn.execute(
            "INSERT INTO tasks (title, created_at) VALUES (?, ?)",
            (title.strip(), datetime.utcnow().isoformat()),
        )
        return {"id": cur.lastrowid, "title": title.strip()}

@mcp.tool()
def list_tasks(status: str = "open", limit: int = 20) -> list[dict]:
    """List tasks by status. Status can be 'open', 'done', or 'all'."""
    query = "SELECT id, title, status, created_at, completed_at FROM tasks"
    params: tuple = ()
    if status in {"open", "done"}:
        query += " WHERE status = ?"
        params = (status,)
    query += " ORDER BY created_at DESC LIMIT ?"
    params = (*params, limit)
    with db() as conn:
        return [dict(row) for row in conn.execute(query, params)]

@mcp.tool()
def complete_task(task_id: int) -> dict:
    """Mark a task as done. Returns the updated task."""
    with db() as conn:
        conn.execute(
            "UPDATE tasks SET status = 'done', completed_at = ? WHERE id = ?",
            (datetime.utcnow().isoformat(), task_id),
        )
        row = conn.execute(
            "SELECT id, title, status, completed_at FROM tasks WHERE id = ?",
            (task_id,),
        ).fetchone()
        if row is None:
            raise ValueError(f"No task with id {task_id}")
        return dict(row)

@mcp.resource("tasklog://summary")
def summary() -> str:
    """Human-readable summary of open tasks."""
    with db() as conn:
        rows = conn.execute(
            "SELECT title, created_at FROM tasks WHERE status = 'open' ORDER BY created_at"
        ).fetchall()
    if not rows:
        return "No open tasks."
    lines = [f"- {r['title']} (since {r['created_at'][:10]})" for r in rows]
    return "Open tasks:\n" + "\n".join(lines)

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

That is the whole thing. A few details worth calling out:

Tool docstrings are the contract. FastMCP uses the docstring as the description the model sees. A vague docstring is a vague tool. Spend effort here — this is what the model reads when deciding whether to call the tool.

Type hints are the schema. title: str, limit: int, return types — these generate the JSON schema the client uses to validate arguments. Do not skip them.

Errors propagate. Raise a normal Python exception and the SDK turns it into a structured MCP error. The client surfaces this to the model, which typically recovers or asks for clarification.

Running It Locally

Two ways to test. The first is the MCP Inspector, a dev tool that speaks the protocol and lets you call tools manually:

mcp dev server.py

That opens a browser tab where you can see the registered tools and resources and try calls. Hit add_task with a title, hit list_tasks, confirm you get rows back. This is your inner dev loop.

The second way is to wire it into a real client. For Claude Desktop, edit ~/Library/Application Support/Claude/claude_desktop_config.json (or the platform equivalent):

{
  "mcpServers": {
    "tasklog": {
      "command": "/absolute/path/to/.venv/bin/python",
      "args": ["/absolute/path/to/server.py"]
    }
  }
}

Restart Claude Desktop, and the tools appear in the tool picker. For Claude Code, add the same block to .mcp.json at the project root.

The Traps That Will Bite You on Day One

Absolute paths in the client config. Relative paths, ~, and $HOME do not get expanded in most clients. Put absolute paths. I have lost more time to this than anything else.

Stdout is the transport. FastMCP.run() uses stdio by default. Anything you print() to stdout gets mangled as protocol traffic. Log to stderr or to a file, never stdout. In Python that means print(..., file=sys.stderr) or a proper logger targeting stderr.

Dependencies in the venv, not global. If you install the SDK globally and the client launches your server with a different Python, imports fail silently and the server dies at startup. Always point the command at the Python from the project's virtual environment, not python3.

Tools that take arbitrary SQL are a footgun. You will be tempted to write a single query_database(sql: str) tool. Do not. The model will write genuinely odd SQL under pressure. Expose narrow, named tools (list_tasks, count_by_status, tasks_due_this_week) and keep the blast radius small.

Returned objects must be JSON-serializable. datetime, Decimal, and custom classes will explode at the edge. FastMCP will handle dicts, lists, strings, and numbers cleanly. Serialize your own rich types before returning them.

When Resources Matter

Tools are actions. Resources are readable state the model pulls when it decides it is relevant. The difference is pull vs push: tools execute because the model calls them, resources are fetched because the model chose to read them.

Good fits for resources:

  • A canonical summary of an entity ("project X status")
  • Slow-changing reference data (a glossary, a config dump)
  • Anything the model benefits from having in context but does not need to mutate

The tasklog://summary resource above is a small example — the model can read the open-task summary without calling list_tasks, which keeps the token usage lower when the same state is relevant to multiple turns.

From Here

Once the basic shape clicks, MCP servers scale well. I have one for our internal ticketing system (three tools: search, open, comment), one for a customer analytics database (read-only, narrow tools), one for our shared design system (resources pointing at the live component docs). Each is 80-200 lines of Python.

The server you just wrote is enough to prove the pattern. The next project is almost always "expose one of our internal systems the same way." That is where MCP stops being a demo and starts being infrastructure.

Keep reading

Newsletter

New posts, straight to your inbox

One email per post. No spam, no tracking pixels, unsubscribe anytime.

Comments

No comments yet. Be the first.