Subchapter 3.4
references/mcp.mdMarkdown9 KBView on GitHub
A Model Context Protocol server is a textbook Neon Functions workload: it’s a long-running HTTP handler that an AI client (Cursor, Claude, ChatGPT, an agent) calls to discover and invoke tools, and those tools usually read and write a database. Running it as a Neon Function puts the MCP server’s compute next to its Postgres data, gives it a public HTTPS URL, and lets it branch with the rest of your backend — each branch gets its own MCP server against its own isolated data.
MCP’s streamable HTTP transport is a plain POST/GET on a single endpoint (conventionally /mcp), so it maps directly onto a function’s web-standard fetch handler — no upgrade method or extra protocol like WebSockets needed. A Hono app is the simplest host.
Two packages do the work: the official @modelcontextprotocol/sdk (opens in a new tab) (defines the server and its tools) and @hono/mcp (opens in a new tab) (bridges MCP’s streamable HTTP transport to a Hono route). Tools query Postgres through Drizzle on a module-scope pg pool, exactly like any other function (see Connecting to Postgres).
// src/index.ts
import { Hono } from "hono";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPTransport } from "@hono/mcp";
import { attachDatabasePool } from "@neon/functions";
import { contacts } from "./db/schema";
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
attachDatabasePool(pool);
const db = drizzle(pool);
const mcpServer = new McpServer({ name: "contacts", version: "1.0.0" });
// Each tool: a name, a config (description + a Zod input schema), and a handler
// that returns MCP content. The Zod shape becomes the tool's JSON schema, which
// the client uses to call the tool correctly.
mcpServer.registerTool(
"create_contact",
{
title: "Create contact",
description: "Create a new contact.",
inputSchema: {
name: z.string().describe("Full name (required)."),
email: z.string().optional().describe("Email address."),
},
},
async ({ name, email }) => {
const [row] = await db.insert(contacts).values({ name, email }).returning();
return { content: [{ type: "text", text: JSON.stringify(row) }] };
},
);
mcpServer.registerTool(
"delete_contact",
{
title: "Delete contact",
description: "Delete a contact by id.",
inputSchema: { id: z.number().int().positive() },
},
async ({ id }) => {
const [row] = await db
.delete(contacts)
.where(eq(contacts.id, id))
.returning();
return {
content: [
{ type: "text", text: JSON.stringify(row ?? { error: "not found" }) },
],
};
},
);
// Connect the server to the transport once per isolate, then let the Hono route
// hand every /mcp request (POST for calls, GET for the stream) to the transport.
const transport = new StreamableHTTPTransport();
const app = new Hono();
app.all("/mcp", async (c) => {
if (!mcpServer.isConnected()) await mcpServer.connect(transport);
return transport.handleRequest(c);
});
export default app;Key points:
McpServer, register its tools, create the StreamableHTTPTransport, and open the pg pool once at module load — they’re reused across every request the isolate serves (see runtime limits). Connect the transport lazily with the isConnected() guard so it happens once.neon deploy, the server lives at https://<branch_id>-<slug>.compute.…neon.tech/mcp. Point any streamable-HTTP MCP client at that /mcp path.[!WARNING] A Neon Function has a public HTTPS URL — anyone can reach it. An unauthenticated MCP server hands every caller your tools (and the database behind them). Authenticate at the top of the handler before touching the transport, exactly as for any client-facing function.
Better Auth (opens in a new tab) covers both common MCP shapes when you need an OAuth authorization server or API keys. Managed Auth does not. Keep existing app login (Clerk, Managed Auth, or Better Auth) unless the user asked to migrate it. Confirm the installed Better Auth version before copying imports: the MCP plugin is moving out of better-auth/plugins into @better-auth/mcp (withMcpAuth → requireMcpAuth, createMcpAuthClient → createMcpResourceClient). Docs: https://better-auth.com/docs/plugins/mcp (opens in a new tab)
The MCP plugin (opens in a new tab) makes your Better Auth app the OAuth authorization server for MCP, implementing the MCP authorization spec end to end: discovery (/.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource), dynamic client registration, and the consent/token flow. MCP clients that support OAuth (Cursor, Claude, ChatGPT) then sign the user in and obtain a token with no API key to copy around.
Your Neon Function is the resource server — a separate service from the Better Auth app, so it doesn’t share a process. Use Better Auth’s remote MCP client to validate the incoming Bearer token against the auth server’s published JWKS, and serve the protected-resource metadata so clients can discover where to authenticate:
// src/index.ts (sketch) — verify the bearer token against your remote Better Auth server.
// Import path/name depend on your Better Auth version (createMcpAuthClient in better-auth/plugins/mcp/client,
// or createMcpResourceClient in @better-auth/mcp/client) — check the docs.
import { createMcpAuthClient } from "better-auth/plugins/mcp/client";
const mcpAuth = createMcpAuthClient({ authURL: process.env.AUTH_URL }); // your Better Auth base URL
app.all("/mcp", async (c) => {
const session = await mcpAuth.verify?.(c.req.raw); // verifies the Bearer token via the remote JWKS
if (!session) {
// Tell the client where to authenticate (RFC 9728 / MCP spec).
return c.json({ error: "unauthorized" }, 401, {
"WWW-Authenticate": `Bearer resource_metadata="${process.env.AUTH_URL}/.well-known/oauth-protected-resource"`,
});
}
if (!mcpServer.isConnected()) await mcpServer.connect(transport);
return transport.handleRequest(c); // scope tools to session.userId
});Pass AUTH_URL (and any signing/JWKS config) to the function via its env in neon.ts (see Environment variables). Because the function only verifies tokens against the remote server, the Better Auth instance can live anywhere — typically your Next.js / app host on Vercel.
When the callers are your own agents/services or a personal MCP server, you don’t need the full OAuth dance. Run Better Auth self-hosted and either:
Authorization: Bearer <key> (or an x-api-key header) on every request; orjwt plugin and verify it in the function against the app’s JWKS, the same jose pattern used for the agent backend.Either way it’s one check at the top of the /mcp route — reject anything that doesn’t carry a valid key/token before connecting the transport:
app.all("/mcp", async (c) => {
const auth = c.req.header("authorization");
if (!(await isValidApiKey(auth)))
return c.json({ error: "unauthorized" }, 401);
if (!mcpServer.isConnected()) await mcpServer.connect(transport);
return transport.handleRequest(c);
});This keeps the secret server-side, costs nothing to operate, and is trivial to rotate — a solid default until you need third-party clients to self-authorize, at which point reach for Option 1.
Drive the server with any MCP client. mcporter (opens in a new tab) is a quick CLI for it — mcporter list <url>/mcp --schema lists the tools and mcporter call "<url>/mcp.<tool>" key=value invokes one (--allow-http for a local neon dev URL). To wire it into a client interactively, npx add-mcp <url>/mcp -a <agent> writes the client config for you.