# MCP 用戶端如何分辨 HTTP 503、JSON-RPC error 與 isError？

> Canonical: https://vxi.me/zh-tw/posts/mcp-client-http-jsonrpc-iserror/
> Locale: zh-tw
> Published: 2026-10-18
> Updated: 2026-10-18
> Markdown: https://vxi.me/zh-tw/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 .NET 服務或 Cursor、Codex 等真實 MCP 用戶端的互通性。

## 一次 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`（不一定是物件），但這個版本**仍要求 `content` 為陣列**。本文分類器只檢查此範圍，並非適用所有 MCP 版本與 SDK 的通用 Schema 驗證器。

工具錯誤文字有時能幫助模型修正參數。[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 notification 不帶 `id`。對已接受的 notification POST，[傳輸規範說明可回傳 `202 Accepted` 且沒有本文](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#sending-messages)。2026-07-28 核心 Streamable HTTP 並未定義一般的用戶端主動 HTTP notification 流程，取消則透過關閉回應串流表達。**不能把 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`（第一個 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`。三者是不同層級的故障注入。

我也對照了 [DI 註冊程式第 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 等額外實作，但並未預設註冊；找到類別不表示管理介面一定能直接選用。

切換情境還有工作階段時序問題：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)則給出此範例無狀態主機的相容矩陣：支援 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 公開的 TypeScript 範例用戶端](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)確實沒有該欄位；不做回退的嚴格驗證器會錯誤拒絕合法回應。

我又加入 `content` 被寫成字串 `"not-an-array"` 而非陣列的案例，分類器實際回傳 `INVALID_TOOL_RESULT`。最新版執行結果是 **11 個通過、0 個失敗、退出碼 0**，八種預定 `outcome` 均由合成輸入觸發。

| 合成輸入 | 實際分類 | 重點 |
| --- | --- | --- |
| 正常的 HTTP 200 工具結果 | `TOOL_SUCCESS` | 成功對照組 |
| 第一次 POST 503，程式明確再送一次得到 200 | `HTTP_ERROR` → `TOOL_SUCCESS` | 第二次是測試程式主動發送，不是自動重試 |
| 200 + JSON-RPC `error: -32602` | `JSONRPC_ERROR` | 只看 HTTP 會誤判成功 |
| 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`** | 工具結果格式錯誤 |

分類器定義了八種結果：`TOOL_SUCCESS`；`HTTP_ERROR`、`JSONRPC_ERROR`、`TOOL_ERROR` 三類錯誤；`INPUT_REQUIRED` 未完成互動；以及 `MALFORMED_JSON`、`INVALID_ENVELOPE`、`INVALID_TOOL_RESULT` 結構問題。**八種結果都在本機測試觀察到**，不等於程式碼達到 100% 條件分支覆蓋，也不代表通過官方 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>

折疊區已包含**完整可執行程式**。複製存檔後即可使用上述命令執行；歸檔 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`。本文的極簡分類器只回傳錯誤碼，不能取代正式診斷系統。`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`，應優先尊重該值。對扣款、寫入資料庫、發送通知等操作，沒有冪等鍵或服務端去重便不應因 503 而盲目重送。處理 SSE 還要記錄 `Content-Type`、事件數、是否收到最終 JSON-RPC 回應及中斷原因：**200 但沒有最終結果，不是工具成功。**

恢復策略應由**失敗層級、是否取得最終結果、重放是否安全**決定，而不是只看 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 規定每 IP 每分鐘 **120 次 MCP 請求**，超限回 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 互通測試。*
