Skip to main content
10 min read

A Postgres-Backed MCP Server in ~20 Lines

A Postgres-Backed MCP Server in ~20 Lines

The Model Context Protocol is how an AI agent gets tools. You stand up an MCP server, it advertises a set of tools with typed inputs, and the agent calls them. For a huge number of real MCP servers, those tools are thin wrappers around a database: search these records, create this row, update that field. The server is mostly a translator between JSON-RPC and SQL.

Which raises an obvious question. If an MCP server spends its life talking to Postgres, why does it so often run somewhere far away from Postgres? The usual setup is an MCP server on one host and the database on another, so every tool call pays a network round trip to reach the data it needs.

Neon Functions let you skip that. You deploy the MCP server as a function that lives on the same database branch it queries, in the same region, so the server-to-Postgres hop is a local one. In this post I build a Postgres-backed MCP server, deploy it onto a branch, connect a real MCP client, and show what the round trips actually look like. The whole thing is about twenty lines of interesting code, and the repo is at the end.

TL;DR

  • An MCP server that exposes database tools is mostly network plus queries. Running it next to the database removes a cross-region hop from every tool call.
  • Neon Functions deploy your MCP server onto a database branch, co-located with Postgres. The server-to-database query is a same-region hop of a millisecond or two, not a transatlantic one.
  • The core is small: define a Drizzle schema, register a tool whose handler runs a query, and expose the MCP server over the streamable HTTP transport at /mcp. That is the ~20 lines.
  • Any MCP client that speaks streamable HTTP connects to it: mcporter, the MCP SDK, or an agent like Claude or Cursor pointed at the URL.
  • Each branch gets its own function URL, so every preview or test branch can have its own isolated MCP endpoint over its own copy of the data.

Prerequisites

  • Node.js 20+ and the Neon CLI (npm i -g neon, then neon login)
  • A Neon account with the platform preview enabled (Functions, new us-east-2 projects)
  • Basic familiarity with Postgres and TypeScript
  • Optional: an MCP client to point at it, such as mcporter, Claude, or Cursor

What an MCP server actually is

Strip away the branding and an MCP server is a small RPC service. It speaks JSON-RPC over a transport, and it advertises a list of tools. Each tool has a name, a description, and an input schema. When the agent decides to call a tool, the server runs a handler and returns a result. That is the whole contract.

The transport here is streamable HTTP: the client POSTs JSON-RPC messages to a single endpoint (/mcp) and reads responses back, with server-sent events for anything streamed. It works over plain HTTPS, which is exactly what a serverless function serves, so an MCP server and a Neon Function are a natural fit.

The ~20 lines

Here is the core of a Postgres-backed MCP server. A schema, one tool whose handler runs a query, and the wiring to expose it over streamable HTTP. Everything else is more of the same.

import { Hono } from 'hono';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { ilike } from 'drizzle-orm';
import { z } from 'zod';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPTransport } from '@hono/mcp';
import { contacts } from './db/schema';

// One pool per isolate, reused across requests.
const db = drizzle(new Pool({ connectionString: process.env.DATABASE_URL }));

const mcp = new McpServer({ name: 'contacts', version: '1.0.0' });

mcp.registerTool(
  'search_contacts',
  {
    description: 'Search contacts by name. Omit the query to list everyone.',
    inputSchema: { query: z.string().optional().describe('substring to match') },
  },
  async ({ query }) => {
    const rows = await db
      .select()
      .from(contacts)
      .where(query ? ilike(contacts.name, `%${query}%`) : undefined);
    return { content: [{ type: 'text', text: JSON.stringify(rows) }] };
  },
);

// Expose the server over streamable HTTP at /mcp.
const app = new Hono();
const transport = new StreamableHTTPTransport();
app.all('/mcp', async (c) => {
  if (!mcp.isConnected()) await mcp.connect(transport);
  return transport.handleRequest(c);
});

export default app;

The tool handler is the interesting part. It is just a query. registerTool gives the agent the name, the description, and a Zod input schema (the SDK turns that into the JSON schema the model sees), and your handler returns content. The companion repo fills this out to full CRUD (create_contact, update_contact, delete_contact, search_contacts) against a small contacts table, but every tool follows this same shape: describe it, run a query, return the rows.

The schema is ordinary Drizzle:

import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core';

export const contacts = pgTable('contacts', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  email: text('email'),
  company: text('company'),
  notes: text('notes'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

And the function declaration that tells Neon what to deploy:

// neon.ts
import { defineConfig } from '@neon/config/v1';

export default defineConfig({
  preview: {
    functions: {
      contacts: { name: 'contacts mcp server', source: 'src/index.ts' },
    },
  },
});

Deploy it onto the branch

The Neon CLI scaffolds the template, links (or creates) a project, pushes the schema, and deploys the function. From an empty directory:

That last URL is the deployed MCP server. The function and the Postgres branch it queries are in the same region, us-east-2. The MCP endpoint is that URL plus /mcp. If you want to iterate before deploying, neon dev serves the same function locally at http://localhost:8787 with the MCP endpoint at /mcp.

Warning

A Neon Function has a public HTTPS URL, reachable by anyone who has it. This example runs open for the demo, which is not acceptable for anything real: these tools read and write your database. Gate the endpoint before you share the URL.

The gate is a few lines of Hono middleware in front of /mcp. The repo ships it env-gated: leave MCP_TOKEN unset and the demo stays open, set it and every request needs the bearer token.

app.use('/mcp', async (c, next) => {
  const token = process.env.MCP_TOKEN;
  if (token && c.req.header('authorization') !== `Bearer ${token}`) {
    return c.json({ error: 'unauthorized' }, 401);
  }
  await next();
});

Most MCP clients can send custom headers, so the agent side is one config line (Authorization: Bearer <token>). I verified the gate directly against the app: no header and a wrong token both get a 401, the right token passes through to the transport, and with MCP_TOKEN unset the endpoint behaves exactly as before.

Wire up a client and watch it work

Any MCP client that speaks streamable HTTP can connect to /mcp. Here are three ways: a CLI, the SDK, and adding it to an agent.

I ran the SDK client against the deployed server from a machine in Europe. The handshake and the tool calls all worked on the first try:

connect (initialize + handshake): ~1.5 s   (cold start ~2 s the first time)
tools/list: create_contact, update_contact, delete_contact, search_contacts
create_contact: 196 ms  ->  { "created": { "id": 1, "name": "Ada Lovelace", ... } }
search_contacts "navy": 150 ms  ->  { "count": 1, "contacts": [ { "name": "Grace Hopper", ... } ] }

A direct SELECT count(*) against the branch afterwards showed the rows really landed in Postgres. Nothing is held in memory; the tools are just queries.

Why co-location is the point

Those tool-call numbers are around 150 to 200 milliseconds, but that is a measurement of my distance to the function, not the function's speed. I am in Europe and the function is in us-east-2, so each call is roughly one transatlantic round trip. An agent running near the region, or the model provider's own infrastructure calling the tool, sees a small fraction of that.

The number that does not move with the client's location is the hop from the function to Postgres, and that is the one co-location fixes. In the first post in this series I measured exactly that: a SELECT from inside the function against the co-located branch ran in about 1.2 ms, versus about 135 ms for the same query issued across the Atlantic.

A tool call that runs one or two queries inherits that difference on every invocation. Put the MCP server a region away from its database and each tool call carries an extra cross-region round trip on top of whatever the client already paid to reach the server. Put the server on the branch and that part is effectively free. For a server whose entire job is querying Postgres, that is the hop worth optimizing.

One endpoint per branch

There is a second thing you get for free here. Neon Functions are deployed per branch, and each branch has its own function URL. Because a branch is also a copy of your data, that means every branch can have its own MCP server over its own dataset.

Spin up a branch for a preview environment and it comes with an MCP endpoint backed by that branch's data. Give an agent a scratch branch to work against and it cannot touch production. Run your CI against a branch and the agent's tools operate on the ephemeral copy, then it all gets thrown away with the branch. You are not standing up and tearing down a separate MCP service per environment; the endpoint rides along with the branch you already have.

The repo

The full example, with all four CRUD tools, the schema, the deploy config, and client test scripts, is here:

Wrapping up

An MCP server that fronts a database is mostly network and queries, and the network part is worth taking seriously because an agent may call these tools dozens of times in a single task. Neon Functions let you collapse the server-to-database distance to a same-region hop by deploying the MCP server onto the branch it queries, and the code to do it is small: a schema, a tool that runs a query, and the streamable HTTP transport. Point any MCP client at the URL and the agent has typed, database-backed tools running right next to the data. Give each branch its own endpoint and you get isolated, per-environment agent tooling without any extra services to run.

Published: 2026-07-02|Last updated: 2026-07-02T09:00:00Z

Found an issue?

Also worth your time on this topic