跳至正文
来两杯美式
返回

MCP Client 升级 2026-07-28:服务端能留兼容窗口,我们偏不兼容旧协议

By 来两杯美式
发布于

上一篇写了 MCP Server 升级 2026-07-28 的服务端实践。这一篇换到另一侧:MCP Client。

很多人升级 MCP 时会先盯服务端:handler 怎么换、session 怎么删、OAuth metadata 怎么补。这个方向没错,但只做服务端还不够。真正跑在 Agent 里的,是 Client 端调用层。它决定了工具列表怎么同步、调用时带什么身份、失败后怎么恢复,以及是否允许悄悄退回旧协议。

这次 Client 端改造发生在一个 Next.js 技术门户里。门户本身有 AI 助手,助手会从数据库里读取已配置的 MCP Server,选择合适工具,再通过 MCP Client 远程调用。

最终目标很明确:Client 端只走 2026-07-28 modern 协议,不做 legacy fallback;但在连接生命周期、用户身份和错误恢复上,把工程兜底补齐。

Client 端为什么也要升级

MCP Server 升到 2026-07-28 后,服务端可以做到 per-request、无协议级 session、Header 可路由。但如果 Client 端仍按旧模型工作,就会继续把问题带回来。

旧 Client 最常见的三个问题是:

所以 Client 端升级的核心,不是“能不能 callTool 成功”,而是把调用层做成一个稳定边界:协议版本明确、身份来源明确、错误类型明确。

第一件事:协议版本直接 pin 住

Client 端依赖升级到官方 v2:

{
  "@modelcontextprotocol/client": "2.0.0"
}

创建 Client 时,不让它自由协商一堆版本,而是明确只支持 2026-07-28

const MCP_MODERN_PROTOCOL_VERSION = "2026-07-28";

const client = new Client(
  {
    name: "tech-portal-mcp-client",
    version: "1.0.0",
  },
  {
    supportedProtocolVersions: [MCP_MODERN_PROTOCOL_VERSION],
    versionNegotiation: { mode: { pin: MCP_MODERN_PROTOCOL_VERSION } },
  }
);

这个选择有一点强硬:不做 legacy fallback。

原因是这个 Client 属于内部自控系统,不是要兼容未知客户端的软件分发包。它连接的 MCP Server 也是平台配置出来的服务。与其静默降级到旧协议,不如在连接阶段尽早失败,让管理员发现这个 Server 还没升级或配置不对。

服务端可以保留 legacy 兼容窗口,因为它面对的是很多外部客户端;Client 端反过来应该更收敛,因为它代表的是平台自己的调用标准。

第二件事:传输层统一封装请求头

传输层使用 StreamableHTTPClientTransport

const transport = new StreamableHTTPClientTransport(new URL(connection.url), {
  requestInit: buildRequestInit(
    connection.authType,
    authConfig,
    shouldInjectIdentity ? userEmployeeNo : undefined
  ),
});

所有请求头都集中在 buildRequestInit 里生成:

function buildRequestInit(
  authType: string,
  authConfig: AuthConfig,
  userEmployeeNo?: string
): RequestInit {
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
    Accept: "application/json, text/event-stream",
    ...authConfig.headers,
  };

  if (authType === "bearer" && authConfig.token) {
    headers.Authorization = `Bearer ${authConfig.token}`;
  }

  if (userEmployeeNo) {
    headers["X-Employee-Id"] = userEmployeeNo;
  }

  return { headers };
}

这段代码看起来像普通封装,但它解决了两个很实际的问题。

第一,认证头不会散落在 listToolscallToolreadResource 各处。以后如果从静态 Bearer token 切到 OAuth access token,改造点更集中。

第二,业务身份注入有统一入口。MCP 协议解决“工具怎么被发现和调用”,但企业内部经常还需要知道“这次调用代表哪个员工”。这个身份不能靠 Client 进程里的全局变量猜,应该每次请求显式带上。

第三件事:连接缓存必须按用户隔离

MCP Client 通常会做连接复用,否则每次工具调用都重新 connect,成本太高。

但连接缓存有个陷阱:如果只按 connectionId 缓存,同一个 MCP Server 的 client 可能被多个登录用户共享。一旦服务端根据请求头识别用户,就会出现身份串用风险。

所以缓存 key 设计成:

const cacheKey = `${connectionId}:${userEmployeeNo ?? ""}`;

连接配置里还有一个 injectIdentity 开关:

const shouldInjectIdentity =
  authConfig.injectIdentity === true && !!userEmployeeNo;

开启后,每个用户会拿到独立 client,请求头里注入 X-Employee-Id。未开启或没有用户身份时,才退回连接级共享 client。

这条规则很重要。MCP 工具经常连接的是个人数据、文档、记忆、流程系统。调用层必须保证“谁在问”是显式的,不能让 A 用户复用 B 用户建立的连接上下文。

第四件事:工具发现先同步入库

Agent 真正调用工具前,平台需要先知道有哪些工具可用。因此管理后台会对 MCP Server 做工具同步:

export async function syncMcpTools(connectionId: string) {
  const client = await getMcpClient(connectionId);
  const { tools } = await client.listTools();

  await db.mcpTool.deleteMany({ where: { connectionId } });

  await db.mcpTool.createMany({
    data: tools.map(tool => ({
      connectionId,
      name: tool.name,
      description: tool.description ?? "",
      inputSchema: JSON.stringify(tool.inputSchema),
    })),
  });
}

资源也是同样逻辑:listResources 拉取后写入 mcpResource 表。

为什么不每次对话实时 listTools?因为 Agent 的工具选择器需要的不只是“远端有什么工具”,还包括本地配置的优先级、关键词、启用状态、连接展示名。这些是平台治理信息,不属于 MCP Server 自己。

所以工具发现被拆成两层:

MCP Server tools/list
  → 同步到平台 DB
  → 管理员补充 priority / keywords / isEnabled
  → Agent 工具选择器按本地配置做 Top-K

这也是企业内部 MCP Client 和桌面客户端很不一样的地方:桌面客户端更像“发现即使用”,平台型 Client 更像“发现之后进入治理”。

第五件事:调用失败要分层处理

MCP 工具调用的失败至少有三类,不能混着看。

第一类是工具业务失败。新版 callTool 返回的是 CallToolResult,里面可能有 isError

const result = await client.callTool({
  name: toolName,
  arguments: args,
});

if (
  result &&
  typeof result === "object" &&
  "isError" in result &&
  result.isError
) {
  const content = "content" in result ? result.content : [];
  const errorText = Array.isArray(content)
    ? content
        .map((c: { type: string; text?: string }) =>
          c.type === "text" ? c.text : ""
        )
        .join("")
    : String(content);

  throw new Error(`工具执行错误: ${errorText || "未知错误"}`);
}

isError=true 不是 HTTP 失败,也不是协议失败,而是工具正常返回了一个“业务上失败”的结果。比如权限不足、参数非法、业务系统拒绝执行,都应该走这个语义。

第二类是传输或认证失败。比如 401、网络断开、服务端不可用。这类错误才适合进入重试逻辑。

第三类是模型侧工具参数问题。例如模型生成的参数不符合 schema,这应该交给上层 ToolExecutor 或 AI SDK 的工具调用修复机制处理,而不是在 MCP Client 里吞掉。

把三类错误拆开后,日志才会有诊断价值。

第六件事:重连只重试一次

连接复用带来的另一个现实问题是:缓存里的 client 可能过期。

实践里做了两道兜底。

第一,闲置超过 5 分钟,下次使用前先 ping:

if (Date.now() - cached.lastUsedAt > SESSION_IDLE_TIMEOUT_MS) {
  try {
    await cached.client.ping({ timeout: 5000 });
    clientCache.set(cacheKey, { ...cached, lastUsedAt: Date.now() });
    return cached.client;
  } catch {
    await closeClientEntry(cacheKey, cached);
  }
}

第二,真正调用时如果遇到 session not found401unauthorized,清掉缓存并重新连接,再重试一次:

if (isSessionExpiredError(error) && !hasRetried) {
  const cacheKey = `${connectionId}:${userEmployeeNo ?? ""}`;
  const cached = clientCache.get(cacheKey);

  if (cached) {
    await closeClientEntry(cacheKey, cached);
  }

  // hasRetried 置为 true,保证只重试一次
  return executeMcpCall(connectionId, toolName, args, userEmployeeNo, true);
}

这里特意只重试一次。因为 401 可能是 token 失效,也可能是服务端配置错;session not found 可能是兼容路径残留,也可能是中间代理把请求打散。无限重试只会把错误放大。

关闭连接时也有顺序:

await entry.transport.terminateSession().catch(() => {});
await entry.client.close().catch(() => {});

即使 Client 端已经 pin 到 modern,兼容期里仍可能面对旧服务端或中间状态。连接生命周期这块保守一点,线上会少很多偶发问题。

第七件事:HTTP 状态码不要只从 message 里找

这是一个很容易踩的小坑。

MCP SDK 抛出的 Streamable HTTP 错误,message 可能长这样:

Streamable HTTP error: Error POSTing to endpoint: <body>

真正的 HTTP 状态码不一定出现在 message 里,而是在错误对象字段上。SDK 原始错误放在 status 上,上层工具执行器包装过的错误又放在 code 上,所以提取函数要两种形态都兼容:

function extractHttpCode(error: unknown): number | undefined {
  if (typeof error !== "object" || error === null) {
    return undefined;
  }

  // SDK 抛出的 Streamable HTTP 错误
  if ("status" in error && typeof error.status === "number") {
    return error.status;
  }

  // 上层工具执行器包装过的错误
  if ("code" in error && typeof error.code === "number") {
    return error.code;
  }

  return undefined;
}

这个细节会直接影响重试策略。如果你只在 message 里找 502503,实际可能永远匹配不到,导致网关抖动、服务端重启这类临时错误无法恢复。

第八件事:结果要兼容 content 和 structuredContent

MCP 工具结果不是普通字符串。

新版工具调用结果可能同时包含:

在 Agent 侧,直接把原始结果丢给模型并不总是最稳。实践里做了一层结果处理:

这层处理不属于 MCP 协议本身,但它决定了用户看到的是“可读答案”,还是一坨嵌套 JSON。

升级后的调用链

把 Client 端这次升级串起来,完整链路是这样:

Admin 配置 MCP Server
  → syncTools / syncResources 拉取远端能力
  → 工具元数据写入本地 DB
  → Agent 工具选择器按关键词、描述、优先级选 Top-K
  → ToolExecutor 设置超时与重试
  → callMcpTool 获取按用户隔离的 Client
  → StreamableHTTPClientTransport 发起 2026-07-28 请求
  → 处理 CallToolResult / structuredContent / isError
  → 记录 ToolExecutionLog
  → 结果返回给模型和前端

这条链路里,MCP Client 不是一个薄薄的 SDK wrapper。它更像平台的“工具调用网关”:协议协商、身份、缓存、重试、日志、结果适配都在这里收口。

Client 端升级清单

最后收成一份 checklist。以后再升级 MCP Client,我会按这个顺序过:

服务端升级让 MCP Server 变得更像 Web API;Client 端升级则让 Agent 调工具这件事变得可治理、可诊断、可恢复。

两边都改完,MCP 才不只是“协议能通”,而是真正进入企业系统的稳定调用链。

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 爆炸之后,Agent 怎么从一堆工具里选对那一个
下一篇
MCP Server 升级 2026-07-28:不是换依赖,而是重画协议边界