26% 的 MCP 开发者把「错误处理和可靠性」列为头号痛点(Zuplo State of MCP 2026 报告)。工具一报错,Agent 就死循环或直接摆烂------这不是协议的问题,是你的 Server 没做防御性设计。
为什么 MCP Server 的错误处理比你想的重要 10 倍
Model Context Protocol(MCP)已经成为 AI Agent 连接外部工具的事实标准。GitHub Copilot、Cursor、各大 AI IDE 与 Agent 框架------几乎所有主流 AI 开发工具都通过 MCP 与外部世界对话。
但有个问题:大多数 MCP Server 的错误处理停留在「throw new Error()」的水平。
一个真实场景:你的 Agent 调用了 3 个 MCP 工具完成一个多步骤任务。第 2 个工具因为外部 API 超时返回了错误。Agent 看到错误后做了什么?它重试了。又失败了。又重试了。5 次之后,LLM 的上下文窗口被 5 条相同的错误消息塞满,Agent 开始胡言乱语,最终输出一堆垃圾。用户看到的结果比不调用工具还差。
这不是 MCP 协议的问题,也不是 LLM 的问题------是你的 MCP Server 在错误处理上偷了懒。
根据 Zuplo 2026 年发布的 State of MCP 报告,26% 的开发者将「错误处理和可靠性」列为构建 MCP Server 的最大挑战,与「安全」并列第一。arxiv 上的一篇论文(2609.35381)更直接指出:大多数 MCP Server 的错误消息是写给人类开发者看的,不是给 LLM 看的------这是 LLM Agent 工具调用失败的首要原因之一。
传统 API 的错误处理范式在 MCP 世界彻底失效。HTTP 400 配上一句 "Invalid input" 对人类够用,但 LLM 看到这个消息会:
- 不知道该怎么修正参数
- 不知道该换一个工具还是放弃
- 可能编造一个假参数再试一次
MCP Server 的错误不是给人看的日志,而是给 LLM 看的行动指令。 这是整篇文章的核心论点,理解了这一点,后面所有的工程设计都顺理成章。
第一层:错误分类------不是所有错误都该重试
MCP 协议基于 JSON-RPC 2.0,定义了一组标准错误码。但大多数 Server 只用了一个:-32000(ServerError),把所有问题一股脑扔出去。
这是错误处理的第一步就错了。不同性质的错误需要完全不同的处理策略。
在生产实践中,我把 MCP Server 的错误分成 5 类:
| 错误类型 | 性质 | LLM 应该怎么做 | MCP 错误码建议 |
|---|---|---|---|
| 参数校验错误 | 确定性失败,重试无意义 | 修正参数后重试,或换工具 | -32602 (InvalidParams) |
| 外部服务暂不可用 | 暂时性失败,重试有意义 | 等待后重试 | -32001 (自定义 TransientError) |
| 权限不足 | 确定性失败,需要人工介入 | 停止调用,请求用户授权 | -32003 (自定义 PermissionDenied) |
| 资源不存在 | 确定性失败 | 换一个查询条件或停止 | -32004 (自定义 NotFound) |
| 内部异常 | 未知失败 | 跳过此工具,用备选方案 | -32603 (InternalError) |
关键洞察:错误的「可重试性」决定了 LLM 的下一步行动。 如果你不告诉 LLM 这个错误值不值得重试,它就会自己猜------而 LLM 猜错的概率非常高。
在 Rust 的 PMCP SDK 中,错误分类的实现很清晰:
rust
use pmcp::McpError;
// 参数错误:告诉 LLM 具体哪个参数有问题、期望什么
fn invalid_sku_error(sku: &str) -> McpError {
McpError::new(
-32602,
format!(
"SKU '{}' 格式无效。期望格式:大写字母+数字,长度 3-16。示例:'WIDGET-42', 'BOLT-7'",
sku
),
)
}
// 暂时性错误:告诉 LLM 这是暂时的,建议等待
fn transient_api_error(cause: &str) -> McpError {
McpError::new(
-32001,
format!(
"外部服务暂时不可用({})。建议等待 5-10 秒后重试。如果连续失败 3 次,请跳过此工具并使用备选方案。",
cause
),
)
}
Node.js 中的等价实现:
typescript
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk";
class ToolErrorFactory {
static invalidParam(param: string, got: unknown, expected: string): McpError {
return new McpError(
ErrorCode.InvalidParams,
`参数 '${param}' 无效。收到: ${JSON.stringify(got)}。期望: ${expected}。请修正后重试。`
);
}
static transient(service: string, retryAfterSec: number = 5): McpError {
return new McpError(
-32001, // 自定义 TransientError
`${service} 暂时不可用。建议 ${retryAfterSec} 秒后重试。连续 3 次失败后请跳过。`,
);
}
static notFound(resource: string, id: string): McpError {
return new McpError(
-32004,
`未找到 ${resource} (id: ${id})。请检查 ID 是否正确,或使用 search 工具查找。`,
);
}
}
注意每个错误消息都包含三要素:发生了什么、为什么发生、下一步怎么做。这是与传统 API 错误消息最大的区别。
第二层:LLM-Friendly 错误消息------把错误变成行动指令
arxiv 论文 2609.35381 的核心发现是:MCP Server 的错误消息如果按照传统 API 风格编写(比如 "Run npm install to fix"),LLM 会尝试执行这些命令,即使它根本没有权限或能力。反过来,如果错误消息完全不给指引,LLM 会编造修复方案。
LLM-Friendly 的错误消息需要遵循一个公式:
css
[错误事实] + [对 LLM 能力的判断] + [具体行动建议]
反面教材:
arduino
"Database connection failed: ECONNREFUSED 127.0.0.1:5432"
LLM 看到这个会做什么?它可能会尝试调用其他工具来"修复数据库连接",或者直接告诉用户"请重启 PostgreSQL"。两种都是错误的。
正面写法:
arduino
"数据库连接暂时失败。你无法修复此问题。请跳过需要数据库的工具,
用缓存数据或默认值完成任务。如果用户询问,告知服务暂时不可用。"
区别在哪里?
- 明确告诉 LLM「你无法修复此问题」------阻止它进入修复死循环
- 给出「跳过」「用缓存」「告知用户」三个具体备选行动
- 不暴露内部基础设施细节(IP 地址、端口号对 LLM 毫无意义)
在代码中实现这个模式:
typescript
interface ToolErrorResponse {
isError: true;
content: Array<{
type: "text";
text: string;
}>;
// 扩展字段,帮助 LLM 决策
_meta: {
retryable: boolean; // 是否值得重试
retryAfterMs?: number; // 建议等待时间
alternativeTool?: string; // 备选工具名
userMessage?: string; // 可以展示给用户的消息
};
}
function buildErrorResponse(
error: Error,
context: ErrorContext
): ToolErrorResponse {
const isRetryable = isTransientError(error);
return {
isError: true,
content: [
{
type: "text",
text: isRetryable
? `工具 ${context.toolName} 暂时失败(${error.message})。建议 ${context.retryAfterMs}ms 后重试,最多重试 ${context.maxRetries} 次。`
: `工具 ${context.toolName} 执行失败且无法重试。${context.alternativeAction}`,
},
],
_meta: {
retryable: isRetryable,
retryAfterMs: isRetryable ? context.retryAfterMs : undefined,
alternativeTool: context.alternativeTool,
userMessage: context.userFacingMessage,
},
};
}
MCP 协议的工具返回规范支持 isError: true 标记,客户端(LLM)看到后会将内容作为错误上下文处理。在错误消息中嵌入结构化的 _meta 字段,让 LLM 可以直接读取决策信息,而不需要从自然语言中推理。
第三层:超时控制------别让一个慢工具拖死整个 Agent
MCP 协议本身没有内置的超时机制。一个工具调用可能永远不返回------外部 API 无响应、数据库查询卡死、子进程挂起。如果你的 Agent 同时调用 3 个工具,其中一个无限等待,整个任务就永远完不成。
Octopus Deploy 的工程博客(2026)展示了一个关键设计:通过 ClientSession 的 read_timeout_seconds 参数设置读超时。
python
from datetime import timedelta
from mcp import ClientSession
# 每个 MCP Server 连接设置独立的超时
session = ClientSession(
transport,
read_timeout_seconds=timedelta(seconds=30)
)
但这只解决了「连接级」超时。真正需要控制的是「工具级」超时------不同的工具有不同的合理响应时间。一个文件读取工具 5 秒就够,一个代码生成工具可能需要 60 秒。
在 Server 端实现工具级超时:
typescript
import { Server } from "@modelcontextprotocol/sdk/server";
function withTimeout<T>(
fn: () => Promise<T>,
timeoutMs: number,
toolName: string
): Promise<T> {
return Promise.race([
fn(),
new Promise<never>((_, reject) =>
setTimeout(
() =>
reject(
new McpError(
-32001,
`工具 ${toolName} 在 ${timeoutMs}ms 内未响应。这是一个暂时性超时,建议缩短请求参数后重试,或使用更轻量的查询。`
)
),
timeoutMs
)
),
]);
}
// 使用示例:每个工具设定自己的超时预算
server.tool(
"search_products",
searchSchema,
async (input) => {
return withTimeout(
() => productDb.search(input.query, input.limit),
5000, // 搜索 5 秒超时
"search_products"
);
}
);
server.tool(
"generate_report",
reportSchema,
async (input) => {
return withTimeout(
() => reportEngine.generate(input.dateRange),
60000, // 报告生成 60 秒超时
"generate_report"
);
}
);
超时预算的传递也是一个工程要点。如果你的 Agent 有一个 120 秒的总超时预算,它调用了 3 个工具,每个工具不应该各拿 120 秒。一个实际的做法是在 Agent 层面设总预算,Server 层面设单工具上限,两者取最小值。
第四层:重试与熔断------在 Server 端而不是 Agent 端做
一个反直觉的设计原则:重试逻辑应该放在 MCP Server 内部,而不是交给 Agent(LLM)来决定。
为什么?因为 LLM 的重试策略不可预测。它可能重试 1 次就放弃,也可能重试 20 次。它可能在两次重试之间塞入大量推理 token,白白烧钱。更糟的是,如果多个 Agent 同时使用同一个 MCP Server,它们的重试行为会叠加,形成 Retry Storm(重试风暴)------你之前可能读过我们关于 Retry Storm 的文章,在 MCP 场景下这个问题更严重,因为 LLM 的重试间隔完全没有随机化。
在 Server 端实现重试有两个层次:
层次一:对外部依赖的重试(Server → 外部 API/DB)
typescript
import { retry, stopAfterAttempt, waitExponential } from "retry-kit";
const fetchWithRetry = retry(
{
stop: stopAfterAttempt(3),
wait: waitExponential({ min: 500, max: 5000 }),
retryIf: (error) => error.status >= 500 || error.code === "ECONNRESET",
onRetry: (attempt, error) => {
console.error(
`[retry] attempt=${attempt} tool=fetch_data error=${error.message}`
);
},
},
async (url: string) => {
const resp = await fetch(url, { signal: AbortSignal.timeout(10000) });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.json();
}
);
层次二:熔断器------当重试也解决不了问题时
如果一个外部服务连续失败超过阈值(比如 5 分钟内失败 10 次),熔断器打开,后续请求直接快速失败,不再等待超时。这保护了两端:Server 不浪费资源等待一个挂掉的服务,Agent 立即收到"不可用"的明确信号。
typescript
import CircuitBreaker from "opossum";
const breaker = new CircuitBreaker(fetchFromExternalAPI, {
timeout: 10000, // 单次调用超时
errorThresholdPercentage: 50, // 50% 失败率触发熔断
resetTimeout: 30000, // 30 秒后尝试半开
});
breaker.on("open", () => {
console.error("[circuit-breaker] OPEN: external API marked as unavailable");
});
breaker.on("halfOpen", () => {
console.error("[circuit-breaker] HALF-OPEN: testing if API recovered");
});
// 在 MCP tool handler 中使用
server.tool("get_order_status", orderSchema, async (input) => {
try {
const result = await breaker.fire(input.orderId);
return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (error) {
if (error.message.includes("circuit breaker")) {
return {
isError: true,
content: [{
type: "text",
text: "订单查询服务当前不可用(熔断器已打开)。请告知用户稍后重试,并使用其他不依赖此服务的工具继续任务。"
}],
_meta: { retryable: false, alternativeTool: "get_cached_order" }
};
}
throw error;
}
});
熔断器的价值不在于它阻止了失败------它阻止的是对已经失败的服务持续投入资源。在 MCP 场景下,这意味着 Agent 的上下文窗口不会被重复的超时和错误消息填满。
第五层:优雅降级------工具挂了,任务不能挂
这是最高级的防护层,也是最少 MCP Server 实现的部分。
核心思想:当一个工具完全不可用时,Server 提供一个降级响应,让 Agent 可以用不完整但可用的数据继续完成任务,而不是整个任务链断裂。
三种常见的降级策略:
策略一:缓存回退
typescript
server.tool("get_user_profile", profileSchema, async (input) => {
try {
// 优先从实时 API 获取
const profile = await userService.getProfile(input.userId);
await cache.set(`profile:${input.userId}`, profile, { ttl: 300 });
return formatResult(profile);
} catch (error) {
// 实时 API 失败,尝试缓存
const cached = await cache.get(`profile:${input.userId}`);
if (cached) {
return {
content: [{
type: "text",
text: JSON.stringify({
...cached,
_warning: "此数据来自缓存,可能不是最新状态。缓存时间: " + cached._cachedAt,
}),
}],
_meta: { degraded: true, dataSource: "cache" },
};
}
// 缓存也没有,返回结构化空响应而不是错误
return {
isError: true,
content: [{
type: "text",
text: `无法获取用户 ${input.userId} 的资料。实时服务和缓存均不可用。请跳过此步骤,在最终结果中标注"用户资料暂不可用"。`,
}],
_meta: { retryable: false, userMessage: "部分用户信息暂时无法加载" },
};
}
});
策略二:功能替代
当主要工具不可用时,Server 可以引导 Agent 使用功能较弱但可用的替代工具:
typescript
// 在错误响应中提供替代方案
{
isError: true,
content: [{
type: "text",
text: "全文搜索服务不可用。请使用 'search_by_title' 工具进行标题搜索作为替代,虽然结果可能不够完整,但可以继续完成任务。"
}],
_meta: {
retryable: false,
alternativeTool: "search_by_title",
degradationLevel: "partial" // partial = 功能降级; full = 完全不可用
}
}
策略三:聚合降级
当一个工具聚合了多个数据源,其中部分数据源失败时,返回成功的那部分而不是整体报错:
typescript
server.tool("get_dashboard_data", dashboardSchema, async (input) => {
const results = await Promise.allSettled([
fetchMetrics(),
fetchRecentOrders(),
fetchAlerts(),
]);
const data: Record<string, unknown> = {};
const failures: string[] = [];
const sections = ["metrics", "recentOrders", "alerts"];
results.forEach((result, i) => {
if (result.status === "fulfilled") {
data[sections[i]] = result.value;
} else {
data[sections[i]] = null;
failures.push(sections[i]);
}
});
if (failures.length > 0) {
data._partialFailures = failures;
data._message = `以下数据暂时不可用: ${failures.join(", ")}。其余数据正常,请基于可用数据回答用户。`;
}
return { content: [{ type: "text", text: JSON.stringify(data) }] };
});
Promise.allSettled 是关键:它不会因为一个 Promise 失败就放弃全部结果。结合降级标记,LLM 可以决定是用不完整的数据继续,还是告诉用户"部分数据暂时无法获取"。
把五层串起来:一个完整的防护体系
| 层级 | 防护目标 | 实现位置 | 关键能力 |
|---|---|---|---|
| 1. 错误分类 | 让 LLM 知道错误性质 | Server 错误响应 | 结构化错误码 + 可重试性标记 |
| 2. LLM-Friendly 消息 | 让 LLM 知道下一步做什么 | Server 错误消息 | 行动导向的自然语言 + meta 字段 |
| 3. 超时控制 | 防止慢调用拖死整条链路 | Server 工具层 + Client Session | 工具级超时预算 |
| 4. 重试与熔断 | Server 端自治,保护 Agent 上下文 | Server 外部依赖层 | 指数退避重试 + 熔断器 |
| 5. 优雅降级 | 工具挂了任务不挂 | Server 工具层 | 缓存回退 + 功能替代 + 聚合降级 |
这五层不是"选几个做做就行"------它们是递进关系。没有第一层的错误分类,第二层的消息就没有依据。没有第三层的超时,第四层的重试就会无限等待。没有前四层的兜底,第五层的降级策略永远不会被触发。
可观测性:你怎么知道哪层在起作用
没有可观测性的错误处理等于盲人摸象。你需要知道:
- 每个工具的错误率和错误类型分布
- 重试的成功率(如果重试成功率低于 20%,说明重试策略有问题)
- 熔断器打开的频率和恢复时间
- 降级响应的占比(如果超过 30% 的请求走降级,说明外部依赖出了大问题)
一个最小可行的监控方案:
typescript
// 在 tool handler 外层包一个中间件
function withObservability(toolName: string, handler: ToolHandler): ToolHandler {
return async (input, extra) => {
const start = Date.now();
const labels = { tool: toolName };
try {
const result = await handler(input, extra);
metrics.increment("mcp.tool.success", labels);
if (result._meta?.degraded) {
metrics.increment("mcp.tool.degraded", labels);
}
return result;
} catch (error) {
metrics.increment("mcp.tool.error", {
...labels,
errorCode: error.code,
errorType: classifyError(error),
});
metrics.histogram("mcp.tool.duration_ms", Date.now() - start, labels);
throw error;
}
};
}
在 Grafana 中,你需要至少三张面板:
- 工具调用成功率(按工具名分)
- 错误类型分布饼图(参数错误 vs 暂时性错误 vs 熔断 vs 降级)
- P95/P99 延迟趋势线(按工具名分)
当某个工具的 P95 延迟突然飙升,或者错误类型从"参数错误"变为"暂时性错误",你就知道外部依赖出了问题,而不是你的 Server 代码有 bug。
从 GitHub Copilot 和 Block 学到的教训
GitHub Copilot 团队在 2026 年公开分享了一个数据:他们把 MCP Server 的工具数从 40 个缩减到 13 个之后,Agent 的任务完成率显著提升。工具越少,LLM 的选择空间越小,出错概率越低。
Block 的 Linear MCP Server 更极端------他们重建了三次,最终从 30 多个工具压缩到只剩 2 个。
这两个案例给我们的启示不是"少做工具",而是:每个工具的错误处理路径必须穷举。 40 个工具,每个有 3-5 种错误路径,就是 120-200 条错误处理逻辑。13 个工具,每个精心处理 5-8 种错误路径,只有 65-104 条。数量少了一半,质量高了一个量级。
做 50 个工具每个只有 try/catch,不如做 5 个工具每个都有完整的 5 层防护。
一个 Checklist:你的 MCP Server 错误处理及格了吗
对照以下清单给你的 Server 打分:
- 每个工具的错误响应都包含
isError: true标记 - 错误消息告诉 LLM「下一步做什么」,而不只是「发生了什么」
- 区分可重试错误和不可重试错误,并通过
_meta字段传递 - 每个工具有独立的超时设置,而不是所有工具共用一个全局值
- 对外部依赖有重试策略(指数退避,不是固定间隔)
- 对外部依赖有熔断器(防止对已经挂掉的服务持续请求)
- 关键工具有降级方案(缓存回退 / 功能替代 / 聚合降级)
- 有错误率和错误类型分布的监控面板
- 错误消息不包含对 LLM 无意义的内部细节(IP、端口、文件路径)
- 测试过至少一种"外部服务完全挂掉"的场景
如果打勾少于 6 个,你的 MCP Server 在生产中大概率会出现 Agent 死循环或任务静默失败的问题。
结语
MCP 协议解决的是「AI 怎么连接工具」的问题,但它不解决「工具出了问题怎么办」的问题。后者是 Server 开发者的责任。
把错误消息当成写给 LLM 的行动指南,把重试逻辑放在 Server 端而不是赌 LLM 的行为,用超时、熔断和降级构建多层防线------这些不是高级技巧,而是 MCP Server 进入生产环境的基本门槛。
毕竟,一个从不报错的 Server 不是好 Server,一个报错了能自己处理的 Server 才是。
参考资料:
- Zuplo, "The State of MCP --- Adoption, Security & Production Readiness", 2026
- Stainless, "Error Handling And Debugging MCP Servers", 2025
- arxiv 2609.35381, "MCP Error Messages Written for Developers Hurt the Most"
- Octopus Deploy, "Resilient AI Agents With MCP: Timeout And Retry Strategies", 2026
- GitHub Blog, "How we're making GitHub Copilot smarter with fewer tools", 2026
- Block Engineering, "Block's Playbook for Designing MCP Servers", 2026
- Model Context Protocol Specification, modelcontextprotocol.io