MCP 参数校验失败,isError 还是 InvalidParams?用三条探针定归属

先给结论:tools/call 的调用方是模型,所以参数校验失败只要模型自己能修正------缺必填字段、类型不对、业务上不合法(比如除数为零)------就应该走工具错误通道 ,即一个正常的 JSON-RPC result 带 isError: true,错误文本里写清怎么改,模型读到后重试。JSON-RPC 错误响应(如 -32602 InvalidParams)模型永远看不到,它只到达宿主程序的客户端代码,适合"代码 bug"类错误而不是"参数填错"类错误。但有一个现实陷阱:很多框架在进入你的 handler 之前就做 schema 校验,这一层走哪条通道规范没有统一规定,需要你用探针实测确认------这就是本文交付的检查。

两条通道的本质区别:谁在读这个错误

TypeScript SDK v2 的错误文档把这件事说得非常直白:工具错误是一个带 isError: true 的成功 JSON-RPC result ,模型能读到并从中恢复;协议错误是一个 JSON-RPC error response,模型从未见过它S2。

规范层面,tools/call 的 result 里就有 isError 字段,配合 content 数组携带文本S1。这两条通道的受众完全不同:

  • isError: true + content 文本 → 写给模型看,模型下一轮自己改参数重试
  • JSON-RPC error(code / message)→ 写给宿主客户端代码看,由代码决定重试、报错还是静默

判断规则只有一句话:谁能修复这个错误,错误就该送到谁面前。 SDK 文档的建议也是按受众选通道:模型驱动 tools/call,所以"模型能恢复的失败------缺记录、坏参数、上游瞬时故障------放进 isError: true,消息里写明修法"S2。而 resources/read、prompts/get 这类由宿主应用代码驱动的调用,失败才是发给调用方代码的协议错误S2。

一个容易踩的分层问题

假设你用某个框架写了一个 divide 工具,divisor 声明为必填 number。现在模型传了 "divisor": "2"(字符串)或者干脆漏了这个字段。这个校验失败发生在哪一层,决定了它走哪条通道:

第一层:框架的 schema 预校验。 比如基于 Dry::Schema 的 Ruby 实现,会在进入 call 方法前自动校验参数,"validation fails 时向客户端返回一个错误"S3------注意,这句文档没有说明是 isError 结果还是协议错误。这不是这家文档写得含糊,而是这一层的通道选择本来就没有跨实现的统一保证。

第二层:handler 内的校验。 这一层的行为是有明确文档结论的:TypeScript SDK v2 会把 handler 抛出的任何异常(包括你故意抛的 ProtocolError)转成 isError: true 的 result,异常消息变成 content 文本。文档甚至明说:"tool handler 无法产生协议错误"S2。也就是说,在 handler 内部,你没有通道选择权------想给模型看就写好错误文本,不想给模型看就别在这里失败。

所以真正悬而未决的只有第一层。下面用探针把它测出来。

可复现检查:三条探针定通道归属

以下示例基于 TypeScript SDK v2(@modelcontextprotocol/server)的写法改编自其错误文档S2,示例代码,未在本文环境实测 。运行前提:Node 环境,npm install @modelcontextprotocol/server zod,用 SDK 自带的内存 Client 直连 Server(即文档中 "Test a server" 的接线方式)。

注册一个故意分层暴露两种失败的工具:

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

const server = new McpServer({ name: 'probe', version: '1.0.0' });

server.registerTool('divide',
  {
    description: 'Divide dividend by divisor',
    inputSchema: z.object({
      dividend: z.number(),
      divisor: z.number(),
    }),
  },
  async ({ dividend, divisor }) => {
    if (divisor === 0) {
      // 业务校验失败:handler 内抛出
      throw new Error('divisor must not be 0; pass a non-zero number');
    }
    return { content: [{ type: 'text', text: String(dividend / divisor) }] };
  }
);

然后依次发三个调用,只看响应的顶层形状,不看具体文案:

探针一(基线) :{ dividend: 10, divisor: 2 }。预期:普通 result,isError: false,content 里是 5。

探针二(业务校验失败) :{ dividend: 10, divisor: 0 }。这能通过 schema,但会触发 handler 内的 throw。预期(这是 SDK 文档的明确结论,不是推测):一个result ,isError: true,content 文本就是你抛出的消息S2。

探针三(schema 校验失败) :{ dividend: 10 },漏掉必填的 divisor。观察点在这里------响应到底是:

  • (a) 一个 result 带 isError: true,或
  • (b) 一个 JSON-RPC error,形如 { code: -32602, message: ... }?

这一步没有可以引用的统一预期,因为不同框架的第一层校验通道不同。探针的价值就是替你回答这个问题。

预期结果与失败边界

三条探针读法如下:

探针 预期通道 如果不符
合法参数 result,isError: false 工具本身有问题
业务校验失败 result,isError: true(文档结论S2) 检查 SDK 版本是否为 v2 语义
schema 校验失败 取决于实现 见下面的处置

如果探针三返回的是 (b) 协议错误,意味着:模型传错参数时,模型自己不知道错了,错误被宿主客户端代码吃掉,对话循环会表现为模型等待一个永远不会来的结果,或宿主直接报错。这时你有两条处置路径:

  1. 客户端翻译 。在宿主代码里 catch tools/call 的协议错误,把 message 组装成一条模型可读的文本(比如作为下一轮工具结果或用户侧消息注入),让模型仍有机会修正参数。适合你控制客户端、或想保留框架严格校验的情况。
  2. 把校验下移到 handler。放宽 schema(比如把字段改成 optional 或宽松类型),在 handler 内自己校验并抛出带修复提示的错误,依赖 SDK 的"throw → isError"转换S2。适合你能改服务端、且希望错误文案可控的情况。

边界要认清两点。第一,这个探针只适用于无副作用的工具 ------探针三那种"参数不完整"的调用如果命中的是一个会写库的工具,副作用可能已经发生,测试前先确认工具是只读的。第二,不要把结论跨 SDK 搬运:TypeScript SDK v2 的"handler 只能产生工具错误"是它的明确行为S2;Go 等实现里 CallTool 返回 (response, error),error 通道的行为要看对应框架文档S4,探针三在那边照样要跑一遍。

另外一个容易忽略的细节:SDK 对 isError: true 的结果会跳过 outputSchema 校验S2。所以"错误以结构化数据返回"这条路在错误场景下没有 schema 保护,修复提示必须放在 content 文本里------那也是模型唯一能读到的东西。

客户端要兜的底

最后补一层服务端管不到的判断:即使服务端把校验失败正确地放进了 isError: true,模型能否恢复还取决于宿主客户端是否把这个结果回传进对话上下文。如果你在写客户端,处理 tools/call 响应时至少分三个分支:isError: false 取结果;isError: true 把 content 文本回传给模型;JSON-RPC error 决定是翻译给模型还是终止流程。跳过第二个分支,等于服务端做对了通道、客户端又把它断了。

跑完三条探针后,欢迎把你环境中探针三的实际通道(isError 还是 -32602,以及框架与版本)反馈出来------这个数据点目前正是各家实现分歧最大、也最值得汇总的地方。

参考资料

相关推荐
夏天要喝冰可乐1 小时前
Antigravity + Blender MCP(下):3D 智慧仓储数字孪生进阶实战
前端·webgl·three.js
cidy_981 小时前
第 3 章:工作台——数据看板
前端
国奉1 小时前
iOS 音频格式转换怎么实现?从 AVAudioFile、AAC、MP3 到 FLAC 与批量转码架构
前端·后端
易朵朵1 小时前
package.json 中的 `vue-router` 详解
前端·vue.js
呃呃呃呃ex1 小时前
3. JavaScript 异步编程:Promise、async/await、Generator 与异步调度
前端·javascript
IT_陈寒1 小时前
Vite的静态资源引用把我坑惨了
前端·人工智能·后端
风骏时光牛马1 小时前
AI工作流全链路自动化落地实践
前端
kyriewen1 小时前
5 次优化让首屏快 3.6 秒,只有 1 次是改代码
前端·javascript·程序员
编程老船长1 小时前
模型中立——把大模型做成"可替换零件",而不是焊死在业务里
java·前端·后端
禁止摆烂_才浅1 小时前
Axios 完整封装合集(鉴权 + 重复拦截 + Loading + 缓存 + 统一错误 + 请求重试|全代码逐行注释)
前端·javascript·axios