feat(mcp): dimension parity for the write/read tool edges (#1274)

Closes the MCP dimension gaps found in the 2026-07-28 audit:

- gnubok_bulk_book_inbox_items accepts a shared dimensions bag through
  all three layers (tool schema + BulkBookInboxSchema + categorize-core
  BulkBookInboxInput), resolve-don't-select with echoed resolutions; the
  web inbox bulk-book route and the pending-op executor inherit it via
  the shared schema.
- gnubok_create_employee / gnubok_update_employee accept
  default_dimensions (names resolve to codes; {} clears on update).
  The command layer already persisted the field: only the MCP boundary
  blocked it, leaving payroll tagging dashboard-only.
- gnubok_query_journal: dimensions bag filter (jsonb containment via
  the GIN index, covers custom dims the legacy project/cost_center
  filters cannot) + include_dimensions to return each line's bag.
  The wide full-match fetch stays dims-free unless something needs it.
- gnubok_list_invoices / gnubok_list_supplier_invoices return
  default_dimensions (agents could set invoice bags but never read
  them back).
- Discoverability: create_voucher, categorize_transaction,
  correct_entry, update_invoice descriptions now name dimensions;
  categorize_month and invoice_run loadouts include
  gnubok_list_dimensions. Trimmed new schema prose to stay under the
  tools/list payload budget.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-29 09:39:53 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 222e581476
commit 80a14ddfd2
8 changed files with 418 additions and 13 deletions
+89 -11
View File
@@ -3869,7 +3869,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_categorize_transaction',
title: 'Categorize Bank Transaction',
description: 'Categorize a bank transaction. Stages the verifikat — cost line booked NET of moms, bank line gross; preview.lines shows the exact entry. Commit via gnubok_approve_pending_operation. vat_amount overrides computed moms; reverse_charge rejected when the underlag shows seller VAT.',
description: 'Categorize a bank transaction. Stages the verifikat: cost line NET of moms, bank line gross; dimensions bag tags the cost line. vat_amount overrides computed moms; reverse_charge rejected when the underlag shows seller VAT. Commit via gnubok_approve_pending_operation.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -4539,7 +4539,7 @@ export const tools: McpTool[] = [
let query = supabase
.from('invoices')
.select('id, invoice_number, status, customer_id, total, currency, invoice_date, due_date, document_type, customers(name)', { count: 'exact' })
.select('id, invoice_number, status, customer_id, total, currency, invoice_date, due_date, document_type, default_dimensions, customers(name)', { count: 'exact' })
.eq('company_id', companyId)
if (status) {
@@ -4562,6 +4562,7 @@ export const tools: McpTool[] = [
invoice_date: inv.invoice_date,
due_date: inv.due_date,
document_type: inv.document_type,
default_dimensions: inv.default_dimensions ?? {},
}))
return {
@@ -5721,7 +5722,7 @@ export const tools: McpTool[] = [
let query = supabase
.from('supplier_invoices')
.select('id, supplier_invoice_number, invoice_date, due_date, status, total, total_sek, currency, vat_treatment, remaining_amount, supplier:suppliers(id, name)')
.select('id, supplier_invoice_number, invoice_date, due_date, status, total, total_sek, currency, vat_treatment, remaining_amount, default_dimensions, supplier:suppliers(id, name)')
.eq('company_id', companyId)
if (status !== 'all') {
@@ -7021,7 +7022,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_query_journal',
title: 'Query Journal Lines',
description: "Flexible journal-line query for ad-hoc questions. Filters: account, date, amount, voucher series/number, source type, status, project, cost center, free-text. Optional group_by aggregation. Returns lines + totals over the full match set (see totals_scope).",
description: "Flexible journal-line query for ad-hoc questions. Filters: account, date, amount, voucher, source, status, dimensions bag, free-text. group_by/group_by_dimension aggregation; include_dimensions returns each line's bag. Lines + totals over the full match set (totals_scope).",
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -7039,8 +7040,17 @@ export const tools: McpTool[] = [
voucher_number_to: { type: 'number', description: 'Highest voucher number (inclusive)' },
source_type: { type: 'string', description: 'Filter by source: bank_transaction, invoice_created, supplier_invoice, currency_revaluation, year_end, opening_balance, etc.' },
status: { type: 'string', enum: ['posted', 'reversed', 'all'], description: 'Default: posted' },
project: { type: 'string', description: 'Filter by project code' },
cost_center: { type: 'string', description: 'Filter by cost center' },
project: { type: 'string', description: 'Filter by project code (SIE dim 6)' },
cost_center: { type: 'string', description: 'Filter by cost center (SIE dim 1)' },
dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Filter: SIE dim no → value (code OR name, resolved server-side), e.g. {"6":"P001"}. Containment match; covers custom dims unlike project/cost_center.',
},
include_dimensions: {
type: 'boolean',
description: "Return each line's dimensions bag (default false).",
},
group_by: { type: 'string', enum: ['account_number', 'voucher_series', 'source_type', 'cost_center', 'project'], description: 'Aggregate matching lines into groups by this field. Mutually exclusive with group_by_dimension.' },
group_by_dimension: { type: 'string', description: 'Aggregate by SIE dimension number (e.g. "6" = projekt) from each line\'s dimensions bag; untagged → "(utan dimension)". Mutually exclusive with group_by.' },
limit: { type: 'number', minimum: 1, maximum: 500, description: 'Max lines returned 1-500 (default 100). Totals/groups cover the FULL match set even when truncated, except under free-text search (see totals_scope).' },
@@ -7084,6 +7094,7 @@ export const tools: McpTool[] = [
description: 'Present when group_by/group_by_dimension is set; sorted by |net| desc. Scope follows totals_scope.',
},
applied_filters: { type: 'object' },
...DIMENSION_FILTER_OUTPUT_PROPS,
},
required: ['lines', 'total_lines', 'returned_lines', 'totals', 'totals_scope'],
},
@@ -7112,6 +7123,10 @@ export const tools: McpTool[] = [
const sourceType = args.source_type as string | undefined
const project = args.project as string | undefined
const costCenter = args.cost_center as string | undefined
const includeDimensions = args.include_dimensions === true
// Resolve-don't-select: value NAMES resolve to registry codes; the
// containment filter then hits the GIN index on the jsonb bag.
const dimFilter = await resolveReportDimensionFilter(supabase, companyId, args.dimensions)
const GROUP_BY_FIELDS = ['account_number', 'voucher_series', 'source_type', 'cost_center', 'project'] as const
const groupBy = args.group_by as (typeof GROUP_BY_FIELDS)[number] | undefined
@@ -7133,9 +7148,10 @@ export const tools: McpTool[] = [
}
const wantsGroups = Boolean(groupBy || groupByDimension)
// The dimensions jsonb only rides along when a group needs it: it is
// The dimensions jsonb only rides along when something needs it (a
// dimension group, the bag filter's echo, or include_dimensions): it is
// the widest column on the line and the aggregate pass fetches ALL rows.
const dimsSelect = groupByDimension ? ', dimensions' : ''
const dimsSelect = groupByDimension || includeDimensions || dimFilter.filter ? ', dimensions' : ''
// Free-text legs only. The embed survives here on purpose: each leg is
// capped at `legLimit` rows, and that cap (which drives legCapHit and
// the `truncated` signal) has no equivalent in the two-step fetch,
@@ -7183,6 +7199,7 @@ export const tools: McpTool[] = [
if (project) q = q.eq('project', project)
if (costCenter) q = q.eq('cost_center', costCenter)
if (dimFilter.filter) q = q.contains('dimensions', dimFilter.filter)
return q
}
@@ -7213,6 +7230,7 @@ export const tools: McpTool[] = [
}
if (project) l = l.eq('project', project)
if (costCenter) l = l.eq('cost_center', costCenter)
if (dimFilter.filter) l = l.contains('dimensions', dimFilter.filter)
return l
}
@@ -7420,6 +7438,7 @@ export const tools: McpTool[] = [
line_description: r.line_description,
project: r.project,
cost_center: r.cost_center,
...(includeDimensions ? { dimensions: r.dimensions ?? {} } : {}),
currency: r.currency,
}
})
@@ -7508,9 +7527,12 @@ export const tools: McpTool[] = [
status,
project: project ?? null,
cost_center: costCenter ?? null,
dimensions: dimFilter.filter ?? null,
group_by: groupBy ?? null,
group_by_dimension: groupByDimension ?? null,
},
...(dimFilter.filter ? { dimension_filter: dimFilter.filter } : {}),
...(dimFilter.resolutions.length > 0 ? { dimension_resolutions: dimFilter.resolutions } : {}),
}
},
},
@@ -8245,6 +8267,11 @@ export const tools: McpTool[] = [
vat_amount: { type: 'number', exclusiveMinimum: 0, description: "The underlag's exact moms override; only valid with a rate-based vat_treatment. Rarely needed in bulk: all items share one value." },
notes: { type: 'string', description: 'Audit-trail note appended to every verifikation. Keep under 200 chars.' },
allow_duplicate: { type: 'boolean', description: 'Override the per-item duplicate-booking guard (default false). Set true only after the user confirms these bank lines are genuinely separate events.' },
dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Shared dims bag {sie_dim_no: kod eller namn} applied to the business lines of every verifikat. Unknown values rejected: never auto-created.',
},
},
required: ['item_ids', 'category'],
},
@@ -8265,6 +8292,15 @@ export const tools: McpTool[] = [
? args.notes.trim()
: undefined
// Resolve-don't-select: codes AND natural-language names resolve against
// the registry; the staged params carry only resolved codes.
const { bags: resolvedDimBags, resolutions: dimensionResolutions } = await resolveDimensionBags(
supabase,
companyId,
[parseDimensionsArg(args.dimensions, 'dimensions')],
)
const resolvedDimensions = resolvedDimBags[0]
// Pre-flight: classify the selection so the preview (and the agent) sees
// the real shape before staging. Tenant isolation via company_id.
const { data: items, error } = await supabase
@@ -8323,6 +8359,9 @@ export const tools: McpTool[] = [
vat_amount: vatAmount ?? null,
notes: notes ?? null,
allow_duplicate: args.allow_duplicate === true,
dimensions: resolvedDimensions && Object.keys(resolvedDimensions).length > 0
? resolvedDimensions
: null,
},
{
item_count: itemIds.length,
@@ -8334,6 +8373,12 @@ export const tools: McpTool[] = [
total_sek: Math.round(totalSek * 100) / 100,
category: args.category,
vat_treatment: args.vat_treatment ?? null,
...(resolvedDimensions && Object.keys(resolvedDimensions).length > 0
? { dimensions: resolvedDimensions }
: {}),
// Echoed for every non-exact dimension resolution (resolve-don't-
// select) so the agent can verify what a name attached to.
...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}),
},
actor,
{
@@ -11194,12 +11239,29 @@ export const tools: McpTool[] = [
jamkning_percentage: { type: 'number' },
jamkning_valid_from: { type: 'string' },
jamkning_valid_to: { type: 'string' },
default_dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Dims bag {sie_dim_no: kod eller namn} tagging this employee\'s salary cost lines on every run. Never auto-created.',
},
},
required: ['first_name', 'last_name', 'personnummer', 'employment_start'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
// Resolve-don't-select BEFORE schema validation: the bag may carry
// registry value NAMES, which the strict DimensionsBagSchema inside
// CreateEmployeeSchema would reject.
const { bags: employeeDimBags } = await resolveDimensionBags(
supabase,
companyId,
[parseDimensionsArg(args.default_dimensions, 'default_dimensions')],
)
if (args.default_dimensions !== undefined) {
args = { ...args, default_dimensions: employeeDimBags[0] ?? {} }
}
const { CreateEmployeeSchema } = await import('@/lib/api/schemas')
const parsed = CreateEmployeeSchema.safeParse(args)
if (!parsed.success) {
@@ -11290,6 +11352,11 @@ export const tools: McpTool[] = [
jamkning_percentage: { type: ['number', 'null'], description: 'null clears the beslut' },
jamkning_valid_from: { type: ['string', 'null'] },
jamkning_valid_to: { type: ['string', 'null'] },
default_dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Dims bag {sie_dim_no: kod eller namn} tagging salary cost lines. Replaces the whole bag; {} clears all tags. Omit to keep.',
},
},
required: ['employee_id'],
},
@@ -11302,6 +11369,17 @@ export const tools: McpTool[] = [
throw new Error('personnummer cannot be changed: identity is immutable post-create')
}
// Resolve-don't-select: names resolve to registry codes; an explicit {}
// stays {} (the clear-all-tags update).
if (rest.default_dimensions !== undefined) {
const { bags: employeeDimBags } = await resolveDimensionBags(
supabase,
companyId,
[parseDimensionsArg(rest.default_dimensions, 'default_dimensions')],
)
rest.default_dimensions = employeeDimBags[0] ?? {}
}
const patch: Record<string, unknown> = {}
for (const [key, value] of Object.entries(rest)) {
if (value !== undefined) patch[key] = value
@@ -12937,7 +13015,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_update_invoice',
title: 'Update Draft Invoice',
description: 'Stage an edit to a DRAFT invoice: header fields and/or items (items = FULL REPLACE of all lines). Only editable drafts: no verifikat, not self-billed, not a credit note. Sent/paid invoices need gnubok_credit_invoice. Find invoice_id with gnubok_list_invoices.',
description: 'Stage an edit to a DRAFT invoice: header fields (incl. default_dimensions) and/or items (items = FULL REPLACE). Drafts only: no verifikat, not self-billed, not a credit note. Sent/paid invoices need gnubok_credit_invoice. Find invoice_id with gnubok_list_invoices.',
outputSchema: STAGED_OPERATION_SCHEMA,
inputSchema: {
type: 'object',
@@ -13332,7 +13410,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_create_voucher',
title: 'Create Manual Voucher (Verifikation)',
description: 'Stage a manual verifikation with arbitrary balanced lines: capitalization (1010), accruals, FX adjustments, rättelser outside categorize_transaction. Pass inbox_item_id to book a kvitto direct. HIGH risk.',
description: 'Stage a manual verifikation with arbitrary balanced lines: capitalization (1010), accruals, FX adjustments, rättelser outside categorize_transaction. Lines accept dimensions bags {sie_dim_no: code or name}. Pass inbox_item_id to book a kvitto direct. HIGH risk.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -13602,7 +13680,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_correct_entry',
title: 'Correct Posted Entry (Rättelse)',
description: 'Stage a rättelse for a posted verifikation per BFL 5 kap 5§: storno + corrected entry in the original period (never in-place edit). Use for partial fixes like 2641 → 2614/2645. Account drives ruta. HIGH risk.',
description: 'Stage a rättelse for a posted verifikation per BFL 5 kap 5§: storno + corrected entry in the original period (never in-place edit). Use for partial fixes like 2641 → 2614/2645; lines accept dimensions bags. Account drives ruta. HIGH risk.',
inputSchema: {
type: 'object',
additionalProperties: false,