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 {