2 工具协议:JSON Schema、function calling、MCP
模型输出的一个 JSON,凭什么能被宿主执行?谁来保证参数合法、权限足够、结果可信、失败可纠正?MCP 的 2025-11-25 与 2026-07-28 两版差在哪,为什么旧版还在生产里跑?OAuth 的 audience 与 confused deputy 跟一个工具调用有什么关系?
画出模型/宿主/工具的三层协议栈;写出 strict 兼容的 JSON Schema;处理 function calling 的并行与流式增量;区分 MCP 的三原语与两代生命周期;为一次调用选择正确的错误通道;识别 token 透传与旧版握手迁移这两类事故。
第 1 章把工具定义成八字段契约,但那份契约还只是宿主内部的数据结构。一旦模型、宿主、工具分属三个进程甚至三个组织,契约就必须落到线上格式:模型怎么声明想调什么,宿主怎么校验与授权,工具怎么回传结果与错误,双方怎么确认彼此说的是同一版协议。协议解决的从来不是「模型会不会调用函数」,而是出现分歧时以谁为准。
这一章的主线是一条调用穿过三层边界:模型 provider 只产出意图,宿主把它变成可执行的调用,MCP 把调用送到工具进程。每穿过一层,信息都会丢失一部分;协议的价值是把丢失限制在可检测、可恢复的范围内。JSON Schema 约束参数形状,function calling 约束模型与宿主之间的一轮交换,MCP 约束宿主与工具之间的能力、调用、结果与错误,OAuth 则约束「谁有权让谁做什么」。
版本坐标必须先说清:MCP 最新规范是 2026-07-28,上一版是 2025-11-25。新实现应面向 2026-07-28,但必须能识别仍在使用 initialize 握手的旧 server。把 initialize 当永恒,或者把无状态当默认,都会在真实部署里翻车;本章会给两版对照表与迁移路径。
读图:从左到右是控制流,不是信任流。模型只产出 tool_calls;宿主做校验、授权、路由;MCP 只负责把调用送到 server。中间的请求帧里,参数可能来自网页、文件或另一个工具的输出,所以「schema 合法」不等于「可以执行」。底部两行是版本坐标:新版把 initialize 与协议级会话去掉了,新实现不应再把握手当默认前提。
2.1 三方与分层:模型只产出意图,宿主负责执行
先看一次调用里三个角色各自能决定什么。把这张表记住,后面所有协议细节都能挂上去。
| 角色 | 产出 | 能决定 | 不能决定 |
|---|---|---|---|
| 模型 provider | tool_calls 或文本 | 选哪个工具、参数草稿 | 是否执行、权限够不够、结果是否可信 |
| 宿主 runtime | 校验后的调用 | 执行/拒绝/重试/裁剪/审批 | 工具内部的业务语义 |
| 工具 / MCP server | result 或错误 | 真实副作用与错误码 | 调用者的意图是否合法 |
很多实现把这张表压成一行:模型输出 JSON,宿主直接 json.loads 后调用函数。Demo 能跑,生产会出三类事故:模型声称做了但没做;参数越权但没人拦;执行失败被模型当成成功继续。三者都不是模型能力问题,而是宿主放弃了执行层。
┌──────────────────────────────────────────────────────────────┐
│ 模型 provider │
│ 输入:messages + tools[](名字、描述、JSON Schema) │
│ 输出:tool_calls[{id, name, arguments}] ← 只是意图 │
└───────────────────────────┬──────────────────────────────────┘
│ ① 模型 API 边界(格式约束)
┌───────────────────────────▼──────────────────────────────────┐
│ 宿主 runtime │
│ 解码 → 校验 → 授权 → 去重 → 路由 → 记账 → 回填观察 │
│ 能拒绝、能改写、能延迟、能要求人工确认 │
└───────────────────────────┬──────────────────────────────────┘
│ ② MCP / HTTP 边界(JSON-RPC 契约)
┌───────────────────────────▼──────────────────────────────────┐
│ MCP server / 工具进程 │
│ inputSchema 再校验 → 前置条件 → 副作用 → result/isError │
│ 持有凭证、文件句柄、数据库连接 │
└──────────────────────────────────────────────────────────────┘
③ 进程/网络边界:OS 权限、沙箱、审计(第 3、4、11 章)
读图:三条横线是三种边界,不是三种信任。① 只保证模型输出可解析;② 只保证双方对帧格式一致;③ 才限制真实副作用。每跨一条边界,信息都会丢:模型看不到宿主的权限表,server 看不到模型为什么选它。协议的工作是把这种丢失变成可检测的偏差——id 对不上、schema 不合法、能力没声明、audience 不匹配,都能在造成副作用之前被拦下。
一个工具协议是三元组
其中 是能力声明编码:工具名、描述、输入/输出 schema、注解; 是调用编码:调用 id、工具名、参数、请求元数据; 是结果编码:结果类型、内容、结构化数据、错误通道。一个可用的协议还必须满足四条不变量:
- 对应性:每个调用恰好得到一个结果(执行、拒绝或错误),结果的 id 属于该调用;
- 可判定性:收到一帧时,能判断它属于哪个版本、哪个方法、哪次调用;
- 错误分类稳定:同一类失败总走同一条通道,不因实现而异;
- 能力可协商:未声明的能力不会被调用,声明了也不等于被授权。
前两条让通信可恢复,后两条让失败可纠正。少了任何一条,协议退化成「双方都猜」。
这个三元组是第 1 章八字段工具契约 定义 1.4.1 的线上投影:给模型看的部分变成 ,执行器内部的前置条件、幂等性、权限留在宿主;两者不是替代关系,而是同一契约的两个视图。
同一份意图,在两层协议里有两种编码。模型 API 层(OpenAI Chat Completions 形状):
{
"id": "call_demo_1",
"type": "function",
"function": {
"name": "fs_read",
"arguments": "{\"path\":\"seed.txt\"}"
}
}
MCP 层:
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "fs.read",
"arguments": {"path": "seed.txt"}
}
}
两个 JSON 说的是同一件事,但有三处关键差异。arguments 在模型层是字符串(可能是半截 JSON),到 MCP 层必须是对象;id 在模型层由 provider 生成、只在一次响应内有效,到 MCP 层由客户端生成、在一条连接上唯一;错误在模型层通常没有位置,只能塞进文本,到 MCP 层有 error 与 isError 两条通道。把这三处理顺,才算真正「支持工具调用」,而不只是会解析一个函数名。
模型说「我调用了」、工具说「我没收到」、用户说「它明明改了文件」——协议的作用不是让这三句话都成真,而是在每一步留下可核对的凭据:调用 id、参数哈希、结果类型、错误码、版本号。没有凭据,排障只能靠猜;有了凭据,至少能定位到是哪一层说了谎。
反过来,协议不能替你做的事也要说清:它不判断一个工具该不该存在,不判断参数里的路径是否敏感,不判断调用者是否被诱导。这些属于权限、沙箱与安全(第 3、4、11 章)。协议只保证「如果一切按契约发生,双方看到的是同一件事」。
2.2 JSON Schema:约束、strict 与 2020-12
JSON Schema 是三方唯一共同读取的字段:模型据此生成参数,宿主据此预校验,server 据此最终校验。三方对同一份 schema 的理解只要有一点偏差,就会出现「模型觉得合法、宿主放行、server 崩溃」的缝隙。
先澄清六个最容易写反的语义:
| 关键字 | 约束什么 | 不约束什么 | 常见误读 |
|---|---|---|---|
type | 值的 JSON 类型 | 业务含义 | integer 拒绝所有小数(规范接受零小数) |
properties | 已知字段的形状 | 字段是否必须出现 | 写了 properties 就等于必填 |
required | 字段必须出现 | 是否允许额外字段 | required 会拒绝多余字段 |
additionalProperties | 额外字段是否允许 | 已知字段是否合法 | 默认是 false(实际默认 true) |
format | 注解,供工具提示 | 默认不参与校验 | format:"email" 会自动拒绝坏地址 |
enum / const | 值必须命中集合 | 集合外值的业务合理性 | 枚举能防注入 |
required 与 additionalProperties 的组合值得单独记:{"required":["path"]} 允许 {"path":"a","extra":1};要拒绝额外字段,必须显式写 "additionalProperties": false。很多「schema 校验过了但 server 炸了」的事故,就是宿主只查了 required,把多余字段原样转发。
integer 的判定也值得留意:2020-12 规定「零小数的数字」算整数,所以 1.0 合法;但不少实现直接按宿主语言类型判断(Python 的 isinstance(1.0, int) 为假),于是同一份 schema 在两个校验器上给出不同结果。跨语言时要么统一用整数传输,要么在边界显式规范化。
2.2.1 2020-12 与 draft-07 的差别
MCP 2026-07-28 规定:schema 没有 $schema 字段时,默认按 JSON Schema 2020-12 解释;实现必须支持 2020-12,可以额外支持其他方言。这条规定把「方言漂移」变成了显式错误,而不是静默的宽松解释。
| 主题 | draft-07 写法 | 2020-12 写法 | 影响 |
|---|---|---|---|
| 复用定义 | definitions | $defs | 旧 schema 直接搬过来可能找不到定义 |
| 元组 | items 为数组 | prefixItems + items | 数组校验语义变化 |
| 组合后的额外属性 | additionalProperties(组合下常失效) | unevaluatedProperties | 跨 allOf/$ref 仍可约束 |
| 字段依赖 | dependencies | dependentRequired / dependentSchemas | 旧关键字被拆分 |
| 动态引用 | 无 | $dynamicRef / $dynamicAnchor | 递归结构更可控 |
这张表说明什么:schema 不是「一段永远兼容的 JSON」,它有方言。跨系统传输时,要么显式写 $schema,要么双方约定只使用交集子集;靠「应该差不多」会在大半年后炸。
2020-12 还有一个安全相关的细节:$ref 可以指向绝对 URI,但 MCP 明确要求实现不得默认解引用网络 URI。否则一个恶意 server 返回的 schema 就能让客户端去请求内网地址,形成 SSRF(第 2.8 节)。需要远程引用时,必须显式开启、限制主机、拒绝回环与私网地址、加超时与大小上限。
2.2.2 strict 模式:三个条件,不是一种方言
OpenAI 的 strict 不是 JSON Schema 的新方言,而是「把 schema 编译成受约束解码」的一组额外要求。要让 strict: true 真正生效,核心是三条结构要求:
- 根是
object,且每一层 object 都写"additionalProperties": false; - 每个在
properties里出现的键都必须列进required; - 「可选」用可空类型表达(例如
["string","null"]),而不是从required里省略。
{
"type": "object",
"properties": {
"path": {"type": "string"},
"encoding": {"type": ["string", "null"], "enum": ["utf-8", "base64", null]}
},
"required": ["path", "encoding"],
"additionalProperties": false
}
这段 schema 里,encoding 在语义上可选,但在结构上必填:调用方要么给 "utf-8",要么给 null。这不是冗余,而是为了让受约束解码有确定的形状——模型不能「忘记」一个字段,只能显式选择空值。
一个 schema 对某 provider 的 strict 模式兼容,当且仅当:
- 它落在该 provider 支持的 JSON Schema 子集内;
- 每个 object 节点都禁止额外属性;
- 每个 object 节点的
properties键都在同层required中; - 可选性只由
null联合表达。
strict 保证的是输出形状,不保证值域合理、不保证参数来源可信、不保证调用该被执行。它把「模型漏字段」变成解码失败,而不是把「模型被注入」变成安全。
一个最小校验器的核心逻辑只有十几行。下面是 experiments/ch02/mcp_stdio_server.py 里实际使用的子集(省略了数值、布尔等分支):
def validate(schema, value, path="$"):
if schema.get("type") == "object":
if not isinstance(value, dict):
return [f"{path}: expected object"]
errors = [f"{path}.{k}: required property is missing"
for k in schema.get("required", []) if k not in value]
props = schema.get("properties", {})
if schema.get("additionalProperties") is False:
errors += [f"{path}.{k}: unexpected property" for k in value if k not in props]
for key, sub in props.items():
if key in value:
errors += validate(sub, value[key], f"{path}.{key}")
return errors
if schema.get("type") == "string" and not isinstance(value, str):
return [f"{path}: expected string"]
return []
这段代码故意只覆盖本章用到的关键字。真实实现要用完整校验器,并且在宿主与 server 两侧各校验一次:宿主校验是为了在产生副作用前拦住明显错误,server 校验是因为宿主可能被绕过、也可能是另一个团队写的。
MCP 对 schema 还有两条工程约束:inputSchema/outputSchema 必须是合法 schema 对象,不能为 null;组合关键字(anyOf、oneOf、allOf、if/then/else)与 $defs 可能被恶意构造为校验器的 DoS 向量,实现应限制深度、子 schema 数量与单次校验时间。schema 本身也是不可信输入。
- 「
required会禁止额外字段」——它只检查存在性;禁止额外字段要additionalProperties: false。 - 「
additionalProperties: false就安全了」——它只约束形状。路径穿越、越权 ID、注入内容都可能是合法字符串。 - 「
format: "email"会校验邮箱」——默认它是注解,很多校验器不实现;要校验就写pattern或在业务层查。 - 「draft-07 与 2020-12 可以混用」——同一份 schema 只能按一个方言解释;混用会导致
$defs、items、unevaluatedProperties行为不一致。 - 「strict 能防提示词注入」——strict 只约束输出形状;注入改变的是「模型想调什么」,不是「JSON 长什么样」。
schema 描述的是单次调用的形状;一次调用在时间上的形状——谁先谁后、能否并行、流式片段怎么拼——由下一节的 function calling 决定。
2.3 function calling:tools、tool_choice、并行与流式
function calling 是模型 API 层的交换格式。不同 provider 的字段名不一样,但回路相同:宿主把工具目录放进请求,模型返回一个或多个调用意图,宿主执行后把结果按 id 回填,再次请求模型。先把 OpenAI Chat Completions 的形状走一遍,再对照 Responses API。
请求侧只保留关键字段:
{
"tools": [
{
"type": "function",
"function": {
"name": "fs_read",
"description": "读取 workspace 内的 UTF-8 文本文件;path 必须是相对路径。",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string", "minLength": 1}},
"required": ["path"],
"additionalProperties": false
},
"strict": true
}
}
],
"tool_choice": "auto",
"parallel_tool_calls": false
}
模型返回的调用意图:
{
"id": "chatcmpl_demo",
"choices": [
{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_demo_1",
"type": "function",
"function": {
"name": "fs_read",
"arguments": "{\"path\":\"seed.txt\"}"
}
}
]
}
}
]
}
注意 arguments 是字符串,不是对象。它可能是 ""、"{}"、半截 JSON,或者带转义的嵌套 JSON;宿主必须自己 json.loads 并处理解析失败。把这一步交给模型「保证合法」是常见的错误:strict 能降低概率,但不能替代宿主的解析与校验。
tool_choice 有四种取值,用途完全不同:
| 取值 | 含义 | 适用 | 风险 |
|---|---|---|---|
"none" | 禁止调用工具 | 纯文本回答、最终总结 | 模型可能仍想调,需宿主兜底 |
"auto" | 模型自行决定 | 默认 | 可能不调、可能乱调 |
"required" | 必须至少调一个 | 强制先取证再回答 | 无合适工具时会硬调,形成循环 |
| 具名函数 | 只能调指定工具 | 固定工作流的第一步 | 模型失去选择权,参数仍可能错 |
parallel_tool_calls 在 Chat Completions 里默认是 true。并行不是免费的:两个写操作可能冲突,后一个可能依赖前一个的输出,两个调用可能对同一文件做「读—改—写」。宿主要么显式关闭并行,要么在收到多个调用后做依赖分析,把有冲突的调用串行化。判定规则可以很朴素:只读且参数无重叠的调用可并行;任何写调用默认串行,除非工具有幂等键且作用域不相交。
回填结果时,assistant 消息要原样带上 tool_calls,然后每个调用一条 role: "tool" 消息:
[
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_demo_1",
"type": "function",
"function": {"name": "fs_read", "arguments": "{\"path\":\"seed.txt\"}"}
}
]
},
{
"role": "tool",
"tool_call_id": "call_demo_1",
"content": "{\"path\":\"seed.txt\",\"bytes\":11,\"content\":\"alpha\\nbeta\\n\"}"
}
]
三条不变量经常被违反:每个 tool_call 必须有且只有一条对应结果;tool_call_id 必须来自同一轮;未执行的调用也必须回填一条显式拒绝,不能静默丢弃。少一条,多数 provider 会直接返回 400;少数会容忍,但模型会在下一轮「忘记」自己调过什么。
2.3.1 流式:按 index 拼,不按到达顺序拼
流式响应里,tool_calls 是增量下发的。第一块通常带 index、id、type 与函数名,后续块只带 index 与 arguments 片段:
chunk 1 delta.tool_calls[0] = {index: 0, id: "call_demo_1", type: "function",
function: {name: "fs_read", arguments: ""}}
chunk 2 delta.tool_calls[0] = {index: 0, function: {arguments: "{\"path\":"}}
chunk 3 delta.tool_calls[0] = {index: 0, function: {arguments: "\"seed.txt\"}"}}
chunk 4 finish_reason = "tool_calls"
正确做法是维护 index -> {id, name, arguments_buffer} 的映射,每块按 index 追加,收到 finish_reason 后再统一 json.loads。三个细节:index 是调用在本次响应里的序号,不是数组下标,多个调用可能交错到达;id 与 name 只出现一次,后续块没有;arguments 片段可能在任意字节处切开,包括 UTF-8 多字节字符与转义序列,逐块解析必然失败。
2.3.2 Responses API 与 Chat Completions 的字段差异
两套接口表达同一回路,但字段层级不同。迁移时最容易错的不是名字,而是「谁包着谁」:
| 维度 | Chat Completions | Responses API |
|---|---|---|
| 工具定义 | {type, function:{name, description, parameters, strict}} | {type, name, description, parameters, strict}(扁平) |
| 调用项 | choices[0].message.tool_calls[] | output[] 中 type: "function_call" 的项 |
| 关联 id | tool_call_id | call_id |
| 回填 | role: "tool" 消息 | function_call_output 项 |
| 续跑 | 重发完整 messages | previous_response_id 或重发 input |
| 流式 | delta.tool_calls | response.function_call_arguments.delta 等事件 |
| 结构化输出 | response_format.json_schema | text.format |
这张表说明什么:适配器不能只改字段名,还要改「状态放在哪」。Chat Completions 把历史放在 messages 里,Responses 把一部分历史放在服务端的 response 对象里;前者更容易审计与重放,后者更省往返。选哪个取决于你的可观测性与数据保留要求,不是哪个更新。
结构化输出(structured outputs)与工具参数是两件事。前者约束最终答案的形状,后者约束动作参数的形状。两者都用 JSON Schema,但失败语义不同:答案 schema 失败通常重试或降级为文本;参数 schema 失败应该变成一条 isError 观察,让模型改参数(第 2.7 节)。
一次工具调用回路包含五步:
- 宿主声明能力:
tools[],含名字、描述、参数 schema; - 模型产出调用:
id、name、arguments(字符串); - 宿主解码:解析 JSON、按 schema 校验、按策略授权;
- 执行:串行或并行,产生结果或错误;
- 回填:以同一
id追加结果,重新请求模型。
回路必须满足
其中未执行的调用也必须有一个显式结果(拒绝或错误),否则回路不完整。回路允许在任意一步终止(预算、权限、人工中止),但终止必须留下与调用 id 对应的记录。
宿主的执行伪代码可以压成十几行:
resp ← model.chat(messages, tools)
calls ← decode(resp) # 解析失败 → 回填 EINVAL,不重试模型
for c in calls:
if c.name not in registry: result[c.id] ← ENOTOOL
elif not validate(registry[c.name].schema, c.args): result[c.id] ← EINVAL
elif not authorize(c): result[c.id] ← EPERM
if parallel and all_readonly(calls): run_concurrently(calls, deadline)
else: run_in_order(calls, deadline)
append(assistant_message_with_calls)
append(each result keyed by id)
四个边界必须显式处理:finish_reason == "length" 说明参数可能被截断,不能执行;content 与 tool_calls 同时存在时,文本可能是在解释调用,不能当最终答案;同一响应里出现重复 id 时,保留第一个、拒绝其余;finish_reason == "tool_calls" 但 tool_calls 为空,是 provider 异常,应重试一次而不是当成功。
OpenAI 的 Chat Completions 与 Responses、Anthropic 的 tool use、Google 的 function calling,字段名与流式增量格式都不同。生产代码应把「模型适配器」与「工具执行器」分开:适配器只负责把各家响应归一化成内部的 Invocation{id, name, args},执行器只认这个内部结构。这样换模型不会改工具层,换工具也不会改模型层。
- 「流式可以逐块
json.loads」——参数片段可能在任意字节处切开;只能按index累积到结束再解析。 - 「并行默认更快」——并行只对独立只读调用成立;写调用并行会制造竞态与重复副作用。
- 「只回填最后一个结果就行」——每个调用都要有对应结果;漏回填会被 provider 拒绝,或被模型误读为未执行。
- 「
tool_call_id可以自己编」——它必须来自模型返回的调用;自造 id 会让回填无法对应,模型下一轮会重复调用。
到这里,模型与宿主之间的回路已经闭合。但宿主与工具之间还没有协议:工具可能是本地函数、另一个进程、一个远程服务,甚至别人家的 SaaS。下一节的 MCP 就是这后半段的通用语言。
2.4 MCP 原语:tools/resources/prompts 与能力协商
MCP(Model Context Protocol)是宿主与工具之间的 JSON-RPC 2.0 协议。它不替代模型 API,也不规定模型怎么选工具;它规定的是:工具如何声明自己、宿主如何调用、结果与错误如何回传、双方如何确认能力边界。用一句话概括:MCP 是工具侧的操作系统调用表——不是插件市场,也不是权限系统。
JSON-RPC 2.0 只有三种消息。request 带 id、method、params;response 必带同一 id(解析失败、id 不可读时可为 null),并在 result 与 error 中二选一;notification 没有 id,接收方不得回复。JSON-RPC 允许 id 为 null(不推荐),MCP 收紧为字符串或整数、不得为 null,且同一发送方未完成的请求 id 不得重复。错误对象固定三字段:code(整数)、message(一句话)、data(可选的结构化补充)。
{"jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": {"name": "nope"}}
{"jsonrpc": "2.0", "id": 7, "error": {"code": -32602, "message": "Unknown tool: nope"}}
{"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 7}}
服务端有三个原语,控制点各不相同:
| 原语 | 控制者 | 主要方法 | 副作用 | 典型用途 | 常见误用 |
|---|---|---|---|---|---|
| tools | 模型 | tools/list、tools/call | 可能有 | 动作、查询、计算 | 把写操作标成只读 |
| resources | 应用 | resources/list、resources/read、resources/templates/list | 只读 | 文件、记录、配置 | 当成任意文件读取器 |
| prompts | 用户 | prompts/list、prompts/get | 无 | 可复用消息模板 | 当成 system prompt 自动注入 |
客户端侧在 2025-11-25 还有 roots、sampling、elicitation 三个能力;2026-07-28 只保留 elicitation(并且改为通过 MRTR 传递),roots 与 sampling 进入弃用。这个变化不是删功能,而是把「server 反向控制 client」改成「client 在一次请求内决定是否配合」。
tools/list 的响应包含工具名、描述、输入/输出 schema 与注解。下面是实验里真实返回的第一个工具(只保留一个,字段未改):
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "complete",
"tools": [
{
"name": "fs.read",
"title": "Read a text file",
"description": "读取 workspace 根目录内的 UTF-8 文本文件。path 必须是相对路径;不要用于读取二进制文件或目录。",
"inputSchema": {
"type": "object",
"properties": {"path": {"type": "string", "minLength": 1, "maxLength": 200}},
"required": ["path"],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"properties": {
"path": {"type": "string"},
"bytes": {"type": "integer", "minimum": 0},
"content": {"type": "string"}
},
"required": ["path", "bytes", "content"],
"additionalProperties": false
},
"annotations": {
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
}
}
],
"ttlMs": 300000,
"cacheScope": "public"
}
}
三个字段值得停一下。outputSchema 让宿主可以在把结果喂给模型前校验结构;annotations 是提示,不是权限;ttlMs 与 cacheScope 是 2026 新增的缓存提示,前者说这份列表多久内可视为新鲜,后者说能否跨授权上下文共享。缓存不是可选的优化:没有 ttlMs 时客户端应按 0 处理,也就是每次都重新拉。
2.4.1 注解只是提示
| 注解 | 默认值 | 含义 | 不能拿来做什么 |
|---|---|---|---|
readOnlyHint | false | 工具是否只读 | 不能替代服务端鉴权 |
destructiveHint | true | 是否可能破坏性修改 | 不能替代人工确认 |
idempotentHint | false | 重复调用是否等价 | 不能替代幂等键 |
openWorldHint | true | 是否与开放世界交互 | 不能替代出口过滤 |
规范明确要求:除非 server 可信,客户端必须把注解当作不可信。原因很直接——注解由 server 自己声明,一个被投毒的 server 可以把 rm -rf 标成 readOnlyHint: true。默认值也刻意保守:没声明就当作「可能破坏、可能不幂等、可能访问开放世界」。
规范建议工具名满足:长度 1–128,字符集为 ASCII 字母、数字、下划线、连字符与点,大小写敏感,同一 server 内唯一。聚合多个 server 的宿主必须处理重名,通常加 server 前缀;serverInfo.name 不保证唯一,不能拿它当命名空间。
2.4.2 resources 与 prompts
resources 面向「读数据」,URI 是标识符,不一定是文件路径。工具结果里可以返回 resource_link(让客户端自己决定要不要读)或 resource(内嵌内容)。前者适合大对象与需要权限的数据,后者适合小段上下文。
{"jsonrpc": "2.0", "id": 9, "method": "resources/read",
"params": {"uri": "file:///tmp/ch02-mcp/seed.txt"}}
{"jsonrpc": "2.0", "id": 9, "result": {
"contents": [{"uri": "file:///tmp/ch02-mcp/seed.txt", "mimeType": "text/plain",
"text": "alpha\nbeta\n"}]}}
prompts 面向「用户主动选择的模板」:prompts/list 列出模板与参数,prompts/get 返回一组消息。它不应该被宿主自动注入——那是 system prompt 的职责;prompts 的语义是「用户点了一下这个模板」。
一个 MCP 原语由三部分组成:
- 方法集:该原语允许的 JSON-RPC 方法(如 tools 的
tools/list、tools/call); - 控制点:谁决定是否使用(模型 / 应用 / 用户);
- 副作用类:只读、可逆写、不可逆写。
能力位(capability)是 server 对「我会回答这些方法」的承诺,例如 capabilities.tools.listChanged。能力位缺失时,客户端不应调用对应方法;能力位存在也不代表授权通过——它只说「这个功能存在」,不说「你这个调用者可以用」。2025-11-25 的能力位在 initialize 时协商一次,2026-07-28 改为每个请求在 _meta 中携带客户端能力。
能力协商要回答三个问题:对方会不会回答这个方法(能力位)、我这个请求需不需要对方的能力(例如 MRTR 需要 elicitation)、回答了是否等于授权(不等于)。前两个问题决定「能不能调」,第三个问题决定「该不该执行」。很多实现只检查第一个,于是把「server 声明了 tools」误当成「这个调用者可以用所有 tools」。
tools/list 是菜单,tools/call 是点单,result 是小票。菜单写得再清楚,也不代表厨房有食材、你有钱付账、你点的菜不会被别人掉包。协议保证的是「你点的和我做的能对上」,不是「这顿饭一定安全」。
- 「resources 就是文件」——URI 可以指向数据库行、API 对象、内存缓冲区;文件只是最常见的一种。
- 「prompts 是 system prompt」——prompts 是用户选择的消息模板;自动注入会绕过用户意图,也会放大注入面。
- 「annotations 是权限声明」——它只是提示,且默认不可信;权限必须在宿主与 server 两侧独立检查。
- 「工具越多能力越强」——工具越多,模型的选择空间与误用面越大;按任务分层、按需发现,比一次性塞 200 个工具更可靠。
- 「MCP 只能本地 stdio」——stdio 只是最常用传输;Streamable HTTP 用于远程 server,两者的生命周期与可靠性语义不同(第 2.6 节)。
三个原语解决「有什么、怎么调、结果是什么」;它们没有解决「第一次见面怎么确认对方是谁、说哪版」。这就是生命周期与版本协商。
2.5 生命周期:从 initialize 到无状态
MCP 的版本号是日期字符串,不是 semver。2026-07-28 指一份具体修订,不承诺与 2025-11-25 的补丁级兼容。两版之间不是小改:2026-07-28 把协议级会话与握手整个移除,改成每个请求自带元数据。理解这个断点,比记住字段名更重要。
2025-11-25(legacy) 2026-07-28(modern) client server client server │ initialize │ │ server/discover │ │─────────────────────>│ │─────────────────────>│ │←─────────────────────│ │←─────────────────────│ │ notifications/ │ │ │ │ initialized │ │ (没有握手,没有会话) │ │─────────────────────>│ │ │ │ tools/list │ │ tools/list │ │─────────────────────>│ │─────────────────────>│ │←─────────────────────│ │←─────────────────────│ │ │ │ │ connection = session 每个请求自带 _meta 断线重连必须重新握手 断线重试换新 id(副作用仍需幂等)
读图:左边前两帧建立上下文,后面所有帧都依赖它;右边每一帧都是自包含的,server 不要求你复用同一条连接。代价从「连接建立时的往返」变成「每一帧的字节」;收益是任意请求可以落到任意实例,失败恢复不必重放握手。注意右边仍然可以有状态——购物车、事务、长任务——但状态必须由 server 显式发一个 handle,客户端在后续参数里带回来,而不是靠连接隐式维系。
2025-11-25 的握手长这样(实验 trace 的真实响应,已缩进):
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"protocolVersion": "2025-11-25",
"capabilities": {
"tools": {"listChanged": false},
"resources": {"subscribe": true, "listChanged": false},
"prompts": {"listChanged": false},
"logging": {}
},
"serverInfo": {"name": "ch02-stdio-server", "version": "1.0.0"},
"instructions": "教学 server:先 initialize,再发 initialized 通知。"
}
}
客户端在 initialize 里给出自己支持的版本、能力与身份;server 返回它选的版本与能力;客户端再发 notifications/initialized,双方才进入操作阶段。版本不匹配时,server 返回自己支持的最新版,客户端若不支持就断开。这套协商只发生一次,之后的请求都默认沿用。
2026-07-28 把同样的信息拆进每一帧:
{
"jsonrpc": "2.0",
"id": 1,
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "ch02-client", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities": {"elicitation": {}}
}
}
}
protocolVersion 与 clientCapabilities 是必填;clientInfo 是建议填。server 在处理请求前检查版本:不认识就返回 -32022 UnsupportedProtocolVersion,并在 data.supported 里列出自己支持的版本;缺字段返回 -32602。结果侧对应地带 _meta.io.modelcontextprotocol/serverInfo,但它是自报的,只能用于展示与排障,不能用于安全决策。客户端还要兼容旧 server:收到缺少 resultType 的结果时,必须按 complete 处理,而不是判协议错误。
按版本与元数据位置,实现分三类:
- legacy:
2025-11-25及更早;用initialize/notifications/initialized建立连接级上下文,HTTP 上可有Mcp-Session-Id会话。 - modern:
2026-07-28及更晚;无握手、无协议级会话,每个请求在_meta中携带协议版本与客户端能力,结果带resultType。 - dual-era:同时支持两类;必须先探测再选择路径。探测失败不能以某个特定错误码为准——legacy server 对未知方法的反应是实现定义的,可能是
-32601、-32602,也可能直接不响应。
两版的关键差异可以压成一张表。这张表建议直接放进你的迁移文档,而不是只留在脑子里:
| 维度 | 2025-11-25(legacy) | 2026-07-28(modern) |
|---|---|---|
| 握手 | initialize + notifications/initialized | 无;每请求 _meta |
| 版本协商 | 一次,连接级 | 每请求;错误码 -32022 |
| 会话 | 协议级;HTTP 用 Mcp-Session-Id | 移除;跨调用状态用显式 handle |
| 服务发现 | 无 | server/discover(server 必须实现) |
| 结果类型 | 无 resultType | 必带 resultType(核心值 complete / input_required) |
| 反向请求 | server 独立发 roots/list、sampling/createMessage、elicitation/create | MRTR:inputRequests / inputResponses |
| 订阅 | HTTP GET SSE 流 + resources/subscribe | subscriptions/listen 单条长响应流 |
| ping / 日志 | ping、logging/setLevel、notifications/roots/list_changed | 全部移除;级别用每请求 _meta.logLevel |
| tasks | 核心协议中的实验特性 | 移到 io.modelcontextprotocol/tasks 扩展 |
| Roots / Sampling / Logging | 核心特性 | 弃用,新实现不应采用 |
| 错误码分区 | -32000..-32099 实现自定义 | -32020..-32099 保留给 MCP;-32000..-32019 为遗留 |
| JSON Schema | draft-07 常见 | 默认 2020-12 |
| 列表缓存 | 无 | ttlMs + cacheScope |
| HTTP 请求头 | MCP-Protocol-Version | 增加 Mcp-Method、Mcp-Name;不匹配 -32020 |
| 旧传输 | Streamable HTTP(含 GET 流、会话、续传) | GET 流/会话/Last-Event-ID 全移除;HTTP+SSE 弃用 |
这张表说明什么:2026 不是「加了几个字段」,而是把「连接即上下文」改成「请求即上下文」。所有依赖连接状态的设计——会话粘滞、GET 流广播、断点续传——都要重新设计。
方法可用性速查。 迁移时最常问的是「这个方法还在不在」,直接查表比翻 changelog 快:
| 方法 | 2025-11-25 | 2026-07-28 | 备注 |
|---|---|---|---|
initialize | 有 | 移除 | modern 返回 -32601 |
notifications/initialized | 有 | 移除 | 不再需要 |
server/discover | 无 | 必须实现 | 返回版本、能力、身份 |
tools/list | 有 | 有 | 2026 增加 ttlMs/cacheScope |
tools/call | 有 | 有 | 2026 可返回 input_required |
resources/list / read | 有 | 有 | 2026 增加缓存提示 |
resources/templates/list | 有 | 有 | 语义不变 |
resources/subscribe | 有 | 移除 | 改用 subscriptions/listen |
prompts/list / get | 有 | 有 | 2026 增加缓存提示 |
ping | 有 | 移除 | 用传输层健康检查 |
logging/setLevel | 有 | 移除 | 用每请求 _meta.logLevel |
subscriptions/listen | 无 | 有 | 单条长响应流 |
notifications/cancelled | 有 | 有 | 仅 stdio 使用 |
notifications/progress | 有 | 有 | 跟随所属请求的响应流 |
tasks/* | 实验(get/result/list/cancel) | 扩展(get/update) | 语义有变,需重读扩展规范 |
这张表只列核心方法。扩展方法(如 UI、tasks)随扩展走,不在核心版本矩阵里。
2.5.1 MRTR:把反向请求变成两轮往返
旧版里,server 可以在处理 tools/call 的过程中反向发一个 elicitation/create 请求,等客户端返回后再继续。这要求连接保持、双向通道可用、server 记住中间状态。新版把这件事改成多轮往返(MRTR):server 不主动发请求,而是返回一个 resultType: "input_required" 的结果,里面带 inputRequests;客户端收集输入后,用新的请求 id 重发原请求,并带上 inputResponses 与 requestState。
{
"jsonrpc": "2.0",
"id": 11,
"result": {
"resultType": "input_required",
"inputRequests": {
"confirm_write": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "允许写入 out.txt(5 字节)?",
"requestedSchema": {
"type": "object",
"properties": {"confirm": {"type": "boolean"}},
"required": ["confirm"],
"additionalProperties": false
}
}
}
},
"requestState": "eyJwIjogImV5SnBZWFFpT2pF...(省略)"
}
}
server 只能请求客户端声明过的能力:clientCapabilities 里没有 elicitation 时,应返回 -32021 MissingRequiredClientCapability,而不是硬发一个 inputRequests。
requestState 是不透明字符串,客户端不得解析或修改,重试时原样带回。它让 server 不必在两次请求之间保存状态:需要记住的上下文(写哪个文件、内容哈希、过期时间)由 server 自己编码进去,并做完整性保护。这也意味着它经过客户端,必须当作攻击者可控输入:要校验 MAC、过期时间、主体、以及是否与本次参数匹配。实验里的 fs.write 就是这么做的;篡改尾部四个字符会得到 -32602。
stdio 上没有 HTTP 状态码可用,dual-era 客户端应先发 server/discover:返回 DiscoverResult 说明是 modern;返回 -32022 说明是 modern 但不支持你的版本,应改用 supported 里的版本,不要回退到 initialize;返回其他错误或超时,才回退到 initialize。HTTP 上可以先发一个 modern 请求:400 时先看响应体,若含 -32022、-32021、-32020 等 modern 错误码,说明对方是 modern,应修请求而不是回退;响应体为空或不是已知 modern 错误,才回退。
这条规则看起来啰嗦,但它避免了一类真实事故:旧 server 不校验「是否已 initialize」,直接把 tools/call 当 legacy 语义处理,于是客户端以为自己在说新版,server 却在用旧规则执行。
- 「initialize 会一直存在」——2026-07-28 已移除;modern server 收到
initialize返回-32601。 - 「无状态就是 server 不能有状态」——协议不替你保存状态;购物车、事务、长任务要用显式 handle,由客户端在参数里带回。
- 「
server/discover是可选的,所以可以不做」——客户端可以选不调用,但 server 必须实现;这是规范里的 MUST。 - 「2026 不兼容旧 server」——协议不兼容,但实现可以做 dual-era;兼容的责任在客户端探测与适配,不在协议。
生命周期解决「我们说的是不是同一版」;传输解决「帧怎么过去、断了怎么办」。后者才是事故高发区。
2.6 传输与可靠性:stdio、Streamable HTTP、取消与重试
2.6.1 stdio:一行一帧,stdout 只许协议
stdio 传输的规则少而硬:
- 每条消息是一行 JSON,消息内不得有裸换行(字符串里的换行由 JSON 转义成
\n); - server 从 stdin 读、往 stdout 写;stdout 只能写协议帧;
- 日志、调试输出、进度条一律写 stderr;客户端不应把 stderr 当错误信号;
- 所有消息共享一条通道,靠 JSON-RPC
id关联请求与响应,没有 per-request 流; - 取消用
notifications/cancelled,因为 stdio 没有「关掉某条响应流」这种粒度; - 关闭 stdin 是优雅停机的首要信号;不退出再升级到
SIGTERM、SIGKILL。
import json, sys
def write_frame(stream, frame):
line = json.dumps(frame, ensure_ascii=False, separators=(",", ":"))
assert "\n" not in line, "stdio frame must be one line"
stream.write(line + "\n")
stream.flush()
print() 调试是 stdio server 的第一号事故:一行 print("debug") 就会让客户端收到无法解析的帧,随后整条连接被判定为协议错误。更隐蔽的是第三方库把日志写到 stdout,或者 json.dumps 的 indent 参数把一帧拆成多行。
2.6.2 Streamable HTTP:一个端点,两种响应
Streamable HTTP 把每个 JSON-RPC 消息变成一次 POST。客户端必须同时接受两种响应:单个 JSON 对象,或一条 SSE 流。SSE 流里可以先有与本请求相关的 notifications/progress、notifications/message,最后才是最终响应;最终响应之后流应结束。
POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: fs.read
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"fs.read","arguments":{"path":"seed.txt"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
MCP-Protocol-Version 必须与 body 里 _meta 的版本一致;Mcp-Method 与 Mcp-Name 让网关不必解析 body 就能路由。头与 body 不一致时,server 必须返回 400 与 -32020 HeaderMismatch——这是防止「负载均衡按头路由、server 按 body 执行」的经典混淆。未知方法返回 404 加 -32601,以便与旧 HTTP+SSE server 的 404 区分。
旧版(2025-11-25)在这套机制之上还有四样东西:initialize 响应可带 Mcp-Session-Id,后续请求必须带上;GET 可以打开一条独立 SSE 流;DELETE 终止会话;Last-Event-ID 可以续传。2026-07-28 全部移除:只支持新版的 server 对 GET/DELETE 建议回 405,收到 Mcp-Session-Id 应忽略且不再签发,收到 Last-Event-ID 应忽略。SSE 断流不再可续,断掉的请求就是丢了,客户端必须用新的 id 重发。
安全性上,Streamable HTTP 有三条基线要求:校验 Origin,不合法回 403(防 DNS rebinding,MUST);本地运行只绑 127.0.0.1(SHOULD);所有连接都应认证(SHOULD)。前两条缺任何一条,一个恶意网页就可能驱动你本机的 MCP server。
2.6.3 可靠性:超时、取消与重试的边界
先看一张故障对照表。它的重点是第三列:客户端能不能确定副作用已经发生。
| 故障 | 客户端看到 | 能确定执行了吗 | 正确动作 |
|---|---|---|---|
| 连接建立失败 | connect 错误 | 一定没执行 | 可安全重试 |
| 请求写出前失败 | write 错误 | 一定没执行 | 可安全重试 |
| 写出后读超时 | timeout | 不确定 | 只有幂等键才重试 |
| 429 | 明确限流 | 一定没执行 | 退避后重试,带预算 |
| 503 / 504 | 网关或上游错误 | 不确定 | 查外部状态,或幂等键重试 |
| SSE 中途断开(2026) | 流结束无响应 | 不确定 | 新 id 重发;副作用需去重 |
| 404 + 旧 session id | 会话过期 | 不确定 | 重新 initialize,再决定是否重放 |
| 400 HeaderMismatch | 明确错误 | 一定没执行 | 修头,不重试 |
| 401 / 403 | 明确状态码 | 一定没执行 | 走授权或 step-up,不重试 |
这张表说明什么:只有「确定没执行」的失败可以无条件重试。读超时、SSE 断流、会话过期都属于「不确定」,必须靠幂等键或去重表兜底;没有这两样,重试就是在赌。
设客户端发出请求后,在收到响应前观察到一个超时。此时服务端状态只有两种可能:已执行()或未执行(),而客户端对两者的观察完全相同。若客户端不携带稳定幂等键、服务端也不维护去重表,则不存在任何仅依赖该观察的确定性策略,能使所有执行历史的副作用次数都恰好为 1。
客户端只能基于观察做决策,而 与 产生同一个观察,所以策略在两种状态下给出同一个动作。若动作为「重试」:在 的历史里副作用变为 1,在 的历史里变为 2。若动作为「放弃」:在 的历史里为 0,在 的历史里为 1。两种动作都无法让所有历史都等于 1。要让两个分支收敛,服务端必须能识别「这是同一个请求的重试」,即稳定的幂等键加持久化的结果表;或者副作用本身幂等,重复执行不改变最终状态。∎
∎定理的工程含义不是「不要重试」,而是「重试必须与幂等设计配套」。三条可执行的规则:读操作与显式幂等键的写操作可以重试;不可逆写默认不自动重试,转人工或查询外部状态;所有重试计入独立预算,带指数退避与抖动,且不能重试 EPERM/EACCES 这类权限错误。
取消也不是「保证没执行」。客户端发 notifications/cancelled,或者关掉 SSE 响应流,server 应尽快停止,但副作用可能已经发生。取消的语义是「别再往下做」,不是「把已经做的撤回」。需要撤回的场景要靠补偿动作,而不是取消。
观测一次协议会话,不需要抓包工具,客户端已经把帧写成了 JSONL:
python3 experiments/ch02/mcp_client.py --era 2026 --root /tmp/ch02-mcp \
--trace experiments/ch02/trace-2026.jsonl --quiet
jq -r 'select(.dir=="recv" and .frame.error) | [.frame.id, .frame.error.code, .frame.error.message] | @tsv' \
experiments/ch02/trace-2026.jsonl
checks: ok=28 fail=0
7 -32602 Unknown tool: nope
8 -32601 Method not found: tools/frobnicate
9 -32602 Invalid params: missing _meta
10 -32022 Unsupported protocol version
14 -32602 Invalid params: bad requestState
这五行覆盖了五类协议错误:未知工具、未知方法、缺元数据、版本不支持,以及被篡改的 requestState。它们都没有走到工具执行层——这正是「在副作用之前失败」的样子。第 2.7 节会把「哪些错误该走这条通道、哪些该走 isError」讲清。
- 「超时就等于没执行」——写超时是最危险的失败;必须用幂等键或查询外部状态确认。
- 「SSE 断了可以续传」——2025 有
Last-Event-ID,2026 移除了;断流请求要用新 id 重发。 - 「stdio 可以用
print调试」——stdout 只能写协议帧;调试写 stderr,或用logging配到 stderr。 - 「重试三次就可靠」——重试放大尾延迟与副作用;没有去重表,重试次数越多,重复执行概率越高。
2.7 结果与错误:isError、结构化内容与错误码
协议里最容易偷懒的决定,是把所有失败都塞进一条通道。MCP 给了两条:JSON-RPC 的 error,与工具结果里的 isError: true。选错的代价很具体——模型看不到本可以自纠的参数错误,或者宿主把一次可恢复的业务失败当成连接崩溃。
先看错误码的分区。JSON-RPC 2.0 把 -32768..-32000 整段保留给协议自身与实现,其中 -32000..-32099 是实现自定义的服务器错误区。MCP 再把这一段切开:-32000..-32019 是遗留实现用过的,新代码不应再分配;-32020..-32099 保留给规范。这条分区规则是 2026-07-28 才明确的,旧实现里各种自定义码混用,是迁移时的主要噪音源。
| 场景 | 通道 | 码 / 字段 | 谁该修 |
|---|---|---|---|
| JSON 解析失败 | JSON-RPC error | -32700 | 客户端/传输 |
| 请求结构非法 | JSON-RPC error | -32600 | 客户端 |
| 方法不存在 | JSON-RPC error | -32601 | 客户端/版本适配 |
参数结构非法(缺 name、arguments 非对象) | JSON-RPC error | -32602 | 客户端 |
| 未知工具 | JSON-RPC error | -32602(规范示例) | 宿主刷新 tools/list |
| server 内部异常 | JSON-RPC error | -32603 | server |
| 资源不存在(2026) | JSON-RPC error | -32602 | 客户端 |
| 资源不存在(2025 及更早) | JSON-RPC error | -32002 | 客户端兼容 |
| HTTP 头与 body 不一致 | JSON-RPC error | -32020 | 客户端/SDK |
| 缺少客户端能力 | JSON-RPC error | -32021 | 客户端补能力 |
| 协议版本不支持 | JSON-RPC error | -32022 | 客户端换版本 |
| 工具输入不合法(类型、范围、格式) | 工具结果 | isError: true + 结构化错误 | 模型改参数 |
| 业务规则失败(余额不足、日期已过) | 工具结果 | isError: true | 模型换方案或转人工 |
| 认证失败 / 权限不足 | HTTP 状态 | 401 / 403 | 宿主走授权流程 |
这张表说明什么:「模型能不能改」是分通道的第一判据。参数类型错、枚举越界、日期格式错,模型看到具体原因后有机会改对,应走 isError;方法不存在、版本不支持、头不匹配,模型改不了,走 JSON-RPC error 让宿主处理。规范明确把「输入校验错误」列为工具执行错误,而不是协议错误,原因就在这里。
某退款工具的 server 把「金额超过可退上限」实现成 -32602 Invalid params。宿主的适配器把任何 JSON-RPC error 都当成传输失败:重试三次相同请求,然后终止整个任务。trace 里是三条一模一样的 tools/call,没有任何 isError 结果,模型从未看到「上限是 20000」这句话。
修复分两层。server 侧改成 isError: true,并在 structuredContent.error 里给 {code:"ELIMIT", field:"amount_cents", limit:20000, retryable:false};宿主侧把「协议错误」与「工具执行错误」分开处理:协议错误不重试,直接上抛或切换版本;isError 回填给模型,并按工具单独计重试预算。修复后同一任务在第二次调用就把金额改到上限内并成功。
回归测试要断言两件事,而不只是「最终成功」:错误必须走 isError 通道;error.code 在版本升级后保持稳定。只断言最终答案,下一次重构还会把通道改回去。
结果侧的内容也有结构。content 是给模型看的非结构化块数组(text/image/audio/resource_link/resource),structuredContent 是给程序校验的结构化值。规范建议:返回结构化内容时,同时把序列化 JSON 放进一个 text 块,兼容只认 content 的旧客户端。structuredContent 与模型的「结构化输出」是两件事:前者是 server 产出的结果数据,后者是约束模型生成的 schema。
{
"jsonrpc": "2.0",
"id": 6,
"result": {
"resultType": "complete",
"content": [
{"type": "text", "text": "{\"error\": {\"code\": \"EINVAL\", \"message\": \"$.a: expected number, got str\", \"retryable\": true}}"}
],
"structuredContent": {
"error": {"code": "EINVAL", "message": "$.a: expected number, got str", "retryable": true}
},
"isError": true
}
}
这段是实验里的真实响应(缩进调整过)。三个字段各司其职:isError 告诉宿主「这是工具层失败,不是连接失败」;content 让模型能读到原因;structuredContent.error 给宿主稳定的机器码与 retryable。错误对象里不要放栈、SQL、内部 URL 或 token——它们既可能泄密,也会把模型的注意力带偏。
一个可复用的错误对象建议包含以下六个字段,缺一个就会让某一方靠猜:
| 字段 | 类型 | 作用 | 反例 |
|---|---|---|---|
code | string | 稳定的机器码,跨版本不改 | 直接复用 EINVAL 表示所有失败 |
message | string | 一句话说清「哪里错、期望什么」 | "error"、"failed" |
retryable | boolean | 同一请求重试是否有意义 | 权限错误标 true |
field | string | 定位到参数路径 | 只给整体「参数错误」 |
suggested_action | string | 给模型或宿主的下一步 | 所有错误都写「请重试」 |
detail | object | 结构化补充(上限、当前值) | 塞完整栈或 SQL |
code 与 message 的分工要固定:程序读 code,模型读 message,人读 suggested_action。三者混用会导致「改文案破坏了监控」或「模型看不懂机器码」。
宿主收到不同错误时的策略:
| 收到 | 含义 | 动作 | 回填模型 |
|---|---|---|---|
-32601 | 方法/能力不存在 | 刷新 tools/list,禁用该工具 | 不回填,记能力缺口 |
-32602(未知工具) | 工具名不在目录 | 刷新一次目录,仍无则拒绝 | 可回填「工具不存在」 |
-32022 | 版本不支持 | 按 data.supported 切换并重试一次 | 不回填 |
-32021 | 缺客户端能力 | 启用能力或终止 | 不回填 |
-32020 | 头与 body 不一致 | 视为 SDK/代理 bug,修实现 | 不回填 |
isError + EINVAL | 参数可纠正 | 回填,按工具计重试 | 回填完整原因 |
isError + EACCES/EPERM | 权限问题 | 不重试,升级审批或终止 | 回填「需授权」 |
isError + EUSERDECLINED | 用户拒绝 | 不重试 | 回填「用户拒绝」 |
| HTTP 401 | 凭证无效/过期 | 重新授权 | 不回填 |
| HTTP 403 | scope 不足 | step-up 或拒绝 | 不回填 |
这张表说明什么:错误处理不是 try/except,而是一棵决策树。树的第一层是「通道」,第二层是「可重试性」,第三层才是「文案」。把三层压成一层,必然出现「权限错误重试三次」或「参数错误直接终止」这两种极端。
再看一眼 trace 里的分布,确认失败都发生在该发生的层:
jq -r 'select(.dir=="recv" and .frame.result) | [.frame.result.resultType, .frame.result.isError] | @tsv' \
experiments/ch02/trace-2026.jsonl | sort | uniq -c
2 complete
4 complete false
2 complete true
1 input_required
两行 complete 没有 isError 字段,是 server/discover 与 tools/list;四个成功调用、两个工具级失败、一个 MRTR 中间结果。协议错误不在这个统计里,因为它们走 error 通道,被前一条 jq 单独筛出。把两张表放在一起,才能回答「失败有多少、分别在哪一层」。
2.8 授权:OAuth 2.1、audience 与 confused deputy
前面所有协议都假设「调用者有权」。真实系统里,这个假设要靠 OAuth 2.1 兑现。MCP 的授权模型很明确:受保护的 MCP server 是 OAuth 2.1 资源服务器,MCP client 是 OAuth 客户端,授权服务器(AS)可以是独立服务。server 不签发 token,只验证 token;client 不为 server 保存密码,只持有 AS 签发的、绑定到该 server 的 token。
一次完整的授权流程:
- client 不带 token 请求,server 返回
401,并在WWW-Authenticate里给出resource_metadata与所需scope; - client 拉取受保护资源元数据(RFC 9728),从中找到 AS;
- client 通过 AS 元数据(RFC 8414 或 OIDC Discovery)找到授权端点与 token 端点;
- client 注册:优先用 CIMD(Client ID Metadata Documents),退回预注册或 DCR;
- client 生成 PKCE 参数,带
resource(RFC 8707)发起授权请求; - 用户授权后,client 用 code + verifier +
resource换 token,并校验iss(RFC 9207); - 之后每次请求都带
Authorization: Bearer <token>;server 校验签名、过期、audience 与 scope。
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="files:read"
这条 401 是发现流程的起点,不是终点。客户端不应硬编码 AS 地址,也不应假设所有 server 共用一个 AS;元数据里的 authorization_servers 才是权威来源。
| 标准 / 机制 | 作用 | 缺了会怎样 |
|---|---|---|
| OAuth 2.1(草案) | 授权码 + PKCE 的基础 | 隐式流、无 PKCE 的截获风险 |
| RFC 6750 | Bearer token 的使用与错误 | token 放 query、错误语义混乱 |
| RFC 9728 | 受保护资源元数据 | 客户端不知道 AS 在哪 |
| RFC 8414 / OIDC Discovery | AS 元数据 | 端点靠猜,容易指向攻击者 |
| RFC 8707 | resource 指示器,绑定 audience | token 可被拿到别的服务复用 |
| RFC 9207 | 授权响应里的 iss | 多 AS 环境下的 mix-up |
| CIMD(草案) | 用 HTTPS URL 作为 client_id | 每个 AS 都要单独注册,注册端点成为额外攻击面 |
| RFC 7591 DCR | 动态客户端注册 | 2026 起弃用,仅为兼容保留 |
这张表说明什么:MCP 没有发明授权,它把一组现成 RFC 组合成一条最小路径。工程上不要自己发明「用 API key 代替 OAuth」的捷径——那会同时失去 audience、过期、撤销与审计。
scope 的粒度是设计题,不是越细越好。按「一个用户能理解的授权单元」切:files:read、files:write、issues:write,而不是按每个工具切一个 scope。太细会让用户在授权页面上点几十次,最终形成「全部同意」的肌肉记忆;太粗则一次授权覆盖所有危险动作。可行的折中是「粗 scope + 每工具映射 + 运行时 step-up」:初始只申请只读,遇到写操作返回 403 insufficient_scope,客户端把当前 scope 与挑战里的 scope 取并集再授权,且限制重试次数。
2.8.1 token 透传与 confused deputy
规范里有两句很短、但经常被忽略的 MUST:MCP server 不得接受不是发给它的 token;不得接受或中转任何其他 token。违反这两句,就会得到 token passthrough(透传)与 confused deputy(混淆代理)。confused deputy 的成立需要两个条件:代理拥有调用者没有的权限;代理在调用者的请求上使用了该权限,却没有校验调用者是否有权。
某内部 MCP 网关 G 暴露 github.issue.comment 等工具。为了「省一次授权」,G 把客户端发来的 Authorization: Bearer 原样转发给 api.github.com;当 GitHub 拒绝这个 token 时,G 回退到自己的服务账号 token(repo scope)重试。
触发条件。 客户端 token 是 G 的 AS 签发的,audience 是 https://mcp.internal/gateway,scope 只有 issues:read。GitHub 当然不接受它——这不是 GitHub 签发的 token,audience 也不是它。G 的回退逻辑让请求以服务账号身份成功,但调用者只有只读意图。
观测证据。 三处日志对不上:G 的访问日志里,token 哈希与上游请求头里的 token 哈希相同,说明发生了透传;GitHub 侧审计日志的 actor 全是 svc-mcp-gateway,没有真实用户;G 自己的 scope 检查日志写的是 scope_check=present,而不是 scope_check=issues:write——它只检查了 token 存在。
根因。 两个错误叠加:一是把「认证」当成了「授权」,只要 token 能解析就放行;二是用服务账号兜底,把调用者的权限替换成了网关的权限。前者让 audience 失效,后者让 confused deputy 成立。攻击者不需要偷 token,只要让 G 替自己调用即可。
修复。 ① G 作为资源服务器校验 aud == https://mcp.internal/gateway,不匹配返回 401;② 永不转发入站 token,上游调用使用独立凭证,或用 RFC 8693 token exchange 换取 audience=github 的 token;③ 每个工具声明所需 scope,github.issue.comment 要求 issues:write,缺失返回 403 insufficient_scope;④ 审计记录 subject + tool + credential_id,不记录 token 本身;⑤ 回归用例断言:只读 token 调用写工具返回 403,且上游零请求。
修复后的行为。 同一调用在 G 的入口就被拒绝,GitHub 侧没有任何请求;审计日志能回答「谁、通过哪个工具、用了哪个凭证」。代价是用户需要为 GitHub 单独授权一次——这正是 audience 绑定换来的可归责性。
上线一个受保护的 MCP server 前,逐条确认:
401是否带WWW-Authenticate,其中有resource_metadata与最小scope;- 是否实现 RFC 9728 元数据,且
resource与 server 的规范 URI 完全一致; - 是否校验 token 的
aud等于自己,而不是「能验签就放行」; - 是否拒绝入站 token 转发到任何上游;上游凭证是否独立、可轮换;
- 每个工具是否映射到最小 scope,缺失时返回
403与insufficient_scope; - 授权码流程是否强制 PKCE,是否校验
iss与state; - 客户端注册是否优先 CIMD,DCR 是否仅作兼容;
- 审计是否记录主体、工具、凭证 id 与决策,而非 token 明文;
- 获取元数据的 URL 是否限制为 HTTPS、是否拒绝回环/私网/重定向(防 SSRF);
- 本地 stdio server 是否从环境取凭证,且没有被当成 HTTP token 的容器。
十条里任何一条缺失,都可能让「有 OAuth」变成「有登录页但没有授权」。
2.8.2 其余四类授权事故
SSRF。 客户端要拉取 resource_metadata 与 AS 元数据,这两个 URL 都可能由不可信的 server 提供。恶意 server 可以让客户端请求 http://127.0.0.1:8500/v1/... 或云元数据地址。控制:只允许 HTTPS、主机 allowlist、拒绝回环/链路本地/私网、禁止重定向、超时与响应大小上限、记录实际拉取的 URL。schema 的远程 $ref 同理(第 2.2 节)。
状态句柄劫持。 2025 的 Mcp-Session-Id 如果是可猜测的短字符串,攻击者可以冒充他人会话;2026 的显式 handle 也常被当成 bearer token 使用。控制:高熵、不可解析、绑定主体、每次调用校验归属、设置 TTL、泄露后可撤销。handle 是名字,不是能力——server 每次都要重新授权。
本地 server 失配。 stdio server 通常以当前用户权限运行,能读用户能读的一切。它不是安全边界,也不应接收 HTTP 世界的 token。正确做法是从环境或 OS keychain 取该 server 专用的最小凭证,并用第 3、4 章的沙箱与权限把它的能力收紧。把本地 server 当成「可信内网」是常见错觉。
开放重定向与 mix-up。 授权响应可能被重定向到攻击者控制的 URI;多 AS 环境下,客户端可能把 code 发给错误的 AS。控制:严格匹配注册的 redirect_uri、校验 iss、把 PKCE verifier 与 AS 绑定、拒绝不在元数据里的端点。
| 威胁 | 触发条件 | 主要控制 |
|---|---|---|
| token 透传 | server 把入站 token 转发上游 | 禁止转发;独立凭证或 token exchange |
| confused deputy | server 用自身权限替调用者执行 | 每工具 scope + 主体校验 + 审计 |
| SSRF | 元数据/$ref URL 指向内网 | HTTPS + allowlist + 拒绝私网 + 无重定向 |
| 会话/句柄劫持 | id 可猜测或未绑定主体 | 高熵 + 归属校验 + TTL |
| 本地 server 越权 | 以用户全权限运行 | 沙箱 + 最小凭证 + 不传 HTTP token |
| mix-up / 开放重定向 | 多 AS 或宽松 redirect | 校验 iss、严格匹配 redirect |
这张表把 2.8.1 的两类也列进来,共六类;其中只有 SSRF 是「网络层」问题,其余都是「把认证当授权」或「把句柄当能力」。授权设计的核心不是接上 OAuth,而是让每一个副作用都能回答「这是谁授权的」。
2.9 互操作与迁移:版本矩阵、弃用与事故复盘
两版协议会长期共存。迁移的第一件事不是改代码,而是画矩阵:你的客户端是什么时代,对端 server 是什么时代,组合起来会发生什么。
| 客户端 \ server | 仅 2025-11-25 | 仅 2026-07-28 | dual-era |
|---|---|---|---|
| 2025 客户端 | 正常 | initialize 返回 -32601;HTTP 可能 400 | 走 legacy 路径,正常 |
| 2026 客户端 | server/discover 返回 -32600/-32601/-32602 或无响应 → 回退 initialize | 正常 | 先 discover,选 modern |
| dual-era 客户端 | 探测失败 → initialize | discover 成功 → modern | 优先 modern,失败再 legacy |
这张表说明什么:兼容不是「支持所有版本」,而是「在每一格都有确定行为」。最危险的一格是「2026 客户端 × 仅 2025 server」:如果没有探测,客户端会把 -32600/-32601 当成致命错误,或者更糟——旧 server 不校验握手,直接把 tools/call 当 legacy 语义执行。
弃用项也要列清楚。弃用不等于立刻不可用,而是「仍能工作,但新实现不应采用,未来版本会移除」:
| 弃用项 | 状态 | 替代 | 迁移动作 |
|---|---|---|---|
| Roots | 2026 弃用 | 工具参数、资源 URI、server 配置 | 把根目录作为参数或配置传入 |
| Sampling | 2026 弃用 | 直接调用模型 provider API | 客户端自己完成补全 |
| Logging | 2026 弃用 | stderr、OpenTelemetry、每请求 logLevel | 去掉 logging/setLevel |
| DCR(RFC 7591) | 2026 弃用 | CIMD | 用 HTTPS URL 作为 client_id |
| HTTP+SSE 传输 | 2025-03-26 起弃用 | Streamable HTTP | 迁移端点与客户端 |
| tasks 核心方法 | 2026 移出核心 | io.modelcontextprotocol/tasks 扩展 | 改用 tasks/get、tasks/update |
| ping | 2026 移除 | 传输层健康检查 | 删掉应用层心跳 |
resources/subscribe | 2026 移除 | subscriptions/listen | 改为单条长响应流 |
| GET SSE 流 | 2026 移除 | subscriptions/listen | 删除 GET 端点 |
Last-Event-ID 续传 | 2026 移除 | 新 id 重发 | 去掉续传逻辑,补幂等 |
一个双时代适配器的骨架如下。关键点不是代码量,而是把「探测」「编解码」「错误映射」三件事分开,别让它们散落在业务逻辑里:
class EraAdapter:
def __init__(self, transport):
self.transport = transport
self.era = None # "2025" | "2026"
def detect(self):
if self.transport == "stdio":
resp = self.rpc("server/discover", meta=modern_meta())
if "result" in resp:
self.era = "2026"
return self.era
if resp.get("error", {}).get("code") == -32022:
# modern server,只是版本不支持:按 supported 选,不回退
self.era = "2026"
self.version = pick(resp["error"]["data"]["supported"])
return self.era
# 其他错误或超时:legacy
self.era = "2025"
self.initialize_legacy()
return self.era
# HTTP:先发 modern 请求;400 时先看 body 是否是已知 modern 错误
resp, status = self.post_modern("tools/list")
if status == 200:
self.era = "2026"
elif is_known_modern_error(resp):
raise ProtocolError(resp) # 不要回退
else:
self.era = "2025"
self.initialize_legacy()
return self.era
def call_tool(self, name, args):
if self.era == "2026":
return self.rpc("tools/call", {"name": name, "arguments": args,
"_meta": modern_meta()})
return self.rpc("tools/call", {"name": name, "arguments": args})
这段骨架省略了认证、超时与重试,但保留了最重要的分支:modern 错误不触发回退。把 -32022 当成「对方是旧版」是最常见的误判——它恰恰说明对方是新版,只是不支持你请求的版本。
- 盘点:列出每个 server 的版本、传输、认证方式。以实际抓到的帧为准,不以文档或
serverInfo为准。 - 探测:stdio 先发
server/discover;HTTP 先发一个 modern 请求并检查400响应体。把「modern 错误」与「legacy 无响应」分开。 - 适配:内部只保留一套
Invocation{id, name, args}模型;两个时代各写一个 codec,负责_meta/握手、resultType、错误码映射。 - 遥测:给每个请求打上
era、protocol_version、error_code标签;统计回退率与-32022比例。 - 契约测试:至少覆盖「新客户端×旧 server」「旧客户端×新 server」「dual-era×dual-era」三种组合,以及 stdio/HTTP 两种传输;断言错误通道,不只断言成功。
- 回滚:legacy 路径放在开关后面,保留一个版本周期;记录移除日期与负责人。
六步里,第 2 步最容易被跳过,第 5 步最容易只测「happy path」。真实事故通常发生在「探测失败后的分支」和「错误通道」上。
某客户端 2.0 按 2026-07-28 重写,删掉了 initialize,第一个请求直接发 tools/list(带 _meta),走 stdio 连接一个只支持 2025-11-25 的 server。
触发条件。 该 server 的握手是严格状态机:未收到 initialize 前,任何请求都返回 -32600 Server not initialized。客户端把 -32600 当成「请求格式错误」,没有回退,直接失败退出;外层重试重启进程三次,三次都在第一帧失败。
观测证据。 trace 里只有一种形状:tools/list → -32600,连续三轮,id 都是 1。server 的 stderr 显示进程正常启动、没有崩溃。换句话说,双方都没错,只是不在同一个时代。
根因。 客户端的回退逻辑只写在 HTTP 路径上(看到 400 才回退),stdio 路径没有探测;而且回退条件绑定在具体错误码上,而 legacy server 对未知请求的反应是实现定义的——可能是 -32600、-32601、-32602,也可能直接不响应。
修复。 ① stdio 启动后先发 server/discover;② 收到 DiscoverResult 走 modern,收到 -32022 按 supported 选版本(不回退),其他错误或超时走 initialize;③ 回退逻辑不绑定具体错误码;④ 增加「2026 客户端 × 2025 server」的契约测试;⑤ 监控 fallback_rate,突然升高说明有新 server 或代理改写了响应。
修复后的行为。 同一个 server 在一次探测往返内完成回退,随后的 tools/list 成功;trace 里能看到 server/discover → 错误帧 → initialize → notifications/initialized → tools/list 的完整链路。代价是每个新连接多一次往返,但换来了对旧 server 的确定性兼容。
把两个时代跑一遍。 协议细节读到这里,最好的验收是让两版真的跑起来。实验目录里的客户端会启动 server 子进程,逐帧打印,并对关键路径做断言:
python3 experiments/ch02/mcp_client.py --era 2026 --root /tmp/ch02-mcp \
--trace experiments/ch02/trace-2026.jsonl --quiet
python3 experiments/ch02/mcp_client.py --era 2025 --root /tmp/ch02-mcp \
--trace experiments/ch02/trace-2025.jsonl --quiet
python3 -m unittest discover -s experiments/ch02 -v
checks: ok=28 fail=0
checks: ok=19 fail=0
28 条断言覆盖 2026 的发现、列表、成功调用、参数错误、未知工具、未知方法、缺元数据、版本不支持、MRTR 往返与 requestState 篡改;19 条覆盖 2025 的握手顺序、ping、资源读取、订阅通知与 legacy 错误码。把请求序列单独抽出来看,能直接读出「哪些帧是正常路径、哪些是负例」:
jq -r 'select(.dir=="send") | [.frame.id, .frame.method] | @tsv' \
experiments/ch02/trace-2026.jsonl
1 server/discover
2 tools/list
3 tools/call
4 tools/call
5 tools/call
6 tools/call
7 tools/call
8 tools/frobnicate
9 tools/list
10 tools/list
11 tools/call
12 tools/call
13 tools/call
14 tools/call
notifications/cancelled
读这段输出:id 5、6、7、8、9、10、14 是负例(路径逃逸、类型错误、未知工具、未知方法、缺 _meta、旧版本、篡改状态);id 11 与 12 是 MRTR 的两轮,必须是两个不同的 id——它们是两次独立请求,第二次带上 inputResponses 与 requestState;最后一行的通知没有 id,因为它不需要响应。正常路径是 1–4 与 12–13,其余都在验证「失败发生在正确的层」。
测试输出(完整;耗时因机器而异):
test_call_and_legacy_error_code (test_protocol.Test2025.test_call_and_legacy_error_code) ... ok
test_discover_is_unknown_in_2025 (test_protocol.Test2025.test_discover_is_unknown_in_2025) ... ok
test_handshake_is_required (test_protocol.Test2025.test_handshake_is_required) ... ok
test_initialized_notification_is_required (test_protocol.Test2025.test_initialized_notification_is_required) ... ok
test_missing_resource_uses_legacy_code (test_protocol.Test2025.test_missing_resource_uses_legacy_code) ... ok
test_ping_logging_and_resources (test_protocol.Test2025.test_ping_logging_and_resources) ... ok
test_call_success (test_protocol.Test2026.test_call_success) ... ok
test_discover (test_protocol.Test2026.test_discover) ... ok
test_initialize_is_gone (test_protocol.Test2026.test_initialize_is_gone) ... ok
test_input_validation_is_tool_error (test_protocol.Test2026.test_input_validation_is_tool_error) ... ok
test_missing_meta (test_protocol.Test2026.test_missing_meta) ... ok
test_mrtr_request_state_is_integrity_protected (test_protocol.Test2026.test_mrtr_request_state_is_integrity_protected) ... ok
test_mrtr_round_trip (test_protocol.Test2026.test_mrtr_round_trip) ... ok
test_stdout_is_pure_json (test_protocol.Test2026.test_stdout_is_pure_json) ... ok
test_tools_list_is_stable_and_has_schemas (test_protocol.Test2026.test_tools_list_is_stable_and_has_schemas) ... ok
test_unknown_method (test_protocol.Test2026.test_unknown_method) ... ok
test_unknown_tool_is_protocol_error (test_protocol.Test2026.test_unknown_tool_is_protocol_error) ... ok
test_wrong_version (test_protocol.Test2026.test_wrong_version) ... ok
test_client_2025 (test_protocol.TestClientEndToEnd.test_client_2025) ... ok
test_client_2026 (test_protocol.TestClientEndToEnd.test_client_2026) ... ok
----------------------------------------------------------------------
Ran 20 tests in 0.652s
OK
test_stdout_is_pure_json 值得单独说:它把 server 的所有 stdout 行重新 json.loads 一遍,任何一行日志混进来都会失败。这类测试比「功能对不对」更早发现问题,因为它把传输层的硬约束变成了可执行的断言。
顺序建议:先读 changelog(知道这版改了什么),再读 versioning(知道怎么协商),再读 transports(知道帧怎么走),最后把 schema.ts 当唯一事实来源。日期版本号不是 semver,2026-07-28 与 2025-11-25 之间没有「补丁兼容」的承诺;serverInfo 是自报的,不能用于安全决策;被标为 Deprecated 的特性仍能工作,但新实现不应再采用。规范文本里的 MUST/SHOULD/MAY 是关键词,不是修辞——MUST 违反就是协议错误,SHOULD 违反要能解释原因。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| 《Linux 系统与内核》 | 2.10 信号、2.19 作业控制 | 2.6 stdio 关闭、SIGTERM/SIGKILL 升级 | 边界引用 |
| 《Linux 系统与内核》 | 文件描述符与管道 | 2.6 stdio 单通道、stdout 只写协议帧 | 边界引用 |
| 《Linux 系统与内核》 | 套接字与 TCP | 2.6 Streamable HTTP 只写 agent 侧语义 | 边界引用 |
| 《深度学习导论》 | 序列建模与解码 | 2.2 受约束解码、strict 与结构化输出 | 背景 |
| JSON Schema 2020-12 | Core、Validation | 2.2 方言、$ref 与组合关键字边界 | 背景 |
| OpenAI function calling / Responses | tools、streaming、structured outputs | 2.3 回路、并行、流式增量 | 完整 |
| MCP 2025-11-25 | Lifecycle、Transports | 2.5 握手与会话、2.6 GET 流与续传 | 完整 |
| MCP 2026-07-28 | Architecture、Tools、Transports、Authorization | 2.4–2.9 原语、无状态、MRTR、授权 | 完整 |
| OAuth 2.1、RFC 8707、RFC 9728 | resource indicator、protected resource metadata | 2.8 audience 与授权发现 | 完整 |
读这张表要抓住两点:对 OS 与网络只做边界引用,不重复讲信号、管道、TCP 的重传;对协议新增层(schema、function calling、MCP、OAuth 组合)则给到可实现的细节。表中「背景」表示只借用一个概念,正文不展开母本内容。
本章陷阱
| # | 陷阱 | 症状 | 正确做法 | 去哪 |
|---|---|---|---|---|
| 1 | 把模型输出直接当执行 | 模型说做了,实际没做;参数越权无人拦 | 意图与执行分离,宿主校验/授权 | 2.1 |
| 2 | 只查 required,不查额外字段 | server 收到多余字段后崩溃或越权 | additionalProperties:false,双侧校验 | 2.2 |
| 3 | 把 format 当校验 | 坏邮箱、坏 URL 一路通过 | 用 pattern 或业务层显式校验 | 2.2 |
| 4 | 混用 draft-07 与 2020-12 | $defs 找不到、数组语义漂移 | 显式 $schema 或只用交集子集 | 2.2 |
| 5 | 流式逐块 json.loads | 参数解析随机失败,长参数必坏 | 按 index 累积到 finish_reason | 2.3 |
| 6 | 默认并行所有工具调用 | 写冲突、重复副作用、旧观察 | 只读且无重叠才并行;写调用串行 | 2.3 |
| 7 | 漏回填某个 tool_call_id | provider 400,或模型重复调用 | 每个调用恰好一条结果,含拒绝 | 2.3 |
| 8 | 把 annotations 当权限 | 只读工具实际可写;投毒 server 绕过 | 注解不可信,权限在执行器与 server | 2.4 |
| 9 | 把 initialize 当永恒 | 新客户端对旧 server 第一帧就失败 | dual-era 探测 + 回退,不绑定错误码 | 2.5、2.9 |
| 10 | 输入校验错误走 -32602 | 模型看不到原因;宿主重试后终止 | 校验错误走 isError + 结构化原因 | 2.7 |
| 11 | 超时后无条件重试写操作 | 重复退款、重复发信 | 幂等键 + 去重表;否则不自动重试 | 2.6 |
| 12 | 接受/转发非本服务的 token | audience 失效;审计只见服务账号 | 校验 aud;禁止透传;每工具 scope | 2.8 |
这张表说明什么:十二条里,前七条偏「格式与回路」,后五条偏「边界、错误与权限」。它们的共同点是都不会靠换模型消失;换一个更强的模型,只会让越权调用发生得更流畅。第 3、4 章会把第 11、12 条展开成进程、文件、网络与 capability 的具体边界。
本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 能画出三方协议栈 | 标出模型/宿主/工具各自能决定什么 | 2.1 |
| 能写 strict 兼容 schema | 三个条件齐全,可选字段用 null 联合 | 2.2 |
能解释 required 与 additionalProperties | 各举一个误读与反例 | 2.2 |
| 能处理 function calling 回路 | 并行、流式、tool_call_id 回填都不漏 | 2.3 |
| 能说出 MCP 三原语 | 控制点、方法、副作用各说清 | 2.4 |
| 能对照两个协议版本 | 握手、会话、结果类型、MRTR、订阅 | 2.5 |
| 能选对错误通道 | 校验错误 isError,协议错误走 error | 2.7 |
| 能识别 token 透传 | 说出 audience 与 confused deputy 两个条件 | 2.8 |
| 能跑双时代实验 | 两个 era 客户端退出码 0,测试全绿 | 2.6、2.9 |
| 能设计迁移路径 | 四格矩阵 + 探测 + 回退 + 契约测试 | 2.9 |
Q1 initialize 与 server/discover 都返回版本与能力,为什么 2026 客户端还需要探测?直接把 server/discover 当握手不行吗?
Q2 工具参数「金额超过上限」为什么应该返回 isError,而不是 -32602 Invalid params?如果返回 -32602,会发生什么?
Q3 一个网关把客户端的 token 原样转发给上游 API,为什么违反 audience?要让 confused deputy 成立,还需要哪个条件?
答案
A1 两者语义不同。initialize 是 legacy 的连接级握手:协商一次,之后的请求依赖连接上下文,HTTP 上还可能建立 Mcp-Session-Id 会话。server/discover 是 modern 的普通 RPC:返回 supportedVersions、capabilities、serverInfo,不建立会话,也不改变后续请求的语义。2026 客户端探测的目的不是「握手」,而是识别对端是哪个时代:stdio 没有 HTTP 状态码,若直接发 tools/list,旧 server 可能返回 -32600/-32601,也可能因为不校验握手而按 legacy 语义执行 tools/call。探测给出确定性失败,再决定走 modern 还是 initialize。注意 -32022 说明对方是 modern 只是版本不支持,应换版本,不应回退。
A2 输入校验错误是模型可纠正的:它能看到字段名、上限、当前值,然后改参数重试。isError: true 的结果会被回填进对话,模型有机会自纠;-32602 是协议错误,宿主通常不会回填给模型,而是重试请求、切换版本或终止任务。反例:server 对「金额超上限」返回 -32602,宿主把 JSON-RPC error 当传输失败,重试三次相同请求后放弃,模型从未看到「上限是 20000」。只有请求结构本身非法——缺 name、arguments 不是对象、JSON-RPC 字段缺失——才应该用 -32602。
A3 audience 绑定 token 的目标资源:token 只对签发时指定的 resource 有效。网关把 token 转发给上游,等于让一个「发给网关」的凭证在另一个服务上使用,接收方无法确认 token 是不是发给自己的,也无法把 scope 限制在网关的工具边界内。confused deputy 还需要第二个条件:网关拥有调用者没有的权限,并且在调用者的请求上使用了这些权限,却没有校验调用者是否有权。两个条件叠加,调用者就通过网关完成了自己无权完成的操作;审计里看到的还是网关的服务账号。
下一站:3 沙箱 把「工具能做什么」收紧为「进程、文件、网络、资源四层边界」,让协议里的授权承诺落到内核能执行的限制上。