前面写过一篇 MCP 2026-07-28 改了什么,那篇讲的是规范本身:无状态核心、Header 路由、MRTR、OAuth 2.1 加固。
但真正把一个已经在跑的 MCP Server 升上去时,问题会变得更具体:依赖能不能直接升?旧客户端怎么办?Mcp-Session-Id 要不要立刻删?认证上下文从哪里传进工具?服务端版本号要不要同步改?
这篇只讲一次 Next.js MCP Server 的升级实践。结论先放前面:MCP 2026-07-28 的升级,不是把 SDK 版本号改掉就结束;真正要改的是协议边界、请求分流、状态归属和兼容窗口。
升级前:会话式 Streamable HTTP
这个 MCP Server 嵌在一个 Next.js 应用里,对外暴露单个 /api/mcp 入口,认证、权限、审计和业务数据都复用主应用已有能力。
早期实现采用的是 2025-11-25 会话式协议下的 Streamable HTTP:同一个路由同时处理 POST、GET、DELETE。
POST /api/mcp → JSON-RPC 请求
GET /api/mcp → SSE 流
DELETE /api/mcp → 关闭会话
服务端通过 Mcp-Session-Id 维持协议层会话,并在进程内放一个 session store。这个方案在单实例部署时很直接:实现简单、延迟低、没有额外基础设施。
但它有三个明显边界。
第一,多实例部署会变麻烦。请求必须回到同一个进程,除非引入 sticky session 或共享 session store。
第二,协议状态和业务状态容易混在一起。工具真正需要的是用户身份、权限、业务对象 ID,但旧协议让一部分上下文隐式挂在 session 后面。
第三,客户端生态开始切换。新客户端会按 2026-07-28 的请求形态来发包,如果服务端只认旧式 session,就会出现“依赖升了,协议没通”的尴尬。
所以这次升级的目标不是“抛弃全部旧逻辑”,而是:新协议走无状态路径,旧客户端继续可用,兼容期内用日志把流量看清楚。
第一步:依赖升级只是入口
依赖先切到官方 2.0 包:
{
"@modelcontextprotocol/core": "^2.0.0",
"@modelcontextprotocol/server": "^2.0.0"
}
同时,MCP server 自身暴露的 metadata 版本也改成 2.0.0:
const server = new McpServer(
{
name: "tech-agent-kit-mcp",
version: "2.0.0",
},
{
instructions: "...",
}
);
这里有个容易误会的点:serverInfo.version 是这个 MCP Server 的能力版本,不等同于协议版本 2026-07-28。前者告诉客户端“服务端实现升级了”,后者出现在请求 _meta 或协议分流逻辑里,告诉服务端“这次请求按哪版协议解释”。
我会把两件事分开看:
- 协议版本:决定请求怎么解析、是否使用 session、是否走 modern handler;
- server 版本:决定客户端是否应该刷新缓存、重新理解工具集合与服务端行为。
如果 MCP 工具集合、instructions、入参出参或协议行为发生了客户端可见变化,server metadata 的版本也应该同步升级。否则客户端可能拿旧缓存调用新服务,问题很隐蔽。
第二步:双协议分流,而不是一刀切
生产服务最怕“正确但突然”。哪怕目标是无状态协议,也不能假设所有客户端同一天升级。
所以入口层做了一个很小但很关键的分流函数:
import { isLegacyRequest } from "@modelcontextprotocol/server";
export type McpProtocolRoute = "legacy" | "modern";
export async function classifyMcpHttpRequest(
req: Request
): Promise<McpProtocolRoute> {
const method = req.method.toUpperCase();
if (method === "GET" || method === "DELETE") {
return req.headers.get("mcp-session-id") ? "legacy" : "modern";
}
if (method === "POST") {
return (await isLegacyRequest(req)) ? "legacy" : "modern";
}
return "modern";
}
这里的原则是:
POST请求让 SDK 的isLegacyRequest根据 envelope 判断;GET/DELETE只有带mcp-session-id才当旧协议;- 其他情况默认走 modern。
有一个实现细节容易踩:isLegacyRequest(req) 需要读取请求 body 才能判断 envelope。在真实的 route handler 里,传进去的应该是 req.clone(),否则分流函数把 body 消费掉之后,后面的 legacy / modern handler 都会拿不到请求体。
分流之后,旧协议仍交给原来的 sessionful handler,新协议走 2026-07-28 的 per-request handler:
if (route === "legacy") {
switch (req.method.toUpperCase()) {
case "POST":
response = await handleLegacyMcpPost(req, auth, requestFields);
break;
case "GET":
response = await handleLegacyMcpGet(req, auth, requestFields);
break;
case "DELETE":
response = await handleLegacyMcpDelete(req, auth, requestFields);
break;
default:
response = new Response("Method not allowed", { status: 405 });
}
} else {
response = await runWithMcpRequestContext(auth, req.headers, () =>
handleModernMcpRequest(req)
);
}
这个分流层看起来普通,但它是整个升级最重要的工程保险。它让“升级协议”和“迁移客户端”解耦:服务端可以先上线 modern 能力,旧客户端继续按原路径运行。
第三步:modern handler 明确拒绝 legacy
新版请求交给 createMcpHandler:
export const modernMcpHandler = createMcpHandler(
async ctx => {
const auth = getCurrentMcpAuth();
const headers =
ctx.requestInfo?.headers ?? getCurrentMcpRequestHeaders() ?? null;
if (!auth || !headers) {
throw new Error("MCP request context is not initialized");
}
return createMcpServer(auth, headers);
},
{ legacy: "reject" }
);
这里我刻意用了 { legacy: "reject" }。
既然入口层已经负责判断新旧协议,modern handler 就不再承担兼容职责。它只处理新版请求;如果旧请求漏进来,直接拒绝,暴露路由判断问题。
这样边界会清楚很多:
/api/mcp route
├─ classify legacy → legacy sessionful handler
└─ classify modern → createMcpHandler({ legacy: "reject" })
兼容可以存在,但兼容逻辑不能散在每一层里。否则以后排查“为什么这个请求还带 session”时,会很难判断到底是谁吃掉了旧协议。
第四步:每个请求创建 server,状态从协议层挪出去
升级后,modern 路径按请求创建 MCP Server:
export async function createMcpServer(auth, headers): Promise<McpServer> {
const server = new McpServer({
name: "tech-agent-kit-mcp",
version: "2.0.0",
});
const ctx = {
auth,
db,
requestId: crypto.randomUUID(),
headers,
getHeaders: () => getCurrentMcpRequestHeaders() ?? headers,
};
registerIdentityTool(server, ctx);
if (auth.apiKeyUser.keyId.startsWith("oauth-")) {
registerAllToolsForOauth(server, ctx);
} else {
const toolScopes = await loadGrantedMcpScopes(auth.apiKeyUser.keyId);
registerToolsForScopes(server, ctx, toolScopes);
}
return server;
}
这个写法有两个收益。
第一,协议层不再保存 session。一次请求需要的身份、权限、headers 都从当前请求解析出来,通过显式 ctx 传给工具。
第二,原来的 scope-gated 工具注册逻辑可以保留。API Key 仍然只注册它有权限的工具;OAuth 凭证则按授权 scopes 暴露可用工具。升级协议不等于重做权限系统。
这也是我很喜欢的一条迁移经验:好的旧抽象应该留下来,坏的旧耦合才需要拆掉。 这里被拆的是协议 session,被保留的是认证、scope、审计和业务服务边界。
第五步:OAuth 元数据要补齐
2026-07-28 对 OAuth 的要求更明确,服务端需要提供受保护资源元数据发现。实践里做了两个动作。
未认证时,返回 WWW-Authenticate,指向资源元数据地址:
export function buildMcpUnauthorizedResponse(): Response {
return new Response(null, {
status: 401,
headers: {
"WWW-Authenticate": `Bearer resource_metadata="${getResourceMetadataUrl()}", scope="${MCP_INITIAL_OAUTH_SCOPES.join(" ")}"`,
},
});
}
同时暴露 RFC 9728 资源元数据:
export function buildProtectedResourceMetadata() {
return {
resource: getMcpResourceUrl(),
authorization_servers: [keycloakIssuer],
scopes_supported: MCP_INITIAL_OAUTH_SCOPES,
bearer_methods_supported: ["header"],
};
}
认证层则同时支持两类凭证:
x-api-key或普通 Bearer token:继续走 API Key 解析;- JWT 形态的 Bearer token:按 Keycloak OAuth token 校验,再映射到本地用户。
这样 MCP 客户端可以走标准 OAuth discovery,也不影响已有 API Key 客户端继续使用。
第六步:日志里必须能看见协议路径
兼容期最怕“我以为已经没人用旧协议了”。所以每个 MCP 请求都会记录协议分流结果:
function protocolRouteFields(route: McpProtocolRoute) {
return {
protocolRoute: route,
// 日志里的协议版本直接对齐规范日期,方便跨文章、跨系统对照
protocolRevision: route === "modern" ? "2026-07-28" : "2025-11-25",
};
}
这比单纯看 HTTP 方法可靠得多。因为兼容期里 POST 既可能是 legacy,也可能是 modern,必须结合 envelope 或 header 才能判断。
上线后我会重点看几类指标:
- modern / legacy 请求占比;
- legacy GET / DELETE 是否还存在;
- modern 请求里的
mcp-method、mcp-name是否完整; - 认证失败是 API Key 失败、OAuth token 失败,还是本地用户映射失败;
- 是否有 legacy 请求误入 modern handler 被 reject。
等 legacy 流量长期归零,再考虑删除旧 session store 和 GET / DELETE 路由。迁移不是靠感觉宣布完成,而是靠流量证据确认完成。
这次升级真正改掉了什么
如果把这次实践浓缩成一张表,大概是这样:
| 位置 | 升级前 | 升级后 |
|---|---|---|
| SDK | @modelcontextprotocol/sdk 1.x | @modelcontextprotocol/core + @modelcontextprotocol/server 2.0 |
| 协议形态 | 2025-11-25 会话式兼容 | 2026-07-28 per-request modern |
| 路由 | POST / GET / DELETE 共享 session | modern 只走 per-request,legacy 独立分流 |
| 状态 | Mcp-Session-Id + 进程内 store | 请求上下文 + 显式业务状态 |
| 认证 | API Key 为主 | API Key + OAuth JWT + RFC 9728 metadata |
| 观测 | 看普通请求日志 | 明确记录 protocolRoute / protocolRevision |
注意,这不是“一次性把所有旧代码删掉”的升级。真实生产系统更常见的路径是:
- 先引入新版 handler;
- 做明确的新旧协议分流;
- 保留旧协议兼容窗口;
- 用日志确认客户端迁移情况;
- 最后再删 legacy。
踩坑总结
这次升级之后,我对 MCP 协议迁移有几个更强的判断。
第一,只升级依赖不等于升级协议。 依赖版本只是前提,真正决定行为的是请求 envelope、handler 选择、session 依赖和客户端协商。
第二,server version 和 protocol version 要分开。 2.0.0 是服务端能力版本,2026-07-28 是协议版本。前者影响客户端缓存和工具理解,后者影响请求解释方式。
第三,compatibility 要集中管理。 兼容层应该在入口清晰分流,而不是让每个 handler 自己猜请求属于哪个时代。
第四,无状态不是没有状态。 协议层不再维护 session,不代表业务不需要状态。审批流、长任务、草稿、导入进度都应该变成显式业务对象,用 task_id、draft_id、requestState 这类句柄传递。
第五,MCP 能力升级要同步考虑缓存。 工具集合、instructions、参数 schema、返回结构只要发生客户端可见变化,就应该同步升级 server metadata version,避免客户端拿旧认知调用新能力。
最后
MCP 2026-07-28 的价值,不是“协议更潮”,而是把服务端从连接和 session 的隐式约束里解放出来。
但对存量系统来说,最稳的升级方式不是立刻删除旧世界,而是先把边界画清楚:legacy 归 legacy,modern 归 modern;协议状态归协议,业务状态归业务;版本元数据归版本元数据,协议协商归协议协商。
这几条线画清楚之后,MCP Server 才真的变成一个可以被网关治理、可以水平扩展、可以长期演进的 Web API。