Agent 工程体系 · 第 39/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。

MCP Client 工程:连接管理、能力缓存、取消、重试和隔离

MCP Client 不是“把一个 HTTP 请求发出去”的薄封装。它位于 Agent Host 与 MCP Server 之间,负责把一个不稳定、可能变化、具有权限边界的远程能力,转换成模型可以安全使用的工具、资源和提示。

在 MCP 架构中,Host 是承载和协调多个 Client 的应用进程;Client 是 Host 内部与某一个 Server 通信的连接器;Server 提供工具、资源和提示。一个 Client 与一个 Server 保持一对一关系,而一个 Host 可以同时管理多个 Client。这个关系决定了连接、缓存、重试和隔离都应以“单个 Server 的 Client 实例”为基本边界,而不应把所有 Server 混成一个全局连接池。(modelcontextprotocol.io)

本文以 2026-07-28 协议版本及之后的现代生命周期为主,同时说明如何兼容 2025-11-25 及更早的初始化握手生命周期。这里的“2026-09 Agent 工程基线”指截至 2026 年 9 月 1 日,实现应优先采用 2026-07-28 的规范行为;旧版本兼容属于明确的降级路径,而不是默认抽象。


1. 先把 MCP Client 的职责边界划清楚

一个工程化 Client 至少包含以下逻辑层:

┌──────────────────────────────────────────────┐
│ Host / Agent Runtime                         │
│                                              │
│ 任务上下文、用户确认、模型调用、全局预算       │
└──────────────────────┬───────────────────────┘
                       │
              ┌────────▼────────┐
              │ MCP Client       │
              │                  │
              │ 版本选择          │
              │ 连接与传输        │
              │ 请求复用          │
              │ 能力缓存          │
              │ 取消              │
              │ 重试              │
              │ 熔断与隔离        │
              └────────┬─────────┘
                       │
          stdio / Streamable HTTP
                       │
              ┌────────▼────────┐
              │ MCP Server       │
              │ tools/resources  │
              │ prompts          │
              └──────────────────┘

Client 不应承担以下职责:

  1. 不替代 Host 做用户授权决策。 工具调用通常涉及外部系统、数据访问或代码执行,Host 仍应让用户了解并确认高风险操作。
  2. 不把连接等同于对话会话。 2026-07-28 规范明确采用无状态的请求模型;Server 不应依赖前一个请求建立协议版本、能力或身份上下文。
  3. 不把缓存等同于权限。 缓存只能复用之前获得的能力描述,不能绕过当前请求的认证、授权和工具调用检查。
  4. 不把超时等同于服务端已停止执行。 客户端停止等待,并不必然意味着服务端已经回滚或停止副作用。

MCP 消息基于 JSON-RPC 2.0。请求具有唯一 id,响应必须带回相同的 id;通知没有 id,也不需要响应。Client 的并发复用、取消关联和重试判断,最终都依赖这个请求标识。(modelcontextprotocol.io)


2. 连接管理:现代无会话,旧版有握手

2.1 2026-07-28:连接是传输资源,不是协议会话

2026-07-28 及之后的协议版本移除了 initialize / initialized 握手和 Mcp-Session-Id。每个请求都携带自己的协议版本、Client 身份和 Client 能力;HTTP 请求还通过 MCP-Protocol-Version 表达协议版本。Client 可以先调用 server/discover,也可以直接发送业务 RPC,然后根据错误结果处理版本不兼容。(modelcontextprotocol.io)

因此,现代 Client 的状态机不应写成:

连接建立 → 初始化 → 会话建立 → 调用工具

而应接近:

stateDiagram-v2
    [*] --> Disconnected
    Disconnected --> Probing: 首次请求或显式 discover
    Probing --> Ready: 选择共同协议版本
    Probing --> Unsupported: 没有共同版本
    Ready --> Ready: 普通 RPC
    Ready --> Reconnecting: 传输错误
    Reconnecting --> Ready: 下一次请求重新发送
    Reconnecting --> Degraded: 连续失败
    Degraded --> Ready: 熔断恢复探测
    Unsupported --> [*]

这里的 Ready 只表示 Client 已经拥有一个可用的协议版本和传输路径,不表示 Server 保存了一个隐式会话。

一个现代请求的核心结构如下:

{
  "jsonrpc": "2.0",
  "id": "call-42",
  "method": "tools/call",
  "params": {
    "name": "search",
    "arguments": {
      "query": "MCP"
    },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": {
        "name": "wr-agent",
        "version": "1.0.0"
      },
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}

对于 Streamable HTTP,请求还应携带与请求体一致的协议和路由信息,例如:

MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search

2026-07-28 的规范要求 HTTP 传输使用这些标准头部进行协议版本和方法路由;网关可以据此路由、鉴权和限流,而不必解析 JSON 请求体。头部与请求体不一致时,服务端应拒绝请求。(blog.modelcontextprotocol.io)

工程含义:

  • HTTP 普通 RPC 可以经过无状态负载均衡器;
  • 不需要为协议会话设计粘性会话;
  • 连接池主要复用 TCP、TLS、HTTP keep-alive 或进程资源,而不是复用 MCP 会话;
  • 某个请求失败后,重试可以落到同一服务的另一个实例;
  • 需要跨请求保存的业务状态,必须通过显式句柄传递,例如 basket_idjob_idbrowser_id

2.2 2025-11-25 及更早版本:握手产生协议会话

旧版 MCP 使用初始化握手:

Client                         Server
  │                              │
  │ initialize                   │
  │─────────────────────────────>│
  │                              │
  │ 选择协议版本、能力、身份       │
  │<─────────────────────────────│
  │                              │
  │ initialized                  │
  │─────────────────────────────>│
  │                              │
  │ tools/list / tools/call      │
  │─────────────────────────────>│

在旧生命周期中,HTTP Server 可能通过 Mcp-Session-Id 绑定后续请求;重连通常意味着重新初始化,并且可能需要新的会话标识。支持双时代的 Client 不能把现代请求格式强行用于旧 Server。

推荐的兼容策略是:

  1. 优先尝试 2026-07-28
  2. 如果 Server 返回结构化的 UnsupportedProtocolVersionError,从 supported 列表中选择共同版本;
  3. 如果是 HTTP 且服务端表现为旧生命周期,按实现策略回退到 initialize
  4. 如果是 stdio,由于没有 HTTP 状态码,优先调用 server/discover 进行探测;
  5. 旧版连接完成握手后,所有请求使用旧版协议语义。

server/discover 本身是一个可选的客户端探测动作,但 Server 必须实现。其结果可以包含支持的协议版本、Server 能力、Server 身份、说明文字以及缓存提示。(modelcontextprotocol.io)

2.3 不要把“现代无会话”误解成“没有任何长连接”

协议无会话只约束 MCP 的请求语义,不意味着底层一定是短连接。

  • stdio 仍然可能是一个长期运行的子进程;
  • HTTP 客户端仍可能复用连接;
  • subscriptions/listen 是长期保持的通知流;
  • 某些扩展可能引入长时间运行任务或显式句柄。

这些都是传输资源或应用状态,不是隐藏在连接中的协议会话。现代规范要求跨请求状态通过明确标识引用,而不是由连接身份隐式推断。(modelcontextprotocol.io)


3. 能力发现:能力、工具目录和工具定义不是一回事

3.1 三层信息模型

Client 需要区分三种信息:

第一层:协议能力

协议能力回答:

这个 Server 是否支持工具、资源、提示,是否会发布列表变化通知?

例如:

{
  "capabilities": {
    "tools": {
      "listChanged": true
    },
    "resources": {}
  }
}

它不是工具目录,只说明 Server 支持哪些 MCP 功能。

第二层:工具目录

工具目录回答:

当前调用者能够看到哪些工具?

tools/list 返回当前可用工具集合。工具集合可能因授权信息不同而不同,因此缓存键不能只包含 Server URL,还必须考虑租户、用户、凭据作用域等请求上下文。规范允许工具列表随请求中的授权信息变化,但在底层工具集合不变时,Server 应返回确定性顺序。(modelcontextprotocol.io)

第三层:工具定义

工具定义包含:

  • name
  • description
  • inputSchema
  • 可选的 outputSchema
  • 图标、注解或其他元数据

工具定义决定模型如何构造参数,也决定 Client 是否可以在调用前进行参数校验、在调用后验证结构化输出。

因此:

server/discover
    ≠ tools/list
    ≠ tools/call

server/discover 用于知道 Server 支持什么;tools/list 用于获得当前工具目录;tools/call 才执行具体工具。

3.2 能力缓存的正确缓存键

可以把能力缓存抽象为:

CacheKey =
    serverEndpoint
  + transportKind
  + selectedProtocolVersion
  + authenticatedPrincipal
  + authorizationScope
  + tenantId
  + clientCapabilityProfile
  + extensionProfile

其中:

  • serverEndpoint 是逻辑 Server 身份,不一定只是 URL;
  • transportKind 区分 stdio、Streamable HTTP 等传输;
  • selectedProtocolVersion 防止不同版本的响应结构混用;
  • authenticatedPrincipalauthorizationScope 防止越权复用;
  • tenantId 用于多租户系统;
  • clientCapabilityProfile 防止把针对不同 Client 能力的结果混用;
  • extensionProfile 防止把扩展支持情况不同的响应错误解释。

对于 tools/list,还建议保存一个内容摘要:

ToolCatalog {
  key
  tools
  contentHash
  fetchedAt
  expiresAt
  cacheScope
  serverInfo
  protocolVersion
}

contentHash 不只是用于去重,也用于判断模型上下文是否真的发生了变化。确定性排序可以避免工具列表内容相同但顺序变化,进而导致 Prompt Cache 或模型输入缓存失效。(modelcontextprotocol.io)

3.3 ttlMscacheScope

2026-07-28 的列表和发现结果可以携带 ttlMscacheScope

{
  "resultType": "complete",
  "tools": [
    {
      "name": "search",
      "description": "Search documents",
      "inputSchema": {
        "type": "object",
        "properties": {
          "query": { "type": "string" }
        },
        "required": ["query"]
      }
    }
  ],
  "ttlMs": 300000,
  "cacheScope": "private"
}

Client 的过期时间可以计算为:

Texpire=min(Tfetched+ttlMs,Tclient-policy)T_{\text{expire}} = \min( T_{\text{fetched}} + ttlMs, T_{\text{client-policy}} )

其中:

  • TfetchedT_{\text{fetched}} 是响应接收时间;
  • ttlMs 是 Server 建议的有效期;
  • Tclient-policyT_{\text{client-policy}} 是客户端自身允许的最长缓存时间。

取最小值的原因是:Server 比 Client 更了解能力变化频率,但 Client 仍然不能让远端响应无限期控制本地缓存。

cacheScope 至少应影响缓存共享策略:

  • public:在不含用户私有授权因素时,可以在多个调用上下文间共享;
  • private:只能在当前用户、凭据或租户边界内共享;
  • 未知值:按最保守的 private 处理。

不要把 ttlMs: 0 解释成“永久缓存”;它通常意味着立即失效或不建议缓存。也不要把没有缓存字段解释成永久有效,工程上应采用较短的默认 TTL 或每次请求重新获取。

3.4 列表变化通知与缓存失效

当 Server 声明:

{
  "capabilities": {
    "tools": {
      "listChanged": true
    }
  }
}

它表示 Server 能够在工具列表变化时通知 Client。2026-07-28 中,Client 通过 subscriptions/listen 明确订阅通知;Server 不应未经订阅就发送任意变化通知。Client 收到工具列表变化事件后,应执行:

收到 tools changed
    ↓
标记 catalog stale
    ↓
取消正在进行的预取任务
    ↓
下一次需要工具时重新 tools/list
    ↓
比较 contentHash
    ↓
只把变化后的定义注入模型上下文

这里的“失效”不等于“立即阻塞所有请求重新拉取”。更稳妥的做法是:

  • 当前正在执行的 tools/call 使用调用开始时捕获的工具定义;
  • 新调用不再使用过期目录;
  • 多个并发刷新请求合并成一个 single-flight 请求;
  • 刷新失败时保留旧目录,但标记为 stale,并限制继续使用的时间。

在 2025 生命周期中,列表变化通常通过旧式通知机制发送;双时代 Client 必须按照所选协议版本选择相应的订阅和通知处理方式。2026 SDK 的迁移说明也明确指出,现代连接使用 subscriptions/listen,旧连接继续使用旧通知语义。(ts.sdk.modelcontextprotocol.io)

3.5 能力缓存与渐进式工具发现

当 Host 连接了很多 Server,把所有工具定义一次性放入模型上下文会造成两个问题:

  1. 工具定义消耗上下文窗口;
  2. 无关工具增加模型选择错误的概率。

渐进式工具发现的流程是:

tools/list 获取完整目录
        ↓
本地建立名称、描述、标签索引
        ↓
向模型暴露轻量 search_tools
        ↓
模型搜索候选工具
        ↓
Client 将选中的完整定义注入上下文
        ↓
模型执行 tools/call

这与“缓存工具目录”并不冲突。缓存解决的是减少 Server 请求;渐进式发现解决的是减少模型上下文输入。官方客户端建议也将两者分别作为缓存和工具发现策略处理。(modelcontextprotocol.io)


4. 请求并发:一个 Client 可以复用传输,但不能混淆请求上下文

JSON-RPC 请求通过 id 关联响应。Client 可以在同一个传输上并发执行:

id=101 tools/call(search)
id=102 tools/call(get_document)
id=103 resources/read

响应可以乱序返回:

id=102 response
id=101 response
id=103 response

因此 Client 内部至少需要一个 pending map:

pending[id] = {
  resolve,
  reject,
  deadline,
  abortSignal,
  method,
  serverKey,
  retryPolicy,
  toolName
}

响应到达时:

  1. 读取 JSON-RPC id
  2. pending 中找到对应请求;
  3. 校验请求是否仍处于等待状态;
  4. 删除 pending 项;
  5. 交付结果或错误;
  6. 更新指标和熔断统计。

如果响应中出现未知 id,不能直接交给当前请求。它可能来自:

  • 传输重连后的迟到响应;
  • Server 实现错误;
  • 请求 ID 复用错误;
  • 多个 Client 错误地共享了一个消息分发器。

Client 应记录该事件并丢弃或进入协议错误处理,不能“猜测”它属于哪个调用。

4.1 请求级上下文

每个 MCP 请求都应有独立的请求上下文:

type RequestContext = {
  requestId: string;
  serverKey: string;
  protocolVersion: string;
  authContextHash: string;
  deadline: number;
  signal: AbortSignal;
  attempt: number;
  idempotencyClass: "read" | "write" | "unknown";
};

尤其不能把以下对象挂在全局 Client 上:

  • 当前用户确认状态;
  • 当前 Agent 任务 ID;
  • 某一次工具调用的取消信号;
  • 某一个租户的授权上下文;
  • 某个工具的临时参数。

否则同一个 Server 上的并行 Agent 任务会发生上下文串线。


5. 取消:停止等待、发送取消意图和回滚副作用是三件事

5.1 取消的三个层次

层次一:本地取消等待

Client 不再等待结果:

用户取消
  ↓
AbortSignal 触发
  ↓
Promise 拒绝 AbortError
  ↓
释放模型调用槽位

这只改变 Client 行为,Server 可能仍在执行。

层次二:协议级取消

在旧版 MCP 和 stdio 传输中,Client 可以发送:

{
  "jsonrpc": "2.0",
  "method": "notifications/cancelled",
  "params": {
    "requestId": "call-42",
    "reason": "user_cancelled"
  }
}

这个通知没有响应,表示“请求方希望取消请求”。取消是协作式的,Server 是否能够立即停止,取决于具体工具实现。

在 2026-07-28 的 Streamable HTTP 中,客户端中止进行中的请求会关闭该请求的 SSE 响应流,这是现代 HTTP 绑定下的取消信号;不再通过 POST notifications/cancelled 表达。旧版连接和 stdio 仍可能使用取消通知。(ts.sdk.modelcontextprotocol.io)

层次三:业务级终止或回滚

例如:

  • 取消一个数据库查询;
  • 终止一个子进程;
  • 撤销一个异步任务;
  • 回滚已写入的文件;
  • 撤销一笔外部支付。

这些不是 MCP 通用取消机制能够保证的,而是工具自身的业务语义。一个“删除文件”的工具即使收到取消,也可能已经删除了一部分文件;Client 不应向用户承诺协议取消必然回滚。

5.2 取消的正确时序

sequenceDiagram
    participant U as User
    participant H as Host
    participant C as MCP Client
    participant S as MCP Server
    participant T as Tool

    H->>C: tools/call(requestId=42)
    C->>S: JSON-RPC request 42
    S->>T: 执行工具
    U->>H: 取消
    H->>C: abort signal
    C-->>H: 立即结束等待
    C-->>S: 现代 HTTP:关闭响应流
    S-->>T: 尝试协作式停止
    T-->>S: 停止、完成或继续运行

关键点是 C-->>H 可以先于 Server 的任何结果发生。Host 需要把结果状态记录为:

client_cancelled

而不是:

server_failed

如果之后仍收到 Server 响应,应将其视为迟到响应,不能再次完成已经结束的调用。

5.3 取消与重试的关系

取消请求不得触发自动重试:

if (error instanceof AbortError) {
  throw error;
}

原因很直接:用户取消代表明确的控制意图,而重试会重新触发网络请求,甚至重新触发副作用。

对于现代 HTTP,关闭响应流后,Client 通常无法仅凭网络层判断 Server 是否已经开始执行工具。因此:

  • 只读查询可以在用户再次发起时重新执行;
  • 写操作必须依赖幂等键或业务状态查询;
  • 未知状态的操作不应自动重试。

6. 重试:重试的是传输失败,不是所有错误

6.1 先区分四类失败

A. 参数或能力错误

例如:

  • 工具不存在;
  • 参数不符合 inputSchema
  • Server 未声明所需能力;
  • 请求使用了 Server 不支持的协议版本。

这类错误重试同一个请求通常没有意义。应刷新能力、修正参数或降级协议。

MCP 使用 JSON-RPC 标准错误码以及 MCP 定义的错误码。2026-07-28 包含 -32022 UnsupportedProtocolVersion-32021 MissingRequiredClientCapability 等;本地 SDK 超时并没有一个统一的 MCP 协议错误码,不应伪装成 Server 返回的 JSON-RPC 错误。(modelcontextprotocol.io)

B. 认证和授权错误

例如:

  • 凭据过期;
  • scope 不足;
  • 资源访问被拒绝。

除非完成刷新令牌或重新授权,否则重试只会重复失败,甚至触发限流。

C. 传输失败

例如:

  • TCP 连接重置;
  • stdio 子进程退出;
  • HTTP 连接超时;
  • 响应体截断;
  • 临时 DNS 或代理错误。

这类错误可能适合重试,但必须结合操作幂等性。

D. Server 已执行但 Client 未收到响应

这是最危险的一类:

Client → Server: 写操作
Server: 已完成写入
网络断开
Client: 认为超时
Client: 自动重试

如果工具不是幂等的,可能产生重复订单、重复消息或重复文件。

6.2 幂等性分类

把工具调用分成三类:

类型 示例 自动重试
只读幂等 搜索、读取资源、查询状态 可以
有条件幂等 使用业务幂等键创建订单 有条件可以
非幂等写入 发送消息、扣款、追加记录 默认不可以

可以用以下判定式:

retry=transportErrortransient¬cancelled(readidempotencyKeyPresent)retry = transportError \land transient \land \neg cancelled \land \left( read \lor idempotencyKeyPresent \right)

其中:

  • transportError 表示请求没有得到可信的协议结果;
  • transient 表示错误可能短时间后恢复;
  • cancelled 防止用户取消后被重新执行;
  • read 表示操作无可见副作用;
  • idempotencyKeyPresent 表示服务端能识别重复请求。

6.3 指数退避与截止时间

设第 nn 次重试前的等待时间为:

dn=min(dmax,d02n)+jitterd_n = \min(d_{\max}, d_0 \cdot 2^n) + jitter

例如:

  • d0=200msd_0 = 200\text{ms}
  • dmax=5sd_{\max} = 5\text{s}
  • jitter 为随机抖动

重试不应只由“最大次数”控制,还要受请求截止时间约束:

now+dn+estimatedRequestTime<deadlinenow + d_n + estimatedRequestTime < deadline

否则客户端可能在用户请求已经超时后才发出最后一次重试。

一个可执行的纯 TypeScript 重试器如下:

type RetryDecision = {
  retry: boolean;
  delayMs?: number;
};

function decideRetry(
  attempt: number,
  now: number,
  deadline: number,
  kind: "read" | "write" | "unknown",
  error: { category: string; cancelled?: boolean },
  hasIdempotencyKey: boolean,
): RetryDecision {
  if (error.cancelled) {
    return { retry: false };
  }

  const retryableTransportError =
    error.category === "connection_reset" ||
    error.category === "timeout" ||
    error.category === "response_truncated";

  const safeOperation =
    kind === "read" || (kind === "write" && hasIdempotencyKey);

  if (!retryableTransportError || !safeOperation) {
    return { retry: false };
  }

  const maxAttempts = 3;
  if (attempt >= maxAttempts) {
    return { retry: false };
  }

  const base = Math.min(5_000, 200 * 2 ** attempt);
  const jitter = Math.floor(Math.random() * 100);
  const delayMs = base + jitter;

  if (now + delayMs >= deadline) {
    return { retry: false };
  }

  return { retry: true, delayMs };
}

这段代码只负责“是否值得重试”,不负责重新建立连接、重新获取 Token 或恢复订阅。实际 Client 应将这些动作拆开,避免一个重试器承担所有故障恢复责任。

6.4 重试时是否复用请求 ID

同一次 JSON-RPC 请求重试时,是否复用原始 id 取决于 SDK 和传输实现,但工程上不能仅靠 id 做业务去重。因为 Server 可能已经处理过请求,而重连后 Client 无法保证 Server 还记得这个 id

对于有副作用的调用,应优先使用业务幂等键:

{
  "name": "create_order",
  "arguments": {
    "order": {
      "sku": "A-100",
      "quantity": 1
    },
    "idempotency_key": "agent-run-8f2c-order-1"
  }
}

业务幂等键必须由 Server 认可并持久化,否则它只是一个普通参数,不能真正防止重复执行。


7. 连接恢复:重建传输,不盲目重放请求

7.1 stdio 的恢复路径

stdio Client 通常负责启动 MCP Server 子进程:

spawn process
    ↓
stdin/stdout 建立
    ↓
discover 或 initialize
    ↓
ready
    ↓
子进程退出
    ↓
记录退出码和 stderr
    ↓
释放 pending 请求
    ↓
按策略重新 spawn

一个子进程退出时,所有尚未收到响应的请求都应进入明确状态:

transport_lost

不能简单地全部标记为“失败后可重试”,因为其中可能有已执行但响应丢失的写操作。

重启 Server 时还需要:

  • 清理旧 stdin/stdout 引用;
  • 解除旧事件监听器;
  • 防止旧进程迟到输出污染新连接;
  • 保存 stderr 片段用于诊断;
  • 限制连续重启频率;
  • 检查命令路径、工作目录和环境变量;
  • 防止配置文件中的参数注入。

本地 Server 的命令、参数和授权目录属于高风险配置。MCP 官方本地连接示例也强调由 Host 配置并启动 Server,同时让用户明确控制可访问的目录和操作。(modelcontextprotocol.io)

7.2 Streamable HTTP 的恢复路径

现代 Streamable HTTP 的普通请求不依赖 Mcp-Session-Id,因此恢复的核心是:

连接错误
    ↓
保留 Server 逻辑身份和缓存
    ↓
重新发送下一次请求
    ↓
每个请求携带协议版本与 Client 元数据

不需要恢复一个已经丢失的 MCP 协议会话。

但通知订阅流是例外:subscriptions/listen 本身是长期流,流断开后需要重新订阅。重新订阅时应:

  1. 判断是正常关闭还是异常断开;
  2. 正常关闭则不再重订阅;
  3. 异常断开则按退避策略重订阅;
  4. 重新检查 Server 能力和订阅过滤器;
  5. 重新标记工具、提示或资源缓存的可用性。

官方 SDK 的现代迁移说明区分了 graceful close 和 remote disconnect:服务端正常关闭会返回空的订阅结果;没有结果就关闭则表示非预期断开,Client 如果仍需接收事件,应重新监听。(ts.sdk.modelcontextprotocol.io)


8. 隔离:每个 Server、租户、任务都应有清晰边界

“隔离”不是只给每个 Server 起一个名字,而是阻断错误传播和权限混用。

8.1 Server 级隔离

每个 MCP Server 应有独立的 Client 实例:

type ServerClient = {
  serverKey: string;
  transport: Transport;
  capabilityCache: CapabilityCache;
  pending: Map<string, PendingRequest>;
  breaker: CircuitBreaker;
  concurrency: Semaphore;
  authProvider: AuthProvider;
};

不要让多个 Server 共享:

  • 一个全局 pending map;
  • 一个全局能力缓存;
  • 一个全局认证 Token;
  • 一个全局取消控制器;
  • 一个全局熔断状态。

否则 A Server 的异常可能导致 B Server 的调用全部被熔断;A 用户的工具目录也可能进入 B 用户的模型上下文。

8.2 用户和租户级隔离

能力缓存的权限维度比连接维度更重要。

错误示例:

cache["https://mcp.example.com/tools"] = tools

如果这个缓存先由管理员请求填充,普通用户随后命中缓存,就可能看到本不具备权限的工具。

正确方向是:

cache[
  serverKey
  + tenantId
  + principalId
  + scopeHash
  + protocolVersion
] = tools

即使工具名称相同,也不能跳过调用时授权。工具列表只是“可发现能力”,不是“永久授权票据”。

8.3 任务级隔离

一个 Host 可能同时运行多个 Agent 任务。每个任务应拥有:

  • 独立的总截止时间;
  • 独立的取消信号;
  • 独立的工具调用预算;
  • 独立的上下文和用户确认状态;
  • 独立的审计关联 ID。

但任务可以共享只读能力缓存,前提是缓存作用域允许共享。可以将数据分成两层:

全局只读层:
  server/discover
  public tools/list
  serverInfo

任务私有层:
  用户授权后的工具集合
  requestState
  工具调用结果
  用户确认状态

8.4 资源根目录隔离

Client 可能向 Server 提供 roots,用于描述 Server 可以访问的本地目录。Root 不应被当作普通字符串列表直接拼接到文件系统路径上。

至少要验证:

  1. URI 能否解析;
  2. 是否位于允许的根目录;
  3. 是否存在路径穿越;
  4. 是否访问了符号链接指向的外部目录;
  5. 是否跨越租户或用户边界;
  6. Server 是否真正需要该目录。

MCP 的架构原则要求 Server 只能获得必要的上下文,不能读取整个对话,也不能直接看到其他 Server 的上下文;Host 负责维护这些安全边界。(modelcontextprotocol.io)


9. 并发和背压:重试会放大故障,必须限制调用量

设某个 Server 的并发上限为 CC,当前正在执行的调用数为 RR。当:

RCR \geq C

新调用不能继续无限创建网络请求,而应进入队列、降级或快速失败。

一个 Server 级信号量可以表达为:

class Semaphore {
  private available: number;
  private waiters: Array<() => void> = [];

  constructor(size: number) {
    if (size <= 0) throw new Error("size must be positive");
    this.available = size;
  }

  async acquire(): Promise<() => void> {
    if (this.available > 0) {
      this.available--;
      return () => this.release();
    }

    await new Promise<void>((resolve) => {
      this.waiters.push(resolve);
    });

    this.available--;
    return () => this.release();
  }

  private release() {
    this.available++;
    const next = this.waiters.shift();
    next?.();
  }
}

使用时必须保证异常路径也释放许可:

const release = await semaphore.acquire();

try {
  return await callTool();
} finally {
  release();
}

重试请求是否占用新的并发槽位,要提前定义。通常应让原始调用在重试过程中持续占用一个逻辑槽位,但每次实际传输请求仍需受到底层连接并发限制。否则一次调用的三次重试会被统计成三项独立业务调用,导致监控和限流失真。


10. 一个完整的 Client 调用流程

下面把一次工具调用展开:

sequenceDiagram
    participant H as Host
    participant C as Client
    participant K as Capability Cache
    participant S as MCP Server
    participant A as Authorization

    H->>C: callTool(serverKey, toolName, args)
    C->>K: 查找工具定义
    alt 缓存有效
        K-->>C: 返回工具定义
    else 缓存缺失或过期
        C->>S: server/discover 或 tools/list
        S-->>C: 能力、工具目录、ttlMs
        C->>K: 写入缓存
    end

    C->>C: 校验工具存在与输入 Schema
    C->>A: 检查本地授权策略
    A-->>C: 允许或拒绝
    C->>S: tools/call
    S-->>C: result 或 error
    C-->>H: 工具结果

每一步的失败含义不同:

失败位置 典型结果 Client 行为
缓存读取 数据损坏 删除缓存,重新发现
server/discover 版本不支持 选择共同版本或停止
tools/list 网络超时 读请求可有限重试
本地 Schema 校验 参数错误 不发送请求,返回参数错误
本地授权 用户拒绝 不发送请求
tools/call 参数错误 -32602 等协议错误 修正参数,不重试原请求
tools/call 传输断开 状态未知 只读可重试,写操作进入未知状态
响应 Schema 校验 输出不符合约定 记录协议/Server 错误,不把结果当可信结构

输入 Schema 校验应在调用前完成,但不能把它当作 Server 授权的替代品。Schema 只能说明参数形状是否符合定义,不能说明用户是否有权执行该工具。

MCP 使用 JSON Schema;无 $schema 时默认支持 JSON Schema 2020-12。Client 应对工具输入和结构化输出进行边界控制,特别是限制过深的组合 Schema 和过长的验证时间,避免恶意 Schema 消耗资源。(modelcontextprotocol.io)


11. 错误模型:把协议错误、远端错误和本地错误分开

推荐使用三层错误类型:

type ClientError =
  | {
      kind: "protocol";
      code: number;
      message: string;
      data?: unknown;
    }
  | {
      kind: "transport";
      category:
        | "timeout"
        | "connection_reset"
        | "process_exit"
        | "response_truncated";
      cause?: unknown;
    }
  | {
      kind: "local";
      category:
        | "cancelled"
        | "deadline_exceeded"
        | "cache_miss"
        | "validation_failed"
        | "authorization_denied";
      cause?: unknown;
    };

不要把本地超时伪装成:

{
  "error": {
    "code": -32603,
    "message": "Internal error"
  }
}

因为这会让上层误以为 Server 返回了 JSON-RPC 错误,也会错误触发协议级重试逻辑。规范明确指出,SDK 内部产生的超时等本地错误目前没有统一 MCP 错误码;如果实现使用 JSON-RPC 形状包装,也必须保证不会与对端错误混淆。(modelcontextprotocol.io)

11.1 工具返回错误与 JSON-RPC 错误

还要区分:

JSON-RPC error
    = 请求本身没有按协议完成

工具结果中的 isError
    = tools/call 成功到达 Server,但工具业务执行失败

前者通常说明协议、参数、权限或 Server 层面失败;后者可能是“文件不存在”“搜索服务返回业务错误”等正常工具结果。Client 不能把所有 isError 都当成传输失败进行重试。


12. 断路器:保护 Host,而不是隐藏 Server 故障

当某个 Server 连续失败时,Client 可以使用断路器:

Closed
  │ 连续失败达到阈值
  ▼
Open
  │ 冷却时间到
  ▼
HalfOpen
  │ 探测成功          │ 探测失败
  ├───────────────────┴──────┐
  ▼                          ▼
Closed                      Open

断路器应至少按以下维度隔离:

(serverKey, tenantId, operationClass)

是否要把不同工具分别熔断,取决于 Server 的故障特征:

  • 如果整个 Server 进程不可用,按 Server 熔断;
  • 如果只有一个后端 API 持续失败,按工具或操作类别熔断;
  • 如果只是某个用户权限错误,不应熔断整个 Server。

错误的做法是看到任意 tools/call 失败就熔断所有能力。一个工具的业务错误不应阻断同一 Server 上的资源读取和其他只读工具。


13. 可观测性:必须能回答“失败发生在哪里”

每一次调用至少记录以下字段:

traceId
agentRunId
serverKey
transport
protocolVersion
requestId
method
toolName
attempt
queueWaitMs
networkTimeMs
serverProcessingTimeMs(如果可得)
cacheHit
cacheAgeMs
cancelled
retryable
errorKind
errorCode

日志中不要记录完整工具参数,尤其是:

  • Token;
  • 密码;
  • 文件内容;
  • 个人信息;
  • 数据库查询结果;
  • 用户私有资源 URI。

2026-07-28 规范定义了 W3C Trace Context 相关的 _meta 键,例如 traceparenttracestatebaggage,可用于把 Host、Client、Server 以及下游调用关联到同一条分布式追踪链路。(modelcontextprotocol.io)

诊断时应先按以下顺序判断:

  1. 是否在 Client 本地被拒绝?
    检查 Schema、权限、截止时间和并发队列。

  2. 是否发出了请求?
    检查传输日志和 request ID。

  3. HTTP 头部与 JSON body 是否一致?
    现代 Streamable HTTP 的 Mcp-MethodMcp-Name 和协议版本不一致会被拒绝。

  4. Server 是否返回 JSON-RPC 响应?
    没有响应更像传输错误;有响应但包含 error 才是协议层失败。

  5. 工具是否已执行但响应丢失?
    对写操作这是最关键的未知状态判断。

  6. 能力缓存是否过期或跨权限复用?
    检查缓存键、TTL、scope 和最近一次变化通知。


14. 典型反例

反例一:只按 URL 缓存工具列表

toolsCache["https://server.example/mcp"]

结果是管理员看到的工具定义可能被普通用户复用,造成能力泄露或后续调用失败。

修复:缓存键必须包含授权主体、scope、租户和协议版本。

反例二:所有错误都重试三次

for (let i = 0; i < 3; i++) {
  await callTool();
}

这会重试:

  • 参数错误;
  • 用户拒绝;
  • 无权限;
  • 工具业务失败;
  • 非幂等写操作。

结果不是提高可用性,而是放大副作用。

修复:先分类错误,再结合操作幂等性和截止时间决定。

反例三:超时后立即重试写操作

await withTimeout(createOrder(), 3000)
  .catch(() => createOrder());

第一次请求可能已创建订单,只是响应在 3 秒后丢失;第二次请求会创建第二个订单。

修复:使用业务幂等键,或者先查询订单状态,不要盲目重放。

反例四:把 HTTP 连接当成 MCP 会话

if (sameTcpConnection) {
  assumeSameCapabilities();
}

2026-07-28 的请求是自描述的,Server 不应依赖同一连接建立协议上下文。连接复用不能替代每请求元数据,也不能成为权限判断依据。(modelcontextprotocol.io)

反例五:取消后继续等待并自动重试

signal.addEventListener("abort", () => {
  retry();
});

取消是用户控制意图,不是瞬时网络抖动。此实现可能在用户明确停止操作后重新执行工具。

修复:取消直接结束本地调用,并根据协议版本发送适当的取消信号;不触发自动重试。


15. 推荐的 Client 内部接口

可以把具体 SDK 隔离在一层内部接口之后:

interface McpTransport {
  send(
    request: JsonRpcRequest,
    options: {
      signal: AbortSignal;
      headers?: Record<string, string>;
    },
  ): Promise<JsonRpcResponse>;

  close(): Promise<void>;
}

interface CapabilityStore {
  get(key: string): Promise<CachedCapabilities | undefined>;
  put(key: string, value: CachedCapabilities): Promise<void>;
  markStale(key: string): Promise<void>;
}

interface McpClient {
  discover(signal?: AbortSignal): Promise<DiscoverResult>;
  listTools(signal?: AbortSignal): Promise<Tool[]>;
  callTool(
    name: string,
    arguments_: Record<string, unknown>,
    options?: {
      signal?: AbortSignal;
      deadline?: number;
      idempotencyKey?: string;
    },
  ): Promise<ToolResult>;
  close(): Promise<void>;
}

这个接口有几个重要特征:

  • discoverlistToolscallTool 是不同操作;
  • signal 属于单次请求;
  • deadline 属于单次请求;
  • idempotencyKey 只对业务调用有效;
  • close 关闭的是传输和本地资源,不代表撤销已经提交的业务操作;
  • 能力缓存由 Client 使用,但不混入 Transport。

实际使用官方 SDK 时,SDK 通常负责 JSON-RPC 编解码、传输适配、请求关联和部分取消逻辑;Host 仍然需要自己实现缓存策略、权限边界、重试分类、幂等判断和业务级隔离。官方 SDK 为 TypeScript、Python、C#、Go、Rust 等提供客户端和本地/远程传输支持,但不同语言的 API 形态和版本能力应以对应 SDK 文档为准。(modelcontextprotocol.io)


16. 生产基线

一个可用于生产的 MCP Client,至少应满足以下可验证条件:

  1. 连接

    • 能区分现代无会话和旧版初始化握手;
    • 能处理协议版本不兼容;
    • 能在 stdio Server 退出后回收并按策略重启;
    • 能在 HTTP 请求失败后重新发送后续请求。
  2. 能力

    • 缓存 server/discovertools/list
    • 缓存键包含协议版本和授权边界;
    • 尊重 ttlMscacheScope
    • 支持列表变化后的失效和 single-flight 刷新;
    • 不把工具目录直接等同于授权。
  3. 取消

    • 本地取消能立即释放 Host 等待;
    • 现代 HTTP、旧版连接和 stdio 使用正确的取消路径;
    • 取消不触发自动重试;
    • 对副作用操作明确记录未知执行状态。
  4. 重试

    • 只重试临时传输失败;
    • 受截止时间、最大次数和退避控制;
    • 写操作需要业务幂等键或状态确认;
    • 不重试参数、权限和用户拒绝错误。
  5. 隔离

    • 每个 Server 有独立 Client、缓存、熔断和并发限制;
    • 每个任务有独立取消信号和截止时间;
    • 用户、租户和授权 scope 不会跨缓存复用;
    • roots、工具参数和返回结果都经过边界控制。
  6. 诊断

    • request ID、trace ID、attempt 和错误类别可追踪;
    • 能区分本地错误、传输错误、JSON-RPC 错误和工具业务错误;
    • 不在日志中泄露凭据和私有上下文。

MCP Client 的核心不是“保持连接不断”,而是让每个请求在正确的协议版本、能力视图、授权边界和故障语义下完成。现代 MCP 将协议核心推向无状态之后,连接管理反而更容易水平扩展;但能力缓存、取消、重试和隔离必须更加精确,因为这些责任不再能被一个隐式会话替代。


系列导航与关联阅读

官方资料

本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。