MCP Client Errors: Distinguishing HTTP 503, JSON-RPC Errors, and isError
An MCP client can receive HTTP 200 for a tool call while the agent still reports failure. That is not contradictory: an HTTP success status does not rule out a JSON-RPC error or a tool result with isError: true.
For an MCP gateway, coding-agent integration, or custom client, checking only response.ok misses two important failure paths. Examine the HTTP transport first, the JSON-RPC envelope next, and the tool result last. Whether a call may be retried is a separate decision.
I inspected Cakewalk MCP Test Kitchen at the pinned cf2f689 commit and ran an independent Node.js loopback classifier: 11 synthetic cases passed. This validates my classifier, not interoperability with Cakewalk’s .NET server, Cursor, Codex, or another actual MCP client.
Three failure layers in tools/call: HTTP, JSON-RPC, and tool results
The 2026-07-28 MCP Tools Error Handling specification distinguishes protocol-level errors from errors originating inside a tool. With HTTP in the picture, a call has three distinct failure surfaces:
| Layer | Example | What the client can conclude |
|---|---|---|
| HTTP transport | 503 Service Unavailable | This HTTP request failed; tool side effects and retry behavior are not yet known |
| JSON-RPC protocol | HTTP 200 with top-level error.code and error.message | A JSON-RPC error occurred; there is no successful tool result |
| Tool execution | HTTP 200 with result.isError: true | The protocol exchange can be complete while the tool itself failed |
The first HTTP 200 example is a JSON-RPC error response:
{
"jsonrpc": "2.0",
"id": 1,
"error": { "code": -32602, "message": "Invalid params" }
}
The next HTTP 200 response represents a failure reported by the tool:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"content": [{ "type": "text", "text": "Tool-side failure" }],
"isError": true
}
}
These are server response bodies; a full request must also follow the protocol’s _meta and HTTP-header requirements.
The 2026-07-28 CallToolResult schema makes isError an optional boolean, treated as false when absent. Treating a missing resultType as complete is a separate backward-compatibility rule. A tool result may additionally contain structuredContent (not necessarily an object), but the schema for this version still requires an array-valued content field. This classifier checks that narrow shape; it is not a universal schema validator for every MCP version.
Tool-error text can help a model correct its arguments. The Tools Error Handling section says “Clients SHOULD provide tool execution errors to language models” and allows protocol errors with “Clients MAY provide protocol errors to language models.” SHOULD/MAY concern what to show the model, not an obligation to replay a request. This distinction matters especially for tools with side effects.
resultType: "input_required" means the call is not finished and further input is necessary. Under MRTR (Multi Round-Trip Requests), the response contains inputRequests. The client collects answers and sends a follow-up request using inputResponses and, if present, requestState, with a new JSON-RPC id. That is neither a tool error nor an ordinary timeout retry.
Streamable HTTP has a less obvious trap. The 2026-07-28 transport specification allows an HTTP request to return either application/json or text/event-stream. HTTP 200 plus SSE only establishes that the response stream started. A stream can break before the final JSON-RPC response, or that final response can be an error. A client should branch by Content-Type, match the request ID, and wait for a terminal response. Stream framing is an additional completeness concern, not a fourth kind of tool error.
JSON-RPC notifications have no id. For an accepted notification POST, the transport specification describes 202 Accepted with an empty body. Core Streamable HTTP in 2026-07-28 does not define ordinary client-originated notifications over HTTP; closing the response stream expresses cancellation. Do not feed a 202 empty notification response into a classifier expecting a tools/call result body.
Cakewalk source inspection: 503 → 200 does not establish an automatic retry
Cakewalk deliberately makes the server misbehave according to selected scenarios. Its pinned code shows where errors are injected; the local classifier tests in the next section are a separate exercise.
In HttpStatusSequenceScenario.cs, lines 12–35, ScenarioWireInvocationKeys.HttpRequest counts MCP POST requests for the session, rather than counting tools/call invocations. The configuration {"statusCodes":[503,200]} returns 503 on the first counted POST; a second POST proceeds under status 200. A different POST may consume the first status. For example, tools/list → first POST returns 503 → client stops → tools/call is never sent. This is a deduction from the counter, not a packet capture. The array alone proves no client retry.
In contrast, JsonRpcErrorScenario.cs injects HTTP 200 with a JSON-RPC error for tools/call, whereas ToolErrorScenario.cs exercises CallToolResult.isError. They operate at distinct layers.
I checked the DI registrations, lines 308–315 and the pinned README, lines 197–220. The six scenarios enabled by default are baseline, errors.http_status_sequence, errors.jsonrpc_error, errors.tool_error, elicitation.approval, and compat.sdk_v2. Additional timeout, authentication, and catalog implementations exist in the repository but are not registered by default.
Session timing also matters: a saved scenario selection takes effect for the next MCP session, not retroactively for an existing one. Before blaming a client cache for a surprising 200 after selecting 503, inspect the session and actual POST sequence.
The SdkV2CompatScenario.cs matrix, lines 74–106 describes this project’s stateless host: native 2026-07-28 MRTR is available, but a stateful host path and the 2025-11-25 stateful bridge are not enabled here. Older stateless clients may be told to resend with closeReason. That is a matrix for this example server, not a universal statement about MCP client support.
A static look at real SDK code adds useful context. The official TypeScript SDK Client.callTool() documentation distinguishes a returned result.isError tool failure from a thrown ProtocolError. The lower-level _onresponse handler retains error.code, error.message, and error.data. Meanwhile, the Cakewalk TypeScript reference client’s example extracts and prints result text without its own isError branch in that display function. These are source observations, not a test of the reference client’s recovery behavior.
Classifying MCP errors: 11 synthetic Node.js loopback tests
I first passed 9/9 cases on Linux and Node.js v22.16.0, then fixed the legacy resultType handling and passed 10/10. The latest fixture adds malformed tool-result validation and passes 11/11. Each version retains its own source and TAP evidence. The tests use node:http, node:events, node:test, node:assert/strict, and built-in fetch on an ephemeral 127.0.0.1 port, with no npm dependencies.
The earlier classifier rejected a valid response without resultType as INVALID_TOOL_RESULT. The 2026-07-28 base specification’s ResultType rule says clients “MUST treat an absent resultType as "complete"” for earlier servers. The 2025-11-25 tool-result examples omit that field. A strict client without the fallback therefore rejects legitimate legacy results.
Next I introduced a tool result where content is the string "not-an-array" instead of an array. The classifier returned INVALID_TOOL_RESULT. The newest run passed 11 tests, 0 failures, exit code 0, and exercised every one of its eight named outcomes.
| Synthetic input | Observed classification | Why it matters |
|---|---|---|
| Normal HTTP 200 tool result | TOOL_SUCCESS | Control case |
| POST 1 returns 503; test sends POST 2 and receives 200 | HTTP_ERROR → TOOL_SUCCESS | The test sends the second request explicitly; this is not automatic retry |
HTTP 200 with JSON-RPC error: -32602 | JSONRPC_ERROR | HTTP-only checks miss the error |
HTTP 200 with isError: true | TOOL_ERROR | Protocol success differs from tool success |
| HTTP 500 with success-looking body | HTTP_ERROR | Transport status takes precedence |
| Malformed JSON | MALFORMED_JSON | Parse failure is not completion |
Both result and error | INVALID_ENVELOPE | Contradictory JSON-RPC envelope |
Response id differs from request | INVALID_ENVELOPE | Responses must match their request IDs |
input_required | INPUT_REQUIRED | More input is required; this is not final success |
Valid legacy result lacking resultType | TOOL_SUCCESS | Required backward-compatibility fallback |
content: "not-an-array" | INVALID_TOOL_RESULT | Invalid tool-result shape |
The classifier defines eight outcomes: TOOL_SUCCESS; the three failure kinds HTTP_ERROR, JSONRPC_ERROR and TOOL_ERROR; incomplete interaction INPUT_REQUIRED; and MALFORMED_JSON, INVALID_ENVELOPE and INVALID_TOOL_RESULT. All eight labels were observed, which does not mean 100% conditional branch coverage or official MCP conformance.
The backward-compatibility fix comes down to these lines from classifyToolCall:
const result = response.result;
const resultType = has(result, 'resultType') ? result.resultType : 'complete';
Save the complete program below as mcp-error-layers-v3.test.mjs and run it with Node.js 22:
node --test --test-reporter=tap mcp-error-layers-v3.test.mjs
Expand: complete executable source for all eleven synthetic cases
/**
* 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' }));
The fold above contains the entire executable source. Copy it into mcp-error-layers-v3.test.mjs and run the command. The archived TAP summary is:
# tests 11
# pass 11
# fail 0
The research repository is private. The original source, complete TAP output, and execution receipt files are restricted to authorized repository collaborators; they are not public downloads. The archived source SHA-256 is 0f4616594ea14f23081b3da60d91808363b3b5b24a0889c560332bb5977f1cb2 and applies to the original file bytes, which may differ from text copied out of a browser. The earlier 9/9 and 10/10 records were preserved.
Designing real-client diagnostics: logs and safe retry boundaries
Real-client troubleshooting requires matching each request to its responses, not relying on a final green or red UI. A useful log record could look like this; the values below are illustrative:
{
"client": "your-client@version",
"protocolVersion": "2026-07-28",
"scenario": "errors.jsonrpc_error",
"method": "tools/call",
"postCount": 1,
"httpStatuses": [200],
"jsonrpc": { "id": 1, "errorCode": -32602 },
"resultType": null,
"outcome": "JSONRPC_ERROR",
"retryOrigin": "none"
}
Capture the client and negotiated protocol versions from client logs and request _meta; inspect scenario configuration, POST counters, and ordering in server logs. Record the HTTP status, Content-Type, JSON-RPC id and sanitized error.code / error.message / error.data. This minimal classifier returns only the code and is not a production logging system. retryOrigin distinguishes an SDK retry from a business-layer resend or a manual retry. Use isolated packet captures if necessary.
| Observation | Auto-retry? | Conditions |
|---|---|---|
| HTTP 503 or temporary network failure | Conditional | Confirm read-only or idempotent operation; bound timeout and attempts; stop if side effects are uncertain |
| HTTP 429 rate limit | Conditional | Honor Retry-After when present; otherwise use bounded backoff and published quotas; never bypass limits through parallelism or accounts |
JSON-RPC -32602 invalid parameters | Usually not with unchanged input | Fix arguments before making a new call |
Tool failure isError: true | Depends on tool semantics | Retain details, inspect input and business state, do not blindly replay |
MALFORMED_JSON / INVALID_ENVELOPE / INVALID_TOOL_RESULT | No blind retry | Retain a sanitized response, request ID, and protocol version; investigate implementation and compatibility |
INPUT_REQUIRED | Not a failure retry | Gather MRTR input and issue a follow-up call with inputResponses |
For provably side-effect-free reads, a starting policy might be at most two retries with 200 ms initial exponential backoff and jitter, adjusted for SLA and rate limits. For 429, an actual Retry-After value (when supplied) takes precedence. Without idempotency keys or server-side deduplication, do not replay payment, write, or notification tools simply because a 503 was returned. For SSE, record Content-Type, event count, whether a final JSON-RPC response was received, and why a stream ended. HTTP 200 without the final result is not tool success.
Recovery should be determined by which layer failed, whether a final result exists, and whether replay is safe, not by the number 200 or 503 alone.
Further reading: tools, local setup, and hosted-service limits
MCP Inspector helps inspect messages interactively. Official MCP Conformance checks protocol compliance; Cakewalk Test Kitchen injects failures to observe clients. These tools serve different purposes.
According to the pinned Cakewalk README, local setup requires .NET 10 SDK and Docker/PostgreSQL, with Node.js 22.17.1 for Web UI development. A test PAT can be used for local /mcp access; Google OAuth configuration for administration is separate.
The Cakewalk hosted Terms (dated 2026-09-21) prohibit connecting the hosted endpoint to CI or automated load tests (5.2), set **120 MCP requests per minute per IP** and HTTP 429 above the limit (5.3), and do not warrant that injected failures correspond to real-world faults (`8). The Terms still describe the server as closed source, while the pinned public repository includes an MIT LICENSE. Hosted-service rules and source licensing must be evaluated separately. All automated tests in this article ran on local loopback, not the hosted endpoint.
Two related posts cover the surrounding workflow: Gatus HTTP 503 alerts and recovery explains service-health detection, while Claude Code log compression and retained errors addresses what evidence survives summarization. This article fills the step between them: identifying which failure layer actually occurred.
Local loopback tests recorded 2026-10-17 (UTC), eleven synthetic cases. Cakewalk source pinned to cf2f6897b9712ec0cb9a7e01abee5109385fcd8b. No real MCP-client retry, SSE-disconnect, or MRTR interoperability tests were run.