I Built an MCP Server in 40 Minutes. Here's Exactly What Happened.
Not the marketing version — the actual build log, including the two hours I lost to a bug that had nothing to do with code.
Senior Developer

I kept seeing MCP mentioned everywhere — in Claude Code docs, in Cursor settings, in every "AI engineering stack" post on LinkedIn. I kept nodding like I understood it.
I didn't.
So last week I sat down and built one from scratch. A real MCP server, connected to a real Postgres database, that Claude can actually query. No toy examples. No hand-waving over the hard parts.
This is the full account of what happened — including the two hours I lost to a configuration mistake that had nothing to do with code, and the exact moment the whole thing clicked.
First: what MCP actually is, in one paragraph
Before MCP existed, connecting an AI to your database meant writing a custom integration for every AI tool separately. One integration for Claude, another for Cursor, another for GitHub Copilot. Three tools, three integrations, all slightly different.
MCP is a standardised protocol — think of it as USB for AI tools. You write one MCP server that connects to your Postgres database, and every MCP-compatible client (Claude Desktop, Claude Code, Cursor, Windsurf, VS Code Copilot) can use it immediately. The official server registry crossed 2,000 community implementations by Q1 2026, and SDK downloads jumped roughly 970x in 18 months. It became the standard faster than almost any protocol I can remember.
That's the pitch. Here's the build.
What we're building
A Python MCP server with two tools:
list_tables— shows Claude what tables exist in your databasequery_database— runs a read-only SQL query and returns results
By the end, you'll open Claude Desktop, type "what tables are in my database?" and get a real answer from your actual Postgres instance.
Prerequisites:
Python 3.10 or higher
A Postgres database (local or cloud — Neon has a free tier that works perfectly)
Claude Desktop installed (download here)
Basic familiarity with Python async/await
Step 1: Set up the project
uv is now the standard for MCP Python projects. It's faster than pip and handles virtual environments automatically. If you don't have it:
# Install uv
curl -LsSf https://astral.sh/uv/install.sh | sh
# Create the project
uv init zyvop-mcp-server
cd zyvop-mcp-server
# Install dependencies
uv add "mcp[cli]" fastmcp asyncpg python-dotenvIf you prefer pip:
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install "mcp[cli]" fastmcp asyncpg python-dotenvCreate a .env file for your database credentials:
DATABASE_URL=postgresql://user:password@localhost:5432/yourdbNever hardcode credentials. Never commit .env. Add it to .gitignore right now before you forget.
Step 2: Write the server
Create server.py. This is the full working server — nothing left out:
import asyncio
import asyncpg
from fastmcp import FastMCP
from dotenv import load_dotenv
import os
load_dotenv()
# FastMCP does the heavy lifting — one line to create the server
mcp = FastMCP(
name="postgres-assistant",
instructions="You are a database assistant. Use list_tables first to understand
the schema, then query_database to answer questions about the data."
)
DATABASE_URL = os.getenv("DATABASE_URL")
async def get_connection():
"""Create a database connection."""
return await asyncpg.connect(DATABASE_URL)
@mcp.tool()
async def list_tables() -> str:
"""
List all tables in the database with their column names and types.
Always call this before querying to understand the schema.
"""
conn = await get_connection()
try:
rows = await conn.fetch("""
SELECT
table_name,
column_name,
data_type
FROM information_schema.columns
WHERE table_schema = 'public'
ORDER BY table_name, ordinal_position
""")
if not rows:
return "No tables found in the public schema."
# Group by table name for readable output
tables = {}
for row in rows:
table = row['table_name']
if table not in tables:
tables[table] = []
tables[table].append(f" {row['column_name']} ({row['data_type']})")
result = []
for table_name, columns in tables.items():
result.append(f"Table: {table_name}")
result.extend(columns)
result.append("")
return "\n".join(result)
finally:
await conn.close()
@mcp.tool()
async def query_database(sql: str) -> str:
"""
Run a read-only SQL SELECT query against the database.
Only SELECT statements are allowed — no INSERT, UPDATE, DELETE, or DROP.
Returns results as a formatted table.
Args:
sql: A valid SQL SELECT statement
"""
# Safety check — only allow SELECT
sql_clean = sql.strip().upper()
if not sql_clean.startswith("SELECT"):
return "Error: Only SELECT queries are allowed. Received: " + sql[:50]
# Block dangerous keywords even inside SELECT
dangerous = ["DROP", "DELETE", "INSERT", "UPDATE", "TRUNCATE", "ALTER", "CREATE"]
for keyword in dangerous:
if keyword in sql_clean:
return f"Error: Query contains forbidden keyword: {keyword}"
conn = await get_connection()
try:
rows = await conn.fetch(sql)
if not rows:
return "Query returned no results."
# Format as readable table
headers = list(rows[0].keys())
header_row = " | ".join(headers)
separator = "-" * len(header_row)
result_rows = [header_row, separator]
for row in rows[:50]: # Limit to 50 rows to avoid token bloat
result_rows.append(" | ".join(str(val) for val in row.values()))
if len(rows) > 50:
result_rows.append(f"\n... and {len(rows) - 50} more rows (query returned {len(rows)} total)")
return "\n".join(result_rows)
except asyncpg.PostgresError as e:
return f"Database error: {str(e)}"
finally:
await conn.close()
if __name__ == "__main__":
mcp.run()Three things worth noting in this code:
The docstrings are not optional. Without a docstring on your tool function, the AI model has no idea what the tool does and will either refuse to use it or use it wrong. The docstring is literally what Claude reads to decide whether to call your tool.
The 50-row limit on results is deliberate. Token bloat is the number one production pain point — developers report tool schemas and responses consuming 15,000+ tokens before the agent even starts reasoning. Return the minimum useful data. Claude can always ask for more.
The safety checks are not paranoia. A SQL injection via your MCP server would be Claude running DROP TABLE users on your behalf. The keyword check is basic but catches the obvious cases.
Step 3: Test locally with the MCP Inspector
Before connecting to Claude, test with the official inspector:
fastmcp dev server.pyThis opens a browser UI at http://localhost:6274 where you can call your tools directly, see the raw JSON-RPC messages, and verify everything works before involving Claude.
Call list_tables first. If it returns your schema, you're in good shape. If it returns an error, it's almost certainly the DATABASE_URL — double-check the connection string format.
Step 4: Connect to Claude Desktop
Find your Claude Desktop config file:
macOS:
~/Library/Application Support/Claude/claude_desktop_config.jsonWindows:
%APPDATA%\Claude\claude_desktop_config.json
Add your server:
{
"mcpServers": {
"postgres-assistant": {
"command": "uv",
"args": [
"run",
"--project",
"/absolute/path/to/zyvop-mcp-server",
"python",
"server.py"
],
"env": {
"DATABASE_URL": "postgresql://user:password@localhost:5432/yourdb"
}
}
}
}Use the absolute path. This is where I lost two hours. A relative path like ./zyvop-mcp-server fails silently on some systems — Claude Desktop starts, shows no error, but the server never loads. Absolute path, always.
Restart Claude Desktop completely. Look for a hammer icon in the bottom-right of the chat input — that's your MCP tools indicator. If it's there, your server connected.
The moment it clicked
I typed: "What tables do I have in my database?"
Claude called list_tables. Returned my schema. Then I asked: "How many users signed up last week?"
It wrote a SQL query, called query_database, got the result, and answered me in plain English.
No code. No terminal. Just a question and an answer, backed by my actual data.
That was the moment I understood why MCP had taken off. It's not impressive as a demo. It's impressive as a workflow — the feeling of having a tool that can navigate your actual systems rather than giving you generic advice about systems it imagines.
What breaks in production (and how to fix it)
Connection pool exhaustion. The server above creates a new connection per query. For low-traffic personal tools that's fine. For anything handling multiple simultaneous requests, create a connection pool at startup:
pool = None
@mcp.lifespan
async def lifespan():
global pool
pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10)
yield
await pool.close()
# Then in your tools:
async with pool.acquire() as conn:
rows = await conn.fetch(sql)The "dumps 43 tools into context" problem. One developer reported that GitHub's own MCP server "dumps 43 tools into the context window" before doing anything, destroying agent performance. Keep your tool count small — 5 to 8 focused tools outperforms 40 broad ones. Every tool adds tokens to every conversation whether Claude uses it or not.
Responses that aren't strings. A tool that returned a raw Python dict rendered fine in the MCP Inspector but came back truncated inside Claude Desktop. Wrapping the return value as a typed text string fixed it instantly. Always return strings from your tools, not dicts or lists.
What to build next
Once your Postgres server works, the same pattern extends to anything:
A GitHub MCP server that Claude can use to read your issues and PRs
A filesystem server that lets Claude navigate your project directory
An API wrapper that connects Claude to any internal service your company uses
The official MCP server repository has reference implementations for Slack, Google Drive, GitHub, and more — good reading for patterns to follow.
The honest take
Building this took 40 minutes of actual coding and 2 hours of debugging a config file path.
The coding part is genuinely simple — FastMCP eliminates the boilerplate and provides a decorator-based framework that turns a Python function into a fully typed MCP tool with one line. If you know Python async, you know enough to build this.
The debugging part is a rite of passage. The MCP Inspector is your friend. The Claude Desktop logs (at ~/Library/Logs/Claude/ on macOS) are your other friend. Between those two, every error I hit was eventually traceable.
The thing on the other side is worth it. Give it an afternoon.
Built something with MCP? Drop what you connected it to in the comments — the most interesting integrations I've seen have come from developers solving very specific, unglamorous problems.
External links:
MCP Python SDK — official SDK and changelog
FastMCP docs — the fastest way to build Python MCP servers
MCP Inspector — test your server before connecting to Claude
Neon Postgres — free serverless Postgres, works perfectly for MCP development
Official MCP server examples — reference implementations
Comments (0)
Login to post a comment.