KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
02 · Host、Client、Server 如何分责? — keel 龙骨
## 现场:连上了,但助手一直转圈
现场:连上了,但助手一直转圈
第 01 章评审通过两个月后,Incident Bridge 有了第一个能跑的版本。第一天试运行出了个怪事:
值班工程师在对话框里点「查询 payments 最近事件」。界面转圈 30 秒,然后报「服务无响应」。翻服务端日志——一条记录都没有。
网络是通的,initialize 是成功的,前一天同样的调用还正常。开发同学盯了一个小时,最后发现改动只有一行:有人重构客户端代码时,把构造请求的地方从
JsonRpcRequest("tools/call", params, request_id=next_id)
改成了
JsonRpcRequest("tools/call", params)
少传一个 id。而 request_id 的默认值是 None。
这一个疏忽触发了完美的静默失败:没有 id 的消息是「通知」,服务端收到通知不回响应。客户端还在等一个永远不会到来的响应,服务端觉得自己已经处理完了。双方都没有报错。
本章要解决的就是这类问题:一条 MCP 连接上有哪几种消息,谁对什么负责,以及生命周期的边界在哪。
直觉模型:别把一条连接想成一次函数调用
函数调用的隐含假设是「发出去、拿回来、结束」。MCP 连接更像一个长期的双工通道:
Host(Incident Bridge 助手,负责策略与 UX)
│
┌────┴─────┐
│ │
Client A Client B ← 每个 Client 管且只管一条连接
│ │
▼ ▼
Server: Server:
incidents runbooks ← 每个 Server 只知道自己那点事
三种角色分工固定:
| 角色 | 负责 | 不负责 |
|---|---|---|
| Host | 用户体验、同意授权、把多个 Server 的结果编排进同一个对话 | 具体某条连接的协议细节 |
| Client | 一条连接的生命周期:建立、版本校验、请求关联、关闭 | 决定调用哪个工具(那是模型的事) |
| Server | 暴露能力、执行自己的策略、对自己的副作用负责 | 读取整段对话、窥探其他 Server |
现场那个 bug 落谁头上?Client。它构造了一条语义错误的消息,而 id 关联正是 Client 的职责。Server 按协议行事,没问题。
精确定义:JSON-RPC 2.0 的三种消息
MCP 的底层是 JSON-RPC 2.0。只有三种消息形态,但每一种丢了区分就会出事:
| 消息 | 标志 | 是否期待响应 |
|---|---|---|
| 请求 Request | 有 id |
是,且响应必须带回同一个 id |
| 响应 Response | 有 id,且 result 与 error 互斥 |
— |
| 通知 Notification | 没有 id |
否 |
项目里用 contracts.py 的 JsonRpcRequest 表达请求与通知:
@dataclass(frozen=True)
class JsonRpcRequest:
method: str
params: dict[str, Any] = field(default_factory=dict)
request_id: int | str | None = None
@property
def is_notification(self) -> bool:
return self.request_id is None
一个布尔属性,就是现场那次事故的全部因果:request_id is None 即为通知。
一次完整运行
python courses/foundation/mcp-protocol-engineering/course/project/examples/01_lifecycle.py
实跑输出:
{'jsonrpc': '2.0', 'method': 'initialize', 'id': 1, 'params': {'protocolVersion': '2026-07-28'}}
{'protocolVersion': '2026-07-28', 'capabilities': {'tools': {'listChanged': False}, 'resources': {'subscribe': False}, 'prompts': {'listChanged': False}, 'tasks': {'requests': {'list': True, 'get': True, 'cancel': True}}, 'extensions': ['tasks']}, 'serverInfo': {'name': 'incident-bridge', 'version': '0.1.0'}}
{'jsonrpc': '2.0', 'method': 'notifications/initialized'}
notification: True
四行输出,逐行读:
- 第一行是 Client 发出的请求。注意
id: 1—— 有了它,这条消息才要求服务端回答,后续响应才能对上号。 - 第二行是服务端的初始化结果:服务端声明了自己的
capabilities和serverInfo。这几个字段怎么协商、不兼容时谁负责报错,是第 03 章的主题。 - 第三行是一条通知,序列化后没有
id字段。客户端发它表示「我准备好了」,服务端不回。 - 第四行验证了这一点:
is_notification为True。
顺带一个容易误解的地方:第 3 行的 notifications/initialized 是一个方法名,不是「这是通知」的语法标记。判断是不是通知,唯一依据是有没有 id。
失败注入
注入 A:丢掉 id(复现现场事故)
from _bootstrap import *
from mcp_bridge.contracts import JsonRpcRequest
request = JsonRpcRequest("tools/call", {"name": "incident.search", "arguments": {"service": "payments"}})
print(request.to_dict())
print("is_notification:", request.is_notification)
实跑输出:
{'jsonrpc': '2.0', 'method': 'tools/call', 'params': {'name': 'incident.search', 'arguments': {'service': 'payments'}}}
is_notification: True
消息看起来完全正常——有 method、有参数,什么都没少。但 is_notification 是 True,服务端不会回,客户端会等到超时。这是最难查的一类 bug:双方都符合自己的预期。
防御手段在 Client 侧:给请求关联设超时,并对「发了请求却在超时时间内没对上 id 的响应」主动报错,而不是无限等待。
注入 B:空 params 被静默省略
JsonRpcRequest("tools/list", {}, 7).to_dict()
实跑输出:
{'jsonrpc': '2.0', 'method': 'tools/list', 'id': 7}
params 整个消失了。这是 Python 侧「falsy 就省略」的写法导致的:
if self.params: # 空 dict 为假
result["params"] = self.params
多数情况下无害,因为 JSON-RPC 允许省略 params。但如果对端实现写死读 params 字段、又没做缺省处理,就会拿到 KeyError。序列化的便利性和协议的严格性冲突时,协议优先——生产序列化层应该显式区分「没传」和「传了空对象」。
注入 C:这个包没有强制「result 与 error 互斥」
翻一遍 contracts.py 会发现:项目里有 JsonRpcRequest 和 JsonRpcError,但没有任何一个地方把「id + result/error」组装成响应信封,更没有校验二者互斥。
如果你看过旧版本资料提到"消息对象会拒绝同时带 result 和 error",请忘掉它——当前代码没有实现这个保护。这不是文档的错,是把「校验放在哪一层」讲清楚的机会:
- 教学包把注意力放在责任划分上,信封拼装留给传输层;
- 你的真实实现必须在序列化响应时保证
result与error恰有其一,否则对端无法判断这次调用到底成功了没有。
生命周期:两代,不要混着用
生命周期是本章第二个容易踩坑的地方,因为它在 2026-07-28 发生了结构性变化。规范把实现分成三代:
| 术语 | 版本 | 建立连接的方式 |
|---|---|---|
| Legacy | 2025-11-25 及以前 |
initialize 握手建立会话,后续请求依赖会话状态 |
| Modern | 2026-07-28 及以后 |
没有握手,每个请求自带 _meta(版本、身份、能力) |
| Dual-era | 两者都支持 | 按客户端怎么开口来选择行为 |
本项目保留 initialize 是有意的:Dual-era 服务端仍然接受它(否则旧客户端无从下手),而且进程内函数调用没有地方挂「每请求信封」。
但你要清楚这意味着什么:把 01_lifecycle.py 的握手流程当成 2026-07-28 的唯一正确形态,是一个容易犯的错。 真实 Modern 服务端对每个请求独立校验版本(第 03 章给细节),而不是只在开头验一次。
无论如何传输,生命周期至少要能被说清这四个阶段:
1. 版本确认 → Legacy: initialize 握手 Modern: 每个请求 _meta 自带
2. 能力确认 → 双方声明 capabilities,未协商的不许调用
3. 正常操作 → discover / tools:call / resources:read / 任务轮询 / 取消
4. 优雅关闭 → 清理未完成任务、落审计、释放连接
生产替换点
| 教学实现 | 生产替换 | 为什么必须换 |
|---|---|---|
| 进程内函数调用传 dict | stdio(换行分隔 JSON)或 Streamable HTTP | 需要跨进程 / 跨网络 |
手写 JsonRpcRequest |
官方 SDK 的消息层 | 需要处理 framing、粘包、并发请求关联 |
| 无超时 | 每个请求带超时与上限 | 注入 A 这种静默失败必须被 deadline 兜住 |
| 无并发控制 | 并发上限、背压、排队 | 否则一个慢工具拖垮整条连接 |
| 无关闭钩子 | 优雅关闭与连接清理 | 进行中的任务要在关闭时落状态 |
练习与验收
练习(得到可观察结果):给 InMemoryTransport 加一层「带 id 的请求必须在 2 秒内得到同 id 响应,否则抛自定义 RequestTimeoutError」的封装,并用注入 A 的代码触发它。
验收标准:构造一个忘记带 id 的 tools/call,你的封装必须报错;构造正常请求则正常返回。
本章检查点
- 现场那次事故,严格来说该由 Host、Client、Server 中哪一个负责修复?为什么?
is_notification为True的消息,服务端应该记录吗?(提示:想想审计。)- 关掉连接时,如果一个长任务还在
input_required,谁有责任把它落盘?
现在能解释什么
你现在能解释「连上了却没反应」这种故障的完整因果:请求退化成通知 → 服务端合规地不回 → 客户端无限等待。你也知道了三件事——id 是请求关联的命脉、params 省略是真实存在的序列化细节、以及生命周期在 2026-07-28 从「握手」变成了「每请求自证」。下一章把这些落到实处:版本和能力到底怎么协商,不兼容时谁先炸。