← Back to Blog

Build an MCP Server in Python: From Zero to Working Tools

You have an AI assistant that can talk, but it cannot do anything. It cannot check your database, create a ticket, or read a file. Every real-world AI application needs connections to actual systems.

The Model Context Protocol (MCP) fixes this. It is an open standard from Anthropic that gives LLMs a way to call tools, read resources, and follow prompts - from any MCP-compatible client.

Think of it as a USB-C port for AI apps. One protocol. Many servers. Any client.

This tutorial walks through building an MCP server in Python from scratch. By the end you will have a working server that exposes tools an AI agent can call - and you will know how to connect it to Claude Desktop, VS Code, or any MCP host.

Prerequisites

  • Python 3.10+
  • uv installed (pip works too, but uv is faster)
  • A text editor
  • Basic Python and async knowledge

Step 1: Project Setup

Create a new project directory and set up a virtual environment:

mkdir mcp-tasks-server
cd mcp-tasks-server
uv init
uv venv
source .venv/bin/activate

Install the MCP SDK:

uv add "mcp[cli]"

The mcp package includes the server framework and the CLI inspector. The [cli] extra gives you the mcp command for testing.

Create a file called server.py:

touch server.py

Step 2: Basic Server Skeleton

Open server.py and add the minimal server:

from mcp.server import MCPServer

mcp = MCPServer("tasks")

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

Run it to verify it starts:

python server.py

You will see no output. That is normal - stdio servers stay silent until the client sends a JSON-RPC message. Press Ctrl+C to stop it.

Step 3: Add a Tool

Let us add a tool that returns a simple greeting. The @mcp.tool() decorator turns any async function into an MCP tool. The function's type hints and docstring become the tool's schema:

from mcp.server import MCPServer

mcp = MCPServer("tasks")

@mcp.tool()
async def hello(name: str) -> str:
    """Say hello to someone.

    Args:
        name: The person's name
    """
    return f"Hello, {name}!"

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

Test it with the MCP Inspector:

mcp dev server.py

This starts a web UI at http://localhost:5173. Click "Connect", then try calling the hello tool with {"name": "Alice"}. You should get back "Hello, Alice!".

The MCP SDK uses Python type hints to generate the JSON Schema for each tool. The docstring becomes the description. The Args: section populates the parameter descriptions. This means zero boilerplate for schema definitions.

Step 4: Build a Real Tool - Task Manager with SQLite

A greeting tool is not useful. Build a task manager backed by SQLite. This shows tools with different parameter patterns and database access.

Database Setup

Add SQLite helper functions at the top of server.py:

import sqlite3
from pathlib import Path
from typing import Any
from mcp.server import MCPServer

DB_PATH = Path.home() / ".mcp-tasks.db"

def get_db() -> sqlite3.Connection:
    conn = sqlite3.connect(str(DB_PATH))
    conn.row_factory = sqlite3.Row
    return conn

def init_db() -> None:
    with get_db() as conn:
        conn.executescript("""
            CREATE TABLE IF NOT EXISTS tasks (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                title TEXT NOT NULL,
                description TEXT DEFAULT '',
                status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'done')),
                created_at TEXT DEFAULT (datetime('now')),
                updated_at TEXT DEFAULT (datetime('now'))
            );
        """)

init_db()
mcp = MCPServer("tasks")

Tool: Create a Task

@mcp.tool()
async def create_task(title: str, description: str = "") -> str:
    """Create a new task.

    Args:
        title: Task title
        description: Optional task description
    """
    with get_db() as conn:
        cursor = conn.execute(
            "INSERT INTO tasks (title, description) VALUES (?, ?)",
            (title, description),
        )
        return f"Task created with ID {cursor.lastrowid}"

Tool: List All Tasks

@mcp.tool()
async def list_tasks(status: str = "") -> str:
    """List all tasks, optionally filtered by status.

    Args:
        status: Filter by status - 'pending', 'done', or empty for all
    """
    with get_db() as conn:
        if status:
            rows = conn.execute(
                "SELECT * FROM tasks WHERE status = ? ORDER BY created_at DESC",
                (status,),
            ).fetchall()
        else:
            rows = conn.execute(
                "SELECT * FROM tasks ORDER BY created_at DESC"
            ).fetchall()

    if not rows:
        return "No tasks found."

    result = []
    for row in rows:
        result.append(
            f"[{row['id']}] {row['title']} ({row['status']})\n"
            f"    {row['description'] or 'No description'}"
        )
    return "\n\n".join(result)

Tool: Mark Task as Done

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

    Args:
        task_id: ID of the task to complete
    """
    with get_db() as conn:
        cursor = conn.execute(
            "UPDATE tasks SET status = 'done', updated_at = datetime('now') WHERE id = ?",
            (task_id,),
        )
        if cursor.rowcount == 0:
            return f"No task found with ID {task_id}"
        return f"Task {task_id} marked as done."

Tool: Search Tasks

@mcp.tool()
async def search_tasks(query: str) -> str:
    """Search tasks by title or description.

    Args:
        query: Search keyword
    """
    with get_db() as conn:
        rows = conn.execute(
            "SELECT * FROM tasks WHERE title LIKE ? OR description LIKE ? ORDER BY created_at DESC",
            (f"%{query}%", f"%{query}%"),
        ).fetchall()

    if not rows:
        return f"No tasks matching '{query}'."

    result = []
    for row in rows:
        result.append(f"[{row['id']}] {row['title']} ({row['status']})")
    return "\n".join(result)

Full Server

The complete server.py:

import sqlite3
from pathlib import Path
from typing import Any
from mcp.server import MCPServer

DB_PATH = Path.home() / ".mcp-tasks.db"

def get_db() -> sqlite3.Connection:
    conn = sqlite3.connect(str(DB_PATH))
    conn.row_factory = sqlite3.Row
    return conn

def init_db() -> None:
    with get_db() as conn:
        conn.executescript("""
            CREATE TABLE IF NOT EXISTS tasks (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                title TEXT NOT NULL,
                description TEXT DEFAULT '',
                status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'done')),
                created_at TEXT DEFAULT (datetime('now')),
                updated_at TEXT DEFAULT (datetime('now'))
            );
        """)

init_db()
mcp = MCPServer("tasks")

@mcp.tool()
async def create_task(title: str, description: str = "") -> str:
    """Create a new task.

    Args:
        title: Task title
        description: Optional task description
    """
    with get_db() as conn:
        cursor = conn.execute(
            "INSERT INTO tasks (title, description) VALUES (?, ?)",
            (title, description),
        )
        return f"Task created with ID {cursor.lastrowid}"

@mcp.tool()
async def list_tasks(status: str = "") -> str:
    """List all tasks, optionally filtered by status.

    Args:
        status: Filter by status - 'pending', 'done', or empty for all
    """
    with get_db() as conn:
        if status:
            rows = conn.execute(
                "SELECT * FROM tasks WHERE status = ? ORDER BY created_at DESC",
                (status,),
            ).fetchall()
        else:
            rows = conn.execute(
                "SELECT * FROM tasks ORDER BY created_at DESC"
            ).fetchall()

    if not rows:
        return "No tasks found."

    result = []
    for row in rows:
        result.append(
            f"[{row['id']}] {row['title']} ({row['status']})\n"
            f"    {row['description'] or 'No description'}"
        )
    return "\n\n".join(result)

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

    Args:
        task_id: ID of the task to complete
    """
    with get_db() as conn:
        cursor = conn.execute(
            "UPDATE tasks SET status = 'done', updated_at = datetime('now') WHERE id = ?",
            (task_id,),
        )
        if cursor.rowcount == 0:
            return f"No task found with ID {task_id}"
        return f"Task {task_id} marked as done."

@mcp.tool()
async def search_tasks(query: str) -> str:
    """Search tasks by title or description.

    Args:
        query: Search keyword
    """
    with get_db() as conn:
        rows = conn.execute(
            "SELECT * FROM tasks WHERE title LIKE ? OR description LIKE ? ORDER BY created_at DESC",
            (f"%{query}%", f"%{query}%"),
        ).fetchall()

    if not rows:
        return f"No tasks matching '{query}'."

    result = []
    for row in rows:
        result.append(f"[{row['id']}] {row['title']} ({row['status']})")
    return "\n".join(result)

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

Step 5: Test with the MCP Inspector

Run the inspector:

mcp dev server.py

Go to http://localhost:5173 in your browser. The inspector connects to your server automatically. Try these calls:

  1. create_task with {"title": "Buy groceries", "description": "Milk, eggs, bread"}
  2. create_task with {"title": "Write blog post", "description": "MCP tutorial"}
  3. list_tasks with {}
  4. search_tasks with {"query": "blog"}
  5. complete_task with {"task_id": 2}

The inspector shows every request and response, including the raw JSON-RPC messages. Use the inspector to debug your server - it catches issues before you connect to a client.

Step 6: Connect to Claude Desktop

Claude Desktop can run MCP servers locally. Add your server to its config file.

On macOS, the config is at ~/Library/Application Support/Claude/claude_desktop_config.json. Create it if it does not exist:

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

Replace /ABSOLUTE/PATH/TO/mcp-tasks-server with the actual path.

On Windows, it is at %APPDATA%\Claude\claude_desktop_config.json:

{
  "mcpServers": {
    "tasks": {
      "command": "uv",
      "args": [
        "--directory",
        "C:\\ABSOLUTE\\PATH\\TO\\mcp-tasks-server",
        "run",
        "server.py"
      ]
    }
  }
}

Save the file and restart Claude Desktop completely (Cmd+Q on Mac, or right-click system tray > Quit on Windows).

You should see a hammer icon with "Tasks" tools available. Ask Claude: "Create a task to research MCP servers" - it will call your tool.

Step 7: Add Resources

Tools are for actions. Resources are for data. Let us add a resource that exposes the database stats:

@mcp.resource("tasks://stats")
async def get_stats() -> str:
    """Database statistics"""
    with get_db() as conn:
        total = conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0]
        pending = conn.execute(
            "SELECT COUNT(*) FROM tasks WHERE status='pending'"
        ).fetchone()[0]
        done = conn.execute(
            "SELECT COUNT(*) FROM tasks WHERE status='done'"
        ).fetchone()[0]
    return f"Total: {total}, Pending: {pending}, Done: {done}"

Resources are addressed by URI like tasks://stats. The client fetches them with resources/read.

How Transport Works

MCP servers use one of two transport mechanisms:

STDIO (local) - The client spawns your server as a subprocess and communicates over stdin/stdout. This is what we used above. Fast, simple, no network config.

Streamable HTTP (remote) - The server runs as an HTTP server. Clients connect over HTTP POST with optional SSE for streaming. This is how remote MCP servers like Sentry's work.

For local development, STDIO is the right choice. For production, you want HTTP so multiple clients can connect and the server can run independently.

When to Use MCP vs Alternatives

Tool Best For Not For
MCP Standardized tool access for AI agents; multi-client support (Claude Desktop, VS Code, Cursor, etc.) Complex workflow orchestration; stateful multi-step agent logic
OpenAI Function Calling Single-provider scenarios; simple tool definitions via API Provider lock-in; no standardized server ecosystem
LangChain Tools Framework-integrated agents (LangChain/LangGraph users) Tight coupling to LangChain ecosystem; overhead for simple use cases
Custom HTTP API Maximum control; non-AI consumers too Reinventing tool discovery; no client ecosystem

MCP shines when you want to build a tool once and have it work across multiple AI clients. Write one server, use it from Claude Desktop, VS Code, Cursor, or any custom client.

Common Pitfalls

Printing to stdout. STDIO servers use stdout for JSON-RPC. Do not use print(). Use logging (writes to stderr) instead:

import logging
logger = logging.getLogger(__name__)
logger.info("Server started")  # goes to stderr, safe

Forgetting to await async tools. The MCP SDK expects async functions. Forget await and you get a coroutine object instead of a result.

Not handling errors. Wrap database calls in try/except. Return a clear error string. A crashing server breaks the connection.

Hardcoding paths. Use pathlib.Path.home() or environment variables for configurable paths. Your server should work on any machine.

Over-complicating the first tool. Start with one tool. Get it working. Then add more. Do not build a 10-tool server before testing the first tool end-to-end.

Next Steps

Your server is running. What is next?

  • Add prompts - reusable templates that guide the LLM on how to use your tools effectively
  • Try the Streamable HTTP transport to make your server accessible over the network
  • Check the MCP Inspector for deeper debugging
  • Read the MCP specification for the full protocol details
  • Browse example servers on the official MCP site for patterns in filesystem, database, and API access

References

Need Help Implementing This?

I help teams design and build scalable cloud infrastructure, DevOps pipelines, and production-grade systems.

Book a Free Consultation