「告别握手」系列 · 第 2 篇Agent 调用 MCP 工具服务需要先确认两边协议版本和能力对得上。在 MCP 的旧版本里「确认双方能对话」这一步本身就要在客户端和服务端之间来回好几轮之后的每一次查询还要带上服务端发的一张「通行证」。7 月 28 日发布的 MCP 新规范把这一整套来回拆掉了。官方称这是 MCP 自远程协议推出以来最重要的一次修订核心变化在协议的状态管理上让我们拆开看清楚。一次调用为什么要先握手先看旧版2025-11-25在远程模式下一次工具调用的完整路径。假设 Agent 要调一个搜索工具流程是三步客户端发initialize告诉服务端「我用的协议版本是 2025-11-25我支持工具调用我是 my-app」。服务端核对版本和能力签发一个Mcp-Session-Id随响应返回客户端再发一个notifications/initialized通知「初始化完成」。之后每一个真实请求——tools/list、tools/call——都带上这张通行证。报文长这样。先看握手POST /mcp HTTP/1.1 Content-Type: application/json { jsonrpc: 2.0, id: 1, method: initialize, params: { protocolVersion: 2025-11-25, capabilities: { tools: {} }, clientInfo: { name: my-app, version: 1.0.0 } } }服务端回一个 200响应头部带着Mcp-Session-Id: 7f3a…body 里是它支持的协议版本和能力清单。然后是实际调用POST /mcp HTTP/1.1 Mcp-Session-Id: 7f3a… Content-Type: application/json { jsonrpc: 2.0, id: 2, method: tools/call, params: { name: search, arguments: { q: 订单 } } }这套「握手加会话」的模式是从 Web 借鉴来的。HTTP 本身无状态Web 应用靠 Cookie 和 Session 在应用层补上状态MCP 在 2025-03-26 引入 Streamable HTTP 传输之后也采用了同样的思路initialize相当于登录Mcp-Session-Id相当于登录后发的那张会话凭证。再往前追溯2025-03-26 之前 MCP 用的是 HTTPSSE 传输服务端还要为每个客户端维持一条持续连接的 SSE 流。这套设计放在单机场景没毛病。MCP 最早就是为本地进程设计的——AI IDE拉起一个文件系统服务利用stdio 管道通信进程本身就是那个会话。问题在于「连接」管理 被耦合成协议设计的一部分所有请求默认共享一个隐式的连接上下文服务端必须记得「这个客户端是谁」。这个假设在本地成立在分布式环境里很快撑不住。握手在分布式环境里变成负担生产环境的特点是服务不止一台机器而是几十上百个实例流量不直连而是经过网关和负载均衡器实例随时在扩容、缩容、重启。在这种环境里「连接绑定实例」的设计会带来一系列问题。举个具体场景采购服务部署了三台实例一个 Agent 的搜索请求被网关分到实例 A会话建在 A 上下一个请求因为粘性路由又被送回 A。只要 A 稳定一切正常。可一旦 A 要重启、或者扩容时新实例要分担流量麻烦就来了。第一负载均衡必须做粘性路由。同一个会话的请求必须被路由到当初签发Mcp-Session-Id的那台实例否则新实例不认识这张通行证。负载均衡器得按会话 ID 把请求固定调度到同一台实例——这种固定调度就是常说的「会话亲和性」它本身就是一种状态。更麻烦的是扩缩容时亲和性还得重新计算新实例上线了老会话还钉在旧实例上负载并不均匀。第二会话状态需要共享存储。要支持多实例、避免单点会话数据必须放进 Redis 这类外部存储。每个请求因此多一次存储查询还得处理过期、续期、存储故障降级。更糟的是一旦 Redis 出问题所有会话一起失效MCP 服务的可用性绑定在这个外挂的存储上了。第三实例宕机会话一起没。一台实例挂了它持有的所有会话同时失效这些会话上的客户端必须重新走一遍握手。对 Agent 来说之前几轮调用积累的上下文也随会话一起丢了任务可能要从头再来。第四网关想路由得解析请求体。方法名tools/call藏在 JSON-RPC 的 body 里网关要按方法路由或限流只能解析 JSON。专业网关每次请求都多一次解析开销轻量入口如 Nginx 干脆做不了得靠 Lua 或 NJS 扩展补能力。四个问题背后是同一个根源协议把状态放在连接里而生产环境里连接恰恰是最不稳定的一环——实例会重启、流量会重新分配、网络会抖动。一句话概括基础设施在为协议「让路」。而协议本该是为基础设施服务的。新版每个请求自带上下文2026-07-28 的改法很直接把连接级的握手和会话整个拿掉。具体是两件事——移除initialize/initialized握手SEP-2575移除Mcp-Session-Id和协议级 sessionSEP-2567。替代方案是让每个请求自包含。客户端把三样信息塞进请求的_meta字段协议版本io.modelcontextprotocol/protocolVersion、客户端身份io.modelcontextprotocol/clientInfo、客户端能力io.modelcontextprotocol/clientCapabilities。这些信息不是只在建立连接时交换一次而是跟着每一个请求走。新版将三步调用变成一步POST /mcp HTTP/1.1 MCP-Protocol-Version: 2026-07-28 Mcp-Method: tools/call Mcp-Name: search Content-Type: application/json { jsonrpc: 2.0, id: 1, method: tools/call, params: { name: search, arguments: { q: 订单 }, _meta: { io.modelcontextprotocol/protocolVersion: 2026-07-28, io.modelcontextprotocol/clientInfo: { name: my-app, version: 1.0.0 }, io.modelcontextprotocol/clientCapabilities: { elicitation: {} } } } }注意几个细节头部多了三样东西MCP-Protocol-Version声明协议版本Mcp-Method和Mcp-Name让网关不用解析 body 就知道这是哪个方法、哪个工具。body 里的_meta三个字段各司其职协议版本告诉服务端「我说的是哪版协议」客户端身份用于排障和统计客户端能力告诉服务端「我能接收什么」。这不止是「把握手参数挪了个位置」。旧版里这些信息只在握手时交换一次服务端默认后续请求都属于同一个会话新版里每一个请求都带着完整上下文服务端不需要任何「之前」的记忆。请求之间不再有隐式的先后关系——它们是一群平等的独立个体而不是串在同一条线上的珠子。这个请求落到任何一台实例上都能被独立处理。实例不需要查「这个客户端之前跟我握过手吗」不需要查会话表不需要判断 session 有没有过期。对服务端而言处理成本反而变简单了。要不要状态、怎么表达状态是业务自己的事协议不再掺和。新旧对照一张表说清楚维度2025-11-25旧2026-07-28新建立连接必须initialize握手无需握手直接发请求会话标识Mcp-Session-Id绑定实例无协议级会话版本与能力握手时协商一次每个请求携带于_meta实例要求粘性路由或共享存储任意实例可处理任意请求状态表达藏在传输层显式句柄参数见下文版本不匹配怎么办客户端说「我用 2026-07-28」假如服务端不匹配只支持 2025-11-25就返回一个UnsupportedProtocolVersionError错误码 -32022并列出它支持的版本。客户端收到后挑一个双方都支持的版本重发请求即可。这和旧版的区别很微妙但很重要。旧版里版本协商在连接建立时一次完成之后所有请求都假设已经谈妥新版里协商是每个请求各自的事——这个请求谈不拢重试就好不影响其他请求也不需要任何连接级状态。如果配合server/discover的响应缓存客户端通常可以提前拿到版本信息连出错重试都省掉。这个设计还给版本升级带来了便利。假设一个集群里新旧实例并存的窗口期旧版协议下会话在握手时锁定版本期间不能变新版下每个请求独立协商客户端可以根据服务端返回的 supportedVersions 自动选择——请求打到老实例按老版本应答打到新实例按新版本应答互不干扰。新旧实例可以共存一段时间不用在某个时间点一次性全部切换。server/discover能力发现但不是握手客户端怎么提前知道服务端支持什么新规范加了一个server/discover方法服务端必须实现它客户端可以但不是必须在任何其他请求之前调用。它返回服务端支持的协议版本、能力清单和身份信息POST /mcp HTTP/1.1 MCP-Protocol-Version: 2026-07-28 Mcp-Method: server/discover Content-Type: application/json { jsonrpc: 2.0, id: 1, method: server/discover, params: { _meta: { io.modelcontextprotocol/protocolVersion: 2026-07-28, io.modelcontextprotocol/clientInfo: { name: my-app, version: 1.0.0 }, io.modelcontextprotocol/clientCapabilities: { elicitation: {} } } } }响应大概是这样的{ jsonrpc: 2.0, id: 1, result: { resultType: complete, supportedVersions: [2026-07-28], capabilities: { tools: { listChanged: true }, resources: {} }, _meta: { io.modelcontextprotocol/serverInfo: { name: order-server, version: 1.0.0 } }, ttlMs: 3600000, cacheScope: public } }拆开看这个响应。resultType: complete表示这是一次完整应答supportedVersions列出服务端愿意对话的协议版本capabilities是它的能力清单这里声明支持 tools 和 resourcesserverInfo是服务端身份用于排障和兼容性统计最后的ttlMs和cacheScope告诉客户端这份信息多久有效、能不能放进共享缓存。对一个常驻的企业网关来说discover 结果拉一次、缓存起来、在内存里复用即可不需要每个请求都重新询问。还要分清两个「发现」server/discover发现的是协议级能力——支持哪些版本、tools、resources 这些tools/list发现的才是业务级工具——具体有哪些工具、参数长什么样。前者是「这家店卖什么类型的货」后者是「货架上具体有哪些」。一次 discover 打底再按需 list两次请求各司其职。它和握手的区别在哪握手的特征是「必须」——不完成握手任何请求都做不了会话状态被协议强绑定。discover 的特征是「可选」——不调用它也能直接干活只是可能走弯路调用了能提前拿到版本和能力信息少一次出错重试。这就像到一个新城市不查地图也能走只是可能走错路查了地图路线更稳但无论查不查都不需要和城市签任何「协议」。它不建立任何连接级状态响应里带着ttlMs多久内可复用和cacheScope能不能被共享缓存客户端可以缓存它不需要每个请求前都问一遍。它甚至在 STDIO 传输下还能当兼容性探针用——先探一下对方支持什么版本再决定怎么说话。协议无状态业务有状态这是理解这次改版最容易踩的坑协议无状态不等于你的服务不能有状态。官方给的做法很朴素需要跨调用状态服务端就在工具结果里返回一个显式标识符Agent 下一次调用时把这个标识符作为普通参数传回去。还拿采购 Agent 举例。Agent 搜索供应商、创建采购申请服务端创建成功后在结果里返回一个request_idAgent 后续要查审批状态、要下单都把这个request_id作为参数传回去。状态存在于业务系统里只是不再由协议偷偷记着。把流程一步步写出来更直观。第一轮Agent 调search_suppliers拿到候选列表挑中一家调create_request(supplier, amount)服务端落库返回request_id: R-2026-0001。第二轮Agent 想确认审批进度调get_request_status(request_id: R-2026-0001)审批通过后再调place_order(request_id: R-2026-0001)。三次调用之间服务端没有在传输层记任何东西它认的只是这个显式句柄。对熟悉 Web 的读者来说这个模式很眼熟——它和 HTTP API 里用资源 ID 表达资源、用 token 表达身份是同一个思路。MCP 只是把这套被验证了几十年的做法从 HTTP 层搬到了 Agent 的工具调用层。这种「显式句柄」的做法比藏在传输层里的隐式会话更好用原因有三条。第一模型看得见。会话 ID 藏在连接元数据里模型不知道、也操作不了它而request_id是模型手里的一个普通值它可以比较、可以传给别的工具、可以在推理里引用。状态从「协议的隐式负担」变成了「模型的可编排资源」——模型甚至能在一个工具返回的句柄和另一个工具之间做编排这是隐形会话给不了的。第二归属清楚。状态在业务系统里谁负责数据就归谁管协议不越俎代庖。审计和排查时跟着request_id走业务链路比翻传输层日志直观得多——每一笔操作都能追到对应的请求和句柄而不是一段模糊的连接历史。第三重试干净。请求自包含重试就是再发一遍不会因为「会话过期了」「会话不在本实例」这种传输层原因失败。官方发布说明里有句话值得记住把状态从传输层的元数据里搬出来、变成模型可见的显式参数往往比藏在 session 里的隐形状态更有用。这是这次设计的一个正面收益。顺手精简掉的连接级东西SEP-2575 在无状态化的同时还移除了其他一批依赖会话的东西。把它们列出来能更清楚地看到改版的完整边界。ping被移除了。旧版里它是协议自带的「你还在吗」——客户端定期发、服务端回用来判断连接是否存活。现在没有连接这个概念了健康检查回到最朴素的方式发一个真实请求能通就是活的。对基础设施来说这反而更省事探活探的是真实路径不是一条专门的虚拟路径。logging/setLevel没了。日志级别改成每个请求在_meta里带io.modelcontextprotocol/logLevel按请求控制日志而不是按会话。这在无状态服务里是顺理成章的请求散落在不同实例上日志级别只能跟着请求走绑定到某台实例的某个会话没有意义。服务端对没带这个字段的请求也不该再主动发日志通知。列表接口不再随连接变化。tools/list、resources/list这类结果不再依赖会话状态同一个列表对谁返回都一样。SSE 流的断点续传和消息重发被移除了。旧版响应流断了可以恢复代价是服务端要记住消息队列和已发送的进度。新版把这些记忆全部删除换来传输层的纯粹无状态代价也很明确流一断正在处理的请求就作废客户端必须用一个新请求 ID 重新发起。听起来更脆弱但这就是无状态的核心交换——协议不再为「万一断流」兜底。这些改动看起来琐碎指向的却是同一个原则协议里不再保留任何连接级的概念一切都可以按普通 HTTP 的语义来理解、缓存、路由和运维。这次改动带来的工作量这次改动直接改的是协议层工作量首先落在 MCP 客户端、服务端和 SDK 的实现与维护者身上凡是假设了initialize→Mcp-Session-Id→initialized生命周期、或者依赖会话 ID 做状态管理的代码都要改。原本靠会话识别的业务状态要改成显式标识符。不过改动范围有一条界限移除握手是「新协议版本」的事不是「7 月 28 日一切失效」。老版本协议照常能用新旧实现还可以各自按自己支持的版本说话SDK 也保留了向后兼容——比如 TypeScript SDK 的 v1.x 还会继续接收至少 6 个月的修复与安全更新Python 的 v1.x 分支也保留关键修复通道。真正要改的是那些准备切到 2026-07-28 的实现。另一个需要接受的取舍是协议变轻换来的是部署和运维变简单代价是传输层不再替你兜底状态——流断了就是断了会话没了就是没了。业务状态必须自己显式管理这条责任线从协议移到了实现者手里。而对最终使用者来说界面不会有任何变化。简单来说这次改版把连接和 MCP 协议解耦了协议层面不再需要关心连接如何建立和维护实现者只需要关注协议本身。结语移除握手和会话本质上是把连接和 MCP 协议解耦了。从 2026-07-28 开始每个请求都是独立、自包含、可被任意实例处理的普通 HTTP 请求协议本身不再需要关注连接如何建立和维护。本文基于 2026-07-28 正式发布的 MCP 规范撰写。来源The 2026-07-28 Specification — 官方发布公告MCP 架构总览2026-07-28— 官方文档MCP 变更日志2026-07-28— 官方文档SEP-2575无状态化与移除初始化握手SEP-2567移除协议级 session 与 Mcp-Session-IdModel Context Protocol 官方仓库