今天 CSDN 头条是《Agent 能搭出来,为什么还做不成事?六位专家拆解智能体生产化的六道关》。这个标题很扎心:搭 Agent 早就不难了,选个模型、接上知识库和工具,一下午就能跑起来。真正卡住企业的是------能不能稳定、安全、持续地把事做成。
而就在 2026 年 7 月 28 日,MCP 发布了官方口中「自发布以来最大的一次修订」。这次修订没有加什么炫酷的新能力,它干的事情恰恰是六道关里最不性感、但最要命的那一半:把 MCP 从一个有状态的双向协议,改成一个无状态的请求-响应协议。
对 Java 后端来说,这次改版简直像一次「回家」:状态不再靠长连接持有,而是靠请求自带、显式句柄、幂等重放。这不就是我们做了二十年的无状态服务、分布式会话、幂等接口、网关路由吗?
Spring AI 2.1.0-RC2(2026-11-05 发布)是第一个把 MODERN 协议搬进 Java 生态的版本。这篇文章只讲一件事:MCP 2026-07-28 到底改了什么,以及 Java 团队怎么在 Spring AI 里把它落地成生产可用。

一、先看清改版的动机:不是加功能,是重新铺管道
老版 MCP 有三个隐含假设,全都建立在「连接会一直在」上:
initialize握手协商一次,之后所有请求默认沿用这个协商结果;Mcp-Session-Id让服务端能记住「这个客户端是谁、上次聊到哪」;- 服务端可以反向发请求给客户端(
sampling/createMessage、elicitation/create、roots/list),因为流还挂着。
这三个假设在 Demo 阶段无比方便,在 K8s 里就是灾难。你的 MCP Server 一旦横向扩容到 3 个 Pod、前面挂个普通负载均衡,会话状态就分裂了;用户在你等审批的 30 秒里,重试请求落到了另一个 Pod,之前收集的上下文全丢。
2026-07-28 的解法非常工程化:把所有隐含状态显式化。
- 想要协商结果?每次请求自己带
_meta; - 想要跨调用状态?服务端签发一个显式句柄,让模型当成普通 tool 参数带回来;
- 想要中途问用户?别反向发了,返回一个「我还缺信息」的结果,让客户端带着答案重放原请求。
这套逻辑,和「把 session 从 Tomcat 内存搬到 Redis + 令牌里带租户上下文」是同一个思路。所以我说 Java 团队看这版协议应该感到亲切。
二、协议变更全景:9 条大改,12 条小改
2.1 九条破坏性变更
| # | 变更 | SEP | 影响面 |
|---|---|---|---|
| 1 | 移除协议级 session 与 Mcp-Session-Id;tools/list、resources/list、prompts/list 不再随连接变化 |
SEP-2567 | 状态存储全废 |
| 2 | 移除 initialize / notifications/initialized;版本与能力改由每次请求的 _meta 携带 |
SEP-2575 | 客户端全改 |
| 3 | 新增 server/discover,服务端 MUST 实现 |
SEP-2575 | 必做新方法 |
| 4 | subscriptions/listen 取代 HTTP GET 流与 resources/subscribe/unsubscribe |
SEP-2575 | 通知通道全换 |
| 5 | 移除 ping、logging/setLevel、notifications/roots/list_changed |
SEP-2575 | 健康检查失效 |
| 6 | Tasks 从核心移出成扩展 io.modelcontextprotocol/tasks;tasks/result 换成 tasks/get 轮询 + tasks/update,删除 tasks/list |
SEP-2663 | 长任务全改 |
| 7 | MRTR :服务端不再反向发请求,改为返回 InputRequiredResult,客户端重试带 inputResponses |
SEP-2322 | 交互式中断 |
| 8 | 所有 result 必须带 resultType:complete 或 input_required |
SEP-2322 | 序列化契约 |
| 9 | 移除 SSE 断点续传(Last-Event-ID 与事件 ID);断流即丢请求,客户端须用新 ID 重发 |
SEP-2575 | 可靠性语义 |
2.2 五条最容易被忽略、但最先炸的小改
_meta成为新的握手信封 :io.modelcontextprotocol/protocolVersion、io.modelcontextprotocol/clientCapabilities必带;clientInfoSHOULD 带;服务端在 result 的_meta里回serverInfo。- Streamable HTTP 强制路由头 :每个 POST 必须带
Mcp-Method,tools/call/resources/read/prompts/get还必须带Mcp-Name。工具参数可以用x-mcp-header提升成Mcp-Param-{Name}头。 ttlMs+cacheScope:tools/list、prompts/list、resources/list、resources/read、resources/templates/list的返回都要带缓存提示,cacheScope取public或private。- 错误码重新分区 :
-32000~-32019留给实现,-32020~-32099归规范。于是HeaderMismatch从-32001变-32020,MissingRequiredClientCapability从-32003变-32021,UnsupportedProtocolVersion从-32004变-32022。另外 resource not found 从-32002改成-32602。 tools/list要求确定性顺序:这条看起来最无害,其实是为了 LLM prompt cache 命中率------列表顺序一抖,整段前缀缓存全失效。
三、源码级:一次 tools/call 的完整旅程
3.1 一个能跑通的 MODERN 请求长什么样
先别看 SDK,手写一次 curl,把协议看清:
bash
curl -s -X POST http://127.0.0.1:8787/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: server/discover' \
-d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{}}}}'
注意这里有两份版本号 :header 里一份 MCP-Protocol-Version: 2026-07-28,body 的 _meta 里又一份 io.modelcontextprotocol/protocolVersion。很多人第一反应是「重复了,删一个」------删哪个都会挂:
- 只留
_meta、不带 header → HTTP 400 +-32020(规范明确要求 2026-07-28 必须带 header); - 只带 header、
_meta缺失或格式错 →-32602(Missing envelope)。
3.2 最阴的一个坑:server/discover 报 -32601
调试时你会遇到这种诡异现象:
json
{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}
明明规范说服务端 MUST 实现 server/discover,为什么没有?因为没带 _meta 的请求会被当成 2025 时代的开场 ,走 legacy 路径,而 legacy 路径里根本没有这个方法。加上 _meta 之后,同一个服务立刻正常返回。
这个坑的杀伤力在于:它让你误判「SDK 没实现新协议」,然后你去降级、去改配置、去翻 issue,浪费半天。真相是版本探测本身就是协议的一部分:客户端就是靠「带版本 → 成功 / 不带版本 → legacy」这套行为来感知服务端支持到哪一代。
3.3 头与 body 必须一致,这是安全属性不是格式要求
Mcp-Method / Mcp-Name 的存在不是为了好看,而是为了让网关能在不解析 body 的前提下做路由、限流和鉴权。这就带来一个必须守住的不变量:
处理 body 的服务端必须校验 header 与 body 一致,不一致直接 400 +
-32020。
为什么不能睁一只眼闭一只眼?假设你的网关按 Mcp-Name: read_file 做了权限校验然后放行,body 里写的却是 delete_file------如果服务端不校验,你就完成了一次教科书级的越权。网关信任 header 的前提,是这条链路上有人把 header 和 body 钉死。
推而广之,任何中间件(API 网关、Sidecar、WAF)如果改写了 body,就必须同步改写 header;不能只改一个。而网关自己也必须先校验再信任------拿着未经校验的 header 去路由,等于把安全边界建在沙子上。
一个黑盒测试就能验证你的服务端有没有做这件事:header 写 Mcp-Name: read_file,body 里写一个根本不存在的工具名。正确行为是什么都不执行 ,返回 400 + -32020。
四、MRTR:把「服务端反向请求」翻译成「幂等重放」
这是整版协议里工程含量最高的一块,也是最能体现 Java 后端经验的地方。
4.1 老做法为什么必须死
老版里,一个工具执行到一半想问用户「你确定要删这个任务吗?」,服务端直接反向发一个 elicitation/create 给客户端,然后在当前这条流上等着。这在无状态世界里根本不成立:没有任何一条流是「你的」流。
4.2 新契约:input_required + requestState
新做法是一次协议层的中断-重放:
第 1 轮 客户端 → tools/call
服务端 → { resultType: "input_required",
inputRequests: { confirm: { method: "elicitation/create", params: {...} } },
requestState: "<不透明句柄>" }
第 2 轮 客户端 → tools/call(同一个请求,新 id)
arguments 不变
inputResponses: { confirm: { content: {...} } }
requestState: "<原样回传>"
服务端 → { resultType: "complete", content: [...] }
关键点在于 确定性重放(deterministic replay) :服务端不保留任何跨轮记忆,第 2 轮请求进来时,handler 从第一行重新跑一遍 ,靠 ctx.inputResponse("confirm") 读到答案、靠回传的 requestState 恢复中间态。
这正是 Java 里「无状态服务 + 客户端回传上下文」的标准模式。Spring AI 官方给的例子非常直白:
java
@McpTool(name = "delete_task",
description = "Deletes a task after asking the user to confirm",
requiredCapabilities = McpClientCapability.ELICITATION_FORM)
public CallToolOutcome deleteTask(McpSyncNotifier notifier,
McpModernSyncRequestContext ctx,
@McpToolParam(description = "Task id") String id) {
// 第一轮:客户端还没答,这里是 null
Map answer = ctx.inputResponse("confirm", Map.class);
if (answer == null) {
notifier.progress(10, 100.0, "asking for confirmation");
Map schema = Map.of(
"type", "object",
"properties", Map.of("confirmed", Map.of("type", "boolean")));
return InputRequiredResult.builder()
.elicit("confirm", ElicitFormRequest.builder("Delete task " + id + "?", schema).build())
.requestState(id)
.build();
}
// 第二轮:从头上重跑到这里,answer 有了
return CallToolOutcome.of("deleted " + id);
}
注意 requiredCapabilities = McpClientCapability.ELICITATION_FORM:服务端在方法执行之前 就检查客户端是否声明了表单交互能力,没声明直接返回 -32021,而不是跑一半才发现没法问。
4.3 requestState 是客户端可控输入,必须密封
这是最容易出事的一点。requestState 在客户端手里过了一趟,回来的时候已经不可信了。
如果你把 "taskId=42&approved=true" 明文塞进去,攻击者改成 approved=false 就能绕过审批。所以规范推荐的姿势是:AES-256-GCM 密封 + TTL + 绑定来源 method、target、arguments 。参考实现(Ruby SDK)直接给了个 RequestStateSecurity.new(key:),用 32 字节密钥自动封解,TTL 和绑定关系全透明。多进程部署时必须共享同一把密钥,否则一半请求解封失败。
Java 侧自己封一个不难,Spring AI 里可以包成一个组件:
java
@Component
public class RequestStateSealer {
private static final String TTL = "ts";
private static final String BIND = "bind";
private final SecretKey key;
private final Duration ttl;
public RequestStateSealer(@Value("${mcp.request-state.key}") String base64Key,
@Value("${mcp.request-state.ttl:PT10M}") Duration ttl) {
this.key = new SecretKeySpec(Base64.getDecoder().decode(base64Key), "AES");
this.ttl = ttl;
}
/** 密封:把业务状态 + 时间戳 + 绑定指纹一起塞进 GCM */
public String seal(Object payload, String method, String target, Map<String, Object> args) {
try {
byte[] iv = new byte[12];
SecureRandom.getInstanceStrong().nextBytes(iv);
byte[] plain = JsonMapper.builder().build().writeValueAsBytes(
Map.of("p", payload,
TTL, Instant.now().getEpochSecond(),
BIND, fingerprint(method, target, args)));
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));
byte[] ct = c.doFinal(plain);
byte[] out = new byte[iv.length + ct.length];
System.arraycopy(iv, 0, out, 0, iv.length);
System.arraycopy(ct, 0, out, iv.length, ct.length);
return Base64.getUrlEncoder().withoutPadding().encodeToString(out);
} catch (Exception e) {
throw new IllegalStateException("seal requestState failed", e);
}
}
/** 解封:TTL 过期、绑定指纹不符,一律视为无效(fail-fast) */
public <T> T unseal(String token, Class<T> type, String method, String target,
Map<String, Object> args) {
try {
byte[] all = Base64.getUrlDecoder().decode(token);
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.DECRYPT_MODE, key,
new GCMParameterSpec(128, all, 0, 12));
JsonNode node = JsonMapper.builder().build()
.readTree(c.doFinal(all, 12, all.length - 12));
long issuedAt = node.get(TTL).asLong();
if (Instant.now().getEpochSecond() - issuedAt > ttl.getSeconds()) {
throw new RequestStateExpiredException("requestState expired");
}
if (!fingerprint(method, target, args).equals(node.get(BIND).asText())) {
throw new RequestStateTamperedException("requestState bound to another request");
}
return JsonMapper.builder().build()
.treeToValue(node.get("p"), type);
} catch (RequestStateExpiredException | RequestStateTamperedException e) {
throw e;
} catch (Exception e) {
throw new RequestStateTamperedException("requestState invalid", e);
}
}
private String fingerprint(String method, String target, Map<String, Object> args) {
String raw = method + '|' + target + '|' + new TreeMap<>(args);
try {
byte[] d = MessageDigest.getInstance("SHA-256")
.digest(raw.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(d);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
fingerprint 里用 TreeMap 是为了参数顺序不影响哈希;绑定 method|target|args 是为了防止「审批 A 操作的 token 被拿去执行 B 操作」------这跟签名 URL 绑定资源是同一个套路。
4.4 没有 exactly-once,副作用必须自己协调
规范说得很清楚:MRTR 的重试可能落到另一个服务实例 ,而且 MCP 不提供 exactly-once 保证。
所以第一轮里「收集输入之前」做过的任何副作用,都必须在第二轮里有对账逻辑。落地到 Java,就是老几样:
- 执行类工具的副作用走 Outbox 表 + 状态机 ,用
requestId做幂等键; - 中断点之前只做只读准备(查库存、算价格、生成预览),真正写库放在答案回来之后;
- 状态机用条件更新(
UPDATE ... WHERE status = 'PENDING')抢占,避免两个实例都执行。
4.5 legacy shim:双时代兼容不是免费的午餐
好消息是 SDK 给了过渡方案:用 2026 风格写出来的 handler,遇到 2025 时代的客户端时,服务端会替客户端把 inputRequests 翻译成真实的 elicitation/create 等反向请求 ,收集完答案再重跑 handler------同一套确定性重放契约。这个 shim 默认开启,上限 8 轮 ,可以用 input_required_legacy_shim: false 关掉、恢复对 legacy 请求的严格拒绝。
坏消息是:八轮上限、双路径分支、两套测试矩阵,全都是技术债。给 legacy 路径定一个下线日期,不然它会跟着你到 2028。
五、Spring AI 2.1.0-RC2 落地:一行配置切到 MODERN
Spring AI 2.1.0-RC2 的 MCP 服务端现在能说 2026-07-28 这一代,与原有 STDIO、SSE、STREAMABLE、STATELESS 并列:
yaml
spring:
ai:
mcp:
server:
protocol: MODERN # 关键开关
name: approval-mcp
version: 1.0.0
modern:
# 见 spring.ai.mcp.server.modern.* 命名空间
legacy-compat: true # 过渡期保留双时代
legacy-removal-date: 2027-07-31
底层是 MCP Java SDK 2.2 的 modern API(当前 2.2.0-M2)。它带来的能力可以归成四块:
① 传输层收敛 :WebMVC 和 WebFlux 两种容器内传输,都通过单一 POST 端点提供该代协议,配合 SSE 做流式;同时支持 STDIO。
② 内存仓储可运行时变更:tools / prompts / resources / completions 的内存仓储支持运行时增删、变更广播和分页。这一条在「工具集要热更新」的场景里是关键------工具不上线重启就能加。
③ 注解回调现代化 :@McpTool、@McpResource、@McpPrompt、@McpComplete 四个注解都有 modern 回调路径。方法可以额外声明两个参数:
McpModernSyncRequestContext:读请求数据 + 读 MRTR 的inputResponse(s)+requestState;McpSyncNotifier:上报进度。
④ 声明式能力门槛与元数据:
requiredCapabilities(三个注解上都有):方法依赖的客户端能力,方法执行前就报错;@McpIcon、@McpArg(title)、@McpResource(size, ttlMs, cacheScope)、@McpToolParam(header = true)。
@McpToolParam(header = true) 直接对应协议里的 x-mcp-header:参数会被镜像成 Mcp-Param-{Name} 请求头。最经典的用法是把 region 参数提升成路由头,网关据此把请求打到对应地域的集群,全程不用解析 body。
5.1 实战:企业变更审批 MCP Server
我们做一个真实场景:运维通过 Agent 发起数据库变更,高风险操作需要人工审批。
java
@Configuration
@RequiredArgsConstructor
public class ApprovalMcpServerConfig {
private final ChangeRequestService changeService;
private final RequestStateSealer sealer;
@McpTool(
name = "create_change_request",
description = "创建数据库变更申请。高危操作(DROP/ALTER)需要人工审批。",
requiredCapabilities = McpClientCapability.ELICITATION_FORM)
public CallToolOutcome createChange(
McpSyncNotifier notifier,
McpModernSyncRequestContext ctx,
@McpToolParam(description = "目标库,如 order_db") String database,
@McpToolParam(description = "变更 SQL") String sql,
@McpToolParam(description = "执行地域", header = true) String region) { // 提升为 Mcp-Param-Region
notifier.progress(5, 100.0, "risk assessment");
RiskLevel risk = RiskAssessor.assess(sql); // 只读判断,无副作用
if (risk == RiskLevel.HIGH) {
// 第二轮从头上重跑时,这里能读到审批结果
ApprovalAnswer approved = ctx.inputResponse("approve", ApprovalAnswer.class);
if (approved == null) {
notifier.progress(30, 100.0, "high risk, awaiting approval");
String state = sealer.seal(
new PendingState(database, region, risk), // 只放必要信息
"tools/call", "create_change_request",
Map.of("database", database, "sql", sql, "region", region));
return InputRequiredResult.builder()
.elicit("approve", ElicitFormRequest.builder(
"该变更被判为高风险(" + risk + "),是否批准执行?",
ApprovalSchema.FORM)
.build())
.requestState(state)
.build();
}
if (!approved.confirmed()) {
return CallToolOutcome.of("已拒绝,变更未提交");
}
}
// 只有在审批通过(或低风险)之后,才产生副作用
String requestId = changeService.submit(
new ChangeRequest(database, sql, region, risk,
ctx.requestId(), ctx.clientInfo().name()));
notifier.progress(100, 100.0, "submitted");
return CallToolOutcome.of(Map.of(
"requestId", requestId,
"risk", risk.name(),
"status", "PENDING_REVIEW"));
}
@McpTool(
name = "list_change_requests",
description = "按状态查询变更单列表",
requiredCapabilities = McpClientCapability.ELICITATION_FORM)
public CallToolOutcome listChanges(
McpModernSyncRequestContext ctx,
@McpToolParam(description = "状态:PENDING/APPROVED/EXECUTED") String status,
@McpToolParam(description = "分页游标") String cursor) {
var page = changeService.page(status, cursor, 20);
return CallToolOutcome.of(page);
}
}
两个细节值得单独说:
第一,评审在中断点之前。 RiskAssessor.assess(sql) 是纯函数式只读判断,放在 input_required 之前安全;changeService.submit(...) 是写操作,必须放在答案回来之后。这样即使第二轮落到另一个实例、重跑一遍 handler,也不会重复写库。这就是「确定性重放友好」的代码结构。
第二,ctx.requestId() 做幂等键。 MRTR 的重试在协议层是「同一个请求的新一轮」,但网络层可能是全新的 HTTP 请求。用服务端生成的 requestId 而不是客户端 ID,能保证同一个逻辑请求的多次重放收敛到同一条变更单。
5.2 让 tools/list 支持缓存
协议要求 tools/list 返回确定性顺序 + ttlMs/cacheScope。Spring AI 里用注解直接声明:
java
@McpResource(
uri = "config://approval/rules",
name = "approval-rules",
description = "审批规则配置(按租户隔离)",
size = 12_400,
ttlMs = 60_000, // 客户端可缓存 1 分钟
cacheScope = "private") // 租户数据,禁止共享中间件缓存
public String rules(@McpModernSyncRequestContext ctx) {
// 按请求自带的身份解析租户,而不是靠 session
return ruleRepo.load(ctx.clientInfo().name(), ctx.tenantId()).toJson();
}
cacheScope = "private" 这里绝不能图省事写 public------一旦中间层把它当公共内容缓存,A 租户就会读到 B 租户的审批规则。缓存提示写得越激进,泄漏面越大。
六、生产踩坑清单(10 条,按被坑概率排序)
1. 版本只在 _meta 里写、没带 MCP-Protocol-Version 头。
2026-07-28 明确要求 header,缺了就是 400 + -32020。这是升级后第一个撞墙的点。
2. server/discover 报 -32601 就以为服务端没实现。
其实是不带 _meta 的请求走了 legacy 路径。加上 _meta.io.modelcontextprotocol/protocolVersion 再试,同一个服务端立刻返回。别急着降级。
3. Mcp-Name 与 body 里的工具名不一致。
服务端不做校验 → 400 + -32020 只是表面;真正的风险是网关按 header 鉴权、服务端按 body 执行,直接越权。上线前做一次黑盒测试:header 写 A、body 写未注册的工具,确认「什么都没执行」。
4. 中间件改写 body 却忘了改写 header。
流式网关、日志脱敏、参数注入这类组件最容易犯。规则只有一条:要么都改,要么都不改;网关必须先拿 body 校验 header 再信任它。
5. MRTR 重试落到别的实例,内存中间态全丢。
老代码里「先把上下文塞进 ConcurrentHashMap<sessionId, Context>」的地方,无状态化后 100% 失效。要么全塞进 requestState(注意体积),要么落库用 handle 取。
6. requestState 明文回传。
最危险的坑。客户端可控 → 可篡改 → 审批绕过。必须 AES-GCM 密封 + TTL + 绑定 method|target|args。
7. 多实例部署 requestState 密钥不共享。
表现是「大约一半的审批重试解封失败」,随机且难复现。密钥走 KMS / Vault 下发,别写死在 application.yml。
8. 把业务状态藏在 session 里。
典型三种:文件上传游标、「这个用户已经付过钱」、分页上下文。无状态化后全丢。改法:要么每次请求付费、要么签发带 scope 和过期的显式 handle------注意这种 handle 本质是 bearer 凭据,必须自带作用域和有效期。
9. cacheScope 对租户数据写了 public。
跨租户数据泄漏,而且是通过缓存中间件泄漏的,日志里一点痕迹都没有。
10. 继续拿 ping 做健康检查。
ping 已被移除,K8s liveness probe 会 404 / -32601,然后 Pod 被反复重启。换成 server/discover,或者干脆用独立的 /health 端点,别让探针依赖协议方法。
补充两条:
- SSE 断点续传没了。
Last-Event-ID与 SSE 事件 ID 一并移除,断流即丢请求。客户端必须用新 request id 重发;如果无脑复用旧 id,会撞上「同 id 不同内容」的语义冲突。 - 双时代服务器没有下线日期。 legacy 路径今天很方便,明年就是包袱。上线时就写进 ADR:legacy 保留到 2027-07-31,到期删除。
七、迁移路线图:五步走,不要一次到位
| 阶段 | 动作 | 验收标准 |
|---|---|---|
| 1. 盘点 | 找出所有依赖 session 的地方:会话存储、内存态、ping 探针、resources/subscribe |
输出一份「状态清单」,每条注明改成 handle 还是 durable store |
| 2. 双时代并行 | protocol: MODERN + 保留 legacy shim,modern 走主路 |
modern / legacy 两套协议都能跑通同一批工具 |
| 3. 状态外化 | handle + 持久化;MRTR handler 改成「只读准备 → 中断 → 写库」结构 | 故意 kill 掉第一轮实例,第二轮换实例仍能正确重放 |
| 4. 网关前置 | 打开 Mcp-Method/Mcp-Name 路由与按工具限流;x-mcp-header 提升地域等路由参数 |
网关不解析 body 也能正确路由与限流;header/body 校验生效 |
| 5. 摘掉 legacy | 按 ADR 日期删除 legacy 路径与 shim | 只有一条代码路径、一套测试矩阵 |
顺序不能乱。第 3 步没做完就切到第 4 步,网关会放行一批注定失败的请求;第 4 步没做完就摘 legacy,你连出问题的是哪一代协议都分不清。
八、总结:这次改版把 Agent 的工程化往前推了一大步
回头看六道关里的「企业级 Runtime」和「异步通信底座」两道,MCP 2026-07-28 给的答案其实很朴素:
- 无状态,所以任何实例都能处理任何请求,扩容不再需要粘性会话;
- 显式句柄,所以跨调用状态是可审计、可过期的,而不是藏在进程内存里;
- 幂等重放,所以交互式中断在分布式环境下也可恢复,代价是你要自己保证副作用可对账;
- 头部路由,所以网关能在不解析 body 的前提下做治理,而校验 header/body 一致性成了新的安全底线。
这些没有一个是在发明新概念。它们是分布式系统里被验证过二十年的老手艺:无状态、显式上下文、幂等、网关治理、fail-fast、可观测。
所以对 Java 后端来说,这版协议不需要你「转型」什么。你需要的只是把做微服务时那套「状态在哪、谁保证幂等、边界怎么防」的自觉,从 HTTP 服务平移到 Agent 的工具层。
模型负责决定做什么,协议负责把这件事可靠地送达,而系统负责敢让人少盯着一点。这三件事里,后两件一直是 Java 工程师的主场。