ADR 0041 — The cause of a failed tool call is observable
- Status: Accepted — closes an asymmetry between the HTTP and tool paths of ADR 0007 / ADR 0014; extends the raw hooks of ADR 0012
- Date: 2026-08-06
Context
An agent on a consuming project reported that when a tool handler fails, nothing in their logs says why. Their audit rows carried the failure, the caller got the envelope, and the log line for the request was blank on the reason.
Verified before answering, and the report was accurate — though not for the
reason given. It was framed as “ToolLifecycle has only beforeHandle /
afterHandle, so there is nothing to hook”. ToolCallHooks.afterToolCall does
fire for every failure and does carry the ToolResult, so a thrown AppError
is fully legible today: code and details are there. Those cases were never
broken.
Amended by ADR 0045 (2026-08-06). This paragraph also said
setRequestErrorfrom that hook reaches the enclosing request’s log line. A tool call now runs in its own context, so it does not — which only strengthens the conclusion below: route the cause to your own sink.
The hole is the unexpected throw — a dropped connection, a TypeError — the
case you actually consult logs for. normalizeError handles it like this
(internal/errors.ts):
console.error('[stitchkit] unhandled error:', err);
return new AppError('INTERNAL_SERVER_ERROR', 'Internal server error', 500);
The scrubbing is correct: a raw Error.message can carry a connection string or
a file path, and a tool result is read by a model and often relayed to a user.
What was wrong is that the scrubbed value was the only thing any consumer
hook could see. The real value existed for one statement and left through a
console.error that cannot be routed, filtered or correlated.
The HTTP path has no such hole. hooks.onError(ctx, error, endpoint) receives
the value as thrown, before normalisation. The same handler, reached through two
transports, gave two different answers to “why did it fail” — which is precisely
what ADR 0014 says the tool surface must not do.
Decision
Add ToolCallHooks.onToolError — the value as thrown, fired from the catch
in executeToolMethod before toolResultFromError runs.
onToolError?: (
toolName: string,
error: unknown,
context: ToolCallContext,
endpoint: MethodDef,
) => void | Promise<void>;
On ToolCallHooks, not as an onError twin on ToolLifecycle. This is the
part worth recording, because the request was for the opposite and it is the
question that will be asked again.
ToolLifecycle documents that a whole createServer({ hooks }) object is
assignable to it — that is how a createAuthHook result drops in as
beforeHandle and gates tools with the same rules as routes. Putting onError
there forces a choice between two bad ends, both probed rather than assumed:
- Reuse the HTTP signature (
=> Response | Promise<Response> | undefined) and ignore the return on the tool path. Assignability survives; a sharedonErrorsilently stops producing its envelope on one of two transports. A value that is accepted and quietly dropped is a trap. - Narrow it to
=> void | Promise<void>. Honest, and it breaks the assignment — confirmed withtsc: the void-return bivariance rule does not apply when the target return type is avoid | Promise<void>union, soLifecycleHooksstops being assignable toToolLifecycleand every consumer passing a shared hooks object fails to compile.
ToolCallHooks carries no such constraint. It is already the observation
surface, it is where afterToolCall lives, and it is the object a project
passes for audit — so the hook lands next to the one that needed it.
It fires only where information is destroyed: a throw from
lifecycle.beforeHandle, the handler, or lifecycle.afterHandle. Not for a
beforeToolCall rejection, an argument-validation failure or an output-schema
mismatch — each of those is already described in full by the ToolResult that
afterToolCall receives, and a second path to the same information only invites
double-logging.
It observes; it does not handle. The return value is ignored, and a throw
from the hook is reported to console.error and swallowed. A broken sink must
not replace the failure it was called to observe, nor cost the audit record. It
is awaited before afterToolCall, so whatever it records — a
setRequestError, typically — is in place when the audit hook reads the context.
normalizeError keeps its console.error. A project that wires
onToolError now sees the failure twice, in two formats. That was weighed and
accepted: the HTTP path has had exactly this property since the beginning
(hooks.onError and the console write both fire), and the loud default is what
makes an unwired framework debuggable. Removing it would trade a duplicate for a
silence in every project that wires nothing.
Consequences
- The cause of a tool failure is routable — to a log line, an error tracker, an audit row — instead of being visible only on stderr.
- Tool and HTTP transports now answer “why did it fail” the same way, closing an ADR 0014 parity gap that was not previously stated.
- Purely additive: an optional field on an optional hooks object, reaching every
mount that already accepts
hooks(mountMcp,createMcpHandler,mountAgent,createCli) through the sharedcreateToolRunner. - The tool envelope stays the framework’s alone. If a project ever needs to shape a tool error rather than observe it, that is a separate decision and a separate hook — deliberately not conflated with this one.