# Error Handling (/aiway/mcp/guides/error-handling)



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](/aiway/mcp/reference/error-codes).

## Overview [#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 &#x2A;*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.

<Callout type="info">
  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.
</Callout>

## How it works [#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.

<Mermaid
  chart="`
graph TB
CLIENT[&#x22;AI model / MCP client&#x22;]
HTTP[&#x22;Shared HTTP server<br/>auth + origin&#x22;]
RPC[&#x22;JSON-RPC envelope<br/>parse + method&#x22;]
TOOL[&#x22;Tool execution&#x22;]
AUTHERR[&#x22;-32010 auth error&#x22;]
RPCERR[&#x22;error{} (no result)<br/>-32700 / -32600 / -32601 / -32602&#x22;]
TOOLERR[&#x22;result.isError = true<br/>content[].text&#x22;]
OK[&#x22;result.isError = false<br/>content[].text&#x22;]

CLIENT --> HTTP
HTTP -. rejected .-> AUTHERR
HTTP --> RPC
RPC -. malformed / unknown .-> RPCERR
RPC --> TOOL
TOOL -. operation failed .-> TOOLERR
TOOL --> OK

class CLIENT external
class HTTP,RPC,TOOL aiway
class AUTHERR,RPCERR,TOOLERR external
class OK broker
`"
/>

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

## Layer 1 — HTTP and auth [#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:

```json title="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](/connectors/reference/auth-and-security); the MCP-specific
`validateOrigin`/`TrustedOrigins` rules are covered in
[Authentication](/aiway/mcp/guides/authentication).

## Layer 2 — JSON-RPC protocol errors [#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 &#x2A;*no `result`** field at all.

```json title="Protocol error (-32601)"
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32601,
    "message": "Method not found"
  }
}
```

| Code     | Trigger                                                                                        |
| -------- | ---------------------------------------------------------------------------------------------- |
| `-32700` | Malformed JSON body, or wrong `Content-Type` (e.g. `text/plain` instead of `application/json`) |
| `-32600` | Empty `method` field, or `jsonrpc` is not `"2.0"`                                              |
| `-32601` | Unknown method name (e.g. `tools/unknown`)                                                     |
| `-32602` | `params` 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](/aiway/mcp/reference/error-codes).

## Layer 3 — tool execution errors [#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.

```json title="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:

| Failure                                                      | Affected tools                                                                       |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------ |
| Reserved-channel rejection (channel starts with `_AGENTS_.`) | `queue_send`, `events_publish`, `events_store_publish`, `command_send`, `query_send` |
| Non-existent agent                                           | `agent_info`, `agent_send`, `agent_query`                                            |
| Timeout exceeded / no subscriber                             | `command_send`, `query_send`, `agent_send`, `agent_query`                            |
| Non-existent channel                                         | `channel_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](/aiway/mcp/tools/agent-bridge)
(`agent_send`, `agent_query`) instead.

## Handling timeouts [#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](/aiway/mcp/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 [#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](/aiway/mcp/guides/client-setup).

<Tabs groupId="language" items="['curl','Go','Python','TypeScript','Java','C#','Kotlin','Ruby','Rust','Swift']">
  <Tab value="curl">
    ```bash
    # 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
          }
        }
      }'
    ```
  </Tab>

  <Tab value="Go">
    ```go
    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)
    ```
  </Tab>

  <Tab value="Python">
    ```python
    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}")
    ```
  </Tab>

  <Tab value="TypeScript">
    ```typescript
    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));
    }
    ```
  </Tab>

  <Tab value="Java">
    ```java
    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);
    }
    ```
  </Tab>

  <Tab value="C#">
    ```csharp
    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}");
    }
    ```
  </Tab>

  <Tab value="Kotlin">
    ```kotlin
    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")
    }
    ```
  </Tab>

  <Tab value="Ruby">
    ```ruby
    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
    ```
  </Tab>

  <Tab value="Rust">
    ```rust
    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:#?}");
    }
    ```
  </Tab>

  <Tab value="Swift">
    ```swift
    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)")
    }
    ```
  </Tab>
</Tabs>

<Callout type="warn">
  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.
</Callout>

## Best practices [#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.

## Related [#related]

<Cards>
  <Card title="Error codes reference" href="/aiway/mcp/reference/error-codes" description="The canonical catalog: JSON-RPC codes, tool-level messages, and HTTP status codes." />

  <Card title="Session management" href="/aiway/mcp/guides/session-management" description="The initialize handshake, MCP-Session-Id, batches, and keepalive." />

  <Card title="Authentication" href="/aiway/mcp/guides/authentication" description="JWT auth, the -32010 auth error, and origin validation for /mcp." />

  <Card title="Command & query tools" href="/aiway/mcp/tools/commands-queries" description="The synchronous tools whose timeouts surface as tool-layer isError results." />
</Cards>
