Guide · MCP Database Integration

MCP Server Neon — serverless Postgres with branch-per-PR development for MCP servers

Neon is a serverless Postgres database that auto-suspends when idle and resumes on the next query. For MCP servers, Neon's serverless model means near-zero infrastructure cost for low-traffic endpoints, sub-second resume after idle periods (with the HTTP driver), and database branching that lets you spin up a complete Postgres clone for each feature branch. This guide covers connecting an MCP server to Neon using both the standard pg driver and Neon's optimized HTTP driver, handling cold starts, using database branches in CI, and monitoring for the latency spikes that serverless suspend/resume introduces.

TL;DR

Neon offers two connection modes: an HTTP driver (fetch-based, ~100ms cold start, no persistent connection) and a TCP driver via WebSocket tunnel (compatible with pg, ~500ms cold start, supports transactions). Use the HTTP driver for single-statement tools; use TCP with a small connection pool for transactional operations. Neon auto-suspends compute after 5 minutes of idle by default — the first query after suspension takes 500ms–2s. Use a keep-warm setInterval to prevent suspensions if your MCP tools have latency SLAs, or accept the cold start and surface it in your health endpoint so AliveMCP can distinguish a cold-start latency spike from actual downtime.

Two connection modes: HTTP driver vs TCP

Neon's standard connection endpoint is a WebSocket tunnel that speaks the Postgres wire protocol, making it compatible with any pg-based library. But Neon also offers an HTTP-based query API via @neondatabase/serverless that uses tagged template literals and doesn't require a persistent connection at all — each query is an independent HTTP POST.

Mode Package Cold start (Neon suspend) Transactions Best for
HTTP driver @neondatabase/serverless (neon()) ~100ms Single-query only (use transaction() for multi-statement) Simple SELECT/INSERT tools, edge runtimes
TCP via WebSocket @neondatabase/serverless (Pool) or standard pg ~500ms (first query after suspend) Full ACID transactions Multi-statement tools, complex queries

Connection string format for both modes:

# Standard Neon connection string — works for both HTTP and TCP
DATABASE_URL=postgres://user:password@ep-cool-name-123456.us-east-2.aws.neon.tech/neondb?sslmode=require

# Neon uses endpoint IDs (ep-xxxx) that encode region — one per branch
# Branch connection strings are the same format with a different ep- prefix

HTTP driver setup

The HTTP driver is the recommended default for MCP tools that don't need transactions. It uses tagged template literals that automatically parameterize values — there's no string interpolation risk, so SQL injection is structurally prevented.

import { neon, NeonQueryFunction } from '@neondatabase/serverless';

// Create the SQL function once — it's stateless, no connection to manage
const sql: NeonQueryFunction<false, false> = neon(process.env.DATABASE_URL!);

// Simple query — parameters are automatically escaped
const userId = 'user-123';
const rows = await sql`SELECT * FROM users WHERE id = ${userId}`;

// Array expansion for IN clauses
const statuses = ['active', 'trial'];
const users = await sql`
  SELECT id, email, plan
  FROM users
  WHERE status = ANY(${sql.array(statuses)})
  ORDER BY created_at DESC
  LIMIT 50
`;

// Multi-statement transaction via HTTP (batched, not individual requests)
const results = await sql.transaction([
  sql`UPDATE accounts SET balance = balance - ${amount} WHERE id = ${fromId}`,
  sql`UPDATE accounts SET balance = balance + ${amount} WHERE id = ${toId}`,
  sql`INSERT INTO transfers (from_id, to_id, amount) VALUES (${fromId}, ${toId}, ${amount})`
]);

// MCP tool using the HTTP driver
server.tool(
  'query_users',
  {
    plan: z.enum(['free', 'pro', 'enterprise']).optional(),
    limit: z.number().int().min(1).max(100).default(25)
  },
  async ({ plan, limit }) => {
    const rows = plan
      ? await sql`SELECT id, email, plan, created_at FROM users WHERE plan = ${plan} LIMIT ${limit}`
      : await sql`SELECT id, email, plan, created_at FROM users ORDER BY created_at DESC LIMIT ${limit}`;

    return { content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }] };
  }
);

TCP driver with connection pooling

For transactional tools, use Neon's Pool class from @neondatabase/serverless. Set max connections conservatively — Neon free tier limits total connections across all clients, and serverless functions can have many concurrent instances each holding connections.

import { Pool, neonConfig } from '@neondatabase/serverless';
import ws from 'ws';

// Required for TCP mode in Node.js (Neon uses WebSocket for the wire protocol)
neonConfig.webSocketConstructor = ws;

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 5,           // Neon free tier: 100 total connections across all clients
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000
});

// Transactional tool — transfer funds between accounts
server.tool(
  'transfer_funds',
  {
    from_account_id: z.string().uuid(),
    to_account_id: z.string().uuid(),
    amount: z.number().positive(),
    currency: z.enum(['USD', 'EUR', 'GBP']).default('USD')
  },
  async ({ from_account_id, to_account_id, amount, currency }) => {
    const client = await pool.connect();
    try {
      await client.query('BEGIN');

      // Check sufficient balance with row lock
      const { rows: [from] } = await client.query(
        'SELECT balance, currency FROM accounts WHERE id = $1 FOR UPDATE',
        [from_account_id]
      );
      if (!from) throw new McpError(ErrorCode.InvalidParams, 'Source account not found');
      if (from.currency !== currency) throw new McpError(ErrorCode.InvalidParams, `Account currency is ${from.currency}, not ${currency}`);
      if (from.balance < amount) throw new McpError(ErrorCode.InvalidParams, `Insufficient balance: ${from.balance} < ${amount}`);

      await client.query(
        'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
        [amount, from_account_id]
      );
      await client.query(
        'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
        [amount, to_account_id]
      );

      const { rows: [transfer] } = await client.query(
        'INSERT INTO transfers (from_id, to_id, amount, currency) VALUES ($1, $2, $3, $4) RETURNING id',
        [from_account_id, to_account_id, amount, currency]
      );

      await client.query('COMMIT');
      return {
        content: [{ type: 'text', text: JSON.stringify({ transfer_id: transfer.id, amount, currency }) }]
      };
    } catch (e) {
      await client.query('ROLLBACK');
      throw e;
    } finally {
      client.release();
    }
  }
);

Database branching for MCP development

Neon's branching feature creates copy-on-write clones of your database in seconds. For MCP server development, this means every PR can run against an isolated Postgres instance with the same schema and seed data as main, without interfering with other PRs or production.

# Install Neon CLI
npm install -g neonctl

# Authenticate
neonctl auth

# Create a branch for a feature PR
neonctl branches create \
  --project-id your-project-id \
  --name pr-123-feature \
  --parent main

# Get the connection string for the branch
neonctl connection-string \
  --project-id your-project-id \
  --branch pr-123-feature
# outputs: postgres://user:pass@ep-xxxx-branch.us-east-2.aws.neon.tech/neondb

# In CI (GitHub Actions example):
# - env: DATABASE_URL=$(neonctl connection-string --branch pr-${{ github.event.pull_request.number }})
# - Run migrations on branch: npx drizzle-kit push or npx prisma db push
# - Run MCP server tests against branch

# Reset branch to main (wipe test data, re-sync schema)
neonctl branches reset pr-123-feature --parent main

# Delete branch after PR merge
neonctl branches delete pr-123-feature

In your CI pipeline, set DATABASE_URL to the branch connection string. The branch starts as a complete copy of the parent at the time of creation — same schema, same seed data, no shared state with other branches. Writes to the branch don't propagate back to main.

Traditional approach Neon branch approach
Shared staging DB — PRs pollute each other's data Isolated branch per PR — no interference
Schema migrations must be coordinated Each branch migrates independently
Seed data must be reset between test runs Reset branch to parent with one command
Costs: always-on database Branches auto-suspend when idle; billed only for active compute

Handling cold starts in MCP tools

Neon auto-suspends compute after the idle timeout (default: 5 minutes). The first query after suspension takes 500ms–2s. For MCP tools with latency SLAs, this is unacceptable. Two strategies:

// Strategy 1: Keep-warm — prevent suspension with periodic SELECT 1
// Use this if your tools have sub-500ms latency requirements
const KEEP_WARM_INTERVAL_MS = 4 * 60 * 1000; // 4 minutes (before the 5-min idle timeout)

let keepWarmInterval: NodeJS.Timeout | null = null;

export function startKeepWarm(): void {
  keepWarmInterval = setInterval(async () => {
    try {
      await sql`SELECT 1`;
    } catch (e) {
      console.warn('Keep-warm query failed:', (e as Error).message);
    }
  }, KEEP_WARM_INTERVAL_MS);
  keepWarmInterval.unref(); // Don't prevent process exit
}

export function stopKeepWarm(): void {
  if (keepWarmInterval) {
    clearInterval(keepWarmInterval);
    keepWarmInterval = null;
  }
}

// Strategy 2: Accept cold start and surface it in tool response
// Use this for low-traffic tools where the keep-warm cost isn't worth it
server.tool(
  'query_archive',
  { query: z.string().max(500) },
  async ({ query }) => {
    const start = Date.now();
    const rows = await sql`SELECT * FROM archive WHERE content ILIKE ${'%' + query + '%'} LIMIT 20`;
    const elapsed = Date.now() - start;

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          rows,
          meta: {
            response_ms: elapsed,
            cold_start: elapsed > 1000,  // Flag in response so caller knows
            note: elapsed > 1000 ? 'First query after idle — subsequent queries will be faster' : null
          }
        }, null, 2)
      }]
    };
  }
);

Health endpoint: /health/neon

The Neon health endpoint's unique job is to distinguish three states: healthy (fast query), cold start (slow but successful query), and down (query failure). AliveMCP uses the response time field to alert on cold starts that exceed your acceptable latency threshold.

import express from 'express';
import { neon } from '@neondatabase/serverless';

const sql = neon(process.env.DATABASE_URL!);
const app = express();

app.get('/health/neon', async (_req, res) => {
  const start = Date.now();
  try {
    await sql`SELECT 1`;
    const responseMs = Date.now() - start;

    // Classify the state based on response time
    let state: 'warm' | 'cold-start' | 'slow';
    if (responseMs < 200) {
      state = 'warm';
    } else if (responseMs < 3000) {
      state = 'cold-start';
    } else {
      state = 'slow';
    }

    // 503 if exceptionally slow (likely a real problem, not just cold start)
    const httpStatus = responseMs > 5000 ? 503 : 200;

    res.status(httpStatus).json({
      status: httpStatus === 200 ? 'ok' : 'degraded',
      state,
      response_ms: responseMs,
      note: state === 'cold-start'
        ? 'Neon compute resumed from suspension — next query will be faster'
        : null
    });
  } catch (err) {
    res.status(503).json({
      status: 'error',
      error: (err as Error).message,
      elapsed_ms: Date.now() - start
    });
  }
});

Configure AliveMCP with a response-time alert: alert if response_ms > 2000 even when HTTP status is 200. This catches cold-start latency spikes that don't manifest as failures but do degrade tool usability. Set the alert threshold above your expected cold-start time (typically 2s) so routine suspensions don't page, but compute credit exhaustion (which causes multi-second or infinite delays) does.

Silent failure modes

Failure mode Symptom Detected by HTTP check? How to detect
Idle suspension during long-running tool A slow aggregation holds a TCP connection open past Neon's idle limit; connection is reset mid-query. No Catch connection reset errors in tool handlers; set statement_timeout appropriately
Compute credit exhaustion (free tier) Free tier runs out of monthly compute hours. Queries fail with "project exceeded compute limit". MCP server stays alive. No — server process is running /health/neon query fails with credit exhaustion error message
SSL certificate rotation Neon rotates certs periodically. Older driver versions with pinned certs fail TLS handshake. No (until cert expires) Keep @neondatabase/serverless updated; use sslmode=require not verify-full
Branch deleted while in use CI or developer deletes the branch. MCP server loses its database entirely. All queries fail. No /health/neon query fails; catch "endpoint not found" error

Frequently asked questions

Neon vs PlanetScale for MCP servers — which should I choose?

Neon and PlanetScale solve different problems. Neon is standard Postgres — full SQL, all extensions (pgvector, PostGIS, pg_trgm), foreign keys, and standard tooling. PlanetScale is MySQL-based, no foreign keys by default, but has a unique non-blocking schema change workflow (Vitess-backed) that's valuable at very high scale. For most MCP servers, Neon's Postgres compatibility is the clearer choice: you can use any Postgres-compatible ORM (Drizzle, Prisma, Kysely), extensions like pgvector for embedding storage, and standard SQL. PlanetScale makes sense if your team is already deeply invested in MySQL tooling or if you specifically need Vitess's horizontal sharding. Note: PlanetScale ended their free tier in 2024; Neon's free tier remains available.

When should I use the HTTP driver vs the TCP driver?

Use the HTTP driver (the neon() tagged template function) for the majority of tool handlers: it has a faster cold start (~100ms vs ~500ms), works in edge runtimes (Cloudflare Workers, Deno Deploy), and requires no connection management. Use the TCP driver (Pool from @neondatabase/serverless with neonConfig.webSocketConstructor = ws) when you need ACID transactions spanning multiple statements, or when you're using a query builder or ORM that expects a pg-compatible pool. You can mix both in the same MCP server: use HTTP for read-only tools and TCP for transactional write tools.

How do I automate branch cleanup after PR merge?

Add a GitHub Actions job that runs on pull_request with types: [closed]. Use the Neon CLI or Neon API to delete the branch: neonctl branches delete pr-${{ github.event.pull_request.number }}-feature --project-id ${{ secrets.NEON_PROJECT_ID }}. Authenticate via NEON_API_KEY secret in the workflow. If using the Neon GitHub integration (available in your Neon project settings), branch creation and deletion can be automated entirely — Neon creates a branch when a PR opens and deletes it when the PR closes, setting the connection string as a GitHub Actions variable automatically.

What is the connection limit on Neon's free tier?

Neon free tier allows up to 100 concurrent connections total across all branches in a project. With a Pool of max: 5, a single MCP server instance uses at most 5 connections. If you run multiple instances (e.g., 3 replicas × 5 connections = 15 connections), you're still well within the limit. However, if you use branches for PR testing and each has its own MCP server instance, connections accumulate across branches. Neon also offers connection pooling via PgBouncer on their platform (add -pooler to the endpoint hostname), which allows thousands of client connections while maintaining a small pool of actual Postgres connections. Use the pooler endpoint in production MCP server deployments.

Further reading

Monitor Neon cold starts and credit exhaustion

AliveMCP tracks your /health/neon endpoint response times and alerts you when cold starts exceed 2 seconds or compute credits are exhausted.

Start monitoring free