写给 AI Agent 的 MCP Server API 契约
Model Context Protocol 悄悄把每个内部脚本、数据库查询和管理操作都变成了 API。而这个 API 的客户端不是读文档的开发者,是一个在运行时读取工具描述、自行决定调用什么和传什么参数的语言模型。这个客户端和所有 API 消费者一样,值得拥有一份契约——而且缺了契约时,它施加的惩罚比任何人类集成者都来得快。
MCP 工具就是一个客户端具有概率性的 API
人类开发者集成你的 API 时,歧义会被他们的判断力吸收:文档读两遍、在 staging 里先试、遇到莫名其妙的 409 就发邮件问你。agent 一样也不做。它在上下文窗口里读到工具的名称、描述和输入 schema,形成一个"这个工具是干什么的"的信念,然后立刻据此行动——在生产环境里,可能一小时几百次。
这改变了规格的经济学。面向人的 API 里每处歧义,成本是一条支持工单;面向 agent 的工具里每处歧义,成本是 agent 对它的任意解读,乘以走到同一个岔路口的每一个会话。解法不在客户端提示词——那是你唯一控制不了的东西。解法在服务端契约:在任何 agent 依赖它们之前,用一份规格钉死输入、输出、错误和副作用。
当消费者是 agent 时,什么变了
| 契约要素 | 人类开发者客户端 | AI agent 客户端 |
|---|---|---|
| 文档 | 集成时读一次 | 每个会话从 schema 重新读;描述就是全部文档 |
| 校验错误 | 开发阶段就修掉 | 必须点名失败字段,agent 才能自我纠正 |
| 重试 | 写代码时慎重决定一次 | 凡是看起来短暂的错误都会被重试;幂等是生存问题 |
| 破坏性操作 | 靠开发者的谨慎守着 | 只有契约打的标和客户端的强制在守着 |
| 破坏性变更 | changelog 里公告 | 静默出错,直到有人发现 agent 行为变差 |
这些都不新奇。它就是一份好 OpenAPI 规格本来就要求的严谨,只是要假设读者拥有无限耐心、除 schema 外零上下文、且没有能力提出澄清问题。设计层面的完整讨论见面向 agentic 客户端的 API 设计;本文聚焦契约这份 artifact 本身。
MCP server 的契约包
动手实现之前,为每个 server 写一份规格文档,包含六节:每个工具一行用途说明的工具清单、每个工具的输入 schema、每个工具的输出契约、共享的错误分类、副作用分类,以及版本策略。API 规格生成器可以从一段自然语言描述生成这个骨架,API 规格模板是可以直接复制的文件版本。
工具清单这一节的价值,在于逼团队做一个总被跳过的决定:哪些能力不做成工具。一个暴露二十个细粒度工具的 server,等于把编排逻辑推进 agent 的上下文窗口,让它在每个会话里重新推导一遍,而且推得很差。大多数 server 更好的做法是:暴露更少、按任务形状设计、契约严格的工具,而不是把内部 API 一比一搬过来。
输入 schema:用约束,不要用描述
JSON Schema 是契约的强制层,agent 对它的遵守比对散文可靠得多。凡是能在 schema 里表达的约束,就在 schema 里表达:用枚举代替自由文本字符串,给标识符加 format 和 pattern,写明默认值,required 字段控制在真正的最小集。description 字段承载 schema 表达不了的语义:单位、时区假设、可选字段省略时会发生什么。
"amount_cents": {
"type": "integer",
"minimum": 1,
"maximum": 500000,
"description": "退款金额,单位为分。不得超过发票
剩余可退余额;超额请求会被
REFUND_EXCEEDS_BALANCE 拒绝。"
}
反模式是一个 string 类型的字段,配一大段散文解释接受哪些值。agent 迟早会发来你的解析器没料到的那个变体。值可枚举就枚举,有格式就写 pattern,某种组合非法就同时在 description 里说明并且用命名错误拒绝它。
输出契约:agent 是解析,不是浏览
一个返回友好散文段落的 MCP 工具,等于强迫每个消费它的 agent 去解析英文。这个解析是概率操作,总会在某处出错。输出契约应该承诺结构化内容:稳定的字段名、机器可读的状态、可以直接喂给后续调用的标识符。散文只属于一个指定的 summary 字段,不属于承重结构。
{
"status": "refunded",
"refund_id": "rf_8104",
"amount_cents": 4200,
"invoice_state": "closed",
"summary": "已对发票 inv_2231 退款 $42.00。"
}
对输出字段要用公开 API 响应同等的向后兼容纪律。把 refund_id 改名成 refundId 是破坏性变更,尽管没有编译器会报错——消费它的 agent 只会在需要这个标识符的那一步开始静默失败。
agent 能据此行动的错误分类
错误是面向 agent 的契约和习惯做法分歧最大的地方。人读到"出错了,请稍后再试"会运用判断力;agent 需要判断力被编码进去:server 能返回的每个错误,都应该精确映射到一种恢复动作。完整方法见 API 错误分类指南;简版是一小组稳定错误码,按"调用方接下来该做什么"分类。
| 类别 | 示例错误码 | agent 动作 |
|---|---|---|
| 短暂故障 | UPSTREAM_TIMEOUT | 退避重试,使用相同幂等键 |
| 非法输入 | INVALID_FIELD: amount_cents | 修正点名字段,重试一次 |
| 状态冲突 | REFUND_EXCEEDS_BALANCE | 重新读取状态、重新规划,不要盲目重试 |
| 权限 | ROLE_FORBIDDEN | 停止并上报给用户 |
| 不存在 | INVOICE_NOT_FOUND | 先用读工具核实标识符 |
两条规则让分类保持诚实。错误码只增不改:agent 和它们的提示词会围绕错误码固化,改名就是破坏性变更。每个校验错误都在结构化 detail 里点名失败字段,因为一句"invalid request"会把 agent 送进猜测重试循环——在你的日志里,那看起来和攻击一模一样。
副作用:你不打标,agent 就会自己发现
契约的副作用一节把每个工具按三个维度分类:只读还是变更、幂等还是非幂等、可逆还是破坏性。MCP 为此提供了原生注解——readOnlyHint、destructiveHint、idempotentHint——你的规格应该有意识地决定它们的值,而不是放任默认。客户端应用靠这些提示决定何时向人类请求确认;标错的工具会继承标签所承诺的全部信任。
任何可能被 agent 重试的变更工具都需要幂等,契约要写明实现方式:一个让重复调用返回原始结果的 idempotency_key 输入,是经得起真实 agent 行为考验的模式。对真正破坏性的操作——删除、不可撤回的发送、资金移动——最强的契约条款是两步结构:dry_run 预览模式返回"将会发生什么",执行模式要求预览签发的 token。这把"agent 删错了东西"从事故变成了一次被拒绝的调用。
实战示例:退款工具的契约
tool: refund_invoice
用途: 对失败或有争议的发票退款,仅一次
输入: invoice_id (pattern inv_*), amount_cents (1..500000),
idempotency_key (uuid, 必填), dry_run (bool, 默认 true)
输出: status, refund_id, amount_cents, invoice_state, summary
错误: INVALID_FIELD, INVOICE_NOT_FOUND, REFUND_EXCEEDS_BALANCE,
ROLE_FORBIDDEN, UPSTREAM_TIMEOUT
副作用: 变更、按键幂等、破坏性
(标记: readOnlyHint=false, destructiveHint=true,
idempotentHint=true)
策略: dry_run=true 返回预览 + confirm_token;
执行需要 10 分钟内预览签发的 confirm_token
版本: v2(v1 金额单位为美元;2026-06 移除)
每一行都在回答一个 agent 否则只能靠猜的问题。版本那一行不是装饰:v1 从美元到分的变化,正是 agent 客户端既检测不到也活不过的那种静默语义漂移。
版本管理与评审门禁
MCP server 因为小所以迭代快,这也意味着契约漂移得快。两条流程规则能控制漂移。第一,工具的 schema 和描述作为可评审的 artifact 放进仓库,任何改动都要走和 OpenAPI 变更一样的 schema diff 评审:破坏了什么、谁在消费、迁移路径是什么。第二,schema 表达不了的语义变化——默认值翻转、单位改变、错误码含义变化——必须升版本号并在契约里留一行带日期的记录,因为没有任何 diff 工具会替你发现它们。
关于面向 agent 的 API,有一个不太舒服的事实:你真正的接口不是代码,而是你的契约在语言模型里诱导出的信念。先写契约,至少那些信念是你选择的。