AI agents
Ren Okabe8 min read10 views

How to Build an MCP Server in Python with FastMCP (2026)

A runnable 2026 tutorial: build a write-capable MCP server in Python with FastMCP and the official MCP SDK. Add tools with @mcp.tool(), back them with SQLite, test in the Inspector, and connect it to Claude for Desktop.

Minimalist dark-teal diagram of a Python terminal linked through an MCP hub to three tool cards
Minimalist dark-teal diagram of a Python terminal linked through an MCP hub to three tool cards
On this page

> Quick answer (July 2026): To build an MCP server in Python, pip install "mcp[cli]", create a file with from mcp.server.fastmcp import FastMCP, decorate each function with @mcp.tool(), and end with mcp.run(transport="stdio"). FastMCP reads your type hints and docstrings to generate the tool schema automatically. Run it locally with mcp dev server.py to open the Inspector, then point Claude for Desktop at it through claude_desktop_config.json. This tutorial builds a real, write-capable task server, tests it without Claude Desktop, and shows the three mistakes that make Claude ignore your tools.

Python logo This is a code-first, copy-paste tutorial. Everything below runs on plain Python logo Python 3.10+ with the official MCP Python SDK and the standard-library SQLite logo sqlite3 module. No external API keys, no cloud account. If you have never wired a tool onto a Claude agent before, skim give your agent custom tools first, then come back.

Most MCP tutorials in 2025 wrapped a read-only weather or search API. That teaches the plumbing but hides the interesting part: an MCP server can safely change state. Here we build a server that reads and writes a local task list, so Claude can add a to-do, list what is open, and mark things done. Same pattern you would use for a CRM, a ticket queue, or an internal database.

What is an MCP server, in one paragraph

The Model Context Protocol is an open standard from Anthropic (introduced 2024, widely adopted by 2026) that lets an AI host like Claude for Desktop discover and call your tools. Your server exposes tools; the host (Claude) is the client that calls them. You write the server once and any MCP-aware client can use it. FastMCP, bundled inside the official mcp package, turns a plain Python function into a fully described tool with almost no boilerplate.

What you will build

A tasks server with three tools, add_task, list_tasks, and complete_task, backed by a local SQLite file. Then you will test it in the MCP Inspector and register it with Claude for Desktop.

Prerequisites

  • Python 3.10 or newer
  • The official MCP SDK with its CLI extras
  • Ten minutes

Install the SDK

bash
pip install "mcp[cli]"

The [cli] extra installs the mcp command, which gives you mcp dev (the Inspector) and mcp install (register with Claude for Desktop). If you prefer uv, run uv add "mcp[cli]" instead.

Create the server

Make a file called tasks.py. The first few lines are the whole framework:

python
# tasks.py
import sqlite3
from pathlib import Path
from mcp.server.fastmcp import FastMCP

DB = Path("tasks.db")
mcp = FastMCP("tasks")


def _db() -> sqlite3.Connection:
    conn = sqlite3.connect(DB)
    conn.execute(
        "CREATE TABLE IF NOT EXISTS tasks ("
        "  id INTEGER PRIMARY KEY AUTOINCREMENT,"
        "  title TEXT NOT NULL,"
        "  done INTEGER NOT NULL DEFAULT 0)"
    )
    return conn

FastMCP("tasks") names the server. _db() is a small helper that opens the SQLite file and makes sure the table exists. Nothing MCP-specific yet.

Add a read tool

A tool is just a function with the @mcp.tool() decorator. The type hints become the input schema, and the docstring becomes the description Claude reads when it decides whether to call the tool.

python
@mcp.tool()
def list_tasks(include_done: bool = False) -> str:
    """List tasks on the to-do list.

    Args:
        include_done: If true, also show completed tasks. Defaults to false.
    """
    conn = _db()
    if include_done:
        rows = conn.execute(
            "SELECT id, title, done FROM tasks ORDER BY id"
        ).fetchall()
    else:
        rows = conn.execute(
            "SELECT id, title, done FROM tasks WHERE done = 0 ORDER BY id"
        ).fetchall()
    conn.close()
    if not rows:
        return "No tasks found."
    return "\n".join(
        f"#{tid} [{'x' if done else ' '}] {title}" for tid, title, done in rows
    )

That include_done: bool = False hint is what tells Claude the argument exists, that it is a boolean, and that it is optional. Skip the hint and Claude has no idea the option is there.

Add the write tools

This is the part read-only tutorials never show. Writing from an MCP tool is exactly as easy: you run the mutation and return a short confirmation string so Claude can tell the user what happened.

python
@mcp.tool()
def add_task(title: str) -> str:
    """Add a new task to the to-do list.

    Args:
        title: Short description of the task, e.g. "Renew the domain".
    """
    conn = _db()
    cur = conn.execute("INSERT INTO tasks (title) VALUES (?)", (title,))
    conn.commit()
    task_id = cur.lastrowid
    conn.close()
    return f"Added task #{task_id}: {title}"


@mcp.tool()
def complete_task(task_id: int) -> str:
    """Mark a task as done.

    Args:
        task_id: The numeric id of the task to complete (from list_tasks).
    """
    conn = _db()
    cur = conn.execute("UPDATE tasks SET done = 1 WHERE id = ?", (task_id,))
    conn.commit()
    changed = cur.rowcount
    conn.close()
    if changed == 0:
        return f"No task with id {task_id}."
    return f"Completed task #{task_id}."

Note the parameterized ? placeholders. Never build SQL by string-formatting an argument that came from a model. Treat tool inputs like any other untrusted input.

Run it

Add the standard entry point and you have a complete server:

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

stdio means the server talks over standard input and output. That is what local hosts like Claude for Desktop expect: they launch your script as a subprocess and speak MCP over the pipe. Start it with python tasks.py and it will sit and wait for a client.

Test it without Claude for Desktop

You do not need Claude for Desktop to see if the server works, and on Linux you cannot install it yet (as of 2026 it ships for macOS and Windows only). Use the MCP Inspector instead:

bash
mcp dev tasks.py

This launches a local web UI where you can see the three tools, read their generated schemas, and call each one by hand. Add a task, list it, complete it, and confirm the SQLite file changes. If a tool does not appear here, it will not appear in Claude either, so fix it before moving on.

Register it with Claude for Desktop

On macOS or Windows, open the config file at ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %AppData%\Claude\claude_desktop_config.json (Windows), and add your server under mcpServers:

json
{
  "mcpServers": {
    "tasks": {
      "command": "python",
      "args": ["/ABSOLUTE/PATH/TO/tasks.py"]
    }
  }
}

Use an absolute path, and make sure mcp is installed in whatever Python that command resolves to (a virtualenv path like /ABSOLUTE/PATH/.venv/bin/python is the safest choice). If you use uv, the official docs recommend "command": "uv" with "args": ["--directory", "/ABSOLUTE/PATH", "run", "tasks.py"]. Fully quit and reopen Claude for Desktop, and your three tools appear behind the tools icon. Ask it "add buy milk to my tasks" and watch it call add_task.

The three reasons Claude ignores your tools

Servers that run fine in the Inspector but get skipped by Claude almost always trip one of these:

  1. Vague or missing docstrings. The docstring is the tool description the model reads. """Handle tasks""" tells Claude nothing. Write what the tool does and when to use it, in plain language.
  2. Loose type hints. FastMCP builds the JSON schema from your annotations. def add_task(title): with no hint gives Claude an untyped argument and it will hesitate. Annotate every parameter.
  3. Kitchen-sink tools. One manage_tasks(action, ...) that does five things confuses the planner. Narrow, verb-named tools (add_task, complete_task) get called far more reliably. Split them up.

One more that costs people an hour: Claude for Desktop reads the tool list at launch. After you edit tasks.py, fully restart the app. A reload is not enough.

stdio vs HTTP in 2026

stdio is right for a local server that one desktop app launches. If you want a server that runs somewhere else and several clients share, switch the transport to Streamable HTTP:

python
mcp.run(transport="streamable-http")

Same tools, same decorators. The server now listens on a port instead of a pipe. Start local with stdio, and move to HTTP only when you actually need a shared, remote deployment.

Where to go next

You now have a write-capable MCP server that any Claude client can drive. Two natural next steps: give the tools to an agent you control instead of the desktop app by following give your agent custom tools, or step back to the fundamentals with how to make an AI agent. When you are ready to expose real systems, keep the same discipline you used here: narrow tools, honest docstrings, typed inputs, and parameterized queries.

R

Written by

Ren Okabe

Frequently asked questions

How do I build an MCP server in Python?

Install the official SDK with pip install "mcp[cli]", create a file that imports FastMCP from mcp.server.fastmcp, decorate each function with @mcp.tool(), and finish with mcp.run(transport="stdio"). FastMCP generates each tool's schema from your type hints and docstring. Run mcp dev server.py to test it in the Inspector, then register it in claude_desktop_config.json.

What is FastMCP and do I need to install it separately?

FastMCP is the high-level, decorator-based way to define MCP tools. It is bundled inside the official mcp package, so pip install "mcp[cli]" is enough. You import it with from mcp.server.fastmcp import FastMCP. It reads your Python type hints and docstrings to build tool definitions automatically.

Can an MCP server write to a database, not just read?

Yes. A tool is a normal Python function, so it can insert, update, or delete just like any other code. This tutorial's add_task and complete_task tools mutate a local SQLite database. Use parameterized queries and treat every tool input as untrusted, because the arguments come from a model.

How do I test an MCP server without Claude for Desktop?

Run mcp dev server.py to launch the MCP Inspector, a local web UI that lists your tools, shows their generated schemas, and lets you call each one by hand. This works on Linux, where Claude for Desktop is not available as of 2026.

Why does Claude ignore my MCP tools?

The three common causes are vague or missing docstrings (the docstring is the description Claude reads), loose or missing type hints (they become the input schema), and overly broad kitchen-sink tools. Also remember Claude for Desktop reads the tool list at launch, so fully restart it after editing your server.