5 月 21 日,MCP 2026-07-28 Release Candidate 锁定。7 月 28 日正式发布。
这是 MCP 自 2024 年底问世以来最大的一次协议变更。核心改动一句话:协议层面去掉了 Session。
听起来没什么。实际影响:你现有的每个远程 MCP Server 都需要改代码。不改的话,客户端一升级,你的 Server 就挂。
我花了两天把一个生产环境的 MCP Server 从 2025-11-25 迁移到新版。踩了几个坑,记录在这里。
旧版本到底有什么问题
2025-11-25 版本中,每个连接要走 initialize/initialized 握手,服务端发一个 Mcp-Session-Id:
bash
POST /mcp HTTP/1.1
Content-Type: application/json
{
"jsonrpc": "2.0", "id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "my-agent", "version": "1.0"}
}
}
服务端返回 Session ID,后续每个请求都得带 Mcp-Session-Id: 1868a90c-3a3f-4f5b 这个 header。
问题在部署时暴露:
粘性路由。 Session ID 把客户端绑死在某一台实例上。你需要 sticky session 或共享 session store,或者让网关做深度包检查来路由。
扩缩容要命。 加新实例,老会话路由不过去。减实例,活跃会话直接断。
重启即断连。 服务器重启 = 所有 session 丢失 = 客户端全部重新握手。
我们的场景:一个 MCP Server 挂了 4 个工具(数据库查询、文件搜索、代码执行、审批流),跑在 3 个 K8s Pod 后面。之前用 Redis 存 session state,每次扩容都要确认 Redis 集群健康,Pod 重启后客户端得重新走握手流程。运维成本不低。
新版本的 6 个关键变化
变化 1:握手没了
不用 initialize/initialized。协议版本、客户端信息、capabilities 放进每个请求的 _meta 字段:
makefile
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": "kubernetes pod restart"},
"_meta": {
"io.modelcontextprotocol/clientInfo": {
"name": "my-agent", "version": "1.0"
}
}
}
}
每个请求自包含。任意一台服务器实例都能处理。Round-robin 负载均衡就够了。
变化 2:路由 header
新增 Mcp-Method 和 Mcp-Name 两个 HTTP header。网关不用解析 body 就知道这个请求调的是哪个工具。
实际用途------我们用 Nginx 根据工具名把请求路由到不同后端:
nginx
# nginx.conf - 按工具名路由到不同服务
map $http_mcp_name $backend {
"db_query" db-service;
"file_search" search-service;
"code_exec" exec-service;
default default-service;
}
server {
listen 443 ssl;
location /mcp {
proxy_pass http://$backend;
proxy_set_header Host $host;
# 不再需要 sticky session
# 不再需要 ip_hash
}
}
以前做同样的事,要么解析 JSON body 提取 method(性能差),要么所有工具挤在同一个后端(没法独立扩缩容)。
变化 3:状态管理方式变了
协议不再替你管 session state。需要有状态的场景,用「显式 handle」模式------服务端创建一个 ID,让模型在后续调用中传回来:
python
from mcp.server import Server
import uuid
carts = {} # 生产环境用 Redis 或数据库
app = Server("shop")
@app.tool("create_cart")
async def create_cart(user_id: str):
cart_id = str(uuid.uuid4())
carts[cart_id] = {"user_id": user_id, "items": []}
return {"cart_id": cart_id, "message": "购物车已创建"}
@app.tool("add_item")
async def add_item(cart_id: str, item: str, qty: int):
if cart_id not in carts:
return {"error": "购物车不存在,请先调用 create_cart"}
carts[cart_id]["items"].append({"item": item, "qty": qty})
return {"cart_id": cart_id, "items": carts[cart_id]["items"]}
@app.tool("checkout")
async def checkout(cart_id: str):
if cart_id not in carts:
return {"error": "购物车不存在"}
cart = carts.pop(cart_id)
return {"order_id": str(uuid.uuid4()), "items": cart["items"]}
以前 cart 状态挂在 session 上,session 断了就丢。现在 cart_id 是显式参数,模型自己传来传去。任何实例都能处理,因为状态在存储层而不是传输层。
这个模式还有一个意外收获:模型可以同时操作多个 handle。比如一个 agent 同时打开两个购物车、比较价格、合并下单------旧版的 session 模式做不到,因为一个 session 只绑一个状态。
变化 4:服务端发起请求的新方式
旧版本里,服务端要在处理过程中问客户端问题(比如确认删除),得保持 SSE 长连接。新版改成了请求-响应模式:
python
# 服务端返回 InputRequiredResult,不阻塞连接
{
"resultType": "inputRequired",
"inputRequests": {
"confirm": {
"type": "elicitation",
"message": "要删除这 3 个文件吗?",
"schema": {"type": "boolean"}
}
},
"requestState": "eyJzdGVwIjoxLCJmaWxlcyI6WyJ0bXAvYSIsInRtcC9iIiwidG1wL2MiXX0="
}
客户端收集用户输入后,把原始请求连同用户回答一起重新发:
python
# 客户端重新发起请求,带上用户回答
POST /mcp HTTP/1.1
Mcp-Method: tools/call
Mcp-Name: delete_files
{
"jsonrpc": "2.0", "id": 2,
"method": "tools/call",
"params": {
"name": "delete_files",
"arguments": {"path": "/tmp/old"},
"inputResponses": {"confirm": true},
"requestState": "eyJzdGVwIjoxLCJmaWxlcyI6WyJ0bXAvYSIsInRtcC9iIiwidG1wL2MiXX0="
}
}
requestState 是 base64 编码的服务端状态。客户端不需要理解里面的内容,原样传回就行。因为所有信息都在 payload 里,任意实例都能接手处理。
变化 5:缓存和可观测性
工具列表和资源响应现在带 ttlMs(缓存有效期)和 cacheScope(缓存范围):
python
# tools/list 响应
{
"tools": [...],
"_meta": {
"ttlMs": 300000, # 5分钟内客户端不用重新拉取
"cacheScope": "global" # 所有用户可共享这份缓存
}
}
之前客户端要知道工具列表变没变,得开一个 SSE 长连接等通知。现在用 TTL,跟 HTTP Cache-Control 一个思路。
W3C Trace Context 也正式入规范了。_meta 里放 traceparent、tracestate、baggage,OpenTelemetry 后端可以把 agent 的工具调用链路串成一棵完整的 span 树。从 agent 发起请求到 MCP Server 再到下游服务,一条 trace 串到底。
变化 6:三个功能被废弃
Roots、Sampling 和 Logging 进入废弃状态,给了 12 个月缓冲期:
| 废弃功能 | 替代方案 |
|---|---|
| Roots | 工具参数或服务端配置 |
| Sampling | 直接调 LLM 提供商 API |
| Logging | stderr(stdio)或 OpenTelemetry(远程) |
Logging 影响最大。之前很多 MCP Server 用协议内置的 logging 能力往客户端推日志。现在建议改用标准方案。
我们的做法:
python
import structlog
log = structlog.get_logger()
@app.tool("db_query")
async def db_query(sql: str):
log.info("executing_query", sql=sql[:100])
results = await run_query(sql)
log.info("query_done", rows=len(results), ms=elapsed)
return results
structlog 输出到 stderr,再由 OpenTelemetry Collector 采集。跟现有的 K8s 日志体系打通,比 MCP 自己的 logging 好管理。
迁移踩的 4 个坑
坑 1:SDK 版本跳跃
Python MCP SDK 的 RC 支持在 mcp>=1.8.0。我们项目锁在 mcp==1.6.2,直接 bump 到 1.8 后发现几个内部 API 签名变了------handler 注册的参数格式有微调,类型注解也更严格了。花了半天定位一个 TypeError。
避免方式:先看 SDK 的 CHANGELOG 和 migration guide,别直接改版本号跑 pip install。
坑 2:requestState 序列化炸了
我们的审批工具用了 InputRequiredResult 做多步确认。第一版把 Python dict 直接 json.dumps + base64.b64encode 塞进 requestState。结果 dict 里有 datetime 对象,json.dumps 直接报 TypeError: Object of type datetime is not JSON serializable。
修复很简单,但这个坑在测试环节容易漏------因为简单场景(纯字符串和数字)不会触发:
python
import json, base64
from datetime import datetime
def encode_state(data: dict) -> str:
def serialize(v):
if isinstance(v, datetime):
return v.isoformat()
return v
clean = {k: serialize(v) for k, v in data.items()}
return base64.b64encode(json.dumps(clean).encode()).decode()
坑 3:Nginx header 值大小写
Nginx 读 HTTP header 时会把 header 名转小写、连字符换下划线,所以 Mcp-Method 用 $http_mcp_method 读------这部分没问题。
坑在 header 的值。tools/call 和 Tools/Call 是不同的字符串。我们一个测试客户端(自己写的)发了首字母大写的值,导致路由匹配失败,请求全落到 default-service。官方 SDK 统一用小写值,但规范没有强制。如果你有自研客户端,检查一下。
坑 4:Tasks 扩展的生命周期重写
我们的代码执行工具用了 2025-11-25 的实验性 Tasks API 跟踪长任务。新版把 Tasks 从核心功能挪到了 Extension,改动不小:
tasks/list被移除了。没有 session 就没法安全限定"谁的任务",合理但痛。- 创建方式从客户端主动发起变成服务端决定(服务端判断某个
tools/call应该异步执行时,返回 task handle)。 tasks/status拆成了tasks/get、tasks/update、tasks/cancel。
我们代码里有 10 多处 tasks/list 调用,全部需要改成自己维护一个 task registry(用 Redis sorted set,按创建时间排序,客户端传 user_id 来过滤)。改动量 200 行左右,主要是 registry 的 CRUD 逻辑。
迁移清单
把完整步骤列在这里,可以直接当 checklist 用:
sql
□ 升级 SDK(Python: mcp>=1.8.0, TS: @modelcontextprotocol/sdk@2.x)
□ 去掉 initialize/initialized 处理逻辑
□ 去掉 Mcp-Session-Id 的读取和校验
□ 有状态工具改用显式 handle 模式
□ 如果用了实验性 Tasks,迁移到 Tasks Extension
□ tools/list 响应加上 ttlMs 和 cacheScope
□ MCP Logging 改成 stderr 或 OpenTelemetry
□ 更新网关配置:去掉 sticky session,加 Mcp-Method / Mcp-Name 路由
□ 测试 InputRequiredResult 的 requestState 序列化(重点测非基本类型)
□ Auth:验证响应中的 iss 参数,注册时声明 application_type
□ 自研客户端:确认 Mcp-Method / Mcp-Name header 值用小写
迁不迁
如果你的 MCP Server 只在本地跑(stdio 传输),影响不大。SDK 升级后基本自动适配。
远程部署、多实例、需要水平扩展的------现在就迁。7 月 28 日正式发布后,新版客户端会用新协议,老 Server 直接不兼容。RC 窗口还有 6 周,够了。
我们迁移花了 2 天。第一天改代码(去 session 依赖 + Tasks 重写),第二天改部署配置和测试。代码改动不到 500 行,但省掉了 Redis session store 和 sticky session 配置,运维复杂度降了一个档次。
协议这次把传输层的状态甩掉了,让 MCP Server 跟普通 HTTP API 一样好部署。方向是对的。