> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getmcpulse.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Outcomes

> How calls ended: ok, empty, bad_args, tool_error and crashed — and why empty is its own slice.

Every call ends as exactly one of four outcomes, recorded by the SDK. The breakdown splits them into **five** slices, because `ok` hides one.

| Slice | Meaning | What it usually means |
| - | - | - |
| `ok` | Ran and returned something useful | Working |
| `empty` | Ran, succeeded, returned nothing useful | A filter matched nothing, or an id did not exist |
| `bad_args` | Arguments failed schema validation — your handler never ran | The model does not understand your input schema |
| `tool_error` | Ran and returned `isError: true` | Your tool refused, on purpose |
| `crashed` | Threw | A bug in your tool |

## Why empty is split out

A call that succeeded and returned nothing is the failure the outcome field hides. Burying it inside the green bar is exactly how it stays hidden — so `ok` is drawn as `ok − empties` and `empty` gets its own slice.

Both the overview and the [tool page](/tools/detail) render this from the same function, so the two cannot disagree about what "ok" counts. See [Empty results](/metrics/empty-results).

## What these outcomes measure

**MCPulse instruments the `McpServer` layer, not the wire.**

A thrown error and a deliberately returned `isError` are distinguishable to MCPulse because the SDK wraps your tool callbacks — but they arrive at the model **identically**, once `McpServer` has normalised them.

That is measured rather than assumed. A fixture server that throws, returns `isError`, and fails validation on purpose, probed through a real MCP client:

| Your code did | What the client received (SDK 1.30.0, stdio) |
| - | - |
| threw | `result`, `isError: true`, with content |
| returned `isError: true` | `result`, `isError: true`, with content |
| failed schema validation | `result`, `isError: true`, with content |
| returned normally | `result`, `isError: false`, with content |

All three failures are one thing on the wire. *Verified against SDK 1.30.0 on 21 Sep 2026* — and the table is re-derived weekly, because normalisation changes are often undocumented and a check keyed to the version cannot fire when the behaviour changed and the version did not.

**This is the right layer for the purpose.** You are who MCPulse reports to, and "my code threw" versus "my code returned an error" is actionable for you in a way the wire is not. But these outcomes describe *your code*, not *the caller's experience*, and it would be wrong to let them read as the same thing.

If you are on an SDK version not in that table, MCPulse shows you the `McpServer` layer and says so. It does not interpolate from the nearest version it has tested.

## crashed versus tool\_error

These look identical from outside `McpServer`, which catches everything a tool does and converts it into `{ isError: true }`. The SDK wraps your tool callbacks as well as the request handler so the difference survives.

It matters because the two have opposite fixes:

* `tool_error` — your tool decided to refuse. Often correct behaviour. Worth checking the model understands *why* it was refused.
* `crashed` — your tool has a bug. Nothing about the model's behaviour will fix it.

## bad\_args is a description problem

`bad_args` means the model constructed arguments your schema rejected, and your handler never ran. That is not a bug in your code; it is a gap between what your schema requires and what your description implies.

A tool with a high `bad_args` share almost always has an under-described required field, an enum whose values are not listed, or a name that suggests something other than what it takes.

## The error rate

The **error rate** on the overview is `bad_args + tool_error + crashed` as a share of calls. `empty` is deliberately **not** in it — an empty result is a different failure with a different rate beside it.

## Counted by name, in both tables

The tool table and the clients panel both carry **`Bad args`**, **`Tool errors`**, **`Crashes`** and **`Empty`** as their own columns, from one shared definition — so a row means the same thing on both.

They were one column called *Failed*, and pooling is what made it useless. 13.4% failed says open the tool and nothing about what to change inside it. 389 bad arguments is a schema the model cannot fill in; 42 tool errors is a handler refusing work it was given; 3 crashes is a bug. One number, three fixes.

**Counts, not rates.** Both tables already carry `calls`, so the denominator is on the row — and a crash is an event rather than a proportion. Three of them reads as something to go and fix, where the same three as 0.1% is the figure your eye skips. A zero from real traffic is muted; a row with no calls dashes all four, because zero failures out of zero calls is not a measurement.

The one pooled figure left is the line above the outcome breakdown — *"13.4% failed · 0.4% empty"* — which is a summary sitting on top of a panel that lists all five by name underneath it.

## Filtering

Outcomes scope cleanly to **both** filters: `tool_hours` carries per-outcome counters per tool, per client, per hour. So does the error rate, and so does everything derived from them.

## Related

* [Empty results](/metrics/empty-results)
* [First-call success](/metrics/first-call-success) — which counts `ok` and non-empty and un-retried
* [Tool detail](/tools/detail) — the same breakdown for one tool


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.