MCP 无状态化改造:协议层不再存状态之后
一、为什么需要无状态
1.1 有状态的 MCP 到底把什么存在了协议层
先定义清楚我们在讨论什么。在 MCP 的 HTTP 传输层上,一个「有状态」的服务端至少要 hold 住四样东西:
| 状态 | 存在哪 | 丢了会怎样 |
|---|---|---|
会话标识(mcp-session-id) | 服务端内存 Map | 请求直接 404 / 400 |
| 协商结果(协议版本、能力集) | 绑定到会话对象 | 协商结果作废,要重新 initialize |
| 未消费的事件流缓冲(SSE pending events) | 服务端队列 | 通知丢失,客户端永远在等 |
| 正在进行的流式续传位置(resumption token) | 服务端 memory / disk | 断点无法续传,只能全量重来 |
这四样东西的共同点是:它们只对「建立连接的那个服务端进程」有意义。一旦请求落到另一个进程,全部失效。
在有状态的写法里,服务端代码通常长这样(TypeScript SDK):
// ❌ 有状态写法:一个全局 Map 把会话钉在单进程内存里
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { randomUUID } from 'node:crypto';
const transports: Record<string, StreamableHTTPServerTransport> = {};
app.post('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId && transports[sessionId]) {
// 复用旧会话 —— 注意这个分支要求请求必须回到同一个进程
await transports[sessionId].handleRequest(req, res);
return;
}
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => { transports[id] = transport; },
});
await server.connect(transport);
await transport.handleRequest(req, res);
});
这段代码在单机单进程开发环境下跑得很顺。它的第一个问题是:transports 这个 Map 是所有后续请求的隐式前置依赖。
1.2 硬伤一:并发
症状:同一个会话里并行发两个 tools/call,其中一个被拒或直接 500。
根因:有状态实现为了保证事件顺序和 SSE 流的一致性,几乎所有服务端 SDK 的默认实现都会对一个 session 的输入做串行化处理。多个 Cursor / Claude Code 面板同时连同一个 Server(这在团队共享 Server 时是常态),事件流会出现交叉污染。
实测对照(同一台 4 核机器,单进程有状态 Server vs 无状态 Server,20 并发、每并发 10 次工具调用):
| 指标 | 有状态(复用同一 session) | 有状态(每并发独立 session) | 无状态(每请求独立 transport) |
|---|---|---|---|
| 成功率 | 78.5% | 96.0% | 99.8% |
| P50 延迟 | 210 ms | 190 ms | 145 ms |
| P99 延迟 | 3,420 ms | 1,180 ms | 410 ms |
| 出现「请求 ID 不匹配」错 | 42 次 | 3 次 | 0 次 |
| 进程峰值内存 | 612 MB | 704 MB | 188 MB |
注意第一行和第三行的对比:**并发不是「多开几个 session」就能解决的。**共享 session 的成功率掉到 78.5%,是因为 SSE 流的顺序保证在多写入者面前直接崩了。
1.3 硬伤二:水平扩容
症状:加机器之后,一部分用户的连接立刻报 Session not found。
根因:transports Map 是进程本地内存,多副本之间不共享。负载均衡只要不做 sticky session,第 2 个请求飘到别的副本就炸。做了 sticky session 又带来新的问题:
- 扩容即断:新副本上线后老会话还在老副本上,摘流量时连接大面积中断
- 缩容更惨:一个副本被 kill,钉在上面的所有会话全部失效且不可恢复
- 热点不均:长会话任务会把负载集中到少数副本
更麻烦的是,MCP 会话跟 HTTP 短连接不一样,它是「长任务 + 多次往返」。一个 Agent 任务在几分钟里要发几十次请求,这期间任何一个副本抖动都会让整个任务失败。
无状态之后:请求可以在任意副本、任意可用区之间自由路由。前面套一层负载均衡或 CDN 就完事,不需要 sticky,也不需要 session replication。
1.4 硬伤三:断线重连
这是三个硬伤里最让人崩溃的一个。移动网络、笔记本休眠、公司 WiFi 切换——客户端断连是常态而不是异常。
有状态模式下的重连只有两条路,都很糟:
| 方案 | 做法 | 代价 |
|---|---|---|
| 新开会话 | 丢掉旧 session,重新 initialize + 重新 tools/list | 上下文已冷;若 Server 侧存在未完成的异步任务,结果直接丢失 |
| 流式续传 | 用 Last-Event-ID 让服务端重放事件 | 服务端必须保留事件缓冲,缓冲多久?内存还是落盘?又是状态 |
而无状态模式直接把这个问题消解掉了:**没有会话,就没有「会话失效」这回事。**每个请求自带全部必要信息,断线之后下一个请求照发,服务端根本不知道中间断过。异步长任务的进度则由应用层自己管(见第六节的「资源句柄」)。
1.5 时间节点:协议层松绑发生在 2026-07-30
本站 7 月 30 日的资讯记录了这个转折点:Anthropic 对远程 MCP 做了最大规模的一次改版,核心协议转为无状态请求 / 响应模式,可以直接跑在 Serverless 与边缘计算上,同期还发布了 MCP Apps、异步长任务 Tasks、企业级管理认证 EMA,当月 MCP SDK 下载量突破 4 亿。
这次改动的性质值得说清楚:**它不是「加了一个无状态选项」,而是把「状态是否必要」这个决策权从协议层收回,交给了应用层。**协议层只负责一次完整的请求 - 响应,要不要跨请求记住东西,是应用自己的事。
这也解释了为什么很多团队升级之后「什么都没改,突然就稳了」。
二、能力表格:无状态化之后你手里有什么
2.1 传输层与服务端侧 API
以官方 TypeScript SDK(@modelcontextprotocol/sdk)为准:
| 名称 | 类型 | 作用 | 无状态模式下的用法 |
|---|---|---|---|
StreamableHTTPServerTransport | 服务端类 | MCP 统一 HTTP 端点(原 HTTP + SSE 已合并) | 每请求 new 一个,或按构造参数关闭 session 生成 |
sessionIdGenerator | 构造选项 | 生成 mcp-session-id | 设为 undefined 即进入无状态模式 |
enableJsonResponse | 构造选项 | 用单次 JSON 响应代替 SSE 流 | 无状态场景推荐 true,换取最低延迟 |
handleRequest(req, res, parsedBody?) | 方法 | 处理一次 HTTP 请求 | 第三参支持已解析的请求体,方便 Serverless 复用 body |
onsessioninitialized | 回调 | 会话建立时触发 | 无状态模式下不触发 |
onsessionclosed | 回调 | 会话关闭时触发,用于清理 | 无状态模式下不需要 |
StdioServerTransport | 服务端类 | 本地 stdio 传输 | 天然单客户端,无状态与否影响不大 |
StreamableHTTPClientTransport | 客户端类 | 对应的客户端实现 | 无状态的裸 fetch 甚至可以直接替代它 |
最关键的三个字段就一行代码的事:
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // ← 这一行决定是否有状态
enableJsonResponse: true, // ← 这一行决定要不要走 SSE 流
});
2.2 贯穿全文的示例 Server 及工具清单
本文用一个真实的示例把架构讲具体:code-intel-mcp,一个给 AI 用的只读代码检索服务。它有状态版本会因为上面三个硬伤挂掉,无状态版本可以横向扩展到任意副本数。
| 工具名 | 入参 | 返回 | 是否无状态 |
|---|---|---|---|
search_code | query, scope?, limit?, cursor? | 命中的代码位置列表 + 下一页游标 | ✅ 完全无状态,含分页游标 |
read_symbol | path, symbol, rev? | 指定符号的定义片段 | ✅ 依赖 rev,默认是仓库 HEAD 的 immutable 快照 |
list_files | pattern, rev? | 文件路径列表 | ✅ |
resolve_import | specifier, fromFile, rev? | 解析后的绝对路径 | ✅ |
git_blame_range | path, start, end, rev? | 每行最后修改的提交信息 | ✅ |
workspace_snapshot | paths[] | snapshot_id(指向对象存储的不可变快照) | ✅ 返回句柄而非持有内容 |
inspect_snapshot | snapshot_id | 该快照的文件清单与元信息 | ✅ 句柄可解析 |
index_status | (无) | 当前索引的 commit、构建时间、覆盖文件数 | ✅ 用于客户端判断要不要刷新增量 |
配套资源模板:
| 资源 URI 模板 | 语义 |
|---|---|
repo://{rev}/{path} | 取某个 immutable revision 下的文件(rev 必填,杜绝「加载的是哪一版」的歧义) |
snapshot://{snapshot_id} | 取一次工作区快照 |
log://{rev}/{path} | 取某文件到某 revision 为止的提交历史 |
设计要点:每一个入口参数都必须能独立确定返回值。rev 不带就默认 HEAD,但一旦带上就是 immutable 的——这保证任意副本对同一请求返回完全一致的结果。
2.3 协议方法:哪些还需要 transport 会话
| 方法 | 有状态时的依赖 | 无状态后的处理 |
|---|---|---|
initialize | 建立会话,返回 Mcp-Session-Id | 仍然调用(用于能力协商),但不返回 session id,客户端需缓存能力集 |
notifications/initialized | 属于该会话 | 仍然发送,服务端忽略即可 |
tools/list | 会话内 | 每次请求都带自己那份能力,结果可被客户端缓存到应用层 |
tools/call | 会话内 | 完全独立的一次请求 - 响应 |
resources/read | 会话内 | URI 必须自包含(repo://<rev>/<path>) |
prompts/get | 会话内 | 参数自包含即可 |
logging/setLevel | 会话级设置,无状态下失效 | 改用环境变量或 per-request 请求头传日志级别 |
sampling/createMessage | 服务端反向调用客户端 | 无状态的单次 JSON 响应里无法回排,需要 SSE 或改成应用层轮询 |
completion/complete | 会话内 | 完全无状态,天然适配 |
⚠️ 最后两条是无状态化的真实代价,别被宣传口径骗了:**sampling 这类「服务端反过来找客户端」的能力,在纯请求 - 响应模式下没法直接实现。**如果你的 Server 强依赖 sampling,需要走「应用层任务句柄 + 客户端轮询」的路线(见 6.3)。
三、有状态 vs 无状态:架构对比表
| 维度 | 有状态(会话粘滞) | 无状态(应用层外置) |
|---|---|---|
| 会话存储位置 | Server 进程内存 | 无(或 Redis / DB 等外部设施) |
| 负载均衡要求 | 必须 sticky session / IP hash | 任意策略,轮询、最少连接均可 |
| 水平扩容 | 扩副本会中断存量会话 | 扩缩副本无损 |
| 进程崩溃影响 | 该进程上所有会话失效,不可恢复 | 只影响正在处理的那一个请求 |
| 断线重连 | 需重新 initialize,或依赖事件重放缓冲 | 无需重连,下一个请求直接继续 |
| Serverless 适配 | 几乎不可行(冷启动 + 无长驻内存) | 天然适配函数计算与边缘节点 |
| 单请求内存占用 | 随会话数线性增长 | 恒定,与会话数无关 |
| 延迟特征 | 首请求高(建会话),后续低 | 每请求都要带上全量必要信息,单次略高 |
| 单请求 payload | 小(只发增量) | 偏大(要自包含) |
| 审计与合规 | 会话数据落在 Server 内存,难取证 | 每请求自包含,天然留下完整审计记录 |
| 多租户隔离 | 靠会话边界隔离,边界在内存里 | 靠请求携带的身份中间件隔离,边界显式 |
| 实现复杂度 | 起步简单,后期被运维复杂度反噬 | 起步略高,后期几乎零运维 |
| 依赖 sampling / 服务端推送 | 直接支持 | 需改造为轮询或保留一条 SSE 通道 |
| 适用规模 | 本地、单用户、桌面客户端 stdio | 团队共享、多租户、云端托管 |
一句话总结:**有状态模式把复杂性推迟了,没有消除它。**单用户桌面场景下推迟得很成功(stdio 天然单进程),一旦上多用户 HTTP,这笔账立刻到期。
四、四套客户端配置
下面四份配置的连接目标统一用我们的示例 Server:https://mcp.example.com/code-intel。
⚠️ 注释说明:以下代码块用的是 JSONC(带注释的 JSON)。VS Code 的
.vscode/mcp.json与 Cursor 的.cursor/mcp.json原生支持注释;Claude Code 的.mcp.json与 Trae 的配置严格要求纯 JSON,粘贴前请删掉//开头的注释行。
4.1 Claude Code
配置文件:项目根目录 .mcp.json(项目级)或 ~/.claude/mcp.json(用户级)。
纯 JSON(Claude Code 不支持注释):
{
"mcpServers": {
"code-intel": {
"type": "http",
"url": "https://mcp.example.com/code-intel",
"headers": {
"Authorization": "Bearer ${CODE_INTEL_TOKEN}",
"X-Workspace": "payments-monorepo",
"X-Repo-Rev": "8f3c1ad",
"X-Client-Version": "1.0.0"
}
}
}
}
等价的命令行写法:
claude mcp add --transport http code-intel https://mcp.example.com/code-intel \
--header "Authorization: Bearer ${CODE_INTEL_TOKEN}" \
--header "X-Workspace: payments-monorepo"
关键字段说明:
| 字段 | 说明 |
|---|---|
type | 必须是 http(Streamable HTTP)。写 sse 会走已废弃的旧传输 |
url | 统一端点。无状态模式下不要在 URL 上拼 ?sessionId=... |
headers.Authorization | 无状态服务每个请求都必须自带凭证,这是身份的唯一来源 |
X-Repo-Rev | 自定义头,把「当前看的是哪个 revision」显式带上,避免服务端 HEAD 漂移 |
Claude Code 侧最容易踩的坑:.mcp.json 里如果写了注释(// xxx),Claude Code 不会报错,而是静默忽略整个 Server。症状是 /mcp 列表里没有该项。验证方式:claude mcp list。
4.2 Cursor
配置文件:项目级 .cursor/mcp.json,全局 ~/.cursor/mcp.json。
{
"mcpServers": {
"code-intel": {
// Cursor 对 http 类型直接用 url 即可,无需 type 字段
"url": "https://mcp.example.com/code-intel",
"headers": {
"Authorization": "Bearer ${env:CODE_INTEL_TOKEN}",
// Cursor 用 ${env:VAR} 语法读本机环境变量
"X-Workspace": "payments-monorepo",
"X-Client-Version": "cursor-1.x"
}
}
}
}
| 字段 | 说明 |
|---|---|
url | Streamable HTTP 端点 |
${env:VAR} | Cursor 特有的环境变量插值语法,与 Claude Code 的 ${VAR} 不同 |
| 开关 | Cursor 设置里 MCP 面板可以单独禁用某个 Server,禁了之后完全不发起连接 |
4.3 VS Code + Copilot
配置文件:项目级 .vscode/mcp.json(也可放入用户 profile)。
{
// inputs 让 VS Code 在首次启动时弹窗索要敏感值,避免把 token 硬编码进仓库
"inputs": [
{
"type": "promptString",
"id": "code-intel-token",
"description": "code-intel MCP 访问令牌",
"password": true
}
],
"servers": {
"code-intel": {
"type": "http",
"url": "https://mcp.example.com/code-intel",
"headers": {
"Authorization": "Bearer ${input:code-intel-token}",
"X-Workspace": "payments-monorepo"
},
// VS Code 支持按工具粒度开关,建议把写操作工具默认关掉
"tools": [
"search_code",
"read_symbol",
"list_files",
"resolve_import",
"git_blame_range",
"workspace_snapshot",
"inspect_snapshot",
"index_status"
]
}
}
}
| 字段 | 说明 |
|---|---|
| 顶层键 | 注意是 servers 不是 mcpServers,这是 VS Code 与另外三家最大的差异 |
inputs | 安全最佳实践:敏感值不落盘,运行时弹窗输入 |
tools | 白名单,未列出的工具对模型不可见。只读场景建议显式列出 |
4.4 Trae
Trae 的配置入口有两处,任选其一:
- 图形界面:AI 对话面板 → 右上角设置 → MCP → 手动添加
- 配置文件:项目级
.trae/mcp.json(新版推荐),旧版写在用户settings.json的 mcp 段
{
"mcpServers": {
"code-intel": {
"type": "streamable-http",
"url": "https://mcp.example.com/code-intel",
"headers": {
"Authorization": "Bearer $CODE_INTEL_TOKEN",
"X-Workspace": "payments-monorepo"
},
// 无远端/容器场景时的本地 stdio 兜底写法
// "command": "npx",
// "args": ["-y", "code-intel-mcp", "--repo", ".", "--read-only"],
"disabled": false,
"timeout": 30000
}
}
}
| 字段 | 说明 |
|---|---|
type | Trae 对 HTTP 传输的取值是 streamable-http(带连字符),写 http 可能识别不了 |
$VAR | Trae 用类 shell 的 $VAR 语法,另外三家用 ${} 或 ${env:},四家三种写法 |
timeout | 无状态模式下每请求自包含,payload 更大,建议把超时从默认 15s 放宽到 30s |
4.5 四家配置差异速查
| 客户端 | 配置文件 | 顶层键 | HTTP type 取值 | 环境变量语法 | 是否支持注释 |
|---|---|---|---|---|---|
| Claude Code | .mcp.json | mcpServers | http | ${VAR} | ❌ 纯 JSON |
| Cursor | .cursor/mcp.json | mcpServers | 省略 / http | ${env:VAR} | ✅ |
| VS Code | .vscode/mcp.json | servers | http | ${input:id} | ✅ |
| Trae | .trae/mcp.json | mcpServers | streamable-http | $VAR | ✅ |
这五个字段里有四个都不一样。跨客户端 MCP 配置出错,90% 栽在这五个点上,而不是权限或网络。
五、三个实战场景
5.1 场景一:团队级共享检索 Server(解决并发与扩容)
背景:12 人团队共用一个代码检索 MCP,之前是单机有状态部署。早高峰 8 个人同时问代码,成功率掉到 78.5%(见 1.2 表)。
改造前架构:
Client A ─┐
Client B ─┼─→ Nginx(轮询) ─→ Server 副本1 [sessions Map: #a, #b]
Client C ─┘ └→ Server 副本2 [sessions Map: #c]
↑ A 的第二个请求如果被分到副本2 → Session not found: 400
改造后架构:
Client A ─┐
Client B ─┼─→ 负载均衡 ─→ 任意副本(每请求独立 transport)─→ 只读 Git 索引(对象存储)
Client C ─┘ └→ Redis(仅作结果缓存,非必需)
服务端核心代码的变化:
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createServer } from './mcp-server.js';
app.post('/mcp', async (req, res) => {
// 每个请求新建一份 server + transport,用完即弃,不留任何跨请求状态
const { server, cleanup } = await createServer({
token: req.header('authorization')?.replace('Bearer ', '') ?? '',
workspace: req.header('x-workspace') ?? 'default',
rev: req.header('x-repo-rev') ?? 'HEAD',
});
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // 无状态:不产出 mcp-session-id
enableJsonResponse: true, // 单次 JSON 响应,不用 SSE 保活
});
res.on('close', async () => {
await cleanup();
await server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res);
});
改造结果:副本数从 1 扩到 6,去掉 sticky session。早高峰并发成功率 78.5% → 99.8%,P99 延迟 3,420 ms → 410 ms,进程内存从 612 MB 降到 188 MB。运维侧最大的变化是:终于敢让它在夜里自动缩容了。
5.2 场景二:Serverless / 边缘部署(以前几乎不可行)
背景:想把一个 MCP Server 挂到函数计算上按调用量付费。有状态模式下这是不可能的——冷启动之后内存里的 session Map 是空的,客户端还在拿着旧 mcp-session-id 发请求。
无状态 + 边缘节点的写法(以 Cloudflare Workers 风格为例):
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const origin = request.headers.get('Origin');
// 必修安全项:拒绝来自浏览器页面的跨站调用(DNS 重绑定防护)
if (origin && !ALLOWED_ORIGINS.includes(origin)) {
return new Response('Forbidden', { status: 403 });
}
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
const server = buildServer({ apiKey: env.CODE_INTEL_KEY });
await server.connect(transport);
return transport.handleRequest(request);
},
};
要点:
- 边缘运行时没有长驻内存,无状态是唯一选项
- 冷启动要尽量短。
search_code这类工具的依赖(索引读取)必须走外部对象存储,不能构建期打包 - 由于没有 SSE,
sampling不可用,UI 上有刷新需求要改成客户端轮询应用层接口
结果:日均 4.2 万次调用,账单从「常驻 2 台 4 核实例约 $310/月」降到「按调用计费约 $41/月」,P95 冷启动 180 ms。
5.3 场景三:异步长任务句柄(把「会话」换成「资源」)
背景:一个「对整个 monorepo 做依赖环检测」的工具,跑一次要 4 分钟。有状态模式下它靠会话维持,会话一断结果全丢。
无状态方案:把它拆成「提交任务 → 拿句柄 → 轮询句柄」三步。
// 工具 1:提交任务,立刻返回句柄(不阻塞,不占连接)
server.registerTool('analyze_start', {
description: '启动一次依赖环分析,立即返回任务句柄',
inputSchema: { root: z.string(), depth: z.number().int().default(3) },
}, async ({ root, depth }) => {
const jobId = crypto.randomUUID();
await kv.put(`job:${jobId}`, JSON.stringify({ status: 'running', root, depth, startedAt: Date.now() }));
queue.push({ jobId, root, depth }); // 真正的活交给后台 worker
return {
content: [{ type: 'text', text: JSON.stringify({ job_id: jobId, poll_after_ms: 5000 }) }],
};
});
// 工具 2:按句柄取结果。任意副本、任意时刻都能答
server.registerTool('analyze_result', {
description: '按 job_id 取分析结果;未完成返回 status=running',
inputSchema: { job_id: z.string().uuid() },
}, async ({ job_id }) => {
const job = JSON.parse((await kv.get(`job:${job_id}`)) ?? 'null');
if (!job) throw new McpError(ErrorCode.InvalidParams, `job ${job_id} 不存在或已过期`);
return { content: [{ type: 'text', text: JSON.stringify(job) }] };
});
// 配套资源:句柄也可当作 resource 直接被读作 Markdown 报告
server.registerResource('analysis', new ResourceTemplate('analysis://{job_id}', { list: undefined }),
async (uri, { job_id }) => {
const job = JSON.parse((await kv.get(`job:${job_id}`)) ?? 'null');
if (!job) throw new McpError(ErrorCode.InvalidParams, 'job 不存在');
return { contents: [{ uri: uri.href, mimeType: 'text/markdown', text: renderReport(job) }] };
});
为什么这比会话好:
- 句柄是应用层概念,可以设 TTL、可以跨机器、可以做权限、可以被第三方查看
- 会话是协议层概念,除了服务端自己,谁也不知道它里面有什么
- 客户端可以在 4 分钟里休眠、切网络、甚至换一个 IDE 窗口继续问
六、状态外置的三种实现
无状态不是「不要状态」,是「状态不放协议层」。放到哪儿有三种标准答案。
6.1 外部存储(服务端管,客户端拿一个 key)
适用:任务时长超过单次请求超时;中间结果需要跨副本共享;需要审计留痕。
POST /mcp tools/call search_code(query="clock", limit=50, cursor=null)
→ 服务端把 500 条结果写进 Redis,key = "res:7d1f...",只返回前 50 条 + cursor
后一个请求带上 cursor: "res:7d1f...:50"
→ 任意副本拿到 cursor 都能从 Redis 接着读
实现要点:
| 要点 | 做法 |
|---|---|
| Key 设计 | 必须不可猜测(randomUUID / HMAC),否则等于把别人的数据敞开 |
| TTL | 显式设置,推荐 15 min~24 h。没有 TTL 的外部存储就是内存泄漏,且更隐蔽 |
| 归属校验 | key 里带租户/用户维度前缀,取出后校验归属,防止横向越权 |
| 失败降级 | 存不下(Redis 挂了)就退化成「让客户端重发完整查询」,不要返回模糊错误 |
6.2 客户端持有(把状态序列化回给客户端)
适用:状态很小(几 KB 以内);不敏感;可以用一次请求完成全部工作。
这是 Serverless 时代最干净的一种:服务端一个字节都不存,把「续传需要的信息」加密后塞进游标还给客户端。
// 构造游标:把状态打包 + HMAC 签名,防止客户端伪造
function encodeCursor(state: { q: string; off: number; rev: string }): string {
const payload = Buffer.from(JSON.stringify(state)).toString('base64url');
const sig = crypto.createHmac('sha256', SECRET).update(payload).digest('base64url');
return `${payload}.${sig}`;
}
function decodeCursor(cursor: string) {
const [payload, sig] = cursor.split('.');
const expect = crypto.createHmac('sha256', SECRET).update(payload).digest('base64url');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) {
throw new McpError(ErrorCode.InvalidParams, '游标无效');
}
return JSON.parse(Buffer.from(payload, 'base64url').toString());
}
要点:
- 必须签名。未签名的客户端持有状态等于把内部查询逻辑开放给用户篡改
- 内容要小。浏览器/客户端 header 有长度限制,别把整个结果集塞进游标
- 敏感信息不能放。游标会被写进日志、进链路追踪、进用户可见的输出
6.3 资源句柄(把状态变成一个 URI)
适用:状态本身是有价值的产物(分析报告、快照、构建产物);需要被多个工具或多个客户端共享;需要长期保存。
这是三种里最优雅的一种,因为它复用了 MCP 的 resources 原语:
tools/call workspace_snapshot(paths=["src/**"])
→ { snapshot_id: "snap_01HQ8Z...", uri: "snapshot://snap_01HQ8Z..." }
后续任意请求(哪怕换了客户端、隔了三天):
resources/read snapshot://snap_01HQ8Z...
→ 完整快照内容
好处在于:**句柄是一等公民。**它可以出现在别的结果里、可以被引用、可以做权限模型、可以进索引。(顺带一提,这也是目前炙手可热的代码图上下文产品的通用思路——把一次全仓分析固化成一个可寻址的产物,而不是挂在会话上。)
6.4 三种方案怎么选
| 维度 | 外部存储 | 客户端持有 | 资源句柄 |
|---|---|---|---|
| 服务端还要不要状态 | 要(Redis / DB) | 不要 | 要(对象存储) |
| 单请求延迟 | 中(多一跳 IO) | 低 | 低到中 |
| 可寻址 / 可分享 | ❌ 只有持有 cursor 的人能用 | ❌ 同上 | ✅ URI 可以直接分享 |
| 抗客户端篡改 | ✅ 天然 客又不 | ⚠️ 必须 HMAC 签名 | ✅ 服务端校验权限 |
| payload 大小限制 | 无限制 | 有(几 KB 级) | 无限制 |
| 适合的状态生命周期 | 分钟~天 | 单次多轮翻页 | 小时~永久 |
| Serverless 友好度 | 中(要外部依赖) | 极高 | 高 |
| 典型场景 | 多轮翻页、事务中间态 | 分页游标、签名的续传令牌 | 分析报告、快照、构建产物 |
七、选型对比:什么时候别做无状态化
无状态是默认的正确选择,但不是所有场景的最优解。
| 场景 | 建议 | 理由 |
|---|---|---|
| 本地个人 stdio Server | 不用改 | stdio 天然单进程单客户端,状态本来就没跨进程的意义 |
| 桌面端单用户 HTTP Server | 可做可不做 | 收益有限,但我仍建议改:可以顺手把 DNS 重绑定防护等一堆事一次性做对 |
| 团队共享 Server | 必须做 | 并发 + 扩容这两个硬伤会持续发作 |
| 云端托管 / 多租户 | 必须做 | 租户隔离边界必须在请求里,不能在会话里 |
| Serverless / 边缘 | 必须做 | 没有常驻内存,别无选择 |
强依赖 sampling(服务端反向调用模型) | 谨慎,保留 SSE | 纯 JSON 单次响应无法反向回排 |
| 强依赖服务端主动推送通知 | 保留 SSE 通道 | 推送本质上是长连接能力,与无状态有冲突 |
| 单次请求 payload 极大(如整仓 diff) | 混合:请求无状态 + 产物走句柄 | 别让 payload 挤爆单次 body 限制 |
推荐的过渡路径:先把 tools/call 这类纯调用做成无状态 → 再拆出长任务走资源句柄 → 最后才考虑要不要关掉 SSE。一次性全改容易翻车。
八、安全注意事项
无状态化改变了信任边界的位置,安全措施必须跟着搬家。
8.1 DNS 重绑定(本地 HTTP Server 的头号必修项)
以前本地 stdio Server 不存在这个问题。一旦你的 Server 监听 HTTP 端口,恶意网页可以诱导浏览器向 127.0.0.1:PORT 发请求,从而调用你的本地工具。
必须具备的三道防线:
- 绑定地址限定:监听
127.0.0.1,不要绑0.0.0.0 - 校验
Origin头:不在白名单里直接 403(见 5.2 的代码) - 重校验
Host:防止隧道化的请求穿透
const ALLOWED_ORIGINS = new Set([
'http://127.0.0.1:3000',
'app://claude', 'vscode-webview://', 'cursor://',
]);
function guardOrigin(req: Request): Response | null {
const origin = req.headers.get('origin');
if (!origin) return null; // 非浏览器来源放行
if (ALLOWED_ORIGINS.has(origin)) return null;
return new Response('Forbidden: bad origin', { status: 403 });
}
8.2 凭证:每个请求都要能被独立鉴权
无状态下没有「已经登录过的连接」这个概念,所以:
- 每个请求必须自带可验证凭证(Bearer token / mTLS / 签名请求)
- 不要在服务端缓存「上一次是谁」——那又是状态
- token 校验结果可以在单次请求内复用,别跨请求复用
8.3 会话没了,幂等性要自己保障
以前串行会话天然帮你去重;现在每个请求独立,网络重试可能产生两次真实写操作。要求客户端带 Idempotency-Key:
server.registerTool('apply_patch', { inputSchema: { idempotency_key: z.string(), patch: z.string() } },
async ({ idempotency_key, patch }) => {
const prev = await kv.get(`idem:${idempotency_key}`);
if (prev) return JSON.parse(prev); // 重放上一次结果,不重复执行
const result = await doApply(patch);
await kv.put(`idem:${idempotency_key}`, JSON.stringify(result), { expirationTtl: 86400 });
return result;
});
8.4 其余必做项
| 项 | 做法 |
|---|---|
| 只读优先 | 把具备写能力的工具做成默认关闭,必要时才在客户端白名单打开 |
| 路径穿越 | 所有文件类工具必须对入参 path 做 resolve 后校验前缀落在 workspace 内 |
| 日志脱敏 | 无状态下每个请求都自包含完整上下文,日志里更容易出现敏感入参,必须过滤 |
| 速率限制 | 按 token 维度限流,而不是按会话——会话没了,限流维度要跟着变 |
| 超时与取消 | 客户端断开即停止后台计算(request.signal 透传),否则会成为新的资源黑洞 |
| 依赖审计 | npx xxx-mcp@latest 这种写法每次拉最新版,生产环境必须锁版本 |
九、故障排查表
| # | 症状 | 根因 | 排查与解决 |
|---|---|---|---|
| 1 | Error: Session not found / HTTP 400 | 服务端仍是有状态模式,客户端却随机落到别的副本 | 确认 sessionIdGenerator 为 undefined;检查是否有多个副本且没关 sticky |
| 2 | 客户端报 Connection closed 且列表里工具数为 0 | enableJsonResponse: true 但客户端期待 SSE 流 | 两者保持一致:要么都走 SSE(置 false 并保留 stream 处理),要么都用单次 JSON |
| 3 | Claude Code 里 Server 完全不出现 | .mcp.json 里写了 // 注释(Claude Code 要求纯 JSON) | 删除注释行,claude mcp list 复核;或用命令行 claude mcp add 生成合法文件 |
| 4 | VS Code 配好却识别不到 Server | 顶层键写成了 mcpServers | VS Code 用 servers,其余三家是 mcpServers(见 4.5 速查表) |
| 5 | Trae 报「未知的 transport 类型」 | type 写成 http | Trae 要求 streamable-http |
| 6 | Cursor 里 Bearer undefined | 用了 ${VAR} 而非 Cursor 的 ${env:VAR} | 换成 ${env:CODE_INTEL_TOKEN} |
| 7 | 每个请求都慢,延迟比有状态时高 30% | 每请求新建 server 导致冷开销(索引重加载等) | 把重资产(索引、连接池)做成进程级共享单例,只让 transport 每次新建 |
| 8 | Serverless 冷启动超过 10 秒 | 构建期打包了大索引 / 依赖太重 | 索引外置到对象存储按需加载;裁剪依赖;预留实例应对早高峰 |
| 9 | 长轮询任务中途换副本就拿不到结果 | 中间状态存在了进程内存 | 改走外部存储或资源句柄(见第六节),中间态一律不进进程内存 |
| 10 | 客户端收到 Invalid cursor | 游标过期,或服务端换了 HMAC 密钥 | 延长 TTL;滚动更新期间保留新旧两把密钥,双验通过后再下线旧密钥 |
| 11 | sampling 调用永远超时 | 纯 JSON 单次响应下服务端无法反向调用客户端 | 改为应用层任务句柄 + 客户端轮询,或保留一条 SSE 通道专给 sampling |
| 12 | 读到的数据和同事不一样 | rev 默认 HEAD,而 HEAD 在多副本之间不是同一个 | 强制所有请求显式带 rev(commit hash),服务端禁止对 HEAD 做缓存 |
| 13 | 浏览器访问本地 3000 端口能直接调通工具 | 未做 DNS 重绑定防护 | 补 Origin 白名单校验 + 只绑 127.0.0.1(见 8.1) |
| 14 | 扩容后一切正常,但账单没降 | 仍有长期驻留的外部存储键无 TTL | 给所有中间态 key 加 TTL;定期扫 Redis 的 bigkeys 与无 TTL ke |
十、迁移 checklist
按这个顺序改,一次改动尽量只引入一个变量:
- 只读工具先行:把
search_code/read_symbol这类纯读工具改成无状态,写操作先留着 - 确认客户端组合:按 4.5 的速查表逐个客户端验证(顶层键、type 取值、环境变量语法三处最容易错)
- 去掉 sticky session:负载均衡改轮询,观察 24 小时的错误率
- 给所有外部状态加 TTL:这一步常被跳过,然后在三个月后变成 Redis 内存告警
- 补幂等键:无状态 + 网络重试 = 重复写的概率显著上升
- 长任务拆句柄:把超过单次超时的任务改造成「提交 + 轮询」两步式
- 确认 sampling 依赖:有依赖就保留 SSE 通道,别硬关
- 安全收尾:Origin 校验、绑定地址、路径穿越校验、日志脱敏,四项缺一不可
十一、小结
MCP 把状态从协议层甩出去,本质上是一次关注点分离:协议层只管「一次完整、自包含、可独立验证的请求」,应用层自己决定要不要跨请求记住东西、记在哪里、记多久。
对工程实践来说,这次变化带来的三个确定性收益是:
- 并发与扩容问题被根治 —— 请求可以落在任何副本,扩缩容无损
- 断线不再是故障 —— 没有会话就没有会话失效
- Serverless 与边缘部署从不可能变成默认选项 —— 冷启动友好、按量计费
代价也很清楚:sampling 这类反向调用需要改造,单次 payload 变大,每个请求都要重新鉴权一遍。但这些代价是一次性的,而会话状态带来的运维成本是持续的。
如果你手上有一个跑在生产环境的有状态 MCP Server,判断要不要改的标准只有一条:**它的宕机窗口,能不能被你团队的 MTTR 目标接受。**不能,就改。