How to build your own MCP server
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 account (free tier is enough), a Postgres database, and an MCP client — Claude Desktop, Claude Code, Cursor, anything that speaks MCP. Step 1 — Get a Postgres database If you already have one, skip ahead. If not, any of these work and all have a usable free tier: Provider What you get Neon Serverless Postgres, free tier, connection string in the dashboard Supabase Postgres + a UI to browse rows while you test Local 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. Step 2 — Create the schema 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'); Step 3 — Set two variables In the Air Pipe dashboard, under your environment's managed variables (or as ap_vars 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. Step 4 — Write the config 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 top is what a client and a registry see before any tool runs — more on it after step 8. Delete it and everything still works, just anonymously. The mcp: block is the only thing that makes it a tool. Delete it and you have an ordinary HTTP route. Keep it and you have both — same auth, same query, same trace, one definition. 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 those assert tests — which is why each carries a description:. Write them for a reader who isn't you, because the model picks tools by reading them. is_not_null: false is an always-pass predicate: it declares the field as optional without requiring it. And because only CheckBody reads a|body|, the token never leaks into the tool's schema. Parameters are bound, not interpolated. $1, $2 with a params: list — so a task titled '); DROP TABLE mcp_tasks; -- is a task title. Step 5 — Deploy 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 It serves on port 4111 by default, so the URLs in the next steps are http://localhost:4111/…. Run airpipe login once and you can drop --api-key. Step 6 — Mint a token Once, at jwt.io: algorithm HS256, secret = your SOLO_SECRET, payload: { "sub": "me", "exp": 1798761600 } Copy the token. Rotating SOLO_SECRET invalidates it. Prefer the command line: python3 -
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to