最近在集中补几门老课:JSON-RPC、SSE、stdio。

说它们老,是真老。JSON-RPC 2.0 定稿于 2010 年,SSE 2006 年就有了标准,stdio 更是跟 Unix 同岁——比互联网还年长。

但说实话,这些年我几乎没正眼看过它们——各有各的理由:

  • JSON-RPC:从没见过这东西——RPC 还有这种协议?
  • SSE:印象一直停留在「过时的推送方案」——明明有更强的双向 WebSocket,为什么还要用它?
  • stdio:做业务开发的,谁会专门去学标准输入输出。

直到 AI Agent 时代,MCP 把它们翻出来当了通信底座,我才不得不回头补课。

这篇就是补课的学习记录:三个东西分别是什么、怎么配合、为什么在今天重新有了舞台。

一、JSON-RPC:它到底是什么?

本质

先说一个事实:它今天无处不在——MCP、A2A、AG-UI、ACP,这些当红的 Agent 相关协议,场景各异,却不约而同地选了 JSON-RPC 当消息底座。

至于它到底是什么,名字已经说了一半:一个用 JSON 承载消息的 RPC 协议。

所以得先聊 RPC。RPC(Remote Procedure Call,远程过程调用)的核心想法朴素得很:让调用远端服务,像调用本地函数一样自然。客户端说「帮我执行 method X,参数是这些」,服务端回「结果在这儿」,或者「出错了,错误码是这个」——体验上和调本地函数没什么区别,只不过这件事发生在另一个进程、或者另一台机器上。

JSON-RPC 就是这条路线上的一个具体做法:把一次调用的意图——方法名、参数、配对用的序号——打包成 JSON 消息,交给传输层去送。

主要的示例格式

请求就是个四件套:

{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": { "name": "read_file", "arguments": { "path": "/tmp/a.txt" } },
  "id": 1
}
字段 必须 含义
jsonrpc 必须 固定字符串 "2.0",表明协议版本
method 必须 要调用的方法名
params 可选 参数,数组(位置参数)或对象(具名参数)
id 可选 调用序号,用来把响应和请求对上号

响应走「成功」和「失败」两条路——result 和 error 互斥,必须二选一(2.0 的硬规矩);id 永远原样带回来:

// 成功
{ "jsonrpc": "2.0", "result": { "content": "文件内容..." }, "id": 1 }

// 失败
{ "jsonrpc": "2.0", "error": { "code": -32601, "message": "Method not found" }, "id": 1 }

还有个我后来才品出味道的设计——通知(notification):请求里不带 id,意思就是「我说一声,你不用回」。服务端绝对不能回——回了反而是违规。

它跟「RPC」是什么关系?

RPC 不是某一个协议的名字,而是一大类思路。从早期的 CORBA、Java RMI,到今天的 gRPC,都是这个家族的成员。JSON-RPC 也在其中——只是它走的路,和这些「前辈」们不太一样。

作为一家子,它们的哲学是一致的——都把一次远程调用,封装成一个「方法名 + 参数 + 配对标识」的消息体:

  • 调用模型一致:方法名 + 参数 → 结果;
  • 都要解决「请求与响应的配对」问题——这就是 id 存在的原因;
  • 都有结构化的错误传递机制。

而差异,恰恰是 JSON-RPC 的立身之本——它只规定消息格式,不规定怎么传。家族里的其他成员往往把传输也定死(gRPC 绑 HTTP/2 + Protobuf,CORBA 有自己的 IIOP),JSON-RPC 却把传输层完全留白:走 HTTP、WebSocket、TCP,还是 stdio,都行。

💡 打个比方:gRPC 是「塞进标准集装箱、外面刷好编码」,JSON-RPC 是「白纸黑字写清楚」。后者的克制,恰恰是它在 AI 时代被选中的原因——第二节就会应验。

那和 REST 比呢?

REST 的哲学核心就一句话:操作某个资源。落到 HTTP 上,它的典型形态是「路径 + 四个标准方法的组合」——同一个路径,配不同的动词,就是对它做不同的操作;参数要么拼在 URL 后面,要么塞进 Body——「操作什么」由 URL 说了算,「怎么操作」由动词说了算:

POST   /api/users      ← 创建用户(参数在 Body)
GET    /api/users/1    ← 查看用户 1
DELETE /api/users/1    ← 删除用户 1

JSON-RPC 完全不是这个玩法。它的请求永远发往根路径——要调哪个方法、传什么参数,全部写在 Body 里:

POST /                 ← 永远就这一个端点
{"jsonrpc":"2.0","method":"users.create","params":{"name":"foo"},"id":1}
{"jsonrpc":"2.0","method":"users.get","params":{"id":1},"id":2}

看出来了吗——「方法名 + 参数全在 Body 里」,这跟传统 RPC 是一脉相承的:调用一个方法,而不是操作一个资源。这是它和 REST 最根本的分野。

再对照得细一点:

JSON-RPC REST
核心概念 调用一个方法 操作一个资源
URL 怎么用 通常就一个端点,靠 method 区分 每个资源一个路径
风格 函数调用,像本地调函数 数据操作,靠 HTTP 动词
适合 内部服务、工具调用、双向通信 对外开放的 API

说白了:想「像调本地函数一样调远端」,JSON-RPC 最省事;想「操作资源」,REST 更自然。JSON-RPC 选了前者——RPC 家族的哲学,它一样没丢。

二、SSE:它怎么跟 JSON-RPC 走到一起?

先说说 SSE 是什么

SSE(Server-Sent Events)是个「服务端推送」的浏览器标准:客户端发一个普通 GET 请求,这条连接就挂着不关,服务端沿着响应流随时写数据——格式朴素得可爱,就是一行行 data: xxx 文本。

但有个细节得掰清楚:它和 WebSocket 的「推送」,不是一回事。WebSocket 是全双工——连接建好,谁都能开口;SSE 是单工——客户端先敲门,服务端才好开口。

这标准 2006 年就有了,却一直被 WebSocket 盖着。人家双向、能力更强——SSE 站在旁边,像个没什么存在感的配角。

那为什么还有场景选它?答案有点反直觉:WebSocket 的「强」,本身就是入场费。

它要先做协议升级(Upgrade: websocket),很多企业网关和代理直接就把这类连接拦了;SSE 只是一次普通的 HTTP 响应,防火墙看它,跟看网页请求没区别。

而且能力过剩也是成本:MCP 这类场景,需要的只是服务端能推消息——客户端发消息,走普通 POST 就够了。为一半的需求背上整套双向协议,不划算。

所以这次重翻规范时有点意外:这个老东西,比我印象中干净得多。

结合起来发生了什么

回头看第一节讲的 notification(服务端有话想说),但在「纯 HTTP 一问一答」模式下,服务端根本没有说话的时机。SSE 恰好补上这块——客户端先开一条常驻连接,让服务端拿到了持续说话的通道。于是两件事同时成立:

  • 服务端可以随时发消息:进度、日志、通知,不必再等下一次请求;
  • 一个请求,多个消息:在响应前后可以夹带一串 notification(比如 progress: 10、progress: 60),最后才用一条带 id 的 result 收尾——变相实现了流式输出。

这就是 MCP 远程传输(Streamable HTTP)的核心思路:一个端点、一个 POST;普通请求直接回 JSON,需要流式时切换成 SSE。

老实说,看完这套设计我有点服气——两个「过气」的东西,拼出了一个恰好够用的方案。

现在有哪些典型应用

目前我接触到的主要是两类:MCP 的远程传输(上面说的);以及各类 AI 应用的流式输出——你看到的那些「边生成边出字」的聊天界面,底下大多是 SSE 在干活。

实践里也踩过坑,挑两个最有代表性的分享:

  • JSON 必须压成一行——SSE 的 data: 遇到裸换行就分段了,所以 pretty-print 会直接破坏协议(和 stdio 的纪律异曲同工,后面还会见到它);
  • EventSource 不能带自定义请求头——标准 API 没法加 Authorization,很多实现被逼着用 query 参数传令牌。

三、stdio:古老,但在本地恰到好处

JSON-RPC 规定了「消息长什么样」;至于消息怎么流动,传统答案是 HTTP——「HTTP + JSON-RPC」这对组合,用了很多年。

但在本地场景,有人把传输层换掉了:从 HTTP 换成一条进程管道,消息一个字都不用改。换上来的这套就是 stdio——比 HTTP 老得多,却成了 MCP 本地传输的默认选择。还记得第一节说的「传输层自由」吗?这就是它兑现价值的地方。

原理:一根管道

原理简单得不像个技术:让两个进程用一根双向管道对话。客户端把服务端拉成子进程,往 stdin 写请求,从 stdout 读响应,stderr 拿来打日志。

有多老?它跟 Unix 一起诞生——1970 年代定下的设计,比在座的所有用户都老。

理解它最好的方式,是亲手在 shell 里模拟一次:

# 把一条 JSON-RPC 请求写进 server 的 stdin,从 stdout 读回响应
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | ./my-mcp-server

两条纪律,不写进代码就永远踩坑,顺手记下:

  • 换行分帧:stdio 是字节流,没有消息边界。规范规定一条消息占一行、不能内嵌换行——所以 pretty-print 在这里同样是"拆协议";
  • stdout 绝对不能打日志:规范原文是 server MUST NOT 往 stdout 写任何非协议消息。一个随手 fmt.Println 就可能让客户端解析失败。日志要走 stderr——Go 里 log.Println 默认就是 stderr,而 Python 的 print() 默认 stdout,尤其危险。

为什么在本地场景,它优于 HTTP?

这是这次补课里我想得最久的一个问题。「HTTP + JSON-RPC」明明也跑得通,为什么到了本地场景,MCP 和很多本地工具要换成 stdio?我的理解是四点:

  • 零配置:不用分配端口、不用握手、不用证书——进程启动的那一刻,通道就已经存在了;
  • 天然隔离:每个 server 一个进程,崩了互不影响;权限边界就是操作系统的进程边界;
  • 生命周期绑定:进程停,服务停,不会留孤儿端口和僵尸服务;
  • 无网络暴露:没有端口,天然规避了一整类网络攻击面——这在"让 AI 调用本地工具"的场景里,是很实在的安全考量。

再加上不走网络栈、没有 TCP/HTTP 开销的天然性能优势,就不难理解 MCP 的本地传输为什么选了它:一行配置拉起一个工具进程,即插即用。于是今天的 MCP 形成了「本地 stdio + 远程 Streamable HTTP」的双轨格局——一个覆盖本机进程,一个覆盖跨网络。

四、写在最后

回头看这三个老东西,气质出奇地一致:简单、够用、好穿透。它们都不是当年最强的那个——JSON-RPC 打不过 gRPC 的体系化,SSE 打不过 WebSocket 的双向,stdio 更是「老掉牙」的进程通信方式。

但 AI Agent 时代重新需要它们,恰恰是因为它们朴素:协议简单到不用文档就能实现,通道皮实到什么环境都能跑起来。

技术选型从来不是「谁先进谁赢」,而是「谁恰好合适谁赢」。