MCP Python SDK 协议版本协商指南:从 `initialize` 握手到 `server/discover` 时代的 `mode=` 控制

原创2026-09-20 18:07:14251 阅读
文章标签:人工智能MCP 服务MCP Clients

MCP(Model Context Protocol)协议目前存在两个代际:2026-07-28 之前的服务器在每个连接上都以 initialize 握手开场(客户端提版本、服务器还价、客户端确认),而 2026-07-28 起的新一代服务器取消了握手,改为客户端发送一次 server/discover 探测请求,服务器在一个结果里返回全部信息。本文以官方 Python SDK 的 Client 构造参数 mode= 为线索,完整讲解自动协商("auto")、强制握手("legacy")、版本固定(pin)与 prior_discover= 缓存复用这四种连接方式,并结合仓库源码说明其底层实现与取舍。读完本文,你将能针对任意代际的 MCP 服务器写出无需分支判断的客户端代码,并精确控制协商流量与 server_info 的可得性。

两个代际:握手时代与现代时代

MCP 的连接建立方式在两个代际之间存在根本差异:

  • 握手时代(2025-11-25 及更早):每个连接都以 initialize 请求开场。客户端提议一个协议版本,服务器给出还价,客户端确认,这一切都发生在第一个有用请求之前。会话上存在一个由握手建立的、服务器可以主动发起请求的通道。
  • 现代时代(2026-07-28):握手被取消了。客户端发送一次 server/discover 探测请求,服务器在一个结果(DiscoverResult)中一次性返回 supported_versionscapabilitiesinstructions 以及写入结果 _meta 的服务器身份信息。

好消息是:绝大多数场景下你不需要关心这两个代际的区别,因为 SDK 的 Client 会替你完成协商。真正需要你决策的只有一个构造参数——mode=——以及三种需要修改它的场景。

版本常量的源码证据

在仓库的 src/mcp/client/_probe.py 中,代际版本被定义为两组明确的集合(HANDSHAKE_PROTOCOL_VERSIONSMODERN_PROTOCOL_VERSIONS,以及 LATEST_MODERN_VERSION),协商逻辑完全基于这些常量运转:

  • 探测请求总是以 LATEST_MODERN_VERSION(当前即 2026-07-28)发出;
  • 只有 server/discoverinitialize 返回的版本落在各自代际集合内才会被接受;
  • 如果服务器返回的 supportedVersions 与客户端没有任何交集,连接直接失败。

协议版本常量还对应仓库 schema/ 目录下的两份 JSON Schema(schema/2025-11-25.jsonschema/2026-07-28.json),schema/PINNED.json 标明当前固定版本,需要核对协议字段结构时可参照。

复现环境:Bookshop 服务器 + 两个终端

本文的每一个代码片段都是一个 client.py,与「客户端」章节中的 Bookshop server.py 通信(见 docs_src/client/tutorial001.py)。Bookshop 服务器定义了两个工具(search_bookslookup_book)和两个资源(catalog://genrescatalog://genres/{genre})。

在第一个终端启动服务器:

uv run mcp run server.py --transport streamable-http

然后在第二个终端依次运行各片段:

python client.py

mode="auto":默认的自动协商

第一个片段没有传 mode,因此使用的是默认值 "auto"

import anyio

from mcp import Client


async def main() -> None:
    async with Client("http://localhost:8000/mcp") as client:
        print(client.protocol_version)


if __name__ == "__main__":
    anyio.run(main)

(完整源码见 docs_src/protocol_versions/tutorial001.py。)

进入 async with 时,客户端以本 SDK 能讲的最新版本发送一次 server/discover 探测,然后分两种情况处理:

  • 现代服务器:正常应答探测,客户端直接采用其结果。一个往返,连接建立完毕。
  • 较老的服务器:从未听说过 server/discover,返回错误。客户端回退到经典的 initialize 握手,并采用握手协商出的结果。

无论哪种情况,连接都已建立,且 client.protocol_version 会告诉你最终走了哪条路:

2026-07-28

这就是整个特性:一个 Client,任何代际的服务器,你的代码里不需要任何分支判断。

回退策略:拒绝清单而非允许清单

从源码看,mode="auto" 的回退不是简单的「出错就握手」,而是一套精确的判定策略。src/mcp/client/_probe.py 的模块文档将其描述为 denylist(拒绝清单) 而非 allowlist:

  • 任何 MCPError 都会触发回退,唯一例外是错误码 -32022UNSUPPORTED_PROTOCOL_VERSION)且服务器的 supported 列表与现代版本完全不相交——这种情况说明服务器是只讲现代协议且与客户端无交集,属于真正的不兼容,直接抛出。
  • server/discover 成功但返回的 supportedVersions 不含任何现代版本(例如 go-sdk 的 stateful streamable 默认实现就是这样),被视为「旧时代声明」,同样回退到 initialize
  • MCPError 的异常(网络错误、连接中断、anyio 取消)会原样向上传播——一次故障或进程内 bug 绝不会被误判为代际结论。
  • 回退握手本身也可能以 -32022 应答(例如探测在客户端超时、却在慢启动的服务器上成功,导致连接在排队的 initialize 到达前已被锁定为现代)。由于 -32022 本身就是服务器是现代的积极证据(它点名了服务器的版本),此时会以双方共有的版本再探测一次,而不是让连接失败。

对自己服务器的行为

一个值得注意的细节:MCPServer 在任何传输上都应答 server/discover——包括 Streamable HTTP、stdio,以及测试使用的进程内连接。因此在 src/mcp/server/lowlevel/server.py 中,server/discover 注册了默认处理器 _handle_discover,它返回包含服务器能力、指令与版本支持的 DiscoverResult。这意味着:

与自己的服务器通信时,auto 模式总是落在 2026-07-28。回退只会在面对真正的 2026 年之前的服务器时触发——而那正是你希望它触发的时机。

mode="legacy":强制握手,换取 push 能力

第二个片段显式传入 mode="legacy"

import anyio

from mcp import Client


async def main() -> None:
    async with Client("http://localhost:8000/mcp", mode="legacy") as client:
        print(client.protocol_version)


if __name__ == "__main__":
    anyio.run(main)

(完整源码见 docs_src/protocol_versions/tutorial002.py。)

mode="legacy" 从不发送探测请求。它直接执行 initialize 握手——与 2026 年之前的客户端打开连接的方式完全一致:

2025-11-25

同一个服务器。它完全能讲 2026-07-28——只是你让客户端别去问。

为什么需要握手时代:服务器发起的请求

选择 legacy 模式是为了 push 风格(服务器发起)的功能

  • ctx.elicit(...):服务器把表单推到你的用户面前;
  • sampling(采样):服务器在工具调用中途向你的模型请求一次补全;
  • message_handler:服务器主动推送的消息处理。

这类「服务器调用你」的通道只存在于握手时代建立的会话上。在 2026-07-28 现代协议中,这条通道被移除了:服务器改为返回它的提问,你在本地处理后再带着答案重试同一个调用——这正是「多轮往返请求(multi-round-trip)」机制(见 docs/handlers/multi-round-trip.md)。

因此决策规则很清晰:

  • mode="auto" 只在服务器老到别无选择时才给你握手;
  • mode="legacy" 保证给你握手。

只要你在 Client(...) 中传入 sampling_callback、希望以请求方式驱动的 elicitation_callback,或 message_handler,就应该使用 mode="legacy"。这些回调的逐一讲解见 docs/client/callbacks.md

src/mcp/client/client.py 的源码注释可以看到,legacy 模式被描述为「强制 initialize 握手(与 2026 年前行为逐字节一致)」;在进程内连接场景下,legacy 走 InMemoryTransport 驱动的流循环,而其他模式走现代的按请求分发路径。

固定版本(Pin):mode="2026-07-28",零协商流量

mode 也接受一个现代协议版本字符串。当前这个集合恰好是 ["2026-07-28"]

import anyio

from mcp import Client


async def main() -> None:
    async with Client("http://localhost:8000/mcp", mode="2026-07-28") as client:
        print(client.protocol_version)


if __name__ == "__main__":
    anyio.run(main)

(完整源码见 docs_src/protocol_versions/tutorial003.py。)

固定版本不发送任何东西:没有探测,没有握手。客户端在本地直接采纳 2026-07-28async with 返回的那一刻连接就已就绪。

固定是一个由作出的承诺:你已经知道服务器讲这个版本。客户端不会去验证。

固定的代价:server_infoNone

固定不是发现(discovery)。打印 client.server_info,代价立刻显现:

None

客户端从未向服务器询问过「你是谁」,因此 server_infoNoneclient.server_capabilities 同理:每一项能力都是 None。工具调用依然正常工作(协议本身并不需要这些信息),但那些读取 server_capabilities 来决定「向用户提供什么」的代码则无法工作。下一节正是这个问题的解法。

非现代版本不能固定

只有现代版本可以被固定。握手时代的版本字符串会在构造时、任何 I/O 发生之前被拒绝,且错误信息会直接告诉你该写什么:

ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy')

这条校验在 src/mcp/client/client.py__init__ 中完成:mode 既不是 "legacy"/"auto" 也不在 MODERN_PROTOCOL_VERSIONS 中时,如果它属于 HANDSHAKE_PROTOCOL_VERSIONS,就会附上「这是握手时代版本,请用 mode='legacy'」的提示,否则列出所有合法取值。

prior_discover= 重连:把身份找回来

探测请求虽然便宜,但毕竟是一个往返,每次重连都要付一次费,而答案几乎从不变化。

所以:把它存下来。在 auto 模式下连接成功后,client.session.discover_result 里保存着服务器发送的精确 DiscoverResult:它的 supported_versionscapabilitiesinstructions,以及服务器写进结果 _meta 的身份信息。下次连接时把它作为 prior_discover= 传回:

import anyio

from mcp import Client


async def main() -> None:
    async with Client("http://localhost:8000/mcp") as client:
        saved = client.session.discover_result

    async with Client("http://localhost:8000/mcp", mode="2026-07-28", prior_discover=saved) as client:
        print(client.protocol_version)
        if client.server_info is not None:
            print(client.server_info.name)


if __name__ == "__main__":
    anyio.run(main)

(完整源码见 docs_src/protocol_versions/tutorial004.py。)

输出:

2026-07-28
Bookshop

第二次连接做了次协商往返,却仍然精确地知道自己在和谁说话。这就是「做得对的固定模式」:mode= 指明版本,prior_discover= 提供身份。

跨进程持久化

DiscoverResult 是一个 Pydantic 模型。用 saved.model_dump_json() 可以把结果序列化进文件或缓存;在下一个进程里用 DiscoverResult.model_validate_json(...) 恢复它,再传给新的 Clienttests/docs_src/test_protocol_versions.py 中的 test_discover_result_survives_json 正是这样验证的:dumps 成 JSON、validate 回来、带着它重连,server_info.name 依然等于 "Bookshop"

生效条件

prior_discover= 只有在 mode 是固定版本时才起作用:

  • "auto" 模式下,客户端无论如何都会重新探测服务器;
  • "legacy" 模式下,该参数被忽略。

src/mcp/client/client.py 的构造参数注释明确写着:prior_discover 是「当 mode 为版本固定时,通过 .adopt() 安装的、先前取得的 DiscoverResult;当 mode='legacy' 时被忽略」。测试 test_prior_discover_is_ignored_unless_mode_is_a_pin 也验证了这一点:在 autolegacy 下传入一个伪造的陈旧结果,server_info 依然来自真实的握手/探测,而不是被陈旧数据覆盖。

四种模式的速查表

你写的代码 协商流量 你得到的结果
Client(target) 一次 server/discover 探测;失败时回退到 initialize 握手 双方都能讲的最新版本,无论哪个代际
Client(target, mode="legacy") initialize 握手 握手时代的版本;服务器发起的请求可用
Client(target, mode="2026-07-28") 该版本,固定,server_infoNone
Client(target, mode="2026-07-28", prior_discover=saved) 该版本,固定,外加你上次保存的身份信息

底层实现:adopt()discover() 的分工

理解前文行为,离不开 src/mcp/client/session.py 中两个核心方法:

  • initialize():发送 InitializeRequestprotocol_version 固定为 LATEST_HANDSHAKE_VERSION(当前 2025-11-25),随后校验服务器应答的版本必须落在 HANDSHAKE_PROTOCOL_VERSIONS 内,然后 adopt() 结果并发送 InitializedNotification
  • adopt():将协商结果「安装」到会话上,不产生任何线上流量。传入 DiscoverResult 时,从 supported_versions 中选取双方共有的最新现代版本,解析服务器写入 _meta 的身份信息(_parse_server_info_stamp),并设置 discover_resultserver_info;传入 InitializeResult 时则走握手时代的 stamp 逻辑。两种结果互斥:任一时刻最多只有 initialize_result / discover_result 之一非 None。这正是「固定模式 + prior_discover=」能零流量恢复身份的原因——adopt() 本来就不需要联网。

src/mcp/client/client.py_connect 流程中,三种模式的落点清晰可见:legacyinitialize()autonegotiate_auto()(即 _probe.py 的探测-回退策略),固定版本则直接 session.adopt(prior_discover or _synthesize_discover(mode))——连探测都省了。另外注意,现代协议下 ping 方法已被移除(仅 mode="legacy" 下可用),client.pyping 签名注释明确说明了这一点。

结论速记

  • MCP 有握手时代(截至 2025-11-25initialize 握手)和现代时代(2026-07-28server/discover),Client 负责桥接两者。
  • mode="auto" 是默认值:先探测、失败回退。除非下表其他三行中的某一项描述的就是你的场景,否则保持默认。
  • client.protocol_version 永远是「我最终得到了什么」这一问题的答案。
  • mode="legacy" 强制握手。服务器发起的请求(sampling、push 式 elicitation、message_handler)必须依赖它。
  • 版本固定(mode="2026-07-28")完全不发送协商流量——代价是 client.server_infoNone
  • prior_discover= 把代价补回来:保存 client.session.discover_result,重连时带上它,两者兼得。

最后补充一个闭环:现代连接没有 push 通道,那么 2026 年的服务器如何在调用中途向你提问?答案是「返回」——服务器把问题作为结果返回,你带着答案重试调用,详见 docs/handlers/multi-round-trip.md

登录后查看全文
python-sdk