# MCP 客户端怎么区分 HTTP 503、JSON-RPC error 与 isError？

> Canonical: https://vxi.me/posts/mcp-client-http-jsonrpc-iserror/
> Locale: zh-cn
> Published: 2026-10-18
> Updated: 2026-10-18
> Markdown: https://vxi.me/posts/mcp-client-http-jsonrpc-iserror.md

MCP 客户端调用工具时，HTTP 返回了 200，Agent 却提示执行失败。两件事并不矛盾：HTTP 请求得到成功状态，不代表 JSON-RPC 响应没有 `error`，也不代表工具结果的 `isError` 为 `false`。

在 MCP Gateway、Coding Agent 接入层或自己写的客户端里，**只检查 `response.ok` 会漏掉两类重要失败**。更稳妥的顺序是先判断 HTTP 传输，再检查 JSON-RPC 响应信封，最后判断工具结果；至于要不要重试，那是下一道独立决策。

我核对了 [Cakewalk MCP Test Kitchen](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen) 固定提交 [`cf2f689`](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/tree/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b) 的源码，再独立运行一份 Node.js 本地回环分类器：**11 组合成用例全部通过**。这验证的是自写分类逻辑，而不是 Cakewalk 服务或 Cursor、Codex 等真实客户端的互操作行为。

## 一次 tools/call 的三层错误：HTTP、JSON-RPC 与工具结果

[2026-07-28 MCP Tools 规范的 Error Handling 节](https://modelcontextprotocol.io/specification/2026-07-28/server/tools)把协议错误和工具执行错误分得很清楚。算上 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 错误信封：

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": { "code": -32602, "message": "Invalid params" }
}
```

另一种是工具返回的业务失败：

```json
{
  "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](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2026-07-28/schema.json)中，`isError` 是可选布尔字段，省略时按 `false` 理解；`resultType` 缺失按 `complete` 处理则是向后兼容规则。工具结果还可包含 `structuredContent`（不限于对象），但这个版本的 Schema **仍要求 `content` 是数组**。本文对 `content` 的类型校验符合这个范围，不是通用的所有版本/SDK 类型验证器。

工具执行失败的文本有时能帮助模型修正参数。[MCP Tools 规范的 Error Handling 节](https://modelcontextprotocol.io/specification/2026-07-28/server/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，多轮往返请求）](https://modelcontextprotocol.io/specification/2026-07-28/basic#message-patterns) 在响应里携带 `inputRequests`，客户端处理后用 `inputResponses` 和可能存在的 `requestState` 发起新的请求，JSON-RPC `id` 也要更新。它不是工具执行错误，更不是超时重试。

Streamable HTTP 还有一个更隐蔽的边界：[2026-07-28 传输规范](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http)允许同一请求返回 `application/json` 或 `text/event-stream`。收到 **HTTP 200 + SSE** 只表示响应流已经开始；中途断流、尚未收到最终 JSON-RPC 响应、或最终收到 `error`，都不能记作工具成功。客户端要按 `Content-Type` 解析响应，追踪请求 ID 与最终结果。这里是传输帧完整性问题，不是把原本三类错误再机械增加一种工具错误。

另外，JSON-RPC 通知不带 `id`；通知 POST 被接受时，[传输规范规定可返回 `202 Accepted` 且没有响应体](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#sending-messages)。2026-07-28 的核心 Streamable HTTP 本身并不定义客户端主动发通知的常规流程，取消则通过关闭响应流表达。因此**不能把 `202` 空响应套进要求 `tools/call` 有结果体的分类器**。

## Cakewalk 静态源码：503 → 200 不等于客户端自动重试

Cakewalk 让服务端按配置主动制造故障。先看固定源码如何产生这些响应，再看下一节独立运行的本地合成测试。

在 [`HttpStatusSequenceScenario.cs` 第 12–35 行](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/src/McpTestServer.API/Scenarios/Errors/HttpStatusSequenceScenario.cs#L12-L35)，实现使用 `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`](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/src/McpTestServer.API/Scenarios/Errors/JsonRpcErrorScenario.cs#L20-L36)会针对 `tools/call` 返回 HTTP 200 + JSON-RPC `error`；[`ToolErrorScenario.cs`](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/src/McpTestServer.API/Scenarios/Errors/ToolErrorScenario.cs)则提供 `CallToolResult` 的 `isError` 路径。它们在代码里是三个不同的注入位置，不能把它们合并成一个“异常请求”。

我还核对了 [默认注册代码第 308–315 行](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/src/McpTestServer.API/Extensions/ServiceCollectionExtensions.cs#L308-L315)及[固定 README 第 197–220 行](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/README.md#L197-L220)：当前默认注册的六个场景是 `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 行](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/src/McpTestServer.API/Scenarios/Compat/SdkV2CompatScenario.cs#L74-L106)：这台设为 `Stateless=true` 的示例主机支持 2026-07-28 stateless 原生 MRTR，不启用 stateful 路径或 2025-11-25 的有状态桥接；旧版 stateless 示例会提示补充 `closeReason` 后重新调用。**这是本项目的支持矩阵，不是所有 MCP 客户端的能力边界。**

对照真实 SDK 的静态实现，错误处理边界也很清楚：[TypeScript SDK `Client.callTool()` 文档与源码](https://github.com/modelcontextprotocol/typescript-sdk/blob/9bc40e903fde15534ec06ff4c1347d47000dbe16/packages/client/src/client/client.ts#L2286-L2306)区分了**返回的 `result.isError` 工具错误**与**抛出的 `ProtocolError` 协议错误**；[底层 `_onresponse`](https://github.com/modelcontextprotocol/typescript-sdk/blob/9bc40e903fde15534ec06ff4c1347d47000dbe16/packages/core-internal/src/shared/protocol.ts#L1213-L1230)也保留 `error.code/message/data`。反过来看，[Cakewalk 公开 TS 示例客户端](https://github.com/cakewalk-security/cakewalk-mcp-ts-test-client/blob/4a0ff892bca6557ea97d427408cf888e2644000e/src/index.ts#L95-L108)主要提取工具结果文本后打印，并未在这个展示函数里单独处理 `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 小节](https://modelcontextprotocol.io/specification/2026-07-28/basic#resulttype) 原文要求：“clients MUST treat an absent `resultType` as `"complete"`”。也就是说，客户端**必须**兼容没有该字段的旧服务器；[2025-11-25 版工具结果示例](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)恰好未包含 `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`）：

```js
const result = response.result;
const resultType = has(result, 'resultType') ? result.resultType : 'complete';
```

完整程序在下方。保存为 `mcp-error-layers-v3.test.mjs`，用 Node.js 22 运行：

```sh
node --test --test-reporter=tap mcp-error-layers-v3.test.mjs
```

<details>
<summary>展开：十一组合成案例的完整可执行 Node.js 源码</summary>

```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' }));
```

</details>

上面的折叠区已经包含**完整可执行源码**。复制保存成 `mcp-error-layers-v3.test.mjs`，运行上方的 `node --test` 命令即可复现合成测试；归档的 TAP 摘要如下：

```text
# tests 11
# pass 11
# fail 0
```

研究仓库目前是私有的，原始源码、完整 TAP和运行回执**仅供拥有仓库权限的协作者查阅**，不作为普通读者的下载入口。归档源码 SHA-256 是 `0f4616594ea14f23081b3da60d91808363b3b5b24a0889c560332bb5977f1cb2`；它校验仓库原始字节，而不是浏览器复制时可能变动的文本。9/9 与 10/10 的早期记录仍保留。

## 真实 MCP 客户端的排障设计：日志记录与重试边界

真实客户端排障要对齐每次请求与响应，不能只看 UI 最后有没有报错。例如，先设计一份这样的日志结构（示意值）：

```json
{
  "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 两个数字，而是**调用到了哪一层、有没有最终结果、是否可安全重放**。

<details>
<summary>延伸：测试工具、本地环境与托管限制</summary>

[MCP Inspector](https://github.com/modelcontextprotocol/inspector)适合手工观察消息，[官方 MCP Conformance](https://github.com/modelcontextprotocol/conformance)用于协议符合性检查，Cakewalk Test Kitchen 则通过故障注入观察客户端行为，三者解决的问题不同。

依照 Cakewalk [固定 README](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/README.md#L18-L39)，本地部署需 .NET 10 SDK、Docker/PostgreSQL；开发 Web UI 使用 Node.js 22.17.1。本地 `/mcp` 可用测试 PAT，管理页面的 Google OAuth 配置则是另一个问题。

Cakewalk [托管服务条款（网页标注 2026-09-21）](https://www.cakewalk.security/mcp-test-kitchen/legal/terms) §5.2 禁止将官方端点接入 CI 或自动压测；§5.3 说明 MCP 端点按每 IP 每分钟 **120 次**限流，超出返回 429；§8 则明确不保证故障注入能复现真实世界的故障模式。该条款仍称服务器未开源，但源码固定提交已公开 [MIT LICENSE](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/LICENSE)，应把仓库许可证与官方托管端点使用规则分开核对。本文所有自动化测试都仅在本机回环进行，没有触碰官方端点。

</details>

相邻的两篇文章可以接着读：[Gatus 的 HTTP 503 告警与恢复](/posts/gatus-docker-compose-http-503-alert-recovery/)讨论服务健康探测如何识别持续失败与恢复；[Claude Code 测试日志压缩的错误保留边界](/posts/claude-code-context-diet-log-compression/)讨论错误信息在摘要中如何避免被隐藏。它们分别对应“何时发现故障”和“发现后如何保留证据”，与本文的“先分清失败发生在哪一层”恰好衔接。

*本地回环测试时间：2026-10-17（UTC），11 组合成用例。Cakewalk 源码固定提交：`cf2f6897b9712ec0cb9a7e01abee5109385fcd8b`。未执行真实 MCP 客户端重试、SSE 断流或 MRTR 互操作测试。*
