Errores en clientes MCP: HTTP 503, JSON-RPC error e isError
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 en el commit fijo cf2f689 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 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:
{
"jsonrpc": "2.0",
"id": 1,
"error": { "code": -32602, "message": "Invalid params" }
}
Esta otra HTTP 200 representa un fallo notificado por la propia herramienta:
{
"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, 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 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), 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 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. 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, 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 devuelve HTTP 200 con error JSON-RPC para tools/call, mientras que ToolErrorScenario.cs utiliza la ruta CallToolResult.isError. Son lugares de inyección distintos.
También comprobé las inscripciones DI, líneas 308–315 y el README fijado, líneas 197–220. 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 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() distingue un fallo de herramienta devuelto como result.isError de un error de protocolo lanzado como ProtocolError. El manejador inferior _onresponse conserva error.code/message/data. Por su parte, la función de presentación del cliente TypeScript de Cakewalk 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 exige, para servidores antiguos: “clients MUST treat an absent resultType as "complete"”. Los ejemplos de herramientas de 2025-11-25 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:
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:
node --test --test-reporter=tap mcp-error-layers-v3.test.mjs
Desplegar: código Node.js ejecutable completo de las once pruebas sintéticas
/**
* 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' }));
El bloque anterior contiene todo el código ejecutable. Se puede copiar al archivo y lanzar la orden indicada. El resumen TAP archivado es:
# 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:
{
"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.
Ampliación: herramientas de prueba, entorno local y límites del servicio alojado
MCP Inspector sirve para examinar mensajes manualmente, MCP Conformance oficial para comprobar el protocolo y Cakewalk Test Kitchen para inyectar fallos y observar al cliente. Son herramientas complementarias.
El README fijado de Cakewalk 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) 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. 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.
Dos lecturas relacionadas completan el flujo: alertas HTTP 503 y recuperación con Gatus explica cómo detectar fallos del servicio; compresión de logs de Claude Code y conservación de errores 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.