# MCP-Client-Fehler unterscheiden: HTTP 503, JSON-RPC error und isError

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

Ein MCP-Client ruft ein Tool auf, der Server antwortet mit HTTP 200, und der Agent meldet trotzdem einen Fehler. Das ist kein Widerspruch: Ein erfolgreicher HTTP-Status bedeutet weder, dass die JSON-RPC-Antwort kein `error` enthält, noch, dass `isError` im Tool-Ergebnis `false` ist.

In MCP-Gateways, Integrationen für Coding Agents und eigenen Clients **übersieht eine reine Prüfung von `response.ok` zwei wichtige Fehlerarten**. Zuerst muss der HTTP-Transport beurteilt werden, danach die JSON-RPC-Antworthülle, schließlich das Tool-Ergebnis. Ob ein Aufruf wiederholt werden darf, ist eine eigenständige Entscheidung.

Ich habe [Cakewalk MCP Test Kitchen](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen) beim [festen Commit `cf2f689`](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/tree/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b) untersucht und einen unabhängigen Node.js-Loopback-Klassifikator ausgeführt. **Alle elf synthetischen Testfälle bestanden.** Das bestätigt die selbst geschriebene Klassifizierung, nicht die Interoperabilität mit dem .NET-Server von Cakewalk oder realen MCP-Clients wie Cursor und Codex.

## Drei Fehlerebenen eines tools/call: HTTP, JSON-RPC und Tool-Ergebnis

Der Abschnitt Error Handling der [MCP-Tools-Spezifikation vom 28. Juli 2026](https://modelcontextprotocol.io/specification/2026-07-28/server/tools) trennt Protokollfehler von Fehlern der Tool-Ausführung. Zusammen mit dem HTTP-Transport ergeben sich drei Ebenen:

| Ebene | Beispiel | Was der Client tatsächlich weiß |
| --- | --- | --- |
| HTTP-Transport | `503 Service Unavailable` | Die HTTP-Anfrage schlug fehl; Nebenwirkungen und automatisches Wiederholen sind unbekannt |
| JSON-RPC-Protokoll | HTTP 200, oberste Felder `error.code` und `error.message` | Ein JSON-RPC-Fehler liegt vor; es gibt kein erfolgreiches Tool-Ergebnis |
| Tool-Ausführung | HTTP 200, `result.isError: true` | Der Protokollaustausch kann abgeschlossen sein, obwohl das Tool gescheitert ist |

Auch bei HTTP 200 kann eine JSON-RPC-Fehlerantwort vorliegen:

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

Diese andere HTTP-200-Antwort steht dagegen für einen Fehler innerhalb des Tools:

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

Beide JSON-Beispiele sind **Antwortkörper des Servers**. Vollständige Anfragen müssen zusätzlich `_meta` und die vorgeschriebenen HTTP-Header berücksichtigen.

Laut dem [`CallToolResult`-Schema von 2026-07-28](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2026-07-28/schema.json) ist `isError` ein optionales Boolean-Feld, das bei Abwesenheit als `false` gilt. Das Zurückfallen von fehlendem `resultType` auf `complete` ist eine andere Kompatibilitätsregel. `structuredContent` ist ebenfalls möglich und muss kein Objekt sein, doch **`content` bleibt in dieser Schemaversion als Array verpflichtend**. Unser Klassifikator prüft diesen begrenzten Fall, nicht sämtliche MCP-Versionen und SDK-Varianten.

Fehlermeldungen eines Tools können dem Sprachmodell helfen, Argumente zu korrigieren. Im [Abschnitt Error Handling](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#error-handling) steht „Clients SHOULD provide tool execution errors to language models“; für Protokollfehler heißt es „Clients MAY provide protocol errors to language models“. **SHOULD und MAY beziehen sich auf die Weitergabe an das Modell, nicht auf eine Pflicht zur unbedingten Wiederholung der Anfrage.** Das ist bei Tools mit Nebenwirkungen besonders wichtig.

`resultType: "input_required"` zeigt an, dass ein Aufruf weitere Eingaben benötigt und noch nicht abgeschlossen ist. [MRTR (Multi Round-Trip Requests, mehrstufige Anfragen)](https://modelcontextprotocol.io/specification/2026-07-28/basic#message-patterns) liefert dazu `inputRequests`. Der Client sammelt Antworten und sendet sie als `inputResponses`, gegebenenfalls mit `requestState`, in einer neuen Anfrage. Dabei muss die JSON-RPC-`id` wechseln. Es handelt sich weder um einen Tool-Fehler noch um einen gewöhnlichen Timeout-Retry.

Bei Streamable HTTP gibt es eine subtilere Grenze. Laut [Transport-Spezifikation 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http) kann dieselbe Anfrage `application/json` oder `text/event-stream` erhalten. **HTTP 200 zusammen mit SSE besagt lediglich, dass der Antwortstrom begonnen hat.** Der Strom kann vor der finalen JSON-RPC-Antwort abbrechen, oder die finale Antwort enthält `error`. Clients müssen nach `Content-Type` unterscheiden und bis zum passenden terminalen Ergebnis warten. Das ist ein Problem der Vollständigkeit des Streams, keine mechanische vierte Art von Tool-Fehler.

JSON-RPC-Benachrichtigungen besitzen keine `id`. Für angenommene Notification-POSTs beschreibt die [Transport-Spezifikation `202 Accepted` mit leerem Körper](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#sending-messages). Die Kernfassung von Streamable HTTP 2026-07-28 definiert keine gewöhnlichen clientseitigen HTTP-Benachrichtigungen; ein Abbruch wird durch Schließen des Antwortstroms ausgedrückt. **Eine leere 202-Antwort darf nicht in einen `tools/call`-Klassifikator geraten, der einen Ergebniskörper erwartet.**

## Cakewalk im Quellcode: 503 → 200 beweist keinen automatischen Retry

Cakewalk lässt den Server konfigurierte Störungen absichtlich erzeugen. Der feste Quellcode zeigt, an welcher Stelle sie entstehen; die eigenständigen lokalen Tests folgen im nächsten Abschnitt.

In [`HttpStatusSequenceScenario.cs`, Zeilen 12–35](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/src/McpTestServer.API/Scenarios/Errors/HttpStatusSequenceScenario.cs#L12-L35) zählt `ScenarioWireInvocationKeys.HttpRequest` die MCP-POSTs einer Sitzung, nicht ausschließlich die Aufrufe von `tools/call`. Die Einstellung `{"statusCodes":[503,200]}` gibt beim ersten gezählten POST 503 zurück und lässt einen tatsächlich eintreffenden zweiten POST mit 200 weiterlaufen. **Auch ein anderer POST kann den ersten Status verbrauchen.** Beispiel: `tools/list` → erster POST mit 503 → Client beendet den Vorgang → `tools/call` findet gar nicht statt. Dies ist eine Ableitung aus dem Zähler, kein Paketmitschnitt. Die Folge `[503,200]` beweist für sich genommen keinen Retry.

Dagegen gibt [`JsonRpcErrorScenario.cs`](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/src/McpTestServer.API/Scenarios/Errors/JsonRpcErrorScenario.cs#L20-L36) für `tools/call` HTTP 200 mit JSON-RPC-`error` zurück; [`ToolErrorScenario.cs`](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/src/McpTestServer.API/Scenarios/Errors/ToolErrorScenario.cs) verwendet die `isError`-Variante von `CallToolResult`. Es sind drei unterschiedliche Stellen der Fehler-Injektion.

Die [DI-Registrierung in Zeilen 308–315](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/src/McpTestServer.API/Extensions/ServiceCollectionExtensions.cs#L308-L315) und die [fixierte README in Zeilen 197–220](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/README.md#L197-L220) nennen sechs standardmäßig aktivierte Szenarien: `baseline`, `errors.http_status_sequence`, `errors.jsonrpc_error`, `errors.tool_error`, `elicitation.approval` und `compat.sdk_v2`. Zusätzliche Implementierungen für Timeout, Authentifizierung oder Kataloge liegen zwar im Repository, sind aber nicht standardmäßig registriert.

Auch der Zeitpunkt eines Szenariowechsels ist wichtig: Die gespeicherte Auswahl gilt laut README für die **nächste MCP-Sitzung**, nicht rückwirkend für bestehende Sitzungen. Wenn nach Auswahl von 503 immer noch ein alter normaler Status erscheint, sollte man zuerst Sitzung und POST-Reihenfolge prüfen statt sofort einen Client-Cache zu verdächtigen.

Die [Kompatibilitätsmatrix in `SdkV2CompatScenario.cs`, Zeilen 74–106](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/src/McpTestServer.API/Scenarios/Compat/SdkV2CompatScenario.cs#L74-L106) beschreibt diesen stateless-Beispielhost. Das native MRTR von 2026-07-28 wird unterstützt, während ein stateful-Host-Pfad und die stateful-Brücke von 2025-11-25 hier nicht aktiviert sind. Ältere stateless-Clients können aufgefordert werden, `closeReason` mitzusenden und erneut aufzurufen. **Diese Einschränkung betrifft das Beispielprojekt, nicht alle MCP-Clients.**

Ein statischer Blick in echte SDK-Quellen liefert eine weitere Unterscheidung: Die offizielle [TypeScript-SDK-Dokumentation zu `Client.callTool()`](https://github.com/modelcontextprotocol/typescript-sdk/blob/9bc40e903fde15534ec06ff4c1347d47000dbe16/packages/client/src/client/client.ts#L2286-L2306) trennt einen **als `result.isError` zurückgegebenen Tool-Fehler** von einem **geworfenen `ProtocolError`**. Der darunterliegende Handler [`_onresponse`](https://github.com/modelcontextprotocol/typescript-sdk/blob/9bc40e903fde15534ec06ff4c1347d47000dbe16/packages/core-internal/src/shared/protocol.ts#L1213-L1230) bewahrt `error.code/message/data` auf. Im [öffentlichen TypeScript-Beispielclient von Cakewalk](https://github.com/cakewalk-security/cakewalk-mcp-ts-test-client/blob/4a0ff892bca6557ea97d427408cf888e2644000e/src/index.ts#L95-L108) extrahiert die Ausgabefunktion dagegen vor allem Text; sie hat in dieser Funktion keinen eigenen `isError`-Zweig. Das sind Befunde aus Quelltext, keine ausgeführten Wiederherstellungstests des Beispielclients.

## MCP-Fehlerklassifikation mit elf lokalen synthetischen Node.js-Tests

Unter Linux mit Node.js `v22.16.0` bestand die erste Fassung **9/9** Fälle. Nach Behebung der älteren `resultType`-Kompatibilität waren es **10/10**. Ein zusätzlicher Fall mit ungültigem Tool-Inhalt brachte **11/11**. Die Quellen und TAP-Protokolle aller Fassungen bleiben separat erhalten. Verwendet werden `node:http`, `node:events`, `node:test`, `node:assert/strict` und das eingebaute `fetch` auf einem zufälligen `127.0.0.1`-Port, ohne npm-Abhängigkeiten.

Die frühere Version stufte ein legitimes Ergebnis ohne `resultType` als `INVALID_TOOL_RESULT` ein. Der [ResultType-Abschnitt der Basisspezifikation 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic#resulttype) fordert für alte Server: „clients MUST treat an absent `resultType` as `"complete"`“. Den [Beispielen der Tools-Spezifikation 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) fehlt dieses Feld. Ohne Fallback würde ein strenger Client gültige alte Antworten ablehnen.

Danach fügte ich den Fall `content: "not-an-array"` hinzu: ein String statt des erforderlichen Arrays. Das Ergebnis war `INVALID_TOOL_RESULT`. Die aktuelle Ausführung meldet **elf bestandene Tests, null Fehler und Exit-Code 0**. Alle acht vorgesehenen `outcome`-Werte wurden in synthetischen Eingaben beobachtet.

| Synthetische Eingabe | Beobachteter Wert | Bedeutung |
| --- | --- | --- |
| Normales HTTP-200-Tool-Ergebnis | `TOOL_SUCCESS` | Erfolgreicher Kontrollfall |
| Erster POST 503, Test sendet explizit einen zweiten POST mit 200 | `HTTP_ERROR` → `TOOL_SUCCESS` | Kein automatischer Retry: der Test sendet selbst |
| HTTP 200 mit JSON-RPC `error: -32602` | `JSONRPC_ERROR` | Nur HTTP zu prüfen wäre falsch |
| HTTP 200 mit `isError: true` | `TOOL_ERROR` | Protokollerfolg ist nicht Tool-Erfolg |
| HTTP 500 mit scheinbar erfolgreichem Körper | `HTTP_ERROR` | Transportstatus hat Vorrang |
| Ungültiges JSON | `MALFORMED_JSON` | Parsing-Fehler ist kein Abschluss |
| `result` und `error` gleichzeitig | `INVALID_ENVELOPE` | Widersprüchliche Antwort-Hülle |
| Falsche Antwort-`id` | `INVALID_ENVELOPE` | Antwort muss zur Anfrage passen |
| `input_required` | `INPUT_REQUIRED` | Weitere Eingabe statt endgültigem Erfolg |
| Legitime alte Antwort ohne `resultType` | **`TOOL_SUCCESS`** | Verbindlicher Kompatibilitäts-Fallback |
| `content: "not-an-array"` | **`INVALID_TOOL_RESULT`** | Ungültige Tool-Ergebnisstruktur |

Die acht definierten Werte heißen `TOOL_SUCCESS`, `HTTP_ERROR`, `JSONRPC_ERROR`, `TOOL_ERROR`, `INPUT_REQUIRED`, `MALFORMED_JSON`, `INVALID_ENVELOPE` und `INVALID_TOOL_RESULT`. **Alle acht Klassifikationsergebnisse wurden beobachtet**. Daraus folgt weder eine 100-prozentige Abdeckung sämtlicher Codezweige noch eine offizielle MCP-Konformitätsprüfung.

Die entscheidende Abwärtskompatibilität steckt in diesen beiden Zeilen aus `classifyToolCall`:

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

Das vollständige Programm unten als `mcp-error-layers-v3.test.mjs` speichern und mit Node.js 22 ausführen:

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

<details>
<summary>Aufklappen: vollständiger ausführbarer Node.js-Quelltext für elf synthetische Tests</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>

Der Bereich enthält den **vollständigen ausführbaren Quelltext**. Nach dem Speichern lässt er sich mit dem angegebenen Befehl erneut testen. Die archivierte TAP-Zusammenfassung lautet:

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

Das Forschungs-Repository ist privat. Die Links zu Originalquelltext, vollständigem TAP und Ausführungsbeleg **sind nur für berechtigte Repository-Mitarbeitende zugänglich**; sie sind keine öffentlichen Downloads. Der SHA-256 der archivierten Quelldatei lautet `0f4616594ea14f23081b3da60d91808363b3b5b24a0889c560332bb5977f1cb2` und gilt für die Originalbytes. Beim Kopieren aus dem Browser können Zeilenumbrüche anders ausfallen. Die älteren 9/9- und 10/10-Daten wurden nicht überschrieben.

## Diagnose realer MCP-Clients: Logfelder und sichere Retry-Grenzen

Bei echten Clients sollten Anfragen und Antworten miteinander verknüpft werden, statt nur auf den abschließenden Status in der Oberfläche zu schauen. Eine mögliche Logstruktur mit **beispielhaften, nicht gemessenen Werten** ist:

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

Clientversion, ausgehandelte Protokollversion und `_meta` stammen aus Clientlogs; Szenario-Konfiguration, POST-Zähler und zeitliche Reihenfolge sollten mit Serverlogs abgeglichen werden. HTTP-Status, `Content-Type`, JSON-RPC-`id` sowie bereinigte `error.code`, `error.message` und `error.data` gehören ins Protokoll. Der kleine Testklassifikator gibt überwiegend nur Codes zurück und ersetzt kein Diagnosesystem. `retryOrigin` trennt SDK-Retry, erneutes Senden durch Geschäftslogik und manuellen Klick.

| Beobachtung | Automatischer Retry? | Erforderliche Bedingungen |
| --- | --- | --- |
| HTTP 503 oder vorübergehender Netzwerkfehler | **Bedingt** | Nur wenn Lesen oder Idempotenz bestätigt ist; Timeout und Versuche begrenzen, bei unklaren Nebenwirkungen stoppen |
| HTTP 429, Rate Limit | **Bedingt** | `Retry-After` respektieren, falls vorhanden; sonst Kontingent und begrenzten Backoff beachten, Limits nicht umgehen |
| JSON-RPC `-32602`, falsche Parameter | **Normalerweise nicht mit denselben Argumenten** | Parameter korrigieren und neu aufrufen |
| `isError: true`, Tool-Fehler | **Je nach Tool-Semantik** | Fehlermeldung erhalten, Eingabe und Geschäftsstatus prüfen, nicht blind wiederholen |
| `MALFORMED_JSON` / `INVALID_ENVELOPE` / `INVALID_TOOL_RESULT` | **Kein blinder Retry** | Bereinigte Antwort, Anfrage-ID und Protokollversion sichern, Implementierung prüfen |
| `INPUT_REQUIRED` | **Kein Fehler-Retry** | MRTR-Eingaben sammeln und Folgeanfrage mit `inputResponses` senden |

Für nachweislich nebenwirkungsfreie Lesezugriffe kann man mit **maximal zwei Retries, anfänglich 200 ms exponentiellem Backoff plus Jitter** beginnen und an SLA und Rate Limits anpassen. Falls bei 429 ein tatsächliches `Retry-After` vorliegt, hat es Vorrang. Zahlungen, Datenbank-Schreibvorgänge und Benachrichtigungen dürfen ohne Idempotenzschlüssel oder serverseitige Deduplizierung nicht allein wegen 503 wiederholt werden. Bei SSE sollten außerdem `Content-Type`, Ereignisanzahl, finaler JSON-RPC-Status und Abbruchgrund erfasst werden. **HTTP 200 ohne finales Ergebnis ist kein Tool-Erfolg.**

Die Wiederherstellungsstrategie hängt von **Fehlerebene, Vorliegen des Endergebnisses und Sicherheit einer Wiederholung** ab, nicht bloß von den Zahlen 200 und 503.

<details>
<summary>Zusatz: Testwerkzeuge, lokale Installation und Hosted-Service-Grenzen</summary>

[MCP Inspector](https://github.com/modelcontextprotocol/inspector) eignet sich zur manuellen Nachrichteninspektion, [offizielle MCP Conformance](https://github.com/modelcontextprotocol/conformance) zur Protokollprüfung und Cakewalk Test Kitchen zur Beobachtung von Störungen. Die Aufgaben unterscheiden sich.

Nach der [fixierten Cakewalk-README](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/README.md#L18-L39) braucht die lokale Umgebung .NET 10 SDK und Docker/PostgreSQL; für die Weboberfläche wird Node.js 22.17.1 verwendet. Ein Test-PAT kann lokal für `/mcp` genutzt werden. Die Google-OAuth-Konfiguration für die Administration ist eine separate Frage.

Die [Cakewalk-Nutzungsbedingungen (Standangabe 2026-09-21)](https://www.cakewalk.security/mcp-test-kitchen/legal/terms) untersagen in `5.2 die Einbindung des gehosteten Endpunkts in CI oder automatisierte Lasttests. `5.3 nennt **120 MCP-Anfragen pro Minute und IP** und HTTP 429 bei Überschreitung; `8 garantiert keine Übereinstimmung injizierter Störungen mit realen Fehlerbildern. Die Bedingungen bezeichnen den Server weiterhin als nicht quelloffen, obwohl das fixierte öffentliche Repository eine [MIT LICENSE](https://github.com/cakewalk-security/cakewalk-mcp-test-kitchen/blob/cf2f6897b9712ec0cb9a7e01abee5109385fcd8b/LICENSE) enthält. Quelllizenz und Regeln des Hosted-Dienstes sind getrennt zu prüfen. **Alle automatisierten Tests hier liefen ausschließlich auf lokalem Loopback.**

</details>

Zwei verwandte Beiträge ergänzen die Diagnose: [Gatus: HTTP-503-Alarme und Wiederherstellung](/posts/gatus-docker-compose-http-503-alert-recovery/) behandelt die Erkennung von Störungen; [Claude Code: Fehlererhalt bei Logkomprimierung](/posts/claude-code-context-diet-log-compression/) behandelt die Sicherung von Fehlerevidenz. Dazwischen liegt die Frage dieses Artikels: **Auf welcher Ebene ist der Aufruf gescheitert?**

*Lokale Loopback-Tests: 2026-10-17 (UTC), elf synthetische Fälle. Cakewalk-Quellcode fixiert auf `cf2f6897b9712ec0cb9a7e01abee5109385fcd8b`. Keine realen Client-Recovery-, SSE-Abbruch- oder MRTR-Interoperabilitätstests durchgeführt.*
