新版 MCP spec 移除会话层和握手协议,Server 变为纯无状态 HTTP 服务,支持轮询负载均衡和自动扩缩容,附 Cloudflare Workers 免费部署教程。
MCP 刚刚发布了自上线以来最重要的版本。
7 月 28 日,维护者发布了 2026-07-28 规范,这次更新从相当基础的层面改变了 MCP 服务器的工作方式。握手机制没了。会话机制没了。三个长期存在的功能被废弃了。

维护者自己称这是自引入授权以来最重大的变更。他们是这么说的,不是我。
听起来挺吓人的。但实际上这让 MCP 服务器的部署变得简单多了。我来这儿是干嘛的?我是来帮你构建和部署一个 MCP 服务器的。
你的 MCP 服务器现在就是一个普通的无状态 HTTP 服务。轮询负载均衡、自动扩缩容和缓存都能用了。不需要粘性会话或共享会话状态了。
在本指南中,我们将基于新规范构建一个小型 MCP 服务器,连接一个客户端到它上面,实际运行每个主要功能,然后将其部署到 Cloudflare Workers 上。免费的。
ℹ️ 这里的所有代码都使用了随该规范一起发布的新版 TypeScript SDK v2。如果你还在用旧的 @modelcontextprotocol/sdk 包,那个现在是 v1 了。
2026-07-28 规范到底改了些什么(简述)
用新版 SDK v2 构建 MCP 服务器
无状态核心实战
MRTR:工具如何在不占用流的情况下请求用户确认
对尚不支持 MRTR 的客户端的优雅降级(这样的客户端很多)
带 ttlMs 的可缓存工具列表
用客户端和原始 curl 测试
部署到免费套餐的 Cloudflare Workers 上
新版 MCP 规范(2026-07-28)的变更

快速概览新内容。如果你想要完整的变更日志,它在官方规范网站上。
握手机制没了
initialize / initialized 交换和 Mcp-Session-Id header 正式退休。
每个请求现在都是自描述的。它在 _meta 中携带自己的协议版本、客户端身份和能力。任何请求都可以落到负载均衡器后面的任意服务器实例上。终于解脱了!!
如果客户端想要提前获取能力,有一个可选的 server/discover RPC。但它是可选的。一个裸的 POST 就是一次完整的对话了。
多轮请求(MRTR)
这是我的最爱。
以前,如果一个工具在调用过程中需要用户提供某些东西,比如确认或缺失的参数,服务器不得不通过一个保持开放的流推送一个 elicitation/create 请求回去。这意味着你需要保持一个开放的流,这对无状态部署来说很糟糕。
MRTR 颠覆了这一点。服务器返回 resultType: "input_required" 以及它需要回答的问题,然后关闭连接。客户端收集答案后重试原始调用,并附上这些答案,再加上一个不透明的 requestState token,以便服务器知道它在哪里暂停。
不需要开放的流。不需要会话。在完全无状态的基础设施上实现交互式工具。
请求现在携带 Mcp-Method 和 Mcp-Name HTTP header。你的网关、限流器或 WAF 可以根据 header 而非解析 JSON body 来路由和计量请求。
可缓存的列表结果
tools/list、prompts/list、resources/list 和 resources/read 的响应现在携带 ttlMs 和 cacheScope 字段,参照 HTTP 的 Cache-Control 建模。客户端会缓存你的工具目录,而不是每次连接都重新获取。
扩展框架 + 废弃通知
Tasks 从实验性核心移出,成为一个正式扩展(io.modelcontextprotocol/tasks)。MCP Apps 和 Enterprise Managed Authorization 也在那里。你也可以构建自己的扩展。
以及废弃通知:
Roots、Sampling 和 Logging 已废弃。它们至少还能工作 12 个月,但新实现不应该使用它们。
旧的 HTTP+SSE 传输被废弃,给予一年的缓冲期。
动态客户端注册被废弃,取而代之的是客户端 ID 元数据文档(CIMD)。
现在也有正式的废弃政策:任何被标记为废弃的内容至少有 12 个月的窗口期。所以你有机会规划升级,这很棒!
在我们开始构建之前还有一件事:TypeScript SDK 不再是一个包了。
v2 将其拆分为 @modelcontextprotocol/server、@modelcontextprotocol/client,以及轻量级框架适配器(@modelcontextprotocol/hono、express、fastify、node)。
构建 MCP 服务器
终于到了构建环节。我们将通过 MCP 构建一个快速的小型部署机器人。
deploy 在部署前请求用户确认(MRTR 实战)。
list_deployments 读取部署历史记录
server_stats 证明每个请求都是由一个新的服务器实例处理的
这里有个在部署时会得到回报的技巧:所有 MCP 逻辑都存在于一个平台无关的文件中(bot.ts),每个平台获得一个小型入口文件。Node 用 server.ts。Cloudflare 用 worker.ts。两者都大约十行代码。新规范上的 MCP 服务器就是一个 fetch handler;平台只是一个服务垫片。
你会在过程中理解一切。
步骤 1:安装 SDK v2
运行以下命令:
mkdir updated-mcp-spec-bot && cd updated-mcp-spec-bot
npm init -y && npm pkg set type=module
npm install @modelcontextprotocol/server @modelcontextprotocol/client \
@modelcontextprotocol/hono @hono/node-server hono zod tsx
ℹ️ 在 TypeScript 6+ 上,安装 @types/node 后,在 tsconfig 的 compilerOptions 中添加 "types": ["node"]。TS 6 不再自动包含 @types/*,没有它你会遇到 Cannot find name 'process' 错误。别问我怎么知道的。😴
步骤 2:服务器逻辑
创建 bot.ts。这是整个 MCP 服务器,里面没有任何平台代码:
// 👇 bot.ts
import type {
CallToolResult,
InputRequiredResult,
} from "@modelcontextprotocol/server";
import {
acceptedContent,
CLIENT_CAPABILITIES_META_KEY,
createRequestStateCodec,
inputRequired,
McpServer,
} from "@modelcontextprotocol/server";
import * as z from "zod/v4";
const deployments: { env: string; at: string }[] = [];
let requestsServed = 0;
type DeployState = { step: "confirm"; env: string };
// 在生产环境设置 STATE_KEY 以便所有实例共享密钥
// 延迟初始化:Workers 禁止在模块作用域生成随机值
let codec: ReturnType<typeof createRequestStateCodec<DeployState>> | undefined;
function stateCodec() {
if (!codec) {
const key = globalThis.process?.env?.STATE_KEY;
codec = createRequestStateCodec<DeployState>({
key: key
? new TextEncoder().encode(key)
: crypto.getRandomValues(new Uint8Array(32)),
ttlSeconds: 600,
});
}
return codec;
}
const CONFIRM_SCHEMA = {
type: "object" as const,
properties: { confirm: { type: "boolean" as const } },
required: ["confirm"],
};
// 每个请求都运行,保持轻量
export function buildServer(): McpServer {
requestsServed++;
const server = new McpServer(
{ name: "updated-mcp-spec-bot", version: "1.0.0" },
{
cacheHints: {
"tools/list": { ttlMs: 30_000, cacheScope: "public" },
},
requestState: { verify: (...a) => stateCodec().verify(...a) },
},
);
server.registerTool(
"list_deployments",
{
title: "List deployments",
description: "List all deployments recorded by this server.",
},
async (): Promise<CallToolResult> => ({
content: [
{
type: "text",
text: deployments.length
? deployments.map((d) => `${d.env} @ ${d.at}`).join("\n")
: "No deployments yet.",
},
],
}),
);
server.registerTool(
"server_stats",
{
title: "Server stats",
description:
"How many requests this process served, each on a fresh server instance.",
},
async (): Promise<CallToolResult> => ({
content: [
{
type: "text",
text: `pid=${globalThis.process?.pid ?? "edge"} requestsServed=${requestsServed}`,
},
],
}),
);
server.registerTool(
"deploy",
{
title: "Deploy",
description:
"Deploy to an environment. Requires confirmation: interactive clients get a prompt, others must pass confirm: true.",
inputSchema: z.object({
env: z.enum(["staging", "prod"]).describe("Target environment"),
confirm: z
.boolean()
.optional()
.describe("Set true to confirm, only after asking the user"),
}),
},
async (
{ env, confirm },
ctx,
): Promise<CallToolResult | InputRequiredResult> => {
const caps =
(ctx.mcpReq.envelope as Record<string, unknown> | undefined)?.[
CLIENT_CAPABILITIES_META_KEY
] ?? server.server.getClientCapabilities();
const canElicit = Boolean(
(caps as { elicitation?: unknown } | undefined)?.elicitation,
);
```typescript
if (canElicit) {
const state = ctx.mcpReq.requestState<DeployState>();
const confirmed = acceptedContent<{ confirm: boolean }>(
ctx.mcpReq.inputResponses,
"confirm",
);
if (!state || !confirmed?.confirm) {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: `Deploy to ${env}? This will go live.`,
requestedSchema: CONFIRM_SCHEMA,
}),
},
requestState: await stateCodec().mint({ step: "confirm", env }),
});
}
const record = { env: state.env, at: new Date().toISOString() };
deployments.push(record);
return {
content: [
{ type: "text", text: `Deployed to ${record.env} at ${record.a
这里有几个点值得解释:
buildServer() 在每一次请求时运行。不是启动时运行一次,而是每次请求都会获得一个全新的 McpServer 实例。
如果你觉得这出乎意料,我理解。我当初也很惊讶。但这恰恰就是 SDK 官方示例中的标准模式,而这正是这次更新的核心所在。
构造过程只是对象创建和 handler 映射,耗时以微秒计。现在不再有协议状态需要保留,所以也就没有什么需要保活的东西。
每个请求一个服务器实例,每个进程一份资源。应用状态(我们的 deployments 数组、状态编解码器、真实场景中的数据库连接池)都放在模块级别。服务器实例是一次性的。
deploy 工具从不阻塞。当它需要确认时,它返回 inputRequired(...),然后请求就结束了。完毕。连接关闭。唯一跨轮次存活下来的是 requestState token,它在客户端和服务器之间往返。
这意味着客户端可能篡改它。所以我们用 createRequestStateCodec 对它进行密封,这样即使状态被篡改或过期,也会在我们的 handler 运行之前就被拒绝,并报出 wire 级别的错误。
注意这个编解码器是在首次使用时惰性创建的,而不是在模块级别。这在 Node 上看起来像是一层无意义的间接寻址。但其实不是。Cloudflare Workers 禁止在全局作用域生成随机数,而正是这行代码让同一个文件能同时在两个平台上运行。同样的逻辑也适用于 globalThis.process?. 这些守卫:Workers 默认没有 process 全局对象。
所以工具从每个请求的信封中读取客户端声明的能力(就是那个 CLIENT_CAPABILITIES_META_KEY 查找,有遗留连接的降级方案),然后判断:
confirm: true 参数,没有这个参数时就返回一条简单指令:"请询问用户,然后再带 confirm: true 调用 deploy"创建 server.ts。这是所有 Node 相关的代码:
// 👇 server.ts
import { serve } from "@hono/node-server";
import { createMcpHonoApp } from "@modelcontextprotocol/hono";
import { createMcpHandler } from "@modelcontextprotocol/server";
import { buildServer } from "./bot.js";
const handler = createMcpHandler(buildServer);
// 在生产环境将 ALLOWED_HOSTS 设置为你的公开域名
const allowedHosts = process.env.ALLOWED_HOSTS?.split(",").map((h) => h.trim());
const app = createMcpHonoApp(allowedHosts ? { allowedHosts } : {});
app.get("/healthz", (c) => c.text("ok"));
app.all("/mcp", (c) => handler.fetch(c.req.raw));
const port = Number(process.env.PORT ?? 3000);
const hostname = process.env.HOST ?? "127.0.0.1";
serve({ fetch: app.fetch, port, hostname }, () => {
console.error(`updated-mcp-spec-bot listening on <http://$>{hostname}:${port}/mcp`);
});
就这样。createMcpHandler 给你一个标准的 fetch 风格 handler,Hono 只是做路由。createMcpHonoApp() 会验证 Host/Origin 请求头(DNS 重绑定保护),开箱即用只允许 localhost,所以当你把服务放在真实域名后面时,需要设置 ALLOWED_HOSTS 环境变量。
一切都是环境变量驱动的(PORT、HOST、ALLOWED_HOSTS、STATE_KEY),因为这就是 VM 或 Railway 这样的 PaaS 平台想要的。我们不会用这个文件来做 Cloudflare 部署,但如果你更想在任意地方用 Node 运行,这就是你的路径。
// 👇 client.ts
import {
Client,
StreamableHTTPClientTransport,
} from "@modelcontextprotocol/client";
const url = process.env.MCP_URL ?? "<http://127.0.0.1:3000/mcp>";
const client = new Client(
{ name: "mcp-demooo-client", version: "1.0.0" },
{
capabilities: { elicitation: { form: {} } },
versionNegotiation: { mode: "auto" }, // 当服务器支持时使用 2026-07-28
},
);
// elicitation handler:在真实应用中这里渲染确认对话框。
// 这里我们自动接受并记录服务器询问的内容。
client.setRequestHandler("elicitation/create", async (request) => {
const { message } = request.params as { message: string };
console.log(`\n[elicitation] server asks: "${message}" -> answering yes`);
return { action: "accept", content: { confirm: true } };
});
await client.connect(new StreamableHTTPClientTransport(new URL(url)));
console.log(
`connected, negotiated protocol: ${client.getNegotiatedProtocolVersion()}`,
);
const tools = await client.listTools();
const { ttlMs, cacheScope } = tools as { ttlMs?: number; cacheScope?: string };
console.log(`tools/list: ${tools.tools.map((t) => t.name).join(", ")}`);
console.log(`cache hints: ttlMs=${ttlMs} cacheScope=${cacheScope}`);
await client.listTools();
console.log("second listTools served from cache");
const before = await client.callTool({ name: "list_deployments" });
console.log(
`list_deployments: ${(before.content[0] as { text: string }).text}`,
);
const result = await client.callTool({
name: "deploy",
arguments: { env: "prod" },
});
console.log(`deploy: ${(result.content[0] as { text: string }).text}`);
const stats = await client.callTool({ name: "server_stats" });
console.log(`server_stats: ${(stats.content[0] as { text: string }).text}`);
const after = await client.callTool({ name: "list_deployments" });
console.log(`list_deployments: ${(after.content[0] as { text: string }).text}`);
await client.close();
⚠️ 不要漏掉 versionNegotiation: { mode: 'auto' }。没有它的话,客户端会协商旧版 2025-11-25 协议,MRTR 流程就会失败。这花了我半小时来调试。
注意这个 elicitation handler 是一个完全正常的 elicitation/create handler,和你为旧流程写的完全一样。SDK 的自动履行引擎会通过它路由嵌入式 MRTR 请求,并替你重试工具调用。你的代码甚至感知不到这次往返。
在两个终端(最好用 tmux)中,分别运行以下命令:
第一个终端:
npx tsx server.ts
第二个终端:
npx tsx client.ts
这是你会得到的输出:
connected, negotiated protocol: 2026-07-28
tools/list: list_deployments, server_stats, deploy
cache hints: ttlMs=30000 cacheScope=public
second listTools served from cache
list_deployments: No deployments yet.
[elicitation] server asks: "Deploy to prod? This will go live." -> answering yes
deploy: Deployed to prod at 2026-08-01T08:02:45.601Z
server_stats: pid=159984 requestsServed=6
list_deployments: prod @ 2026-08-01T08:02:45.601Z
这里的每一行都演示了一个规范特性,我是故意这样设计的:
2026-07-28:我们用的是新协议,不是遗留的降级方案ttlMs=30000 + served from cache:第二次 listTools() 根本没有触及网络tools/call POST 请求。第一次返回 input_required 并关闭连接。第二次带有答案和密封的 requestState。整个过程中没有任何 stream 被保持打开。requestsServed=6:6 次请求,6 个全新的服务器实例,单个进程。在负载均衡器下,这 6 次请求本可以打到 6 台不同的机器上。厉害吧?最后的 list_deployments:应用状态存活了下来,即使协议状态没有。
计算一下:4 次工具调用,加上 2 次 listTools()(但只有 1 次真正走了网络),再加上 MRTR 重试的 1 次额外轮次,等于 6 次服务器构建。

来看看这个"无握手"是怎么回事。用一条裸 curl,不需要任何初始化:
运行以下命令:
curl -s -X POST http://127.0.0.1:3000/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: list_deployments" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_deployments","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}'
对了,这条 curl 命令是 Claude 建议的。
你会得到如下结果:
响应中有两点值得注意。
结果已经显示了生产部署,因为我对同一个服务器进程运行了这个 curl 请求,而客户端正是通过它完成的部署。
这是一个完全独立的客户端,无需握手,无需会话,就能读取 TypeScript 客户端写入的记录。应用状态持久化,协议状态不持久化。
看看那些请求头。Mcp-Method 和 Mcp-Name 就赫然在列,供你的网关做路由分发。而 _meta 使这个请求完全自描述。
这里的开发者体验确实很棒。
我们将把它部署到 Cloudflare Workers,费用为零:免费计划每天提供 100,000 次请求和一个 *.workers.dev 子域名,无需信用卡。
为什么选 Workers?因为它是无状态 MCP 服务器的自然归宿。createMcpHandler 返回的是一个 fetch 风格的处理器,而 fetch 处理器正是 Workers 运行的内容。整个平台差异只体现在一个很小的文件中。
⚠️ Cloudflare 有快速启动的 MCP 模板(npm create cloudflare -- --template=cloudflare/ai/demos/remote-mcp-authless)。在撰写本文时,Cloudflare 自己的文档已经警告说这些模板仍然会脚手架化已废弃的 McpAgent 路径,并写道:"不要在新服务器中使用该路径。"那是旧的有状态世界,不符合 2026-07-28 规范。跳过模板。createMcpHandler 是推荐路径,而且我们已经在使用了。
// 👇 worker.ts
import { Hono } from "hono";
import { createMcpHandler } from "@modelcontextprotocol/server";
import { buildServer } from "./bot.js";
const handler = createMcpHandler(buildServer);
const app = new Hono();
app.get("/healthz", (c) => c.text("ok"));
app.all("/mcp", (c) => handler.fetch(c.req.raw));
export default app;
十一行代码。同样的 buildServer,同样的工具,同样的 MRTR 流程。
与 Node 入口两个有意的不同之处:
使用朴素的 new Hono() 而不是 createMcpHonoApp()。createMcpHonoApp 中的 Host 验证是针对本地服务器 DNS 重绑定攻击的保护。放在 Cloudflare Edge 后面,反而碍事。
没有 serve(...)。Workers 自己调用你导出的 fetch 处理器。
Wrangler 是 Cloudflare 的 Workers CLI。它打包你的 TypeScript(无需构建步骤),在真实的生成环境运行时本地运行,管理密钥并部署。
npm install -D wrangler
创建 wrangler.jsonc:
{
"name": "updated-mcp-spec-bot",
"main": "worker.ts",
"compatibility_date": "2026-07-01",
"compatibility_flags": ["nodejs_compat"]
}
nodejs_compat 标志补全 Node 风格的全局变量,使 npm 包表现正常。
npx wrangler dev
这会在 workerd 上运行 worker.ts,即 Cloudflare 生成环境运行的同一个引擎,地址是 http://localhost:8787。把客户端指向它:
MCP_URL=http://localhost:8787/mcp npx tsx client.ts
输出与 Node 运行完全相同:2026-07-28 协商、缓存提示、MRTR 部署往返。只是多了一行:
server_stats: pid=1 requestsServed=6
pid=1。这是 Edge 运行时在向你打招呼。🫡
如果你没有账号,请在 dash.cloudflare.com/sign-up 注册一个免费账号,然后:
npx wrangler login
设置生产环境的 requestState 密钥(这是共享的 HMAC 密钥,因此每个 Edge 实例都能验证由任何其他实例签发的令牌):
openssl rand -hex 32 # 复制输出
npx wrangler secret put STATE_KEY # 出现提示时粘贴

⚠️ 我自己运行时遇到的一个坑:密钥必须至少 32 字节,否则 codec 会在启动时抛出异常。openssl rand -hex 32 给你 64 个十六进制字符,绰绰有余。
npx wrangler deploy
首次部署会要求你选择一个免费的 workers.dev 子域名。十秒后:
https://updated-mcp-spec-2026.<your-subdomain>.workers.dev
你的 MCP 服务器已在 Cloudflare 全球 Edge 网络上线。

用公共 URL 运行第四步的 curl 请求(只需换掉 host),在浏览器中访问 /healthz,然后是真正的验证:
MCP_URL=https://updated-mcp-spec-2026.<your-subdomain>.workers.dev/mcp npx tsx client.ts
输出相同。只不过现在它已经在互联网上了。

将一个真实的 AI 智能体连接上去,无需隧道,无需 ngrok:
claude mcp add --transport http updated-mcp-spec-2026 \
https://updated-mcp-spec-bot.<your-subdomain>.workers.dev/mcp
在 Claude Code 会话中运行 /mcp 查看已连接,然后让它"部署到 staging"。由于 Claude Code 尚未声明 elicitation 能力,我们的能力感知回退机制就启动了:工具告诉 AI 智能体先与你确认,你在聊天中说 yes,部署就落地了。
附赠:运行 npx wrangler tail 的同时观察请求实时落入你的生产日志。
我们的 deployments 数组存活在内存中,而在 Workers 上,内存格外短暂:isolate 按地域启停,因此两个请求可能看到不同的历史记录。这不是 demo 的 bug;这是规范给我们上的又一课。协议状态按设计消失,应用状态应该存在于真正的存储中。