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.