Bridgekit 实现基于客户端密钥的 MCP 工具细粒度权限控制,每次调用(无论通过还是拒绝)均记录到只追加审计日志。
我见过的大多数 MCP 服务器,都会把自己知道的所有工具一股脑暴露给每一个连接的客户端。在你自己的笔记本上这么干没问题。但一旦同一个服务器要夹在公司的真实账号(Shopify、分析平台、Postgres 数据库)和多个不同的 AI 客户端之间——其中一个是只读的分析师助手,另一个是有写入权限的运维机器人——这就成了问题。
协议只给了你 tools/list 和 tools/call。它没有告诉你谁能看到什么,也没有告诉你谁允许做什么。如果把服务器接得简单粗暴,tools/list 会把完整菜单全部返回,对面的模型看到写入工具就会欢快地尝试去调它。Bridgekit 就是我的答案:这是一个带作用域的 MCP 服务器,每个客户端密钥都携带自己的权限边界,写操作独立于读操作受控,每一次调用(无论允许还是拒绝)都会落入只追加的审计日志。
核心思路:作用域依附于密钥,而非工具
客户端配置为一个 JSON 密钥。每个密钥映射到一个名称、它可以使用的确切工具列表,以及是否允许写入:
{
"bk_live_demo123": {
"name": "growth-os",
"tools": ["shopify_orders", "triplewhale_metrics", "db_query"],
"allowWrite": false
}
}
调用方把密钥作为 Authorization: Bearer <key> 或 x-bridgekit-key 请求头提交。每个请求必须在其他任何事发生之前先解析到一个已知的客户端。如果密钥缺失或未知,请求永远到不了工具层,而是以一个带 401 的 JSON-RPC 错误返回。
重要的一点是:作用域是调用方的属性,而不是服务器上的全局设置。两个客户端访问同一个 /mcp 端点,看到的是两个不同的世界。
传输层走的是基于 Streamable HTTP 的 MCP:客户端向 /mcp POST JSON-RPC 2.0 请求,服务器实现 initialize、tools/list、tools/call 和 ping。整个服务以一个 Cloudflare Worker 的形式运行。
作用域第一次发挥作用是在发现阶段。tools/list 返回的不是工具目录,而是"存在的工具"、"此客户端被授权的工具"以及(对于写工具)"此客户端是否允许写入"三者的交集:
const visible = TOOLS.filter(
(t) =>
caller.config.tools.includes(t.name) &&
(!t.write || caller.config.allowWrite),
).map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
}));
只读客户端根本看不到写工具的存在。这很关键,因为模型看不到的工具,模型就不会尝试去调用。
第二个发挥作用的地方是执行,即 tools/call。列表过滤只是体验上的便利;它本身不是安全机制,因为客户端仍然可以直接指定工具名。所以在调用路径上会从头重新检查一切,并把决策记录下来:
if (!caller.config.tools.includes(name)) {
await audit(env, caller, {
tool: name,
decision: "denied",
reason: "not in client scope",
});
return rpcOk(id, toolError(`tool "${name}" not allowed for this client`));
}
if (tool.write && !caller.config.allowWrite) {
await audit(env, caller, {
tool: name,
decision: "denied",
reason: "write scope required",
args,
});
return rpcOk(id, toolError(`tool "${name}" is a write action; client lacks write scope`));
}
当前构建中有四个工具:shopify_orders、triplewhale_metrics 和 db_query 是读操作,shopify_tag_order 是唯一的写操作。db_query 读取的是 Postgres 中一份白名单表,而不是接受任意 SQL,所以作用域在工具内部再次收窄。
一个刻意的设计选择:拒绝的写操作不会以传输层错误的形式爆炸式报错。它会作为 MCP 工具结果返回,其中 isError: true。这遵循了协议惯例——工具级别的失败是可读的结果,而非连接故障——所以对面的模型能够实际读到"你缺少写作用域"并做出反应,而不是看到一个不透明的崩溃。
上述每个分支在返回之前都会调用 audit()。允许的路径在工具运行后记录日志;拒绝的路径则记录它们被拒绝的原因。条目携带客户端名称、密钥的不可逆短标签(前八位字符、一个省略号、后两位,所以原始密钥永远不会进入日志)、工具名称、决策结果、可选的拒绝原因以及截断后的参数。这些条目通过 PostgREST 写入 bk_audit 表。
有两个细节是我在意的。第一,日志写入失败会被吞掉。如果审计接收端宕了,工具调用依然正常返回;可观测性永远不应该把实际产品搞挂,失败会转而出现在 Worker 日志中。第二,参数在存储前会被截断(上限 2000 字符),所以巨大的 payload 不会撑爆一行,而且代码路径设计上也会把密钥排除在日志之外。
一个坦诚的局限性
作用域是粗粒度的。一个客户端要么拥有某个工具,要么没有;它要么可以写入,要么不能。没有行级或字段级的策略,没有按客户端的速率限制,也没有按工具的写批准;写权限对于整个客户端来说只是一个布尔值。对于构建这个系统所面向的 Shopify 和分析工作流来说,工具级加上读/写分离已经覆盖了真实场景。但如果需要"此客户端只能标记 500 美元以下的订单",这个逻辑目前还不存在,你得手动把它推进连接器里。Bridgekit 强制执行的边界是哪个工具和哪个方向,而不是哪些值。
还有一个 demo 形状的边缘情况:审计日志被裁剪到最新的行以保持有界,所以它是一条实时轨迹,而非长期保留。在真实部署中你会去掉裁剪并把它指向持久化存储。
我想要的东西平凡而具体:给 AI 客户端真正的工具,但不给它钥匙,并且能够在事后回答"谁调用了什么,我们是否允许了"。作用域依附于密钥,发现和执行都尊重它,没有任何东西能在不留记录的情况下运行。这就是整个产品。
代码和完整的工具列表在这里:https://github.com/AgentPostmortem/Bridgekit