Errori nei client MCP: distinguere HTTP 503, JSON-RPC error e isError
Un client MCP chiama uno strumento, riceve HTTP 200 e l’agente segnala comunque un errore. Non c’è contraddizione: uno stato HTTP riuscito non garantisce che nella risposta JSON-RPC manchi error né che il risultato dello strumento abbia isError: false.
In un gateway MCP, in un’integrazione per Coding Agent o in un client sviluppato internamente, controllare soltanto response.ok nasconde due importanti classi di errore. Conviene esaminare prima il trasporto HTTP, poi la busta JSON-RPC e infine il risultato dello strumento. La decisione di riprovare è separata.
Ho letto il sorgente di Cakewalk MCP Test Kitchen al commit fissato cf2f689 e ho eseguito un classificatore indipendente su loopback locale con Node.js: tutti gli undici casi sintetici sono passati. Questo verifica la logica che ho scritto, non l’interoperabilità con il server .NET di Cakewalk o con client MCP reali come Cursor e Codex.
Tre livelli di errore in tools/call: HTTP, JSON-RPC e risultato dello strumento
La sezione Error Handling della specifica MCP Tools del 2026-07-28 distingue gli errori di protocollo da quelli causati dall’esecuzione dello strumento. Considerando anche HTTP, i livelli diventano tre:
| Livello | Esempio di risposta | Cosa può stabilire il client |
|---|---|---|
| Trasporto HTTP | 503 Service Unavailable | La richiesta HTTP è fallita; effetti collaterali e retry automatici restano sconosciuti |
| Protocollo JSON-RPC | HTTP 200 con error.code e error.message al livello superiore | Si è verificato un errore JSON-RPC, senza risultato utile dello strumento |
| Esecuzione dello strumento | HTTP 200 con result.isError: true | Lo scambio di protocollo può terminare, ma lo strumento è fallito |
La prima risposta HTTP 200 è una busta di errore JSON-RPC:
{
"jsonrpc": "2.0",
"id": 1,
"error": { "code": -32602, "message": "Invalid params" }
}
La seconda è un fallimento applicativo segnalato dallo strumento:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"content": [{ "type": "text", "text": "Tool-side failure" }],
"isError": true
}
}
Entrambi i JSON rappresentano corpi della risposta server; una richiesta completa deve rispettare anche _meta e le intestazioni HTTP previste.
Nel CallToolResult Schema del 2026-07-28, isError è un booleano opzionale: se manca, vale implicitamente false. Interpretare resultType assente come complete segue un’altra regola di retrocompatibilità. Il risultato può inoltre contenere structuredContent, non necessariamente un oggetto, ma questa versione dello schema continua a richiedere content come array. Il nostro classificatore convalida questo ambito limitato, non tutti gli schemi di tutte le versioni e SDK.
Il testo di un errore dello strumento può aiutare il modello a correggere gli argomenti. La sezione Error Handling recita “Clients SHOULD provide tool execution errors to language models”; per gli errori di protocollo scrive “Clients MAY provide protocol errors to language models”. SHOULD e MAY riguardano cosa mostrare al modello, non un obbligo di ripetere incondizionatamente la richiesta. È cruciale con strumenti che producono effetti collaterali.
resultType: "input_required" significa che la chiamata non è terminata e richiede altri dati. In MRTR (Multi Round-Trip Requests, richieste a più scambi), la risposta include inputRequests; il client raccoglie i dati e invia un’altra richiesta con inputResponses, eventualmente requestState, e un nuovo id JSON-RPC. Non è un errore dello strumento né un semplice retry per timeout.
Streamable HTTP presenta un’insidia meno evidente. La specifica di trasporto 2026-07-28 permette come risposta application/json oppure text/event-stream. HTTP 200 con SSE indica solo che lo stream di risposta è iniziato. Può interrompersi prima del risultato JSON-RPC finale, oppure il risultato finale può contenere error. Il client deve distinguere in base a Content-Type, associare l’ID della richiesta e verificare l’arrivo della risposta conclusiva. È un problema di completezza dello stream, non una quarta categoria artificiale di errore dello strumento.
Le notifiche JSON-RPC non hanno id. Per i POST di notifica accettati, la specifica descrive 202 Accepted senza corpo. La versione di base Streamable HTTP 2026-07-28 non definisce il consueto flusso di notifiche HTTP avviate dal client; chiudere lo stream esprime la cancellazione. Non bisogna passare una risposta 202 vuota al classificatore che si aspetta il corpo di tools/call.
Analisi statica di Cakewalk: 503 → 200 non prova un retry automatico
Cakewalk permette al server di provocare deliberatamente errori secondo lo scenario scelto. Il sorgente fissato mostra dove vengono iniettati; i test sintetici locali della prossima sezione restano un esperimento diverso.
In HttpStatusSequenceScenario.cs, righe 12–35, ScenarioWireInvocationKeys.HttpRequest conta i POST MCP della sessione, non soltanto le chiamate a tools/call. La configurazione {"statusCodes":[503,200]} restituisce 503 al primo POST contato e permette a un secondo POST effettivo di proseguire con 200. Un altro POST può consumare il primo stato. Ad esempio tools/list → primo POST con 503 → il client si ferma → tools/call non viene nemmeno inviato. È una sequenza dedotta dal codice, non una cattura di pacchetti; [503,200] da solo non dimostra che ci sia stato un retry.
Diversamente, JsonRpcErrorScenario.cs produce HTTP 200 con error JSON-RPC per tools/call, mentre ToolErrorScenario.cs percorre la via CallToolResult.isError. I punti di iniezione sono diversi.
Ho verificato anche le registrazioni DI alle righe 308–315 e il README fissato alle righe 197–220. I sei scenari predefiniti sono baseline, errors.http_status_sequence, errors.jsonrpc_error, errors.tool_error, elicitation.approval e compat.sdk_v2. Altre implementazioni di timeout, autenticazione e catalogo si trovano nel repository, ma non risultano abilitate di default.
Il cambio di scenario ha anche una dimensione temporale: secondo il README la selezione salvata vale per la sessione MCP successiva, non modifica quelle già attive. Se dopo aver scelto 503 compare ancora una vecchia risposta positiva, controllerei sessione e sequenza POST prima di attribuirla alla cache del client.
La matrice di SdkV2CompatScenario.cs, righe 74–106 riguarda l’host stateless di esempio: supporta MRTR nativo stateless del 2026-07-28, ma non abilita il percorso stateful né il bridge stateful del 2025-11-25. I vecchi client stateless possono ricevere istruzioni per ripetere la chiamata aggiungendo closeReason. È il perimetro di quel server dimostrativo, non il comportamento universale dei client MCP.
Uno sguardo statico a un SDK reale offre un confronto concreto. La documentazione di Client.callTool() nell’SDK TypeScript distingue l’errore dello strumento restituito come result.isError dall’errore di protocollo sollevato come ProtocolError. Il gestore sottostante _onresponse conserva error.code/message/data. Al contrario, la funzione dimostrativa del client TypeScript di Cakewalk estrae e stampa principalmente testo e, in quella funzione, non gestisce autonomamente una diramazione isError. Sono osservazioni del sorgente, non test di ripristino effettuati sul client.
Classificare gli errori MCP con undici test sintetici locali in Node.js
Con Linux e Node.js v22.16.0 la prima versione ha superato 9/9 casi. Correggendo la compatibilità con il vecchio resultType è arrivata a 10/10; aggiungendo un risultato con struttura non valida, a 11/11. Ho conservato sorgenti e registri TAP separati. I test usano node:http, node:events, node:test, node:assert/strict e fetch integrato, su una porta casuale di 127.0.0.1, senza pacchetti npm esterni.
La prima versione giudicava INVALID_TOOL_RESULT una risposta valida priva di resultType. La sezione ResultType del protocollo base 2026-07-28 richiede, per server precedenti: “clients MUST treat an absent resultType as "complete"”. Gli esempi del 2025-11-25 non hanno quel campo. Un validatore rigido senza fallback rifiuta dunque risultati legittimi.
Ho aggiunto un caso con content scritto come stringa "not-an-array" anziché array. Il classificatore ha restituito INVALID_TOOL_RESULT. L’ultima esecuzione ha prodotto 11 test superati, zero errori, codice di uscita 0, osservando tutti gli otto valori outcome previsti.
| Input sintetico | Risultato osservato | Significato |
|---|---|---|
| Normale risultato strumento con HTTP 200 | TOOL_SUCCESS | Caso di controllo positivo |
| Primo POST 503, il test invia esplicitamente un secondo POST con 200 | HTTP_ERROR → TOOL_SUCCESS | Il secondo invio è del test, non un retry automatico |
HTTP 200 con error: -32602 JSON-RPC | JSONRPC_ERROR | La sola verifica HTTP segnerebbe un falso successo |
HTTP 200 con isError: true | TOOL_ERROR | Successo del protocollo non significa successo dello strumento |
| HTTP 500 con corpo apparentemente valido | HTTP_ERROR | Il trasporto ha precedenza |
| JSON malformato | MALFORMED_JSON | Errore di parsing non equivale a completamento |
result ed error insieme | INVALID_ENVELOPE | Busta JSON-RPC contraddittoria |
id della risposta diverso | INVALID_ENVELOPE | Associare sempre la risposta alla richiesta |
input_required | INPUT_REQUIRED | Serve altro input, non è un successo concluso |
Vecchio risultato valido senza resultType | TOOL_SUCCESS | Fallback di compatibilità obbligatorio |
content: "not-an-array" | INVALID_TOOL_RESULT | Struttura del risultato non valida |
Gli otto valori sono TOOL_SUCCESS, HTTP_ERROR, JSONRPC_ERROR, TOOL_ERROR, INPUT_REQUIRED, MALFORMED_JSON, INVALID_ENVELOPE e INVALID_TOOL_RESULT. Tutti e otto sono comparsi nei test, ma ciò non implica una copertura al 100% dei rami del codice né una certificazione ufficiale di conformità MCP.
Il fix di retrocompatibilità è concentrato in due righe di classifyToolCall:
const result = response.result;
const resultType = has(result, 'resultType') ? result.resultType : 'complete';
Salvare il programma seguente come mcp-error-layers-v3.test.mjs ed eseguirlo con Node.js 22:
node --test --test-reporter=tap mcp-error-layers-v3.test.mjs
Espandi: codice Node.js completo ed eseguibile degli undici casi sintetici
/**
* 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' }));
L’area espandibile contiene tutto il codice eseguibile. Può essere copiato nel file e avviato con il comando precedente. Questo è il riepilogo TAP archiviato:
# tests 11
# pass 11
# fail 0
Il repository di ricerca è privato. I collegamenti al sorgente originale, al TAP completo e alla ricevuta d’esecuzione sono disponibili solo ai collaboratori autorizzati e non sono download pubblici. Lo SHA-256 del file originale è 0f4616594ea14f23081b3da60d91808363b3b5b24a0889c560332bb5977f1cb2 e ne verifica i byte originali; copiando dal browser possono cambiare i ritorni a capo. Anche i precedenti registri 9/9 e 10/10 restano conservati.
Diagnostica dei client MCP reali: log e limiti di retry sicuro
Per analizzare un client reale bisogna associare ogni richiesta alla risposta, non guardare soltanto l’ultimo indicatore verde o rosso dell’interfaccia. Un possibile record con valori illustrativi, non derivati da una cattura, è il seguente:
{
"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"
}
Versione del client, protocollo negoziato e _meta provengono dai log del client; configurazione dello scenario, conteggi POST e ordine dai log server. Registrare stato HTTP, Content-Type, id JSON-RPC e error.code, error.message e error.data dopo la rimozione dei dati sensibili. Il classificatore minimo restituisce soprattutto codici e non è un sistema di diagnostica per la produzione. retryOrigin distingue un retry dell’SDK, un reinvio della logica applicativa e una ripetizione manuale.
| Osservazione | Retry automatico? | Condizioni preliminari |
|---|---|---|
| HTTP 503 o problema temporaneo di rete | Condizionato | Accertare sola lettura o idempotenza; limitare durata e tentativi; fermarsi con effetti collaterali incerti |
| HTTP 429 per rate limit | Condizionato | Rispettare Retry-After se presente; altrimenti backoff limitato e quote del servizio, senza eludere i limiti |
JSON-RPC -32602, parametri errati | Di norma non con gli stessi parametri | Correggere gli argomenti e fare una nuova chiamata |
Fallimento dello strumento isError: true | Dipende dalla semantica | Conservare i dettagli e controllare input e stato applicativo; non ripetere alla cieca |
MALFORMED_JSON / INVALID_ENVELOPE / INVALID_TOOL_RESULT | No retry indiscriminato | Salvare risposta ripulita, ID richiesta e versione del protocollo, indagare la compatibilità |
INPUT_REQUIRED | Non è un retry per guasto | Ottenere i dati MRTR e inviare una chiamata successiva con inputResponses |
Per letture certamente prive di effetti collaterali, si può iniziare con non più di due retry, backoff esponenziale iniziale di 200 ms e jitter, adattando la scelta a SLA e rate limit. In caso di 429, un effettivo Retry-After inviato dal server ha precedenza. Pagamenti, scritture su database e notifiche non vanno ripetuti solo perché è arrivato 503, se mancano chiavi d’idempotenza o deduplicazione lato server. Con SSE registrare anche Content-Type, numero di eventi, presenza della risposta JSON-RPC finale e causa dell’interruzione. HTTP 200 senza risultato conclusivo non è successo dello strumento.
La strategia di recupero deve dipendere da quale livello ha fallito, se esiste un risultato finale e se la ripetizione è sicura, non dal solo numero 200 o 503.
Approfondimento: strumenti, installazione locale e limiti del servizio ospitato
MCP Inspector consente l’ispezione manuale dei messaggi, MCP Conformance ufficiale verifica la conformità del protocollo e Cakewalk Test Kitchen inietta guasti per osservare il client. Sono strumenti con obiettivi diversi.
Il README fissato di Cakewalk richiede .NET 10 SDK e Docker/PostgreSQL in locale; lo sviluppo dell’interfaccia usa Node.js 22.17.1. Per /mcp locale si può usare un PAT di test; la configurazione Google OAuth dell’amministrazione è separata.
Le condizioni d’uso del servizio ospitato Cakewalk (data indicata 2026-09-21) vietano di collegare l’endpoint ufficiale a CI o test di carico automatizzati (5.2), impongono **120 richieste MCP al minuto per IP** con HTTP 429 oltre la soglia (5.3) e non garantiscono che i guasti iniettati corrispondano a quelli reali (`8). Le condizioni descrivono ancora il server come non open source, mentre il repository pubblico fissato contiene una MIT LICENSE. Licenza del codice e regole del servizio ospitato vanno valutate separatamente. Tutti i test automatici dell’articolo sono avvenuti su loopback locale.
Due articoli completano il contesto: allarmi HTTP 503 e recupero con Gatus tratta il rilevamento dei guasti; compressione dei log Claude Code e conservazione degli errori parla delle prove da mantenere. Questo testo copre il passaggio intermedio: capire in quale livello sia avvenuto il guasto.
Test su loopback locale registrati il 2026-10-17 (UTC), undici casi sintetici. Sorgente Cakewalk fissato al commit cf2f6897b9712ec0cb9a7e01abee5109385fcd8b. Non sono stati eseguiti test di retry con client MCP reali, disconnessione SSE o interoperabilità MRTR.