Files
accounted/extensions/general/mcp-server/__tests__/resources.test.ts
T
Jakob WennbergandClaude Fable 5 a3c6566caf feat(mcp): ledger-context resource with per-company booking patterns (#928)
* feat(mcp): ledger-context resource with per-company booking patterns

Adds Accounted://ledger/context: derived account usage, counterparty
booking patterns with explicit confidence share (0.7 floor), explicit
mapping rules kept separate as authoritative, observed VAT profile, and
conventions. Backed by a SECURITY INVOKER get_ledger_usage_stats RPC so
group-bys run SQL-side, and surfaced as a top-5 digest stanza on
gnubok_get_agent_briefing so one call still bootstraps a session.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mcp): fold source-quality prereqs into the ledger-context RPC

Merchant-name normalization at the aggregation path (the splinter fix):
new normalize_counterparty_key() SQL function mirroring
normalizeCounterpartyName() so KORTKÖP/SWISH/date-suffixed labels merge
into one counterparty key, which also makes the categorization_templates
join exact. New supplier_patterns section (per-supplier dominant expense
account + VAT treatment from supplier invoices; credit notes and reversed
invoices excluded). account_usage excludes storno lines (they re-inflate
the account a correction moved away from); the counterparty CTE keeps
corrections because the transaction relink self-heals. Pattern confidence
is now count-grounded evidence {seen_12m, agree, share, last_booked}
instead of a bare ratio, and the digest frames it as historical frequency,
never auto-book permission.

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

* fix(agent-context): use roundOre for the share ratio (antipattern ratchet)

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

* fix(agent-context): defensive storno filter on counterparty CTE, fail-loud secondary reads

Review follow-ups: the counterparty CTE now excludes source_type='storno'
defensively (no live code path links a transaction to a storno, but legacy
rows may predate reverseEntry's unlink; a linked storno would count the
reversed category as precedent). Corrections stay included: they are the
live booking after relink. Secondary reads (rules, templates, settings)
now throw instead of silently reading as empty data: an agent must never
be told 'no rules' when the truth is 'read failed'.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 13:48:04 +02:00

68 lines
2.2 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { dataResources, findResource, parseResourceQuery } from '../resources'
describe('mcp resource registry', () => {
it('exposes all data resources with required fields', () => {
expect(dataResources).toHaveLength(8)
const uris = dataResources.map((r) => r.uri).sort()
expect(uris).toEqual([
'Accounted://attention',
'Accounted://capabilities',
'Accounted://chart-of-accounts',
'Accounted://company/current',
'Accounted://ledger/context',
'Accounted://period/active',
'Accounted://recent-activity',
'Accounted://settings/vat-treatments',
])
for (const r of dataResources) {
expect(r.name).toBeTruthy()
expect(r.description.length).toBeGreaterThan(20)
expect(r.mimeType).toBe('application/json')
expect(typeof r.read).toBe('function')
}
})
it('matches base URI ignoring query string', () => {
const r = findResource('Accounted://recent-activity?limit=5')
expect(r?.uri).toBe('Accounted://recent-activity')
})
it('returns null for unknown URI', () => {
expect(findResource('Accounted://does-not-exist')).toBeNull()
})
it('parses query params from URI', () => {
const q = parseResourceQuery('Accounted://recent-activity?limit=5&offset=10')
expect(q?.get('limit')).toBe('5')
expect(q?.get('offset')).toBe('10')
})
it('returns undefined when no query', () => {
expect(parseResourceQuery('Accounted://capabilities')).toBeUndefined()
})
})
describe('vat-treatments resource', () => {
it('returns matrix for all customer types without DB access', async () => {
const r = findResource('Accounted://settings/vat-treatments')!
const result = (await r.read({
// Pure-function resource: no DB calls
supabase: undefined as never,
companyId: 'irrelevant',
userId: 'irrelevant',
scopes: [],
})) as { treatments: string[]; by_customer_type: Record<string, unknown> }
expect(result.treatments).toContain('standard_25')
expect(result.treatments).toContain('reverse_charge')
expect(Object.keys(result.by_customer_type)).toEqual([
'individual',
'swedish_business',
'eu_business',
'non_eu_business',
])
})
})