30·Day·Pivot
MCP Server Ops · Spoke 3 of 10 · Connect a Database

Real data. Live inventory.

Your static PRODUCTS list was always a placeholder. This guide replaces it with a live Postgres connection — three options, complete runnable code for each, and the exact pooling configuration that keeps Vercel serverless from exhausting your database's connection limit.

3 Database options (asyncpg, Supabase, SQLAlchemy)
0 Synchronous calls inside async tools
6543 Supabase pooler port (not 5432)
Dec'24 Vercel Postgres sunset — use Neon instead
What's in this guide
  1. Why Async Is Non-Negotiable
  2. Schema Design: The Products Table
  3. Option A: asyncpg with a Connection Pool
  4. Option B: Supabase with supabase-py
  5. Option C: SQLAlchemy 2.x Async
  6. Hosted Postgres: Pricing Comparison
  7. Environment Variables: Vercel to server.py
  8. Connection Pooling on Serverless
  9. The 8 Mistakes That Break Your DB Connection
  10. Step-by-Step Process
  11. FAQ

One blocking call stalls everything.

FastMCP runs on FastAPI's ASGI stack — a single-threaded async event loop. When one coroutine is waiting on I/O, the loop switches to another. A blocking database call breaks this model and serializes all requests.

When you call psycopg2.connect() or a synchronous SQLAlchemy session inside an async def tool, the call does not yield to the event loop. It blocks the thread, freezing every other in-flight request until the database responds.

Wrong — blocks the event loop; do not do this
import psycopg2

@mcp.tool()
async def search_products(query: str) -> list[dict]:
    conn = psycopg2.connect(os.environ["DATABASE_URL"])  # BLOCKS
    cur = conn.cursor()
    cur.execute("SELECT * FROM products WHERE name ILIKE %s", (f"%{query}%",))
    return cur.fetchall()
Correct — awaits a pooled connection; other requests continue
import asyncpg

@mcp.tool()
async def search_products(query: str, ctx) -> list[dict]:
    async with ctx.lifespan_context["pool"].acquire() as conn:
        rows = await conn.fetch(
            "SELECT * FROM products WHERE name ILIKE $1 AND active = true",
            f"%{query}%"
        )
    return [dict(r) for r in rows]
ApproachPackageUse when
Native async driver asyncpg==0.31.0 Raw SQL, maximum performance, simplest code
Async ORM SQLAlchemy[asyncio]==2.0.49 + asyncpg You want ORM abstractions or Alembic migrations
Supabase client supabase==2.30.0 async client You're on Supabase and want the PostgREST API
Fallback

If you must use a sync library (rare — a third-party SDK with no async option), wrap it with asyncio.get_event_loop().run_in_executor(None, sync_fn) to offload it to a thread pool. This is the fallback, not the default path.

The minimum viable products table.

This schema supports search_products (full-text search on name and description), initiate_checkout (price retrieval, inventory check), and Stripe integration (stripe_price_id column). Run this once against your database before connecting the server.

migration.sql — run this once against your Postgres database
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE TABLE IF NOT EXISTS products (
    id              SERIAL          PRIMARY KEY,
    name            TEXT            NOT NULL,
    slug            TEXT            NOT NULL UNIQUE,
    description     TEXT,
    price           NUMERIC(10,2)   NOT NULL,
    currency        TEXT            NOT NULL DEFAULT 'USD',
    inventory_count INTEGER         NOT NULL DEFAULT 0,
    stripe_price_id TEXT,
    active          BOOLEAN         NOT NULL DEFAULT TRUE,
    created_at      TIMESTAMPTZ     NOT NULL DEFAULT NOW()
);

-- Speeds up ILIKE search on name and description
CREATE INDEX IF NOT EXISTS idx_products_name ON products (name);
CREATE INDEX IF NOT EXISTS idx_products_active ON products (active);

-- Full-text search index (recommended for catalogs with 1000+ products)
CREATE INDEX IF NOT EXISTS idx_products_fts ON products
    USING GIN (to_tsvector('english', name || ' ' || COALESCE(description, '')));

-- Trigram index for ILIKE search (pg_trgm extension required)
CREATE INDEX IF NOT EXISTS idx_products_name_trgm
    ON products USING GIN (name gin_trgm_ops);
ColumnTypeIndexNotes
idSERIALPrimary KeyAuto-increment. Use UUID if you need globally unique IDs across environments.
nameTEXT NOT NULLB-tree + GIN trigramDisplayed to users; used in ILIKE search.
slugTEXT NOT NULL UNIQUEUniqueURL-safe identifier, e.g. blue-widget-pro.
descriptionTEXTGIN full-textCombined with name in full-text index. Nullable.
priceNUMERIC(10,2) NOT NULLNever use FLOAT for money. NUMERIC is exact.
inventory_countINTEGER NOT NULL DEFAULT 0Decrement on checkout confirmation.
stripe_price_idTEXTPopulated when using Stripe's price catalog mode.
activeBOOLEAN NOT NULL DEFAULT TRUEB-treeFilter inactive products from all queries.
Price Storage

Store prices as NUMERIC(10,2) — never FLOAT. Floating-point arithmetic produces values like 9.989999771118164 instead of 9.99. When serializing to JSON with asyncpg, cast to ::text in the query and convert to float() in Python where needed.

asyncpg — fastest, simplest.

asyncpg is a pure-asyncio Postgres driver with no synchronous fallback. It integrates cleanly with FastMCP's lifespan pattern and is the fastest Python driver for Postgres. Best choice for a small, well-defined schema.

requirements.txt
fastmcp>=3.2.0
asyncpg==0.31.0
uvicorn>=0.29.0
server.py — complete asyncpg implementation
import os
import asyncpg
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from fastmcp import FastMCP

DATABASE_URL = os.environ["DATABASE_URL"]  # postgresql://user:pass@host:PORT/dbname

@asynccontextmanager
async def lifespan(server: FastMCP) -> AsyncIterator[dict]:
    """Create the connection pool on startup; close it on shutdown."""
    pool = await asyncpg.create_pool(
        DATABASE_URL,
        min_size=1,
        max_size=3,       # Keep small for Vercel serverless
        command_timeout=30,
        statement_cache_size=0,  # Required for Supabase pooler (port 6543)
    )
    try:
        yield {"pool": pool}
    finally:
        await pool.close()

mcp = FastMCP("ProductServer", lifespan=lifespan)


@mcp.tool()
async def search_products(query: str, ctx) -> list[dict]:
    """Search products by name. Returns active matching products."""
    pool = ctx.lifespan_context["pool"]
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            """
            SELECT id, name, slug, description, price::text, currency, inventory_count
            FROM products
            WHERE active = TRUE
              AND (name ILIKE $1 OR description ILIKE $1)
            ORDER BY name
            LIMIT 20
            """,
            f"%{query}%",
        )
    return [dict(r) for r in rows]


@mcp.tool()
async def initiate_checkout(product_id: int, quantity: int, ctx) -> dict:
    """Check inventory and return product details for checkout."""
    pool = ctx.lifespan_context["pool"]
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            """
            SELECT id, name, price::text, currency, stripe_price_id, inventory_count
            FROM products
            WHERE id = $1 AND active = TRUE
            """,
            product_id,
        )
    if row is None:
        return {"error": "Product not found"}
    if row["inventory_count"] < quantity:
        return {"error": "Insufficient inventory"}
    unit_price = float(row["price"])
    return {
        "product_id": row["id"],
        "name": row["name"],
        "unit_price": unit_price,
        "currency": row["currency"],
        "quantity": quantity,
        "total": unit_price * quantity,
        "stripe_price_id": row["stripe_price_id"],
    }


if __name__ == "__main__":
    mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)

Note the price::text cast in the SELECT. asyncpg returns Decimal for NUMERIC columns, which fails JSON serialization. Casting to text and converting to float() in Python is the correct pattern.

Supabase — fastest to start.

Supabase exposes a REST API over Postgres via PostgREST. The supabase-py async client uses httpx internally — no blocking, asyncio-compatible. Best for getting started quickly with a full BaaS stack.

Free Tier

Supabase free tier: 500 MB database storage, 5 GB egress, 2 active projects. Projects pause after 1 week of inactivity. For a production MCP server, upgrade to Pro ($25/month) which never pauses. (Re-verify pricing before launch.)

requirements.txt
fastmcp>=3.2.0
supabase==2.30.0
uvicorn>=0.29.0
Prerequisites in Supabase dashboard
-- 1. Run the CREATE TABLE migration from Section 2 in the SQL Editor
-- 2. Enable Row Level Security (RLS) on the products table
-- 3. Create a policy allowing service_role to SELECT, UPDATE on products
-- 4. Copy: Settings → API → Project URL and service_role secret key
server.py — complete Supabase implementation
import os
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from supabase import acreate_client, AsyncClient
from fastmcp import FastMCP

SUPABASE_URL = os.environ["SUPABASE_URL"]          # https://abcdefgh.supabase.co
SUPABASE_KEY = os.environ["SUPABASE_SERVICE_KEY"]  # service_role key — never expose client-side

@asynccontextmanager
async def lifespan(server: FastMCP) -> AsyncIterator[dict]:
    client: AsyncClient = await acreate_client(SUPABASE_URL, SUPABASE_KEY)
    try:
        yield {"supabase": client}
    finally:
        pass  # AsyncClient has no explicit close


mcp = FastMCP("ProductServer", lifespan=lifespan)


@mcp.tool()
async def search_products(query: str, ctx) -> list[dict]:
    """Search active products by name or description."""
    client: AsyncClient = ctx.lifespan_context["supabase"]
    response = (
        await client.table("products")
        .select("id, name, slug, description, price, currency, inventory_count")
        .eq("active", True)
        .or_(f"name.ilike.%{query}%,description.ilike.%{query}%")
        .limit(20)
        .execute()
    )
    return response.data or []


@mcp.tool()
async def initiate_checkout(product_id: int, quantity: int, ctx) -> dict:
    """Retrieve product details for checkout and validate inventory."""
    client: AsyncClient = ctx.lifespan_context["supabase"]
    resp = (
        await client.table("products")
        .select("id, name, price, currency, stripe_price_id, inventory_count")
        .eq("id", product_id)
        .eq("active", True)
        .single()
        .execute()
    )
    if not resp.data:
        return {"error": "Product not found"}
    product = resp.data
    if product["inventory_count"] < quantity:
        return {"error": "Insufficient inventory"}
    unit_price = float(product["price"])
    return {
        "product_id": product["id"],
        "name": product["name"],
        "unit_price": unit_price,
        "currency": product["currency"],
        "quantity": quantity,
        "total": unit_price * quantity,
        "stripe_price_id": product["stripe_price_id"],
    }


if __name__ == "__main__":
    mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
Security

Use SUPABASE_SERVICE_KEY (the service_role JWT) only on server-side code. It bypasses Row Level Security. Never expose it in client-side code, commit it to source control, or set it as a public environment variable.

SQLAlchemy 2.x — when you need ORM.

Use SQLAlchemy 2.x async when you need Alembic migrations, ORM relationships, or a team convention that standardizes on SQLAlchemy. Both this and asyncpg use the same driver on the wire — the performance difference is negligible.

Critical

SQLAlchemy async requires the postgresql+asyncpg:// dialect prefix in your connection string. The standard postgresql:// prefix causes InvalidRequestError: The asyncio extension requires an async driver.

requirements.txt
fastmcp>=3.2.0
SQLAlchemy[asyncio]==2.0.49
asyncpg==0.31.0
uvicorn>=0.29.0
server.py — SQLAlchemy 2.x async with lifespan
import os
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from fastmcp import FastMCP

# Replace "postgresql://" with "postgresql+asyncpg://" for the async driver
DATABASE_URL = os.environ["DATABASE_URL"].replace(
    "postgresql://", "postgresql+asyncpg://", 1
)

@asynccontextmanager
async def lifespan(server: FastMCP) -> AsyncIterator[dict]:
    engine = create_async_engine(
        DATABASE_URL,
        pool_size=3,
        max_overflow=0,   # No overflow on serverless — keep connections predictable
        pool_timeout=10,
        pool_recycle=300,
        pool_pre_ping=True,  # Validates connection before each checkout
    )
    session_factory = async_sessionmaker(engine, expire_on_commit=False)
    try:
        yield {"engine": engine, "session_factory": session_factory}
    finally:
        await engine.dispose()


mcp = FastMCP("ProductServer", lifespan=lifespan)


@mcp.tool()
async def search_products(query: str, ctx) -> list[dict]:
    """Search products by name or description."""
    session_factory = ctx.lifespan_context["session_factory"]
    async with session_factory() as session:
        result = await session.execute(
            text(
                """
                SELECT id, name, slug, description, price::text, currency, inventory_count
                FROM products
                WHERE active = TRUE
                  AND (name ILIKE :q OR description ILIKE :q)
                ORDER BY name
                LIMIT 20
                """
            ),
            {"q": f"%{query}%"},
        )
        rows = result.mappings().all()
    return [dict(row) for row in rows]


if __name__ == "__main__":
    mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)

pool_pre_ping=True sends a lightweight SELECT 1 before reusing a connection from the pool. This catches dropped connections after a database restart or firewall timeout without surfacing errors to your tool callers.

Which database, which plan.

Vercel Postgres was sunset in December 2024 — all existing databases were migrated to Neon automatically. For new projects, use the Neon integration from the Vercel Marketplace. Re-verify all pricing before launch.

Vercel Postgres Is Gone

Vercel Postgres is no longer available as of December 2024. If you're seeing @vercel/postgres in tutorials or documentation, it's outdated. Install the Neon integration from the Vercel Marketplace instead — billing stays unified under your Vercel account, and the integration injects DATABASE_URL automatically.

ProviderFree TierPaid TierAsync SupportBest For
Supabase 500 MB DB storage, 5 GB egress; pauses after 7 days idle $25/month Pro — never pauses asyncpg direct, supabase-py via Data API Full BaaS stack; quick start with built-in pooler
Neon 0.5 GB/project, 100 CU-hours/month, scale-to-zero after 5 min ~$0.106/CU-hour (Launch plan, usage-based) asyncpg, SQLAlchemy async Serverless-native; Vercel Marketplace integration; branching
Railway $5/month Hobby (includes $5 usage credit) Usage-based: ~$0.15/GB-month storage asyncpg, SQLAlchemy async Simple deploys; co-locate DB with server on Railway
Vercel Postgres ⚠ Deprecated December 2024 — use Neon via Vercel Marketplace

Practical guidance: Starting out → Supabase free tier (fastest setup, built-in pooler). Deploying on Vercel → Neon via Marketplace (DATABASE_URL injected automatically). Already on Railway → add Postgres as a Railway service.

Vercel dashboard to server.py.

Vercel injects environment variables directly into the function process at runtime. You don't need python-dotenv in production — os.environ["KEY"] works without any additional library.

Setting variables in Vercel

  1. Open your project in the Vercel dashboard
  2. Navigate to Settings → Environment Variables
  3. Click Add New. Enter the key and value.
  4. Check all three scopes: Production, Preview, Development
  5. Click Save. Then redeploy — Vercel does not hot-reload env vars on running instances.
Pull variables to your local machine
vercel env pull .env.local
server.py — loading variables correctly
import os

# Required vars — fail fast on startup if missing (raises KeyError, not silent None)
DATABASE_URL: str = os.environ["DATABASE_URL"]
SUPABASE_URL: str = os.environ["SUPABASE_URL"]
SUPABASE_SERVICE_KEY: str = os.environ["SUPABASE_SERVICE_KEY"]

# Optional var with fallback
DB_POOL_SIZE: int = int(os.environ.get("DB_POOL_SIZE", "3"))
Fail Fast

Use os.environ["KEY"] (square brackets, not .get()) for required secrets. A missing variable causes an immediate KeyError at startup instead of a confusing None-related error at query time. You want to know immediately if a secret is missing — not 10 minutes later when the first tool call fails.

Local development with python-dotenv

server.py — load .env only when not on Vercel
import os

# Only load .env locally — Vercel injects vars at runtime
if os.environ.get("VERCEL") is None:
    from dotenv import load_dotenv
    load_dotenv(".env.local")

DATABASE_URL: str = os.environ["DATABASE_URL"]

Add .env.local to .gitignore immediately. Never commit database credentials to version control.

50 function instances. One connection limit.

Vercel functions are ephemeral. Under moderate load, Vercel may spin up 10, 20, or 50 instances simultaneously — each opening its own pool. With max_size=10, that's 500 connections. Postgres's default max_connections is 100.

Solution A: Transaction-mode pooler (PgBouncer)

PgBouncer in transaction mode sits between your app and Postgres. It accepts thousands of connections and multiplexes them onto a small pool of real Postgres connections — released back at the end of each transaction, not the session.

Supabase: Built-in pooler on port 6543. Connect via the transaction-mode pooler URL instead of the direct connection (port 5432).

Neon: Built-in serverless pooler. Use the -pooler.neon.tech connection string from the Neon dashboard.

asyncpg with Supabase transaction pooler (port 6543)
# Use the POOLER URL from Supabase: Settings → Database → Connection Pooling
# Note: port 6543, not 5432
DATABASE_POOLER_URL = os.environ["DATABASE_POOLER_URL"]
# Example: postgresql://postgres:PASSWORD@db.PROJECT_REF.supabase.co:6543/postgres

pool = await asyncpg.create_pool(
    DATABASE_POOLER_URL,
    min_size=1,
    max_size=3,          # Small per-instance pool; PgBouncer handles aggregation
    statement_cache_size=0,  # REQUIRED for PgBouncer transaction mode
)
Critical

Transaction-mode pooling does not support Postgres prepared statements. asyncpg uses prepared statements by default. Always set statement_cache_size=0 when connecting to the Supabase transaction pooler (port 6543). Without this, you'll see prepared statement "asyncpg_x" already exists errors after the first few requests.

Solution B: Minimal pool size (no pooler)

asyncpg without a pooler — cap at 1 connection per instance
pool = await asyncpg.create_pool(
    dsn=os.environ["DATABASE_URL"],
    min_size=1,
    max_size=1,  # One connection per Vercel function instance
    command_timeout=15,  # Neon cold-start can take ~500ms; give it time
)

With max_size=1, 50 Vercel instances = 50 Postgres connections — manageable for most free tiers. This is the right choice when you can't use a pooler.

Neon Cold-Start

Neon pauses compute after 5 minutes of inactivity on the free tier and takes ~500ms to resume. The first query after a pause will be slow. Set command_timeout=15 on your pool to give Neon time to wake up rather than returning a 500 immediately.

Every mistake, exact fix.

Error 01

Synchronous driver inside async tool

Using psycopg2.connect() or synchronous SQLAlchemy inside an async def tool blocks the event loop. All other requests freeze until the query completes.

Fix: Replace psycopg2 with asyncpg, or declare the tool as def (non-async) so FastMCP dispatches it to a thread pool. The preferred path is asyncpg with async def.
Error 02

Creating a new pool on every tool call

Symptom: FATAL: remaining connection slots are reserved. Each request opens a TCP connection, handshakes TLS, authenticates — then discards the connection.

Fix: Create the pool once in the lifespan context manager and acquire connections from it inside each tool. The pool is created at server startup and shared across all invocations.
Error 03

Missing statement_cache_size=0 with PgBouncer

Symptom: prepared statement "asyncpg_x" already exists errors after the first few requests. PgBouncer transaction mode doesn't support prepared statements across sessions.

Fix: Add statement_cache_size=0 to asyncpg.create_pool() when using any transaction-mode pooler (Supabase port 6543, Neon pooler, PgBouncer).
Error 04

Wrong SQLAlchemy connection string prefix

Symptom: InvalidRequestError: The asyncio extension requires an async driver. SQLAlchemy async requires the asyncpg dialect in the URL.

Fix: Replace postgresql:// with postgresql+asyncpg://. In code: url = os.environ["DATABASE_URL"].replace("postgresql://", "postgresql+asyncpg://", 1)
Error 05

NUMERIC column causes JSON serialization error

Symptom: Object of type Decimal is not JSON serializable. asyncpg returns Python Decimal for NUMERIC columns.

Fix: Cast to ::text in the SQL query (price::text) and convert to float() in Python where needed. Never use FLOAT in the schema for money — use NUMERIC(10,2) and cast on read.
Error 06

Supabase service key exposed as a public variable

The service_role key bypasses Row Level Security entirely. If it appears in client-side code, Next.js NEXT_PUBLIC_ vars, or is committed to source control, all Postgres access controls are voided.

Fix: Store as SUPABASE_SERVICE_KEY (never NEXT_PUBLIC_*). Set only under server-side environments in Vercel. Access only from server-side Python code.
Error 07

Supabase free tier project pauses mid-demo

Projects on the free tier pause after one week of inactivity. An MCP tool call during a paused state returns a connection refused error — and takes ~10 seconds to resume.

Fix: Upgrade to Pro ($25/month) for production servers. For free-tier development, set up a daily cron (GitHub Actions: SELECT 1 query) to keep the project active.
Error 08

Missing pg_trgm extension for ILIKE search

Without the trigram index, ILIKE '%query%' performs a full sequential scan. On a table with 50,000+ products, this becomes a multi-second query that will time out on Vercel.

Fix: Run once: CREATE EXTENSION IF NOT EXISTS pg_trgm; then CREATE INDEX idx_products_name_trgm ON products USING GIN (name gin_trgm_ops);

From static list to live database.

  1. Create the products table

    Run the migration SQL from Section 2 against your database. For Supabase: paste into the SQL Editor and execute. For Neon/Railway: use psql DATABASE_URL -f migration.sql or the provider's query console. Verify that the pg_trgm extension was created and the trigram index on products.name exists before proceeding.

  2. Set environment variables in Vercel

    In the Vercel dashboard, go to Settings → Environment Variables and add DATABASE_URL (or SUPABASE_URL and SUPABASE_SERVICE_KEY). Check all three scopes: Production, Preview, Development. Run vercel env pull .env.local to sync them locally. Add .env.local to .gitignore.

  3. Install async packages and pin versions

    Add asyncpg==0.31.0 to requirements.txt. For Supabase: supabase==2.30.0. For SQLAlchemy: SQLAlchemy[asyncio]==2.0.49. Run pip install -r requirements.txt locally to confirm no dependency conflicts.

  4. Rewrite server.py with lifespan and async tools

    Replace the static PRODUCTS list with a lifespan function that opens an asyncpg pool at startup and closes it on shutdown. Rewrite search_products and initiate_checkout as async def tools that acquire connections from ctx.lifespan_context["pool"]. Use the complete, runnable examples from Sections 3, 4, or 5 verbatim — substitute your actual column names.

  5. Configure pooling and deploy

    For Supabase: switch to the pooler connection string (port 6543) and add statement_cache_size=0. Set max_size=3. Test locally with real data from your database, then deploy with vercel --prod. Run MCP Inspector against the production URL to confirm live queries return your seeded data.

Questions from the real connection.

Can I use psycopg2 if I wrap it in run_in_executor?

Yes, but it's not recommended for production. loop.run_in_executor(None, sync_fn) offloads the call to a thread pool, which avoids blocking the event loop. However, it adds thread-switching overhead and the thread pool has a finite size. The async client (asyncpg) is the correct path for FastMCP. If you must use a sync library — like a third-party SDK with no async option — this is the correct fallback.

What connection string format do I use with Supabase?

For a direct connection from a persistent server: use port 5432: postgresql://postgres:PASSWORD@db.PROJECT_REF.supabase.co:5432/postgres

For serverless or Vercel functions: use the Supavisor transaction-mode pooler on port 6543: postgresql://postgres:PASSWORD@db.PROJECT_REF.supabase.co:6543/postgres

When using port 6543 with asyncpg, always set statement_cache_size=0. You'll find both connection strings in your Supabase dashboard under Settings → Database → Connection string.

My pool global is None when the tool handler runs. What went wrong?

The lifespan function did not run. This typically means you mounted the MCP app before defining the lifespan parameter, or FastMCP(...) was called without lifespan=lifespan. Ensure mcp = FastMCP("ProductServer", lifespan=lifespan) — the lifespan must be passed at construction time, not added after the fact.

How do I seed my database in CI without running the full server?

Write a standalone asyncpg script:

import asyncio, asyncpg, os

SEED_DATA = [
    ("Blue Widget Pro", "blue-widget-pro", "Heavy-duty widget", 29.99, "USD", 100),
    ("Red Widget Lite", "red-widget-lite", "Lightweight widget", 9.99, "USD", 250),
]

async def seed():
    conn = await asyncpg.connect(os.environ["DATABASE_URL"])
    await conn.executemany(
        """INSERT INTO products (name, slug, description, price, currency, inventory_count)
           VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (slug) DO NOTHING""",
        SEED_DATA,
    )
    await conn.close()
    print(f"Seeded {len(SEED_DATA)} products.")

asyncio.run(seed())

The ON CONFLICT DO NOTHING makes it idempotent — safe to run multiple times.

Should I use Supabase's PostgREST API or connect directly to Postgres?

For an MCP server running on Vercel, connecting directly via asyncpg is generally faster — you avoid the PostgREST HTTP layer, which adds ~5–20ms per request. Use the PostgREST API (via supabase-py) when you want Supabase Row Level Security policies enforced at the API level, or when you prefer the chainable query builder interface over raw SQL. Both approaches are valid; direct connection is lower latency.

Neon's free tier pauses — will this break my MCP server?

Neon pauses compute after 5 minutes of inactivity on the free tier and takes ~500ms to resume. The first query after a pause will be slow. Set command_timeout=15 on your pool to give Neon time to wake up rather than returning a 500. For production, upgrade to a paid Neon plan and disable autosuspend, or use Supabase Pro (which doesn't pause) as an alternative.

Ready to take real payments?

Your server now queries live inventory. The next step is wiring Stripe Checkout — replacing the stub URL with a real payment session that handles card validation, 3D Secure, and webhook confirmation.

Wire Stripe Checkout →
Free Guide

Ship faster. Break less.

Get the MCP Server Ops checklist — database setup, pooling config, and migration SQL template — delivered to your inbox.