通过数据库+配置文件替代传统Node SDK方式,快速构建可接入 Claude Desktop 的 MCP 服务器,含 SSL 配置和 Neon/Supabase 部署指南。
大多数 MCP 教程都会给你一个 Node 项目。你安装一个 SDK、写一个工具处理器、接上 stdio,最后得到一个在你的笔记本上以你的身份、用你的凭证运行的程序,只服务于一个用户。
这用于演示没问题。但这不是你能交付给客户的东西。
还有另一种方式,从头到尾:一个数据库、一份配置文件、一个令牌、和一个你粘贴到 Claude 中的 URL。以下每一步都是针对真实 pack 的真实命令——没有省略,没有留给读者作为练习。
耗时:约 15 分钟。你需要:一个 Air Pipe 账户(免费额度足够)、一个 PostgreSQL 数据库、以及一个 MCP 客户端——Claude Desktop、Claude Code、Cursor,或任何支持 MCP 的客户端。
如果你已经有数据库,跳过这一步。如果还没有,下面这些都可以用,而且都有可用的免费额度:
你需要的是一条连接字符串:
postgresql://user:password@host:5432/dbname
本地 Postgres 可以用来跟着做,但你的托管 Air Pipe 实例无法访问 localhost——所以如果你想让工具在 Claude Desktop 中实时工作,请使用托管数据库,或者在本地数据库旁边自托管 Air Pipe 二进制文件。
关于 SSL:大多数托管提供商都要求启用 SSL。如果你的第一次查询失败并提示 SSL is required,在连接字符串后追加 ?sslmode=require。Neon 需要这个;Supabase 给你的字符串中已经包含了。
三张表。只有一张是你的数据:
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);
psql "$DATABASE_URL" -f schema.sql
pgcrypto 仅在 Postgres 12 及更早版本需要 gen_random_uuid()——从 13 版开始已内置,IF NOT EXISTS 使这一行无论如何都没有副作用。
插入一个租户和几行数据,以便有东西可看:
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');
在 Air Pipe 仪表板中,进入你环境的托管变量(或者如果你是自托管,则放在 ap_vars 中):
用生成的方式得到密钥,而不是手动输入——这是互联网和你的数据库之间的唯一屏障:
openssl rand -base64 48
两者都作为 a|ap_var::NAME| 在配置中被引用,所以它们永远不会出现在你提交的文件中。
这是完整的配置。一个文件,两个工具。
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]"
有五点值得注意:
mcp_servers 是服务器;mcp: 块是工具。顶部的声明是客户端和注册表在任何工具运行之前看到的内容——在步骤 8 之后会有更多说明。删掉它一切仍然正常工作,只是匿名状态。
mcp: 块是让它成为工具的唯一因素。删掉它,你有一个普通的 HTTP 路由。保留它,你同时拥有两者——相同的认证、相同的查询、相同的追踪,一条定义。
认证并非 MCP 特有。Air Pipe 接收客户端的 Authorization: Bearer 令牌,将其作为 airpipe-jwt 头转发到接口,并执行与 HTTP 请求相同的操作。保护 MCP 工具和安全保护路由是完全一样的。一套模式,不用学两套。
CheckBody 是 AI 看到的内容。MCP 的 inputSchema 是从那些 assert 测试中生成的——这就是为什么每个都带有 description:。为不是你的读者而写,因为模型通过阅读这些来选择工具。is_not_null: false 是一个始终通过的谓词:它声明字段为可选,而不是要求它。而且因为只有 CheckBody 读取 a|body|,令牌永远不会泄露到工具的 schema 中。
参数是绑定的,而不是插值的。$1, $2 配合 params: 列表——所以一个标题为 '); DROP TABLE mcp_tasks; -- 的任务就是一个任务标题。
无需构建,无需托管。
在托管的 Air Pipe 上,将文件粘贴到仪表板编辑器并点击部署——这会在内部进行验证。如果你是从自己的 AI 客户端使用 Air Pipe MCP 工具,"validate and deploy this config" 可以从聊天中完成同样的事,而安装 pack(见下文)则无需上述两者。
自托管只需一条命令——让二进制文件指向存放该文件的目录:
airpipe server --config-dir . --api-key <your-key>
默认在 4111 端口提供服务,因此后续步骤中的 URL 是 http://localhost:4111/… 运行一次 airpipe login 之后就可以去掉 --api-key。
第 6 步——生成令牌
一次性操作,在 jwt.io 上操作:算法选 HS256,secret 填你的 SOLO_SECRET,payload:
{ "sub": "me", "exp": 1798761600 }
复制该令牌。轮换 SOLO_SECRET 会使其失效。
更喜欢命令行的话:
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
第 7 步——在触碰客户端之前先验证
通过 MCP 客户端调试是件痛苦的事——失败只会显示"工具没生效"。先用 curl 检查。MCP 是建立在 HTTP 之上的 JSON-RPC,所以你可以直接驱动它:
BASE=https://your-airpipe-host/<org>/<env> # 自托管:无 /<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'
# → "list_tasks"
# → "create_task"
# 调用其中一个
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"}}}'
# 同一个工具走纯 HTTP —— 注意头部名称变了
curl -sX POST $BASE/solo/tasks \
-H "airpipe-jwt: $TOKEN" \
-H 'content-type: application/json' \
-d '{"status":"open"}' | jq '.data.ListTasks.data'
如果 tools/list 返回了你的两个工具且 tools/call 返回了一行数据,那就搞定了——之后的所有内容都是客户端配置。
有两个值得命名的失败场景,因为它们很常见:
401 Invalid or missing token —— 用于签名的 secret 与 SOLO_SECRET 不匹配,或者 exp 已过期。先在 jwt.io 上解码令牌检查过期时间;通常就是这个问题。
A database error on the query action —— 连接字符串无法从你的 Air Pipe 实例访问。localhost 是常见的罪魁祸首,SSL 是另一个。
第 8 步——让 Claude 指向它
{
"mcpServers": {
"my-tasks": {
"url": "https://your-airpipe-host/<org>/<env>/mcp",
"headers": { "Authorization": "Bearer <your-token>" }
}
}
}
Claude Desktop 在 macOS 上将配置保存在 ~/Library/Application Support/Claude/claude_desktop_config.json,Windows 上是 %APPDATA%\Claude\claude_desktop_config.json。Claude Code:claude mcp add --transport http my-tasks https://your-airpipe-host/<org>/<env>/mcp --header "Authorization: Bearer <token>"。
重启客户端。问"What's on my task list?"它就会查询你的数据库。
仅凭同一个文件,你还可以免费获得:不支持 MCP 的客户端可用的 HTTP 端点、OpenAPI 文档、Prometheus 指标,以及每次工具调用的 OpenTelemetry 追踪——显示哪个 action 执行了、查询耗时多久。最后一项比听起来更重要——当模型调用一个工具却得到令人困惑的答案时,追踪记录能帮你判断是工具错了还是模型错了。
给服务器起个名字,而不只是工具名
每个客户端在列出内容之前都会调用 initialize,而该响应正是服务器自我介绍的地方。不写的话,你的服务器会用内置名称且没有描述——列表里只是一行裸标签,下面堆满了工具描述。这就是配置顶部 mcp_servers 解决的问题:
mcp_servers:
tasks:
title: Tasks # -> serverInfo.title,客户端 UI 中的名称
instructions: >- # -> initialize 结果的 `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 # 采用所有未指定服务器的工具
instructions 比看起来更重要。MCP 注册中心——mcp.so、Glama、Smithery、PulseMCP——会直接从该字段读取远程服务器的列表描述。没有其他地方可以写这个描述,所以未填写的描述不是某个地方的空白字段——而是无人点击的列表项。
用和检查工具一样的方式验证:
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}'
声明服务器和为它贡献工具是刻意分开的。MCP 服务器是一个命名的工具组,而不是某个配置的属性:任何配置中的工具通过 id 加入一个服务器,所以一个身份可以覆盖散布在多个文件中的工具——这也意味着声明可以独立存在于自己的配置中(interfaces: {}),无论你下次重命名哪个工具文件,它都能存活。
id 是一个路由段,因此第二次声明就是同一部署的第二个端点——比如一个公开服务器和一个内部服务器:
mcp_servers:
tasks: # 服务于 /mcp
title: Tasks
default: true
tasks-admin: # 服务于 /mcp/tasks-admin
title: Tasks (admin)
mcp:
enabled: true
tool_name: purge_tasks
server: tasks-admin # 仅在命名端点上发布
id 由 1–64 个字符组成,只能是 a-z、0-9 或 -,只能有一个服务器是默认的,且一个工具指定的服务器如果未被声明则不会发布在任何服务器上——而是发布失败。需要 engine ≥ 1.38.0。
大多数 MCP 服务器都有的漏洞
无论你用它来构建什么,都值得了解一下:tools/call 运行你的代码,tools/list 不会。
列出工具返回的是元数据——名称、描述、输入模式。你放在处理器内部的任何 auth 在发现阶段都不会触发。所以一个调用被锁定的服务器,仍然允许任何知道 URL 的人枚举你暴露的每个工具及其完整模式。他们无法调用任何东西,但能读到地图。
对于个人服务器,无所谓。对于你提供给客户的端点来说,这个目录往往才是敏感的部分——你的工具名称是对产品的描述。
加一行配置就能堵上它,每个工具指向一个在客户端列出工具时重新运行令牌检查的接口:
mcp:
enabled: true
tool_name: list_tasks
list_authorizer: authorize-discovery
而门本身——一个普通的接口,不是工具:
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
现在未认证的 tools/list 返回 {"result":{"tools":[]}}——连名称都不会暴露。
response_on_success: { http_code: 200 } 是必需的。门在任何非明确 2xx 的情况下是 fail-closed 的,而一个 action 都成功的接口不会设置状态码——这会被解读为"未授权",即使拿着有效令牌也会隐藏每个被门保护的工具。如果你的工具在添加门之后消失了,这就是原因。
需要 engine ≥ 1.7.0。删掉 list_authorizer: 行可以让发现变成公开的。
现在才是重要的部分:你的客户
以上都是一枚令牌、一个授权——持有它的人看到所有行。对让 AI 指向你自己的数据库来说是对的。一旦有了用户,这就毫无用处了。
多租户形态使用同样的配置,但令牌要做更多的事。你的后端已经知道谁登录了,所以它签发一枚携带 tenant_id 的 per-user 令牌:
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')
然后每次查询都改为用令牌中的 claim 来限定范围,而不是硬编码的 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)|
来自另一个租户的行无法匹配。跨租户访问在结构上就是不可能的,而非仅仅是被禁止的——没有任何代码路径会因忘记 WHERE 子句而泄露客户数据,因为过滤器本身就是查询。一个端点,所有客户,每个人只能看到自己的数据。
撤销,这是无状态的 JWT 单独无法完成的事
签名校验无法区分已撤销的令牌和有效令牌——这正是 mcp_tokens 表存在的意义。每个工具都会重新检查令牌的 jti 是否在其中:
- 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
撤销是一次调用,而不是 SSH 会话:
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>"}'
下一次调用就会被拒绝:HTTP 路由返回 401 Token revoked or expired,MCP 上的工具也返回错误结果。这是naive JWT 配置会忘记的那一块。
已经在用 Auth0、Clerk 或 Cognito?
完全跳过 exchange 这一环。将 is_valid_jwt 指向你的 provider 的 JWKS,直接验证他们的 RS256 令牌:
- 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 获取并缓存密钥,根据令牌的 kid 选择签名者,并强制执行 iss / aud / exp。Provider 的密钥轮换自动生效。在你的 IdP 中添加 tenant_id 声明,上文的租户作用域配置保持不变。需要 engine ≥ 0.196.0。
本页的所有内容作为一个 pack 交付,两个 tier,端到端测试——schema、seed 端点、单令牌工具、租户作用域工具、discovery gate、令牌生命周期路由,以及 OIDC 变体。Fork 它,设置两个变量,部署。
如果你只想要步骤 1 到 8——一个令牌、你自己的数据库、无租户——请改用 MCP Quickstart。核心思想相同,但精简到两个工具、一张表,discovery 已受门控。从这里开始,等有了客户再升级;配置形状不会变。
你完全可以用 TypeScript SDK 手撸这一切。但你也需要手撸 auth、租户作用域、discovery gate、撤销黑名单、traces,以及那些不支持 MCP 的客户端的并行 REST API。这就是trade-off。
已知限制,以免你踩坑
令牌是长生命周期的 bearer。MCP 客户端目前使用粘贴到配置中的静态 bearer 进行身份验证——还没有交互式 OAuth 流程。保持 exp 短,并依靠黑名单进行撤销。
Postgres 上每个 action 一个语句——驱动会准备查询,而准备好的语句只能容纳一条命令。对多语句 DDL 块使用 multi: true(engine ≥ 0.196.0)。
HTTP 响应被包装在 {"data":{"<Action>":{"data": …}}} action trace 中,这就是为什么 curl 示例通过 jq 管道传输。MCP 客户端会为你解析工具结果。
你现在的 MCP 服务器上 tools/list 是开放的吗?值得检查一下。
将你的 Postgres 数据转换为安全的 MCP 工具,任何 AI 客户端(Claude Desktop、Claude Code、Cursor)都可以调用。
最小的有用 MCP 服务器:两个工具、一张 Postgres 表、一个共享令牌、一个配置文件。指向 Claude Desktop、Claude Code、Cursor 或任何 MCP 客户端的数据库,无需 SDK、无需 Node 项目、无需托管。Air Pipe 接口是一个 HTTP 路由;添加一个 mcp 块,相同的接口也是一个 MCP 工具,由配置中相同的令牌检查保护。工具发现(tools/list)通过 list_authorizer 由同一令牌保护,因此未身份验证的客户端甚至无法枚举你的工具或其输入 schema。包括一个 seed 端点,用一次 curl 即可创建表和示例数据。