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:
outcome | ok | Meaning |
|---|---|---|
ok | true | The call succeeded |
soft_error | false | The tool returned a result the classifier flagged |
error | false | The 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.
What the default classifier flags
Section titled “What the default classifier flags”The default, DEFAULT_RESULT_CLASSIFIER, looks at dict results only. In order
of precedence:
statusorstateiserror,failedorfailure(any case). This wins even next took: True, since the status is the more specific of the two.okorsuccessis exactlyFalse.- The tool marked the result as an MCP error (
isError) without raising. okorsuccessis exactlyTrue: the result is ok, and anyerrorstring next to it is read as a warning. Truthy values that are notTrue, such as1or"yes", do not count.errorholds 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 Noneassert classify("t", {"ok": 1, "error": "x"}) == "error: x"assert classify("t", {"played": False, "error": "stalled"}) == "error: stalled"assert classify("t", {"error": 0.02}) is Noneassert classify("t", ["not", "a", "dict"]) is NoneThe 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.
Tune it
Section titled “Tune it”soft_error_classifier() builds a classifier with your own keys and values.
Pass the result to instrument:
from fastmcp import FastMCPfrom fastmcp_feedback.instrumentation import instrument, soft_error_classifier
app = FastMCP("My Server")
# A status of your own that means failureinstrument(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 vocabularysoft_error_classifier(status_keys=("phase",), statuses=("crashed", "aborted"))Write your own
Section titled “Write your own”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.
Query soft errors
Section titled “Query soft errors”-- every failureSELECT tool, count(*) FROM ffb_tool_calls WHERE NOT ok GROUP BY tool;
-- exceptions onlySELECT tool, count(*) FROM ffb_tool_calls WHERE outcome = 'error' GROUP BY tool;
-- soft errors with their messagesSELECT tool, error_message FROM ffb_tool_callsWHERE outcome = 'soft_error' ORDER BY started_at DESC LIMIT 20;