JSON-RPC Error or MCP Tool Failure?
A JSON-RPC `error` means the server could not process the protocol method; a successful JSON-RPC `result` whose tool payload has `isError: true` means the tool ran and rejected the domain operation. Correlate responses by request ID, expect no reply to notifications, and preserve error codes and data.
Symptom: every failed call looks like a protocol error
The trace says a tool failed, but it does not show whether the server rejected tools/call or the tool reported a problem after execution began.
Likely cause
The client treats these two wire shapes as equivalent:
{"jsonrpc":"2.0","id":17,"error":{"code":-32602,"message":"Invalid params"}}
{"jsonrpc":"2.0","id":17,"result":{"content":[{"type":"text","text":"The supplied value is outside the allowed range."}],"isError":true}}
They are different layers. The JSON-RPC 2.0 specification, updated 2013-01-04 says a response contains either result or error, never both. A top-level error means the request could not be processed successfully at the protocol method level. Its object contains an integer code, a short message, and optional data supplied by the server.
The MCP tools specification dated 2025-11-25 puts failures from the tool itself inside a successful JSON-RPC result, with isError: true. In that case, the tool ran and reported a domain failure. The specification names API failures, tool-input validation failures, and business-logic failures as examples. Unknown tools, malformed tools/call requests, unsupported tool calls, and exceptional server conditions belong in protocol errors.
That distinction controls recovery. A tool result can contain feedback that the model can use to change arguments and try again. A protocol error points first to the method name, request shape, server capability, or server implementation.
Check
Capture the unmodified response before an SDK wrapper, exception handler, or UI converts it. Inspect the top-level keys, not the wording of the message.
| Wire shape | Classification | What to inspect next |
|---|---|---|
Top-level error |
JSON-RPC protocol error | error.code, error.message, optional error.data, and the request method and parameters |
Top-level result with result.isError === true |
MCP tool failure | Tool-result content, optional structuredContent, and the arguments sent to the tool |
Top-level result without isError: true |
Successful tool result | Validate and consume the returned content |
Do not classify a response by searching its text for words such as “error” or “invalid.” Those words can appear inside tool content. If the envelope is correct but the tool output is unexpected, continue with the narrower process for debugging a failing MCP tool call.
Fix
Branch on the envelope before interpreting the payload:
if ("error" in response) {
handleProtocolError(response.id, response.error);
} else if (response.result.isError === true) {
handleToolFailure(response.id, response.result);
} else {
handleToolSuccess(response.id, response.result);
}
Send tool-failure content to the model when it can act on that feedback. Route protocol errors to diagnostics for the request and server. Keep the raw response available in traces so operators can verify the classification instead of relying on the client’s rendered message.
Symptom: a response is attached to the wrong call—or never arrives
Concurrent calls appear to exchange results, or a client waits until timeout for a message that the server received.
Likely cause
The client discarded, changed, or incorrectly indexed the JSON-RPC request identifier. The identifier joins a result or error to the call that produced it: a response to a request carries the same id. This matters whenever more than one request is in flight and in batches, where responses need not be returned in request order.
The apparent timeout may instead involve a notification. A JSON-RPC notification has no id and receives no response. The server must not reply to it, including when it appears inside a batch. Waiting on a pending-response entry for a notification therefore creates a wait that valid protocol behavior cannot satisfy.
Check
Record these fields at the client’s send and receive boundaries:
send: direction, method, id-present, id, timestamp
receive: direction, result-or-error, id, timestamp
For every ordinary request, find exactly one response carrying the same identifier. Compare both its value and type: MCP request identifiers are strings or numbers, so numeric 7 and string "7" should not collapse into the same pending-call key.
For every message without an identifier, confirm that the client classified it as a notification and did not create a response promise. If the message is intended to report server activity rather than answer a call, inspect it through the server’s structured logging notifications instead of treating it as a missing response.
Fix
Assign a distinct identifier to each request that expects a response. Preserve it unchanged through serialization, transport handling, logs, and the pending-call map. Match incoming results and errors by that identifier rather than by arrival order, tool name, or the position of a request in a batch.
Keep notification handling separate: do not add notifications to the pending-call map, do not start a response timeout for them, and do not make notification delivery depend on receiving an acknowledgement. For ordinary calls that genuinely remain in flight, handle their lifecycle explicitly rather than confusing notification behavior with request cancellation.
Symptom: logs and callers receive only “Tool call failed”
The raw response contained useful fields, but the dashboard, retry code, and model all see the same text string.
Likely cause
An adapter flattened both failure classes into one exception message. That throws away the difference between a protocol error and a tool result and may also discard machine-readable details.
The MCP schema reference dated 2025-11-25 defines a JSON-RPC error with code, message, and optional data. It separately defines CallToolResult with content, optional structuredContent, and optional isError. Clients should preserve these structures instead of reducing every failure to text.
The cost of flattening is diagnostic ambiguity. A code that identifies the protocol error type can no longer be inspected directly. Nested details in data disappear. Tool feedback that could support a corrected call becomes indistinguishable from a method-level rejection.
Check
Trace one known protocol error through every boundary: wire decoder, SDK adapter, application service, log event, UI, and model input. At each boundary, verify that the original request identifier, numeric error code, message, and complete data value remain available.
Repeat with a tool result whose isError is true. Verify that it remains a result rather than becoming a JSON-RPC error and that its content survives intact. Add assertions that compare structured values, not only rendered messages. The broader MCP server observability path should expose which request produced the failure and which layer classified it.
Fix
Use a discriminated application type that retains the protocol structure:
type CallOutcome =
| { kind: "protocol-error"; id: string | number | null; code: number; message: string; data?: unknown }
| { kind: "tool-error"; id: string | number; content: unknown[]; structuredContent?: object }
| { kind: "tool-success"; id: string | number; content: unknown[]; structuredContent?: object };
A UI may derive a short sentence from this value, but that sentence should not replace the stored fields. Log kind, id, and code as separate fields; retain data as structured data. Keep tool content and structuredContent attached to tool outcomes. Make retry or escalation decisions from the discriminant and preserved fields, not substring matching against a single message.
Sources
- JSON-RPC 2.0 specification, updated 2013-01-04jsonrpc.org
- MCP tools specification dated 2025-11-25modelcontextprotocol.io
- MCP schema reference dated 2025-11-25modelcontextprotocol.io
See also
Configure MCP log levels, emit schema-correct notifications, separate them from transport output, and redact sensitive payloads.
Implement MCP 2026-07-28 multi round-trip requests with safe retries, opaque state, elicitation handling, and replay protection.
OAuth binds an MCP server connection to one product environment with no secret on disk. Header auth works headless and holds several environments at once.
Build an MCP scope challenge flow that starts with limited access, explains step-up consent, handles denial, and retries safely.