All skills
neondatabase avatar

/neon-functions

@b8250e6 official

Long-running, serverless Node.js HTTP functions deployed onto your Neon branch, with DATABASE_URL injected automatically and compute that runs next to your data. Use when a user wants to host an API, an AI agent with long streaming responses, a WebSocket or server-sent-events (SSE) server, a webhook handler, a Discord bot, an MCP server, or any request/response workload that risks timing out on short, lambda-style serverless functions — and wants it to branch with their database. Also use for Function Triggers: a cron or an object-storage event that POSTs to a function. Triggers include "serverless function", "deploy an API", "long-running function", "streaming agent", "SSE server", "WebSocket server", "webhook handler", "MCP server", "cron", "function trigger", "scheduled function", "cron job", "object storage trigger", "on upload", "run code next to my database", "function that won't time out", "function logs", "Neon Functions", "Neon Compute", "DDoS protection", "rate limiting", and "production hardening".

Use this Skill: https://skilld.dev/gh/neondatabase/agent-skills/neon-functions

This session only. Nothing lands on disk.

referencesmcp.md

≈2.3k tokens on demand. Your agent reads this file only when SKILL.md points to it.

MCP servers on Neon Functions

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.

The server

Two packages do the work: the official @modelcontextprotocol/sdk (defines the server and its tools) and @hono/mcp (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:

  • Module scope. Build the 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.
  • State in Postgres. Module memory doesn't survive isolate eviction, and several isolates run in parallel — so the source of truth for anything a tool reads or writes belongs in Postgres, not an in-memory structure.
  • The URL. After neon deploy, the server lives at https://<branch_id>-<slug>.compute.…neon.tech/mcp. Point any streamable-HTTP MCP client at that /mcp path.

Authenticating the server

[!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 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

Option 1 — OAuth via the Better Auth MCP plugin (best for third-party clients)

The MCP plugin 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.

Option 2 — API key or session JWT via self-hosted Better Auth (simplest)

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:

  • API keys — enable Better Auth's API Key plugin, issue a key, and have the function verify the Authorization: Bearer <key> (or an x-api-key header) on every request; or
  • Session JWT — mint a short-lived JWT with Better Auth's jwt 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.

Testing

Drive the server with any MCP client. mcporter 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.

Source: SKILL.md on GitHub

1 warningtoday3 checks · Risk SAFE
  • Gen Agent Trust Hubtoday

    This skill provides a comprehensive environment for deploying long-running serverless Node.js functions. It emphasizes robust security practices, including mandatory JWT authentication for public routes, origin secret verification, and input validation. While it facilitates building AI agents which are inherently susceptible to indirect prompt injection, it includes detailed remediation guidance and mitigation strategies.

  • Sockettoday

    No alerts

  • Snyktoday

    Risk: MEDIUM · 1 issue

Signed by skilld at b8250e6. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 43 minutes ago.

Activeupdated yesterday
Other metadata
metadata
{
  "parent": "neon",
  "source": "https://github.com/neondatabase/agent-skills/tree/main/skills/neon-functions"
}
  • API
  • nodejs
  • serverless
  • websocket
  • streaming
  • sse
  • neon
  • postgres
  • agent
  • long-running

README badge

README badge for neondatabase/agent-skills/neon-functions

Deploys long-running Node.js HTTP functions to a Neon branch with automatic DATABASE_URL injection and zero cold starts. Use this for APIs, WebSocket/SSE servers, agent backends with streaming responses, webhook handlers, and any workload that exceeds lambda-style timeouts — all co-located with your Postgres database.

Generated from the current SKILL.md.

Does this work outside us-east-2?
No. Neon Functions are currently only available in us-east-2.
What happens to my function when I create a new branch?
If neon.ts is present, the function is automatically deployed to the new branch at its own URL against the branch's isolated database and storage.
Can I use this for long-running agent workloads?
Yes. Functions are designed for agents that make multiple LLM calls and tool invocations per request; the handler can stay open as long as bytes keep flowing, with no hard execution timeout like lambda-style serverless.
Do I need to manage DATABASE_URL myself?
No. DATABASE_URL is injected automatically at runtime when the branch has Postgres. You retrieve it via parseEnv(config).
Can I host a WebSocket or SSE server on this?
Yes. Functions stay alive across requests, so you can hold WebSocket and SSE connections open in-process without needing an external state store like Redis.

Generated from the current SKILL.md. These answers refresh after source changes.