feat(mcp): structured supplier-resolution failure with candidates in create_supplier_invoice_from_inbox (#873)

* feat(mcp): structured supplier-resolution failure with candidates in create_supplier_invoice_from_inbox

When supplier resolution failed (no match on id, org_number, or exact
name), the tool threw an opaque error — a dead end for the inbox
pipeline on small ad hoc vendors, which are most of the backlog
(agent.feedback). The error prose did mention supplier_id_override, but
gave the agent no candidate ids and no machine-readable next step.

Resolution failure now returns the staged-operation envelope with
staged:false: preview carries the extracted supplier identity and
near-miss candidates (normalized-name + org-digit matching — catches
punctuation/legal-suffix OCR variants like "Polarn o Pyret" vs
"Polarn O. Pyret AB", and formatted org numbers '556677-8899' vs
'5566778899'); next hints either retry-with-override on the best
candidate or a prefilled gnubok_create_supplier. Fuzzy scores never
auto-resolve — the agent confirms against the underlag.

Part of dev_docs/mcp_optimization_plan.md (P1-4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): review fixes — EF org-number forms, candidate-pool truncation flag, override tenancy validation

Addresses the three substantive review-bot findings on #873:

- orgNumberKey(): canonical 10-digit key. Swedish orgnr is exactly 10
  digits; enskild firma org numbers are personnummer that appear in both
  10- and 12-digit forms — exact digit-equality missed that legitimate
  match, and >= 10 accepted non-orgnr garbage lengths.
- No silent caps: preview.candidate_pool_truncated + message note when
  the 500-supplier candidate pool is hit.
- The defaults fetch now validates supplier existence in THIS company on
  every resolution path and rejects a bad supplier_id_override with a
  clear error — the unresolved next-hint actively promotes overrides, so
  a bogus id must fail at staging, not opaquely at commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-03 10:14:16 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 28827c2613
commit 001de3c844
4 changed files with 409 additions and 9 deletions
+73 -5
View File
@@ -56,6 +56,7 @@ import {
IdempotencyKeyReuseError,
} from '@/lib/api/idempotency'
import { toToolError, type NextActionHint } from './tool-result'
import { findSupplierCandidates } from './supplier-candidates'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { generateGeneralLedger } from '@/lib/reports/general-ledger'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
@@ -7463,7 +7464,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_create_supplier_invoice_from_inbox',
title: 'Create Supplier Invoice from Inbox',
description: "Atomic: turn an OCR'd inbox item into a staged supplier invoice. Resolves supplier, builds lines from extracted_data, applies VAT + FX + dimension tags, attaches the document. Stages for human review; honors dry_run.",
description: "Atomic: turn an OCR'd inbox item into a staged supplier invoice. Resolves supplier, builds lines from extracted_data, applies VAT + FX + dimension tags, attaches the document. Stages for human review; honors dry_run. Unresolved supplier → staged:false + candidates + next.",
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -7571,20 +7572,87 @@ export const tools: McpTool[] = [
}
if (!supplierId) {
throw new Error(
`Cannot resolve supplier from extracted data. Pass supplier_id_override, or create the supplier first (extracted name: ${supplierExt?.name ?? 'unknown'}, org: ${supplierExt?.organizationNumber ?? 'unknown'}).`
// Structured resolution failure instead of a dead end (P1-4,
// dev_docs/mcp_optimization_plan.md): a thrown error here stops the
// whole inbox pipeline for small ad hoc vendors. Return staged:false
// with near-miss candidates the agent can pass as supplier_id_override,
// or a create-supplier next hint when nothing is close. Fuzzy scores
// never auto-resolve — the agent/human confirms against the underlag.
const extractedName = (supplierExt?.name as string | undefined) ?? null
const extractedOrg = (supplierExt?.organizationNumber as string | undefined) ?? null
const CANDIDATE_POOL_CAP = 500
const { data: companySuppliers } = await supabase
.from('suppliers')
.select('id, name, org_number')
.eq('company_id', companyId)
.limit(CANDIDATE_POOL_CAP)
const candidates = findSupplierCandidates(
(companySuppliers ?? []) as { id: string; name: string; org_number: string | null }[],
extractedName,
extractedOrg,
)
const best = candidates[0]
// No silent caps: past the pool cap the right supplier may exist yet
// be absent from candidates — say so instead of implying full coverage.
const poolTruncated = (companySuppliers?.length ?? 0) >= CANDIDATE_POOL_CAP
return {
staged: false,
risk_level: getRiskLevel('create_supplier_invoice_from_inbox'),
actor: actor ?? { type: 'user' },
message: (best
? `Could not resolve supplier "${extractedName ?? 'unknown'}" exactly — ${candidates.length} near-miss candidate(s) in preview.candidates. Verify against the underlag, then retry with supplier_id_override; or create the supplier first.`
: `Could not resolve supplier "${extractedName ?? 'unknown'}" (org: ${extractedOrg ?? 'unknown'}) and no similar supplier exists. Create it with gnubok_create_supplier, then retry with supplier_id_override.`)
+ (poolTruncated ? ` Note: candidate search covered only the first ${CANDIDATE_POOL_CAP} suppliers — the pool was truncated.` : ''),
preview: {
supplier_resolution: 'unresolved',
unresolved_supplier: {
extracted_name: extractedName,
extracted_org_number: extractedOrg,
},
candidates,
candidate_pool_truncated: poolTruncated,
},
next: best
? {
description: `Closest existing supplier: "${best.name}" (score ${best.score}). If it matches the underlag, retry with this supplier_id_override.`,
tool: 'gnubok_create_supplier_invoice_from_inbox',
args: { inbox_item_id: inboxItemId, supplier_id_override: best.supplier_id },
}
: {
description:
'Create the supplier, approve it, then retry this tool with supplier_id_override set to the new supplier id.',
tool: 'gnubok_create_supplier',
args: {
...(extractedName ? { name: extractedName } : {}),
...(extractedOrg ? { org_number: extractedOrg } : {}),
},
},
}
}
// Fetch supplier defaults so line items can inherit default_expense_account
// when neither the extraction nor the agent provided an accountSuggestion.
// Doubles as existence/tenancy validation: every resolution path — and
// especially supplier_id_override, which the unresolved next-hint now
// actively promotes — must point at a supplier in THIS company, or the
// staged operation would fail opaquely at commit time instead.
const { data: resolvedSupplier } = await supabase
.from('suppliers')
.select('default_expense_account')
.select('id, default_expense_account')
.eq('id', supplierId)
.eq('company_id', companyId)
.single()
const supplierDefaultExpenseAccount = resolvedSupplier?.default_expense_account ?? null
if (!resolvedSupplier) {
throw new Error(
supplierResolution === 'override'
? `supplier_id_override ${supplierId} does not match any supplier in this company. Use a supplier_id from preview.candidates or gnubok_list_suppliers.`
: `Resolved supplier ${supplierId} no longer exists in this company — re-run extraction or pass supplier_id_override.`,
)
}
const supplierDefaultExpenseAccount = resolvedSupplier.default_expense_account ?? null
// Assemble core invoice fields
const currency = (invoiceExt?.currency as string) || 'SEK'