给 Agent 写 MCP 工具,真正需要定下来的是四件事:工具描述里放什么、参数 schema 敢写到多复杂、一次调用返回多少内容、以及出错时怎么让模型自己纠正。前两件决定模型会不会挑错工具、填错参数,后两件决定这个 Agent 跑到第十轮时还有没有上下文可用。MCP 规范当前版本是 2026-07-28(2026 年 7 月 28 日正式发布),这一版把 inputSchema 从「TypeScript 类型上只声明 type/properties/required 三个字段」放开成了完整的 JSON Schema 2020-12。但放开的是协议,不是模型——真正消费这份 schema 的工具调用实现,支持的子集比规范窄得多。

规范放开了,模型侧没有

SEP-2106Tool.inputSchema 的类型从

inputSchema: { type: "object"; properties?: {...}; required?: string[] }

改成了 { $schema?: string; type: "object"; [key: string]: unknown }。根节点仍然必须type: "object",但除此之外,组合关键字(oneOf/anyOf/allOf/not)、条件关键字(if/then/else)、引用关键字($ref/$defs/$anchor)现在都是合法的。规范的动机很直白:「按 ID 查」和「按名字查」二选一这种极常见的 API 形态,此前根本没法表达。outputSchema 更宽松——它可以是任意合法的 2020-12 schema,不限于对象;对应地 structuredContent 的类型是 unknown,规范明确允许「任意 JSON 值(对象、数组、字符串、数字、布尔、null)」。

问题在于,这份 schema 最终要被客户端翻译成模型厂商的工具定义格式。以 Anthropic 的 strict tool use 为例,它明确不支持递归 schema、数值约束(minimum/maximum/multipleOf)、字符串长度约束(minLength/maxLength),以及 minItems 为 0 或 1 之外的数组约束,并且要求对象上必须写 additionalProperties: false(见 Anthropic JSON Schema 限制文档)。SEP-2106 自己也点了这个兼容性风险:旧客户端只有在服务端返回对象类型structuredContent 时才安全,数组和标量可能直接把它们打挂。

结论是:把约束写进 schema,但不要把正确性押在 schema 上。 两种模式各有各的漏法:开了 strict,写上 minimum 这类不受支持的关键字,请求直接 400 被拒;不开 strict,约束会原样跟着工具定义进上下文,但没有任何机制去兑现它。两种情况下模型都照样能给你传 -3。服务端仍然要自己做完整校验——规范在安全条款里也是这么要求的(Servers MUST validate all tool inputs)。schema 里的约束是给模型看的提示,不是给你省的那行 if

$ref 现在合法了,但别指望它跨网络

既然 $ref/$defs 成了合法关键字,很自然会想把公共类型抽出去复用。规范在这件事上划了硬线:JSON Schema 2020-12 允许 $ref 指向绝对 URI,但实现 MUST NOT 自动解引用解析到网络 URI 的 $ref。可以提供 opt-in 模式去拉取,但必须默认关闭,并且应当强制主机白名单、至少拒绝 loopback / link-local / 私有网段,加超时和大小限制,并记录每个被解引用的 URI。因为外部 $ref 无法解析而校验失败的 schema,应当直接拒绝,而不是当成宽松处理

动机很明确:不受限的 $ref 会把每个 MCP 客户端变成 SSRF 跳板(服务端这一侧还有哪些同类出口需要收口,见MCP Server 安全检查清单)。所以 $defs 内部引用随便用,跨文档 $ref 当它不存在。

同一节还有条容易忽略的性能约束:组合关键字和 $defs 表达力强但校验代价高,实现应当设置合理上限——最大嵌套深度、子 schema 总数上限,或单次校验的时间预算——以防恶意 schema 变成针对校验器的 DoS 向量。所以你那个七层嵌套的 allOf + if/then/else,可能在某个客户端上直接被判超限拒掉。又一个「能写不代表能用」。

名字:两套规则打架

MCP 规范说工具名 SHOULD 在 1–128 字符之间,允许字母、数字、下划线、连字符和点号,官方示例里就有 admin.tools.list。而 Anthropic 的 API 要求工具名 MUST 匹配 ^[a-zA-Z0-9_-]{1,64}$——点号非法,长度砍半。

更麻烦的是,规范说工具名唯一性只在单个 server 内成立,聚合多个 server 的客户端应当做消歧,通常是加 server 前缀。那点前缀还要从 64 个字符里扣。

实践规则:别用点号,名字控制在 50 字符以内,用下划线做服务前缀github_list_prsslack_send_message)。Anthropic 文档也独立推荐了这种命名法,理由是工具库变大后选择更明确,而且一次搜索能匹配整组。

工具描述:模型唯一的路由依据

Anthropic 的文档把话说得很死:详尽的描述「是工具表现最重要的单一因素」,建议每个工具至少写 3–4 句,复杂的更多。要写清四件事:这个工具做什么、什么时候该用、什么时候不该用、每个参数是什么含义、以及有什么重要限制——特别是「它不返回什么」。

最后这条最常被跳过,而且是承重的。模型如果不知道 get_stock_price 只返回价格,它会带着「顺便看看市值」的期待去调,拿到一个数字,然后把其余部分编出来。

比「做什么」更值钱的是「什么时候调」。文档要求描述里写清「什么时候该用、什么时候不该用」,那就把触发条件直接落成一句话(「当用户询问当前价格或近期事件时调用本工具」),而不是只描述功能。

但别为此加重语气。近几代模型对指令的响应比以前更强而不是更弱:Anthropic 的提示工程文档说,那些当年为对抗模型「懒」而写的提示,在 Opus 4.5 / 4.6 上会造成过度触发,修法是把 CRITICAL: 你必须使用本工具…… 降回 使用本工具的时机是……。文档原话是,以前欠触发的工具现在多半会正常触发。所以工具误触发时,正确的修法几乎总是把语气降回陈述句,而不是再加一条护栏。

不该往描述里放的东西也值得单列一句:完整对话示例、编号工作流、HEREDOC 协议。这些每次请求都要付 token,而且会把模型的探索空间钉死。要教模型复杂输入的形状,用厂商侧字段(Anthropic 的 input_examples,简单示例约 20–50 token,嵌套复杂对象约 100–200 token),或者把说明挪到 prompt / skill 层。

工具粒度:按人的任务切,不按 API 端点切

最常见的错误是一个 REST 端点映射一个工具。Anthropic 工程博客给的反例很典型:与其提供 list_userslist_eventscreate_event 让模型自己串,不如提供一个 schedule_event——它内部查空闲时段并直接建日程。原则是按人类会怎么切分任务来切分工具,因为每一次中间调用的结果都要先落进上下文。

官方文档还建议把相关操作合并成带 action 参数的单个工具(create_pr/review_pr/merge_pr → 一个工具),理由是减少选择歧义。这里有个张力值得点破:合并降低选择成本,但会把 schema 推向 oneOfif/then/else——而那正是模型侧支持最差的部分。可行的平衡点是:动作合并,参数保持扁平——用 action 枚举加一组可选字段,服务端按 action 分别校验,而不是写一个漂亮的判别联合类型然后被下游压平或拒绝。

粒度还有硬边界。Anthropic 的 tool search 文档给了具体数字:模型的工具选择准确率在超过 30–50 个工具后开始下降;一套典型的多 server 组合(GitHub、Slack、Sentry、Grafana、Splunk)光工具定义就要吃掉约 55k token。切换到按需加载的阈值也很明确——工具数 ≥ 10 或定义总量 > 10k token;反过来,工具少于 10 个、定义总量不到 100 token 时,标准的全量加载反而更合适。

tools/list 的排序,正在悄悄花你的钱

2026-07-28 给列表响应加了 ttlMscacheScope(SEP-2549)。ttlMs 是毫秒整数,语义「类比 HTTP Cache-Control: max-age」:0 表示立即过期,缺失按 0 处理,负值忽略并当作 0,服务端 MUST 提供 >= 0 的值。规范特别说明这是新鲜度提示而非保证,客户端 SHOULD NOT 把 TTL 当轮询间隔(这套「提示而非保证」的新鲜度语义和 HTTP 缓存一脉相承,取值边界见API 的 ETag 与条件请求)。

但对工具设计影响更大的是旁边那条:服务端 SHOULD 以确定性顺序返回工具,原文给的理由是双重的——让客户端可靠缓存工具列表,并且提升工具进入模型上下文时的 prompt cache 命中率。工具定义渲染在 prompt 前缀的最前面。你的 tools/list 如果遍历哈希表、顺序在请求间抖动,下游整个 prompt 前缀的缓存就作废了,system prompt 和全部对话历史一起重算。按名字排序,成本为零。

cacheScope 只有 publicprivate 两个值,安全说明值得读两遍:public 的语义是任何客户端、共享网关或缓存代理都可以存下来并服务给任何用户——包括跨授权上下文,即使它来自一个需要认证的端点。而规范允许工具集合随请求携带的凭据变化(凭据是每请求输入,不是连接状态)。所以按用户权限过滤过的工具列表标成 public,就是直接的越权泄漏。过滤过的列表一律 private,而且分页时所有页的 cacheScope 必须一致。

返回体与上下文预算

这是 Agent 真正死掉的地方。几个实测锚点:同一份 Slack 数据,简洁格式约 72 token,详细格式约 206 token,差不多三倍;Claude Code 默认把工具响应截断在 25,000 token;Managed Agents 侧则是超过 10 万字符(约 25,000 token) 自动落盘成文件,只给模型一段预览加文件路径。

由此得出的几条:

  • 默认精简,让模型自己要详细。 一个 response_format: "concise" | "detailed" 枚举、默认 concise,是成本最低的杠杆。
  • 分页和 limit 要有合理默认值,别指望模型每次都记得填。
  • 返回语义稳定的标识符(slug、UUID),不要返回内部自增 ID 或不透明句柄——模型得拿它推理下一步。
  • structuredContentcontent 都要给。 声明了 outputSchema必须返回符合它的 structuredContent;同时为向后兼容,应当把序列化后的 JSON 也放一份进 TextContent。这意味着同一份数据在上下文里躺了两遍——更该控制体量。

无状态内核还带来一个相关设计面。协议层已经没有 session 了,跨调用状态必须走显式句柄:由创建工具返回,作为普通参数传回。规范给的(非规范性但很实用的)要点是:句柄要不透明(有结构的句柄招人解析和猜测)、每次调用都要重新校验调用方对该句柄的授权(「句柄是名字,不是能力」),并且把保留期写进创建工具的 description 里(例如「购物车 24 小时无操作后过期」),好让模型在决定要不要建状态时就能看到。

缺参数时,不一定要让模型猜

多轮请求机制(MRTR,SEP-2322)给参数设计开了第三条路。以前只有两个选择:标成 required(模型没有信息时就编一个),或者标成可选(然后服务端拿不到)。

现在服务端可以返回 resultType: "input_required",在 inputRequests 里挂一个 elicitation/create 请求并附上 requestedSchema;客户端向用户收集后,带着 inputResponses 和服务端给的 requestState 重发同一个 tools/call。两个机制细节别搞错:重发时 JSON-RPC 的 id 必须与首次请求不同;并且这条路径产生的结果 MUST NOT 被缓存,因为它依赖了不在缓存键里的输入。

设计上的意义是:凡是「只有用户知道、模型猜必错」的参数,都该走 elicitation,而不是塞进 required。典型的是账号选择、目标环境(prod / staging)、破坏性操作的二次确认。把它们做成必填参数,等于把一次幻觉直接变成副作用。

错误信息:isError 决定模型能不能自愈

MCP 把错误分成两类,这个区分是有牙齿的。

协议错误走 JSON-RPC error——未知工具、请求格式不合法、服务器故障。规范的原话是这些「模型不太可能修好」,客户端 MAY 把它们交给模型。

工具执行错误走结果体里的 isError: true——API 失败、输入校验失败(日期格式错、值越界)、业务逻辑错误。规范说这类错误「包含语言模型可以用来自我纠正、调整参数重试的可操作反馈」,客户端 SHOULD 把它们喂给模型。

搞反的代价很实在:把参数校验失败当成 JSON-RPC error 抛出去,很多客户端会当传输层故障处理,模型根本看不到,于是换个姿势重试同样的错。schema 源码里的措辞毫不含糊——源自工具的错误应当放在结果对象里并置 isError: true而不是作为协议级错误返回,「否则 LLM 无法看到发生了错误,也就无法自我纠正」。

规范自己的示例错误信息值得照抄结构:

Invalid departure date: must be in the future. Current date is 08/08/2025.

三个要素:错在哪个参数、约束是什么、当前的正确参照值是什么。第三条最常被漏掉——只说「日期必须是未来」,模型不知道「现在」是哪天。句柄过期同理:说「basket bsk_a1b2c3 已过期,请调用 create_basket 新建」,而不是返回一个裸 404。

annotations 是提示,不是权限

ToolAnnotations 在 2026-07-28 里仍然存在,四个字段的默认值容易记反,直接抄 schema 源码:

字段 默认值 含义
readOnlyHint false 为真表示不修改环境
destructiveHint true 为真表示可能做破坏性更新(仅在 readOnlyHint == false 时有意义)
idempotentHint false 相同参数重复调用无额外副作用
openWorldHint true 是否与外部开放世界交互

destructiveHintopenWorldHint 默认为真——不写就是在声明「假定危险、假定联网」。这个默认方向是对的,但意味着你必须显式把只读工具标出来,否则确认弹窗会淹没用户。idempotentHint 同理只是个声明,真正让重试安全的是服务端的去重实现(做法见Idempotency-Key 实现指南)。

schema 注释写得很重:所有字段都是 hints,「不保证忠实描述工具行为(包括 title 这类描述性字段)」,客户端「绝不应基于来自不可信服务器的 ToolAnnotations 做工具使用决策」。规范正文里还有一条规范性警告:客户端 MUST 把工具注解视为不可信,除非来自可信服务器。

不要用 annotations 搭权限模型。 授权在服务端做——规范也指了正确位置:工具集合可以随请求携带的授权凭据变化。

另一个「看着像安全机制、其实不是」的是 x-mcp-header:它把某个原始类型参数镜像进 Mcp-Param-{name} HTTP 头,方便网关不解析 body 就能路由。规范的警告很明确:不要把密码、API key、token、PII 标上它,头部对沿途所有中间件可见。

一份落地顺序

  1. 工具名去掉点号,控制在 50 字符内,用下划线加服务前缀。
  2. 每个描述至少 3–4 句,写清「何时不该用」和「不返回什么」;触发条件用陈述语气,去掉 MUSTCRITICAL
  3. 约束照写进 schema,但服务端做完整独立校验;别把正确性寄托在 minimum/maxLength 这类下游要么直接拒绝、要么根本不强制的关键字上。
  4. 只用本地 $defs,不用跨文档 $ref,组合层数保持浅。
  5. 按任务而不是端点定义工具;动作合并、参数扁平。
  6. 工具超过约 10 个或定义超过约 10k token,切按需加载。
  7. tools/list 确定性排序,配好 ttlMs,按用户过滤的列表一律 private
  8. 返回体默认精简,给 response_format 和分页默认值;outputSchemastructuredContent 成对出现。
  9. 所有可自愈的失败走 isError: true,文案带上当前正确参照值。
  10. 显式标注 readOnlyHint,但授权逻辑一行都别放在 annotations 上。

最后一条前瞻:2026-07-28 把 Roots、Sampling、Logging 标记为废弃(SEP-2577),承诺至少保留十二个月。如果你的工具设计依赖 Sampling 做服务端发起的模型调用,现在就该重画,别等到期限。