跳过正文
  1. Welcome to My Blog/

MCP 无状态化:2026-07-28 修订删掉了握手和会话

JekYUlll
作者
JekYUlll
C++ / Go / Linux 开发者

MCP 在 2026-07-28 修订里把传输层拆了重搭:initialize / notifications/initialized 握手删了,Mcp-Session-Id 会话也删了。现在每个请求都是自包含的,自己带协议版本、身份和能力声明,服务器不需要记住这个客户端上一秒说过什么。

影响很实际。以前自托管的远程 MCP server 要水平扩容,会话会把客户端钉死在签发它的那台实例上,要么配 sticky 路由,要么搞共享 session 存储。现在请求落到哪台实例都一样,普通轮询的负载均衡就能跑。官方博客把这次修订称为 remote MCP 上线以来最重要的一次,配了详细的迁移说明。

这篇把新协议完整跑一遍:一个最小的 Python server,加一串 curl 请求。环境是官方 Python SDK 2.x,写这篇时 PyPI 上最新是 2.2.0。

第一个请求就是业务请求
#

旧版 Streamable HTTP 的请求序列是两步:先发 initialize 换一个 Mcp-Session-Id,之后每个请求都带上它。新协议里第一步没了,下面这个请求打到刚启动的服务器上,不带任何前置状态:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
curl -s http://127.0.0.1:8931/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/list' \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/list",
    "params": {
      "_meta": {
        "io.modelcontextprotocol/protocolVersion": "2026-07-28",
        "io.modelcontextprotocol/clientInfo": {"name": "curl", "version": "1.0.0"},
        "io.modelcontextprotocol/clientCapabilities": {}
      }
    }
  }'

返回是个普通 JSON(content-type 是 application/json,不想要流的时候服务器不会硬塞一条 SSE 过来),节选如下,工具 schema 的细节省略:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "complete",
    "tools": [
      {"name": "add", "description": "Add two integers."},
      {"name": "deploy", "description": "Deploy a service, confirming with the client first."}
    ],
    "ttlMs": 0,
    "cacheScope": "private",
    "_meta": {
      "io.modelcontextprotocol/serverInfo": {"name": "stateless-demo", "version": "0.1.0"}
    }
  }
}

resultType 是所有结果的必填项,普通结果是 complete,另有 input_required(后面细说);ttlMscacheScope 是缓存提示,tools/list 这类列表结果和 resources/read 现在必须带。ttlMs 是毫秒级新鲜度提示,语义类似 HTTP 的 Cache-Control: max-age,0 表示立即过期;cacheScopepublicprivate,决定共享网关能不能缓存这份响应。

响应里没有 Mcp-Session-Id,请求里也没有。顺手做了个对照:把服务器进程杀掉,换个新进程起来,同一个请求再打一次,照样 200。会话时代遗留的状态问题(重启后会话悬空、客户端重新握手)也就没有了。

版本、身份、能力:每个请求自己带
#

没有协商握手,协商动作被下放到了每个请求的 _meta 里:

  • io.modelcontextprotocol/protocolVersion:请求用的协议版本,必填。
  • io.modelcontextprotocol/clientInfo:客户端名字和版本,建议每个请求都带。
  • io.modelcontextprotocol/clientCapabilities:能力声明,服务器据此决定能向客户端要什么。比如客户端没声明 elicitation,服务器就不能向它发问询请求。
  • 服务器侧对称:每个结果的可选 _meta 里放 io.modelcontextprotocol/serverInfo

版本对不上时,服务器返回 -32022data.supported 里列出自己支持的版本。实测把版本写成 1900-01-01(HTTP 400):

1
2
3
4
5
6
7
8
9
{
  "jsonrpc": "2.0",
  "id": 5,
  "error": {
    "code": -32022,
    "message": "Unsupported protocol version",
    "data": {"supported": ["2026-07-28"], "requested": "1900-01-01"}
  }
}

客户端从 supported 里挑一个双方都支持的版本重试就行,一次往返解决,不用专门握一轮手。

如果客户端想开局就摸清服务器底细,可以调 server/discover(服务器必须实现,客户端可选)。一个请求拿回支持版本、能力清单和身份:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "resultType": "complete",
    "supportedVersions": ["2026-07-28"],
    "capabilities": {
      "tools": {"listChanged": true},
      "resources": {"listChanged": true, "subscribe": true},
      "prompts": {"listChanged": true}
    },
    "ttlMs": 0,
    "cacheScope": "private",
    "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "stateless-demo", "version": "0.1.0"}}
  }
}

头和体必须对得上
#

新的 Streamable HTTP 把几个 body 字段镜像进了 HTTP 头,网关、限流器、WAF 不解析 body 就能做路由:

  • MCP-Protocol-Version:所有 POST 必带,值必须与 _meta 里的 protocolVersion 一致。
  • Mcp-Method:镜像 method 字段,所有请求必带。
  • Mcp-Name:镜像 params.nameparams.uritools/callresources/readprompts/get 必带。

规则是:体是唯一事实来源,头只是镜像,服务器必须校验两者一致,对不上就 400 + -32020(HeaderMismatch)。把 Mcp-Name 写成 multiply、body 里却是 add,结果:

1
2
3
4
5
6
7
8
{
  "jsonrpc": "2.0",
  "id": 2,
  "error": {
    "code": -32020,
    "message": "mcp-name header does not match the request body's 'name' parameter"
  }
}

这个校验有实际动机:如果头里说调的是 A 工具、体里写的是 B 工具,转发层和服务器就建立在两套事实上,路由和限流可以这样被绕。

另外两个小细节:工具参数可以用 schema 里的 x-mcp-header 声明镜像成 Mcp-Param-{Name} 头(值不是安全 ASCII 时用 =?base64?...?= 哨兵格式包裹),给按租户限流这类场景用;老客户端残留的 Mcp-Session-Id 请求头会被直接忽略,GET / DELETE 打到端点返回 405,Last-Event-ID 也失效了,SSE 流断了不能续传,要换个新 id 重发。

服务器要问用户:多轮往返(MRTR)
#

旧协议里,服务器中途要问用户(elicitation/create)、借客户端模型(sampling/createMessage)、要文件系统信息(roots/list),靠的是在常开流上反向发请求。新修订把这条路整个拆了,换成 Multi Round-Trip Requests(MRTR,SEP-2322),官方标注这是 breaking change。

新流程:服务器需要额外输入时,返回 resultType: "input_required",附带 inputRequests(要问的内容)和 requestState(服务器自己的不透明状态)。客户端收集好答案后,换一个新的 JSON-RPC id 重发原请求,带上 inputResponses 和原样回传的 requestState

调用一个要先确认的 deploy 工具,第一回合的返回(HTTP 200,走正常结果通道,不是错误):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "__main__:ask_confirmation": {
        "method": "elicitation/create",
        "params": {
          "message": "Deploy payments-api?",
          "mode": "form",
          "requestedSchema": {
            "type": "object",
            "properties": {"approved": {"type": "boolean", "title": "Approved"}},
            "required": ["approved"]
          }
        }
      }
    },
    "requestState": "v1.t4_8zkK6JpTXJxTv9…(截断)"
  }
}

inputRequests 的键是服务器起的名字,requestState 是一段对客户端不透明的加密串(SDK 的实现是 AES-256-GCM,密文带 v1. 前缀)。客户端要做的事只有一件:把答案填进 inputResponses,把 requestState 原样抄回来,换新 id 重发。重试时 params 多了这两个字段,其余不变:

1
2
3
4
5
6
7
8
"params": {
  "name": "deploy",
  "arguments": {"service": "payments-api"},
  "inputResponses": {
    "__main__:ask_confirmation": {"action": "accept", "content": {"approved": true}}
  },
  "requestState": "v1.t4_8zkK6JpTXJxTv9…(原样回传第一回合的值)"
}

第二回合返回最终结果:

1
2
3
4
5
6
7
8
9
{
  "jsonrpc": "2.0",
  "id": 6,
  "result": {
    "resultType": "complete",
    "content": [{"type": "text", "text": "deploy payments-api: done (approved=True)"}],
    "structuredContent": {"result": "deploy payments-api: done (approved=True)"}
  }
}

只有 tools/callresources/readprompts/get 三种请求允许这种中间结果;重试的 id 必须和首次不同,规范原文要求把它们当两次独立请求处理;requestState 对客户端是不透明字符串,不许解析、修改、假设内容,重试时原样回传。反过来,服务器必须把它当攻击者可控输入对待,只要它影响授权或业务逻辑就必须做完整性保护,并建议绑定主体、加 TTL、绑请求摘要来收窄重放窗口。

跨调用的状态,协议层不再提供归宿,交给应用自己管:服务端发一个显式 handle(比如 basket_id),让模型用普通参数在后续调用里带回来。状态从传输层的隐藏元数据挪进了模型的上下文,官方认为这反而更好:模型能看到、传递、组合这些 handle,而藏在会话里的状态对它完全不可见。

写一个最小的无状态 server
#

server 全文,保存为 server.py:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
"""Minimal stateless MCP server for tracing the 2026-07-28 revision."""

from typing import Annotated

from mcp.server import MCPServer
from mcp.server.mcpserver import Elicit, Resolve
from mcp.server.elicitation import ElicitationResult
from pydantic import BaseModel

mcp = MCPServer("stateless-demo", version="0.1.0")


@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two integers."""
    return a + b


class Confirm(BaseModel):
    approved: bool


def ask_confirmation(service: str) -> Elicit[Confirm]:
    """Resolver: ask the client to confirm before deploying."""
    return Elicit(f"Deploy {service}?", Confirm)


@mcp.tool()
async def deploy(
    service: str,
    answer: Annotated[ElicitationResult[Confirm], Resolve(ask_confirmation)],
) -> str:
    """Deploy a service, confirming with the client first."""
    if answer.action != "accept":
        return f"deploy {service}: aborted ({answer.action})"
    return f"deploy {service}: done (approved={answer.data.approved})"


if __name__ == "__main__":
    mcp.run(transport="streamable-http", host="127.0.0.1", port=8931)

deploy 的第二个参数不是给模型填的,Resolve(ask_confirmation) 把它标成一个解析器参数:SDK 先跑 ask_confirmation,拿回一个 Elicit 请求标记;对 2026-07-28 的连接,框架把它包进 InputRequiredResult 发出去,重试进来时再把答案注入。同一份代码配旧协议客户端,SDK 会退回中途发请求的老行为,传输语义跟着协商出来的版本走。这个取舍在 SDK 源码的 resolve.py 里写得明确:批量打包成 InputRequiredResult,还是逐个中途发送,由协商出的版本决定。

起服务:

1
2
3
uv venv .venv
uv pip install --python .venv/bin/python "mcp==2.2.0"
.venv/bin/python server.py

服务起来后,用上面的 curl 直接打就行。SDK 客户端这一侧更省事,MRTR 往返被 call_tool 内部消化,应用只要给 elicitation 注册一个回调:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
"""Call the demo server; the MRTR retry loop is invisible to the caller."""

import asyncio

from mcp import Client, types


async def confirm(context, params: types.ElicitRequestParams) -> types.ElicitResult:
    print("[client] server asks:", params.message)
    return types.ElicitResult(action="accept", content={"approved": True})


async def main() -> None:
    async with Client("http://127.0.0.1:8931/mcp", elicitation_callback=confirm) as c:
        print("protocol version:", c.protocol_version)
        r = await c.call_tool("add", {"a": 19, "b": 23})
        print("add ->", r.structured_content)
        r = await c.call_tool("deploy", {"service": "payments-api"})
        print("deploy ->", r.structured_content)


asyncio.run(main())

跑出来是这样:

1
2
3
4
protocol version: 2026-07-28
add -> {'result': 42}
[client] server asks: Deploy payments-api?
deploy -> {'result': 'deploy payments-api: done (approved=True)'}

客户端自己完成了 server/discover 探测、版本选择、MRTR 重试这些事,call_tool 返回的就是最终结果。

升级时会被波及的几件事
#

Roots、Sampling、Logging 三个功能被一次性标记为废弃(SEP-2577):还能用,窗口至少 12 个月,但新实现就别往里投了。迁移方向:目录和文件信息改走工具参数或资源 URI;Sampling 改成直接对接模型供应商 API;日志写 stderr(stdio)或走 OpenTelemetry。pinglogging/setLevel 也删了,日志等级改为按请求在 _meta 里设置。老的 HTTP+SSE 传输正式进了废弃流程(SEP-2596)。

通知机制换了条路:独立 GET 流整个删掉,改成 subscriptions/listen,一条 POST 开出的长 SSE 流,客户端按类型订阅(toolsListChangedpromptsListChangedresourcesListChangedresourceSubscriptions),通知带 io.modelcontextprotocol/subscriptionId 标归属;请求级通知(如 notifications/progress)不受影响,仍走各自请求的响应流。

鉴权侧加了一批硬化:OAuth 授权响应建议带 RFC 9207 的 iss,客户端兑换 code 前必须校验;动态客户端注册(DCR)正式让位给客户端元数据文档(CIMD);客户端凭证按授权服务器绑定,不许跨服务器复用。Tasks 从实验性核心拆成了官方扩展(io.modelcontextprotocol/tasks,轮询式 tasks/get)。

兼容旧服务器不难:客户端先照常发一个 modern 请求,撞上 400 时看一眼响应体,是可识别的 modern 错误(比如 -32022)就按新协议继续,否则回退到 initialize 走旧流程。

参考
#

  • 官方发布博客:https://blog.modelcontextprotocol.io/posts/2026-07-28/
  • Key Changes(相对 2025-11-25 的完整变更):https://modelcontextprotocol.io/specification/2026-07-28/changelog
  • Streamable HTTP 绑定:https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http
  • Multi Round-Trip Requests:https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr
  • Discovery(server/discover):https://modelcontextprotocol.io/specification/2026-07-28/server/discover
  • Versioning and Compatibility:https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning
  • Caching:https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching
  • Python SDK:https://pypi.org/project/mcp/

相关文章

Nginx 源码解析(十二):模块系统与动态加载

··11 分钟
系列开篇就说过,Nginx 的所有功能都是模块提供的。前面的文章你看到了 HTTP 模块、Event 模块、Upstream 模块在各自领域的工作方式,这一篇把视角收回到模块系统本身:模块长什么样,静态模块怎么初始化,动态模块怎样通过 dlopen 加载。 重点看四件事:ngx_module_t 字段、ngx_modules.c 生成逻辑、ngx_count_modules() 的索引分配、ngx_load_module() 的加载路径,以及 commands 数组怎样驱动配置解析器。