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 探测:

这条规则有个前提容易忽略:时代判断是服务端的属性,不是单次请求的属性。 所以规范建议把它缓存起来——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。这比「假装取消了」好——调用方读返回值就知道真相。

但也由此产生一条实践要求:取消之后必须读返回的真实状态,不能假设取消成功了。 界面上想显示"已取消",得先看一眼返回的是什么。

传输检查清单

把本章要点收成一份可以直接过一遍的清单:

生产替换点

教学实现 生产替换
InMemoryTransport 队列 stdio(换行分隔 JSON)或 Streamable HTTP
无限等待 每请求 deadline、连接级超时、最大并发
单条 FIFO 并发请求 + id 关联 + 背压
关闭即抛 ConnectionError 优雅关闭:先停接收 → 排空在途 → 落状态 → 释放
无重连 带退避的重连,且明确区分可重试与不可重试请求

练习与验收

练习(有可观察结果):给 InMemoryTransport 加一个 cancel(request_id) 方法,它把该 id 标记为已取消;receive() 遇到已取消的 id 时跳过它并返回下一条。写一个测试:发 3 条消息,取消中间那条,断言 receive() 依次返回第 1 条和第 3 条。

验收标准:必须能观察到「取消不影响其他请求的顺序与送达」。如果你的实现让后续消息丢失,说明取消实现成了「清空队列」。

本章检查点

现在能解释什么

你现在能解释为什么换部署形态等于换威胁模型:stdio 的边界是 OS 给的,远端 HTTP 必须自己重建鉴权。你也理解了传输层的三条可注入性质(顺序、超时、关闭),知道教学替身比真实网络更友好,不能靠肌肉记忆。取消部分你知道它是协作式的,且必须对已产生的副作用诚实。下一章进入本课程最关键的一章:授权到底该怎么接,以及一个经典攻击——confused deputy(困惑代理人)。

进入 keel 阅读