I Built an MCP Server in 40 Minutes. Here's Exactly What Happened.
DEV Community Grade 10 3d ago

I Built an MCP Server in 40 Minutes. Here's Exactly What Happened.

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 database query_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-dotenv If you prefer pip: python -m venv venv source venv/bin/activate # Windows: venv\Scripts\activate pip install "mcp[cli]" fastmcp asyncpg python-dotenv Create a .env file for your database credentials: DATABASE_URL = postgresql://user:password@localhost: 5432 /yourdb Never 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 " Que

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-dotenv If you prefer pip: python -m venv venv source venv/bin/activate # Windows: venv\Scripts\activate pip install "mcp[cli]" fastmcp asyncpg python-dotenv Create a .env file for your database credentials: DATABASE_URL=postgresql://user:password@localhost:5432/yourdb Never 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.py This 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.json Windows: %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 b

Comments

0
glendafox77 glendafox77 3d ago
The "two hours lost to a configuration mistake" is exactly why I tell teams to start with the MCP Inspector before any client — it catches those silent transport mismatches that eat your afternoon.