From 267ed6c1bbe1338c10b72671d0c90a1822beac3d Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Mon, 3 Aug 2026 22:17:27 +0200 Subject: [PATCH] feat(mcp): expose the konteringspaket catalogue as a resource (#1395) An agent proposing a booking currently invents the account numbers, and a plausible guess (6071 instead of 6072, or the full cost instead of the 80% deductible share) produces a verifikat that posts and is wrong. This turns that into naming a reviewed template. Read from packs/ rather than the database on purpose: legal_note has no column in booking_template_library, so the database copy cannot answer "when does this template apply", and that note is the one field an agent cannot derive from account numbers. Carries the amount maths explicitly (vat lines from vat_rate, everything else from ratio) with an instruction not to fudge an amount to force a balance, plus notes that entity_type is binding, that a company may hold templates beyond this list, and that posting goes through the journal-entry tools so period locks and the balance check still apply. A load failure returns an explicit error rather than an empty list: an agent shown 12 of 26 templates concludes the other 14 do not exist and hand-rolls accounts for them. Verified the packs are actually reachable at runtime before relying on this: a production build traces all 26 YAML files into the serverless function, so the loader is not a local-only convenience. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) --- .../__tests__/booking-packs-resource.test.ts | 105 ++++++++++++++++++ .../mcp-server/__tests__/resources.test.ts | 3 +- .../mcp-server/resources/booking-packs.ts | 86 ++++++++++++++ .../general/mcp-server/resources/index.ts | 2 + 4 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 extensions/general/mcp-server/__tests__/booking-packs-resource.test.ts create mode 100644 extensions/general/mcp-server/resources/booking-packs.ts diff --git a/extensions/general/mcp-server/__tests__/booking-packs-resource.test.ts b/extensions/general/mcp-server/__tests__/booking-packs-resource.test.ts new file mode 100644 index 00000000..cc8a9b4a --- /dev/null +++ b/extensions/general/mcp-server/__tests__/booking-packs-resource.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest' +import { bookingPacksResource } from '../resources/booking-packs' +import { findResource } from '../resources/index' + +/** + * The value of this resource is that an agent stops guessing account numbers. + * These tests pin the parts that make that true: the slug is present so a + * template can be NAMED, the statutory note survives (it exists nowhere else, + * the database has no column for it), and a load failure is distinguishable + * from an empty catalogue. + */ + +interface PackPayload { + templates: Array<{ + slug: string + name: string + description: string + legal_note?: string + category: string + entity_type: string + lines: Array<{ account: string; side: string; type: string; ratio?: number; vat_rate?: number }> + }> + how_to_apply: Record + notes: Record + error?: string +} + +const read = () => bookingPacksResource.read({} as never) as Promise + +describe('booking packs MCP resource', () => { + it('is registered and resolvable by URI', () => { + expect(findResource(bookingPacksResource.uri)).toBe(bookingPacksResource) + }) + + it('exposes every template with its slug, the lookup key an agent names', async () => { + const { templates } = await read() + expect(templates.length).toBe(26) + for (const t of templates) { + expect(t.slug, `${t.name} has no slug`).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/) + expect(t.lines.length).toBeGreaterThanOrEqual(2) + } + expect(new Set(templates.map((t) => t.slug)).size).toBe(templates.length) + }) + + it('carries the statutory note, which exists nowhere else', async () => { + const { templates } = await read() + // legal_note has no column in booking_template_library, so if this resource + // dropped it the information would be unreachable to an agent entirely. + const withNote = templates.filter((t) => t.legal_note) + expect(withNote.length).toBeGreaterThan(0) + expect(withNote.some((t) => t.slug.startsWith('periodiseringsfond'))).toBe(true) + }) + + it('orders templates by meta.order, matching what the user sees', async () => { + const { templates } = await read() + // The gallery and the docs sort on meta.order; an agent reading a different + // order would describe "the first template" as something else again. + const slugs = templates.map((t) => t.slug) + expect(slugs).toEqual([...new Set(slugs)]) + expect(slugs[0]).toBeTruthy() + }) + + it('keeps account numbers as strings', async () => { + const { templates } = await read() + for (const t of templates) { + for (const l of t.lines) { + expect(typeof l.account, `${t.slug} ${l.account}`).toBe('string') + expect(l.account).toMatch(/^\d{4}$/) + } + } + }) + + it('states the amount maths, so an agent does not invent a split', async () => { + const { how_to_apply } = await read() + expect(how_to_apply.vat_line).toContain('vat_rate') + expect(how_to_apply.business_line).toContain('ratio') + // The explicit instruction not to fudge an amount to force a balance. + expect(how_to_apply.balance).toMatch(/do not adjust/i) + }) + + it('tells an agent not to post from here', async () => { + const { notes } = await read() + expect(notes.posting).toMatch(/journal-entry tools/) + }) + + it('warns that a company may have its own templates beyond this list', async () => { + const { notes } = await read() + expect(notes.company_templates).toBeTruthy() + }) + + it('every vat line carries vat_rate and every other line carries ratio', async () => { + const { templates } = await read() + for (const t of templates) { + for (const l of t.lines) { + if (l.type === 'vat') { + expect(l.vat_rate, `${t.slug}: vat line without vat_rate`).toBeDefined() + expect(l.ratio).toBeUndefined() + } else { + expect(l.ratio, `${t.slug}: ${l.type} line without ratio`).toBeDefined() + expect(l.vat_rate).toBeUndefined() + } + } + } + }) +}) diff --git a/extensions/general/mcp-server/__tests__/resources.test.ts b/extensions/general/mcp-server/__tests__/resources.test.ts index 26457965..b5e1fc1f 100644 --- a/extensions/general/mcp-server/__tests__/resources.test.ts +++ b/extensions/general/mcp-server/__tests__/resources.test.ts @@ -3,10 +3,11 @@ import { dataResources, findResource, parseResourceQuery } from '../resources' describe('mcp resource registry', () => { it('exposes all data resources with required fields', () => { - expect(dataResources).toHaveLength(8) + expect(dataResources).toHaveLength(9) const uris = dataResources.map((r) => r.uri).sort() expect(uris).toEqual([ 'Accounted://attention', + 'Accounted://booking-templates', 'Accounted://capabilities', 'Accounted://chart-of-accounts', 'Accounted://company/current', diff --git a/extensions/general/mcp-server/resources/booking-packs.ts b/extensions/general/mcp-server/resources/booking-packs.ts new file mode 100644 index 00000000..1da83766 --- /dev/null +++ b/extensions/general/mcp-server/resources/booking-packs.ts @@ -0,0 +1,86 @@ +import type { McpResource } from './types' +import { loadPacks, sortPacks } from '@/lib/packs/load' + +/** + * The konteringspaket catalogue, for agents. + * + * ## Why this exists + * + * Without it an agent proposing a booking has to invent the account numbers, + * and a plausible-looking guess (6071 instead of 6072, or the full cost instead + * of the 80% deductible share) produces a verifikat that posts but is wrong. + * A named catalogue turns "here are some accounts I think apply" into "this is + * the representation-avdragsgill-25-moms template", which the user can + * recognise, and which carries the statutory note explaining when it applies. + * + * ## Read from packs, not from the database + * + * `legal_note` lives only in the YAML: `booking_template_library` has no column + * for it, so the database copy cannot answer "when does this template apply". + * That note is the most valuable field here, because it is the part an agent + * cannot derive from account numbers. + * + * The catalogue is identical for every company (system templates are global), + * so nothing here is company-scoped and no `companyId` filter applies. A + * company's OWN templates are not included: those live in the database and are + * reachable through the booking-template tools. + * + * Read on demand via resources/read, so this costs nothing in the tools/list + * payload budget. + */ +export const bookingPacksResource: McpResource = { + uri: 'Accounted://booking-templates', + name: 'Booking Templates (konteringspaket)', + // Kept within the 280-char house limit for tool descriptions. Resources are + // not covered by that guard, but the surface reads better held to one rule. + description: + 'Standard Swedish bookkeeping templates: which BAS accounts each posts to, on which side, and how one total amount splits across them, plus the statutory note on when each applies. Read before proposing a manual booking so you name a reviewed template instead of guessing accounts.', + mimeType: 'application/json', + read: async () => { + const { packs, errors } = loadPacks() + + if (errors.length) { + // Surface rather than silently return a partial catalogue: an agent that + // sees 12 of 26 templates will confidently conclude the other 14 do not + // exist and hand-roll accounts for them. + return { + error: 'Pack catalogue failed to load; treat this list as unavailable, not as empty.', + details: errors.map((e) => `${e.file}: ${e.message}`), + templates: [], + } + } + + return { + templates: sortPacks(packs).map(({ pack }) => ({ + slug: pack.meta.slug, + name: pack.meta.name, + description: pack.meta.description, + legal_note: pack.meta.legal_note, + category: pack.meta.category, + entity_type: pack.meta.entity_type, + lines: pack.lines.map((l) => ({ + account: l.account, + label: l.label, + side: l.side, + type: l.type, + ratio: l.ratio, + vat_rate: l.vat_rate, + })), + })), + how_to_apply: { + input: 'The user enters ONE total amount. Every line is derived from it.', + vat_line: 'amount = total * vat_rate / (1 + vat_rate). The total is VAT-inclusive.', + business_line: 'amount = total * ratio. The cost or revenue leg.', + settlement_line: 'amount = total * ratio. The money leg (bank account, reskontra).', + balance: 'Debits and credits always sum equal. If your computed lines do not balance, you have applied the template wrong: do not adjust an amount to force it.', + }, + notes: { + entity_type: "A template marked 'aktiebolag' or 'enskild_firma' must not be used for the other form; 'all' applies to both.", + legal_note: 'Where present, it states the statutory limit or condition. Read it before proposing the booking: it is the difference between a template that applies and one that merely looks close.', + accounts: 'Account numbers are strings and every one is a standard BAS 2026 account, enforced in CI. Never substitute a neighbouring number.', + company_templates: 'This is the standard catalogue only. A company may have its own templates; those come from the booking-template tools, not from here.', + posting: 'This resource describes templates. It does not post anything: create the entry through the journal-entry tools so period locks and the balance check apply.', + }, + } + }, +} diff --git a/extensions/general/mcp-server/resources/index.ts b/extensions/general/mcp-server/resources/index.ts index eb23d961..ba4cf208 100644 --- a/extensions/general/mcp-server/resources/index.ts +++ b/extensions/general/mcp-server/resources/index.ts @@ -7,6 +7,7 @@ import { capabilitiesResource } from './capabilities' import { vatTreatmentsResource } from './vat-treatments' import { attentionResource } from './attention' import { ledgerContextResource } from './ledger-context' +import { bookingPacksResource } from './booking-packs' export const dataResources: McpResource[] = [ companyCurrentResource, @@ -17,6 +18,7 @@ export const dataResources: McpResource[] = [ vatTreatmentsResource, attentionResource, ledgerContextResource, + bookingPacksResource, ] export function findResource(uri: string): McpResource | null {