Skip to content

Classify errors returned as data

Many tools report failure in their result instead of raising: {"status": "failed", "error": "disk full"}, {"success": False, ...}. A result classifier catches these so error rates count them. Each record gets an outcome:

outcomeokMeaning
oktrueThe call succeeded
soft_errorfalseThe tool returned a result the classifier flagged
errorfalseThe tool raised

Soft errors get error_type="SoftError" and an error_message such as status=failed: disk full, redacted and truncated like exception text. The classifier never changes what the client receives.

The default, DEFAULT_RESULT_CLASSIFIER, looks at dict results only. In order of precedence:

  1. status or state is error, failed or failure (any case). This wins even next to ok: True, since the status is the more specific of the two.
  2. ok or success is exactly False.
  3. The tool marked the result as an MCP error (isError) without raising.
  4. ok or success is exactly True: the result is ok, and any error string next to it is read as a warning. Truthy values that are not True, such as 1 or "yes", do not count.
  5. error holds a non-empty string. None, "", numbers, dicts and lists there do not count, so {"error": 0.02} stays ok.

The message comes from the first of error, message, detail and reason.

from fastmcp_feedback.instrumentation import DEFAULT_RESULT_CLASSIFIER as classify
assert classify("t", {"status": "failed", "error": "disk full"}) == "status=failed: disk full"
assert classify("t", {"success": True, "status": "failed"}) == "status=failed"
assert classify("t", {"ok": True, "error": "deprecated arg"}) is None
assert classify("t", {"ok": 1, "error": "x"}) == "error: x"
assert classify("t", {"played": False, "error": "stalled"}) == "error: stalled"
assert classify("t", {"error": 0.02}) is None
assert classify("t", ["not", "a", "dict"]) is None

The classifier only sees calls that did not raise. It gets the same payload the record stores: the structured content when the tool returned some, otherwise the content items.

soft_error_classifier() builds a classifier with your own keys and values. Pass the result to instrument:

from fastmcp import FastMCP
from fastmcp_feedback.instrumentation import instrument, soft_error_classifier
app = FastMCP("My Server")
# A status of your own that means failure
instrument(app, result_classifier=soft_error_classifier(extra_statuses=("unknown_target",)))

Other knobs, each shown on its own:

# Tools that report {"failure_reason": "..."} instead of {"error": "..."}
soft_error_classifier(error_keys=("failure_reason",))
# An error string alone is not a failure (behavior before 2026.10.01)
soft_error_classifier(error_keys=())
# Different status vocabulary
soft_error_classifier(status_keys=("phase",), statuses=("crashed", "aborted"))

Any (tool, result) -> str | None works. Return None for ok, or a string, which becomes the error message:

def classify_search(tool, result):
if tool == "search" and isinstance(result, dict) and result.get("hits") == []:
return "no hits"
return DEFAULT_RESULT_CLASSIFIER(tool, result)
app = FastMCP("Search")
instrument(app, result_classifier=classify_search)

If your classifier raises, the error is logged and the call is recorded as ok. result_classifier=None turns classification off, so only exceptions count as errors.

-- every failure
SELECT tool, count(*) FROM ffb_tool_calls WHERE NOT ok GROUP BY tool;
-- exceptions only
SELECT tool, count(*) FROM ffb_tool_calls WHERE outcome = 'error' GROUP BY tool;
-- soft errors with their messages
SELECT tool, error_message FROM ffb_tool_calls
WHERE outcome = 'soft_error' ORDER BY started_at DESC LIMIT 20;