DEV Community

Cover image for How to build your own MCP server
Kav Pather for Air Pipe

Posted on • Originally published at airpipe.io

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Run it:

psql "$DATABASE_URL" -f schema.sql
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]"
Enter fullscreen mode Exit fullscreen mode

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 <your-key>
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Step 7 — Verify it before you touch the client

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>

# List the tools
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'
# → "list_tasks"
# → "create_task"

# Call one
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"}}}'

# The same tool over plain HTTP — note the header name changes
curl -sX POST $BASE/solo/tasks \
  -H "airpipe-jwt: $TOKEN" \
  -H 'content-type: application/json' \
  -d '{"status":"open"}' | jq '.data.ListTasks.data'
Enter fullscreen mode Exit fullscreen mode

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:

  • 401 Invalid or missing token — the secret used to sign doesn't match SOLO_SECRET, or exp is in the past. Decode the token at jwt.io and check the expiry first; it's usually that.
  • A database error on the query action — the connection string can't be reached from your Air Pipe instance. localhost is the usual culprit, SSL the other.

Step 8 — Point Claude at it

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

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.

Give the server a name, not just tools

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
Enter fullscreen mode Exit fullscreen mode

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}'
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
    mcp:
      enabled: true
      tool_name: purge_tasks
      server: tasks-admin      # published only on the named endpoint
Enter fullscreen mode Exit fullscreen mode

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.

The hole most MCP servers have

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now an unauthenticated tools/list returns {"result":{"tools":[]}} — not even
the names.

response_on_success: { http_code: 200 } is required. The gate is
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 tool even 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.

Now the part that matters: your customers

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')
Enter fullscreen mode Exit fullscreen mode

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)|
Enter fullscreen mode Exit fullscreen mode

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.

Revocation, which stateless JWTs can't do alone

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
Enter fullscreen mode Exit fullscreen mode

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>"}'
Enter fullscreen mode Exit fullscreen mode

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.

Already using Auth0, Clerk or Cognito?

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|
Enter fullscreen mode Exit fullscreen mode

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.

The shortcut

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.

Known limits, so you're not surprised

  • Tokens are long-lived bearers. MCP clients today authenticate with a static bearer pasted into config — there's no interactive OAuth flow yet. Keep exp short and rely on the denylist for revocation.
  • One statement per action on Postgres — the driver prepares the query, and a prepared statement holds one command. Use multi: true for a multi-statement DDL block (engine ≥ 0.196.0).
  • HTTP responses are wrapped in a {"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.

Skip the setup

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

Related packs

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.

Top comments (0)