MCP 客户端怎么区分 HTTP 503、JSON-RPC error 与 isError?
MCP 客户端调用工具时,HTTP 返回了 200,Agent 却提示执行失败。两件事并不矛盾:HTTP 请求得到成功状态,不代表 JSON-RPC 响应没有 error,也不代表工具结果的 isError 为 false。
在 MCP Gateway、Coding Agent 接入层或自己写的客户端里,只检查 response.ok 会漏掉两类重要失败。更稳妥的顺序是先判断 HTTP 传输,再检查 JSON-RPC 响应信封,最后判断工具结果;至于要不要重试,那是下一道独立决策。
我核对了 Cakewalk MCP Test Kitchen 固定提交 cf2f689 的源码,再独立运行一份 Node.js 本地回环分类器:11 组合成用例全部通过。这验证的是自写分类逻辑,而不是 Cakewalk 服务或 Cursor、Codex 等真实客户端的互操作行为。
一次 tools/call 的三层错误:HTTP、JSON-RPC 与工具结果
2026-07-28 MCP Tools 规范的 Error Handling 节把协议错误和工具执行错误分得很清楚。算上 HTTP 传输,一次工具调用可能出现三种不同层级的失败:
| 失败层 | 响应示例 | 客户端需要识别的事实 |
|---|---|---|
| HTTP 传输 | 503 Service Unavailable | 本次 HTTP 请求失败;尚不能判断工具副作用、自动重试与否 |
| JSON-RPC 协议 | HTTP 200,根级 error.code、error.message | JSON-RPC 错误,不存在成功的工具结果 |
| 工具执行 | HTTP 200,result.isError: true | 协议交互可能完成,但工具执行失败 |
例如同为 HTTP 200,这段是 JSON-RPC 错误信封:
{
"jsonrpc": "2.0",
"id": 1,
"error": { "code": -32602, "message": "Invalid params" }
}
另一种是工具返回的业务失败:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"content": [{ "type": "text", "text": "Tool-side failure" }],
"isError": true
}
}
两个 JSON 示例都是服务端响应体;完整请求另需按协议携带 _meta 和必要的 HTTP 头。
注意:2026-07-28 CallToolResult Schema中,isError 是可选布尔字段,省略时按 false 理解;resultType 缺失按 complete 处理则是向后兼容规则。工具结果还可包含 structuredContent(不限于对象),但这个版本的 Schema 仍要求 content 是数组。本文对 content 的类型校验符合这个范围,不是通用的所有版本/SDK 类型验证器。
工具执行失败的文本有时能帮助模型修正参数。MCP Tools 规范的 Error Handling 节 明确写着:“Clients SHOULD provide tool execution errors to language models”;对协议错误则是“Clients MAY provide protocol errors to language models”。SHOULD / MAY 约束的是是否将错误交给模型,不是要求无条件重试。 有副作用的工具尤其不能只按 HTTP 状态决定重放。
resultType: "input_required" 表示调用尚未结束,服务器需要更多输入。MRTR(Multi Round-Trip Requests,多轮往返请求) 在响应里携带 inputRequests,客户端处理后用 inputResponses 和可能存在的 requestState 发起新的请求,JSON-RPC id 也要更新。它不是工具执行错误,更不是超时重试。
Streamable HTTP 还有一个更隐蔽的边界:2026-07-28 传输规范允许同一请求返回 application/json 或 text/event-stream。收到 HTTP 200 + SSE 只表示响应流已经开始;中途断流、尚未收到最终 JSON-RPC 响应、或最终收到 error,都不能记作工具成功。客户端要按 Content-Type 解析响应,追踪请求 ID 与最终结果。这里是传输帧完整性问题,不是把原本三类错误再机械增加一种工具错误。
另外,JSON-RPC 通知不带 id;通知 POST 被接受时,传输规范规定可返回 202 Accepted 且没有响应体。2026-07-28 的核心 Streamable HTTP 本身并不定义客户端主动发通知的常规流程,取消则通过关闭响应流表达。因此不能把 202 空响应套进要求 tools/call 有结果体的分类器。
Cakewalk 静态源码:503 → 200 不等于客户端自动重试
Cakewalk 让服务端按配置主动制造故障。先看固定源码如何产生这些响应,再看下一节独立运行的本地合成测试。
在 HttpStatusSequenceScenario.cs 第 12–35 行,实现使用 ScenarioWireInvocationKeys.HttpRequest 对场景会话内的 MCP POST 计数,而不是按 tools/call 数量计数。配置 {"statusCodes":[503,200]} 表示该场景收到第一条 POST 时返回 503;如果有第二条 POST,配置中的 200 会让请求继续处理。其他 POST 也可能先消耗这组状态序列。 例如:tools/list(第 1 条 POST → 503)→ 客户端停止,后面的 tools/call 根本没有发出。这是按计数逻辑推导的时序示例,不是客户端抓包;配置 [503,200] 本身不能证明发生了重试。
与之不同,JsonRpcErrorScenario.cs会针对 tools/call 返回 HTTP 200 + JSON-RPC error;ToolErrorScenario.cs则提供 CallToolResult 的 isError 路径。它们在代码里是三个不同的注入位置,不能把它们合并成一个“异常请求”。
我还核对了 默认注册代码第 308–315 行及固定 README 第 197–220 行:当前默认注册的六个场景是 baseline、errors.http_status_sequence、errors.jsonrpc_error、errors.tool_error、elicitation.approval 和 compat.sdk_v2。timeout、auth、catalog 等额外实现仍在仓库内,但未在默认 DI 注册;不能只因找到类文件就声称控制台可直接选择。
场景切换还有会话时序:README 说明保存的新选择对下一次 MCP 会话生效,不会反向修改既有会话。排查“已经选了 503,却还是看到上一次的正常响应”时,先看当前会话和实际 POST 序列,不必急着认定客户端缓存出了问题。
另一个源码边界在 SdkV2CompatScenario.cs 第 74–106 行:这台设为 Stateless=true 的示例主机支持 2026-07-28 stateless 原生 MRTR,不启用 stateful 路径或 2025-11-25 的有状态桥接;旧版 stateless 示例会提示补充 closeReason 后重新调用。这是本项目的支持矩阵,不是所有 MCP 客户端的能力边界。
对照真实 SDK 的静态实现,错误处理边界也很清楚:TypeScript SDK Client.callTool() 文档与源码区分了返回的 result.isError 工具错误与抛出的 ProtocolError 协议错误;底层 _onresponse也保留 error.code/message/data。反过来看,Cakewalk 公开 TS 示例客户端主要提取工具结果文本后打印,并未在这个展示函数里单独处理 isError。这些是源码观察,不是我执行了参考客户端后的恢复测试。
MCP 错误分类:十一组 Node.js 本地合成测试
我先在 Linux、Node.js v22.16.0 上跑了 9/9,修复旧版 resultType 兼容后是 10/10;本轮补上工具结果形状校验,形成 11/11。各版本保留独立源码和 TAP 日志。测试使用 node:http、node:events、node:test、node:assert/strict 及内置 fetch,监听 127.0.0.1 本地随机端口,不依赖 npm 包。
原模型曾把缺少 resultType 的合法结果误判为 INVALID_TOOL_RESULT。2026-07-28 基础协议的 ResultType 小节 原文要求:“clients MUST treat an absent resultType as "complete"”。也就是说,客户端必须兼容没有该字段的旧服务器;2025-11-25 版工具结果示例恰好未包含 resultType。旧分类器因此会错拒合法结果。
修正后,我又加入一个无效工具结果案例:content 本应是数组,却被写成字符串 "not-an-array",分类器返回 INVALID_TOOL_RESULT。新版 11/11 通过、失败 0、退出码 0,八种预设 outcome 都有实际合成输入触发。
| 合成输入 | 修订版分类结果 | 关键观察 |
|---|---|---|
| 正常 200 工具结果 | TOOL_SUCCESS | 作为成功对照 |
| 第一条 POST 503,显式再发一条得到 200 | HTTP_ERROR → TOOL_SUCCESS | 第二条由测试代码主动发送,不是自动重试 |
200 + JSON-RPC error: -32602 | JSONRPC_ERROR | HTTP-only 判断会误报成功 |
200 + isError: true | TOOL_ERROR | 协议成功不等于工具成功 |
| 500 + 看起来成功的正文 | HTTP_ERROR | 传输失败优先 |
| 无效 JSON | MALFORMED_JSON | 解析失败不能伪装成完成 |
同时出现 result / error | INVALID_ENVELOPE | 信封不自洽 |
响应 id 与请求不一致 | INVALID_ENVELOPE | 必须按对应 ID 关联响应 |
input_required | INPUT_REQUIRED | 需要后续输入,不是最终完成 |
没有 resultType 的合法旧版结果 | TOOL_SUCCESS | 遵守新协议的向后兼容回退 |
content: "not-an-array" | INVALID_TOOL_RESULT | 工具结果字段结构无效 |
这份程序定义了八种 outcome:正常的 TOOL_SUCCESS,三类失败的 HTTP_ERROR / JSONRPC_ERROR / TOOL_ERROR,交互未完成的 INPUT_REQUIRED,以及数据破损或结构错误的 MALFORMED_JSON、INVALID_ENVELOPE、INVALID_TOOL_RESULT。这意味着八种分类结果均被本地用例触发,但不代表分类器全部条件分支都有测试,也不代表通过了 MCP 官方符合性测试。
向后兼容的关键在这两行(摘自下方 classifyToolCall):
const result = response.result;
const resultType = has(result, 'resultType') ? result.resultType : 'complete';
完整程序在下方。保存为 mcp-error-layers-v3.test.mjs,用 Node.js 22 运行:
node --test --test-reporter=tap mcp-error-layers-v3.test.mjs
展开:十一组合成案例的完整可执行 Node.js 源码
/**
* First-party synthetic MCP-like response classifier. Offline loopback only.
* Node.js 22; no MCP SDK, Cakewalk runtime, credentials or external network.
* 2026-07-28 base-protocol compatibility: missing resultType is "complete".
* This deliberately omits real MCP negotiation, _meta, authorization and MRTR.
* Run: node --test --test-reporter=tap mcp-error-layers-v3.test.mjs
*/
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { createServer } from 'node:http';
import { test } from 'node:test';
const has = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
const good = { jsonrpc: '2.0', id: 1, result: {
resultType: 'complete', content: [{ type: 'text', text: 'ok' }], isError: false,
} };
const rpcError = { jsonrpc: '2.0', id: 1, error: {
code: -32602, message: 'synthetic invalid params',
} };
const toolError = { jsonrpc: '2.0', id: 1, result: {
resultType: 'complete', content: [{ type: 'text', text: 'failed' }], isError: true,
} };
export function classifyToolCall({ status, body, expectedId = 1 }) {
if (status < 200 || status >= 300) {
return { layer: 'transport', outcome: 'HTTP_ERROR', status };
}
let response;
try { response = JSON.parse(body); }
catch { return { layer: 'protocol', outcome: 'MALFORMED_JSON' }; }
if (!response || Array.isArray(response) || typeof response !== 'object' ||
response.jsonrpc !== '2.0' || response.id !== expectedId ||
has(response, 'result') === has(response, 'error')) {
return { layer: 'protocol', outcome: 'INVALID_ENVELOPE' };
}
if (has(response, 'error')) {
if (!response.error || !Number.isInteger(response.error.code) ||
typeof response.error.message !== 'string') {
return { layer: 'protocol', outcome: 'INVALID_ENVELOPE' };
}
return { layer: 'protocol', outcome: 'JSONRPC_ERROR', code: response.error.code };
}
const result = response.result;
if (!result || Array.isArray(result) || typeof result !== 'object') {
return { layer: 'protocol', outcome: 'INVALID_TOOL_RESULT' };
}
// Normative 2026-07-28 fallback: absent resultType means "complete".
const resultType = has(result, 'resultType') ? result.resultType : 'complete';
if (resultType === 'input_required') {
return { layer: 'interaction', outcome: 'INPUT_REQUIRED' };
}
if (resultType !== 'complete' || !Array.isArray(result.content) ||
(has(result, 'isError') && typeof result.isError !== 'boolean')) {
return { layer: 'protocol', outcome: 'INVALID_TOOL_RESULT' };
}
return result.isError === true
? { layer: 'application', outcome: 'TOOL_ERROR' }
: { layer: 'application', outcome: 'TOOL_SUCCESS' };
}
async function withPeer(t, reply) {
const calls = [];
const server = createServer(async (req, res) => {
let raw = '';
for await (const part of req) raw += part.toString('utf8');
calls.push({ method: req.method, path: req.url, body: raw });
const next = reply(calls.length);
res.writeHead(next.status, { 'content-type': 'application/json' });
res.end(typeof next.body === 'string' ? next.body : JSON.stringify(next.body));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
t.after(() => new Promise((resolve, reject) =>
server.close(error => error ? reject(error) : resolve())));
return { url: `http://127.0.0.1:${server.address().port}/mcp`, calls };
}
async function onePost(url) {
const response = await fetch(url, { method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'synthetic', arguments: {} } }),
});
const body = await response.text();
return classifyToolCall({ status: response.status, body });
}
function record(t, name, outcome, calls) {
t.diagnostic(JSON.stringify({ synthetic: true, name, outcome, postCount: calls.length }));
}
async function testCase(t, name, wire, expected) {
const peer = await withPeer(t, () => wire);
const actual = await onePost(peer.url);
assert.deepEqual(actual, expected);
assert.equal(peer.calls.length, 1);
record(t, name, actual, peer.calls);
}
test('baseline complete -> TOOL_SUCCESS', t => testCase(t, 'baseline',
{ status: 200, body: good }, { layer: 'application', outcome: 'TOOL_SUCCESS' }));
test('503 then 200 requires explicit second POST', async t => {
const peer = await withPeer(t, n => n === 1
? { status: 503, body: { note: 'synthetic outage' } }
: { status: 200, body: good });
const first = await onePost(peer.url);
assert.deepEqual(first, { layer: 'transport', outcome: 'HTTP_ERROR', status: 503 });
assert.equal(peer.calls.length, 1, 'no automatic retry by toy client');
const second = await onePost(peer.url); // Only this explicit call creates POST #2.
assert.deepEqual(second, { layer: 'application', outcome: 'TOOL_SUCCESS' });
assert.equal(peer.calls.length, 2);
record(t, 'explicit_http_503_then_200', first, peer.calls);
});
test('HTTP 200 + JSON-RPC error -> JSONRPC_ERROR', t => testCase(t, 'jsonrpc_error',
{ status: 200, body: rpcError },
{ layer: 'protocol', outcome: 'JSONRPC_ERROR', code: -32602 }));
test('HTTP 200 + tool isError -> TOOL_ERROR', t => testCase(t, 'tool_isError',
{ status: 200, body: toolError }, { layer: 'application', outcome: 'TOOL_ERROR' }));
test('HTTP 500 with success-looking body -> HTTP_ERROR', t => testCase(t, 'transport_precedence',
{ status: 500, body: good }, { layer: 'transport', outcome: 'HTTP_ERROR', status: 500 }));
test('malformed JSON -> MALFORMED_JSON', t => testCase(t, 'malformed_json',
{ status: 200, body: '{unclosed' }, { layer: 'protocol', outcome: 'MALFORMED_JSON' }));
test('result and error together -> INVALID_ENVELOPE', t => testCase(t, 'conflicting_envelope',
{ status: 200, body: { ...good, error: rpcError.error } },
{ layer: 'protocol', outcome: 'INVALID_ENVELOPE' }));
test('wrong JSON-RPC id -> INVALID_ENVELOPE', t => testCase(t, 'wrong_response_id',
{ status: 200, body: { ...good, id: 99 } },
{ layer: 'protocol', outcome: 'INVALID_ENVELOPE' }));
test('input_required is not successful completion', t => testCase(t, 'input_required',
{ status: 200, body: { jsonrpc: '2.0', id: 1, result: {
resultType: 'input_required', inputRequests: { approval: {
method: 'elicitation/create', params: { mode: 'form', message: 'synthetic input' },
} },
} } }, { layer: 'interaction', outcome: 'INPUT_REQUIRED' }));
test('legacy result without resultType -> TOOL_SUCCESS', t => testCase(t, 'legacy_resultType_absent',
{ status: 200, body: { jsonrpc: '2.0', id: 1,
result: { content: [{ type: 'text', text: 'legacy ok' }], isError: false } } },
{ layer: 'application', outcome: 'TOOL_SUCCESS' }));
test('invalid tool content shape -> INVALID_TOOL_RESULT', t => testCase(t, 'invalid_tool_result',
{ status: 200, body: { jsonrpc: '2.0', id: 1, result: {
resultType: 'complete', content: 'not-an-array', isError: false,
} } }, { layer: 'protocol', outcome: 'INVALID_TOOL_RESULT' }));
上面的折叠区已经包含完整可执行源码。复制保存成 mcp-error-layers-v3.test.mjs,运行上方的 node --test 命令即可复现合成测试;归档的 TAP 摘要如下:
# tests 11
# pass 11
# fail 0
研究仓库目前是私有的,原始源码、完整 TAP和运行回执仅供拥有仓库权限的协作者查阅,不作为普通读者的下载入口。归档源码 SHA-256 是 0f4616594ea14f23081b3da60d91808363b3b5b24a0889c560332bb5977f1cb2;它校验仓库原始字节,而不是浏览器复制时可能变动的文本。9/9 与 10/10 的早期记录仍保留。
真实 MCP 客户端的排障设计:日志记录与重试边界
真实客户端排障要对齐每次请求与响应,不能只看 UI 最后有没有报错。例如,先设计一份这样的日志结构(示意值):
{
"client": "your-client@version",
"protocolVersion": "2026-07-28",
"scenario": "errors.jsonrpc_error",
"method": "tools/call",
"postCount": 1,
"httpStatuses": [200],
"jsonrpc": { "id": 1, "errorCode": -32602 },
"resultType": null,
"outcome": "JSONRPC_ERROR",
"retryOrigin": "none"
}
客户端版本、协商版本和请求内容来自客户端日志及请求 _meta;场景配置、POST 计数与顺序从服务端日志核对。记录 HTTP 状态、Content-Type、JSON-RPC id,遇到协议错误还要保留脱敏后的 error.code / error.message / error.data。本文极简分类器只返回 code,不能直接替代线上诊断日志。retryOrigin 区分 SDK、业务代码与人工重发,必要时用隔离环境抓包核证。
我会把重试决策至少分成下面几类:
| 观察到的结果 | 是否自动重试 | 必须先满足的条件 |
|---|---|---|
| HTTP 503、网络暂不可用 | 有条件 | 先确认只读或幂等;设置总超时、有限次数,无法确定副作用时停止 |
| HTTP 429、触发限流 | 有条件 | 如有 Retry-After,尊重其等待指示;未提供时按本地退避、服务配额及最大次数处理,不通过并发或多账号绕限 |
JSON-RPC -32602 参数错误 | 通常不重试原参数 | 修正参数,再发新调用 |
isError: true 工具执行失败 | 看工具语义 | 保留错误详情;先排查输入和业务状态,不盲目重放 |
MALFORMED_JSON / INVALID_ENVELOPE / INVALID_TOOL_RESULT | 不直接重放 | 留原始脱敏响应、请求 ID、协议版本,排查兼容或实现缺陷 |
INPUT_REQUIRED | 不是故障重试 | 按 MRTR 流程取得输入,再发送带 inputResponses 的后续请求 |
对确定无副作用的只读查询,可以从“最多重试两次、初始 200 ms 指数退避加随机抖动”开始配置,再按 SLA 和限流调整;遇到 429,先服从服务器实际给出的 Retry-After(若有),不能把 200 ms 当作强制覆盖值。扣款、写库、发通知等操作缺少幂等键或服务端去重时,即使返回 503 也不应盲目重放。对 SSE 额外记录 Content-Type、事件数、最终 JSON-RPC 响应是否出现及断流原因:200 + 未收到最终结果,不是工具成功。
真正决定恢复策略的不是 HTTP 200/503 两个数字,而是调用到了哪一层、有没有最终结果、是否可安全重放。
延伸:测试工具、本地环境与托管限制
MCP Inspector适合手工观察消息,官方 MCP Conformance用于协议符合性检查,Cakewalk Test Kitchen 则通过故障注入观察客户端行为,三者解决的问题不同。
依照 Cakewalk 固定 README,本地部署需 .NET 10 SDK、Docker/PostgreSQL;开发 Web UI 使用 Node.js 22.17.1。本地 /mcp 可用测试 PAT,管理页面的 Google OAuth 配置则是另一个问题。
Cakewalk 托管服务条款(网页标注 2026-09-21) §5.2 禁止将官方端点接入 CI 或自动压测;§5.3 说明 MCP 端点按每 IP 每分钟 120 次限流,超出返回 429;§8 则明确不保证故障注入能复现真实世界的故障模式。该条款仍称服务器未开源,但源码固定提交已公开 MIT LICENSE,应把仓库许可证与官方托管端点使用规则分开核对。本文所有自动化测试都仅在本机回环进行,没有触碰官方端点。
相邻的两篇文章可以接着读:Gatus 的 HTTP 503 告警与恢复讨论服务健康探测如何识别持续失败与恢复;Claude Code 测试日志压缩的错误保留边界讨论错误信息在摘要中如何避免被隐藏。它们分别对应“何时发现故障”和“发现后如何保留证据”,与本文的“先分清失败发生在哪一层”恰好衔接。
本地回环测试时间:2026-10-17(UTC),11 组合成用例。Cakewalk 源码固定提交:cf2f6897b9712ec0cb9a7e01abee5109385fcd8b。未执行真实 MCP 客户端重试、SSE 断流或 MRTR 互操作测试。