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:
- Tools — functions the AI can call (like "run this SQL query" or "create a Jira ticket")
- Resources — data the AI can read (like "here's the database schema" or "here's the README")
- Prompts — reusable prompt templates (less common, but useful for standardizing workflows)
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:
- "What tables are in the database?"
- "Show me the schema for the users table"
- "Find all orders placed in the last 24 hours"
- "Which users have never made a purchase?"
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 Type | Tools It Exposes | Why It's Useful |
|---|---|---|
| Internal API gateway | Call any internal microservice | AI agent can hit your staging API directly |
| Log searcher | Search Elasticsearch/Loki logs | "Find errors from the last hour" in natural language |
| Deployment tool | Check status, trigger deploys | Deploy from your AI coding session |
| Monitoring bridge | Query Grafana/Datadog metrics | "Is the API latency normal?" without switching tabs |
| Ticket manager | Create/update Jira/Linear tickets | File 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:
- Tool descriptions matter. The AI reads your docstrings to decide when to call each tool. Vague descriptions = wrong tool calls. Be specific about what each tool does and when to use it.
- Return strings, not objects. MCP tools return text. If you're returning structured data, serialize it to JSON. The AI can parse it.
- Keep tools focused. One tool that does five things is worse than five tools that each do one thing. The AI picks better when the options are clear.
- Environment variables for secrets. Never hardcode database passwords or API keys. Use
os.environand pass them via the-eflag when registering the server. - Test without the AI first. Use the MCP inspector or
fastmcp devto verify your tools work before connecting them to Claude Code. Debugging through an AI agent is painful.
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.