AI 技术
#MCP#JSON-RPC#错误处理#Agent#工具调用

MCP 工具结果的 isError 语义:业务失败与协议错误混用为何让 Agent 误判重试

远程 MCP Server 执行查询工具时,把参数校验失败、业务空结果和传输异常都塞进 isError 或 content 文本,会让客户端与模型无法区分可重试与不可重试。本文拆解 CallToolResult 中 isError 的定位、错误分类混用的失效机制,对比结构化错误码方案,并给出校验点与可观测指标。

一个反复重试的查询场景

设想企业内部知识库的远程 MCP Server 暴露了一个 search_docs 工具,Agent 用它按关键词检索制度文档。用户问“差旅报销上限是多少”,模型调用工具,参数里带了一个拼写错误的部门名 finace。服务端查不到该部门,返回一条文本“未找到匹配文档”,isError 为 false。模型把这条文本当成正常结果,回答“没有相关规定”。

同一周,这个服务端又遇到另一次调用:数据库连接池耗尽,查询抛异常。服务端把异常信息写进 content 文本,isError 设为 true。客户端看到错误,触发重试;重试三次仍然失败,整轮对话以超时结束。

两次调用都“失败”了,但失败的性质完全不同。第一次是参数写错,重试没有意义,应该让模型修正参数;第二次是临时资源不足,重试有意义。如果服务端和客户端对 isError 的理解不一致,Agent 就会在该修正时重试、在该重试时放弃。问题出在错误分类语义,错误处理代码本身并不缺。

isError 在协议里到底标记什么

MCP 规范把工具错误分成两类。第一类是协议错误,用 JSON-RPC 的 error 成员返回,覆盖未知工具、无效参数、服务器内部错误等情况。第二类是工具执行错误,放在 tools/call 的正常结果里,用 isError: true 标记,覆盖 API 调用失败、无效输入数据、业务逻辑错误。

这个划分来自 JSON-RPC 2.0 的基本约定:响应对象要么有 result,要么有 error,两者不能同时出现。协议层错误意味着这次 RPC 调用本身没有完成;工具执行错误意味着 RPC 调用完成了,只是工具在业务上没办成事。规范给出的例子很直白:查询天气时 API 速率超限,返回的是 result 里带 isError: true 的文本,而不是 JSON-RPC 的 error 对象。

规范这样设计,是为了让模型看到工具失败的原因并作出反应。工具执行错误会作为工具输出进入模型上下文,模型可以读错误文本、改参数、换工具或向用户说明。协议错误则通常由客户端处理,模型未必看得到。

isError 是一个布尔值,只有真和假两种状态。它能表达“这次工具执行失败了”,却表达不了失败属于哪一类、是否值得重试、应该由谁处理。规范没有定义错误码字段,也没有规定 content 文本的格式。这个留白在简单工具上够用,在查询类工具上很快暴露问题。

三类失败被压进同一个布尔值

回到知识库场景,服务端开发者面对三种失败,往往只能做二选一:要么返回 JSON-RPC 错误,要么返回 isError: true。

参数校验失败最容易被误放。search_docs 的 inputSchema 要求 department 是枚举值,模型传了 finace。严格来说这是无效参数,规范允许服务端返回协议错误 -32602。但很多服务端为了“让模型看到原因”,选择返回 isError: true 加一段“部门不存在”的文本。客户端收到后把它当工具执行错误,可能重试同一参数,也可能把错误文本交给模型。模型看到“部门不存在”,有可能改参数,也有可能原样再调一次。

业务空结果则常被标成 isError: false。查询没有命中任何文档,从业务角度看是正常结果,返回空列表或“未找到”文本都合理。问题在于模型无法从 isError: false 判断这是“确实没有”还是“查询条件写错了”。在上面的例子里,部门名拼错导致的空结果和真实无数据长得一模一样。

传输与基础设施异常最危险。数据库连接失败、上游 API 超时、鉴权令牌过期,这些都不是工具的业务逻辑结果,却经常被服务端捕获后塞进 content 文本,isError 设为 true。客户端看到 isError 就重试,但令牌过期重试一百次也不会成功,连接池耗尽反而可能被重试放大。

三类失败共用同一个布尔值,客户端和模型只能靠读文本猜。文本是自由格式,没有稳定字段,模型每次解读都可能不同。

客户端与模型的判断链如何断掉

一次 tools/call 从发出到模型看到结果,中间经过服务端、传输层、客户端和模型四层。isError 的语义在这条链上被逐层简化。

flowchart TD
    A[模型发起 search_docs 调用] --> B[客户端序列化 tools/call]
    B --> C[远程 MCP Server 接收请求]
    C --> D{失败类型判断}
    D -->|参数不合法| E[返回 JSON-RPC error -32602]
    D -->|业务无结果| F[返回 result isError=false 文本未找到]
    D -->|上游超时或连接失败| G[返回 result isError=true 文本异常]
    E --> H[客户端按协议错误处理]
    F --> I[模型读文本判断是否改参数]
    G --> J[客户端触发重试策略]
    J --> K{重试次数用尽}
    K -->|是| L[整轮对话失败]
    K -->|否| C
    H --> M[模型可能看不到错误细节]

图中最关键的转折在 D 节点。服务端把三类失败映射到两个出口,信息在这一步就丢了。之后客户端只能按 isError 的布尔值走两条路:false 交给模型自由解读,true 进入重试逻辑。

客户端重试逻辑通常很简单:isError 为真就重试固定次数,或者把错误文本回传给模型让它决定。这两种策略都有盲区。固定重试对不可恢复错误无效,还会放大服务端压力。回传给模型则依赖模型对文本的理解,而模型没有工具调用历史之外的上下文,很难判断“连接超时”和“部门不存在”哪个该重试。

模型侧的问题更隐蔽。当 isError 为 false 但文本写着“未找到”,模型可能把它当成最终答案,不再尝试其他关键词。当 isError 为 true 且文本是异常堆栈,模型可能被无关细节干扰,甚至复述堆栈内容给用户。

资料 3 记录了一个真实的服务端行为差异:在某个 Agent 服务的新 Responses API 上,isError: true 的工具结果会直接终止整个响应,返回 tool_user_error,模型看不到错误内容;同一请求在旧版 agents API 上则把错误交回模型,任务继续。这个案例说明,即使服务端正确使用了 isError,不同客户端对它的处理策略也可能完全不同。规范只定义了字段语义,没有规定客户端必须怎么反应。

结构化错误码能补上什么

要解决分类问题,需要在 isError 之外增加机器可读的失败类别。常见做法是在工具结果里放一个结构化字段,例如 errorCode、retryable 和 category,同时保留 content 文本给模型阅读。

失败类型建议返回位置是否可重试客户端动作模型可见信息
未知工具JSON-RPC error否终止并报告配置错误通常不可见
参数不符合 schemaJSON-RPC error 或 isError 加 INVALID_ARGUMENT否把错误回传模型修正参数字段名与期望值
业务空结果result,isError 为 false,附 EMPTY_RESULT否交给模型换关键词或换工具查询条件与命中数
上游限流isError 加 RATE_LIMITED,retryable 为 true是退避后重试限流原因与建议等待
上游超时isError 加 UPSTREAM_TIMEOUT,retryable 为 true是有限次重试超时环节
鉴权失效isError 加 UNAUTHENTICATED,retryable 为 false否触发重新授权流程需要重新登录
服务端内部错误JSON-RPC error 或 isError 加 INTERNAL视情况有限重试并告警简要原因

这张表的核心是把“是否可重试”从布尔值里拆出来,变成显式字段。客户端不需要理解文本,只读 retryable 和 category 就能决定动作。模型仍然读 content,但文本可以写得更面向人,不必承担机器判断的职责。

结构化字段的代价是实现复杂度上升。服务端要为每个失败点选择错误码,客户端要维护错误码到动作的映射,双方还要约定错误码集合。如果错误码设计得太细,维护成本高;太粗,又退回布尔值的老问题。工程上通常从少量稳定类别起步,例如参数错误、业务空结果、限流、超时、鉴权、内部错误,再按需扩展。

另一个选择是把所有失败都塞进 JSON-RPC error 的 data 字段。这能利用现成的错误对象结构,但会让工具执行错误走协议错误通道,模型可能看不到错误内容,与 MCP 规范希望模型参与纠错的意图相悖。对于需要模型改参数的业务失败,保留在 result 里更合适。

服务端与客户端的校验点

服务端在返回结果前,至少要过三道校验。

第一道是参数校验。inputSchema 能拦下的错误尽量在进入业务逻辑前拦下,返回协议错误 -32602,并在 data 里给出字段路径和期望值。这样模型拿到的是明确的修正线索,而不是一段模糊文本。

第二道是失败分类。业务代码捕获异常后,要按异常类型映射到错误码,不能统一写成 isError: true。连接超时、限流、鉴权失效、查询语法错误,各自有对应的 retryable 取值。映射表应该写进代码,而不是靠开发者临场判断。

第三道是结果结构校验。如果工具声明了 outputSchema,服务端必须保证 structuredContent 符合 schema。规范要求提供 output schema 的服务器返回符合该 schema 的结构化结果,客户端应当校验。错误结果同样应该有稳定结构,否则客户端无法可靠解析。

客户端侧要在三个位置设检查。收到 tools/call 响应后,先看是 error 还是 result,走不同分支。result 里再看 isError 和结构化错误字段,决定重试、回传模型还是终止。把结果交给模型前,检查 content 是否为空、是否只有错误文本,避免模型把错误文本当成业务数据。

重试策略要按错误类别区分。限流和超时用指数退避,设置上限;参数错误和鉴权失效不重试,直接走修正或重新授权流程;业务空结果不算错误,交给模型换策略。重试计数应该按工具调用维度记录,而不是按整轮对话,否则一次失败会拖累后续无关调用。

可观测指标与失效边界

错误分类做得好不好,要靠指标验证。服务端可以按错误码统计调用量、失败率和重试率,观察 INVALID_ARGUMENT 是否异常高。如果参数错误占比很高,说明工具描述或 schema 对模型不够清晰,应该改工具定义,而不是让客户端重试。

客户端侧要区分“重试后成功”和“重试后仍失败”。前者说明分类正确,后者说明把不可恢复错误标成了可重试。还可以统计模型在收到 isError 后是否修改了参数,如果模型总是原样重调,说明错误文本没有提供可操作的修正信息。

一个容易被忽略的指标是空结果率。业务空结果如果被大量标成 isError: false,模型可能把“没查到”当成“不存在”,给出错误结论。监控空结果率并按查询条件分组,能发现拼写错误、枚举值不匹配这类系统性问题。

结构化错误码方案也有失效边界。错误码集合如果由服务端单方面扩展,客户端不认识新码,可能退回默认重试,问题依旧。错误码语义需要版本化,客户端遇到未知码时应该有安全的默认动作,例如不重试并上报。远程 MCP Server 的部署节奏和客户端升级节奏往往不同步,这个兼容性问题在跨组织调用中更明显。

还有一种边界是模型能力。即使客户端把结构化错误正确传给模型,模型也可能忽略 retryable 字段,按自己的理解重试。工具结果的错误信息应该同时对人可读、对机器可解析,两者不能互相替代。

在协议留白处做工程决策

MCP 规范把 isError 定义成工具执行失败的标记,把协议错误留给 JSON-RPC 层,这个划分本身是清楚的。问题出在实现层:参数校验、业务空结果和传输异常都能被塞进 isError 或 content 文本,客户端和模型拿不到足够的分类信息。

对查询类工具,一个务实的做法是:参数错误走协议错误,让模型拿到明确修正线索;业务空结果保持 isError: false 并附结构化标记;可重试的上游故障用 isError: true 加 retryable: true;不可恢复的鉴权或配置错误用 isError: true 加 retryable: false。客户端按结构化字段决策,模型按文本理解原因。

这套做法依赖服务端和客户端对错误码集合的共同约定,也依赖客户端不把所有 isError: true 都当成重试信号。在协议尚未标准化错误码字段之前,这些约定只能靠实现方自行维护。跨组织调用时,先确认对方的错误分类策略,比事后调重试参数更省事。

资料来源

  1. MCP Specification: Tools
  2. JSON-RPC 2.0 Specification
  3. Foundry Agent Service: MCP tool result with isError ends the whole response (tool_user_error) on the new Responses API - Microsoft Q&A
  4. 工具 – MCP 中文站(Model Context Protocol 中文)