上一篇写了 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,容易把身份上下文混在一起;
- 错误恢复靠猜:网络错误、401、session 过期、工具业务失败混在一个 catch 里。
所以 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 };
}
这段代码看起来像普通封装,但它解决了两个很实际的问题。
第一,认证头不会散落在 listTools、callTool、readResource 各处。以后如果从静态 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 found、401 或 unauthorized,清掉缓存并重新连接,再重试一次:
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 里找 502、503,实际可能永远匹配不到,导致网关抖动、服务端重启这类临时错误无法恢复。
第八件事:结果要兼容 content 和 structuredContent
MCP 工具结果不是普通字符串。
新版工具调用结果可能同时包含:
content:文本、图片、资源等内容数组;structuredContent:工具声明 output schema 后返回的结构化数据;isError:工具执行错误标记。
在 Agent 侧,直接把原始结果丢给模型并不总是最稳。实践里做了一层结果处理:
- 字符串结果先尝试解析 JSON;
- MCP 标准数组
[{ type: "text", text: "..." }]会抽出 text; - text 里如果还是 JSON,再递归解析;
- 含
answer、citations、imageRefs、results的结构化结果,会转成更适合聊天界面展示的 Markdown; error、success=false、非 0/200 code 会进入错误展示。
这层处理不属于 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,我会按这个顺序过:
- 协议版本:明确
supportedProtocolVersions,生产内控 Client 优先 pin 到目标版本。 - fallback 策略:内部平台 Client 可以不做 legacy fallback,让不兼容尽早暴露。
- 传输头:统一封装
Content-Type、Accept、Authorization和业务身份 header。 - 缓存粒度:如果服务端按用户识别身份,client cache 必须至少按
connectionId:userEmployeeNo隔离。 - 工具同步:
listTools/listResources进本地 DB,再叠加平台治理字段。 - 错误分层:区分工具业务错误、传输错误、模型参数错误。
- 重连策略:ping 探活 + 401/session-not-found 清缓存重连一次。
- 状态码提取:不要只解析 message,检查 SDK error 的
status/code字段。 - 结果适配:同时兼容
content、structuredContent和文本 JSON。 - 审计日志:记录工具名、入参、出参、状态、耗时和调用人。
服务端升级让 MCP Server 变得更像 Web API;Client 端升级则让 Agent 调工具这件事变得可治理、可诊断、可恢复。
两边都改完,MCP 才不只是“协议能通”,而是真正进入企业系统的稳定调用链。