第 3 章 / 共 12 章
掀开盖子:一次工具调用的完整往返
3.1 为什么要看协议:所有调试都靠它
上一章你写了三十行代码,SDK 把它变成了一个能被调用的工具。这一章要把盖子掀开看一眼。
这不是学院派的好奇心。想想第 8 章你会遇到的那些问题:宿主说连不上、工具列表是空的、调用没反应、返回是乱码。这些症状全都发生在协议层——你的 Python 函数根本没被执行,或者执行了但结果没能正确传回去。如果你脑子里没有「一次调用长什么样」的模型,这些问题就只能靠换配置碰运气。
看一遍就够,不需要背。
3.2 stdio:一行一条消息,仅此而已
本地 MCP server 用的传输方式叫 stdio,规则简单到有点让人意外:
- 客户端把你的 server 作为子进程启动。
- server 从 stdin 读 JSON-RPC 消息,往 stdout 写 JSON-RPC 消息。
- 消息以换行分隔,并且消息内部不能包含换行。
- server 可以往 stderr 写任意级别的日志;客户端可以捕获、转发或忽略它,而且不应该认为 stderr 有输出就意味着出错。
- server 绝不能往 stdout 写任何不是有效 MCP 消息的内容。
没有帧头,没有长度前缀,没有握手包。就是一行一个 JSON。这也是为什么第 2.6 节那个 print 的坑那么致命——它破坏的是这个协议里唯一的一条结构规则。
规范还提到,这套线格式(在可靠的双向字节流上,每行一条 JSON-RPC 消息)原样适用于 Unix domain socket 或 TCP 连接,不限于进程的标准输入输出。

3.3 2026-07-28:没有握手,每个请求自带身份
这是本章最需要更新认知的一点。
在 2025 年 11 月及更早的规范里,MCP 是一个有状态的会话协议:客户端先发 initialize 请求,双方协商协议版本和能力,客户端再发一个 notifications/initialized 通知,然后才能开始正常调用。HTTP 传输还会有一个 MCP-Session-Id 头贯穿整个会话。
2026-07-28 版本把这一整套拆掉了。官方 changelog 的原话是「让 MCP 变成无状态」:移除 initialize / notifications/initialized 握手,每个请求自己携带协议版本和客户端能力,放在 _meta 字段里。
于是一个请求现在长这样——注意 params._meta 里那三个键:
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "search_notes",
"arguments": {"keyword": "MCP"},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "probe", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
服务器不支持这个版本,就返回 UnsupportedProtocolVersionError,并列出自己支持哪些版本,客户端可以换一个再试。
握手没了,那客户端怎么知道服务器有什么能力?新版本加了一个必须实现的 RPC:server/discover。它一次返回服务器支持的协议版本、能力和身份。客户端可以先调它做版本选择,也可以直接发请求、遇到版本错误再处理。
你不需要自己实现它——SDK 已经做了。把第 2 章那个 server 跑起来,往 stdin 里塞一行 server/discover,它真实返回的是:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"supportedVersions": ["2026-07-28"],
"capabilities": {
"tools": {"listChanged": true},
"resources": {"listChanged": true, "subscribe": true},
"prompts": {"listChanged": true}
},
"cacheScope": "private",
"ttlMs": 0,
"_meta": {
"io.modelcontextprotocol/serverInfo": {"name": "notes", "version": ""}
}
}
}
三样东西对应上了:supportedVersions 是版本,capabilities 是能力,_meta 里的 serverInfo 是身份。注意 version 是空字符串——因为第 2 章创建 MCPServer("notes") 时没传 version。这个值会显示给用户看,正式发布前记得填。
还有两个新字段值得认识:所有结果现在都带一个必填的 resultType,普通结果是 "complete";列表类结果带 ttlMs 和 cacheScope,用于客户端缓存。
3.4 一次真实的调用往返
把三条消息依次喂给第 2 章那个 server,下面是它逐条吐回来的东西(未经修改)。
成功调用:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{"type": "text", "text": "meeting.md:2: 2026-09-01 讨论了 MCP 选题。"}
],
"isError": false,
"structuredContent": {
"result": "meeting.md:2: 2026-09-01 讨论了 MCP 选题。"
},
"resultType": "complete",
"_meta": {"io.modelcontextprotocol/serverInfo": {"name": "notes", "version": ""}}
}
}
留意这里有两个返回通道:content 是给模型看的文本块,structuredContent 是给宿主程序用的结构化数据。你的函数只 return 了一个字符串,SDK 同时填了这两处。第 6 章会讲怎么用好它们。
参数类型错误(客户端传了 "keyword": 42):
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"content": [
{"type": "text", "text": "Error executing tool search_notes: 1 validation error for search_notesArguments\nkeyword\n Input should be a valid string [type=string_type, input_value=42, input_type=int]"}
],
"isError": true,
"resultType": "complete"
}
}
注意这不是一个 JSON-RPC 错误——它是一个正常的 result,只是 isError 为 true。这个区别在第 6 章是重点:带 isError 的结果会被交给模型,模型看得懂「应该传字符串而不是数字」,于是能自己改了重试。
你的函数在这次调用里根本没有被执行。类型注解就是契约,SDK 在进函数之前就把不合法的参数挡掉了。
调用不存在的工具:
{
"jsonrpc": "2.0",
"id": 5,
"result": {
"content": [{"type": "text", "text": "Unknown tool: no_such_tool"}],
"isError": true,
"resultType": "complete"
}
}
3.5 读旧文档时的翻译表
你在接下来相当长一段时间里,还会不断读到按旧版本写成的教程和代码。这张表帮你把它们翻译成现在的形态。
| 旧文档里的说法 | 2026-07-28 的现状 |
|---|---|
initialize 握手、notifications/initialized | 已移除;每个请求在 _meta 里带版本与能力 |
MCP-Session-Id 头维持会话 | 已移除;跨调用状态改用服务器生成的 handle,当作普通工具参数传 |
| HTTP GET 打开 SSE 流接收服务端消息 | 已移除;改用 subscriptions/listen |
Last-Event-ID 断线续传 | 不再支持,需要用新的请求 id 重发 |
服务端主动发起 sampling/createMessage、roots/list | 改为多轮往返(MRTR):返回 resultType: "input_required",客户端带着补充信息重试 |
ping、logging/setLevel | 已移除 |
| Roots / Sampling / Logging 三个能力 | 已进入弃用窗口(至少 12 个月),新实现不应再依赖 |
| HTTP+SSE 传输 | 自 2025-03-26 起弃用,现已正式列入弃用注册表,新实现不要采用 |
资源不存在返回 -32002 | 改为 -32602,与 JSON-RPC 规范对齐 |
有一个好消息:这些变化绝大多数你都不用亲自处理。SDK 的同一份工具代码能同时服务新旧两代客户端——比如需要向用户追问信息时,协议版本够新就走多轮往返,够旧就退化成传统的服务端主动请求。你写业务逻辑,兼容性交给 SDK。
真正需要你改变的只有认知:别再按「会话」去理解 MCP。不要假设两次调用之间服务器记得上一次发生了什么。需要延续的状态,要么放进你自己的进程状态里并用显式 handle 关联,要么就每次重新计算。