# MCP クライアントの HTTP 503・JSON-RPC error・isError をどう区別するか

> Canonical: https://vxi.me/ja/posts/mcp-client-http-jsonrpc-iserror/
> Locale: ja-jp
> Published: 2026-10-18
> Updated: 2026-10-18
> Markdown: https://vxi.me/ja/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
  }
}
```

どちらもサーバーの**レスポンス本文**である。実際のリクエストには `_meta` や必要な HTTP ヘッダーも含めなければならない。

[2026-07-28 の `CallToolResult` スキーマ](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2026-07-28/schema.json)では、`isError` は省略可能な boolean で、省略時は `false` と解釈する。`resultType` の省略を `complete` とみなすのは別の後方互換ルールだ。`structuredContent` も追加できるが、必ずしもオブジェクトではない。一方このバージョンのスキーマでは、**`content` は引き続き配列で必須**である。ここでの分類器はその形を検査するだけで、全バージョン・全 SDK 対応の検証器ではない。

ツール実行時のエラーテキストは、モデルの引数修正に役立つことがある。[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 が示すのはモデルへのエラー提供であり、無条件のリクエスト再送ではない。** 副作用のあるツールでは特に重要だ。

`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 は通常のクライアント起点 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、二つ目が到着すれば 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)には、このサンプルの stateless ホストの互換マトリクスがある。2026-07-28 のネイティブ stateless MRTR は使えるが、stateful ホスト経路や 2025-11-25 の stateful ブリッジはここでは有効でない。旧式 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`** と、**throw される `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` 分岐がない。これらはソース観察であり、サンプルクライアントを実行したリカバリ試験ではない。

## Node.js の合成テスト 11 件で MCP エラーを分類する

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>展開：合成テスト 11 件の実行可能な 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` が実際に提示されたらそちらを優先する。決済、DB 書き込み、通知送信などは、冪等キーやサーバー側の重複排除なしに 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 と、管理 UI の 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 の相互運用試験は実施していない。*
