Skip to main content

MCP Quickstart

Category: AI & Agents

Get this pack →

This page is generated from the Air Pipe marketplace. Browse it live to install into your organization.

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 — any MCP client — at your database without writing a line of code or running a Node project.

The whole idea in one sentence: an Air Pipe interface is an HTTP route; add an mcp: block and the same interface is also an MCP tool. Air Pipe adds no MCP-specific authentication — it 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.

Want per-customer, tenant-scoped tools with a revocation denylist and OIDC/JWKS verification? That's the MCP Postgres Starter pack. This one is deliberately the smallest thing that works.


What's included

FilePurpose
tasks.ymlThe server identity (mcp_servers) + the two MCP tools (list_tasks, create_task) + the discovery gate
seed.ymlPOST /api/seed — creates the tasks table and loads three sample rows
schema.sqlThe one table, if you'd rather create it with psql

MCP tools

ToolInputDescription
list_tasks{ status? }List tasks, newest first; optional open/done filter
create_task{ title, status? }Create a task; status defaults to open

The tool's inputSchema is generated from the CheckBody assert tests — the same source as the OpenAPI request body. The token never appears in the schema; it's supplied out-of-band as the connection bearer.

HTTP endpoints

Every tool is also a normal route, and you get OpenAPI docs, Prometheus metrics and traces for both transports from the same file.

MethodPathAuthDescription
POST/api/tasksairpipe-jwtList tasks
POST/api/tasks/createairpipe-jwtCreate a task
POST/authorize-discoveryairpipe-jwtDiscovery gate — returns 200/401 only, no data
POST/api/seednoneCreate the table + sample data

Server identity

Before a client lists or calls anything it calls initialize, and the answer carries the server's name and description. mcp_servers at the top of tasks.yml is what fills them in:

mcp_servers:
tasks:
title: Tasks
instructions: >-
A task list backed by Postgres. Use list_tasks to read tasks…
default: true
  • titleserverInfo.title, the display name in the client's UI.
  • instructions → the initialize result's instructions field. MCP registries (mcp.so, Glama, Smithery, PulseMCP) read a remote server's listing description straight off this — there is no other place to write one. Skip it and your listing is a bare name plus a wall of tool descriptions.
  • default: true → every tool that names no server joins this one, served on the bare endpoint (/mcp, or /<org>/<env>/mcp managed).

Declaring a server and contributing tools to it are separate things. A server is a named group of tools, not a property of one config: tools in any config in the same org/environment join one via mcp.server: <id>, and the one marked default adopts everything that names none. So a second id publishes a second endpoint from the same deployment:

mcp_servers:
tasks: # served at /mcp
title: Tasks
default: true
tasks-admin: # served at /mcp/tasks-admin
title: Tasks (admin)

interfaces:
api/tasks/purge:
mcp:
enabled: true
tool_name: purge_tasks
server: tasks-admin # <- joins the named server, not the default one

Ids are 1–64 characters of a-z, 0-9 or - (they're URL segments), only one may be default: true, and a tool naming a server nothing declares is published on no server rather than the wrong one. Requires engine ≥ 1.38.0; omit the block entirely and tools still work — they just publish under the built-in name.


Setup

1. Managed variables

NameValue
DATABASE_URLpostgresql://user:pass@host:5432/dbname
MCP_SECRET32+ character HS256 secret for your access token

2. Create the table

Deploy seed.yml and call it, or run schema.sql with psql "$DATABASE_URL" -f schema.sql.

curl -sX POST https://your-airpipe-host/api/seed
# → { "tasks": 3 }

3. Mint your token

Once, at jwt.io: algorithm HS256, secret = your MCP_SECRET, payload e.g. {"sub":"me","exp":9999999999}. Copy the token. Rotating MCP_SECRET revokes it.

4. Point your MCP client at it

Managed: https://<host>/<org>/<env>/mcp. Self-hosted: https://<host>/mcp.

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

Restart the client and both tools show up. Ask it what's on your task list and it queries Postgres.


Quick start (curl walkthrough)

Air Pipe wraps HTTP responses in a {"data":{"<Action>":{"data": …}}} action trace, so the HTTP examples extract the useful field with jq. MCP clients parse the tool result for you.

BASE=https://your-airpipe-host        # managed: https://<host>/<org>/<env>
TOKEN=<your HS256 token>

# 1. Seed
curl -sX POST $BASE/api/seed | jq '.data.SeedSummary.data'

# 2. Discovery is gated — no token, no tools (not even the names)
curl -sX POST $BASE/mcp -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# → {"result":{"tools":[]}}

# 3. With the token, both tools appear
curl -sX POST $BASE/mcp -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | jq '.result.tools[].name'
# → "list_tasks", "create_task"

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

# 5. Same thing over plain HTTP
curl -sX POST $BASE/api/tasks -H "airpipe-jwt: $TOKEN" -H 'content-type: application/json' \
-d '{"status":"open"}' | jq '.data.ListTasks.data'

The bit most MCP servers get wrong

tools/call runs your code. tools/list does not.

Listing tools returns metadata — names, descriptions, input schemas. Whatever auth lives 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 that's fine. For an endpoint you offer customers, that catalog is often the sensitive part.

Air Pipe closes it with one line per tool:

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

authorize-discovery is an ordinary interface — not itself a tool — that re-runs the same token check when a client lists tools. No valid token, no tools. Requires engine ≥ 1.7.0; delete the lines to make discovery public.

The authorizer must end with response_on_success: { http_code: 200 }. 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.


Auth model

One token is one grant: any HS256 JWT signed with MCP_SECRET passes, and every tool sees every row. That's the right shape for pointing an AI client at your own database — a solo dev, an internal tool, a trusted team. To revoke, rotate MCP_SECRET.

The moment you have customers, one token stops being enough — each user needs their own scoped view. Same config shape, but the token carries a tenant_id claim and every query filters on it. That's the MCP Postgres Starter pack.


Customisation

Swap tasks for your own table. To add a tool: copy an interface, keep the ValidateTokenCheckBody prefix, point the query at your columns, and give it a unique mcp.tool_name. The assert tests in CheckBody are what the AI client sees as the tool's arguments, so write their description: fields for a reader who isn't you.

Notes & limitations

  • 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 reasonable and rotate the secret to revoke.
  • POST /api/seed is unauthenticated so you can get running in one curl. Delete seed.yml (or add the same ValidateToken action) before pointing anything real at it.
  • 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).

Configuration

seed.yml

POST /api/seed - creates the tasks table and loads three sample rows

name: McpQuickstartSeed
description: Creates the tasks table (idempotent) and loads three sample tasks. Safe to re-run — it clears the table first. Call it once after deploying, then point your MCP client at the endpoint.

docs: true

# Required managed variable:
# DATABASE_URL — Postgres connection string
#
# POST /api/seed is deliberately unauthenticated so you can get running in one
# curl. Delete this file (or add the same ValidateToken action the tools use)
# before pointing anything real at it.

global:
databases:
main:
driver: postgres
conn_string: "a|ap_var::DATABASE_URL|"

interfaces:

# POST /api/seed
api/seed:
output: http
method: POST
summary: Seed the demo table and data
description: Idempotently creates the tasks table and loads three sample tasks.
tags: [setup]
response_example:
tasks: 3

actions:
# One statement per action: the native Postgres driver prepares the query,
# and a prepared statement can hold only one command (SQLSTATE 42601).
# For a multi-statement DDL block, add `multi: true` (engine >= 0.196.0).
- name: CreateSchema
database: main
hide_data_on_success: true
query: |
CREATE TABLE IF NOT EXISTS tasks (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'done')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

- name: ClearTable
run_when_succeeded: [CreateSchema]
database: main
hide_data_on_success: true
query: |
TRUNCATE tasks RESTART IDENTITY;

- name: InsertTasks
run_when_succeeded: [ClearTable]
database: main
hide_data_on_success: true
query: |
INSERT INTO tasks (title, status) VALUES
('Ship the MCP launch post', 'open'),
('Review Q3 numbers', 'done'),
('Migrate data pipeline', 'open');

- name: SeedSummary
run_when_succeeded: [InsertTasks]
database: main
query: |
SELECT COUNT(*) AS tasks FROM tasks;
post_transforms:
- extract_value: "[0]"

tasks.yml

The two MCP tools (list_tasks, create_task) plus the token-gated discovery authorizer

name: McpQuickstart
description: The smallest useful MCP server — two tools over a Postgres table, guarded by one shared HS256 token. Every interface is both an HTTP route and an MCP tool; the only difference is the mcp block. Tool discovery (tools/list) is gated by the same token, so an unauthenticated client can't even enumerate the tools.

docs: true

# MCP QUICKSTART — one token, two tools, one table.
#
# The whole idea: an Air Pipe interface is an HTTP route. Add an `mcp:` block and
# the SAME interface is also an MCP tool. Air Pipe adds no MCP-specific auth — it
# 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 securing a route.
#
# Each tool reads top to bottom:
# 1. ValidateToken — verify the HS256 token against MCP_SECRET
# 2. CheckBody — validate the tool's inputs (this is what generates the
# MCP inputSchema and the OpenAPI request body — auth never
# appears in the tool's schema)
# 3. <the query> — run it
#
# DISCOVERY is gated too. `tools/call` runs your actions; `tools/list` does not —
# it only returns metadata. So each tool sets `list_authorizer: authorize-discovery`,
# an interface (not itself a tool) that re-runs the token check when a client lists
# tools. No valid token, no tools — not even the names. Requires engine >= 1.7.0;
# delete those lines to make discovery public.
#
# GET A TOKEN (once): https://jwt.io, algorithm HS256, secret = your MCP_SECRET,
# payload e.g. { "sub": "me", "exp": 9999999999 }. Paste it into your MCP client.
# Rotate MCP_SECRET to revoke everything.
#
# Required managed variables:
# DATABASE_URL — Postgres connection string
# MCP_SECRET — 32+ char HS256 secret for your access token

# SERVER IDENTITY — what a client sees before any tool runs.
#
# `mcp_servers` declares the server itself, separately from the tools that join it:
# `title` becomes `serverInfo.title` and `instructions` becomes the `instructions`
# field of the `initialize` response. That response is the ONLY place an MCP
# registry (mcp.so, Glama, Smithery, PulseMCP) can read a description from — without
# it a listing renders as a bare name and a wall of tool descriptions.
#
# `default: true` means every tool that names no server joins this one, and it is
# served on the bare endpoint: /mcp self-hosted, /<org>/<env>/mcp managed. Declare a
# second id and point tools at it with `mcp.server: <id>` to publish a second,
# separate endpoint at /mcp/<id> from the same deployment.
#
# The id is a URL segment: 1-64 characters of a-z, 0-9 or '-'. Only one server per
# org+environment may set `default: true`. Requires engine >= 1.38.0.
mcp_servers:
tasks:
title: Tasks
instructions: >-
A task list backed by Postgres. Use list_tasks to read tasks (newest first,
optionally filtered to "open" or "done") and create_task to add one. Both
tools require the bearer token issued by whoever deployed this server.
default: true

global:
databases:
main:
driver: postgres
conn_string: "a|ap_var::DATABASE_URL|"

interfaces:

# MCP tool: list_tasks · HTTP: POST /api/tasks
api/tasks:
output: http
method: POST
summary: List tasks
description: List every task, newest first. Optionally filter by status.
tags: [tasks]
mcp:
enabled: true
tool_name: list_tasks
description: List tasks, newest first. Optional status filter ("open" or "done").
list_authorizer: authorize-discovery
request_example:
status: open
response_example:
- id: 1
title: Ship the MCP launch post
status: open
created_at: "2026-07-01T12:00:00Z"

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

- 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 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 /api/tasks/create
api/tasks/create:
output: http
method: POST
summary: Create a task
description: Create a task. Status defaults to "open".
tags: [tasks]
mcp:
enabled: true
tool_name: create_task
description: Create a new task. Requires a title; status defaults to "open".
list_authorizer: authorize-discovery
request_example:
title: Draft the changelog
status: open
response_example:
id: 4
title: Draft the changelog
status: open
created_at: "2026-07-01T12:05:00Z"

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::MCP_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 tasks (title, status)
VALUES ($1, COALESCE($2, 'open'))
RETURNING id, title, status, created_at;
params:
- a|CheckBody::title|
- a|body::status->default(null)|
post_transforms:
- extract_value: "[0]"

# Discovery gate — an ordinary interface, NOT an MCP tool. Referenced by
# `list_authorizer` above; Air Pipe runs it with the caller's headers whenever a
# client calls tools/list, and shows the tool only if it returns 2xx.
#
# `response_on_success: { http_code: 200 }` is REQUIRED here. The discovery gate is
# fail-closed on anything that isn't an explicit 2xx, and an interface that never
# sets a status code leaves it unset — which reads as "not authorized" and hides
# every gated tool even for a valid token. Setting it explicitly opens the gate.
authorize-discovery:
output: http
method: POST
summary: Authorize MCP tool discovery for the caller's token (used by list_authorizer).
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::MCP_SECRET|
response_on_success:
http_code: 200

schema.sql

The one table, if you prefer to create it with psql

-- MCP Quickstart — the one table the two tools read and write.
-- Either run this directly (psql "$DATABASE_URL" -f schema.sql) or deploy
-- seed.yml and call POST /api/seed, which creates it and loads sample rows.

CREATE TABLE IF NOT EXISTS tasks (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'done')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks (created_at DESC);