# How to build your own MCP server

> Source: <https://dev.to/airpipe/how-to-build-your-own-mcp-server-11gb>
> Published: 2026-08-04 17:05:00+00:00

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 <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](https://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:

``` python
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>

# 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'
```

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:

``` php
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 is`response_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.
