Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 与协议级会话去掉了,新实现不应再把握手当默认前提。 同一份工具契约,穿过四处边界 模型 provider tool_calls / JSON 参数 宿主 runtime 校验、授权、调度 MCP client JSON-RPC 2.0 请求 MCP server / 工具 进程、文件、网络副作用 线上传输的是「意图 + 参数」,不是权限 tools/call 请求 {"jsonrpc":"2.0","id":7,"method":"tools/call", "params":{"name":"fs.read","arguments":{"path":"/work/a.txt"}, "_meta":{"traceparent":"00-..."}}} 宿主必须自己回答四问 ① schema 合法? ② 这个调用者有权? ③ 参数是否来自不可信数据? ④ 失败如何回传? 协议只规定格式,授权与副作用由宿主与 server 负责。 2025-11-25 initialize 握手 + 会话 2026-07-28 无状态 + server/discover + MRTR 弃用窗口 Roots / Sampling / Logging
读图:从左到右是控制流,不是信任流。模型产出的只是 tool_calls;宿主做校验、授权、路由;MCP 只负责把调用送到 server。读到中间的请求帧要意识到:参数可以来自网页、文件或另一个工具的输出,所以「schema 合法」不等于「可以执行」。底部两行是版本坐标:新版把 initialize 与协议级会话去掉了,新实现不应再把握手当默认前提。

读图:从左到右是控制流,不是信任流。模型只产出 tool_calls;宿主做校验、授权、路由;MCP 只负责把调用送到 server。中间的请求帧里,参数可能来自网页、文件或另一个工具的输出,所以「schema 合法」不等于「可以执行」。底部两行是版本坐标:新版把 initialize 与协议级会话去掉了,新实现不应再把握手当默认前提。

2.1 三方与分层:模型只产出意图,宿主负责执行

先看一次调用里三个角色各自能决定什么。把这张表记住,后面所有协议细节都能挂上去。

角色产出能决定不能决定
模型 providertool_calls 或文本选哪个工具、参数草稿是否执行、权限够不够、结果是否可信
宿主 runtime校验后的调用执行/拒绝/重试/裁剪/审批工具内部的业务语义
工具 / MCP serverresult 或错误真实副作用与错误码调用者的意图是否合法

很多实现把这张表压成一行:模型输出 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 不匹配,都能在造成副作用之前被拦下。

定义 2.1.1 · 工具协议

一个工具协议是三元组

Π=(E, D, R) \Pi = (E,\ D,\ R)

其中 EE 是能力声明编码:工具名、描述、输入/输出 schema、注解;DD 是调用编码:调用 id、工具名、参数、请求元数据;RR 是结果编码:结果类型、内容、结构化数据、错误通道。一个可用的协议还必须满足四条不变量:

  1. 对应性:每个调用恰好得到一个结果(执行、拒绝或错误),结果的 id 属于该调用;
  2. 可判定性:收到一帧时,能判断它属于哪个版本、哪个方法、哪次调用;
  3. 错误分类稳定:同一类失败总走同一条通道,不因实现而异;
  4. 能力可协商:未声明的能力不会被调用,声明了也不等于被授权。

前两条让通信可恢复,后两条让失败可纠正。少了任何一条,协议退化成「双方都猜」。

这个三元组是第 1 章八字段工具契约 定义 1.4.1 的线上投影:给模型看的部分变成 EE,执行器内部的前置条件、幂等性、权限留在宿主;两者不是替代关系,而是同一契约的两个视图。

同一份意图,在两层协议里有两种编码。模型 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 仍可约束
字段依赖dependenciesdependentRequired / 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 真正生效,核心是三条结构要求:

  1. 根是 object,且每一层 object 都写 "additionalProperties": false;
  2. 每个在 properties 里出现的键都必须列进 required;
  3. 「可选」用可空类型表达(例如 ["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。这不是冗余,而是为了让受约束解码有确定的形状——模型不能「忘记」一个字段,只能显式选择空值。

定义 2.2.1 · strict 兼容

一个 schema 对某 provider 的 strict 模式兼容,当且仅当:

  1. 它落在该 provider 支持的 JSON Schema 子集内;
  2. 每个 object 节点都禁止额外属性;
  3. 每个 object 节点的 properties 键都在同层 required 中;
  4. 可选性只由 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 本身也是不可信输入。

关于 JSON Schema 的五个误解
  1. 「required 会禁止额外字段」——它只检查存在性;禁止额外字段要 additionalProperties: false。
  2. 「additionalProperties: false 就安全了」——它只约束形状。路径穿越、越权 ID、注入内容都可能是合法字符串。
  3. 「format: "email" 会校验邮箱」——默认它是注解,很多校验器不实现;要校验就写 pattern 或在业务层查。
  4. 「draft-07 与 2020-12 可以混用」——同一份 schema 只能按一个方言解释;混用会导致 $defs、items、unevaluatedProperties 行为不一致。
  5. 「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 CompletionsResponses API
工具定义{type, function:{name, description, parameters, strict}}{type, name, description, parameters, strict}(扁平)
调用项choices[0].message.tool_calls[]output[] 中 type: "function_call" 的项
关联 idtool_call_idcall_id
回填role: "tool" 消息function_call_output 项
续跑重发完整 messagesprevious_response_id 或重发 input
流式delta.tool_callsresponse.function_call_arguments.delta 等事件
结构化输出response_format.json_schematext.format

这张表说明什么:适配器不能只改字段名,还要改「状态放在哪」。Chat Completions 把历史放在 messages 里,Responses 把一部分历史放在服务端的 response 对象里;前者更容易审计与重放,后者更省往返。选哪个取决于你的可观测性与数据保留要求,不是哪个更新。

结构化输出(structured outputs)与工具参数是两件事。前者约束最终答案的形状,后者约束动作参数的形状。两者都用 JSON Schema,但失败语义不同:答案 schema 失败通常重试或降级为文本;参数 schema 失败应该变成一条 isError 观察,让模型改参数(第 2.7 节)。

定义 2.3.1 · 工具调用回路

一次工具调用回路包含五步:

  1. 宿主声明能力:tools[],含名字、描述、参数 schema;
  2. 模型产出调用:id、name、arguments(字符串);
  3. 宿主解码:解析 JSON、按 schema 校验、按策略授权;
  4. 执行:串行或并行,产生结果或错误;
  5. 回填:以同一 id 追加结果,重新请求模型。

回路必须满足

#calls=#results,id(ri)=id(ci) \#\text{calls} = \#\text{results},\qquad \text{id}(r_i)=\text{id}(c_i)

其中未执行的调用也必须有一个显式结果(拒绝或错误),否则回路不完整。回路允许在任意一步终止(预算、权限、人工中止),但终止必须留下与调用 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 异常,应重试一次而不是当成功。

不同 provider 的字段名不统一

OpenAI 的 Chat Completions 与 Responses、Anthropic 的 tool use、Google 的 function calling,字段名与流式增量格式都不同。生产代码应把「模型适配器」与「工具执行器」分开:适配器只负责把各家响应归一化成内部的 Invocation{id, name, args},执行器只认这个内部结构。这样换模型不会改工具层,换工具也不会改模型层。

关于 function calling 的四个误解
  1. 「流式可以逐块 json.loads」——参数片段可能在任意字节处切开;只能按 index 累积到结束再解析。
  2. 「并行默认更快」——并行只对独立只读调用成立;写调用并行会制造竞态与重复副作用。
  3. 「只回填最后一个结果就行」——每个调用都要有对应结果;漏回填会被 provider 拒绝,或被模型误读为未执行。
  4. 「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 注解只是提示

注解默认值含义不能拿来做什么
readOnlyHintfalse工具是否只读不能替代服务端鉴权
destructiveHinttrue是否可能破坏性修改不能替代人工确认
idempotentHintfalse重复调用是否等价不能替代幂等键
openWorldHinttrue是否与开放世界交互不能替代出口过滤

规范明确要求:除非 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 的语义是「用户点了一下这个模板」。

定义 2.4.1 · 原语与能力位

一个 MCP 原语由三部分组成:

  1. 方法集:该原语允许的 JSON-RPC 方法(如 tools 的 tools/list、tools/call);
  2. 控制点:谁决定是否使用(模型 / 应用 / 用户);
  3. 副作用类:只读、可逆写、不可逆写。

能力位(capability)是 server 对「我会回答这些方法」的承诺,例如 capabilities.tools.listChanged。能力位缺失时,客户端不应调用对应方法;能力位存在也不代表授权通过——它只说「这个功能存在」,不说「你这个调用者可以用」。2025-11-25 的能力位在 initialize 时协商一次,2026-07-28 改为每个请求在 _meta 中携带客户端能力。

能力协商要回答三个问题:对方会不会回答这个方法(能力位)、我这个请求需不需要对方的能力(例如 MRTR 需要 elicitation)、回答了是否等于授权(不等于)。前两个问题决定「能不能调」,第三个问题决定「该不该执行」。很多实现只检查第一个,于是把「server 声明了 tools」误当成「这个调用者可以用所有 tools」。

MCP 是菜单、点单与小票

tools/list 是菜单,tools/call 是点单,result 是小票。菜单写得再清楚,也不代表厨房有食材、你有钱付账、你点的菜不会被别人掉包。协议保证的是「你点的和我做的能对上」,不是「这顿饭一定安全」。

关于 MCP 原语的五个误解
  1. 「resources 就是文件」——URI 可以指向数据库行、API 对象、内存缓冲区;文件只是最常见的一种。
  2. 「prompts 是 system prompt」——prompts 是用户选择的消息模板;自动注入会绕过用户意图,也会放大注入面。
  3. 「annotations 是权限声明」——它只是提示,且默认不可信;权限必须在宿主与 server 两侧独立检查。
  4. 「工具越多能力越强」——工具越多,模型的选择空间与误用面越大;按任务分层、按需发现,比一次性塞 200 个工具更可靠。
  5. 「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 处理,而不是判协议错误。

定义 2.5.1 · modern / legacy / dual-era

按版本与元数据位置,实现分三类:

  • 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/createMRTR:inputRequests / inputResponses
订阅HTTP GET SSE 流 + resources/subscribesubscriptions/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 Schemadraft-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-252026-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。

探测顺序:先 discover,再决定

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 却在用旧规则执行。

关于生命周期的四个误解
  1. 「initialize 会一直存在」——2026-07-28 已移除;modern server 收到 initialize 返回 -32601。
  2. 「无状态就是 server 不能有状态」——协议不替你保存状态;购物车、事务、长任务要用显式 handle,由客户端在参数里带回。
  3. 「server/discover 是可选的,所以可以不做」——客户端可以选不调用,但 server 必须实现;这是规范里的 MUST。
  4. 「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 断流、会话过期都属于「不确定」,必须靠幂等键或去重表兜底;没有这两样,重试就是在赌。

定理 2.6.1 · 重试不能凭空产生恰好一次

设客户端发出请求后,在收到响应前观察到一个超时。此时服务端状态只有两种可能:已执行(e=1e=1)或未执行(e=0e=0),而客户端对两者的观察完全相同。若客户端不携带稳定幂等键、服务端也不维护去重表,则不存在任何仅依赖该观察的确定性策略,能使所有执行历史的副作用次数都恰好为 1。

证明

客户端只能基于观察做决策,而 e=0e=0 与 e=1e=1 产生同一个观察,所以策略在两种状态下给出同一个动作。若动作为「重试」:在 e=0e=0 的历史里副作用变为 1,在 e=1e=1 的历史里变为 2。若动作为「放弃」:在 e=0e=0 的历史里为 0,在 e=1e=1 的历史里为 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」讲清。

关于传输的四个误解
  1. 「超时就等于没执行」——写超时是最危险的失败;必须用幂等键或查询外部状态确认。
  2. 「SSE 断了可以续传」——2025 有 Last-Event-ID,2026 移除了;断流请求要用新 id 重发。
  3. 「stdio 可以用 print 调试」——stdout 只能写协议帧;调试写 stderr,或用 logging 配到 stderr。
  4. 「重试三次就可靠」——重试放大尾延迟与副作用;没有去重表,重试次数越多,重复执行概率越高。

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-32603server
资源不存在(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 让宿主处理。规范明确把「输入校验错误」列为工具执行错误,而不是协议错误,原因就在这里。

例题 2.7.1 · 校验错误走错通道

某退款工具的 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——它们既可能泄密,也会把模型的注意力带偏。

一个可复用的错误对象建议包含以下六个字段,缺一个就会让某一方靠猜:

字段类型作用反例
codestring稳定的机器码,跨版本不改直接复用 EINVAL 表示所有失败
messagestring一句话说清「哪里错、期望什么」"error"、"failed"
retryableboolean同一请求重试是否有意义权限错误标 true
fieldstring定位到参数路径只给整体「参数错误」
suggested_actionstring给模型或宿主的下一步所有错误都写「请重试」
detailobject结构化补充(上限、当前值)塞完整栈或 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 403scope 不足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。

一次完整的授权流程:

  1. client 不带 token 请求,server 返回 401,并在 WWW-Authenticate 里给出 resource_metadata 与所需 scope;
  2. client 拉取受保护资源元数据(RFC 9728),从中找到 AS;
  3. client 通过 AS 元数据(RFC 8414 或 OIDC Discovery)找到授权端点与 token 端点;
  4. client 注册:优先用 CIMD(Client ID Metadata Documents),退回预注册或 DCR;
  5. client 生成 PKCE 参数,带 resource(RFC 8707)发起授权请求;
  6. 用户授权后,client 用 code + verifier + resource 换 token,并校验 iss(RFC 9207);
  7. 之后每次请求都带 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 6750Bearer token 的使用与错误token 放 query、错误语义混乱
RFC 9728受保护资源元数据客户端不知道 AS 在哪
RFC 8414 / OIDC DiscoveryAS 元数据端点靠猜,容易指向攻击者
RFC 8707resource 指示器,绑定 audiencetoken 可被拿到别的服务复用
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 的成立需要两个条件:代理拥有调用者没有的权限;代理在调用者的请求上使用了该权限,却没有校验调用者是否有权。

例题 2.8.1 · 一次 token 透传事故的复盘

某内部 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 绑定换来的可归责性。

方法 2.8.1 · 授权检查清单

上线一个受保护的 MCP server 前,逐条确认:

  1. 401 是否带 WWW-Authenticate,其中有 resource_metadata 与最小 scope;
  2. 是否实现 RFC 9728 元数据,且 resource 与 server 的规范 URI 完全一致;
  3. 是否校验 token 的 aud 等于自己,而不是「能验签就放行」;
  4. 是否拒绝入站 token 转发到任何上游;上游凭证是否独立、可轮换;
  5. 每个工具是否映射到最小 scope,缺失时返回 403 与 insufficient_scope;
  6. 授权码流程是否强制 PKCE,是否校验 iss 与 state;
  7. 客户端注册是否优先 CIMD,DCR 是否仅作兼容;
  8. 审计是否记录主体、工具、凭证 id 与决策,而非 token 明文;
  9. 获取元数据的 URL 是否限制为 HTTPS、是否拒绝回环/私网/重定向(防 SSRF);
  10. 本地 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 deputyserver 用自身权限替调用者执行每工具 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-28dual-era
2025 客户端正常initialize 返回 -32601;HTTP 可能 400走 legacy 路径,正常
2026 客户端server/discover 返回 -32600/-32601/-32602 或无响应 → 回退 initialize正常先 discover,选 modern
dual-era 客户端探测失败 → initializediscover 成功 → modern优先 modern,失败再 legacy

这张表说明什么:兼容不是「支持所有版本」,而是「在每一格都有确定行为」。最危险的一格是「2026 客户端 × 仅 2025 server」:如果没有探测,客户端会把 -32600/-32601 当成致命错误,或者更糟——旧 server 不校验握手,直接把 tools/call 当 legacy 语义执行。

弃用项也要列清楚。弃用不等于立刻不可用,而是「仍能工作,但新实现不应采用,未来版本会移除」:

弃用项状态替代迁移动作
Roots2026 弃用工具参数、资源 URI、server 配置把根目录作为参数或配置传入
Sampling2026 弃用直接调用模型 provider API客户端自己完成补全
Logging2026 弃用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
ping2026 移除传输层健康检查删掉应用层心跳
resources/subscribe2026 移除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 当成「对方是旧版」是最常见的误判——它恰恰说明对方是新版,只是不支持你请求的版本。

方法 2.9.1 · 版本迁移六步
  1. 盘点:列出每个 server 的版本、传输、认证方式。以实际抓到的帧为准,不以文档或 serverInfo 为准。
  2. 探测:stdio 先发 server/discover;HTTP 先发一个 modern 请求并检查 400 响应体。把「modern 错误」与「legacy 无响应」分开。
  3. 适配:内部只保留一套 Invocation{id, name, args} 模型;两个时代各写一个 codec,负责 _meta/握手、resultType、错误码映射。
  4. 遥测:给每个请求打上 era、protocol_version、error_code 标签;统计回退率与 -32022 比例。
  5. 契约测试:至少覆盖「新客户端×旧 server」「旧客户端×新 server」「dual-era×dual-era」三种组合,以及 stdio/HTTP 两种传输;断言错误通道,不只断言成功。
  6. 回滚:legacy 路径放在开关后面,保留一个版本周期;记录移除日期与负责人。

六步里,第 2 步最容易被跳过,第 5 步最容易只测「happy path」。真实事故通常发生在「探测失败后的分支」和「错误通道」上。

例题 2.9.1 · 旧版 initialize 迁移事故

某客户端 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 系统与内核》套接字与 TCP2.6 Streamable HTTP 只写 agent 侧语义边界引用
《深度学习导论》序列建模与解码2.2 受约束解码、strict 与结构化输出背景
JSON Schema 2020-12Core、Validation2.2 方言、$ref 与组合关键字边界背景
OpenAI function calling / Responsestools、streaming、structured outputs2.3 回路、并行、流式增量完整
MCP 2025-11-25Lifecycle、Transports2.5 握手与会话、2.6 GET 流与续传完整
MCP 2026-07-28Architecture、Tools、Transports、Authorization2.4–2.9 原语、无状态、MRTR、授权完整
OAuth 2.1、RFC 8707、RFC 9728resource indicator、protected resource metadata2.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_reason2.3
6默认并行所有工具调用写冲突、重复副作用、旧观察只读且无重叠才并行;写调用串行2.3
7漏回填某个 tool_call_idprovider 400,或模型重复调用每个调用恰好一条结果,含拒绝2.3
8把 annotations 当权限只读工具实际可写;投毒 server 绕过注解不可信,权限在执行器与 server2.4
9把 initialize 当永恒新客户端对旧 server 第一帧就失败dual-era 探测 + 回退,不绑定错误码2.5、2.9
10输入校验错误走 -32602模型看不到原因;宿主重试后终止校验错误走 isError + 结构化原因2.7
11超时后无条件重试写操作重复退款、重复发信幂等键 + 去重表;否则不自动重试2.6
12接受/转发非本服务的 tokenaudience 失效;审计只见服务账号校验 aud;禁止透传;每工具 scope2.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,协议错误走 error2.7
能识别 token 透传说出 audience 与 confused deputy 两个条件2.8
能跑双时代实验两个 era 客户端退出码 0,测试全绿2.6、2.9
能设计迁移路径四格矩阵 + 探测 + 回退 + 契约测试2.9
即时自测 · 2

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 沙箱 把「工具能做什么」收紧为「进程、文件、网络、资源四层边界」,让协议里的授权承诺落到内核能执行的限制上。