Files
accounted/extensions/general/mcp-server/__tests__/output-schema.test.ts
T
0ed26c2eca fix(mcp,bookkeeping): FX-safe mapping-rule VAT + honest nullable output schemas (#1846)
Follow-up sweep to #1842 for sibling bugs of both classes.

FX denomination (same defect as categorize's vat_amount):
- mapping-engine buildResult computed input VAT, reverse-charge fiktiv
  moms AND the rutor 20-24 basbelopp pair from the transaction-currency
  amount while journal lines are SEK. Now resolved through the same
  lenient SEK ladder buildTransactionEntryLines uses for the gross.
  Reachable at the ledger only via env-gated auto-book (dev/test) and
  the mapping-rules evaluate API, but one flag from production.
- categorize-core's header doc claimed transaction.amount already IS
  SEK; rewritten, it was license for exactly this bug class.

Output-schema nullability (same defect as matched_supplier_id):
- get_payslip.calculation_breakdown: returned as explicit null before
  calculation (the schema description even said so) but typed object.
- get_document_content.mime_type/size_bytes: verbatim nullable columns.
- export_sie: company_name nullable, and org_number was returned but
  never declared under additionalProperties:false, so strict clients
  failed EVERY successful export.
- currency (two transaction listings) and voucher_series (verifikat
  listing): nullable columns with defaults, loosened defensively.
- New declaration-pinning test in output-schema.test.ts.

Known and deliberately not fixed here: skatteverket bank-counterpart
hint compares a foreign amount to SEK (display-only, missed hint);
own-account-detector cross-currency magnitude tie-break (heuristic);
invoice-inbox still withholds rule proposals on foreign rows (guard
can be lifted separately now that the defect it guards is fixed).


Claude-Session: https://claude.ai/code/session_01ScVhg6XsDtNXkiEQNV7LaZ

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 15:14:21 +02:00

79 lines
3.4 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { tools } from '../server'
describe('outputSchema coverage', () => {
it('every tool declares an outputSchema', () => {
const missing = tools.filter((t) => !t.outputSchema).map((t) => t.name)
expect(missing).toEqual([])
})
it('every outputSchema is an object schema', () => {
for (const t of tools) {
expect(t.outputSchema, `tool ${t.name} outputSchema`).toBeDefined()
const schema = t.outputSchema as Record<string, unknown>
expect(schema.type, `tool ${t.name} outputSchema.type`).toBe('object')
}
})
it('every tool has a tight description (<= 280 chars)', () => {
const tooLong = tools.filter((t) => t.description.length > 280)
expect(tooLong.map((t) => `${t.name}: ${t.description.length} chars`)).toEqual([])
})
it('no description embeds Args:/Returns:/Examples: blocks (those belong to JSON Schema)', () => {
const verbose = tools.filter((t) =>
/Args:\s*\n|Returns JSON:|Examples:\s*\n|Errors:\s*\n/.test(t.description)
)
expect(verbose.map((t) => t.name)).toEqual([])
})
})
describe('annotation correctness', () => {
it('gnubok_feedback is not read-only (it writes a telemetry event + mutates the rate-limit map)', () => {
const feedback = tools.find((t) => t.name === 'gnubok_feedback')
expect(feedback).toBeDefined()
expect(feedback?.annotations?.readOnlyHint).toBe(false)
})
})
describe('nullable output properties stay declared nullable', () => {
// 2026-08-24 audit (sibling of the matched_supplier_id fix, PR #1842):
// these properties are emitted with null on real code paths (explicit
// `?? null`, or verbatim passthrough of a nullable column). A bare
// { type: 'string' } makes strict clients fail SUCCESSFUL calls, and the
// repeated "failures" trip the caller's circuit breaker. This test pins
// the declarations so a schema edit cannot silently regress them.
const NULLABLE: Array<[tool: string, prop: string]> = [
['gnubok_get_payslip', 'calculation_breakdown'],
['gnubok_get_document_content', 'mime_type'],
['gnubok_get_document_content', 'size_bytes'],
['gnubok_export_sie', 'company_name'],
['gnubok_export_sie', 'org_number'],
['gnubok_complete_document_upload', 'matched_supplier_id'],
['gnubok_upload_document', 'matched_supplier_id'],
]
it.each(NULLABLE)('%s.%s allows null and is not required', (toolName, prop) => {
const tool = tools.find((t) => t.name === toolName)
expect(tool, toolName).toBeDefined()
const schema = tool!.outputSchema as {
properties: Record<string, { type: unknown }>
required?: string[]
}
const declared = schema.properties[prop]
expect(declared, `${toolName}.${prop} must be declared (additionalProperties is false)`).toBeDefined()
expect(declared.type, `${toolName}.${prop}`).toContain('null')
expect(schema.required ?? []).not.toContain(prop)
})
it('gnubok_export_sie declares every key its execute returns', () => {
// The tool returned an undeclared org_number under
// additionalProperties: false, failing strict validation on EVERY call.
const tool = tools.find((t) => t.name === 'gnubok_export_sie')!
const props = Object.keys((tool.outputSchema as { properties: Record<string, unknown> }).properties)
for (const key of ['content', 'byte_size', 'fiscal_period_id', 'company_name', 'org_number', 'generated_at']) {
expect(props, key).toContain(key)
}
})
})