fix(mcp): tag unmapped tool failures with their cause vocabulary (#2051) (#2135)

Closes #2051. errorCauseTag() shipped in #2027 written and tested but
wired to nothing. This connects it: the two execution catch paths (sync
call and task) now pass errorCause into mcp.tool_called, carrying the
SQLSTATE or coded-error code, else the error's class name, capped at 64
chars.

The rows this exists for are the UNKNOWN_ERROR residue, whose
errorMessage is the constant "Något gick fel. Försök igen." and whose
errorDetail is the English constant: 465 such rows in the last 30 days
(create_voucher 58 of its 60 failures, query_journal 122) with nothing
to cluster on. A five-character SQLSTATE is protocol vocabulary; a raw
driver message can quote row values from a constraint violation and
belongs in the server log, never in event_log, so the raw message is
deliberately not captured.

A plain `new Error(...)` tags null rather than 'Error': tagging
everything is the same as tagging nothing. Pre-execution denials (scope,
capability, validation, unknown tool) pass nothing because their
errorCode already is the cause.

Self-tested by unwiring one call site and watching the new test name it.


Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-01 22:07:46 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Opus 5
parent 191fb2cfce
commit dd84d6c1bb
4 changed files with 71 additions and 2 deletions
+15 -1
View File
@@ -48,7 +48,7 @@ import { ACCOUNT_NUMBER_RE } from '@/lib/invariants/account-number'
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import { ACCOUNTS_NOT_IN_CHART } from '@/lib/bookkeeping/errors'
import { dbError } from '@/lib/errors/db-error'
import { dbError, errorCauseTag } from '@/lib/errors/db-error'
import { getStructuredError } from '@/lib/errors/get-structured-error'
import { applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
@@ -20509,6 +20509,15 @@ function emitToolCallTelemetry(payload: {
* message_sv is already the domain message costs nothing.
*/
errorDetail?: string | null
/**
* Machine vocabulary for the unmapped failures (#2051): errorCauseTag(err),
* i.e. the SQLSTATE or coded-error code, else the error's class name. For
* UNKNOWN_ERROR rows message_sv is the constant "Något gick fel. Försök
* igen.", so without this the residue cannot be clustered at all. Never the
* raw driver message: that can quote row values from a constraint violation
* and belongs in the server log, not in event_log.
*/
errorCause?: string | null
requestId: string | number | null
userId: string
// null/empty while the key's user has no company yet (issue #1814): the
@@ -20542,6 +20551,9 @@ function emitToolCallTelemetry(payload: {
payload.errorDetail && payload.errorDetail !== payload.errorMessage
? payload.errorDetail.slice(0, 500)
: null,
// Protocol vocabulary only (a SQLSTATE, a code, a class name), capped
// hard: anything longer is a message pretending to be a tag.
errorCause: payload.errorCause ? payload.errorCause.slice(0, 64) : null,
requestId: payload.requestId,
userId: payload.userId,
companyId,
@@ -21481,6 +21493,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
errorKind: 'execution',
errorMessage: structured.error.message_sv,
errorDetail: structured.error.message_en,
errorCause: errorCauseTag(err),
requestId: id ?? null,
userId,
companyId: effectiveCompanyId,
@@ -21574,6 +21587,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
// clustering when mining failures for gotchas.
errorMessage: structured.error.message_sv,
errorDetail: structured.error.message_en,
errorCause: errorCauseTag(err),
requestId: id ?? null,
userId,
companyId: effectiveCompanyId,