MCP Python SDK 协议版本协商指南:从 `initialize` 握手到 `server/discover` 时代的 `mode=` 控制
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_versions、capabilities、instructions以及写入结果_meta的服务器身份信息。
好消息是:绝大多数场景下你不需要关心这两个代际的区别,因为 SDK 的 Client 会替你完成协商。真正需要你决策的只有一个构造参数——mode=——以及三种需要修改它的场景。
版本常量的源码证据
在仓库的 src/mcp/client/_probe.py 中,代际版本被定义为两组明确的集合(HANDSHAKE_PROTOCOL_VERSIONS、MODERN_PROTOCOL_VERSIONS,以及 LATEST_MODERN_VERSION),协商逻辑完全基于这些常量运转:
- 探测请求总是以
LATEST_MODERN_VERSION(当前即2026-07-28)发出; - 只有
server/discover或initialize返回的版本落在各自代际集合内才会被接受; - 如果服务器返回的
supportedVersions与客户端没有任何交集,连接直接失败。
协议版本常量还对应仓库 schema/ 目录下的两份 JSON Schema(schema/2025-11-25.json、schema/2026-07-28.json),schema/PINNED.json 标明当前固定版本,需要核对协议字段结构时可参照。
复现环境:Bookshop 服务器 + 两个终端
本文的每一个代码片段都是一个 client.py,与「客户端」章节中的 Bookshop server.py 通信(见 docs_src/client/tutorial001.py)。Bookshop 服务器定义了两个工具(search_books、lookup_book)和两个资源(catalog://genres、catalog://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都会触发回退,唯一例外是错误码-32022(UNSUPPORTED_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-28,async with 返回的那一刻连接就已就绪。
固定是一个由你作出的承诺:你已经知道服务器讲这个版本。客户端不会去验证。
固定的代价:server_info 为 None
固定不是发现(discovery)。打印 client.server_info,代价立刻显现:
None
客户端从未向服务器询问过「你是谁」,因此 server_info 为 None。client.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_versions、capabilities、instructions,以及服务器写进结果 _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(...) 恢复它,再传给新的 Client。tests/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 也验证了这一点:在 auto 与 legacy 下传入一个伪造的陈旧结果,server_info 依然来自真实的握手/探测,而不是被陈旧数据覆盖。
四种模式的速查表
| 你写的代码 | 协商流量 | 你得到的结果 |
|---|---|---|
Client(target) |
一次 server/discover 探测;失败时回退到 initialize 握手 |
双方都能讲的最新版本,无论哪个代际 |
Client(target, mode="legacy") |
initialize 握手 |
握手时代的版本;服务器发起的请求可用 |
Client(target, mode="2026-07-28") |
无 | 该版本,固定,server_info 为 None |
Client(target, mode="2026-07-28", prior_discover=saved) |
无 | 该版本,固定,外加你上次保存的身份信息 |
底层实现:adopt() 与 discover() 的分工
理解前文行为,离不开 src/mcp/client/session.py 中两个核心方法:
initialize():发送InitializeRequest,protocol_version固定为LATEST_HANDSHAKE_VERSION(当前2025-11-25),随后校验服务器应答的版本必须落在HANDSHAKE_PROTOCOL_VERSIONS内,然后adopt()结果并发送InitializedNotification。adopt():将协商结果「安装」到会话上,不产生任何线上流量。传入DiscoverResult时,从supported_versions中选取双方共有的最新现代版本,解析服务器写入_meta的身份信息(_parse_server_info_stamp),并设置discover_result与server_info;传入InitializeResult时则走握手时代的 stamp 逻辑。两种结果互斥:任一时刻最多只有initialize_result/discover_result之一非None。这正是「固定模式 +prior_discover=」能零流量恢复身份的原因——adopt()本来就不需要联网。
在 src/mcp/client/client.py 的 _connect 流程中,三种模式的落点清晰可见:legacy 走 initialize(),auto 走 negotiate_auto()(即 _probe.py 的探测-回退策略),固定版本则直接 session.adopt(prior_discover or _synthesize_discover(mode))——连探测都省了。另外注意,现代协议下 ping 方法已被移除(仅 mode="legacy" 下可用),client.py 的 ping 签名注释明确说明了这一点。
结论速记
- MCP 有握手时代(截至
2025-11-25,initialize握手)和现代时代(2026-07-28,server/discover),Client负责桥接两者。 mode="auto"是默认值:先探测、失败回退。除非下表其他三行中的某一项描述的就是你的场景,否则保持默认。client.protocol_version永远是「我最终得到了什么」这一问题的答案。mode="legacy"强制握手。服务器发起的请求(sampling、push 式 elicitation、message_handler)必须依赖它。- 版本固定(
mode="2026-07-28")完全不发送协商流量——代价是client.server_info为None。 prior_discover=把代价补回来:保存client.session.discover_result,重连时带上它,两者兼得。
最后补充一个闭环:现代连接没有 push 通道,那么 2026 年的服务器如何在调用中途向你提问?答案是「返回」——服务器把问题作为结果返回,你带着答案重试调用,详见 docs/handlers/multi-round-trip.md。