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 .NET 服務或 Cursor、Codex 等真實 MCP 用戶端的互通性。

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

工具錯誤文字有時能幫助模型修正參數。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 notification 不帶 id。對已接受的 notification POST,傳輸規範說明可回傳 202 Accepted 且沒有本文。2026-07-28 核心 Streamable HTTP 並未定義一般的用戶端主動 HTTP notification 流程,取消則透過關閉回應串流表達。不能把 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(第一個 POST → 503)→ 用戶端停止 → 根本沒有發送 tools/call。這是根據計數邏輯推導的時序,不是封包擷取;[503,200] 本身不能證明發生重試。

相較之下,JsonRpcErrorScenario.cs對 tools/call 注入 HTTP 200 + JSON-RPC error,ToolErrorScenario.cs則測試 CallToolResult 的 isError。三者是不同層級的故障注入。

我也對照了 DI 註冊程式第 308–315 行與固定 README 第 197–220 行。預設啟用的六個情境為 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 行則給出此範例無狀態主機的相容矩陣:支援 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 公開的 TypeScript 範例用戶端在展示函式中主要擷取並輸出文字,沒有在該函式自行分支處理 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 工具結果範例確實沒有該欄位;不做回退的嚴格驗證器會錯誤拒絕合法回應。

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

合成輸入實際分類重點
正常的 HTTP 200 工具結果TOOL_SUCCESS成功對照組
第一次 POST 503,程式明確再送一次得到 200HTTP_ERROR → TOOL_SUCCESS第二次是測試程式主動發送,不是自動重試
200 + JSON-RPC error: -32602JSONRPC_ERROR只看 HTTP 會誤判成功
200 + isError: trueTOOL_ERROR協定成功不等於工具成功
500 + 看似成功的本文HTTP_ERROR傳輸錯誤優先
無效 JSONMALFORMED_JSON解析失敗不能當作完成
同時具有 result 和 errorINVALID_ENVELOPE回應封套矛盾
回應 id 與請求不符INVALID_ENVELOPE必須依 ID 對應
input_requiredINPUT_REQUIRED尚需額外輸入
合法舊版結果沒有 resultTypeTOOL_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 中兩行:

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

折疊區已包含完整可執行程式。複製存檔後即可使用上述命令執行;歸檔 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。本文的極簡分類器只回傳錯誤碼,不能取代正式診斷系統。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。

延伸閱讀:測試工具、本機環境與託管服務限制

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 規定每 IP 每分鐘 120 次 MCP 請求,超限回 429;`8 不保證注入故障等同真實故障。條款仍稱伺服器未開源,但固定公開儲存庫已有 MIT LICENSE。需區分原始碼授權與託管服務使用限制。本文自動測試全部在本機回環執行,並未呼叫官方託管端點。

另外兩篇相關文章可延伸閱讀:Gatus 的 HTTP 503 警報與恢復討論何時發現服務故障;Claude Code 日誌壓縮的錯誤保留邊界討論故障證據如何避免被摘要隱藏。本文補上中間一步:先確認失敗發生在哪一層。

本機回環測試時間:2026-10-17(UTC),11 組合成案例。Cakewalk 原始碼固定提交 cf2f6897b9712ec0cb9a7e01abee5109385fcd8b。未執行真實 MCP 用戶端重試、SSE 中斷或 MRTR 互通測試。