跳至正文
来两杯美式
返回

MCP Server 升级 2026-07-28:不是换依赖,而是重画协议边界

By 来两杯美式
发布于

前面写过一篇 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:同一个路由同时处理 POSTGETDELETE

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 或协议分流逻辑里,告诉服务端“这次请求按哪版协议解释”。

我会把两件事分开看:

如果 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";
}

这里的原则是:

有一个实现细节容易踩: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"],
  };
}

认证层则同时支持两类凭证:

这样 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 才能判断。

上线后我会重点看几类指标:

等 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 共享 sessionmodern 只走 per-request,legacy 独立分流
状态Mcp-Session-Id + 进程内 store请求上下文 + 显式业务状态
认证API Key 为主API Key + OAuth JWT + RFC 9728 metadata
观测看普通请求日志明确记录 protocolRoute / protocolRevision

注意,这不是“一次性把所有旧代码删掉”的升级。真实生产系统更常见的路径是:

  1. 先引入新版 handler;
  2. 做明确的新旧协议分流;
  3. 保留旧协议兼容窗口;
  4. 用日志确认客户端迁移情况;
  5. 最后再删 legacy。

踩坑总结

这次升级之后,我对 MCP 协议迁移有几个更强的判断。

第一,只升级依赖不等于升级协议。 依赖版本只是前提,真正决定行为的是请求 envelope、handler 选择、session 依赖和客户端协商。

第二,server version 和 protocol version 要分开。 2.0.0 是服务端能力版本,2026-07-28 是协议版本。前者影响客户端缓存和工具理解,后者影响请求解释方式。

第三,compatibility 要集中管理。 兼容层应该在入口清晰分流,而不是让每个 handler 自己猜请求属于哪个时代。

第四,无状态不是没有状态。 协议层不再维护 session,不代表业务不需要状态。审批流、长任务、草稿、导入进度都应该变成显式业务对象,用 task_iddraft_idrequestState 这类句柄传递。

第五,MCP 能力升级要同步考虑缓存。 工具集合、instructions、参数 schema、返回结构只要发生客户端可见变化,就应该同步升级 server metadata version,避免客户端拿旧认知调用新能力。

最后

MCP 2026-07-28 的价值,不是“协议更潮”,而是把服务端从连接和 session 的隐式约束里解放出来。

但对存量系统来说,最稳的升级方式不是立刻删除旧世界,而是先把边界画清楚:legacy 归 legacy,modern 归 modern;协议状态归协议,业务状态归业务;版本元数据归版本元数据,协议协商归协议协商。

这几条线画清楚之后,MCP Server 才真的变成一个可以被网关治理、可以水平扩展、可以长期演进的 Web API。

AI 工程化相关阅读


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
查看系列全部文章
  1. 01.MCP Server 选型决策树:从语言到审计,六个维度一次说清
  2. 02.当 MCP Server 只是"翻译"现成能力:FastMCP + Mem0 + Redis 的选型逻辑
  3. 03.当 MCP Server 要"嵌进"对外平台:Node.js + 官方 SDK + API Key 的选型逻辑
  4. 04.SSE 已被 MCP 判为 legacy:存量服务怎么迁、新服务怎么选
  5. 05.MCP 权限怎么分级?只读、可操作和高风险动作(附完整源码)
  6. 06.MCP 工具权限码怎么设计?read、write、dangerous 三层模型(附完整源码)
  7. 07.自建 SearXNG + MCP:给 AI 工具接入可控的网页搜索
  8. 08.Agent Skill 是快照,快照会腐烂:我把业务流程全部搬到了服务端
  9. 09.能力目录与协议下发:把接口设计和控制流一起交给 Agent
  10. 10.四套版本号:把 Agent Skill 的版本管理挪回服务端之后
  11. 11.让用户自定义 Agent 能力,而不打开注入的后门
  12. 12.MCP 2026-07-28 改了什么:从有状态会话到无状态核心
  13. 13.Anthropic 发布 Agent Skills:AI Agent 的能力该如何被打包
  14. 14.Agent Skills 设计模式:用文件夹给 Agent 装上专业能力
  15. 15.Agent Skills 最佳实践:从评测、结构拆分到安全审查
  16. 16.MCP Server 升级 2026-07-28:不是换依赖,而是重画协议边界
  17. 17.MCP Client 升级 2026-07-28:服务端能留兼容窗口,我们偏不兼容旧协议
  18. 18.MCP 爆炸之后,Agent 怎么从一堆工具里选对那一个

上一篇
MCP Client 升级 2026-07-28:服务端能留兼容窗口,我们偏不兼容旧协议
下一篇
Agent 评测工程化(九):把一次性评测变成持续优化体系