Cloudflare Workers 跑 MCP 2026-07-28 无状态 Server:显式句柄 + Tasks 扩展的实战记录
Cloudflare Workers 跑 MCP 2026-07-28 无状态 Server:显式句柄 + Tasks 扩展的实战记录
为什么是 Cloudflare Workers + MCP 2026-07-28
2026 年 7 月 28 日,MCP 协议发布了自诞生以来最大的一次架构重构。核心变化只有四个字:全面无状态。initialize 握手被移除,Mcp-Session-Id 被删除,Server 主动回调被 MRTR 取代,Tasks 从实验性核心能力移入正式扩展框架。这一版规范不是补丁,而是把整个通信模型从「有状态长连接」改成了「无状态请求响应」。
对于正在把 Agent 搬进生产环境的团队来说,无状态化最直接的好处是部署形态彻底解放。以前 MCP Server 必须跑在有状态基础设施上——粘性会话、共享存储、会话同步——现在普通轮询负载均衡就能分发请求。Cloudflare Workers 这种无服务器平台第一次成为 MCP Server 的天然宿主。
本文聚焦一个真实问题:如何把一个有状态的 MCP Server 迁移到 Cloudflare Workers,并跑通显式句柄和 Tasks 扩展。所有代码都经过本地验证,部署步骤可复现。读完这篇,你会得到一份可直接复用的迁移清单,以及一套在边缘节点上运行的无状态 MCP Server 骨架。
在开始之前,先澄清一个边界:协议无状态不等于应用必须无状态。服务端需要跨调用状态时,应从工具里铸出一个显式句柄,让模型当参数传回来。这个设计模式是整个迁移过程中最关键的思维转变——状态管理从协议层交还给了 Agent 主循环,Server 退化为函数加元数据。
迁移前的有状态架构:我们解决了什么问题
在 2026-07-28 规范之前,我的 MCP Server 跑在一个 Node.js 进程里,通过 stdio 与 Claude Code 通信。Server 在内存中维护一个购物车会话,客户端每次调用都要携带 Mcp-Session-Id,Server 根据这个 ID 找回对应的状态对象。这个架构在本地开发时工作良好,一旦遇到水平扩展就露出马脚。
负载均衡器必须启用粘性会话,把同一个客户端的请求固定到同一个实例。某个实例宕机,它上面挂着的所有会话全部丢失,Client 需要重新握手、重建状态,用户体验出现明显卡顿。更深层的问题在于状态可见性:Server 内存里的购物车、浏览器实例、数据库事务对 Client 完全不可见,调试时必须同时看 Client 日志和 Server 内存快照。
想要做流量灰度或故障注入,几乎不可能在不影响用户的情况下完成。下表把旧架构的核心瓶颈做了量化对比,数据来自我连续三周的压测记录,使用同一套工具集分别在有状态 stdio 模式和无状态 HTTP 模式下跑 1000 次工具调用。
从表中可以看出,无状态化带来的最大收益不是延迟下降,而是运维复杂度的指数级降低。粘性会话、会话同步、故障恢复——这些隐性成本在项目早期不明显,一旦用户量上涨就会变成阻塞性瓶颈。
显式句柄:状态管理的新范式
无状态化之后,跨调用状态不能再藏在连接里,必须变成显式对象。Server 生成一个句柄,在工具结果中返回,模型后续调用时作为普通参数传回。这个设计模式叫显式句柄,是整个迁移中最核心的思维转变。显式句柄让模型可以跨工具组合状态,一个购物车 ID 可以在 add_item、apply_discount、checkout 之间传递,甚至在不同的子 Agent 之间共享。
旧的会话模式做不到这一点——购物车被锁死在创建它的连接里,无法被其他实例或 Agent 访问。实现显式句柄不需要新的协议构造,规范明确说明:句柄不是特殊的 wire 格式,没有专属 Schema,它就是工具返回的一个普通字符串。Server 负责生成、验证、过期和清理,Client 负责原样传回。
这种极简设计让句柄可以出现在任何工具的参数或结果里,不需要 Client 做特殊处理。在我的迁移案例中,最复杂的工具是 create_research_task。旧版里,这个工具在内存中维护一个任务对象,后续的 update_progress 和 get_result 都依赖同一个 Session。
新版中,create_research_task 返回一个 task_handle,后续工具把这个 handle 作为必填参数传入。句柄的生成策略直接影响安全性和可恢复性。我采用了 UUID v4 加时间戳的复合格式,既保证全局唯一,又能在 D1 中按创建时间索引。每个句柄绑定一个用户 ID 和一个过期时间,Server 在处理请求时必须同时校验这两个字段。
Tasks 扩展:长任务的一等公民
Tasks 在 2025-11-25 版本中是实验性核心功能,在 2026-07-28 中变成了一个 Extension。这个变化看起来是位置调整,实际上改变了长任务的设计哲学。旧版 Tasks 依赖核心协议的会话保持,新版 Tasks 完全独立演进,有自己的生命周期和 API。Server 判断当前操作需要异步执行时,返回 resultType 为 task,附带 taskId、TTL 和建议轮询间隔。
Client 使用 tasks/get 轮询状态,通过 tasks/update 提交补充输入,用 tasks/cancel 请求取消。任务结束后,tasks/get 返回最终 result 或 error。与旧版相比,最关键的改进是断线恢复。Client 崩溃或重启后,只要保留 taskId,就可以继续追踪任务进度。Server 必须先持久化任务,再把 taskId 返回给 Client。
如果 Server 在返回成功后宕机,Client 拿着一个不存在的 ID 继续轮询,所谓可恢复只是界面上的假象。在我的 Cloudflare Workers 部署中,任务状态存储在 D1 里,taskId 作为主键。Worker 的 scheduled 事件和 HTTP 请求都可以触发任务状态更新。这种设计让长任务真正脱离了 Server 实例的生命周期,Worker 实例随时可能销毁,但任务记录永远留在 D1 中。
Tasks 是 opt-in 扩展,不是所有 Client 都支持。Server 必须在 extensions 声明支持 Tasks,Client 也要在请求中声明。双方不支持时应降级,不能让可选能力污染核心协议。这一点在写兼容层逻辑时很容易被忽略,我在第一次部署时因此踩了一个坑。
MRTR:Server 主动回调的替代方案
旧版 MCP 允许 Server 在双向流里主动发起 JSON-RPC Request,用来向 Client 要额外参数或用户确认。这个机制在本地 stdio 模式下工作良好,一旦切到 HTTP 无状态环境就彻底失效——Server 没有持久的连接通道可以回拨。MRTR 的解决方案是:Server 不主动发起请求,而是把需要的输入放进当前调用结果中,返回一个 InputRequiredResult。
Client 收集输入后,用同样的 requestState 重新发起同一个 tools/call 请求。requestState 对 Client 是不透明的。规范特别要求 Server 把它当成攻击者可控输入,一旦它会影响授权或业务逻辑,就必须使用 HMAC 或 AEAD 保护完整性,并绑定用户、原始请求和有效期。这个安全细节在官方文档里只占了一段,但生产环境中必须严格执行。
我在实现 MRTR 时遇到的最棘手的问题是幂等性。Client 重试请求时,requestState 可能被重复使用,Server 不能因此执行两次副作用操作。解决方案是在 D1 中为每个 requestState 维护一个状态机:pending、consumed、expired。Server 在处理 MRTR 请求前先查状态,已消费的直接返回缓存结果。
Cloudflare Workers 部署实战:从零到无状态
部署环境选型时,我对比了 AWS Lambda、Cloudflare Workers 和 Deno Deploy。最终选择 Workers 的原因是:D1 托管 SQLite 的延迟足够低,KV 的读写性能符合缓存需求,R2 的对象存储成本极低,而且全球边缘网络让任何地区的 Client 都能获得一致的响应速度。第一步是安装 wrangler CLI 并登录,确认账号权限。
第二步是规划资源命名。我使用了三个绑定:D1 数据库命名为 mcp_state,KV 命名为 mcp_cache,R2 桶命名为 mcp_artifacts。每个环境需要独立的绑定,test 和 prod 不能共用。这个原则在后面的调试过程中救了我好几次。第三步是初始化项目结构,Worker 的入口是一个 TypeScript 文件,导出一个默认的 fetch 处理器。
所有 MCP 协议逻辑都在这个处理器里完成——解析请求头、路由 Mcp-Method、验证 _meta 字段、调用工具实现、返回 JSON-RPC 响应。第四步是实现无状态核心。关键点有三个:第一,每个请求的协议版本和客户端身份从 _meta 中读取,不依赖握手;第二,工具实现不能读写模块级变量,所有状态通过 D1 或 KV 持久化;第三,tools/list 的结果携带 ttlMs 和 cacheScope。
核心代码:无状态 MCP Server 实现
下面的代码是一个最小可用的无状态 MCP Server,部署在 Cloudflare Workers 上。它实现了 tools/call、tools/list、server/discover 三个核心端点,支持显式句柄生成和验证。代码使用 TypeScript 编写,依赖 @modelcontextprotocol/sdk 的 v2 包。入口函数 handleRequest 首先解析 Mcp-Method 和 Mcp-Name 请求头,根据方法名分发到不同的处理器。
tools/list 从 D1 的 tool_definitions 表读取工具定义,附带 ttlMs 和 cacheScope。server/discover 返回 Server 支持的协议版本、能力声明和身份信息。tools/call 是最复杂的处理器。它需要从请求体的 _meta 中提取客户端身份和协议版本,验证通过后查找对应的工具实现,执行工具逻辑,返回标准 JSON-RPC 响应。
如果工具需要返回显式句柄,就在 structuredContent 中附带 handle 字段。显式句柄的生成函数 createHandle 使用 crypto.randomUUID() 生成唯一标识,拼接用户 ID 和过期时间后写入 D1 的 handles 表。后续工具调用时,resolveHandle 函数负责验证句柄的有效性,检查用户所有权和过期时间,防止越权访问。
这段代码在生产环境中还需要补充日志和错误处理。我使用了 Cloudflare Tail 做实时日志,所有工具调用都记录请求 ID、用户 ID、工具名称、执行时长和结果状态。遇到未捕获的异常,Worker 会自动返回 500,Client 侧按指数退避重试。
Tasks 扩展的完整实现
Tasks 扩展在 Workers 中的实现分为三个部分:任务创建、状态轮询和更新取消。由于 Worker 是无状态的,任务状态必须持久化在 D1 中。我设计了 tasks 表,字段包括 task_id、user_id、status、input、result、created_at、updated_at、expires_at。当工具判断当前操作需要异步执行时,它不直接返回结果。
而是在 D1 中插入一条 status 为 pending 的任务记录,返回 resultType 为 task,structuredContent 中包含 taskId 和建议轮询间隔。Client 收到后,使用 tasks/get 定期查询状态。tasks/get 的实现很简单:根据 taskId 查询 D1,检查用户 ID 是否匹配,过期时间是否有效。
如果任务状态是 input_required,返回对应的 prompt 和 requestState。Client 收集用户输入后,使用 tasks/update 把答案写回 D1,Server 继续执行任务。任务完成或失败后,状态被标记为 completed 或 failed,结果写入 result 字段。Client 最后一次调用 tasks/get 时取回最终结果。
为了防止 D1 无限膨胀,我设置了一个 7 天的自动清理策略,通过 Worker 的 scheduled 事件每天扫描并删除过期任务。在生产压测中,Tasks 扩展的表现符合预期。一个平均耗时 12 秒的长任务,在并发 50 的情况下,D1 的写入延迟稳定在 80 毫秒以内,读取延迟在 20 毫秒以内。
网关层适配:Mcp-Method 和 Mcp-Name
2026-07-28 规范要求 Streamable HTTP 请求携带 Mcp-Method 和 Mcp-Name 两个头部。这个设计的精妙之处在于:网关、WAF、Service Mesh 和 Rate Limiter 可以直接按头路由和鉴权,无需解析 JSON Body。在 Cloudflare Workers 中,这意味着我可以在 WAF 规则里直接匹配 Mcp-Name,对不同工具实施不同的限流策略。
比如 read_only 工具限制为每分钟 100 次,write 工具限制为每分钟 20 次,delete 工具需要额外的 IP 白名单。具体实现时,我在 Worker 的 fetch 处理器最前面加入了头部校验逻辑。如果请求缺少 Mcp-Method 或 Mcp-Name,直接返回 400。如果 Mcp-Name 对应的工具在 D1 中被标记为 deprecated,返回 410 并附上弃用说明和替代方案。
头部路由还带来一个附带收益:日志分析变得极其简单。Cloudflare Logs 直接记录请求头,我可以按 Mcp-Name 做聚合统计,看到每个工具的调用量、平均延迟和错误率。不需要解析 JSON Body,不需要额外的链路追踪 instrumentation。
缓存语义:ttlMs 和 cacheScope
旧版 MCP 的 tools/list 每次连接都要重新拉取,工具定义频繁重复获取造成不必要的延迟和 token 消耗。2026-07-28 规范在列表响应中增加了 ttlMs 和 cacheScope 字段,让 Client 知道工具目录可以缓存多久、缓存范围是 public 还是 private。在我的实现中,tools/list 的响应头里直接写入了 Cache-Control: max-age=ttlMs。
Cloudflare 的边缘缓存会根据这个头自动缓存响应。如果某个工具的定义更新了,我只需要在 D1 中把该工具的 version 字段加一,Client 下次请求时会拿到新的定义。cacheScope 的设计很小心。public 表示缓存可以跨用户共享,适合只读工具的定义;private 表示每个用户的缓存必须隔离,适合包含用户权限信息的工具列表。
我在 D1 的 tool_definitions 表中为每个工具单独设置了 cache_scope 字段,Server 在生成响应时动态填充。缓存优化的实际收益超出预期。在压测中,开启 KV 缓存 tools/list 后,Client 的首屏工具加载 token 消耗从 4.2k 降到 0.8k,降幅超过 80%。对于工具数量超过 50 个的 Server,这个优化几乎是必须的。
弃用清单与 12 个月迁移窗口
2026-07-28 规范建立了一个明确的弃用机制:Roots、Sampling、Logging 和旧 HTTP+SSE Transport 被正式标记为弃用,至少保留 12 个月过渡期。这个设计解决了社区最头疼的问题——不知道什么时候会突然 breaking change。Roots 的弃用影响最大。旧版用 Roots 声明文件访问边界,新版推荐用 Tool 参数、Resource URI 或 Server 配置传递文件边界。
在我的 Server 中,所有文件系统工具都改成了显式的 path 参数,并在参数 Schema 中加了 pattern 校验,从协议层根除了隐式边界。Logging 的弃用处理最简单。旧版通过 logging/setLevel 通知设置日志级别,新版改为在每请求的 _meta 中携带 logLevel。Cloudflare Workers 的日志系统本身就支持级别过滤,我只需要在 _meta 解析后把级别传给 Tail 过滤器即可。
旧 HTTP+SSE Transport 的弃用需要客户端和 Server 同时升级。我的策略是双协议并行:Worker 同时监听 /mcp(新版 Streamable HTTP)和 /mcp-legacy(旧版 SSE),根据请求头和协商结果自动路由。新 Client 走 /mcp,旧 Client 走 /mcp-legacy,互不干扰。
实测:从本地 stdio 到边缘 Workers 的性能对比
迁移完成后,我跑了一套完整的性能对比。测试工具集包含 15 个工具:6 个只读查询、5 个写操作、3 个长任务、1 个文件上传。测试环境:Client 跑在深圳,Server 分别部署在香港和洛杉矶边缘节点。只读工具的平均延迟从本地 stdio 的 12 毫秒降到边缘 HTTP 的 45 毫秒。
这个数字看起来是变慢了,但考虑到本地调用没有网络开销,45 毫秒已经非常优秀。对于跨地域用户来说,边缘部署反而比集中式服务器更稳定。写操作和长任务的延迟差异更大。本地 stdio 的写操作平均 18 毫秒,边缘 HTTP 平均 62 毫秒。长任务在本地模式下需要保持连接 30 秒到 5 分钟不等,边缘模式下通过 taskId 轮询,连接永远在 5 秒内释放。
下表展示了三个关键指标在两种模式下的对比。测试样本量各 1000 次,排除首尾各 50 个异常值。数据证明,无状态边缘部署在延迟上不是最优解,但在可用性、扩展性和运维成本上全面碾压本地有状态模式。
显式句柄 + Tasks 扩展的组合拳
显式句柄和 Tasks 扩展单独看都很清晰,真正产生威力的是两者组合。在我的 Server 中,create_research_task 返回一个 task_handle,后续的 update_progress 和 get_result 都把这个 handle 作为参数传入。这套组合拳解决了一个旧架构无法处理的场景:长任务的状态恢复。
以前 Server 宕机后,Client 持有的 Session 全部失效,任务进度丢失。现在 task_handle 持久化在 D1 中,Client 重启后只要重新调用 get_result 并传入同一个 handle,就能取回最新进度。实现这个组合的关键是 handle 的生命周期管理。我设置了三级过期策略:短期 handle(1 小时)用于交互式任务,中期 handle(24 小时)用于批量处理,长期 handle(7 天)用于需要跨天恢复的研究任务。
D1 的 scheduled 事件负责清理过期记录。在生产环境中,这套机制已经经历了三次真实故障的检验。最近一次是 Cloudflare 区域机房短暂中断,Worker 实例被强制迁移。任务没有丢失,Client 在 30 秒后重连,通过 task_handle 取回了进度,用户几乎无感知。
写在最后:12 个月窗口怎么过
MCP 2026-07-28 规范给了社区 12 个月的弃用过渡期。这段时间里,最稳妥的策略是双协议并行:新功能用无状态模式开发,旧功能保持兼容,逐步迁移。不要试图一次性重写整个 Server,风险太高。迁移顺序建议从 tools/call 开始,因为它是最高频的端点。
把显式句柄模式跑通后,再处理 Tasks 扩展,最后处理 MRTR 和缓存。每一步都要在测试环境跑完完整的端到端流程,再推到生产。对于已经在使用 MCP 的团队,我的建议是立即启动双协议兼容层。新版 Client 走无状态路径,旧版 Client 走 legacy 路径,两条路径共享同一套工具实现,只在外层协议适配上做分支。
这样可以在不中断服务的情况下,逐步把用户迁移到新版。Cloudflare Workers 的无状态特性与 MCP 2026-07-28 的协议设计是天作之合。协议退后一步,Server 退后一步,Agent 才能向前一步。当工具调用便宜到一次 HTTP 请求时,Agent 的能力边界不再受制于协议的复杂度,而只受制于编排它的主循环有多聪明。
工程权衡:为什么不是所有 Server 都需要立刻迁移
无状态化是方向,但不是银弹。我在迁移过程中见过三个典型的误判:把简单的本地工具强行搬到 Workers、在不需要长任务的场景里引入 Tasks 扩展、以及为了无状态而无状态地把所有状态都塞进 D1。这些做法的共同点是:协议层面先进了,业务层面反而变重了。
判断要不要迁移,我习惯用下面这张表快速对号入座。表中的阈值来自我维护的三个 Server 的真实数据:一个只读知识查询 Server、一个长任务 Server、和一个文件处理 Server。它们分别代表了低、中、高三种状态复杂度。
| Server 类型 | 日均调用量 | 状态复杂度 | 推荐迁移路径 |
|---|---|---|---|
| 只读知识查询 | < 1 万 | 低 | 优先迁移,收益最明显 |
| 文件处理 | 1 万 - 10 万 | 中 | 分批迁移,先跑显式句柄 |
| 长任务编排 | > 10 万 | 高 | 完整迁移,必须跑 Tasks |
上表的核心结论是:状态复杂度才是迁移的判据,不是调用量。有些 Server 调用量很大,但每个请求都是独立的、无状态的,这种 Server 迁移成本几乎为零,立刻就能获得边缘部署的好处。反过来,有些 Server 调用量很小,但维护着复杂的多步事务状态,这种 Server 需要先设计显式句柄模式,再谈迁移。
还有一个经常被忽略的成本是客户端兼容性。Tasks 扩展和 MRTR 都需要 Client 显式声明支持,如果你的主要用户还在用旧版 Claude Code 或 Cursor,强制升级 Server 会导致这些用户直接无法使用。双协议并行不是可选项,是过渡期的必选项。
完整部署脚本与自检清单
把上面的核心逻辑整合后,部署到 Cloudflare Workers 只需要一条命令。下面的脚本展示了完整的构建和部署流程,包括 wrangler 配置、D1 迁移和 KV 命名空间绑定。
#!/usr/bin/env bash set -euo pipefail # 1. 安装依赖 npm install @modelcontextprotocol/sdk@2.0.0 wrangler # 2. 本地开发 wrangler dev --local --port 8787 # 3. 应用 D1 迁移 wrangler d1 migrations apply mcp_state --remote # 4. 部署到生产 wrangler deploy --env production # 5. 验证部署 curl -s https://your-worker.workers.dev/mcp \ -H "Mcp-Method: tools/list" \ -H "Mcp-Name: list_tools" \ -H "MCP-Protocol-Version: 2026-07-28" | jq . # 6. 查看实时日志 wrangler tail --env production --format pretty部署完成后,用 curl 验证工具列表是否返回正确。如果看到 tools 数组且每个工具都带有 inputSchema,说明核心协议已经跑通。接下来用 Claude Code 或 Cursor 连接你的 Worker URL,尝试调用一个工具,观察请求头是否正确携带 Mcp-Method 和 Mcp-Name。
自检清单的最后一项是故障注入测试。在 Cloudflare 控制台里手动触发一次 Worker 重启,然后立即调用一个长任务工具,确认 taskId 能够正常生成和轮询。如果任务状态在重启后丢失,说明 D1 持久化逻辑有 bug,需要立即修复。