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.
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.
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]
| Approach | Package | Use 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 |
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.
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 databaseCREATE 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);
| Column | Type | Index | Notes |
|---|---|---|---|
id | SERIAL | Primary Key | Auto-increment. Use UUID if you need globally unique IDs across environments. |
name | TEXT NOT NULL | B-tree + GIN trigram | Displayed to users; used in ILIKE search. |
slug | TEXT NOT NULL UNIQUE | Unique | URL-safe identifier, e.g. blue-widget-pro. |
description | TEXT | GIN full-text | Combined with name in full-text index. Nullable. |
price | NUMERIC(10,2) NOT NULL | — | Never use FLOAT for money. NUMERIC is exact. |
inventory_count | INTEGER NOT NULL DEFAULT 0 | — | Decrement on checkout confirmation. |
stripe_price_id | TEXT | — | Populated when using Stripe's price catalog mode. |
active | BOOLEAN NOT NULL DEFAULT TRUE | B-tree | Filter inactive products from all queries. |
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 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.txtfastmcp>=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 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.
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.)
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)
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.
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.
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.
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.
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 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.
| Provider | Free Tier | Paid Tier | Async Support | Best 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 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.
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"))
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.
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.
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.
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.
# 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
)
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.
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 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.
Using psycopg2.connect() or synchronous SQLAlchemy inside an async def tool blocks the event loop. All other requests freeze until the query completes.
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.
Symptom: FATAL: remaining connection slots are reserved. Each request opens a TCP connection, handshakes TLS, authenticates — then discards the connection.
lifespan context manager and acquire connections from it inside each tool. The pool is created at server startup and shared across all invocations.
Symptom: prepared statement "asyncpg_x" already exists errors after the first few requests. PgBouncer transaction mode doesn't support prepared statements across sessions.
statement_cache_size=0 to asyncpg.create_pool() when using any transaction-mode pooler (Supabase port 6543, Neon pooler, PgBouncer).
Symptom: InvalidRequestError: The asyncio extension requires an async driver. SQLAlchemy async requires the asyncpg dialect in the URL.
postgresql:// with postgresql+asyncpg://. In code: url = os.environ["DATABASE_URL"].replace("postgresql://", "postgresql+asyncpg://", 1)
Symptom: Object of type Decimal is not JSON serializable. asyncpg returns Python Decimal for NUMERIC columns.
::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.
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.
SUPABASE_SERVICE_KEY (never NEXT_PUBLIC_*). Set only under server-side environments in Vercel. Access only from server-side Python code.
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.
SELECT 1 query) to keep the project active.
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.
CREATE EXTENSION IF NOT EXISTS pg_trgm; then CREATE INDEX idx_products_name_trgm ON products USING GIN (name gin_trgm_ops);
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 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.
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 →Get the MCP Server Ops checklist — database setup, pooling config, and migration SQL template — delivered to your inbox.