在2026-07-28的规范之后,将MCP工具作为无状态服务运行。
放弃粘性会话:MCP 2026-07-28 通过请求封装、头部路由、输入必需时的重试机制,以及在各副本间共享的 requestState 键来实现自包含式请求处理。
来源: MCP Just Went Stateless: Run Your Agent Tools Like Real Services
为何会话机制会成为生产环境的问题
过去在负载均衡器后运行MCP时,需要保持会话绑定性,将每个客户端固定到某个Pod上,并且接受常规部署可能会在任务进行中中断代理程序的情况。MCP 2026–07–28版本彻底取消了协议级的会话机制。维护者称这是自授权功能引入以来最大的变革,对于大规模使用代理工具的团队而言,这也是最重要的变化。
下面的演示将解释这一变革,然后使用官方Python SDK v2构建一个小型无状态退款服务器,其中包含能够在副本切换时依然有效的人工审批步骤。这些示例是在版本上测试的。
MCP最初是作为通过stdio实现的桌面端与本地工具之间的桥梁,当时长期存在的有状态套接字是可以接受的。但在Kubernetes、Cloud Run以及其他多实例平台上,这种设计方式带来了诸多问题:
- 粘性路由。初始化握手会生成一个
Mcp-Session-Id,后续的所有请求都必须发送到同一个进程。 - 脆弱的部署方式。重启容器会清除其中的所有活跃会话。
- 不透明的网关。WAF或API网关的速率限制与路由功能需要解析JSON-RPC格式的请求体。
- 持续开放的流连接。当工具在调用过程中需要人工回复时,服务器会保持流连接处于开放状态并持续等待。
这种有状态的设计模式适合本地编程助手使用,而企业则更需要具备集中式策略管理的常规生产环境访问功能。
2026-07-28发布的规格变更
1. 握手及会话标识符已被移除。原有的initialize / initialized步骤以及头部信息均不再存在。每个请求都会在params._meta中携带协议版本、客户端身份信息及功能能力。通过简单轮询负载均衡机制部署的任何副本都可以接收各类请求。
2. 路由功能集成在HTTP头部中。可流式传输的HTTP流量必须包含和字段。网关无需解析请求体即可对工具调用进行路由、计费及拦截处理。
3. 多轮往返请求(MRTR)。服务器不会为等待人工回复而挂起正在处理的流,而是返回resultType: "input_required"、相关提示以及一个不可见的requestState数据块。客户端收集回复后,使用inputResponses重新发起原始请求。这种模式替代了服务器在请求处理过程中主动发出的elicitation/create、sampling/createMessage和roots/list请求。
4. 可缓存的列表。tools/list、prompts/list、resources/list和resources/read的响应可以标注ttlMs和cacheScope,这样客户端就不会频繁访问列表接口,从而让大语言模型提示词缓存更加稳定。
5. 更严格的授权机制。必须按照 RFC 9207 的要求进行发件人验证。动态客户端注册将被客户端 ID 元数据文档所取代。
6. 扩展功能。长时间运行的任务将使用 io.modelcontextprotocol/tasks 扩展来处理,而 MCP 应用和企业级托管授权也成为了官方扩展功能。
7. 逐步废弃的功能。在团队完成迁移的至少十二个月内,Roots、Sampling、日志记录 RPC 以及传统的 HTTP+SSE 功能仍将可用。
无法保证新服务器能与旧客户端兼容。在能够控制双方的情况下,应同时进行升级。与使用 SDK 的用户相比,自定义协议实现需要做更多的调整。
实战演示:用 Python 构建无状态 MCP 服务器
只需两个工具即可构建一个简易的退款服务。get_order用于查询订单,而refund_order涉及敏感操作,因此资金转账前必须经过人工审批。那种在通话过程中提出的问题正是过去需要依赖紧密关联机制才能解决的。
安装v2 SDK并生成一个共享的加密密钥:
pip install "mcp[cli]>=2.2.0"
export MCP_STATE_KEY=$(python -c "import secrets; print(secrets.token_hex(32))")
服务器端
# server.py — a stateless MCP server (spec 2026-07-28, Python SDK v2)
import os
from typing import Annotated
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.caching import CacheHint
from mcp.server.mcpserver import Elicit, RequestStateSecurity, Resolve
# Tell clients they may cache tools/list for 1 hour and share it across users.
mcp = MCPServer(
"refunds",
cache_hints={"tools/list": CacheHint(ttl_ms=3_600_000, scope="public")},
# Every replica must share this key, or a retry that lands on another pod fails.
request_state_security=RequestStateSecurity(keys=[os.environ["MCP_STATE_KEY"]]),
)
ORDERS = {"A-1001": 49.99, "A-1002": 1299.00} # stand-in for your real database
class Approval(BaseModel):
approve: bool
reason: str = ""
def ask_for_approval(order_id: str) -> Elicit[Approval]:
"""Resolver: ask a human to confirm the refund before the tool runs."""
amount = ORDERS[order_id]
return Elicit(f"Refund ${amount:,.2f} for order {order_id}?", Approval)
@mcp.tool()
def get_order(order_id: str) -> dict:
"""Look up an order's refundable amount."""
return {"order_id": order_id, "amount": ORDERS.get(order_id)}
@mcp.tool()
def refund_order(
order_id: str,
approval: Annotated[Approval, Resolve(ask_for_approval)],
) -> str:
"""Refund an order after a human approves it."""
if not approval.approve:
return f"Refund for {order_id} rejected: {approval.reason or 'no reason given'}"
return f"Refunded ${ORDERS[order_id]:,.2f} for {order_id}"
if __name__ == "__main__":
# No session state: any replica behind a round-robin load balancer can answer.
mcp.run(transport="streamable-http", host="0.0.0.0", port=8000,
stateless_http=True, json_response=True)
有三个关键点需要注意:
Resolve与Elicit构成了v2版本的依赖注入方式。approval参数并非由模型自动填充,SDK会先执行ask_for_approval操作,对于2026-07-28版本的客户端,该操作会将提示转化为input_required类型的响应;旧版本客户端则仍采用传统的通话中询问方式。cache_hints用于为tools/list添加ttlMs和cacheScope参数。
stateless_http=True 结合 json_response=True 时,每个 POST 请求只会返回一个 JSON 响应——没有会话对象,也没有正在处理的流。客户端
# client.py — call the server; the SDK handles the input_required retry loop
import asyncio
from mcp import Client
from mcp.types import ElicitResult
async def on_elicit(context, params):
# In a real app, render params.message in your UI and collect the answer.
print(f"[server asks] {params.message}")
return ElicitResult(action="accept", content={"approve": True})
async def main():
async with Client("http://localhost:8000/mcp", elicitation_callback=on_elicit) as client:
tools = await client.list_tools()
print("tools:", [t.name for t in tools.tools], "| ttlMs:", tools.ttl_ms)
result = await client.call_tool("refund_order", {"order_id": "A-1002"})
print(result.content[0].text)
asyncio.run(main())
典型的运行结果会显示类似如下内容:
tools: ['get_order', 'refund_order'] | ttlMs: 3600000
[server asks] Refund $1,299.00 for order A-1002?
Refunded $1,299.00 for A-1002
实际通过网络传输的内容
使用 curl 发送的第一次请求会显示用于替代传统握手协议的路由头部信息以及 _meta 封装数据:
curl -s -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: refund_order' \
-d '{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {
"name": "refund_order",
"arguments": {"order_id": "A-1002"},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "curl", "version": "1.0"},
"io.modelcontextprotocol/clientCapabilities": {"elicitation": {"form": {}}}
}
}
}'
服务器会立即响应,而不会阻塞等待:
{
"resultType": "input_required",
"inputRequests": {
"__main__:ask_for_approval": {
"method": "elicitation/create",
"params": {
"message": "Refund $1,299.00 for order A-1002?",
"mode": "form",
"requestedSchema": {
"type": "object",
"properties": {"approve": {"type": "boolean"}, "reason": {"type": "string"}},
"required": ["approve"]
}
}
}
},
"requestState": "v1.bOKJzdeMdGCz-ezv..."
}
在人工回应之后,客户端会重新尝试相同的 tools/call 操作,并将响应内容连同不可见的状态数据块一起发送:
"inputResponses": {
"__main__:ask_for_approval": {"action": "accept", "content": {"approve": true}}
},
"requestState": "v1.bOKJzdeMdGCz-ezv..."
后续响应的resultType: "complete"字段会包含类似已为A-1002退款1,299.00美元的文本。在这两次POST请求中,都没有在任何地方保留会话内存;所有的必要信息都通过不透明的requestState字段传递。
生产环境中的隐患:requestState键
为验证这一观点,可在8000和8001上搭建副本,将首次请求发送到A,后续请求发送到B。如果加密方式不同,第二跳就会拒绝该状态数据块:
{"code": -32602, "message": "Invalid or expired requestState",
"data": {"reason": "invalid_request_state"}}
那种失败是故意为之的。SDK内部使用AES-256-GCM进行加密,这样调用方就无法伪造或修改数据块。除非另有配置,否则每个进程都会生成一个私钥,这意味着副本B无法解密在副本A上加密的数据块。请配置RequestStateSecurity(keys=[...]),并将至少32字节的密钥从您的密钥管理器加载到每个副本中。使用共享密钥的话,跨副本重试就能成功。
该SDK还支持无中断的密钥轮换。列表中的第一个密钥用于加密新状态,而列表中的每个密钥都可以解密该状态。首先发送包含旧密钥和备用新密钥的双密钥列表,在下一次发布时交换两者顺序,等到完整的TTL时间窗口(默认为600秒,可自行修改)过后再删除旧密钥条目。如果您希望使用自定义的KMS而非原始密钥,建议使用codec=参数。
生产环境检查清单
- 使用
stateless_http=True运行,并从负载均衡器中移除粘性会话规则。 - 在各个副本之间共享
requestState键,并规划节点轮换策略。 - 将持久化的业务状态存储在自定义的数据库中。长时间运行的任务应返回模型日后可使用的标识符,或使用Tasks扩展——切勿依赖连接来存储内存数据。
- 通过
Mcp-Method/Mcp-Name在边缘层进行管控,设置针对不同工具的限制、允许列表及计量规则。 - 发布缓存提示:对于稳定的工具列表可使用较长的TTL值,当结果因用户不同而有所差异时则设置
scope="private"。 - 保留已过时的RPC接口:用OpenTelemetry或stderr替代原有的日志记录方式,并在十二个月内安排停止使用Sampling和Roots功能。
- 更新认证机制:验证令牌发行方(遵循RFC 9207标准),并逐步过渡到客户端ID元数据文档。
关键要点
- 2026–07–28的版本更新使每个请求都成为独立单元;握手过程和会话ID已被移除。
- 通话过程中如有疑问,可通过
input_required功能结合客户端重试机制,避免服务器挂起已开启的流。 - Python SDK v2承担了主要功能:
Resolve(...)与Elicit(...)结合使用,可获取人工确认,适用于旧版和新版客户端。 - 在扩展部署前,跨副本重试需要使用共享的
requestState键。 - 通过头部路由和缓存提示,可使MCP表现得像普通的HTTP服务。
结论
MCP不再仅仅是桌面插件协议。它可以像其他微服务一样运行,这意味着自动扩缩容、蓝绿部署、API网关和WAF等功能都可以再次应用。如果您已经在使用MCP服务器,建议升级到v2 SDK,设置共享状态键,并取消粘性会话——值班人员将会感受到明显差异。
延伸阅读
- 规范公告:blog.modelcontextprotocol.io
- 协议文本:modelcontextprotocol.io/specification/2026-07-28
- The Register上关于MCP摆脱状态化设计的行业报道
- 官方Python SDK仓库