前置讲 P2 · 网络、HTTP 与 API · Networks, HTTP, and APIs

Engineering AI Agents · 预备知识 2。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

这是一篇前置讲,不对应 CS329Z 的某一次课。它补的是”智能体怎样和外界说话”这一层:调用 LLM、调用外部 API、连接 MCP 服务器,底下都是 HTTP 或 JSON-RPC。支撑第 2 讲(LLM 的 API)、第 3 讲(检索服务)、第 4 讲(工具使用与 MCP)、第 14 讲(安全)。 依据说明:HTTP、JSON、JSON Schema 的内容是通用标准。MCP 的部分依据官方规范 2025-06-18 版(Lifecycle、Transports、Server/Tools 三节;原课第 4 讲的指定阅读就是 MCP 规范),凡是引用规范硬性要求(MUST / SHOULD)的地方都注明了。错误码、重试策略那几段参考了 Claude API 的公开文档和原课作业 1 的 handout。 配套代码 Code:code/prereq2/(只用 Python 标准库;4 个脚本 + 2 个模块;在你自己的电脑上起真的 HTTP 服务器和 MCP 服务器,不联网;最慢的脚本约 1.5 秒) 预计阅读时间:3–4 小时(含运行代码)


0. 本讲导航 · How to read this lecture

0.1 为什么智能体工程师要懂这些

前置讲 P1 让智能体作用于本机(终端、文件)。这一讲让它够到网络。智能体几乎所有有用的能力都在网络那一头:

  • 它的”大脑”是一个 HTTP API(POST /v1/messages);
  • 它的工具(检索、日历、工单系统、代码仓库)是别人的 API;
  • 今天把工具接给模型的标准方式是 MCP,而 MCP 就是”JSON-RPC 消息 + 一次握手”,跑在 stdio 或 HTTP 上。

这一层出问题的方式特别多:网络会断、服务器会忙、密钥会过期、模型会写出不合格式的参数。原课作业 1 的 LM 封装之所以自带缓存、账本、预算,就是因为这些问题在一个智能体里会被步数放大几十倍。

这一讲的做法是:在你自己的电脑上把这些东西从零搭一遍——一个玩具公司 API(net_lab.py)、一个能用的客户端、一个迷你 MCP 服务器(mcp_lab.py),两种传输都实现。全部只用标准库,不联网,所以每一层都能打开看。

0.2 学习目标 Learning goals

  1. 说清楚一次 HTTP 请求 / 响应的结构(请求行、头部、空行、正文),GET 与 POST 的区别和各自该放什么,状态码的五类分别意味着什么。
  2. 知道 JSON 能表示什么、不能表示什么,会用 JSON Schema 描述一个工具的参数,并写出能让模型改对的校验说明。
  3. 会写一个”能用”的 API 客户端:认证(密钥放哪里、绝不放哪里)、超时、按错误类型决定重不重试、指数退避、尊重 Retry-After、幂等性、记账。
  4. 读懂 SSE 流式输出,说出流式对智能体的两个价值。
  5. 说清楚 JSON-RPC 2.0 的三种消息,以及它和 REST 的区别。
  6. 从零实现一个 MCP 服务器:握手(版本与能力协商)、tools/list、tools/call;分清协议错误和工具执行错误;在 stdio 和 Streamable HTTP 两种传输上跑同一套逻辑,并做对会话 id、协议版本头、Origin 校验。

0.3 本讲地图 Map

§1  客户端 / 服务器与一次请求的一生                                   ← 代码 01
§2  HTTP 的结构:请求行、头部、正文;GET vs POST                       ← 代码 01
§3  状态码与错误体                                                    ← 代码 01
§4  JSON 与 JSON Schema:工具参数的校验                                ← 代码 02
§5  一个"能用"的客户端:认证、超时、重试、幂等、记账                     ← 代码 03
§6  流式输出(SSE)                                                   ← 代码 03
§7  JSON-RPC 2.0                                                      ← 代码 04
§8  MCP:握手、工具、两种传输、两种错误                                 ← 代码 04
§9  小结   §10 常见误区   §11 练习   §12 延伸阅读   §13 下一讲

1. 客户端、服务器,和一次请求的一生 · Client, server, request

最小的网络模型只有两个角色:服务器在某个地址上等着(“监听”一个端口),客户端主动连上去、发一段数据、读回一段数据。

一个地址由两部分组成:主机(IP 地址或域名)和端口(0–65535 的一个整数)。http://127.0.0.1:61286 的意思是”本机的 61286 号端口”。127.0.0.1(也写作 localhost)是一个特殊地址:只有这台机器自己能连。本讲的所有代码都只用它——所以不需要联网,也不会把任何东西暴露给外界。

一次请求大致要经过:

域名 → IP(DNS 查询)→ 建立 TCP 连接(三次握手)→ [HTTPS 时再建立 TLS 加密]
      → 发请求字节 → 服务器处理 → 回响应字节 → 关闭或复用连接

每一步都可能出错或变慢,这就是 §5 要处理的全部内容。我们的代码用 http://(没有加密),因为只在本机;任何真实的 API 一定是 https://——否则密钥和数据在网络上是明文。

代码 01 的演示 1 用一个裸 socket(最底层的网络接口)手写了一次请求,这样能看见网线上真正传的是什么。


2. HTTP 的结构 · The shape of an HTTP message

2.1 请求和响应都是”有格式的文字”

GET /health HTTP/1.1                         ← 请求行:方法 路径 版本
Host: 127.0.0.1:<port>                       ← 头部(每行一个 名字: 值)
User-Agent: cs329z-demo/1.0
Accept: application/json
Connection: close
                                             ← 空行:头部到此结束
                                             ← (正文,GET 通常没有)

服务器回的:

HTTP/1.1 200 OK                              ← 状态行:版本 状态码 原因
Server: ToyAPI/1.0 Python/<版本>
Date: <当前时间>
Content-Type: application/json; charset=utf-8
Content-Length: 12
                                             ← 空行
{"ok": true}                                 ← 正文

几个细节值得记住:

  • 每行以 CRLF(\r\n)结尾,不是单个换行——协议规定。
  • 空行是”头部结束”的唯一标志。
  • Host 头是必需的(一台服务器可以用一个 IP 托管很多域名)。
  • Connection: close 让服务器回完就断开;不写的话 HTTP/1.1 默认保持连接(keep-alive)等你发下一个请求,于是一个”读到连接关闭为止”的客户端会一直等到超时。
  • 正文没有自带的结束标志,必须靠 Content-Length(字节数)或分块传输(§6)告诉对方有多少。

后面所有的东西——REST、LLM 的 API、MCP 的 HTTP 传输——都只是在这段文字上加约定。

2.2 方法:GET 还是 POST,参数放哪里

代码 01 的演示 2 对比了两种:

GET  /emails?urgent=true&limit=2        参数在 URL 的查询串里
POST /digest   正文 {"date": "2026-06-10", "max_items": 3}     参数在请求体里

按约定(HTTP 的语义):

GETPOST
语义只读、可重复(“安全”且幂等)可能有副作用
参数位置URL 查询串请求体(通常是 JSON)
能不能缓存能不能
重试是否安全安全要看(§5.4)

一条安全规则由此而来:敏感信息绝不放进 URL。URL 会进服务器日志、代理日志、浏览器历史、Referer 头。密钥、个人信息都放请求头或请求体。(这也是本课安全守则里的一条:不要把个人数据放进 URL 参数。)

2.3 头部,以及写错 Content-Length 的后果

头部是关于这次请求 / 响应的元信息:正文是什么类型(Content-Type)、多长(Content-Length)、我是谁(Authorization、User-Agent)、能不能缓存(Cache-Control)。

Content-Length 必须是正文的字节数(不是字符数——一个汉字 3 个字节,P1 §6.4)。代码 01 的演示 4 故意把它写成 10(实际 22 字节):

故意把 Content-Length 写成 10(实际 22 字节):服务器只读了前 10 个字节
  → HTTP/1.1 400 Bad Request
  → 它看到的是 '{"date": "',于是报错:body is not valid JSON: Unterminated string starting

写小了对方截断,写大了对方会一直等剩下的字节直到超时。自己拼 HTTP 很容易在这里出错——这就是用 HTTP 库的理由。


3. 状态码与错误体 · Status codes and error bodies

状态码的首位就是分类:

类含义客户端该做什么
2xx成功继续
3xx重定向(资源在别处)按 Location 再发一次
4xx你的请求有问题改请求再发;重试没有意义
5xx服务器出问题了可以重试(§5.3)

代码 01 的演示 3 把常见的几种都跑了一遍:

请求                        状态码  错误类型               客户端该做什么
GET /health(不需要认证)      200  -                      继续
GET /emails 不带 token         401  authentication_error   改请求再发
GET /nope 路径不存在           404  not_found_error        改请求再发
POST /digest 请求体缺 date     400  invalid_request_error  改请求再发
POST /digest 日期格式不对      400  invalid_request_error  改请求再发
GET /flaky 服务器临时故障      503  api_error              重试(指数退避)

几个要点:

  • 4xx / 5xx 的分界决定客户端的行为,这是 §5.3 重试逻辑的依据。
  • 好的 API 除了状态码还会回一个机器可读的错误体。我们的玩具 API 回 {"error": {"type": ..., "message": ...}}——Claude API 的错误体就是这个形状。客户端应该按 type 分类,而不是去匹配人类可读的句子(它随时可能改)。
  • 401(没有通过认证)和 403(认证了但没权限)是两回事;429 是”太频繁了”(§5.3);413 是”请求太大”(给 LLM 发了超长的上下文时会遇到)。

值得记住的几个具体状态码:400 请求格式错、401 密钥无效、403 无权限、404 不存在、408 请求超时、409 冲突、413 请求过大、429 限速、500 服务器内部错误、502/503/504 网关或服务不可用、529 过载。


4. JSON 与 JSON Schema · Data and its contract

4.1 JSON 的类型系统

JSON 只有六种类型:对象(键必须是字符串)、数组、字符串、数字、true/false、null。代码 02 的演示 1 跑了几个 Python 开发者常踩的坑:

Python 的值          json.dumps 的结果       回读之后
元组 (1, 2)          [1, 2]                  **变了** → [1, 2](list)
集合 {1, 2}          报错:TypeError         -
整数作为键 {1: 'a'}  {"1": "a"}              **变了** → {'1': 'a'}(dict)
float('nan')         NaN                     **变了** → nan(float)
非常大的整数 2**70   1180591620717411303424  相同
  • 往返一圈不一定还是原来的东西:元组变数组、整数键变字符串键。
  • NaN / Infinity 不是合法的 JSON,但 Python 默认会写出来,别的语言可能解析失败(要严格就传 allow_nan=False)。
  • JSON 的数字不区分整数和浮点,所以 JSON Schema 的 integer 类型很有用。
  • ensure_ascii=False 让中文按原样输出:{"t": "体"} 是 12 字节,而默认转义成 {"t": "体"} 是 15 字节——对长中文提示词,这个差别不小。

4.2 JSON Schema:参数的”说明书”

一个工具的参数长什么样,用 JSON Schema 描述。代码 02 用的例子:

{
  "type": "object",
  "properties": {"employee_id": {"type": "string", "pattern": "^e-\\d{2}$"}},
  "required": ["employee_id"],
  "additionalProperties": false
}

这份 schema 有三个读者:

读者它用 schema 做什么
模型读描述和字段,决定调不调、参数填什么
我们的代码在执行之前校验参数,不合法就拒绝
某些 API用它做”约束解码”,从根上保证模型写出的 JSON 合法(第 2 讲)

常用关键字:type、properties、required、additionalProperties、enum、minimum / maximum、minLength / maxLength、pattern、items。net_lab.validate 实现了这个子集(真实项目请用 jsonschema 库——原课 HW1 明确要求用 jsonschema.Draft202012Validator)。

4.3 模型最常写错的参数

代码 02 的演示 3:

模型写的参数                              问题                  校验器的说明
{}                                        漏了必填字段          $: missing required property 'employee_id'
{"employee_id": "Ed Brandt"}              给了名字不是 id       $.employee_id: 'Ed Brandt' does not match pattern ^e-\d{2}$
{"employee_id": 5}                        类型错                $.employee_id: expected string, got int
{"employee_id": "e-05", "verbose": true}  多给了臆造的参数      $: unexpected properties ['verbose'] (allowed: [...])
{"team": "Firmware"}                      大小写不符 enum       $.team: must be one of ['firmware', ...], got 'Firmware'
{"limit": 999}                            超出 maximum          $.limit: 999 is above maximum 50
{"limit": "2"}                            数字写成了字符串      $.limit: expected integer, got str

几个值得记住的模式:

  • “数字写成字符串”是模型的高频错误,因为提示词里的一切本来都是文字。是严格拒绝还是帮它转换,是一个设计决策:严格更安全(错误能暴露),宽容更顺畅(少一轮往返)。
  • “多给一个臆造的参数”(verbose、format、dry_run)也很常见,additionalProperties: false 专门挡它。
  • 布尔值要单独排除:Python 里 isinstance(True, int) 为真,不小心就会让 limit=True 通过校验(写 validate 时真踩过这个坑,见代码 README)。

4.4 错误说明也是提示词

校验的结果会作为观察回到模型的上下文里(第 1 讲 §3.8),所以它是提示词的一部分:

说明的写法内容模型能据此改对吗
太简略invalid arguments不能:不知道哪个字段错了
只说位置ValidationError at $.team勉强:不知道该填什么
我们的写法$.team: must be one of ['firmware', 'analytics', 'legal'], got 'sales'能:位置 + 原因 + 允许的取值

但也别过头:把整个 schema 塞进每条错误说明里会浪费 token,而它已经在系统提示里出现过一次了。


5. 一个”能用”的 API 客户端 · A client you can rely on

一个智能体任务要发几十到几百个请求。net_lab.HttpClient 用不到 100 行实现了下面这几件事——官方 SDK 替你做的,基本就是这些。

5.1 认证:密钥放哪里

客户端        状态码  结果
不带任何凭证     401  authentication_error
带错的 token     401  authentication_error
带对的 token     200  成功

四条规则:

  1. 密钥从环境变量读,不写进代码、不提交进 git(原课 HW1 把 key 放在 .env 里,并且 .env 是 gitignore 的)。
  2. 密钥放请求头(Authorization: Bearer <token>,Claude API 用 x-api-key: <key>),不放 URL(§2.2)。
  3. 密钥不要打印:日志、错误信息、异常栈里都不该出现。
  4. 401 不要重试。

5.2 超时

超时设置  服务器要花  结果
   0.2 s       0.4 s  timeout_error(retryable=True)
     1 s       0.4 s  成功

超时是客户端的唯一保护:服务器可能因为过载、死锁、网络黑洞而永远不回话。标准库的 urlopen 不设 timeout 时会一直等。官方 SDK 的默认超时是 10 分钟(因为 LLM 生成本来就慢,而且长输出还要更久),但工具调用这类短请求应该设得短得多。注意各语言 SDK 的单位不同(Python 是秒,TypeScript 是毫秒)。

5.3 重试与退避

代码 03 的演示 3:

GET /flaky?fail_times=2(前两次 503,第三次成功)
第几次尝试  状态码  发生了什么                           之后等了几秒
         1     503  503 service temporarily unavailable           0.5
         2     503  503 service temporarily unavailable             1
         3     200  成功                                            -
退避序列:[0.5, 1.0](base_delay × 2^k)

GET /limited 第 3 次起被限速:HTTP 429(type=rate_limit_error)
服务器的 Retry-After 头说'等 1 秒',所以客户端等的是 [1.0, 1.0, 1.0]

GET /nope(404):尝试了 1 次就放弃——4xx 重试没有意义
  • 该重试的:连接错误、超时、429、5xx。官方 SDK 的默认重试列表是 408 / 409 / 429 / 5xx + 连接错误,默认重试 2 次。
  • 指数退避:第 kk 次重试等 base×2k\text{base} \times 2^k。为什么要退避?服务器正忙时所有客户端一起立刻重试会把它彻底压垮;真实客户端还会加随机抖动(jitter),避免所有客户端同时回来。
  • 服务器给了 Retry-After 就听它的——它比我们的猜测准。
  • 次数要有上限,而且上限要和超时一起算:最坏情况的总耗时 ≈ 超时 × (重试次数 + 1) + 退避总和。练习 2 会让你算一遍。

5.4 幂等性:重试安全吗

幂等(idempotent)的意思是”做一次和做多次结果一样”。GET 天然幂等;POST /digest(只是算一份摘要)也无害;但 POST /send_email 重试一次就可能发两封邮件。

超时最危险的地方在于:你不知道请求有没有到达服务器。所以:

  • 读操作放心重试;
  • 写操作要么由服务器支持幂等键(客户端给每个请求一个唯一 id,服务器识别重复),要么不自动重试、交给上层决定(对智能体来说,就是把错误告诉模型,让它判断要不要再试一次)。

这一点在智能体里尤其要紧,因为重试可能是自动发生的:模型并不知道 harness 已经悄悄重试过 3 次。

5.5 记账

代码 03 的演示 5 跑了一个很小的”任务”(查紧急邮件 → 查发件人 → 要一份摘要,其中一次故意失败重试):

指标                    值
发出的请求(含重试)     7
其中失败后重试的尝试     1
收到的响应体字节      1675
等待退避的总秒数       0.1

记账是智能体工程的基本功:一次任务的成本 = 模型调用 + 工具调用 + 重试。重试会悄悄放大成本(重试 3 次就是 4 倍)。原课 HW1 的 LM 封装自带三样东西:缓存(同样的调用不重复付费)、账本(每次调用的 token 和费用)、预算(超过就抛异常,免得失控的循环烧光你的 key)。前置讲 P3 会把这三样实现出来。


6. 流式输出 · Streaming with SSE

LLM 生成一段长回答要十几秒。Server-Sent Events(SSE)让服务器一边生成一边发:

event: delta
data: {"index": 0, "text": "Cardinal"}

event: delta
data: {"index": 1, "text": "Energy"}

event: done
data: {}

格式极简:几行 字段: 值,空行表示一条消息结束;以 : 开头的行是注释(常用来做心跳)。常用字段是 event(类型)、data(内容,可多行)、id(用于断线重连的 Last-Event-ID)。

代码 03 的演示 4 对比了”一次读完再解析”和”边收边出”(生成器)。流式对智能体有两个价值:

  1. 第一个字的延迟:用户立刻看到开头,不用干等十几秒。
  2. 能提前掐掉:发现模型跑偏了可以中断,不用等它说完——省钱。

代价是客户端复杂一些:要处理半条消息、心跳、断线重连,而且”完整的响应”要自己拼。

一个实现细节:SSE 响应的头部是 Transfer-Encoding: chunked(服务器事先不知道总长度)。成熟的 HTTP 库会自动解掉这层分块编码,所以在 Python 里我们看到的是干净的 SSE 文本;只有自己读 socket 时才需要手工解(net_lab.strip_chunked)。


7. JSON-RPC 2.0 · A transport-agnostic convention

REST 把”调什么”放在 URL 的路径里;JSON-RPC 把它放在消息的一个字段里。一条消息就是一个 JSON 对象:

请求:  {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "office_days", "arguments": {...}}}
响应:  {"jsonrpc": "2.0", "id": 1, "result": {...}}
       {"jsonrpc": "2.0", "id": 1, "error": {"code": -32602, "message": "invalid arguments: ..."}}
通知:  {"jsonrpc": "2.0", "method": "notifications/initialized"}
RESTJSON-RPC
“调什么”写在哪URL 的路径里(/employees)消息的 method 字段里(tools/list)
参数在哪查询串或请求体消息的 params 字段里
成功 / 失败HTTP 状态码消息里的 result / error 字段
要不要 HTTP要不要:stdio、HTTP、WebSocket 都行
能不能双向只能客户端发起两边都能发请求

三个要点:

  • id 是请求和响应的配对凭据:可以同时发好几个请求,回来的顺序不一定一样,靠 id 对上。
  • 通知没有 id,所以对方不回复、也没有错误报告。MCP 用它传”我准备好了”这类单向消息。
  • 标准错误码:-32700 解析错误、-32600 请求无效、-32601 方法不存在、-32602 参数无效、-32603 内部错误。

“和传输无关”是关键:怎么把这个 JSON 对象送到对面(标准输入、HTTP、WebSocket)是另一件事。这正是 MCP 能同时支持本地子进程和远程服务器的原因。


8. MCP:Model Context Protocol · 把工具接给模型的那层

MCP 听起来很大,其实只是 JSON-RPC 2.0 消息 + 一次握手 + 几个约定好的方法名。代码 04 把它从零实现了一遍(mcp_lab.py,约 400 行含注释),两种传输都做。

8.1 握手:版本与能力协商

规范要求(MUST)第一次交互就是初始化,三步:

① 客户端 → 服务器(请求)
   {"jsonrpc":"2.0","id":1,"method":"initialize",
    "params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"...","version":"..."}}}
② 服务器 → 客户端(响应)
   {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18",
    "capabilities":{"tools":{"listChanged":false}},"serverInfo":{...},"instructions":"..."}}
③ 客户端 → 服务器(通知,不回复)
   {"jsonrpc":"2.0","method":"notifications/initialized"}
  • 版本协商:客户端发自己支持的(最新)版本;服务器支持就回同一个版本,不支持就回自己支持的最新版本,客户端不接受就断开。
  • 能力协商:双方交换自己支持什么。服务器端的能力有 tools(有工具)、resources(有可读资源)、prompts(有提示词模板)、logging、completions;客户端端的有 roots(能提供文件系统根)、sampling(能帮服务器调模型)、elicitation(能向用户追问)。之后只能用双方都支持的功能。
  • 规范建议(SHOULD):服务器在收到 initialized 通知之前,除了 ping 和 logging 不要主动发请求。

8.2 工具:tools/list 与 tools/call

④ tools/list → 3 个工具:['list_employees', 'list_emails', 'office_days']
⑤ tools/call list_emails(urgent=True, limit=2) → isError=False
   content[0].text = [{"date": "2026-06-10", "from": "e-01", ...}] …

一个工具的定义是:name、可选的 title、description、inputSchema(JSON Schema),可选的 outputSchema 和 annotations。调用的结果是一个 content 数组(可以有 text、image、audio、resource_link、embedded resource 几种内容块)加一个 isError。规范还有一条安全提示:客户端必须把工具的 annotations 当作不可信,除非服务器本身可信。

8.3 两种错误:这是最容易搞错的一点

代码 04 的演示 3:

调用                                  情况                      服务器怎么回
office_days({"employee_id": "e-01"})  正常                      result,isError=False
send_email({})                        工具不存在                协议错误 code=-32601
office_days({})                       缺必填参数                协议错误 code=-32602
office_days({"employee_id": "Ed"})    参数格式不符              协议错误 code=-32602
office_days({"employee_id": "e-99"})  参数合法,但查不到这个人  result,isError=True
  • 协议错误(JSON-RPC 的 error 字段)= “这个调用本身不成立”:工具不存在、参数不合 schema。
  • 工具执行错误(正常的 result,但 isError: true)= “调用成立,但事情没办成”:查不到、API 失败、业务规则不允许。

规范特意把后者做成正常结果,因为它要进到模型的上下文里,让模型自己纠正(“e-99 不存在,我先列一下所有员工”)。如果把执行错误也做成协议错误,很多客户端会直接抛异常,模型根本看不到——这是写工具时最容易搞错的一点。第 1 讲的工具循环把它写成 ToolResult 的 status 字段,是同一个道理。

8.4 传输一:stdio(最常用)

规范说客户端应该(SHOULD)尽可能支持 stdio。它的样子是:

  • 客户端把服务器当子进程启动(P1 §1.2 的父子进程);
  • 服务器从 stdin 读、往 stdout 写;
  • 消息用换行分隔,消息内部不能有换行(所以序列化时不能缩进);
  • 服务器可以往 stderr 写日志,但绝不能往 stdout 写任何非 MCP 的内容(MUST NOT)——一句 print 调试语句就能让客户端的 JSON 解析失败,这是写 MCP 服务器最常见的 bug;
  • 关闭顺序:客户端先关 stdin(服务器读到结束自己退出),等一会儿,不退就 SIGTERM,还不退就 SIGKILL(正是 P1 §7.3)。

8.5 传输二:Streamable HTTP(远程服务器)

同一套服务器逻辑搬到 HTTP 上(代码 04 的演示 4):

POST /mcp  initialize → HTTP 200
    响应头 Mcp-Session-Id: sess-0001(之后每个请求都要带上它)
    协商出的协议版本:2025-06-18(客户端之后还要带 MCP-Protocol-Version 头)
POST /mcp  notifications/initialized → HTTP 202(通知:202 Accepted,没有响应体)
POST /mcp  tools/list → HTTP 200,3 个工具

客户端做了什么       HTTP 状态码  为什么
不带 Mcp-Session-Id          400  规范:服务器应回 400
会话 id 不存在               404  规范:回 404,客户端应重新 initialize
Origin 头是陌生网站          403  规范:必须校验 Origin(防 DNS rebinding)
DELETE /mcp(结束会话)      204

要点:

  • 服务器提供一个端点(例如 /mcp),同时支持 POST 和 GET。客户端每条消息都是一个新的 POST;请求的 Accept 头要同时列出 application/json 和 text/event-stream,因为服务器可以选择回一个 JSON 对象,也可以开一条 SSE 流。
  • 发通知或响应时,服务器接受就回 202 Accepted、没有响应体。
  • 会话:服务器可以在 InitializeResult 的响应里用 Mcp-Session-Id 头给一个会话 id;之后客户端每个请求都要带上它(服务器对缺失的可以回 400),服务器也可以随时终止会话(之后对这个 id 回 404,客户端应重新 initialize)。客户端不再需要会话时应该发 DELETE。
  • 协议版本头:握手之后客户端必须在每个请求带 MCP-Protocol-Version: 2025-06-18。
  • 三条安全要求(规范列为 MUST / SHOULD):必须校验 Origin 头(防 DNS rebinding);本地运行时只绑定 127.0.0.1 而不是 0.0.0.0;要有真正的认证。为什么 Origin 这么重要?因为你的浏览器里打开的任意网页都能向 http://127.0.0.1:... 发请求——如果本地 MCP 服务器不检查来源,一个恶意网页就能通过你的浏览器操作你本机的工具。
  • 客户端还可以用 GET 开一条 SSE 流,接收服务器主动发的消息;服务器不支持就回 405(我们的实现就是这样)。

8.6 tools/list 怎样变成模型看到的东西

代码 04 的演示 5 把服务器返回的 JSON 转成第 1 讲那种文本协议:

list_employees(team: string(可选), limit: integer(可选))
    List Cardinal employees, optionally filtered by team.
list_emails(urgent: boolean(可选), limit: integer(可选))
    List emails from the archive. Set urgent=true for urgent ones only.
office_days(employee_id: string)
    How many days per week one employee is in the office. Needs an employee id like 'e-01'.

模型看到的只是文字:工具名、描述、参数的名字和类型。它不知道 JSON-RPC、不知道子进程、不知道 HTTP——这些都是 harness 的事(第 1 讲 §3.1)。

两个工程后果:

  • 描述是提示词的一部分,要像写提示词一样认真写(Anthropic 的说法:给智能体-计算机接口投入和人机界面同样多的精力)。第 4 讲会专门讲怎样写好工具描述。
  • 工具说明在每一次模型调用里都要重新发送(第 1 讲 §3.5 的上下文账)。这三个工具的完整 JSON 约 346 token(我们的近似计数),只留名字和描述约 53 token。工具一多(接上几个 MCP 服务器就可能有几十上百个),这笔开销就很可观——于是有了”工具搜索”这类做法:先让模型检索需要的工具,再把它们的说明放进上下文。

9. 小结 · Summary

  1. HTTP 是一段有格式的文字:请求行 / 状态行、头部、空行、正文。正文的长度要靠 Content-Length(字节数)或分块传输告知。
  2. GET 只读可重复、参数在 URL;POST 可能有副作用、参数在请求体。敏感信息绝不放 URL。
  3. 状态码首位分类:4xx 是”你错了”(别重试),5xx 和 429 是”稍后再来”(重试)。好的 API 还给机器可读的错误体。
  4. JSON 只有六种类型,往返一圈不一定还是原来的东西;JSON Schema 是工具参数的说明书,同时给模型、给校验代码、给约束解码三个读者看。
  5. 一个能用的客户端 = 认证(密钥在环境变量和请求头里)+ 超时 + 按类型重试 + 指数退避 + 尊重 Retry-After + 幂等性判断 + 记账。
  6. SSE 让输出边生成边到达:第一个字更早,也能提前掐掉。
  7. JSON-RPC 2.0 是和传输无关的消息约定:请求(有 id)、通知(无 id、无回复)、响应(result 或 error)。
  8. MCP = JSON-RPC + 握手 + tools/list / tools/call,跑在 stdio(子进程,最常用,stdout 只能放协议消息)或 Streamable HTTP(会话 id、协议版本头、必须校验 Origin)上。协议错误和工具执行错误要分开:后者必须作为正常结果进到模型的上下文里。

10. 常见误区 · Common pitfalls

  1. “把密钥放在 URL 里图方便。” URL 会进日志、代理、浏览器历史。密钥放请求头。
  2. “不设超时也没事,网络很快。” 服务器可能永远不回话,默认行为是一直等。一个没有超时的请求能让整个智能体卡死。
  3. “出错就重试。” 4xx 重试毫无意义(请求本身错了),而且对非幂等的写操作,重试可能造成重复副作用(发两封邮件)。
  4. “重试就是循环 3 次。” 没有退避会在服务器最脆弱的时候加倍压它;有 Retry-After 不听,可能被直接封掉。
  5. “Content-Length 就是字符数。” 是字节数。一个汉字 3 个字节,写错会截断或超时。
  6. “JSON 里可以放 NaN / 元组 / 整数键。” NaN 不是标准 JSON;元组会变数组;整数键会变字符串键。
  7. “校验失败就回 ‘invalid arguments’。” 这句话模型没法用。要写清哪个字段、为什么、允许什么。
  8. “MCP 服务器里 print 一句看看。” stdio 传输下,往 stdout 写任何非协议内容都会把客户端的解析搞坏。日志写 stderr。
  9. “工具执行失败就抛异常 / 回协议错误。” 那样模型看不到失败原因,没法自我纠正。执行失败要作为 isError: true 的正常结果返回。
  10. “本地 MCP 服务器不用管安全,它只监听 localhost。” 浏览器里的任意网页都能请求 localhost。必须校验 Origin,只绑定 127.0.0.1,并且有认证。

11. 练习 · Exercises

带 💻 的题可以用本讲的模块验证。参考答案在每题后面(先自己做再看)。

练习 1(读原始 HTTP)。下面这个请求有两处错误,找出来并说明服务器会怎样反应:

POST /digest HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: application/json
{"date": "2026-06-10"}
参考答案

(1) 缺少头部与正文之间的空行:服务器会把 {"date": ...} 这一行当作又一个头部来解析,于是读不到正文;(2) 没有 Content-Length:即使有空行,服务器也不知道要读多少字节,会按 0 字节处理(我们的玩具 API 会回 400 “request body is empty”)。另外还少了 Authorization 头,会得到 401。可以用 net_lab.raw_request 亲自试。

练习 2(重试的总耗时)💻。客户端设 timeout=2 秒、max_retries=5、base_delay=0.25,服务器一直返回 503。(a) 一共会尝试几次?(b) 退避序列是什么、总共等多久?(c) 最坏情况下这一个请求会占用多少时间?(d) 如果智能体的一步要调 3 个这样的工具,一步最坏多久?

参考答案

(a) 6 次(1 次首发 + 5 次重试)。(b) 编写时的实测:[0.25, 0.5, 1.0, 2.0, 4.0],共 7.75 秒。(c) 最坏 ≈ 6 × 2(每次都超时)+ 7.75 ≈ 19.75 秒。(d) 约 59 秒——而这只是一步。这就是为什么重试次数、退避上限、超时必须一起设计:它们会被智能体的步数乘上去。真实系统还会给整个任务设一个总预算(deadline),超了就直接放弃,而不是每个请求各自重试。

练习 3(幂等性)。下面哪些操作重试是安全的?对不安全的,说出一种让它变安全的办法:(a) GET /emails?urgent=true;(b) POST /digest {"date": ...}(只计算,不存储);(c) POST /emails/em-003/reply(发一封回复);(d) DELETE /sessions/sess-0001。

参考答案

(a) 安全(只读)。(b) 安全(没有副作用,只是浪费一点算力)。(c) 不安全:可能发出两封邮件。办法:让客户端带一个幂等键(例如 Idempotency-Key: <uuid>),服务器记住已处理过的键;或者 harness 不自动重试这类操作,而是把失败告诉模型,由模型(或用户)决定。(d) 按 HTTP 语义 DELETE 是幂等的:删一次和删两次的最终状态一样(第二次通常回 404,我们的 MCP 服务器就是这样)。注意”幂等”说的是最终状态,不是”返回一样的响应”。

练习 4(JSON Schema)💻。为一个 send_email(to, subject, body, cc=None) 工具写 inputSchema,要求:to 是至少 1 个元素的数组、每个元素是像 [email protected] 这样的邮箱;subject 最长 120 字符;body 必填;cc 可选、也是邮箱数组;不允许其他字段。然后用 net_lab.validate 检查 {"to": "[email protected]", "subject": "hi", "body": "x"} 会报什么错。

参考答案
{"type": "object",
 "properties": {
   "to": {"type": "array", "items": {"type": "string", "pattern": r"^[^@\s]+@[^@\s]+\.[^@\s]+$"}},
   "subject": {"type": "string", "maxLength": 120},
   "body": {"type": "string"},
   "cc": {"type": "array", "items": {"type": "string", "pattern": r"^[^@\s]+@[^@\s]+\.[^@\s]+$"}}},
 "required": ["to", "subject", "body"],
 "additionalProperties": False}

那个例子会报 $.to: expected array, got str——模型把单个收件人写成了字符串而不是只含一个元素的数组,这是非常常见的错误。(注:我们的 validate 子集不支持 minItems,所以”至少 1 个元素”这一条它检查不了;真实项目用 jsonschema 库就有 minItems。这也提醒你:校验器的能力边界要和 schema 写的东西对得上。)

练习 5(MCP 的两种错误)💻。给 mcp_lab.ToolBox 加一个工具 read_doc(doc_id):doc_id 必须匹配 ^doc-\d{3}$,但文档库里只有 doc-001。(a) 调用 read_doc("handbook") 应该回哪种错误?(b) 调用 read_doc("doc-404") 呢?(c) 为什么这个区别对智能体重要?

参考答案

(a) 协议错误(-32602 invalid params):"handbook" 不符合 schema 的 pattern,在执行之前就被 validate 挡住了。(b) 工具执行错误(正常 result,isError: true):参数合法,但这个文档不存在——这是执行时才知道的事实。(c) 因为 (b) 的信息必须进到模型的上下文里,模型才能自己纠正(“doc-404 不存在,我先列一下有哪些文档”)。如果把它也做成协议错误,很多客户端会抛异常中断整个循环,模型连”为什么失败”都不知道。

练习 6(stdio 的陷阱)💻。在 mcp_lab.serve_stdio 的循环里加一行 print("debug: got a message")(注意:print 默认写 stdout),然后运行 04_jsonrpc_mcp.py 的演示 2。会发生什么?为什么?规范的哪一条被违反了?

参考答案

客户端会在 json.loads(line) 处失败(或者读到一行 debug: got a message 而不是 JSON 响应),整个会话崩掉。原因:stdio 传输下 stdout 是协议通道,客户端把每一行都当作一条 JSON 消息来解析。规范明确规定”服务器 MUST NOT 往 stdout 写任何不是有效 MCP 消息的内容”,日志要写 stderr(print(..., file=sys.stderr))。我们的服务器就是这么做的,所以 c.stderr_text() 能看到日志而协议不受影响。

练习 7(Origin 与 DNS rebinding)。一个本地 MCP 服务器监听 127.0.0.1:7777,不校验 Origin,也不要求认证。(a) 你在浏览器里打开 https://evil.example,这个网页能不能向 http://127.0.0.1:7777/mcp 发 POST?(b) 如果它能,会有什么后果?(c) 规范要求的三条防护分别挡住了什么?

参考答案

(a) 能发——浏览器的同源策略会限制网页读取跨源响应,但请求本身已经发出去了(而且通过 DNS rebinding 等手法,攻击者可以让浏览器认为 127.0.0.1 就是自己的源,从而连响应也读到)。(b) 那个网页就能列出并调用你本机 MCP 服务器的所有工具——读你的文件、跑你的命令。(c) 校验 Origin:拒绝来自陌生网站的请求(我们的实现回 403);只绑定 127.0.0.1(不是 0.0.0.0):局域网里的别的机器连不上;要求认证:即使前两条被绕过,没有凭证也调不动。三条是叠起来的,这正是 P1 §8.2 和前置讲 P4 要讲的”分层防御”。

练习 8(工具说明的上下文开销)💻。用 mcp_lab 的三个工具,用 llm.count_tokens 比较:(a) 完整的 tools/list JSON 有多少 token?(b) 只保留名字和描述有多少?(c) 如果一个智能体接了 5 个 MCP 服务器、共 40 个工具(假设平均大小和我们的差不多),一次 20 步的任务里,工具说明一共要发送多少 token?

参考答案

编写时的实测:(a) 346 token;(b) 53 token(约为 1/6.5)。(c) 40 个工具按同比例约 346 / 3 × 40 ≈ 4,613 token;每一步都要重发,20 步约 92,000 token——而这还没算对话历史和工具的返回结果。这就是”工具搜索”(先检索需要的工具,再放进上下文)和”按需加载工具说明”存在的原因;也说明接 MCP 服务器不是免费的,接得越多,每一步都更贵、模型也更容易选错工具。


12. 延伸阅读 · Further reading


13. 下一讲预告 · Next lecture

前置讲 P3 并发、缓存与可靠的调用:这一讲的客户端是一个一个发请求的。可是原课 HW1 的每日摘要要给一天约 280 封邮件分类——串行发会慢到不可接受(handout 明确提示要并发),而且同样的调用重复付费也很浪费。P3 会讲:I/O 密集和计算密集的区别、线程池与 asyncio、共享状态的竞态(“你的索引和缓存必须能被并发安全地使用”也是 HW1 的要求)、限速(令牌桶)、按请求内容做缓存键、成本账本与预算。做完 P3,第 2 讲的 LM 封装就只剩”把这些拼起来”了。

← 返回课程目录