cd /news/developer-tools/how-to-build-your-own-mcp-server Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-86508] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

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.

read15 min views1 publishedAug 4, 2026

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.

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.

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:

  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)|

  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 fromdescription:

. Write them foris_not_null: false

is an always-pass predicate: it declares the field asCheckBody

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 <your-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

.

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 - <<'PY'
import base64, hmac, hashlib, json, os
def b64(b): return base64.urlsafe_b64encode(b).rstrip(b'=')
secret = os.environ['SOLO_SECRET'].encode()
msg = b64(json.dumps({"alg":"HS256","typ":"JWT"}).encode()) + b'.' + \
      b64(json.dumps({"sub":"me","exp":1798761600}).encode())
sig = b64(hmac.new(secret, msg, hashlib.sha256).digest())
print((msg + b'.' + sig).decode())
PY

Debugging through an MCP client is miserable β€” a failure shows up as "the tool

didn't work." Check with curl first. MCP is JSON-RPC over HTTP, so you can

drive it directly:

BASE=https://your-airpipe-host/<org>/<env>   # self-hosted: no /<org>/<env>
TOKEN=<the token from step 6>

curl -sX POST $BASE/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools[].name'

curl -sX POST $BASE/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
       "params":{"name":"create_task","arguments":{"title":"Draft the changelog"}}}'

curl -sX POST $BASE/solo/tasks \
  -H "airpipe-jwt: $TOKEN" \
  -H 'content-type: application/json' \
  -d '{"status":"open"}' | jq '.data.ListTasks.data'

If tools/list

returns your two tools and tools/call

returns a row, you're

done β€” everything after this is client configuration.

Two failures worth naming, because they're the common ones:

Invalid or missing token

SOLO_SECRET

, or exp

is in the past. Decode the token at jwt.io and check the expiry first; it's usually that.localhost

is the usual culprit, SSL the other.

{
  "mcpServers": {
    "my-tasks": {
      "url": "https://your-airpipe-host/<org>/<env>/mcp",
      "headers": { "Authorization": "Bearer <your-token>" }
    }
  }
}

Claude Desktop keeps this at

~/Library/Application Support/Claude/claude_desktop_config.json

on macOS and

%APPDATA%\Claude\claude_desktop_config.json

on Windows. Claude Code:

claude mcp add --transport http my-tasks https://your-airpipe-host/<org>/<env>/mcp --header "Authorization: Bearer <token>"

.

Restart the client. Ask "what's on my task list?" and it queries your

database.

You also have, from that same file and with no extra work: an HTTP endpoint for

the clients that don't speak MCP, OpenAPI docs, Prometheus metrics, and an

OpenTelemetry trace for every tool call showing which action ran and how long

the query took. That last one matters more than it sounds β€” when a model calls

a tool and gets a confusing answer, the trace is how you find out whether the

tool was wrong or the model was.

Every client calls initialize

before it lists anything, and that response is

where the server says who it is. Skip it and yours introduces itself with a

built-in name and no description β€” a listing that's a bare label above a wall of

tool descriptions. That's what mcp_servers

at the top of the config fixes:

mcp_servers:
  tasks:
    title: Tasks               # -> serverInfo.title, the name in the client's UI
    instructions: >-           # -> the initialize result's `instructions`
      A task list backed by Postgres. Use list_tasks to read tasks and
      create_task to add one. Both require the operator's bearer token.
    default: true              # adopt every tool that names no server

instructions

matters more than it looks. MCP registries β€” mcp.so, Glama,

Smithery, PulseMCP β€” read a remote server's listing description straight off

that field. There is no other place to write one, so an unlisted description

isn't a blank field somewhere; it's a listing nobody clicks.

Check it the same way you checked the tools:

curl -sX POST $BASE/mcp \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize",
       "params":{"protocolVersion":"2025-06-18","capabilities":{},
                 "clientInfo":{"name":"curl","version":"1"}}}' \
  | jq '{title: .result.serverInfo.title, instructions: .result.instructions}'

Declaring a server and contributing tools to it are separate on purpose. An

MCP server is a named group of tools, not a property of one config: tools in

any of your configs join a server by id, so one identity can cover tools spread

across many files β€” which also means the declaration can live alone in its own

config (interfaces: {}

) and survive whichever tool file you rename next.

The id is a route segment, so a second declaration is a second endpoint from the

same deployment β€” a public server and an internal one, say:

mcp_servers:
  tasks:                       # served at /mcp
    title: Tasks
    default: true
  tasks-admin:                 # served at /mcp/tasks-admin
    title: Tasks (admin)
mcp:
      enabled: true
      tool_name: purge_tasks
      server: tasks-admin      # published only on the named endpoint

Ids are 1–64 characters of a-z

, 0-9

or -

, only one server may be the

default, and a tool naming a server nothing declares publishes on no server

rather than the wrong one. Needs engine β‰₯ 1.38.0.

Worth knowing regardless of what you build with: tools/call

runs your code,

tools/list

doesn't.

Listing tools returns metadata β€” names, descriptions, input schemas. Whatever

auth you put inside your handlers never fires for discovery. So a server with

locked-down calls can still let anyone who knows the URL enumerate every tool

you expose and its full schema. They can't call anything. They can read the map.

For a personal server, fine. For an endpoint you offer customers, that catalog

is often the sensitive part β€” your tool names are a description of your product.

Close it by adding one line per tool, pointing at an interface that re-runs the

token check when a client lists tools:

    mcp:
      enabled: true
      tool_name: list_tasks
      list_authorizer: authorize-discovery

And the gate itself β€” an ordinary interface, not a tool:

  authorize-discovery:
    output: http
    method: POST
    summary: Authorize MCP tool discovery for the caller's token.
    tags: [internal]

    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|
        response_on_success:
          http_code: 200

Now an unauthenticated tools/list

returns {"result":{"tools":[]}}

β€” not even

the names.

The gate isresponse_on_success: { http_code: 200 }

is required.

fail-closed on anything that isn't an explicit 2xx, and an interface whose

actions all succeed leaves the status code unset β€” which reads as "not

authorized" and hides every gated tooleven for a valid token. If your tools

vanish after adding the gate, this is why.

Needs engine β‰₯ 1.7.0. Drop the list_authorizer:

line to make discovery public.

Everything above is one token, one grant β€” everyone who holds it sees every row.

Right for pointing an AI at your own database. Useless the moment you have

users.

The multi-tenant shape is the same config with the token doing more work. Your

backend already knows who's logged in, so it mints a per-user token carrying a

tenant_id

:

TOKEN=$(curl -sX POST $BASE/auth/exchange \
  -H "x-exchange-secret: $EXCHANGE_SECRET" \
  -H 'content-type: application/json' \
  -d '{"tenant_id":"11111111-1111-1111-1111-111111111111",
       "subject":"user-123","name":"laptop"}' \
  | jq -r '.data.Result.data.token')

Then every query scopes to the claim in that token instead of a hardcoded id:

      - name: ListTasks
        database: main
        query: |
          SELECT id, title, status, created_at
          FROM mcp_tasks
          WHERE tenant_id = $1::uuid
            AND ($2::text IS NULL OR status = $2::text)
          ORDER BY created_at DESC
          LIMIT 200;
        params:
          - a|ValidateJwt::tenant_id|
          - a|body::status->default(null)|

A row from another tenant doesn't match. Cross-tenant access is structurally

impossible rather than merely forbidden β€” there's no code path where forgetting

a WHERE

clause leaks a customer's data, because the filter is the query.

One endpoint, every customer, each seeing only their own rows.

A signature check can't tell a revoked token from a valid one β€” that's what the

mcp_tokens

table is for. Every tool re-checks the token's jti

against it:

      - name: CheckTokenActive
        run_when_succeeded:
          actions: [ValidateJwt]
          http_code_on_error: 401
        database: main
        hide_data_on_success: true
        query: |
          SELECT (
            $1::uuid IS NULL OR EXISTS (
              SELECT 1 FROM mcp_tokens
              WHERE jti = $1::uuid AND revoked_at IS NULL AND expires_at > NOW()
            )
          ) AS ok;
        params:
          - a|ValidateJwt::jti->default(null)|
        assert:
          http_code_on_error: 401
          error_message: "Token revoked or expired"
          tests:
            - value: "[0]ok"
              is_equal_to: true

Revoking is a call, not an SSH session:

curl -sX POST $BASE/auth/revoke \
  -H "x-exchange-secret: $EXCHANGE_SECRET" \
  -H 'content-type: application/json' \
  -d '{"jti":"<the jti returned at mint time>"}'

The next call is refused: 401 Token revoked or expired

on the HTTP route, and

an error result from the tool over MCP. This is the piece a naive JWT setup

forgets.

Skip the exchange hop entirely. Point is_valid_jwt

at your provider's JWKS and

verify their RS256 tokens directly:

            - value: airpipe-jwt
              is_not_null: true
              is_valid_jwt:
                jwks_url: a|ap_var::OIDC_JWKS_URL|
                alg: RS256
                iss: a|ap_var::OIDC_ISSUER|
                aud: a|ap_var::OIDC_AUDIENCE|

Air Pipe fetches and caches the keys, selects the signer by the token's kid

,

and enforces iss

/ aud

/ exp

. Provider key rotation just works. Add a

tenant_id

claim in your IdP and the scoping above is unchanged. Needs engine

β‰₯ 0.196.0.

Everything on this page ships as one pack, both tiers, tested end to end β€” the

schema, the seed endpoint, the single-token tools, the tenant-scoped tools, the

discovery gate, the token lifecycle routes, and the OIDC variant. Fork it, set

two variables, deploy.

If you only want steps 1 through 8 β€” one token, your own database, no tenancy β€”

take MCP Quickstart instead. It's the same idea stripped to two tools over

one table, with discovery already gated. Start there and move up when you have

customers; the config shape doesn't change.

You can absolutely hand-roll all of this with the TypeScript SDK instead. You'll

also be hand-rolling the auth, the tenant scoping, the discovery gate, the

revocation denylist, the traces, and a parallel REST API for the clients that

don't speak MCP. That's the trade.

exp

short and rely on the denylist for revocation.multi: true

for a multi-statement DDL block (engine β‰₯ 0.196.0).{"data":{"<Action>":{"data": …}}}

action trace, which is why the curl examples pipe through jq

. MCP clients parse the tool result for you.Is tools/list

open on your MCP server right now? Worth checking.

Turn your Postgres data into secure MCP tools any AI client (Claude Desktop, Claude Code, Cursor) can call.

The smallest useful MCP server: two tools over one Postgres table, guarded by a single shared token, in one config file. Point Claude Desktop, Claude Code, Cursor or any MCP client at your database with no SDK, no Node project and nothing to host. An Air Pipe interface is an HTTP route; add an mcp block and the same interface is also an MCP tool, secured by the same in-config token check. Tool discovery (tools/list) is gated by that same token via list_authorizer, so an unauthenticated client cannot even enumerate your tools or their input schemas. Includes a seed endpoint that creates the table and sample data in one curl.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @air pipe 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/how-to-build-your-ow…] indexed:0 read:15min 2026-08-04 Β· β€”