自建 MCP 服务端:认证、冒充与删除端点的坑
给一套老系统接 MCP(Model Context Protocol),让 AI 客户端能直接读写业务数据,是这两年被问得很多的需求。真正做起来会发现:协议本身不难,难的是”人”——谁在操作、怎么证明是他、出了问题怎么追溯。
本文记录一次自建 MCP 服务端的完整过程,重点在认证设计上翻过的车。
一、先看现成机制能不能用
动手前先确认:系统自带的 API 机制能不能直接复用。
实测结论是不能。它自带的 entry token 校验只校验”这个入口是否开启”,不负责登录任何用户;而权限校验层要求请求方已经处于登录态。也就是说,它天然面向”浏览器里已登录的人”,不适合”拿着密钥调用 API 的机器”。
所以选了自己的路:在应用里加一个模块,用共享密钥做请求签名,零核心改动、零反向代理改动。
二、共享密钥 + 自称身份 = 可冒充
第一版的设计很常见:所有客户端共享一个密钥,请求里带一个 actor 字段声明”我是谁”,服务端验签通过就按这个 actor 记录操作人。
上线前做了一次自我攻击,结果很明显:
任何拿到密钥的机器,都可以用
actor=别人发请求,系统会把这次操作署名给对方。
更糟的是审计:日志里只有”某人做了什么”,没有”这台机器有没有资格代表他”。一旦出事,追溯不到源头。
这个漏洞的本质:把”认证”(你能进来)和”授权”(你能代表谁)混在了一起。共享密钥只解决了前者。
顺带否决了一个看起来更简单的方案——直接用系统账号密码:老系统的密码是无盐 MD5,且密码随请求走会进访问日志。等于用一个更糟的问题换一个糟问题。
三、”HMAC” 与实际实现不符
文档里写的是 HMAC 签名。读实现才发现是拼接式双层摘要:
1 | token = md5(md5(query_string) + secret) |
服务端把请求里的 token 去掉,用 http_build_query() 重建同样的串再比对(用常量时间比较)。
功能上是等效的(都能防篡改),但它有一个隐含约束:客户端必须复刻服务端的参数编码规则。实际接的时候踩到了:
- 空格要编码成
+而不是%20; - 十六进制要用大写;
~要编码成%7E(有些语言的 urlencode 不编码它)。
差一个字节签名就对不上,而且报错信息只会说”签名无效”。这类协议一定要用”字节级一致性测试”验收——拿同一份参数在客户端和服务端各算一次,比对结果,覆盖中文、空格、特殊字符。
另外两个设计取舍值得记:时间戳必须落在 ±300 秒窗口内(防重放),但没有 nonce——意味着窗口内仍可重放,只能靠业务状态约束兜底(比如删除接口对已删除对象返回”已删除”)。
四、每账号一把密钥
修法是:密钥和身份绑定。
- 每个账号有一把专属密钥,写在服务端的密钥映射里;
- 带
actor的写请求,必须用该账号的密钥签名,否则拒绝; - 读请求放宽(任意有效密钥即可);
- 没配置个人密钥时完全回退到旧行为,保证兼容;
- 审计日志记录密钥归属(是共享的还是某个人的)。
再往前走了一步,做成了单通道:彻底删掉共享密钥的兜底——写请求如果没有个人密钥就直接拒,读请求也必须命中某一把个人密钥。配套做一个自助页面,让每个用户自己生成/重置密钥。
这里有个产品细节:密钥页面必须能展示”完整的客户端配置”,否则用户拿到一串字符不知道往哪儿填。
五、删除端点:控制层会污染 JSON
加上删除能力后遇到一个很典型的问题:调用删除接口,返回的不是 JSON,而是一段 HTML/JS。
根因:业务系统的控制层删除方法普遍长这样:
1 | if (!js::confirm('确定删除吗?')) die(js::alert('已取消')); |
它面向的是”浏览器页面”,会直接输出 JS 并 die。MCP 是无会话的机器请求,拿到的就是这段残留输出——JSON 响应被污染,客户端解析失败。
修法:MCP 的删除端点直接调模型层,绕开控制层的交互逻辑。同时注意模型层里也可能有”无会话就退出”的权限检查(比如文档模块的 getById 内部会 checkPriv 并 die),要选不依赖会话的读取方式。
另一个坑:列表接口默认过滤已删除对象。所以”列表里查不到”不能当删除成功的判据,必须在返回体里显式带上 deleted 字段。
六、工具表是会话启动时定格的
新增 5 个删除工具、版本从 0.6.1 升到 0.6.2 之后,客户端里还是看不到新工具。
根因:客户端进程在会话启动时读取一次工具清单并缓存。已经开着的会话不会热更新,即使磁盘上的代码已经是新版。
处理:要么重启客户端,要么把这条配置移除再重新添加。文档里要写清楚这一点,否则用户会以为升级失败。
七、”关联提交”只认字面标记
做发布归档时想自动把提交关联到任务,结果一条都关联不上。
根因:底层用的是原生解析规则,它只认提交信息里形如 task #123 的字面标记。而这套仓库的提交信息全是中文散文式描述(”合并上游版本并解决冲突”)。
这类”看起来通用的能力其实有严格格式要求”的地方很多。两个选择:
- 改提交规范,以后都带标记(需要
commit-msg钩子,属于仓库改动); - 放弃自动关联,在任务下用评论记录提交哈希(可读性反而更好)。
我们选了后者,并把”提交信息要带标记”写进了约定文档,供以后新提交使用。
八、还有几个小细节
- 时间戳用服务端时间或客户端时间? 用客户端时间但要校验窗口,否则客户端时钟偏差会导致全量失败;
- 请求体要不要参与签名? 参与更安全(防篡改 body),但会让”复刻编码”更难——要明确写进协议;
- 密钥权限收紧到 600,并且
.env不进版本库; - 工具名要稳定:改名会让已配置的客户端全部失效。
总结
自建 MCP 服务端的难点集中在三处:
- 认证与授权必须分开——共享密钥只能证明”能进来”,证明不了”代表谁”;身份必须和密钥绑定。
- 复用业务层要小心副作用——面向浏览器的控制层会
die、会输出 HTML,机器接口要绕开它直连模型层。 - 协议类的”等效实现”要用字节级测试验收——编码规则差一个字节,签名就永远对不上。
最后一句话:**给 AI 开写权限之前,先把审计做对。**否则出了问题,你只能看到”某个操作发生了”,看不到”是谁、通过哪把钥匙做的”。