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

四行输出,逐行读:

  1. 第一行是 Client 发出的请求。注意 id: 1 —— 有了它,这条消息才要求服务端回答,后续响应才能对上号。
  2. 第二行是服务端的初始化结果:服务端声明了自己的 capabilities 和 serverInfo。这几个字段怎么协商、不兼容时谁负责报错,是第 03 章的主题。
  3. 第三行是一条通知,序列化后没有 id 字段。客户端发它表示「我准备好了」,服务端不回。
  4. 第四行验证了这一点: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",请忘掉它——当前代码没有实现这个保护。这不是文档的错,是把「校验放在哪一层」讲清楚的机会:

生命周期:两代,不要混着用

生命周期是本章第二个容易踩坑的地方,因为它在 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,你的封装必须报错;构造正常请求则正常返回。

本章检查点

现在能解释什么

你现在能解释「连上了却没反应」这种故障的完整因果:请求退化成通知 → 服务端合规地不回 → 客户端无限等待。你也知道了三件事——id 是请求关联的命脉、params 省略是真实存在的序列化细节、以及生命周期在 2026-07-28 从「握手」变成了「每请求自证」。下一章把这些落到实处:版本和能力到底怎么协商,不兼容时谁先炸。

进入 keel 阅读