RSS

第 3 章 / 共 12 章

掀开盖子:一次工具调用的完整往返

约 9 分钟 更新于

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.1:从上到下依次是你写的函数、SDK 做的转换、协议消息、传输通道。调试时的定位动作,就是判断问题卡在哪一层。

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 关联,要么就每次重新计算。

3.6 练习与检查点