MCP 里的失败分两条完全不同的通道。第一条是工具执行失败 :它是一次成功的 JSON-RPC 响应,result 里带 isError: true。消息内容会被模型读到,模型有机会自己修正后重试S2。第二条是协议错误 :它是一次 JSON-RPC error 响应,顶层是 error: {code, message}。这类响应由宿主程序的 MCP 客户端代码处理,模型根本看不到它S2。
区分方法只需要一步:看原始响应的顶层字段是 result 还是 error。适用情形是你在调试 tools/call 的报错、决定错误信息写给谁看,或者在排查"模型为什么没反应"。不适用于本地网络层故障------那不是任何一方的协议消息,后面会说边界。
假设一个具体的排查场景
假设(例如)你接了一个天气查询的 MCP server,某天模型连续三次调用 get_weather 都没成功,用户界面只显示一个笼统的"调用失败"。你打开日志,看到两种长得有点像的东西混在一起:有的响应是 {result: {content: [...], isError: true}},有的是 {error: {code: -32602, message: "..."}}。如果你把两者都当成同一种"工具报错"处理------比如都往用户界面上抛、或者都塞回给模型------其中一半信息会被送错对象。

为什么协议要分两条通道
MCP 的底层是 JSON-RPC 2.0,一次请求的响应只有两种形态:带 result 的成功响应,或带 error 的错误响应S4。工具执行失败被放在前一种里------JSON-RPC 层面"成功"地返回了一个结果,只是这个结果声明自己 isError: true。TypeScript SDK 的错误文档把区别讲得很直白:工具错误是"模型读到并从中恢复"的东西,协议错误是"模型永远看不到"的东西S2。
判断标准不是"错误严不严重",而是谁能修正它 。tools/call 由模型驱动,模型可以改参数重试------记录不存在、日期格式错、上游限流,这些放进 isError: true,且文字里要写清楚怎么改,因为那是模型唯一能拿到的东西S2。而 resources/read、prompts/get 这类请求由宿主应用驱动,失败就是抛给调用方代码的协议错误S2。所以同一个"参数格式不对",在工具调用里通常该是工具错误,在资源读取里就是 -32602。
第三方框架的实践也印证这条分界:请求结构问题(未知工具名、schema 校验失败、无效游标)走协议错误;模型能自纠的输入校验失败、业务逻辑错误、超时、限流走 isError: trueS3。
可复现的检查步骤
前提:能拿到一条 tools/call 的原始 JSON-RPC 报文。stdio 传输可以加一层日志代理打印双向消息;Streamable HTTP 可以在客户端记录响应体。关键是看线上原始字节,不要只看 SDK 抛出的异常------原因见下节。
检查本身三步:
- 从日志里取出那次失败的
tools/call响应原文。 - 看顶层有
error字段还是result字段。 - 如果是
result,再看里面的isError值。
对照一个决策表:
| 响应形态 | 归属 | 该给谁看 |
|---|---|---|
result + isError: true |
工具执行失败 | 模型(写明修正线索)S2 |
result + isError: false |
不是失败,是正常返回 | 检查是不是内容本身被误读了 |
顶层 error + code |
协议错误 | 宿主客户端代码处理S2 |
三个探针请求(示例,未在本文环境实测)分别命中三种形态。对一个存在的工具传错误日期格式,预期得到 result + isError: true,message 里应有可操作的提示S3;对不存在的工具名发 tools/call,预期得到顶层 error,常见 code 是 -32602S3;用 SDK 的 in-memory client 调用,TS SDK 会把后者表现为 reject 出来的 ProtocolError,携带 {code: -32602, message: ...} 线上字段S2。
失败边界:这个检查在哪里会失灵
SDK 会抹掉信封差异。 在 TypeScript SDK 里,JSON-RPC error 响应会让 client.callTool 直接 reject 一个 ProtocolError,而 isError: true 是正常 resolve 的返回值S2。如果只在 catch 块里打日志,你看到的都是异常,分不出哪条是协议错误、哪条被框架包装过。所以检查必须落在原始报文层。
服务端抛异常不等于协议错误。 TS SDK 会把工具 handler 抛出的任何异常(包括抛出的 ProtocolError)转成 isError: true 的结果,异常 message 变成 content 文本S2。也就是说,"工具 handler 里 throw"和"显式返回 isError"对客户端是同一形态。唯一例外是 UrlElicitationRequiredError,它会作为 JSON-RPC error 透传给宿主S2。
本地超时不是任何一方的协议错误。 规范明确说,纯粹本地的错误(比如 SDK 内部产生的请求超时)目前没有被分配错误码,实现者在 JSON-RPC 形状的结构里暴露本地错误时,要确保它不会被误认成对端发来的错误S4。如果你把客户端超时异常打印出来发现也有 code 和 message,先确认它是本地造的还是服务端回的------看原始报文里有没有对应的响应。
旧版本 code 会撞车。 -32002(资源不存在)是 2025-11-25 及更早版本的 code,本版本实现不得再发出它,但客户端仍应接受旧 server 发来的这个 codeS4。排查时看到 -32002 不要直接当"未知错误",先查对端的协议版本。
另外注意错误码的区间划分:-32000 到 -32019 是各实现的历史遗留,新代码不得在此分配,接收方除 -32002 外不应假设其含义;-32020 到 -32099 保留给规范本身(目前有 -32020、-32021、-32022)S4。如果你的日志里出现这个区间的陌生 code,它不该来自一个合规的对端。
一条可以带走的检查动作
下次遇到"MCP 工具报错",先别改代码,把那条失败的 tools/call 响应原文找出来,回答两个问题:顶层是 result 还是 error?如果是 result,isError 是不是 true?第一个答案决定错误给模型还是给宿主代码,第二个答案防止你把正常返回误判成失败。跑完之后,欢迎把你的传输方式(stdio / Streamable HTTP)、SDK 和协议版本贴出来对照------上面 TS SDK 的行为是文档结论,你环境里的实际形态值得单独验证。