Build a Custom MCP Server in Python: Step-by-Step Tutorial (2026)

MCP (Model Context Protocol) is the thing that turns AI coding agents from "smart autocomplete" into actual tools that interact with your stack. Claude Code, Cursor, Windsurf, Cline — they all support it now. But most tutorials stop at "install someone else's MCP server and point your client at it."

That's fine for GitHub or Slack integrations. But what about your internal API? Your company's database? That weird legacy system only you understand? For those, you need to build your own MCP server.

It's easier than you think. This tutorial walks you through building a production-ready MCP server in Python using FastMCP, from zero to a working server that Claude Code can talk to. We'll build a real example — a server that queries a PostgreSQL database — not a toy "hello world" demo.

What MCP Actually Does

MCP is a protocol that lets AI tools call functions on external servers. Think of it like a USB port for AI agents. The agent (Claude Code, Cursor, etc.) is the host. Your MCP server is the device. The protocol handles discovery, authentication, and communication.

An MCP server exposes three things:

When you type "find all users who signed up last week" in Claude Code, and it has your database MCP server connected, it can call your query_database tool directly instead of asking you to copy-paste SQL results.

Prerequisites

You need Python 3.10+ and uv (the fast Python package manager). If you don't have uv:

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

That's it. No Docker, no complex setup. We'll use fastmcp, which is the official high-level Python framework for building MCP servers.

Step 1: Project Setup

Create a new project and install dependencies:

mkdir my-mcp-server && cd my-mcp-server
uv init
uv add fastmcp psycopg2-binary

Your project structure will look like this:

my-mcp-server/
├── pyproject.toml
└── server.py

Step 2: Build the Server

Here's a complete MCP server that connects to PostgreSQL and exposes three tools. Create server.py:

from fastmcp import FastMCP
import psycopg2
import os
import json

mcp = FastMCP(
    "Database Explorer",
    description="Query and explore a PostgreSQL database"
)

def get_connection():
    return psycopg2.connect(
        host=os.environ.get("DB_HOST", "localhost"),
        port=os.environ.get("DB_PORT", "5432"),
        dbname=os.environ.get("DB_NAME", "myapp"),
        user=os.environ.get("DB_USER", "postgres"),
        password=os.environ.get("DB_PASSWORD", ""),
    )

@mcp.tool()
def list_tables() -> str:
    """List all tables in the database with their row counts."""
    conn = get_connection()
    cur = conn.cursor()
    cur.execute("""
        SELECT schemaname, tablename
        FROM pg_tables
        WHERE schemaname = 'public'
        ORDER BY tablename
    """)
    tables = cur.fetchall()

    results = []
    for schema, table in tables:
        cur.execute(f"SELECT COUNT(*) FROM {table}")
        count = cur.fetchone()[0]
        results.append(f"{table}: {count} rows")

    cur.close()
    conn.close()
    return "\n".join(results)

@mcp.tool()
def describe_table(table_name: str) -> str:
    """Get column names, types, and constraints for a table.

    Args:
        table_name: Name of the table to describe
    """
    conn = get_connection()
    cur = conn.cursor()
    cur.execute("""
        SELECT column_name, data_type, is_nullable,
               column_default
        FROM information_schema.columns
        WHERE table_name = %s AND table_schema = 'public'
        ORDER BY ordinal_position
    """, (table_name,))

    columns = cur.fetchall()
    cur.close()
    conn.close()

    if not columns:
        return f"Table '{table_name}' not found."

    lines = [f"Table: {table_name}", ""]
    for name, dtype, nullable, default in columns:
        null_str = "NULL" if nullable == "YES" else "NOT NULL"
        default_str = f" DEFAULT {default}" if default else ""
        lines.append(f"  {name}: {dtype} {null_str}{default_str}")

    return "\n".join(lines)

@mcp.tool()
def query(sql: str, limit: int = 100) -> str:
    """Run a read-only SQL query and return results as JSON.

    Args:
        sql: SQL query to execute (SELECT only)
        limit: Maximum rows to return (default 100)
    """
    sql_stripped = sql.strip().upper()
    if not sql_stripped.startswith("SELECT"):
        return "Error: Only SELECT queries are allowed."

    conn = get_connection()
    cur = conn.cursor()

    # Add LIMIT if not present
    if "LIMIT" not in sql_stripped:
        sql = f"{sql.rstrip(';')} LIMIT {limit}"

    try:
        cur.execute(sql)
        columns = [desc[0] for desc in cur.description]
        rows = cur.fetchall()
        results = [dict(zip(columns, row)) for row in rows]
        return json.dumps(results, indent=2, default=str)
    except Exception as e:
        return f"Query error: {e}"
    finally:
        cur.close()
        conn.close()

@mcp.resource("schema://tables")
def get_schema() -> str:
    """Returns the full database schema as a resource."""
    conn = get_connection()
    cur = conn.cursor()
    cur.execute("""
        SELECT table_name, column_name, data_type
        FROM information_schema.columns
        WHERE table_schema = 'public'
        ORDER BY table_name, ordinal_position
    """)
    rows = cur.fetchall()
    cur.close()
    conn.close()

    current_table = None
    lines = []
    for table, column, dtype in rows:
        if table != current_table:
            lines.append(f"\n## {table}")
            current_table = table
        lines.append(f"  - {column}: {dtype}")

    return "\n".join(lines)

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

That's 90 lines of code. You now have an MCP server with three tools and one resource. The AI agent can list tables, inspect schemas, and run read-only queries.

Step 3: Test It Locally

Before connecting it to Claude Code, test it with the MCP inspector:

# Install the inspector
npx @modelcontextprotocol/inspector

# In another terminal, run your server
DB_HOST=localhost DB_NAME=myapp uv run server.py

The inspector gives you a web UI where you can call each tool manually and see the responses. Fix any bugs here before wiring it up to an AI agent.

You can also test directly from the command line:

# Test with fastmcp's built-in dev mode
uv run fastmcp dev server.py

Step 4: Connect to Claude Code

Register your server with Claude Code:

claude mcp add my-database \
  -e DB_HOST=localhost \
  -e DB_NAME=myapp \
  -e DB_USER=postgres \
  -e DB_PASSWORD=secret \
  -- uv run /path/to/my-mcp-server/server.py

That's it. Next time you start Claude Code, type /mcp to verify the server is connected. You should see "my-database" with three tools listed.

Now you can ask Claude Code things like:

Claude Code will call your MCP tools automatically. No copy-pasting SQL. No switching windows.

Step 5: Connect to Cursor / VS Code

For Cursor or VS Code with the Claude extension, add this to your project's .mcp.json file:

{
  "servers": {
    "my-database": {
      "command": "uv",
      "args": ["run", "/path/to/my-mcp-server/server.py"],
      "env": {
        "DB_HOST": "localhost",
        "DB_NAME": "myapp",
        "DB_USER": "postgres",
        "DB_PASSWORD": "secret"
      }
    }
  }
}

Restart your editor, and the MCP server will be available in the AI assistant panel.

Making It Production-Ready

The example above works, but there are a few things you'd want to add before using it on a real project.

Connection Pooling

Creating a new database connection per tool call is wasteful. Use a connection pool:

from psycopg2 import pool

db_pool = pool.ThreadedConnectionPool(
    minconn=1,
    maxconn=10,
    host=os.environ.get("DB_HOST", "localhost"),
    dbname=os.environ.get("DB_NAME", "myapp"),
    user=os.environ.get("DB_USER", "postgres"),
    password=os.environ.get("DB_PASSWORD", ""),
)

def get_connection():
    return db_pool.getconn()

def release_connection(conn):
    db_pool.putconn(conn)

Query Safety

The SELECT-only check in our example is naive. For production, use a read-only database user or wrap queries in a read-only transaction:

@mcp.tool()
def query(sql: str, limit: int = 100) -> str:
    """Run a read-only SQL query."""
    conn = get_connection()
    conn.set_session(readonly=True)
    try:
        cur = conn.cursor()
        cur.execute(sql)
        # ... process results
    except Exception as e:
        return f"Error: {e}"
    finally:
        conn.set_session(readonly=False)
        release_connection(conn)

Error Handling

MCP tools should return helpful error messages, not stack traces. Wrap your tools:

@mcp.tool()
def list_tables() -> str:
    """List all tables in the database."""
    try:
        conn = get_connection()
        # ... your logic
    except psycopg2.OperationalError:
        return "Cannot connect to database. Check DB_HOST and credentials."
    except Exception as e:
        return f"Unexpected error: {type(e).__name__}: {e}"

Beyond Databases: Other MCP Server Ideas

The database example is just one pattern. Here are MCP servers that are actually useful in practice:

Server TypeTools It ExposesWhy It's Useful
Internal API gatewayCall any internal microserviceAI agent can hit your staging API directly
Log searcherSearch Elasticsearch/Loki logs"Find errors from the last hour" in natural language
Deployment toolCheck status, trigger deploysDeploy from your AI coding session
Monitoring bridgeQuery Grafana/Datadog metrics"Is the API latency normal?" without switching tabs
Ticket managerCreate/update Jira/Linear ticketsFile bugs as you find them while coding

The pattern is always the same: identify something you do manually during coding, wrap it in a Python function, decorate it with @mcp.tool(), done.

Common Pitfalls

A few things that trip people up when building MCP servers:

The API Connection

Your MCP server's tools will call AI models under the hood in many real-world scenarios — summarizing log entries, classifying tickets, generating SQL from natural language. When that happens, you need a reliable API endpoint that supports multiple models.

If you're building MCP servers that make LLM calls internally, KissAPI gives you a single OpenAI-compatible endpoint for Claude, GPT-5, Gemini, DeepSeek, and Qwen models. One API key, one base URL, swap models by changing a string. Useful when your MCP tool needs to call different models for different tasks.

Build MCP Servers That Call Any Model

KissAPI gives you one API endpoint for Claude, GPT-5, Gemini, and 100+ models. Perfect for MCP tools that need LLM calls under the hood.

Start Free →

Wrapping Up

Building an MCP server is mostly just writing Python functions and decorating them. The protocol handles the rest. If you can write a Flask endpoint, you can build an MCP server — FastMCP is arguably simpler.

Start with something small. A server that queries your database, or one that searches your logs. Once you see your AI agent actually using it during a coding session, you'll want to build more. The gap between "AI assistant" and "AI that knows my entire stack" is just a few MCP servers.