KubeMQ
AiwayMCPGuides

Error Handling

Detect and branch on the three MCP failure layers — HTTP/auth, JSON-RPC protocol errors, and tool isError results — and handle timeouts robustly.

A tools/call can fail in three structurally different ways, and each one shows up in a different part of the response. A client that treats them as one bucket will either crash on a recoverable failure or silently swallow a malformed request. This guide shows how to detect each layer and branch on them in the right order; for the exhaustive list of codes and messages, see the Error codes reference.

Overview

When you call a tool over /mcp, the request passes through three checkpoints before you get a result back:

  1. HTTP / auth layer — the shared HTTP server validates the origin and the Authorization header before any JSON-RPC is parsed. Failures here surface as the JSON-RPC auth error -32010 (the body still parses as JSON-RPC).
  2. JSON-RPC protocol layer — the connector parses the envelope and resolves the method. A malformed body, an unknown method, or bad params produce a JSON-RPC error object with no result.
  3. Tool execution layer — the envelope was valid and a tool ran, but the operation failed (reserved channel, missing agent, timeout). The response is a normal result carrying isError: true.

The decisive split is between layers 2 and 3: a protocol error means the tool never ran, so retrying the same request fails identically — the fix is to correct the request. A tool error means a valid request ran and failed for an operational reason, which may be transient (a timeout) and worth retrying.

Every JSON-RPC response — success or error — comes back as HTTP 200. The outcome is encoded in the JSON body, not the status line. Do not branch on the HTTP status code for JSON-RPC calls.

How it works

Branch on the layers in a fixed order so a recoverable tool failure is never mistaken for a protocol failure, and vice versa.

A tools/call clears three checkpoints; each failure surfaces in a different field, so inspect them in order.

Layer 1 — HTTP and auth

Authentication and origin checks run in the shared HTTP server, ahead of JSON-RPC parsing. When JWT auth is enabled and the Authorization: Bearer header is missing or invalid, the /mcp endpoint returns the JSON-RPC auth error -32010 — the response is still valid JSON-RPC, so you read it the same way as any other error object:

Auth failure (-32010)
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32010,
    "message": "authentication failed"
  }
}

Origin validation can also reject the request before a tool runs. Auth and origin behavior are shared across all connectors and documented once in Auth & security; the MCP-specific validateOrigin/TrustedOrigins rules are covered in Authentication.

Layer 2 — JSON-RPC protocol errors

Protocol errors mean the envelope itself was rejected: malformed JSON, the wrong Content-Type, an unknown method, or invalid params. They appear in the error field and there is no result field at all.

Protocol error (-32601)
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32601,
    "message": "Method not found"
  }
}
CodeTrigger
-32700Malformed JSON body, or wrong Content-Type (e.g. text/plain instead of application/json)
-32600Empty method field, or jsonrpc is not "2.0"
-32601Unknown method name (e.g. tools/unknown)
-32602params is not an object, or required tool arguments are missing

Retrying an identical request after a protocol error fails the same way — correct the request instead of retrying. The full catalog, with response examples for each code, lives in the Error codes reference.

Layer 3 — tool execution errors

A tool error means the envelope was valid and a tool was invoked, but the operation failed. The response is structurally a success — HTTP 200, a populated result — with isError: true and a human-readable cause in result.content[].text. A client that only checks the error field treats this as a successful call, so always inspect result.isError as a second step.

Tool error (result.isError)
{
  "jsonrpc": "2.0",
  "id": 9,
  "result": {
    "content": [{ "type": "text", "text": "Command timed out: no subscriber on channel 'example-commands' within 10s" }],
    "isError": true
  }
}

Common tool-level failures and the tools that raise them:

FailureAffected tools
Reserved-channel rejection (channel starts with _AGENTS_.)queue_send, events_publish, events_store_publish, command_send, query_send
Non-existent agentagent_info, agent_send, agent_query
Timeout exceeded / no subscribercommand_send, query_send, agent_send, agent_query
Non-existent channelchannel_info

Channels beginning with _AGENTS_. are reserved for the agent bridge, so any publish or request tool targeting one is rejected at the tool layer. To reach an agent, use the agent-bridge tools (agent_send, agent_query) instead.

Handling timeouts

The synchronous tools — command_send, query_send, agent_send, agent_query — block until a responder replies or the timeout elapses. When no responder is listening, the call does not raise a protocol error; it returns a normal result with isError: true and a timeout message. Detect a timeout by reading result.isError, not by catching a transport exception.

  • The per-call timeout is the timeout_seconds argument (default 10 for commands, 30 for queries, 60 for the agent-bridge tools), capped at 300.
  • The connector also enforces a server-side ToolTimeoutSeconds ceiling (default 300) — see Configuration.
  • A timeout may be transient (the responder was briefly absent). It is one of the few tool errors that is reasonable to retry with backoff — unlike a reserved-channel or invalid-params error, which will fail identically.

Detecting both layers in code

Branch in a fixed order: check the error field first, then result.isError. The example below calls command_send (which surfaces a timeout as a tool error) and inspects the result. Each snippet assumes an initialized MCP session — for the connect-and-initialize handshake, see Client setup.

# The JSON-RPC envelope always returns HTTP 200. Inspect the body:
#   .error            -> protocol error (no .result)
#   .result.isError   -> tool execution error
curl -X POST http://localhost:9090/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": 9,
    "method": "tools/call",
    "params": {
      "name": "command_send",
      "arguments": {
        "channel": "example-commands",
        "body": "do-work",
        "timeout_seconds": 10
      }
    }
  }'
result, err := c.CallTool(ctx, mcp.CallToolRequest{
    Params: mcp.CallToolParams{
        Name: "command_send",
        Arguments: map[string]any{
            "channel":         "example-commands",
            "body":            "do-work",
            "timeout_seconds": 10,
        },
    },
})
if err != nil {
    // Layer 1/2: transport or JSON-RPC protocol error — the tool never ran.
    log.Fatal(err)
}

// Layer 3: a valid call that failed at the tool layer (e.g. timeout).
if result.IsError {
    fmt.Printf("tool error: %+v\n", result.Content)
    return
}

fmt.Printf("Result: %+v\n", result)
result = await session.call_tool("command_send", {
    "channel": "example-commands",
    "body": "do-work",
    "timeout_seconds": 10,
})

# Layer 3: tool execution error (timeout, reserved channel, ...).
# Layer 1/2 protocol errors raise an exception before reaching here.
if result.isError:
    print(f"tool error: {result.content[0].text}")
else:
    print(f"Result: {result.content[0].text}")
const result = await client.callTool({
  name: "command_send",
  arguments: {
    channel: "example-commands",
    body: "do-work",
    timeout_seconds: 10,
  },
});

// Layer 3: inspect isError before trusting the content.
if (result.isError) {
  console.error("tool error:", result.content);
} else {
  console.log(JSON.stringify(result, null, 2));
}
var result = client.callTool(new CallToolRequest(
    "command_send",
    Map.of(
        "channel", "example-commands",
        "body", "do-work",
        "timeout_seconds", 10
    )
));

// Layer 3: a valid call that failed at the tool layer.
if (Boolean.TRUE.equals(result.isError())) {
    System.out.println("tool error: " + result.content());
} else {
    System.out.println(result);
}
var result = await client.CallToolAsync("command_send", new Dictionary<string, object>
{
    ["channel"] = "example-commands",
    ["body"] = "do-work",
    ["timeout_seconds"] = 10,
});

// Layer 3: tool execution error (e.g. timeout).
if (result.IsError)
{
    Console.WriteLine($"tool error: {result.Content}");
}
else
{
    Console.WriteLine($"Result: {result}");
}
val result = client.callTool("command_send", mapOf(
    "channel" to "example-commands",
    "body" to "do-work",
    "timeout_seconds" to 10
))

// Layer 3: inspect isError before using the content.
if (result.isError == true) {
    println("tool error: ${result.content}")
} else {
    println("Result: $result")
}
result = client.call_tool("command_send", {
  "channel" => "example-commands",
  "body" => "do-work",
  "timeout_seconds" => 10,
})

# Layer 3: tool execution error (e.g. timeout).
if result.is_error
  puts "tool error: #{result.content}"
else
  puts "Result: #{result}"
end
let result = client.call_tool("command_send", json!({
    "channel": "example-commands",
    "body": "do-work",
    "timeout_seconds": 10
})).await?;

// Layer 3: a valid call that failed at the tool layer.
if result.is_error.unwrap_or(false) {
    eprintln!("tool error: {:#?}", result.content);
} else {
    println!("Result: {result:#?}");
}
let result = try await client.callTool("command_send", arguments: [
    "channel": "example-commands",
    "body": "do-work",
    "timeout_seconds": 10,
])

// Layer 3: inspect isError before trusting the content.
if result.isError == true {
    print("tool error: \(result.content)")
} else {
    print("Result: \(result)")
}

A timeout is reported through result.isError, not as a JSON-RPC error and not as a non-200 HTTP status. Code that only catches transport exceptions or checks the status code will silently treat a timed-out command as a success.

Best practices

  1. Check error first, then result.isError. A protocol error has no result; a tool error is structurally a success with isError: true.
  2. Do not branch on the HTTP status for JSON-RPC calls — every response is HTTP 200 (auth/origin rejections excepted, which return -32010 in the body).
  3. Retry only transient tool errors (timeouts, no-subscriber). Protocol errors and reserved-channel/invalid-params errors fail identically on retry — fix the request instead.
  4. Log the full response when debugging, not just the field you branched on.

Was this page helpful?

On this page