MCP 走向无状态:移除会话与握手后,服务端该怎么改

2026-07-21 27 预计阅读时间: 1 分钟
来源: oschina.net AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:12 分钟

Model Context Protocol(MCP)即将迎来自诞生以来最激进的一次协议调整:RC 于 5 月 21 日锁定,经过 10 周 SDK 验证和社区反馈,最终规范计划在 7 月 28 日定稿。六个 SEP(规范增强提案)共同指向同一个目标:移除协议层状态,其中最明确的变化是彻底取消 Mcp-Session-Id 请求头。

这不只是删掉一个 HTTP Header。没有会话、没有握手,意味着 MCP 服务端不能再假设“前一个请求已经替当前请求准备好了上下文”。负载均衡、重试、扩缩容会变得简单,但身份认证、任务进度和临时上下文也必须找到新的归属。

从连接驱动改成请求驱动

有状态协议通常把一次交互拆成几个阶段:客户端先建立连接或完成初始化,服务端生成会话标识,后续请求再携带该标识。服务端因此能够把能力协商结果、用户上下文和运行状态暂存在内存中。

无状态模型要求每个请求都能被独立处理。任意实例拿到请求后,应当仅根据当前请求以及外部持久化数据完成工作,不能依赖某台进程内保存的会话对象。

这会直接改变服务端的几个关键假设:

  • 负载均衡器不再需要根据 Mcp-Session-Id 实现粘性路由。
  • 实例重启不会天然中断协议会话,因为协议层已经没有会话。
  • 客户端可以更安全地重试,但写操作仍然需要幂等键。
  • 能力信息不能只在握手阶段交换,客户端和服务端需要按照最终规范重新组织发现与调用流程。
  • 长任务不能依赖常驻连接中的隐式状态,应使用明确的任务标识和持久化存储。

“无状态”只约束协议层,并不代表业务系统不能保存状态。订单、审批任务、OAuth 授权和 Agent 运行记录仍然需要数据库;变化在于这些状态必须拥有明确的业务标识,而不能藏在协议会话里。

Mcp-Session-Id 消失后,哪些代码最容易出问题

迁移时应先搜索会话头的生产者和消费者:SDK 中间件可能自动写入它,API 网关可能拿它做哈希路由,服务端也可能用它作为内存缓存的主键。

可以先在代码库和部署配置中做一次静态排查:

rg -n --hidden \
  --glob '!node_modules' \
  --glob '!.git' \
  'Mcp-Session-Id|mcp_session|sessionId|sticky|affinity' .

结果不能机械地全部删除。例如业务登录会话未必属于 MCP 协议状态,需要区分以下概念:

状态类型 迁移建议
MCP 协议会话标识 按新规范移除
用户认证信息 使用标准 Authorization 等机制独立传递
写请求去重信息 使用明确的幂等键,并在共享存储中记录
长任务进度 使用业务任务 ID 查询,不依赖连接或进程内存
工具调用产生的业务数据 持久化到数据库或对象存储
短期性能缓存 可以保留,但不能成为请求正确性的前提

尤其要警惕这样的代码:

sessions[request.headers["Mcp-Session-Id"]].selected_workspace

它同时依赖请求头和本机内存。迁移后,工作区应成为请求参数、认证主体的属性,或者由请求中的稳定资源 ID 从数据库查询。

可以这样实践:搭一个真正无会话的请求处理器

下面是一个可直接运行的最小 Python 示例,用来演示迁移结构。它不是对尚未定稿规范字段的断言;tools/call 和参数结构应在接入时以最终 MCP 规范及所用 SDK 为准。示例的重点是:每次请求都携带完成操作所需的信息,服务端不创建会话,也不把正确性建立在进程内缓存上。

将以下内容保存为 server.py,使用 Python 3.10 或更高版本运行:

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json


def send_json(handler, status, payload):
    body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
    handler.send_response(status)
    handler.send_header("Content-Type", "application/json; charset=utf-8")
    handler.send_header("Content-Length", str(len(body)))
    handler.end_headers()
    handler.wfile.write(body)


class MCPHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/mcp":
            send_json(self, 404, {"error": "not_found"})
            return

        if self.headers.get("Mcp-Session-Id"):
            send_json(self, 400, {"error": "legacy_session_header_rejected"})
            return

        try:
            length = int(self.headers.get("Content-Length", "0"))
            request = json.loads(self.rfile.read(length))
        except (ValueError, json.JSONDecodeError):
            send_json(self, 400, {"error": "invalid_json"})
            return

        if request.get("method") != "tools/call":
            send_json(self, 400, {"error": "unsupported_method"})
            return

        params = request.get("params", {})
        if params.get("name") != "sum":
            send_json(self, 404, {"error": "unknown_tool"})
            return

        arguments = params.get("arguments", {})
        try:
            result = float(arguments["a"]) + float(arguments["b"])
        except (KeyError, TypeError, ValueError):
            send_json(self, 400, {"error": "a_and_b_must_be_numbers"})
            return

        send_json(self, 200, {
            "jsonrpc": "2.0",
            "id": request.get("id"),
            "result": {"value": result},
        })

    def log_message(self, format, *args):
        return


if __name__ == "__main__":
    server = ThreadingHTTPServer(("127.0.0.1", 8000), MCPHandler)
    print("Listening on http://127.0.0.1:8000/mcp")
    server.serve_forever()

启动服务并连续发送两个互不相关的请求:

python3 server.py

另开一个终端执行:

curl -sS http://127.0.0.1:8000/mcp \
  -H 'Content-Type: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": "req-1",
    "method": "tools/call",
    "params": {
      "name": "sum",
      "arguments": {"a": 20, "b": 22}
    }
  }'

curl -sS http://127.0.0.1:8000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Mcp-Session-Id: legacy-session' \
  -d '{
    "jsonrpc": "2.0",
    "id": "req-2",
    "method": "tools/call",
    "params": {
      "name": "sum",
      "arguments": {"a": 1, "b": 2}
    }
  }'

第一个请求不依赖任何先行握手即可被独立处理。第二个请求会暴露仍在发送旧会话头的客户端。这种“迁移期拒绝模式”适合测试和预发布环境;生产环境是否立即拒绝旧请求,应根据兼容窗口决定,也可以先记录指标再逐步收紧。

无状态解决不了重试带来的重复执行

读操作通常可以直接重试,但调用工具可能发送邮件、创建工单或触发付款。即使协议没有会话,网络超时仍可能发生在服务端已经完成操作、客户端却尚未收到响应的时刻。客户端重试后,业务动作可能执行两次。

可以这样实践:为有副作用的调用增加稳定幂等键,并在共享数据库中保存执行结果。下面的 HTTP 片段只展示设计思路,具体 Header 或请求字段需要按照最终规范和应用网关约定调整:

POST /mcp HTTP/1.1
Host: mcp.example.internal
Authorization: Bearer <access-token>
Content-Type: application/json
Idempotency-Key: create-ticket-8f59bdf1

{
  "jsonrpc": "2.0",
  "id": "req-42",
  "method": "tools/call",
  "params": {
    "name": "create_ticket",
    "arguments": {
      "project_id": "OPS",
      "title": "Database latency alert"
    }
  }
}

服务端应对“同一认证主体、同一幂等键”返回第一次调用的结果,而不是再次执行工具。幂等记录必须存入所有实例都能访问的数据库或缓存,并设置与业务风险匹配的过期时间。

迁移时不要把协议状态换个名字藏起来

最危险的伪迁移,是删除 Mcp-Session-Id 后立刻增加一个功能相同的私有 Header。这样做既失去新协议的互操作性,也保留了粘性路由和实例故障问题。

落地时可以按下面的顺序推进:

  1. 盘点 SDK、代理、网关和服务端对 Mcp-Session-Id 及握手流程的依赖。
  2. 将认证、幂等、长任务和资源定位拆成独立机制。
  3. 确保同一个请求发送到任意实例都能获得一致结果。
  4. 增加“旧会话头出现次数”“重复执行次数”和“无握手请求失败率”等指标。
  5. 用实例重启、随机路由和客户端重试验证服务端是否真的无状态。
  6. 最终字段和交互流程以 7 月 28 日定稿规范及对应 SDK 版本为准。

这次变化的收益很具体:MCP 服务更容易横向扩展,代理层不必维护协议会话,故障恢复路径也更短。代价同样明确:原本被会话掩盖的认证、去重和任务管理问题,必须由应用显式解决。迁移完成的判断标准不是“Header 已删除”,而是任意实例都能仅凭当前请求和共享持久化数据,可靠地给出结果。


相关推荐