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:
co-authored by
Claude Fable 5
parent
222e581476
commit
80a14ddfd2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user