底下只有两种跑法:stdio 走管道,HTTP 走端口。 上面那套报文一个字不差。 报文只有四种:请求、成功响应、错误响应、通知。有 id 就要回,没 id 就别回。 五个错误码里,最该分清的是「参数错」和「你还没握手」。
前两节讲的是「有什么」和「谁管谁」,这一节讲「怎么说话」。 这是全书最硬的一节,也是后面所有排错的底子——五个错误码认不全,日志就看不懂。 演示里的报文全是真的:真子进程、真管道、真 HTTPServer。
一、stdio:两根管道,没有网络
最直接的一种:宿主把 Server 当子进程拉起来,往它的 stdin 写,从它的 stdout 读。 演示量了两个数:起进程并握手 514 毫秒,之后一次往返 0.15 毫秒。 那 514 毫秒量的是解释器启动加 import,不是协议。 第二次重连仍然要 509 毫秒, 因为进程还是新起的。
而 0.15 毫秒这个数说明:管道就是内存拷贝,没有网络栈、没有序列化开销之外的任何东西。 这条路径的好处是不用端口、不用鉴权、不用联网;坏处是它跨不了机器——子进程只能起在本机。
二、HTTP:一个端口,能跨机器
第二种:服务端自己跑着,客户端往一个地址 POST 一段 JSON,收回一段 JSON。 演示里三个请求全 200:initialize、tools/list、tools/call,回来的正文就是同一个 result。 协议一个字没改,换的只是搬运方式。 这句话是第 7 章那节的前提,先记住。
还有一条更老的走法:GET 开一条长连接,服务端用 SSE 往回推。 打开时先收一帧 endpoint,告诉你「以后往哪个地址 POST」。 先开流、再发请求——这是老传输的固定顺序,麻烦也在这儿:两条连接、要维护会话、 中间代理还可能把流掐掉。2025 年 3 月那版已经把它换掉了。
三、报文只有四种
不管底下是管道还是端口,上面跑的东西只有四种形态。 请求:带 jsonrpc、method、params、id。等一个回话。 成功响应:带同一个 id 和 result。id 必须对上,对不上客户端会一直等。
错误响应:带同一个 id 和 error,error 里有 code、message,可选 data。 通知:带 method,没有 id。发出去就不管了,对方不回。 这四种里只有通知不回话。 判断一条报文要不要回,只看有没有 id。回错了客户端会等到超时,日志里就是一条「无响应」。
四、五个错误码,各管一段路
JSON-RPC 定了五个码,MCP 全部沿用。它们其实对应一条处理链上的五个位置。
| 码 | 英文 | 卡在哪一步 |
|---|---|---|
| -32700 | Parse error | 根本不是 JSON,还没进解析器 |
| -32600 | Invalid Request | 是 JSON,但不是合法请求,或还没握手 |
| -32601 | Method not found | 方法名不认识,或这版不支持 |
| -32602 | Invalid params | 方法认识,参数缺了或类型不对 |
| -32603 | Internal error | 服务端自己抛异常了 |
从上往下就是一条报文的生命周期。 排错时看码就知道该查哪一段,不用猜。 演示第五段特别值得记:不握手直接 tools/call,回的是 -32600,不是 -32602。 很多人在这里绕弯:明明是参数写错了,为什么报请求错?因为你连「能发请求」的资格都还没有。 【顺序错和参数错要分清,两种修法完全不同。】
五、一次调用走完的十步
把上面这些按时间摆开,就是一次完整的交互。演示最后给了一张十行的表。 前五步是连接和清单:读配置、握手、回话、告知、列表。第六步开始才是模型登场。 第 6、7、10 步是模型的活,第 8、9 步是协议的活。 这句话能解释一大类困惑。
比如「Server 明明没问题,模型就是不调它」——那是第 6 步没做好:工具描述写得让人看不懂, 模型不知道什么时候该用。 再比如「模型说要调,但结果没回来」——那是第 8、9 步,去看错误码。 MCP 只管第 8、9 步。 前面怎么让模型决定调什么,它不管,也不该管。
六、动手 Demo:两种传输,一套报文
同一个 Server,先用管道跑一遍,再用 HTTP 跑一遍,对比耗时和状态码。 然后故意犯三种错:发一段非 JSON、调一个不存在的方法、少传一个参数,看各回什么码。 报文全是真收发的,数字全是这台机器上量出来的。
# 一、stdio:宿主起子进程,两根管道
c = Stdio('server.py')
t0 = time.perf_counter(); c.init(); t1 = time.perf_counter()
c.tools(); t2 = time.perf_counter()
c.call('add', {'a': 1, 'b': 2}); t3 = time.perf_counter()
print('握手 %.1f 毫秒,往返 %.2f 毫秒'
% ((t1 - t0) * 1000, (t3 - t2) * 1000))
# 二、HTTP:服务端自己跑着,客户端 POST
base, stop = serve_mcp(srv)
for method, params, rid in [('initialize', {'protocolVersion': '2024-11-05'},
1),
('tools/list', {}, 2),
('tools/call', {'name': 'weather.forecast'},
3)]:
print(method, post_json(base + '/mcp', jr(method, params, rid))[0])
stop()
# 三、四种报文:区别只在有没有 id
print(jr('tools/call', {'name': 'add'}, 7)) # 请求,要回
print(jr_ok(7, {'content': []})) # 成功,id 对得上
print(jr_err(7, -32602, '缺必填 b')) # 错误,id 也对得上
print(jr('notifications/initialized', {})) # 通知,不回
实跑输出:
--------------------------------------------------------------------
两种传输,一套报文
--------------------------------------------------------------------
① stdio:两根管道
--------------------------------------------------------------------
最直接的一种:宿主把 Server 当子进程拉起来,一个写它的 stdin,一个读它的 stdout。
| 步 | 第一次(毫秒) | 第二次(毫秒) |
|---|---:|---:|
| 起进程并握手 | 513.6 | 508.6 |
| tools/list | 0.14 | — |
| tools/call | 0.15 | — |
第一次握手那 514 毫秒,量的是【解释器启动加 import】,不是协议。
第二次仍然要 509 毫秒,因为进程还是新起的。
后面两次往返都在毫秒以下——【管道就是内存拷贝,没有网络。】
这条路径不需要端口、不需要鉴权、不需要联网。它也跨不了机器。
② HTTP:一个端口
--------------------------------------------------------------------
| 请求 | 状态码 | 正文里有什么 |
|---|---|---|
| initialize | 200 | protocolVersion、capabilities、serverInfo |
| tools/list | 200 | tools 数组 |
| tools/call | 200 | content 数组,一段文本 |
同一套报文,换成 POST 发出去、JSON 收回来。
【协议一个字没改,换的只是搬运方式。】
另一种:GET 一条长连接,服务端用 SSE 往回推。打开时先收到一帧:
event: endpoint
data: /mcp?session=sess-3c81
这一帧告诉客户端「以后往哪个地址 POST」。
先开流、再发请求——这是老传输的固定顺序,新传输已经把这个顺序去掉了。
③ 两种传输摆在一起
--------------------------------------------------------------------
| 比什么 | stdio | HTTP |
|---|---|---|
| 谁起谁 | 宿主拉起子进程 | 服务端自己跑着 |
| 要不要端口 | 不要 | 要 |
| 能跨机器吗 | 不能 | 能 |
| 鉴权 | 靠操作系统权限 | 靠令牌,第 3 章讲 |
| 典型场景 | 本地工具、开发调试 | 线上服务、多人共用 |
④ 三条消息,三种形态
--------------------------------------------------------------------
请求:有 id,等回话。
>> {"jsonrpc":"2.0","method":"tools/call","params":{"name":"add","arguments":{"a":1,
"b":2}},"id":7}
成功响应:id 必须和请求对上。
<< {"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"3"}]}}
错误响应:没有 result,只有 error,附带一个错误码。
<< {"jsonrpc":"2.0","id":7,"error":{"code":-32602,"message":"Invalid params",
"data":"缺必填 b"}}
通知:没有 id,不回话。
>> {"jsonrpc":"2.0","method":"notifications/initialized"}
| 码 | 英文 | 什么时候出现 |
|---|---|---|
| -32700 | Parse error | 发过来的根本不是 JSON |
| -32600 | Invalid Request | 不是合法请求对象,或没握手 |
| -32601 | Method not found | 方法名写错、版本不支持 |
| -32602 | Invalid params | 参数缺了、类型不对 |
| -32603 | Internal error | 服务端自己抛异常 |
【有 id 就要回,没 id 就别回。】回错了客户端会一直等,等到超时。
⑤ 不握手就调工具,会怎样
--------------------------------------------------------------------
直接 tools/call -> code=-32600 Invalid Request,data=先 initialize
握手之后再调 -> {"day": "今天", "text": "多云", "low": 19, "high": 27}
规范把顺序定死了:initialize、initialized、然后才能调。
【顺序错了报的不是参数错,是「你还没握手」——这两种错要分清。】
⑥ 一次调用走完的路
--------------------------------------------------------------------
把上面几步按时间摆出来,就是一次完整的交互:
| 时刻 | 谁 -> 谁 | 发生了什么 |
|---:|---|---|
| 1 | 宿主 -> 客户端 | 读配置,决定要连哪个 Server |
| 2 | 客户端 -> 服务端 | initialize:版本、能力、我是谁 |
| 3 | 服务端 -> 客户端 | 回自己的版本、能力、名字 |
| 4 | 客户端 -> 服务端 | notifications/initialized(不回话) |
| 5 | 客户端 -> 服务端 | tools/list:把表要过来 |
| 6 | 宿主 -> 模型 | 把这张表拼成一段话塞进上下文 |
| 7 | 模型 -> 宿主 | 说要调 weather.forecast,参数是这两个 |
| 8 | 客户端 -> 服务端 | tools/call |
| 9 | 服务端 -> 客户端 | content 里一段文本 |
| 10 | 宿主 -> 模型 | 把这段文本接回去,让模型接着说 |
第 6、7、10 步是模型的活,第 8、9 步是协议的活。
【MCP 只管第 8、9 步。前面怎么让模型决定调什么,它不管。】
! 这句话能解释很多困惑:Server 明明没问题,模型就是不调它——
那是第 6 步没做好(描述写得看不懂),跟协议无关。
①第一张表三行,重点不是绝对数,是量级差:握手五百毫秒,往返零点一五毫秒。 这两个数差三千多倍,说明握手那一下根本不在协议里。 ②第二次重连仍然要五百毫秒,这条比第一个数更有用:它证明贵的是起进程,不是握手本身。 本机工具用 stdio 没问题,因为进程只起一次。 ③第二张表三行全是 200。 HTTP 那层的状态码和协议那层的错误码是两套东西,别混:HTTP 200 表示「我收到了」, 正文里可能是个 -32602。 ④第三段那四种报文,看有没有 id 就能分开。通知没有 id,所以它永远等不到回话——这不是丢包,是设计。 ⑤最后那张十步的表,值得贴在显示器边上。出问题先定位在第几步,再决定看日志的哪一段。
30 秒实验
- 把演示里那个 514 毫秒的握手,跟 0.15 毫秒的往返比一下倍数。想想这个倍数说明了什么。
- 发一段不是 JSON 的字符串给 Server,看它回 -32700 还是直接断开。
- 凭记忆写出五个错误码,再对照那张表。写错的那一个,多半是你还没真正遇到过。
常见误区
- 「HTTP 返回 200 就是成功了。」200 只说「收到了」。协议层的成败看正文里有没有 error。
- 「参数错就报 -32602。」没握手时先报 -32600。顺序在前,参数在后。
- 「通知没回话说明连接坏了。」通知本来就不回。看日志时别把它当超时。
自测三问(点开对答案)
1. stdio 握手为什么比一次往返贵三千倍? —— 握手那一次要把 Server 进程起起来,量的是解释器启动和 import;之后的往返只是管道读写。 2. 一条报文要不要回话,看什么? —— 看有没有 id。有 id 是请求,必须回;没 id 是通知,不回。 3. 没握手就 tools/call,回哪个码?为什么不是参数错? —— -32600 Invalid Request。这时还没建立会话,连「合法请求」都不算,轮不到校验参数。协议这一层到这儿就算拆完了。 下一章换个方向:既然工具描述会进模型上下文,那它被写成恶意的话会怎样。
- ☐ (本篇完,下一篇 3.1 MCP 存在安全问题)