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
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
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'
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:
401Invalid or missing token— the secret used to sign doesn't match
SOLO_SECRET, orexpis 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.localhostis 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>" }
}
}
}
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
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.
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
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.
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')
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.
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
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.
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|
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
expshort 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. Usemulti: truefor 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 throughjq. MCP clients parse the
tool result for you.
Is tools/list open on your MCP server right now? Worth checking.
Skip the setup
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.
Community-Analysen & Experten-Meinungen 0
Verwandte Story-Cluster & Quellen (Vektor-KI)
🔖 Gespeicherte Artikel
tsecurity.de App
Installiere tsecurity.de direkt auf deinen Home-Bildschirm für das ultimative Vollbild-Magazinerlebnis ohne Browser-Leisten.
Community Radar & Live Chat
Aktivitäten deiner Analysten
Neues Thema oder Eilmeldung einreichen
Reiche interessante Links, Zero-Days oder Debatten ein. Die Community entscheidet per Upvote über die Veröffentlichung.
SOCIAL SHARE CARD GENERATOR