ADR 191 · Accepted

ADR-191: A cancelled tool call is not a failed one

Context

ADR-190 made a cancelled run stop the MCP call it has on the wire. The transport raises its own exception for that, and McpTool::execute() returned it the way it returns every transport fault — as ToolResult::error().

A tool result carried one boolean, and that boolean is read all the way to the screen: RunTrace::recordToolExecution() writes it as RunStep::$toolIsError, the step's payload persists it, RunTimelineFactory::stepOutcome() maps it to failed or ok, and the runs module renders that under Outcome. So an operator who cancelled a run saw Failed next to a server that had answered nothing wrong, and anything counted from those rows counted their own cancel as a fault.

McpServers.rst promises that "a server that is flaky is visible without reading transcripts". With cancellations landing in the same bucket, that was no longer true.

Decision

  1. A tool result states its outcome, and the boolean stays. ToolOutcome has three cases — OK, FAILED, CANCELLED — and ToolResult carries one. ToolResult::$isError is unchanged and remains true for both non-OK cases, so every consumer that reads it keeps the meaning it had; the outcome says WHICH of the two.

    Not a second boolean. Two booleans encoding one tri-state is the shape ADR-187 rejected for the write target and its kind, for the same reason: it makes "cancelled but not an error" representable, and nothing would ever produce it.

    ToolResult::cancelled() is fail-closed exactly like self::error() — no artifacts, no write target — because a call that was cut off has no more claim to either than a failed one.

  2. The outcome travels through the bounding transformation. ToolResult::withBoundedChannels() rebuilds an error result from almost nothing, since a failed call may keep neither artifacts nor a write target. The outcome is the exception, and it is the member that most needs to survive: every tool result in a run passes through that method, so rebuilding it as FAILED would relabel every cancelled call before it reached the audit row. ADR-182 names three values already lost to exactly that shape.

  3. One string travels, end to end. ToolOutcome::CANCELLED's value is what RunStep::toArray() writes, what RunTimelineFactory::stepOutcome() returns, what RunTimelineEntry::OUTCOME_CANCELLED holds, and what the template turns into the key runs.detail.outcome.cancelled. Renaming one side alone renders an empty cell and nothing else would notice, so the equality is asserted rather than assumed.

  4. A step cannot disagree with itself. RunStep refuses a toolIsError that contradicts its toolOutcome, and refuses an outcome on a step that is not a tool step. Refused in the value object rather than at each writer, because that is the object which serialises the pair: one definition of the invariant instead of one per entry point.

  5. A row written before this keeps the outcome it had. toolIsError decides WHETHER a step states an outcome — it is the field every tool step has ever carried — and toolOutcome decides WHICH. Reading the boolean first is what makes older rows render as they always did instead of losing their outcome to a field they never held.

  6. The transport says which kind of exception it raised. McpTransportException is final, so there is no subclass to catch; it carries a flag set only by self::forCancelledCall(), and self::isCancellation() reads it. A code comparison at the call site would work too, but a code is a value anyone can copy, and then two places would decide what "cancelled" means.

Consequences

  • ToolOutcome is @api and recorded on the frozen surface, as the closure rule requires for a type an @api signature mentions. ToolResult gains cancelled() and $outcome. Nothing on the surface changes shape: RunTrace::recordToolExecution() keeps its signature and derives the outcome from the boolean it already took, and the typed RunTrace::recordToolResult() reads it off the result. Both build the step through one private method, where toolIsError is DERIVED from the outcome rather than passed beside it, so the pair cannot disagree there at all.

  • The runs module shows cancelled as its own outcome, in English and German.

  • What is NOT decided here: anything about the remote write. Whether a torn-down call mutated something is not knowable from this side — see ADR-190, decision 4, which this record does not revisit.

  • ToolInvocation, which the loop also builds from a result, is left alone: it carries the boolean, is not on the frozen surface, and nothing in the inspector chain reads it. A second place stating the outcome would be a second place to keep in step.

← All ADRs