MCP 变成了无状态:2026-07-28 修订版从我们的服务器里拿走了什么

如果你在运行一个 MCP 服务器,你在某个地方一定有会话。多半是一张表,也可能是内存里的一个 map。客户端连上来,发 initialize,拿回一个 Mcp-Session-Id,之后每个请求都带着这个头。你存下那一行。过一阵子让它过期。你要么保证请求落到持有它的那个实例上,要么在实例之间共享状态。

2026-07-28 修订版把这些删掉了。不是标记为 deprecated,而是从协议核心里移除。握手没了,会话头没了,现在每个请求各自携带自己的协议版本和客户端 identity。用发布文章的话说,"any request can now land on any server instance behind a plain round-robin load balancer without needing shared storage" ——任何请求现在都可以落到一个普通 round-robin 负载均衡器后面的任意服务器实例上,不需要共享存储。

Archyl 的 MCP 服务器现在可以服务讲新修订版的客户端。这篇文章讲的是这件事需要做什么、之后我们量到了什么、第一遍我们弄错的那一处,以及我们还没做的部分。如果你在维护一个 MCP 服务器,有意思的大概是中间那个设计决定、审计新 transport 时在旧 transport 里翻出来的那个 bug,以及文末那份用来检查你自己服务器的清单。

这个修订版实际删掉了什么

直接摘自 changelog,只挑与服务端实现相关的部分:

  • 协议层面的会话和 Mcp-Session-Id从 Streamable HTTP transport 中移除。列表类端点不再随连接而变化。
  • initialize / notifications/initialized 握手被移除。每个请求都在 _meta 里携带自己的协议版本和客户端 capability,在 Streamable HTTP 上同一个版本还会走 MCP-Protocol-Version 头。
  • server/discover 是新增的,而且是必须的。 服务器必须(MUST)实现它,用来公布支持的协议版本、capability 和 identity。客户端可以(MAY)在做任何事之前先调用它;也完全可以直接发一个请求,然后处理版本错误。
  • pinglogging/setLevelnotifications/roots/list_changed 被移除。
  • 版本不匹配返回 UnsupportedProtocolVersionError,并列出服务器确实支持的版本,好让客户端重试。

里面还有更多内容(Multi Round-Trip Requests、subscriptions/listen、可缓存的列表结果、一整块重新编号的错误码、authorization 加固),我后面会回来讲哪些我们做了、哪些跳过了。上面这五条改变的是一台服务器的形状,而不是它的功能。

有一点值得说准确,因为它会改变判断:这已经不是 release candidate 了。release candidate 在 2026 年 5 月 21 日锁定,并为 SDK 维护者和客户端实现者开了一个十周的验证窗口。这个窗口在 2026 年 7 月 28 日规范正式发布时关闭,版本页面现在把 2026-07-28 称为 "the current protocol version" ——当前的协议版本。四个 Tier 1 SDK(TypeScript、Python、Go、C#)在发布当天就都支持它,Rust 处于 beta。如果你一直在等 RC 稳定下来,它已经稳定了。

这对一台有 181 个 tool 的服务器意味着什么

Archyl 的 MCP 服务器在 C4 模型之上暴露了 181 个 tool:项目、系统、container、component、relationship、ADR、文档、contract、conformance、drift、DORA、ownership。在这次改动之前,这 181 个全都在一个会话后面。

具体到我们的后端:

  • 每个连接都会在 mcp_sessions 表里生成一行,24 小时过期,还有一个后台 goroutine 清扫陈旧和过期的行。
  • SSE 响应通道存放在服务器 struct 上的一个 map[string]chan *JSONRPCMessage 里,以 session ID 为键,这就把一个连接钉死在了打开它的那个进程上。这个 map 后来搬了家,原因最终证明是一个 bug,而不是偏好问题。下面会回来讲。
  • 四个 handler(tools/listtools/callresources/listresources/read)都以同样的三行开头:
if !session.Initialized {
	return s.errorResponse(msg.ID, ErrCodeInvalidRequest, "Session not initialized", nil)
}

有意思的正是这个 guard。它提出了一个新协议已经让人无法回答的问题:这个调用方完成握手了吗? 没有握手可以完成。

让改动保持很小的那个决定

诱人的做法是教会这四个 handler 什么叫无状态。加第二个条件,或者在每处检查前面加一个 session.Stateless ||,又或者把整段逻辑提升到中间件里。

这些我们都没做。guard 一行没动。取而代之的是:一个声明 2026-07-28 的请求会拿到一个只为这一次请求构建的内存会话,它在构造上就满足了 guard:

func (s *Server) NewStatelessSession(userID, organizationID uuid.UUID, protocolVersion string) *Session {
	now := time.Now()
	return &Session{
		Session: &mcpsession.Session{
			ID:              "",
			UserID:          userID,
			OrganizationID:  organizationID,
			Initialized:     true,
			ProtocolVersion: protocolVersion,
			Transport:       "streamable",
			LastAccessedAt:  now,
			CreatedAt:       now,
		},
		Stateless: true,
	}
}

什么都不持久化。不分配 ID。不注册 SSE 通道。Initialized: true 不是谎言,也不是绕过:在这个修订版下,这个请求确实是已初始化的,因为协议自带版本,而它原本要完成的那个握手已经不存在了。

为什么这种表述比看上去更重要:这四个 guard 位于一条与授权相邻的路径上。每一个都决定着一次 tool 调用是执行还是被拒绝。修改四处都在回答同一个安全性质问题的调用点,就是四次削弱检查的机会,而且分散在一份评审者必须一次性全装进脑子里的 diff 中。构造 guard 本来就期待的那个对象,只是一个新函数,而每一处已有的检查都保留了它原本精确的含义。

它失败的方向也是安全的那一边。如果我们的版本判断出错,把一个无状态请求误读成旧版请求,后果就是给它建了一行会话记录。不会有任何以前不该放行的东西被放行。反过来的设计——放松 guard,再用一个版本字符串去把门——失败的方向是另一边。

无状态化也没让我们在租户隔离上付出任何代价,因为 identity 本来就从来不是从会话行里来的。无状态会话携带的是从该请求所出示的 API key 或 OAuth token 解析出来的用户和组织,scope 每次调用都会重新推导,所以吊销一个 key 会立即生效,而 tools/call 依然会拒绝一个没有绑定租户的会话。现在可偷的东西少了一样:没有可被重放的、存储下来的 session ID。在旧版路径上我们保留了对应的检查,所以一个 session ID 不可能让某个凭据以别人会话里存的 identity 行事。

路由,一个 switch 搞定

整个决定都在 HTTP handler 里,甚至在 JSON-RPC body 被解析之前:

switch {
case mcp.IsModernProtocolVersion(requestedVersion):
	// Stateless: the request describes itself, so nothing is looked up,
	// nothing is written, and no Mcp-Session-Id comes back.
	session = h.mcpServer.NewStatelessSession(auth.UserID, auth.OrganizationID, requestedVersion)

case sessionID != "":
	// Handshake-based client with a session: look it up, and check it
	// belongs to this credential.

default:
	// Legacy client that has not handshaken yet: mint a session as before.
}

这里有两个细节很容易错过:

IsModernProtocolVersion 是与 "2026-07-28" 的字符串比较。修订版号是 YYYY-MM-DD,所以字典序就是时间序,未来的修订版会默认落到无状态那一侧,而不是退回握手。前提是我们支持它才能走到那一步:无法识别的版本在进入 switch 之前就会被拒绝,并把支持列表放在错误的 data 里,好让客户端重试。

还有响应头:

if !session.Stateless {
	c.Set("Mcp-Session-Id", session.ID)
}

无状态会话没有 ID。回一个空的 Mcp-Session-Id,等于告诉客户端去复用一个并不存在的东西,这比不发还要糟糕,而且是那种只有面对不是你自己写的客户端时才会暴露出来的问题。

changelog 的其余部分,好好读一遍

版本 switch 是那个有意思的决定。修订版的其余部分是一串很小的要求,容易漏掉,但验证起来也便宜,所以我们又把 changelog 一行行过了一遍。这一遍里落地了四条。

每个 result 都要有 resultType 这个修订版把该字段设为必填:已完成的回答用 "complete",multi round-trip 模式里的中间结果用 "input_required"。规范告诉客户端,如果在更老的服务器上没看到它,就当作 "complete";但读的是最终修订版的客户端会去找它。我们的结果现在都带上了它,来自嵌入在每个结果类型里的同一个 Result struct,而不是每个类型各自记得这个字段。

列表结果上的 ttlMscacheScope 通过新的 CacheableResult 接口,在 tools/listprompts/listresources/listresources/readresources/templates/list 上成为必填。这五个里我们提供了三个,它们返回 60000private。六十秒是提示而不是契约:长到足以让 agent 不必每一轮都重新列出 181 个 tool,短到让会话中途注册的 tool 能很快出现。private 是一个决定,不是我们照单全收的默认值。我们返回的每一个结果都限定在调用方的组织范围内,所以任何共享的中间层都不该缓存其中一个再交给另一个租户。

来自声明 2026-07-28 的客户端的 DELETE /mcp DELETE 原本用于终止协议层面的会话,而协议层面的会话已经不存在了。规范说要回 405,所以现代客户端拿到的就是这个。基于握手的客户端保持原有行为。

未实现的方法现在返回携带 JSON-RPC -32601 的 HTTP 404 单看状态码是有歧义的:一台根本没有托管现代端点的旧式 HTTP+SSE 服务器同样会回 404。区分两者的是 JSON-RPC body,规范明确写了客户端要靠它来决定是回退到 initialize 还是重试。

还有一处我们第一遍弄错了

我们的「不支持的版本」错误返回的是 -32600,也就是 JSON-RPC 通用的 "invalid request"。这个做法一直到这个修订版之前都还站得住脚。这个修订版定义了一份错误码分配策略,把 JSON-RPC 的服务端错误区间切开:-32000-32019 仍由实现自行定义,-32020-32099 归规范所有。draft 期间引入的那些码被重新编号到了这一块里。HeaderMismatch-32001-32020MissingRequiredClientCapability-32003-32021UnsupportedProtocolVersion-32004-32022

对着最终修订版写的客户端会去找 -32022。它认不出我们当时发的东西,而这种失败模式正是整个修订版想要避免的:客户端分不清「版本不对,我能讲的是这些」和「你的请求格式有问题」,于是它没有任何可以据以重试的东西。

除了把 changelog 又读了第二遍,没有别的东西发现了这个问题——而这恰好就是这篇文章自己的论点反过来指向我们。这次重新编号是次要变更里的第 12 条,排在关于 OpenTelemetry _meta 键和 JSON Schema 关键字的条目之后。就是那种一眼扫过去的行。

有一处改名没让我们付出代价。resource-not-found 从 -32002 挪到了 -32602,好和 JSON-RPC 的 "invalid params" 对齐,而 resources/read 对未知 URI 本来就已经在回 -32602 了。

我们量到了什么

以上全部都是对着这个构建下运行中的容器测的,用的是真实的 API key,这样我们可以直接数 Postgres 里的行数。

测试 结果
MCP-Protocol-Version: 2026-07-28、不做握手的 tools/list 181 个 tool
该响应中回带的 Mcp-Session-Id
tools/listserver/discover 上的 resultType complete
tools/list 上的 ttlMs / cacheScope 60000 / private
server/discover ["2026-07-28", "2025-03-26"]
声明了不受支持的版本 -32022,错误 data 中带支持列表
来自声明 2026-07-28 的客户端的 DELETE /mcp 405
未知方法 携带 -32601404
旧版 initialize 握手 仍然可用
带 session id 的旧版 tools/list 181 个 tool
10 次无状态请求创建的 mcp_sessions 行数 0
3 次旧版请求创建的 mcp_sessions 行数 3

要看的是最后这一对。十个请求,零行记录。那三个旧版请求各自都没带 session ID,所以每个都生成了一行;一个行为良好、会复用自己 ID 的握手式客户端,在整个会话生命周期里只占一行,而不是每次调用一行。重点在那个零:在无状态路径上,没有东西要写,没有东西要过期,也没有东西留给清理 goroutine 去找。

生成那张表第一行的那个请求,指向公开端点的版本是:

curl -s https://api.archyl.com/mcp \
  -H "X-API-Key: $ARCHYL_API_KEY" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

没有 initialize。没有会话。181 个 tool。

被废弃 transport 藏起来的那个 bug

审计新 transport 让我们回头去看了旧的那个,而旧的那个有一个真正的 bug。

2024-11-05 的 HTTP+SSE transport 把一次会话拆到两条连接上。客户端用 GET 打开一条长连接流,服务器的第一个事件告诉它往哪里 POST,从那以后每条消息都通过 POST 发出,而每个响应都从流上回来。这两条连接不一定会落到同一个实例上。

我们的实现假设它们会。响应通道存在服务器 struct 上的那个 map[string]chan *JSONRPCMessage 里,于是由实例 B 处理的 POST 会把响应写进一个存在于实例 B、而实例 B 上没有任何人在读的通道里。流在实例 A 上。客户端就一直等着。

比设计上的坏味道更糟的是:什么都没记录下来。没有 error,没有 warning,没有失败的请求。POST 返回 202 Accepted,而这是真的,消息确实被接受了,然后答案哪儿也没去。从外面看,这和一次很慢的 tool 调用没有区别。它只在水平扩展的部署里发生,而那正好是你最不愿意动手复现问题的地方。

那个 map 现在变成了 streamrouter.go 里的一个 Redis pub/sub 路由器。发往本进程持有的流的响应会被直接投递,永远不走那一趟往返。发往别处持有的流的响应会被发布到 mcp:stream:<sessionID>,而持有那条流的实例订阅了它。任何实例都可以接下这个 POST。不需要会话亲和性,也不必在一份谁都不记得是自己写的负载均衡器配置里维护 sticky session 规则。

关于这件事有两点值得说,因为路由器本身就是一个依赖。

Redis 现在在这条路径上了。如果启动时连不上,路由器会退回到仅本地投递并打一条 warning,而不是拒绝启动,因为对单实例来说仅本地是正确的,只有出现第二个实例时才变成错的。这个失败是故意做得吵闹的:另一种选择就是我们刚刚除掉的那种静默挂起。如果你要部署这套东西,启动时要找的那一行是 MCP stream router: Redis connected。它的缺席就是全部故事。

而且路由器修的是路由,不是位置。流仍然是某一个进程持有的连接;Redis 把响应送到那个进程,它并不搬动这条流。这一部分是不可再简化的。一条打开的连接活在它被打开的地方,任何协议都一样。

我们还没做的事

一般的发布公告到这里就停了。有两件事值得直说,因为这两件你都可以自己去验证。

Archyl 在真正要紧的那条路径上讲 2026-07-28。它并不是端到端无状态。

无状态路径确实是无状态的:不查会话,不写会话,没有 Mcp-Session-Id,也没有任何东西把请求钉在某个进程上。这条路径可以放在一个普通的 round-robin 负载均衡器后面。

我们的服务器同时还在 /sse 上响应更老的 HTTP+SSE transport,但我们已经不再把它写进文档。以前印着那个 URL 的每一个页面,现在印的都是 /mcp,而那是我们唯一还会请人去配置的端点。

原因就是我们刚刚加进来的那个依赖。路由器只有在 Redis 可达的地方才去掉亲和性要求。在不可达的地方,投递会回退到仅本地,这在一个实例上是正确的,在两个实例上则是静默地错误。我们自己的生产环境今天并没有跑 Redis,所以我们跑着的正是这个回退路径。比起公开一个正确性取决于实例数量的 transport,我们宁愿把所有人都指向正确性不取决于实例数量的那个。

无论 /sse 跑在哪里,仍然为真的是:流是一个单独进程持有的连接,而在它的整个生命周期里,Postgres 里都存在一行会话记录。去掉亲和性要求,跟去掉状态不是一回事。我们不会宣布退役这个 transport 的日期。

不过这个 transport 的时钟不归我们,而且比我们原以为的更短。HTTP+SSE 自 2025-03-26 修订版起就已经是 deprecated;2026-07-28 做的事情是依据新的功能生命周期策略把它重新归类为 Deprecated。该策略规定,从废弃到符合移除条件之间至少要有十二个月的窗口,Roots、Sampling 和 Logging 拿到的就是这个:最早移除时间是 "the first revision released on or after 2027-07-28" ——2027 年 7 月 28 日当天或之后发布的第一个修订版。HTTP+SSE 拿不到十二个月,因为早在这套策略存在之前它就已经被废弃了。废弃功能登记表把它的最早移除时间写为 "Three months after SEP-2596 reaches Final" ——SEP-2596 进入 Final 之后三个月。移除仍然是 Core Maintainer 在准备发布时做出的决定,也可能来得更晚,但如果你在什么地方跑着 HTTP+SSE,那就是你要读的那一行。

我们实现的是这个修订版的形状,不是它的全部。 上线的内容包括版本协商、无状态请求路径、server/discover、带正确错误码的「不支持的版本」错误、resultType、缓存提示,以及 transport 要求的 405404,同时保留了给仍然需要它的客户端用的握手路径。下面这些是没有的:

  • Mcp-MethodMcp-Name 请求头,以及随之而来的校验。 这是最大的缺口。这个修订版要求 POST 把自己的 method,以及自己的 params.nameparams.uri,镜像到请求头里,并要求服务器用 400-32020 HeaderMismatch 拒绝任何不匹配。原因不是整洁。用规范自己的话说,它 "prevents potential security vulnerabilities when different components in the network rely on different sources of truth (e.g., a load balancer routing on the header value while the MCP server executes based on the body value)" ——防止网络中不同组件依赖不同的事实来源时可能出现的安全漏洞(例如负载均衡器按请求头的值路由,而 MCP 服务器按 body 的值执行)。同一条规则也适用于 MCP-Protocol-Version,它的值必须(MUST)与请求 _meta 中的值一致。我们只从请求头读版本,从来不看 _meta,所以我们根本无法检测出一个本应被拒绝的不匹配。请求头在 body 被解析之前就能拿到,这也是我们在那里读它的原因。但这不构成跳过交叉校验的理由。
  • subscriptions/listen,以及带 InputRequiredResult 的 Multi Round-Trip Requests。 这是整块功能,而不是修补。我们从来没有实现过 resources/subscribe,所以取代它的那个方法今天对我们来说没有成本。
  • Origin 请求头校验。 规范把它标为 MUST,对非法 origin 返回 403,作为对 DNS rebinding 的防御。我们在 /mcp 上没有做这件事。
  • capability 上的 extensions,以及 tools/list 的确定性排序。 后者是一个 SHOULD,目标是客户端缓存和 LLM prompt cache 的命中率。我们的顺序来自一个 Go map,所以那天那个 map 给什么顺序就是什么顺序。
  • Dynamic Client Registration。 这个修订版转而推荐 Client ID Metadata Documents 并把它标为废弃,而我们仍然暴露着 POST /register。它会继续保留给那些不支持替代方案的 authorization server,所以这是一次迁移而不是一次断裂,和 Roots、Sampling、Logging 走在同一个十二个月的时钟上。
  • server/discover/mcp 上的其他一切一样,都在同一把 API key 后面。 它不会回应匿名调用方,这是刻意的选择,但并不是一个正在发现服务器的客户端所期待的行为。

剩下的都是工作,它们在清单上,而不是已经完成。

如果你在运行自己的 MCP 服务器

值得拿去跑一遍的检查:

  1. 带上 MCP-Protocol-Version: 2026-07-28、不做握手,发一个 tools/list。如果你拿到 "session not initialized",说明你的服务器并没有在提供当前修订版。
  2. 调用 server/discover。它现在是必须的。如果返回 method-not-found,那是最容易补上的缺口。
  3. 声明一个你不支持的版本。检查错误里是否带着你确实支持的版本列表,以及它的错误码是不是 -32022 而不是某个通用码。这就是我们没通过的那一项。
  4. 随便读一个 result。每一个都需要 resultType,而你的列表结果在此之上还需要 ttlMscacheScope
  5. 看看你在无状态请求上往 Mcp-Session-Id 里回了什么。空值比没有更糟。
  6. 数一数你的写入。发十个无状态请求,然后检查你的会话存储里是否落下了什么。那个数字,就是对「这次迁移到底成没成」的诚实回答。
  7. 如果你仍在提供 HTTP+SSE 并且跑了不止一个实例,就在流被另一个实例持有的情况下往其中一个发 POST。一个日志里什么都没有却一直挂着的客户端,就是我们当时的那个 bug。然后去读上面那条废弃登记表里的记录。

「接受了新的版本请求头」和「真的无状态」之间的距离,是绝大部分工作所在,而只有第 6 步能告诉你自己站在哪一边。

接上它

端点没有变化,两个修订版都能对着它工作。用 Claude Code 的话,在项目根目录放一个 .mcp.json

{
  "mcpServers": {
    "archyl": {
      "type": "http",
      "url": "https://api.archyl.com/mcp",
      "headers": {
        "X-API-Key": "YOUR_API_KEY"
      }
    }
  }
}

由你的客户端来选修订版。如果它讲 2026-07-28,那么它会以无握手、无会话的方式被服务。如果不讲,对它来说什么都不变。

面向 Claude Code、Cursor、VS Code、Codex、Warp、Windsurf 和 Antigravity 的完整配置,以及决定一个 agent 能改动什么的那些 scope,都在 MCP 服务器文档里。