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 不应承担以下职责:
- 不替代 Host 做用户授权决策。 工具调用通常涉及外部系统、数据访问或代码执行,Host 仍应让用户了解并确认高风险操作。
- 不把连接等同于对话会话。 2026-07-28 规范明确采用无状态的请求模型;Server 不应依赖前一个请求建立协议版本、能力或身份上下文。
- 不把缓存等同于权限。 缓存只能复用之前获得的能力描述,不能绕过当前请求的认证、授权和工具调用检查。
- 不把超时等同于服务端已停止执行。 客户端停止等待,并不必然意味着服务端已经回滚或停止副作用。
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_id、job_id或browser_id。
2.2 2025-11-25 及更早版本:握手产生协议会话
旧版 MCP 使用初始化握手:
Client Server
│ │
│ initialize │
│─────────────────────────────>│
│ │
│ 选择协议版本、能力、身份 │
│<─────────────────────────────│
│ │
│ initialized │
│─────────────────────────────>│
│ │
│ tools/list / tools/call │
│─────────────────────────────>│
在旧生命周期中,HTTP Server 可能通过 Mcp-Session-Id 绑定后续请求;重连通常意味着重新初始化,并且可能需要新的会话标识。支持双时代的 Client 不能把现代请求格式强行用于旧 Server。
推荐的兼容策略是:
- 优先尝试
2026-07-28; - 如果 Server 返回结构化的
UnsupportedProtocolVersionError,从supported列表中选择共同版本; - 如果是 HTTP 且服务端表现为旧生命周期,按实现策略回退到
initialize; - 如果是 stdio,由于没有 HTTP 状态码,优先调用
server/discover进行探测; - 旧版连接完成握手后,所有请求使用旧版协议语义。
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)
第三层:工具定义
工具定义包含:
namedescriptioninputSchema- 可选的
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防止不同版本的响应结构混用;authenticatedPrincipal和authorizationScope防止越权复用;tenantId用于多租户系统;clientCapabilityProfile防止把针对不同 Client 能力的结果混用;extensionProfile防止把扩展支持情况不同的响应错误解释。
对于 tools/list,还建议保存一个内容摘要:
ToolCatalog {
key
tools
contentHash
fetchedAt
expiresAt
cacheScope
serverInfo
protocolVersion
}
contentHash 不只是用于去重,也用于判断模型上下文是否真的发生了变化。确定性排序可以避免工具列表内容相同但顺序变化,进而导致 Prompt Cache 或模型输入缓存失效。(modelcontextprotocol.io)
3.3 ttlMs 与 cacheScope
2026-07-28 的列表和发现结果可以携带 ttlMs 与 cacheScope:
{
"resultType": "complete",
"tools": [
{
"name": "search",
"description": "Search documents",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string" }
},
"required": ["query"]
}
}
],
"ttlMs": 300000,
"cacheScope": "private"
}
Client 的过期时间可以计算为:
其中:
- 是响应接收时间;
ttlMs是 Server 建议的有效期;- 是客户端自身允许的最长缓存时间。
取最小值的原因是: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,把所有工具定义一次性放入模型上下文会造成两个问题:
- 工具定义消耗上下文窗口;
- 无关工具增加模型选择错误的概率。
渐进式工具发现的流程是:
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
}
响应到达时:
- 读取 JSON-RPC
id; - 从
pending中找到对应请求; - 校验请求是否仍处于等待状态;
- 删除 pending 项;
- 交付结果或错误;
- 更新指标和熔断统计。
如果响应中出现未知 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 幂等性分类
把工具调用分成三类:
| 类型 | 示例 | 自动重试 |
|---|---|---|
| 只读幂等 | 搜索、读取资源、查询状态 | 可以 |
| 有条件幂等 | 使用业务幂等键创建订单 | 有条件可以 |
| 非幂等写入 | 发送消息、扣款、追加记录 | 默认不可以 |
可以用以下判定式:
其中:
transportError表示请求没有得到可信的协议结果;transient表示错误可能短时间后恢复;cancelled防止用户取消后被重新执行;read表示操作无可见副作用;idempotencyKeyPresent表示服务端能识别重复请求。
6.3 指数退避与截止时间
设第 次重试前的等待时间为:
例如:
jitter为随机抖动
重试不应只由“最大次数”控制,还要受请求截止时间约束:
否则客户端可能在用户请求已经超时后才发出最后一次重试。
一个可执行的纯 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 本身是长期流,流断开后需要重新订阅。重新订阅时应:
- 判断是正常关闭还是异常断开;
- 正常关闭则不再重订阅;
- 异常断开则按退避策略重订阅;
- 重新检查 Server 能力和订阅过滤器;
- 重新标记工具、提示或资源缓存的可用性。
官方 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 不应被当作普通字符串列表直接拼接到文件系统路径上。
至少要验证:
- URI 能否解析;
- 是否位于允许的根目录;
- 是否存在路径穿越;
- 是否访问了符号链接指向的外部目录;
- 是否跨越租户或用户边界;
- Server 是否真正需要该目录。
MCP 的架构原则要求 Server 只能获得必要的上下文,不能读取整个对话,也不能直接看到其他 Server 的上下文;Host 负责维护这些安全边界。(modelcontextprotocol.io)
9. 并发和背压:重试会放大故障,必须限制调用量
设某个 Server 的并发上限为 ,当前正在执行的调用数为 。当:
新调用不能继续无限创建网络请求,而应进入队列、降级或快速失败。
一个 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 键,例如 traceparent、tracestate 和 baggage,可用于把 Host、Client、Server 以及下游调用关联到同一条分布式追踪链路。(modelcontextprotocol.io)
诊断时应先按以下顺序判断:
-
是否在 Client 本地被拒绝?
检查 Schema、权限、截止时间和并发队列。 -
是否发出了请求?
检查传输日志和 request ID。 -
HTTP 头部与 JSON body 是否一致?
现代 Streamable HTTP 的Mcp-Method、Mcp-Name和协议版本不一致会被拒绝。 -
Server 是否返回 JSON-RPC 响应?
没有响应更像传输错误;有响应但包含error才是协议层失败。 -
工具是否已执行但响应丢失?
对写操作这是最关键的未知状态判断。 -
能力缓存是否过期或跨权限复用?
检查缓存键、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>;
}
这个接口有几个重要特征:
discover、listTools和callTool是不同操作;signal属于单次请求;deadline属于单次请求;idempotencyKey只对业务调用有效;close关闭的是传输和本地资源,不代表撤销已经提交的业务操作;- 能力缓存由 Client 使用,但不混入 Transport。
实际使用官方 SDK 时,SDK 通常负责 JSON-RPC 编解码、传输适配、请求关联和部分取消逻辑;Host 仍然需要自己实现缓存策略、权限边界、重试分类、幂等判断和业务级隔离。官方 SDK 为 TypeScript、Python、C#、Go、Rust 等提供客户端和本地/远程传输支持,但不同语言的 API 形态和版本能力应以对应 SDK 文档为准。(modelcontextprotocol.io)
16. 生产基线
一个可用于生产的 MCP Client,至少应满足以下可验证条件:
-
连接
- 能区分现代无会话和旧版初始化握手;
- 能处理协议版本不兼容;
- 能在 stdio Server 退出后回收并按策略重启;
- 能在 HTTP 请求失败后重新发送后续请求。
-
能力
- 缓存
server/discover和tools/list; - 缓存键包含协议版本和授权边界;
- 尊重
ttlMs与cacheScope; - 支持列表变化后的失效和 single-flight 刷新;
- 不把工具目录直接等同于授权。
- 缓存
-
取消
- 本地取消能立即释放 Host 等待;
- 现代 HTTP、旧版连接和 stdio 使用正确的取消路径;
- 取消不触发自动重试;
- 对副作用操作明确记录未知执行状态。
-
重试
- 只重试临时传输失败;
- 受截止时间、最大次数和退避控制;
- 写操作需要业务幂等键或状态确认;
- 不重试参数、权限和用户拒绝错误。
-
隔离
- 每个 Server 有独立 Client、缓存、熔断和并发限制;
- 每个任务有独立取消信号和截止时间;
- 用户、租户和授权 scope 不会跨缓存复用;
- roots、工具参数和返回结果都经过边界控制。
-
诊断
- request ID、trace ID、attempt 和错误类别可追踪;
- 能区分本地错误、传输错误、JSON-RPC 错误和工具业务错误;
- 不在日志中泄露凭据和私有上下文。
MCP Client 的核心不是“保持连接不断”,而是让每个请求在正确的协议版本、能力视图、授权边界和故障语义下完成。现代 MCP 将协议核心推向无状态之后,连接管理反而更容易水平扩展;但能力缓存、取消、重试和隔离必须更加精确,因为这些责任不再能被一个隐式会话替代。
系列导航与关联阅读
- 系列入口:Agent 工程完整路线:从运行循环、记忆与协议到安全、评测和生产交付
- 上一篇:MCP Server 工程:能力注册、Context、并发、错误和部署
- 下一篇:Agent2Agent 协议:Agent Card、Task、Message、Artifact 和互操作
- 延伸:MCP 传输:stdio、Streamable HTTP、会话、重连和代理
官方资料
本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论