KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
06 · stdio、HTTP、通知和取消怎样传播? — keel 龙骨
## 现场:搬到远端之后,「本地」这个安全边界消失了
现场:搬到远端之后,「本地」这个安全边界消失了
Incident Bridge 最初的部署形态是:助手在本机开一个子进程跑 Server,用标准输入输出通信。这套东西跑了三个月,没人管鉴权——因为「它就在本机跑」,能连上它的只有本机的助手进程。
后来为了给多个团队共用,把它改成了远端服务:加一层 HTTP,谁都能连。上线两周后安全扫描发现:任何人拿到那个内网地址就能调 Incident Bridge 的全部工具,不需要任何凭据。
事故的根因不是忘了加鉴权,而是没有意识到部署形态变化会抽掉脚底下那块地板:本机的安全边界,是操作系统给的——进程由谁启动、文件系统权限是谁的,都由 OS 决定。一旦换成长连接的远端服务,这块边界就不存在了,必须显式重建。
同一周发生了第二件事:用户点了「停止生成」,界面立刻停住显示"已取消"。但下游一个正在跑的诊断任务继续跑完了,还往工单系统里写了一条。取消只停了界面,没停作业。
直觉模型:传输只负责送达,不承担语义
先把职责边界定死:
| 层 | 负责 | 不负责 |
|---|---|---|
| 传输层 | 把 JSON-RPC 消息送到对面、管理连接生命周期、framing | 判断消息对不对、有没有权限 |
| 消息层 | 请求/响应/通知的语义、id 关联、版本携带 | 消息怎么在网络上走 |
| 业务层 | 工具做什么、副作用是什么 | 消息怎么发 |
「传输改变,上层断言不变」是一条可靠的验收标准:把 InMemoryTransport 换成真实 HTTP,第 02 章建立的 id 关联、第 03 章的版本校验、第 04 章的 scope 判定都不应该有任何一行改动。
精确定义:两种传输,两种威胁模型
stdio
CLI 场景的默认选择:宿主启动一个子进程,双方通过 stdin / stdout 用换行分隔的 JSON 通信。生命周期绑定进程:父进程关掉 stdin,子进程就该退出。
它的信任基础来自操作系统,所以规范有一条差异很大的建议:HTTP 类传输 SHOULD 走标准的 OAuth 授权流程,而 stdio SHOULD NOT,改为从环境读取凭据。
这条建议容易被误读成"stdio 不需要鉴权"。它的真正含义是:机制要匹配威胁模型。 进程是客户端亲手拉起来的,再叠一层 OAuth 不增加安全性,只增加复杂度。但一旦同一个进程要跨网络给多个租户提供服务,"信任边界由 OS 给出"这个前提就不成立了。
还有一个 stdio 专属的大坑,来自版本化章节:stdio 上没有 HTTP 状态码可以用来判断对方是哪一代实现。 所以同时支持 modern 和 legacy 的客户端,SHOULD 先发 server/discover 探测:
- 返回一个现代错误(例如
-32022)→ 说明对方是 modern,换版本重试,而不是回退; - 返回其他错误或超时 → 说明对方是 legacy,回退到
initialize握手。
这条规则有个前提容易忽略:时代判断是服务端的属性,不是单次请求的属性。 所以规范建议把它缓存起来——stdio 缓存到进程生命周期,HTTP 缓存到 origin,并且允许跨重启持久化,只在缓存假设失效时重新探测。
Streamable HTTP
远端场景的选择。相比 stdio,它要求你自己补上:认证、会话管理、超时、重试策略、流式事件、连接与请求关联。
版本传递有个具体细节:在 HTTP 上,协议版本同时也放在 MCP-Protocol-Version 头里。 但规范明确 body 里的 _meta 才是唯一真相来源——头只是给中间层镜像用的。两者冲突时,以 body 为准,并且绑定必须定义如何拒绝这种不一致。
这带出一个必须写清楚的实现要求:中间层(网关、代理、负载均衡)很可能只改头不改 body。 如果你的服务端信任头而不校验 body,一个能改 HTTP 头的中间人就拿到了版本选择权。
本项目的教学替身
class InMemoryTransport:
"""A deterministic stand-in for stdio or Streamable HTTP."""
def __init__(self) -> None:
self.inbox: deque[dict[str, Any]] = deque()
self.closed = False
def send(self, message: dict[str, Any]) -> None:
if self.closed:
raise ConnectionError("transport is closed")
self.inbox.append(message)
def receive(self) -> dict[str, Any]:
if self.closed:
raise ConnectionError("transport is closed")
if not self.inbox:
raise TimeoutError("no message available")
return self.inbox.popleft()
不到 30 行,但它保留了真实传输的三个关键性质:FIFO 顺序、可耗尽(会超时)、可关闭。省略掉的是 framing、并发、网络分区——这些正是生产环境的难点。
一次完整运行
python courses/foundation/mcp-protocol-engineering/course/project/examples/05_transport.py
实跑输出:
received: {'jsonrpc': '2.0', 'id': 1, 'method': 'ping'}
cancelled/closed: transport is closed
两行分别演示了「正常收发」和「关闭后的行为」。注意 cancelled/closed 那行的异常类型是 ConnectionError——关闭之后收发两端都会抛。这一点比看起来重要:很多实现对「已关闭连接上的操作」是静默忽略的,于是调用方以为消息发出去了。
失败注入
注入 A:顺序保证
transport = InMemoryTransport()
transport.send({"id": 1, "method": "ping"})
transport.send({"id": 2, "method": "tools/list"})
print(transport.receive()["id"], transport.receive()["id"])
实跑输出:
1 2
FIFO 是这个替身给你的保证,但真实传输不一定给。 HTTP/2 多路复用下多个请求并发进行,响应顺序和请求顺序无关——这就是第 02 章强调「必须用 id 关联」而不是「按顺序配对」的原因。教学替身比真实情况更友好,这一点必须写进脑子里,不能靠肌肉记忆。
注入 B:从空队列读
transport.receive() # 已经没有消息了
实跑输出:
TimeoutError: no message available
这是对排查友好的设计:等不到就明确失败。 生产实现里这里通常是「阻塞到超时」而不是立刻抛,语义差别很大——「暂时没有」和「永远不会来」是两种故障,应当能被区分。
注入 C:关闭后发送
实跑输出:
ConnectionError: transport is closed
对应的真实场景是:用户点了取消、连接关了,还有一个后台线程试图往下发。这个异常必须能被上层捕获并决定怎么办(丢弃、重试、还是记为失败),而不是让整个 Agent 崩掉。
取消必须是协作式的
回到现场第二个事故(点了停止但工单照样创建)。
核心事实:收到取消通知 ≠ 工作已经停止。 通知是一個「请你停下来」的请求,不是 kill -9。
一套负责任的取消要做四件事:
1. 客户端发取消通知(notifications/cancelled,带要取消的 request id)
↓
2. 服务端把取消传播到:下游 HTTP 调用、正在运行的工具、任务存储
↓
3. 无法立即终止时 → 返回「取消已接收,正在停止」而不是假装已经停了
↓
4. 已经产生的副作用 → 通过补偿操作处理,不允许伪造「回滚成功」
第 4 点是诚实性问题。看本项目怎么处理已终态任务:
def cancel(self, task_id: str) -> TaskSnapshot:
task = self.get(task_id)
if task.status not in TERMINAL_STATES:
task.status = "cancelled"
self._save()
return task
已经 completed 的任务,取消是静默无效的,返回的对象告诉你真实状态是 completed。这比「假装取消了」好——调用方读返回值就知道真相。
但也由此产生一条实践要求:取消之后必须读返回的真实状态,不能假设取消成功了。 界面上想显示"已取消",得先看一眼返回的是什么。
传输检查清单
把本章要点收成一份可以直接过一遍的清单:
- 请求是否有超时和最大 body 大小限制?
- 每条响应能否关联到 request id 和 trace id?
- 顺序错乱时能否靠 id 配对,而不是靠到达顺序?
- 重试是否会重复副作用?(幂等键,见第 08 章)
- 断线后,客户端是否知道「结果未知」,而不是自动重跑?
- HTTP 上是否同时校验 body
_meta与 header,且以 body 为准? - stdio 上是否做了 modern/legacy 探测,并缓存结论?
- 取消是否有传播路径,能否返回「正在停止」这一中间态?
生产替换点
| 教学实现 | 生产替换 |
|---|---|
InMemoryTransport 队列 |
stdio(换行分隔 JSON)或 Streamable HTTP |
| 无限等待 | 每请求 deadline、连接级超时、最大并发 |
| 单条 FIFO | 并发请求 + id 关联 + 背压 |
关闭即抛 ConnectionError |
优雅关闭:先停接收 → 排空在途 → 落状态 → 释放 |
| 无重连 | 带退避的重连,且明确区分可重试与不可重试请求 |
练习与验收
练习(有可观察结果):给 InMemoryTransport 加一个 cancel(request_id) 方法,它把该 id 标记为已取消;receive() 遇到已取消的 id 时跳过它并返回下一条。写一个测试:发 3 条消息,取消中间那条,断言 receive() 依次返回第 1 条和第 3 条。
验收标准:必须能观察到「取消不影响其他请求的顺序与送达」。如果你的实现让后续消息丢失,说明取消实现成了「清空队列」。
本章检查点
- 把 Server 从本机子进程搬到远端服务,哪一类安全假设会失效?举三个具体例子。
- 为什么 stdio 上要用
server/discover做代际探测,而 HTTP 上不用? - 「用户点了停止」之后,服务端可以返回哪几种状态?哪一种是不诚实的?
现在能解释什么
你现在能解释为什么换部署形态等于换威胁模型:stdio 的边界是 OS 给的,远端 HTTP 必须自己重建鉴权。你也理解了传输层的三条可注入性质(顺序、超时、关闭),知道教学替身比真实网络更友好,不能靠肌肉记忆。取消部分你知道它是协作式的,且必须对已产生的副作用诚实。下一章进入本课程最关键的一章:授权到底该怎么接,以及一个经典攻击——confused deputy(困惑代理人)。