TT Lab
开始
学习 学习路径 课程

智能体删掉了我的数据库

用标准库编写 stdio 服务器

在 TT Lab 中继续学习

一句话总结

MCP stdio 服务器就是一个循环:“从 stdin 读一行,解析成 JSON,有 id 就回答,向 stdout 写一行并 flush”。只用 json、sys、sqlite3 写出这个循环,就能看清 SDK 所隐藏的四件事——分帧、按 id 配对、忽略通知、两种错误。

为什么需要它

用 SDK 写服务器,只要一个装饰器加一个函数。这种便利很好,但出了问题时,就读不懂日志在说什么。“无法解析响应”“没有对应请求的响应”“在 initialize 之前收到了请求”这类话,全都出自协议层,对一次也没有亲手接触过这一层的人来说只是噪音。

而事故恰恰出在这一层。招聘启事里写着 MCP 的那个岗位,实际工作与其说是“写服务器”,不如说更接近“查出别的团队做的服务器为什么接不到我们的应用上”。要做这件事,需要有亲手构造、发送并接收规范所规定的消息格式的经验。本模块只用标准库来积累这种经验。

工作原理

分帧。传输规范的 stdio 一节规定,消息以换行符分隔,且消息内部不得有换行。所以服务器的骨架就是 for line in sys.stdin:。响应也必须是一行,因此在 json.dumps 的结果后加上 \n 再写出,并且必须 flush(),因为管道不同于终端,不是行缓冲,没有 flush 的话,响应会被困在缓冲区里,客户端就会一直等下去。“服务器好像卡住了”的反馈中有一半是这个原因。

由 id 分出的三条路。用 json.loads 解析一行,会出现三种情况。有 id 就是请求,必须用同一个 id 回答。没有 id 成员就是通知,不回答——用 msg.get("id") is None 判断,分不出 id 实际为 null 的错误请求,所以要用 "id" in msg 来判断。而如果 json.loads 本身失败,则按 JSON-RPC 2.0 第 5 节,用 id: null 加 -32700 Parse error 回答。这三条路若不用 try 包起来,一行损坏的数据就会让服务器崩溃,客户端会把它看成“服务器消失了”。

for line in sys.stdin:
    try:
        msg = json.loads(line)
    except json.JSONDecodeError:
        reply(None, error={"code": -32700, "message": "Parse error"}); continue
    if "id" not in msg:          # 알림 — 답하지 않는다
        continue
    try:
        reply(msg["id"], result=handle(msg))
    except RpcError as e:        # 프로토콜 오류
        reply(msg["id"], error={"code": e.code, "message": e.message})

initialize 是第一个请求。按照生命周期的规定,返回包含 protocolVersion、capabilities、serverInfo 的 result。要提供工具的服务器必须声明 capabilities.tools(MUST)——即使这个对象是空的,键也必须存在。客户端随后会发送 notifications/initialized,这是通知,所以服务器不回答。手写服务器时最常见的错误,就是在这里发出了响应。

tools/list 和 tools/call。格式完全按照工具规范。列表中的每个工具都有 name、description、inputSchema,inputSchema 是 type: object 的 JSON Schema。模型读取这个 schema 来构造参数,所以 description 就是给模型的使用说明书。调用接收 params 中的 name 和 arguments,并以 content 数组作答。不存在的工具名属于协议错误(规范示例为 -32602),工具运行中失败则用 isError: true。请求按未知的状态值统计订单,属于后者——模型必须能读到文本,并用正确的值重新调用。

客户端也按同样的规则来写。用 subprocess.Popen 启动服务器,向 stdin 写入,从 stdout readline。发送完通知后不要 readline——不会有答复,会永远等下去。结束时按生命周期中 stdio 的关闭顺序,先关闭 stdin,再等待服务器自行结束。

在现场相遇的样子

一个团队做的 MCP 服务器,在某些宿主应用里能连上,在另一些应用里却报“初始化失败”。原因是服务器启动时向 stdout 打印了一行横幅。宽容的宿主会丢弃非 JSON 的行,严格的宿主在第一行无法解析时就断开连接。规范中的一句话(“不向 stdout 写入 MCP 消息以外的内容”)就解释了这两个宿主的差异。本模块的最后一步会评分这种洁净度。

另一个例子是对 initialized 通知发送响应的服务器。宿主收到那条响应,只会以“没有对应请求的 id”为由发出警告,与此同时它和随后 tools/list 的响应顺序混在一起,出现了工具列表看起来是空的症状。违反“通知不回答”这一规则的后果,出现在了完全不同的地方。

下一项实验要做什么

创建商店数据库,按步骤写出从 initialize 到 tools/call 都能处理的服务器,然后写一个把该服务器作为子进程启动的客户端,完整走一遍会话。最后确认 stdout 的洁净度。