How to build your own MCP server Air Pipe has published a guide demonstrating how to build a production-ready MCP server using a Postgres database, a single config file, and a token, with a setup time of about 15 minutes. The tutorial covers creating a schema with tenant and token tables, seeding data, and configuring environment variables, culminating in a URL that can be pasted into MCP clients like Claude Desktop. Most MCP tutorials hand you a Node project. You install an SDK, write a tool handler, wire up stdio, and end up with something that runs on your laptop as you, with your credentials, for exactly one user. That's fine for a demo. It's not something you can give a customer. Here's the other way, end to end: a database, one config file, a token, and a URL you paste into Claude. Every step below is a real command against a real pack — nothing elided, nothing left as an exercise. Time: about 15 minutes. You'll need: an Air Pipe https://airpipe.io account free tier is enough , a Postgres database, and an MCP client — Claude Desktop, Claude Code, Cursor, anything that speaks MCP. If you already have one, skip ahead. If not, any of these work and all have a usable free tier: | Provider | What you get | |---|---| | docker run -e POSTGRES PASSWORD=pw -p 5432:5432 postgres:16 What you need out of it is one connection string: postgresql://user:password@host:5432/dbname A local Postgres works for following along, but your managed Air Pipe instance can't reach localhost — so if you want the tools live from Claude Desktop, use a hosted database or self-host the Air Pipe binary next to your local one. On SSL: most hosted providers require it. If your first query fails with SSL is required , append ?sslmode=require to the connection string. Neon needs this; Supabase includes it in the string it gives you. Three tables. Only one of them is your data: CREATE EXTENSION IF NOT EXISTS pgcrypto; -- A tenant is one of YOUR customers. Ignore it entirely while it's just you; -- it's what makes step 8 possible without a rewrite. CREATE TABLE IF NOT EXISTS mcp tenants id UUID PRIMARY KEY DEFAULT gen random uuid , name TEXT NOT NULL, created at TIMESTAMPTZ NOT NULL DEFAULT NOW ; -- Issued token metadata — the revocation denylist. The token string itself is -- never stored, only its jti claim. CREATE TABLE IF NOT EXISTS mcp tokens jti UUID PRIMARY KEY, tenant id UUID NOT NULL REFERENCES mcp tenants id ON DELETE CASCADE, subject TEXT NOT NULL, name TEXT NOT NULL DEFAULT 'default', created at TIMESTAMPTZ NOT NULL DEFAULT NOW , expires at TIMESTAMPTZ NOT NULL, revoked at TIMESTAMPTZ ; -- The resource your tools read and write. Swap this for your own table. CREATE TABLE IF NOT EXISTS mcp tasks id UUID PRIMARY KEY DEFAULT gen random uuid , tenant id UUID NOT NULL REFERENCES mcp tenants id ON DELETE CASCADE, title TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open' CHECK status IN 'open', 'done' , created at TIMESTAMPTZ NOT NULL DEFAULT NOW , updated at TIMESTAMPTZ NOT NULL DEFAULT NOW ; CREATE INDEX IF NOT EXISTS idx mcp tasks tenant ON mcp tasks tenant id, created at DESC ; Run it: psql "$DATABASE URL" -f schema.sql pgcrypto is only needed for gen random uuid on Postgres 12 and earlier — it's built in from 13 on, and the IF NOT EXISTS makes the line harmless either way. Seed a tenant and a couple of rows so there's something to see: INSERT INTO mcp tenants id, name VALUES '11111111-1111-1111-1111-111111111111', 'Acme Inc' ; INSERT INTO mcp tasks tenant id, title, status VALUES '11111111-1111-1111-1111-111111111111', 'Ship the MCP launch post', 'open' , '11111111-1111-1111-1111-111111111111', 'Review Q3 numbers', 'done' ; In the Air Pipe dashboard, under your environment's managed variables or as ap var s if you're self-hosting : | Name | Value | |---|---| DATABASE URL | the connection string from step 1 | SOLO SECRET | a 32+ character random string | Generate the secret rather than typing one — it's the only thing standing between the internet and your database: openssl rand -base64 48 Both are referenced as a|ap var::NAME| in the config, so they never appear in the file you commit. Here's the whole thing. One file, two tools. name: McpTasks description: MCP tools over Postgres, guarded by a single shared HS256 token. Who this server says it is when a client calls initialize engine = 1.38.0 . mcp servers: tasks: title: Tasks instructions: - A task list backed by Postgres. Use list tasks to read tasks optionally filtered to "open" or "done" and create task to add one. Both tools require the bearer token issued by the operator. default: true global: databases: main: driver: postgres conn string: "a|ap var::DATABASE URL|" interfaces: MCP tool: list tasks · HTTP: POST /solo/tasks solo/tasks: output: http method: POST summary: List all tasks description: List every task, newest first. Optionally filter by status. tags: tasks mcp: enabled: true tool name: list tasks description: List all tasks. Optional status filter "open" or "done" . actions: - name: ValidateToken input: a|headers| hide data on success: true assert: http code on error: 401 error message: "Invalid or missing token" tests: - value: airpipe-jwt is not null: true is valid jwt: a|ap var::SOLO SECRET| post transforms: - extract value: jwt claims - name: CheckBody run when succeeded: actions: ValidateToken http code on error: 400 input: a|body| hide data on success: true assert: tests: - value: status is not null: false description: Optional status filter — "open" or "done". - name: ListTasks run when succeeded: CheckBody database: main query: | SELECT id, title, status, created at FROM mcp tasks WHERE $1::text IS NULL OR status = $1::text ORDER BY created at DESC LIMIT 200; params: - a|body::status- default null | MCP tool: create task · HTTP: POST /solo/tasks/create solo/tasks/create: output: http method: POST summary: Create a task tags: tasks mcp: enabled: true tool name: create task description: Create a new task. Requires a title; status defaults to "open". actions: - name: ValidateToken input: a|headers| hide data on success: true assert: http code on error: 401 error message: "Invalid or missing token" tests: - value: airpipe-jwt is not null: true is valid jwt: a|ap var::SOLO SECRET| - name: CheckBody run when succeeded: actions: ValidateToken http code on error: 400 input: a|body| hide data on success: true assert: http code on error: 400 error message: "title is required" tests: - value: title is not null: true is not empty: true description: The task title. - value: status is not null: false description: Optional status — "open" default or "done". - name: CreateTask run when succeeded: CheckBody database: main query: | INSERT INTO mcp tasks tenant id, title, status VALUES $1::uuid, $2, COALESCE $3, 'open' RETURNING id, title, status, created at; params: - "11111111-1111-1111-1111-111111111111" - a|CheckBody::title| - a|body::status- default null | post transforms: - extract value: " 0 " Five things worth pointing at: mcp servers is the server; mcp: blocks are the tools. The declaration at The mcp: block is the only thing that makes it a tool. Delete it and you Auth is not MCP-specific. Air Pipe takes the client's Authorization: Bearer token, forwards it into the interface as the airpipe-jwt header, and runs the same actions an HTTP request would. Securing an MCP tool is exactly securing a route. One model to learn, not two. CheckBody is what the AI sees. The MCP inputSchema is generated from description: . Write them for is not null: false is an always-pass predicate: it declares the field as CheckBody reads a|body| , Parameters are bound, not interpolated. $1 , $2 with a params: list — so a task titled ' ; DROP TABLE mcp tasks; -- is a task title. Nothing to build and nothing to host. On managed Air Pipe, paste the file into the dashboard editor and hit deploy — that validates it on the way in. If you're using the Air Pipe MCP tools from your own AI client, "validate and deploy this config" does the same from the chat, and installing the pack below does it without either. Self-hosting is one command — point the binary at the directory holding the file: airpipe server --config-dir . --api-key