MCP Server 错误处理工程实践:5 层防护让你的 AI Agent 不再因工具报错而崩溃

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 看到这个消息会:

  1. 不知道该怎么修正参数
  2. 不知道该换一个工具还是放弃
  3. 可能编造一个假参数再试一次

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 复制代码
"数据库连接暂时失败。你无法修复此问题。请跳过需要数据库的工具,
用缓存数据或默认值完成任务。如果用户询问,告知服务暂时不可用。"

区别在哪里?

  1. 明确告诉 LLM「你无法修复此问题」------阻止它进入修复死循环
  2. 给出「跳过」「用缓存」「告知用户」三个具体备选行动
  3. 不暴露内部基础设施细节(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 工具层 缓存回退 + 功能替代 + 聚合降级

这五层不是"选几个做做就行"------它们是递进关系。没有第一层的错误分类,第二层的消息就没有依据。没有第三层的超时,第四层的重试就会无限等待。没有前四层的兜底,第五层的降级策略永远不会被触发。

可观测性:你怎么知道哪层在起作用

没有可观测性的错误处理等于盲人摸象。你需要知道:

  1. 每个工具的错误率和错误类型分布
  2. 重试的成功率(如果重试成功率低于 20%,说明重试策略有问题)
  3. 熔断器打开的频率和恢复时间
  4. 降级响应的占比(如果超过 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 才是。


参考资料:

  1. Zuplo, "The State of MCP --- Adoption, Security & Production Readiness", 2026
  2. Stainless, "Error Handling And Debugging MCP Servers", 2025
  3. arxiv 2609.35381, "MCP Error Messages Written for Developers Hurt the Most"
  4. Octopus Deploy, "Resilient AI Agents With MCP: Timeout And Retry Strategies", 2026
  5. GitHub Blog, "How we're making GitHub Copilot smarter with fewer tools", 2026
  6. Block Engineering, "Block's Playbook for Designing MCP Servers", 2026
  7. Model Context Protocol Specification, modelcontextprotocol.io
相关推荐
goehou12 小时前
Prompt 注入实战:直接注入、间接注入和四道防线(附测试样例)
安全·ai·llm·agent·prompt注入
虎鲸不是鱼14 小时前
【RTX Pro 4500 Blackwell】Qwen3.8 27B NVFP4 MTP在dsh的真实表现
大模型·llm·qwen·lm studio·harness
XLYcmy15 小时前
AI 赋能后中文与英文数据库对比分析报告 上
数据库·ai·llm·prompt·检索·wos·cnki
桃西西呀16 小时前
LangGraph4j之四:运行引擎与流式
人工智能·llm·ai编程
桃西西呀16 小时前
LangGraph4j之五:持久化与断点续跑
人工智能·llm·ai编程
方方洛17 小时前
ai-agent教程-05-上下文工程
人工智能·llm·agent
用户7217465882617 小时前
三条热词通道的机制拆解:bl speech 的参数面、生效模型范围与错配读数
llm·agent·ai编程
程序员大狐狸17 小时前
Ultra X7 358H 笔记本电脑,本地部署 Bonsai-2-27B,实测生成速度 11.6 tok/s
人工智能·llm
Behavior17 小时前
谷歌 Gemini 4 Argon正式开放:硬刚 GPT-6 与 Claude,这次谁更强?
llm·aigc·gemini