MCP 工具报错走哪条通道:三条探针的最小复现检查

MCP 的失败分成两条互不相通的通道:一条是工具执行失败 ------JSON-RPC 响应里带 result 和 isError: true,模型能读到错误文本并自我纠正;另一条是协议错误 ------响应顶层是 error 对象(code + message),由宿主客户端处理,模型根本看不见S2。判断标准只有一条:看错误出现在 JSON-RPC 消息的哪一层。适用情形:你在写或排查 MCP server,需要决定一个失败该让模型看到、还是该抛给调用方代码。

一个假设场景,先暴露取舍

假设你写了一个 read-note 工具,模型传了一个不存在的 id。你有两个选择:把"没有这个 id,已知 id 有哪些"作为 isError: true 的结果返回,模型读到后换一个 id 重试;或者直接抛异常让请求失败。选错通道的代价很具体------如果该给模型看的错误走了协议错误通道,模型拿不到任何信息,只会看到调用莫名失败,重试也是原样重试S2。反过来,把"请求本身就不合法"这类模型无法纠正的错误塞给模型,只是浪费上下文。

分界的依据:两条通道在 wire 上的形状

不需要背概念,看消息形状就够。工具执行失败长这样(2026-07-28 版规范的 tools/call 示例中带 resultType 字段)S1:

json 复制代码
{ "jsonrpc": "2.0", "id": 2, "result": {
  "resultType": "complete",
  "content": [{ "type": "text", "text": "No note with id \"drafts\"..." }],
  "isError": true
}}

协议错误长这样------顶层是 error 而不是 result:

json 复制代码
{ "jsonrpc": "2.0", "id": 3, "error": {
  "code": -32602, "message": "Note ids are lowercase letters, got \"42\""
}}

规范里 tools/call 的响应本身就带 isError 字段,也就是说工具失败被设计成"成功的 JSON-RPC 结果"S1。而协议错误在 wire 上是 { code, message, data? } 而不是 resultS2。这个结构差异就是全部判据。

最小复现:三条探针

下面用 TypeScript SDK v2 写一个最小 server,三条探针各打一条通道。示例,未在本文环境实测,代码依据 SDK 官方错误处理文档S2。

前提 :Node.js 环境,安装 @modelcontextprotocol/server 与 zod。

ts 复制代码
import { McpServer, ProtocolError, ProtocolErrorCode,
         ResourceNotFoundError, ResourceTemplate } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

const notes = new Map([['welcome', 'Read tools.md first.']]);
const server = new McpServer({ name: 'notes', version: '1.0.0' });

// 探针 A:显式返回 isError
server.registerTool('read-note',
  { description: 'Read a note by its id',
    inputSchema: z.object({ id: z.string() }) },
  async ({ id }) => {
    const note = notes.get(id);
    if (!note) {
      return {
        content: [{ type: 'text',
          text: `No note with id "${id}". Known ids: ${[...notes.keys()].join(', ')}` }],
        isError: true
      };
    }
    return { content: [{ type: 'text', text: note }] };
  });

// 探针 B:handler 里直接 throw
server.registerTool('delete-note',
  { description: 'Delete a note by its id',
    inputSchema: z.object({ id: z.string() }) },
  async ({ id }) => {
    if (!notes.delete(id)) throw new Error(`Cannot delete "${id}": no such note`);
    return { content: [{ type: 'text', text: `Deleted "${id}"` }] };
  });

// 探针 C:资源回调里抛协议错误
server.registerResource('note', new ResourceTemplate('note://{id}', { list: undefined }),
  { description: 'A note by its id' },
  async (uri, { id }) => {
    if (!/^[a-z]+$/.test(String(id))) {
      throw new ProtocolError(ProtocolErrorCode.InvalidParams,
        `Note ids are lowercase letters, got "${id}"`);
    }
    const note = notes.get(String(id));
    if (!note) throw new ResourceNotFoundError(uri.href);
    return { contents: [{ uri: uri.href, text: note }] };
  });

用 SDK 的 in-memory Client 连上后依次触发,记录每次响应:

  1. callTool({ name: 'read-note', arguments: { id: 'drafts' } })
  2. callTool({ name: 'delete-note', arguments: { id: 'drafts' } })
  3. readResource({ uri: 'note://42' })

预期结果 :探针 A 返回普通 result,isError: true,content 文本里含已知 id 列表。探针 B 与 A 形状完全一致------SDK 会把 handler 抛出的任何异常转成 isError: true 的结果,异常 message 变成 content 文本S2。探针 C 则 reject 出一个 ProtocolError,wire 上是 { code: -32602, message: ... } 的错误响应,模型看不到它S2。

如果你不用 TS SDK,判定方法换成抓 wire 消息:stdio 传输下记录 server 的输出帧,找到与请求 id 对应的那条响应,看它有 result 还是 error 字段。这一步与具体 SDK 无关,因为 tools/call 的响应结构是协议层面的约定S1。

三条探针揭示的失败边界

这个复现不只是验证形状,它划出了几个容易踩的边界。

工具 handler 发不出协议错误。 SDK 会把 handler 抛出的一切------包括你特意 throw 的 ProtocolError------都转成 isError: true 的结果。唯一例外是 UrlElicitationRequiredError,它会作为 JSON-RPC 错误(-32042)传播,让宿主去打开 URLS2。所以"我想让这次 tools/call 返回协议错误"这条路,在工具通道里基本是封死的。

资源、prompt、completion 回调没有 isError 通道。 这些请求由宿主应用驱动而非模型,失败本来就是给调用方代码看的,只能走协议错误S2。这解释了为什么同一句"没找到"在工具里是 isError: true、在 resources/read 里是 -32602。

错误码不要自己拼。 SDK 文档给了完整的 ProtocolErrorCode 对照表,也给了带类型的数据子类:比如 ResourceNotFoundError 会自动选对错误码,并把缺失的 URI 打进 dataS2。一个值得注意的既有行为:SDK 应答 resources/read 未命中时用 -32602、从不发出 -32002,但对收到的遗留码保持容忍S2------也就是说收发两端的码表可能不对称,匹配错误时别只认一个码。

复现不符预期时,查这三个方向

如果你自己的 server 跑出来和预期形状对不上,优先排查:

一是 SDK 版本。TS SDK v1 用的是 McpError / ErrorCode,v2 才换成 ProtocolError / ProtocolErrorCode,行为细节有差异S2。你参考的文档和装的包如果不是同一大版本,结论不能直接搬。

二是中间层。代理或宿主客户端可能吞掉或改写错误响应。你的复现要尽量贴着 server 的 wire 输出抓,而不是看客户端日志里的最终呈现。

三是你在错误的层做判断。SDK 文档给的选择标准是按受众分:模型驱动 tools/call,所以模型能恢复的失败------记录不存在、参数值不对、上游瞬时故障------放 isError: true,并且文本里要写明怎么改;宿主应用驱动 resources/read、prompts/get、completion/complete,那里的失败是给调用方代码看的协议错误S2。

你可以现在做的

拿你手头一个真实的失败调用,走一遍三步:抓到与请求 id 对应的响应帧;看它是 result + isError: true 还是顶层 error;再问一句"模型能从错误文本里读出怎么改吗"。如果答案是否定的,要么把错误移到另一条通道,要么把恢复提示写进 content 文本------那是模型唯一能拿到的东西S2。

跑完如果你的结果和本文预期不一致,欢迎带着 SDK 名称、版本和抓到的消息形状来对------三条探针的预期形状都来自官方文档,但不同实现的边界行为值得单独验证。

参考资料

相关推荐
不爱说话郭德纲1 小时前
从“点点点”到一键出包:我把 uni-app x Android 离线打包做成了脚本
android·前端·uni-app
Lstone73641 小时前
从 Jetpack Compose 到 CMP:跨平台开发学习笔记
前端
deli0071 小时前
多加一粒沙,整堆为什么就塌了?sandpile 模型 20 万粒实测
前端
涛涛ing1 小时前
乱序HTML流正式进入浏览器:前端流式渲染的“框架特权”被终结了
前端
__sjfzllv___1 小时前
在职前端Leader学习/转行 AI Agent -DAY73
前端
用户1733598075371 小时前
纯前端 PDF 压平避坑指南:压平后表单字段变了?
前端·javascript·vue.js
nyaomaru1 小时前
将一个真实的 TypeScript OSS 库从 tsup 迁移到 tsdown
前端·typescript
胡写代码1 小时前
雪花 ID 传到前端就变了个数?我用全局 Long 转 String 一次收口
前端·后端
沐言人生2 小时前
82.4k 星!把十几万行代码变成知识图谱,新人终于不用硬啃了
前端·后端·github