# Errores en clientes MCP: HTTP 503, JSON-RPC error e isError

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

Un cliente MCP llama a una herramienta, recibe HTTP 200 y, aun así, el agente informa de un fallo. No hay contradicción: que HTTP haya tenido éxito no implica que la respuesta JSON-RPC carezca de `error` ni que `isError` en el resultado de la herramienta sea `false`.

En un gateway MCP, una integración con un agente de programación o un cliente propio, **comprobar solo `response.ok` pasa por alto dos clases importantes de fallo**. Primero hay que interpretar el transporte HTTP, después el sobre JSON-RPC y por último el resultado de la herramienta. Decidir si se puede reintentar es una cuestión independiente.

Inspeccioné [Cakewalk MCP Test Kitchen](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen) en el [commit fijo `cf2f689`](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/tree/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b) y ejecuté un clasificador propio mediante loopback local en Node.js: **pasaron las 11 pruebas sintéticas**. Esto valida la lógica que escribí, no la interoperabilidad con el servidor .NET de Cakewalk ni con clientes MCP reales como Cursor o Codex.

## Tres capas de error en tools/call: HTTP, JSON-RPC y resultado de la herramienta

La sección Error Handling de la [especificación MCP Tools de 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/server/tools) distingue entre errores de protocolo y errores de ejecución de la herramienta. Al incluir el transporte HTTP aparecen tres capas diferentes:

| Capa | Respuesta de ejemplo | Qué puede concluir el cliente |
| --- | --- | --- |
| Transporte HTTP | `503 Service Unavailable` | Falló la petición HTTP; aún no se conocen efectos secundarios ni reintentos automáticos |
| Protocolo JSON-RPC | HTTP 200 con `error.code` y `error.message` en la raíz | Hay un error JSON-RPC; no existe resultado de herramienta satisfactorio |
| Ejecución de herramienta | HTTP 200 con `result.isError: true` | La interacción de protocolo puede completarse aunque la herramienta falle |

Esta respuesta HTTP 200 contiene un error JSON-RPC:

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

Esta otra HTTP 200 representa un fallo notificado por la propia herramienta:

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

Ambos JSON son **cuerpos de respuesta del servidor**. Una petición completa también debe respetar los requisitos de `_meta` y de cabeceras HTTP.

Según el [esquema `CallToolResult` de 2026-07-28](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2026-07-28/schema.json), `isError` es un booleano opcional: si no aparece, se interpreta como `false`. La conversión de un `resultType` ausente a `complete` obedece a otra regla de compatibilidad. También puede existir `structuredContent`, que no tiene por qué ser un objeto, pero esta versión del esquema **sigue exigiendo `content` como arreglo**. Este clasificador valida esa forma concreta, no todas las variantes de MCP y sus SDK.

El texto de un fallo de herramienta puede ayudar al modelo a corregir parámetros. La [sección Error Handling](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#error-handling) dice “Clients SHOULD provide tool execution errors to language models”; para los errores de protocolo emplea “Clients MAY provide protocol errors to language models”. **SHOULD y MAY regulan qué información recibe el modelo, no ordenan repetir incondicionalmente la petición.** Es esencial cuando hay efectos secundarios.

`resultType: "input_required"` indica que la llamada todavía no ha terminado y hace falta más información. En [MRTR (Multi Round-Trip Requests, solicitudes de varias rondas)](https://modelcontextprotocol.io/specification/2026-07-28/basic#message-patterns), el servidor devuelve `inputRequests`; el cliente recoge respuestas y envía otra petición con `inputResponses`, posiblemente `requestState` y un nuevo `id` JSON-RPC. No es un fallo de ejecución ni un simple reintento por tiempo agotado.

Streamable HTTP tiene otro límite menos evidente. La [especificación de transporte de 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http) permite `application/json` o `text/event-stream` en respuesta a una misma petición. **HTTP 200 con SSE solo confirma el inicio del flujo.** Puede cortarse antes de la respuesta JSON-RPC final, o dicha respuesta puede incluir `error`. El cliente debe diferenciar por `Content-Type`, relacionar el ID de petición y comprobar la respuesta terminal. Esto concierne a la integridad del flujo, no supone inventar una cuarta clase de error de herramienta.

Las notificaciones JSON-RPC no tienen `id`. Para un POST de notificación aceptado, la [especificación describe `202 Accepted` sin cuerpo](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#sending-messages). El núcleo de Streamable HTTP de 2026-07-28 no define las notificaciones HTTP habituales iniciadas por el cliente; el cierre del flujo expresa la cancelación. **Un 202 vacío no se puede introducir en el clasificador que espera el cuerpo de un `tools/call`.**

## Lectura del código de Cakewalk: 503 → 200 no demuestra un reintento automático

Cakewalk hace que el servidor provoque fallos deliberados conforme al escenario configurado. Su código anclado muestra dónde se inyectan; las pruebas locales independientes vienen después.

En [`HttpStatusSequenceScenario.cs`, líneas 12–35](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/src/McpTestServer.API/Scenarios/Errors/HttpStatusSequenceScenario.cs#L12-L35), `ScenarioWireInvocationKeys.HttpRequest` cuenta los POST de MCP dentro de una sesión, no solo las invocaciones de `tools/call`. La configuración `{"statusCodes":[503,200]}` devuelve 503 en el primer POST contado; si realmente llega un segundo, el 200 permite continuar el procesamiento. **Otro POST puede consumir antes el primer estado.** Por ejemplo: `tools/list` → primer POST recibe 503 → el cliente se detiene → `tools/call` nunca se envía. Es una secuencia deducida del contador, no una captura de tráfico; `[503,200]` por sí mismo no prueba un reintento.

En cambio, [`JsonRpcErrorScenario.cs`](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/src/McpTestServer.API/Scenarios/Errors/JsonRpcErrorScenario.cs#L20-L36) devuelve HTTP 200 con `error` JSON-RPC para `tools/call`, mientras que [`ToolErrorScenario.cs`](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/src/McpTestServer.API/Scenarios/Errors/ToolErrorScenario.cs) utiliza la ruta `CallToolResult.isError`. Son lugares de inyección distintos.

También comprobé [las inscripciones DI, líneas 308–315](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/src/McpTestServer.API/Extensions/ServiceCollectionExtensions.cs#L308-L315) y [el README fijado, líneas 197–220](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/README.md#L197-L220). Los seis escenarios habilitados por defecto son `baseline`, `errors.http_status_sequence`, `errors.jsonrpc_error`, `errors.tool_error`, `elicitation.approval` y `compat.sdk_v2`. El repositorio contiene otras implementaciones de timeout, autenticación o catálogos, pero no están registradas por defecto.

El momento del cambio también importa: según el README, una selección guardada afecta a la **siguiente sesión MCP**, sin alterar retroactivamente la sesión en curso. Si al elegir 503 aún se observa una antigua respuesta correcta, antes de culpar a la caché del cliente conviene revisar sesión y orden real de POST.

La [matriz de `SdkV2CompatScenario.cs`, líneas 74–106](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/src/McpTestServer.API/Scenarios/Compat/SdkV2CompatScenario.cs#L74-L106) describe el host stateless de este ejemplo. Soporta MRTR nativo stateless de 2026-07-28, pero no activa la ruta stateful ni el puente stateful para 2025-11-25. Un cliente stateless antiguo puede recibir instrucciones para reenviar con `closeReason`. **Esta es la compatibilidad de ese servidor de ejemplo, no una regla para todos los clientes MCP.**

Una inspección estática de un SDK real aporta contexto: la [documentación y código del SDK TypeScript `Client.callTool()`](https://github.com/modelcontextprotocol/typescript-sdk/blob/9bc40e903fde15534ec06ff4c1347d47000dbe16/packages/client/src/client/client.ts#L2286-L2306) distingue un fallo de herramienta **devuelto como `result.isError`** de un error de protocolo **lanzado como `ProtocolError`**. El manejador inferior [`_onresponse`](https://github.com/modelcontextprotocol/typescript-sdk/blob/9bc40e903fde15534ec06ff4c1347d47000dbe16/packages/core-internal/src/shared/protocol.ts#L1213-L1230) conserva `error.code/message/data`. Por su parte, la [función de presentación del cliente TypeScript de Cakewalk](https://github.com/cakewalk-security/cakewalk-mcp-ts-test-client/blob/4a0ff892bca6557ea97d427408cf888e2644000e/src/index.ts#L95-L108) extrae e imprime texto y no añade su propia rama `isError`. Son observaciones del código, no pruebas ejecutadas del comportamiento de recuperación del cliente.

## Clasificación de errores MCP con 11 pruebas sintéticas locales en Node.js

Primero pasé **9/9** casos con Linux y Node.js `v22.16.0`. Tras corregir la compatibilidad de `resultType` llegué a **10/10**; un caso adicional de resultado mal formado elevó la serie a **11/11**. Conservo por separado el código y las salidas TAP de cada versión. Se emplean `node:http`, `node:events`, `node:test`, `node:assert/strict` y `fetch` integrado, con un puerto efímero en `127.0.0.1` y sin dependencias npm.

La versión anterior clasificaba una respuesta válida sin `resultType` como `INVALID_TOOL_RESULT`. La [regla ResultType del protocolo base de 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic#resulttype) exige, para servidores antiguos: “clients MUST treat an absent `resultType` as `"complete"`”. Los [ejemplos de herramientas de 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) carecen de ese campo. Sin la regla de respaldo, un validador estricto rechaza resultados legítimos.

Agregué un caso en el que `content` era la cadena `"not-an-array"` y no un arreglo. El clasificador devolvió `INVALID_TOOL_RESULT`. La ejecución final produjo **11 pruebas aprobadas, 0 fallos y código de salida 0**, con las ocho categorías `outcome` observadas.

| Entrada sintética | Clasificación observada | Enseñanza |
| --- | --- | --- |
| Resultado normal de herramienta con HTTP 200 | `TOOL_SUCCESS` | Caso de control |
| Primer POST 503, el test envía un segundo POST y recibe 200 | `HTTP_ERROR` → `TOOL_SUCCESS` | El segundo envío es explícito, no un reintento automático |
| HTTP 200 + `error: -32602` JSON-RPC | `JSONRPC_ERROR` | Comprobar solo HTTP genera falso éxito |
| HTTP 200 + `isError: true` | `TOOL_ERROR` | Éxito del protocolo no implica éxito de la herramienta |
| HTTP 500 con cuerpo que parece correcto | `HTTP_ERROR` | Prevalece el error de transporte |
| JSON mal formado | `MALFORMED_JSON` | Un fallo de análisis no equivale a finalización |
| `result` y `error` simultáneos | `INVALID_ENVELOPE` | Sobre JSON-RPC contradictorio |
| `id` de respuesta diferente al de la petición | `INVALID_ENVELOPE` | Se debe asociar por ID |
| `input_required` | `INPUT_REQUIRED` | Falta entrada adicional |
| Resultado antiguo válido sin `resultType` | **`TOOL_SUCCESS`** | Compatibilidad hacia atrás obligatoria |
| `content: "not-an-array"` | **`INVALID_TOOL_RESULT`** | Forma de resultado inválida |

El programa define ocho estados: `TOOL_SUCCESS`; `HTTP_ERROR`, `JSONRPC_ERROR` y `TOOL_ERROR`; la interacción pendiente `INPUT_REQUIRED`; y `MALFORMED_JSON`, `INVALID_ENVELOPE`, `INVALID_TOOL_RESULT`. **Se observaron todos los estados definidos**, pero eso no representa cobertura del 100 % de las ramas del programa ni conformidad oficial con MCP.

La corrección esencial de compatibilidad son estas dos líneas de `classifyToolCall`:

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

Guarda el programa completo que sigue como `mcp-error-layers-v3.test.mjs` y ejecútalo con Node.js 22:

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

<details>
<summary>Desplegar: código Node.js ejecutable completo de las once pruebas sintéticas</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>

El bloque anterior contiene **todo el código ejecutable**. Se puede copiar al archivo y lanzar la orden indicada. El resumen TAP archivado es:

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

El repositorio de investigación es privado. Los enlaces al código original, al TAP completo y al recibo de ejecución **solo están disponibles para colaboradores autorizados**; no son descargas públicas. El SHA-256 del archivo original es `0f4616594ea14f23081b3da60d91808363b3b5b24a0889c560332bb5977f1cb2` y verifica sus bytes originales; copiarlo desde el navegador puede alterar los saltos de línea. También se conservan los registros anteriores de 9/9 y 10/10.

## Diseño de diagnóstico para clientes MCP reales: registros y límites de reintento

Para analizar un cliente real, hay que relacionar cada petición con sus respuestas, no limitarse a comprobar si la interfaz termina en verde o rojo. Este es un posible registro con **valores ilustrativos**, no procedentes de una captura:

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

La versión del cliente, el protocolo negociado y `_meta` se obtienen de los registros del cliente; la configuración de escenario, el contador POST y su orden se contrastan con los del servidor. Conviene registrar el estado HTTP, `Content-Type`, el `id` JSON-RPC y `error.code`, `error.message` y `error.data` tras eliminar datos sensibles. Este clasificador mínimo solo devuelve el código; no sustituye a un sistema de diagnóstico en producción. `retryOrigin` diferencia los reintentos del SDK, de la lógica de negocio y del usuario.

| Observación | ¿Reintento automático? | Condiciones |
| --- | --- | --- |
| HTTP 503 o fallo temporal de red | **Condicional** | Confirmar lectura sin efectos o idempotencia; limitar tiempo e intentos, detenerse si los efectos son inciertos |
| HTTP 429 por límite de tasa | **Condicional** | Respetar `Retry-After` si existe; en otro caso aplicar retroceso acotado y cuotas. No eludir límites con paralelismo o varias cuentas |
| JSON-RPC `-32602`, parámetros inválidos | **Normalmente no con los mismos parámetros** | Corregir los argumentos antes de una llamada nueva |
| Fallo de herramienta `isError: true` | **Depende de la semántica** | Guardar detalles, revisar entradas y estado de negocio, no repetir a ciegas |
| `MALFORMED_JSON` / `INVALID_ENVELOPE` / `INVALID_TOOL_RESULT` | **Sin repetición ciega** | Guardar respuesta saneada, ID y versión, investigar compatibilidad e implementación |
| `INPUT_REQUIRED` | **No es un reintento por fallo** | Reunir entradas MRTR y continuar con `inputResponses` |

Para consultas de solo lectura y sin efectos secundarios demostrados, puede comenzarse con **hasta dos reintentos, retroceso exponencial inicial de 200 ms y jitter**, ajustando por SLA y cuotas. En caso de 429, `Retry-After` prevalece si el servidor lo envía. Un pago, una escritura en base de datos o un envío de notificación no deben repetirse solo por recibir 503 si no hay clave de idempotencia ni deduplicación en el servidor. Con SSE deben anotarse `Content-Type`, número de eventos, presencia de respuesta JSON-RPC final y causa del cierre. **HTTP 200 sin resultado final no es éxito de la herramienta.**

La política de recuperación depende de **qué capa falló, si existe resultado terminal y si repetir es seguro**; no solo de los números 200 y 503.

<details>
<summary>Ampliación: herramientas de prueba, entorno local y límites del servicio alojado</summary>

[MCP Inspector](https://github.com/modelcontextprotocol/inspector) sirve para examinar mensajes manualmente, [MCP Conformance oficial](https://github.com/modelcontextprotocol/conformance) para comprobar el protocolo y Cakewalk Test Kitchen para inyectar fallos y observar al cliente. Son herramientas complementarias.

El [README fijado de Cakewalk](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/README.md#L18-L39) indica .NET 10 SDK, Docker/PostgreSQL para la instalación local y Node.js 22.17.1 para desarrollar la interfaz. El `/mcp` local puede usar un PAT de pruebas; configurar Google OAuth para el panel de administración es una cuestión separada.

Las [condiciones del servicio alojado de Cakewalk (fecha visible 2026-09-21)](https://www.cakewalk.security/mcp-test-kitchen/legal/terms) prohíben conectar el endpoint oficial a CI o cargas automatizadas (`5.2), fijan **120 peticiones MCP por minuto e IP** con 429 al excederlas (`5.3), y no garantizan que los fallos inyectados equivalgan a fallos reales (`8). Las condiciones siguen describiendo al servidor como cerrado, aunque el repositorio público fijado contiene una [MIT LICENSE](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/LICENSE). La licencia del código y las reglas del servicio alojado deben evaluarse por separado. **Todas las pruebas automáticas de este artículo se hicieron por loopback local.**

</details>

Dos lecturas relacionadas completan el flujo: [alertas HTTP 503 y recuperación con Gatus](/posts/gatus-docker-compose-http-503-alert-recovery/) explica cómo detectar fallos del servicio; [compresión de logs de Claude Code y conservación de errores](/posts/claude-code-context-diet-log-compression/) trata la retención de evidencia. Este artículo conecta ambas cosas: **identificar en qué capa se produjo el error**.

*Pruebas locales en loopback: 2026-10-17 (UTC), once casos sintéticos. Código de Cakewalk anclado al commit `cf2f6897b9712ec0cb9a7e01abee5109385fcd8b`. No se realizaron pruebas con reintentos reales de clientes MCP, desconexión SSE ni interoperabilidad MRTR.*
