# MCP Client Errors: Distinguishing HTTP 503, JSON-RPC Errors, and isError

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

An MCP client can receive HTTP 200 for a tool call while the agent still reports failure. That is not contradictory: an HTTP success status does not rule out a JSON-RPC `error` or a tool result with `isError: true`.

For an MCP gateway, coding-agent integration, or custom client, **checking only `response.ok` misses two important failure paths**. Examine the HTTP transport first, the JSON-RPC envelope next, and the tool result last. Whether a call may be retried is a separate decision.

I inspected [Cakewalk MCP Test Kitchen](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen) at the pinned [`cf2f689` commit](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/tree/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b) and ran an independent Node.js loopback classifier: **11 synthetic cases passed**. This validates my classifier, not interoperability with Cakewalk's .NET server, Cursor, Codex, or another actual MCP client.

## Three failure layers in tools/call: HTTP, JSON-RPC, and tool results

The [2026-07-28 MCP Tools Error Handling specification](https://modelcontextprotocol.io/specification/2026-07-28/server/tools) distinguishes protocol-level errors from errors originating inside a tool. With HTTP in the picture, a call has three distinct failure surfaces:

| Layer | Example | What the client can conclude |
| --- | --- | --- |
| HTTP transport | `503 Service Unavailable` | This HTTP request failed; tool side effects and retry behavior are not yet known |
| JSON-RPC protocol | HTTP 200 with top-level `error.code` and `error.message` | A JSON-RPC error occurred; there is no successful tool result |
| Tool execution | HTTP 200 with `result.isError: true` | The protocol exchange can be complete while the tool itself failed |

The first HTTP 200 example is a JSON-RPC error response:

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

The next HTTP 200 response represents a failure reported by the tool:

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "complete",
    "content": [{ "type": "text", "text": "Tool-side failure" }],
    "isError": true
  }
}
```

These are **server response bodies**; a full request must also follow the protocol's `_meta` and HTTP-header requirements.

The [2026-07-28 `CallToolResult` schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2026-07-28/schema.json) makes `isError` an optional boolean, treated as `false` when absent. Treating a missing `resultType` as `complete` is a separate backward-compatibility rule. A tool result may additionally contain `structuredContent` (not necessarily an object), but the schema for this version **still requires an array-valued `content` field**. This classifier checks that narrow shape; it is not a universal schema validator for every MCP version.

Tool-error text can help a model correct its arguments. The [Tools Error Handling section](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#error-handling) says “Clients SHOULD provide tool execution errors to language models” and allows protocol errors with “Clients MAY provide protocol errors to language models.” **SHOULD/MAY concern what to show the model, not an obligation to replay a request.** This distinction matters especially for tools with side effects.

`resultType: "input_required"` means the call is not finished and further input is necessary. Under [MRTR (Multi Round-Trip Requests)](https://modelcontextprotocol.io/specification/2026-07-28/basic#message-patterns), the response contains `inputRequests`. The client collects answers and sends a follow-up request using `inputResponses` and, if present, `requestState`, with a new JSON-RPC `id`. That is neither a tool error nor an ordinary timeout retry.

Streamable HTTP has a less obvious trap. The [2026-07-28 transport specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http) allows an HTTP request to return either `application/json` or `text/event-stream`. **HTTP 200 plus SSE only establishes that the response stream started.** A stream can break before the final JSON-RPC response, or that final response can be an `error`. A client should branch by `Content-Type`, match the request ID, and wait for a terminal response. Stream framing is an additional completeness concern, not a fourth kind of tool error.

JSON-RPC notifications have no `id`. For an accepted notification POST, the [transport specification describes `202 Accepted` with an empty body](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#sending-messages). Core Streamable HTTP in 2026-07-28 does not define ordinary client-originated notifications over HTTP; closing the response stream expresses cancellation. **Do not feed a 202 empty notification response into a classifier expecting a `tools/call` result body.**

## Cakewalk source inspection: 503 → 200 does not establish an automatic retry

Cakewalk deliberately makes the server misbehave according to selected scenarios. Its pinned code shows where errors are injected; the local classifier tests in the next section are a separate exercise.

In [`HttpStatusSequenceScenario.cs`, lines 12–35](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/src/McpTestServer.API/Scenarios/Errors/HttpStatusSequenceScenario.cs#L12-L35), `ScenarioWireInvocationKeys.HttpRequest` counts MCP POST requests for the session, rather than counting `tools/call` invocations. The configuration `{"statusCodes":[503,200]}` returns 503 on the first counted POST; a second POST proceeds under status 200. **A different POST may consume the first status.** For example, `tools/list` → first POST returns 503 → client stops → `tools/call` is never sent. This is a deduction from the counter, not a packet capture. The array alone proves no client retry.

In contrast, [`JsonRpcErrorScenario.cs`](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/src/McpTestServer.API/Scenarios/Errors/JsonRpcErrorScenario.cs#L20-L36) injects HTTP 200 with a JSON-RPC `error` for `tools/call`, whereas [`ToolErrorScenario.cs`](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/src/McpTestServer.API/Scenarios/Errors/ToolErrorScenario.cs) exercises `CallToolResult.isError`. They operate at distinct layers.

I checked [the DI registrations, lines 308–315](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/src/McpTestServer.API/Extensions/ServiceCollectionExtensions.cs#L308-L315) and [the pinned README, lines 197–220](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/README.md#L197-L220). The six scenarios enabled by default are `baseline`, `errors.http_status_sequence`, `errors.jsonrpc_error`, `errors.tool_error`, `elicitation.approval`, and `compat.sdk_v2`. Additional timeout, authentication, and catalog implementations exist in the repository but are not registered by default.

Session timing also matters: a saved scenario selection takes effect for the **next** MCP session, not retroactively for an existing one. Before blaming a client cache for a surprising 200 after selecting 503, inspect the session and actual POST sequence.

The [`SdkV2CompatScenario.cs` matrix, lines 74–106](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/src/McpTestServer.API/Scenarios/Compat/SdkV2CompatScenario.cs#L74-L106) describes this project's stateless host: native 2026-07-28 MRTR is available, but a stateful host path and the 2025-11-25 stateful bridge are not enabled here. Older stateless clients may be told to resend with `closeReason`. **That is a matrix for this example server, not a universal statement about MCP client support.**

A static look at real SDK code adds useful context. The official [TypeScript SDK `Client.callTool()` documentation](https://github.com/modelcontextprotocol/typescript-sdk/blob/9bc40e903fde15534ec06ff4c1347d47000dbe16/packages/client/src/client/client.ts#L2286-L2306) distinguishes a returned `result.isError` tool failure from a thrown `ProtocolError`. The lower-level [`_onresponse` handler](https://github.com/modelcontextprotocol/typescript-sdk/blob/9bc40e903fde15534ec06ff4c1347d47000dbe16/packages/core-internal/src/shared/protocol.ts#L1213-L1230) retains `error.code`, `error.message`, and `error.data`. Meanwhile, the [Cakewalk TypeScript reference client's example](https://github.com/cakewalk-security/cakewalk-mcp-ts-test-client/blob/4a0ff892bca6557ea97d427408cf888e2644000e/src/index.ts#L95-L108) extracts and prints result text without its own `isError` branch in that display function. These are **source observations**, not a test of the reference client's recovery behavior.

## Classifying MCP errors: 11 synthetic Node.js loopback tests

I first passed **9/9** cases on Linux and Node.js `v22.16.0`, then fixed the legacy `resultType` handling and passed **10/10**. The latest fixture adds malformed tool-result validation and passes **11/11**. Each version retains its own source and TAP evidence. The tests use `node:http`, `node:events`, `node:test`, `node:assert/strict`, and built-in `fetch` on an ephemeral `127.0.0.1` port, with no npm dependencies.

The earlier classifier rejected a valid response without `resultType` as `INVALID_TOOL_RESULT`. The [2026-07-28 base specification's ResultType rule](https://modelcontextprotocol.io/specification/2026-07-28/basic#resulttype) says clients “MUST treat an absent `resultType` as `"complete"`” for earlier servers. The [2025-11-25 tool-result examples](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) omit that field. A strict client without the fallback therefore rejects legitimate legacy results.

Next I introduced a tool result where `content` is the string `"not-an-array"` instead of an array. The classifier returned `INVALID_TOOL_RESULT`. The newest run passed **11 tests, 0 failures, exit code 0**, and exercised every one of its eight named outcomes.

| Synthetic input | Observed classification | Why it matters |
| --- | --- | --- |
| Normal HTTP 200 tool result | `TOOL_SUCCESS` | Control case |
| POST 1 returns 503; test sends POST 2 and receives 200 | `HTTP_ERROR` → `TOOL_SUCCESS` | The test sends the second request explicitly; this is not automatic retry |
| HTTP 200 with JSON-RPC `error: -32602` | `JSONRPC_ERROR` | HTTP-only checks miss the error |
| HTTP 200 with `isError: true` | `TOOL_ERROR` | Protocol success differs from tool success |
| HTTP 500 with success-looking body | `HTTP_ERROR` | Transport status takes precedence |
| Malformed JSON | `MALFORMED_JSON` | Parse failure is not completion |
| Both `result` and `error` | `INVALID_ENVELOPE` | Contradictory JSON-RPC envelope |
| Response `id` differs from request | `INVALID_ENVELOPE` | Responses must match their request IDs |
| `input_required` | `INPUT_REQUIRED` | More input is required; this is not final success |
| Valid legacy result lacking `resultType` | **`TOOL_SUCCESS`** | Required backward-compatibility fallback |
| `content: "not-an-array"` | **`INVALID_TOOL_RESULT`** | Invalid tool-result shape |

The classifier defines eight outcomes: `TOOL_SUCCESS`; the three failure kinds `HTTP_ERROR`, `JSONRPC_ERROR` and `TOOL_ERROR`; incomplete interaction `INPUT_REQUIRED`; and `MALFORMED_JSON`, `INVALID_ENVELOPE` and `INVALID_TOOL_RESULT`. **All eight labels were observed**, which does not mean 100% conditional branch coverage or official MCP conformance.

The backward-compatibility fix comes down to these lines from `classifyToolCall`:

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

Save the complete program below as `mcp-error-layers-v3.test.mjs` and run it with Node.js 22:

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

<details>
<summary>Expand: complete executable source for all eleven synthetic cases</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>

The fold above contains **the entire executable source**. Copy it into `mcp-error-layers-v3.test.mjs` and run the command. The archived TAP summary is:

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

The research repository is private. The original source, complete TAP output, and execution receipt files **are restricted to authorized repository collaborators**; they are not public downloads. The archived source SHA-256 is `0f4616594ea14f23081b3da60d91808363b3b5b24a0889c560332bb5977f1cb2` and applies to the original file bytes, which may differ from text copied out of a browser. The earlier 9/9 and 10/10 records were preserved.

## Designing real-client diagnostics: logs and safe retry boundaries

Real-client troubleshooting requires matching each request to its responses, not relying on a final green or red UI. A useful log record could look like this; **the values below are illustrative**:

```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"
}
```

Capture the client and negotiated protocol versions from client logs and request `_meta`; inspect scenario configuration, POST counters, and ordering in server logs. Record the HTTP status, `Content-Type`, JSON-RPC `id` and sanitized `error.code` / `error.message` / `error.data`. This minimal classifier returns only the code and is not a production logging system. `retryOrigin` distinguishes an SDK retry from a business-layer resend or a manual retry. Use isolated packet captures if necessary.

| Observation | Auto-retry? | Conditions |
| --- | --- | --- |
| HTTP 503 or temporary network failure | **Conditional** | Confirm read-only or idempotent operation; bound timeout and attempts; stop if side effects are uncertain |
| HTTP 429 rate limit | **Conditional** | Honor `Retry-After` when present; otherwise use bounded backoff and published quotas; never bypass limits through parallelism or accounts |
| JSON-RPC `-32602` invalid parameters | **Usually not with unchanged input** | Fix arguments before making a new call |
| Tool failure `isError: true` | **Depends on tool semantics** | Retain details, inspect input and business state, do not blindly replay |
| `MALFORMED_JSON` / `INVALID_ENVELOPE` / `INVALID_TOOL_RESULT` | **No blind retry** | Retain a sanitized response, request ID, and protocol version; investigate implementation and compatibility |
| `INPUT_REQUIRED` | **Not a failure retry** | Gather MRTR input and issue a follow-up call with `inputResponses` |

For provably side-effect-free reads, a starting policy might be **at most two retries with 200 ms initial exponential backoff and jitter**, adjusted for SLA and rate limits. For 429, an actual `Retry-After` value (when supplied) takes precedence. Without idempotency keys or server-side deduplication, do not replay payment, write, or notification tools simply because a 503 was returned. For SSE, record `Content-Type`, event count, whether a final JSON-RPC response was received, and why a stream ended. **HTTP 200 without the final result is not tool success.**

Recovery should be determined by **which layer failed, whether a final result exists, and whether replay is safe**, not by the number 200 or 503 alone.

<details>
<summary>Further reading: tools, local setup, and hosted-service limits</summary>

[MCP Inspector](https://github.com/modelcontextprotocol/inspector) helps inspect messages interactively. [Official MCP Conformance](https://github.com/modelcontextprotocol/conformance) checks protocol compliance; Cakewalk Test Kitchen injects failures to observe clients. These tools serve different purposes.

According to the [pinned Cakewalk README](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/README.md#L18-L39), local setup requires .NET 10 SDK and Docker/PostgreSQL, with Node.js 22.17.1 for Web UI development. A test PAT can be used for local `/mcp` access; Google OAuth configuration for administration is separate.

The [Cakewalk hosted Terms (dated 2026-09-21)](https://www.cakewalk.security/mcp-test-kitchen/legal/terms) prohibit connecting the hosted endpoint to CI or automated load tests (`5.2), set **120 MCP requests per minute per IP** and HTTP 429 above the limit (`5.3), and do not warrant that injected failures correspond to real-world faults (`8). The Terms still describe the server as closed source, while the pinned public repository includes an [MIT LICENSE](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/LICENSE). Hosted-service rules and source licensing must be evaluated separately. **All automated tests in this article ran on local loopback, not the hosted endpoint.**

</details>

Two related posts cover the surrounding workflow: [Gatus HTTP 503 alerts and recovery](/posts/gatus-docker-compose-http-503-alert-recovery/) explains service-health detection, while [Claude Code log compression and retained errors](/posts/claude-code-context-diet-log-compression/) addresses what evidence survives summarization. This article fills the step between them: identifying **which failure layer** actually occurred.

*Local loopback tests recorded 2026-10-17 (UTC), eleven synthetic cases. Cakewalk source pinned to `cf2f6897b9712ec0cb9a7e01abee5109385fcd8b`. No real MCP-client retry, SSE-disconnect, or MRTR interoperability tests were run.*
