diff --git a/extensions/general/mcp-server/__tests__/list-cash-accounts.test.ts b/extensions/general/mcp-server/__tests__/list-cash-accounts.test.ts new file mode 100644 index 00000000..351d6822 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/list-cash-accounts.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys' + +vi.mock('@/lib/cash-accounts/service', async () => { + const actual = await vi.importActual('@/lib/cash-accounts/service') + return { ...actual, listForCompany: vi.fn() } +}) + +import { listForCompany } from '@/lib/cash-accounts/service' +import { tools, isDefaultCatalogTool } from '../server' + +const tool = tools.find((t) => t.name === 'gnubok_list_cash_accounts')! + +describe('gnubok_list_cash_accounts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('is a read-only, search-only transactions:read discovery tool', () => { + expect(tool).toBeDefined() + expect(TOOL_SCOPE_MAP.gnubok_list_cash_accounts).toBe('transactions:read') + expect(tool.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false, idempotentHint: true }) + expect(tool.catalogVisibility).toBe('search') + expect(isDefaultCatalogTool(tool)).toBe(false) + }) + + it('maps cash_accounts rows to the qualified wire shape, primary first', async () => { + vi.mocked(listForCompany).mockResolvedValue([ + { id: 'ca-1', company_id: 'company-1', ledger_account: '1930', name: 'Företagskonto', currency: 'SEK', iban: 'SE4550000000058398257466', is_primary: true, enabled: true, source: 'enable_banking' }, + { id: 'ca-2', company_id: 'company-1', ledger_account: '1940', name: null, currency: 'SEK', iban: null, is_primary: false, enabled: false, source: 'manual' }, + ] as never) + + const result = (await tool.execute({}, 'company-1', 'user-1', {} as never)) as { + cash_accounts: Array> + count: number + } + + expect(listForCompany).toHaveBeenCalledWith({}, 'company-1', { enabledOnly: false }) + expect(result.count).toBe(2) + expect(result.cash_accounts[0]).toEqual({ + cash_account_id: 'ca-1', + ledger_account: '1930', + name: 'Företagskonto', + currency: 'SEK', + iban: 'SE4550000000058398257466', + is_primary: true, + enabled: true, + source: 'enable_banking', + }) + expect(result.cash_accounts[1]).toMatchObject({ cash_account_id: 'ca-2', name: null, iban: null, enabled: false, source: 'manual' }) + // No bare `id` leaks onto the wire (qualified-ids convention). + expect(result.cash_accounts[0]).not.toHaveProperty('id') + }) + + it('passes enabled_only through', async () => { + vi.mocked(listForCompany).mockResolvedValue([]) + await tool.execute({ enabled_only: true }, 'company-1', 'user-1', {} as never) + expect(listForCompany).toHaveBeenCalledWith({}, 'company-1', { enabledOnly: true }) + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 7c0d8a1d..2c994049 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -74,6 +74,7 @@ import { } from '@/lib/reports/vat-filing-gate' import { findRcBasisGaps } from '@/lib/reports/rc-basis-gaps' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { listForCompany as listCashAccountsForCompany } from '@/lib/cash-accounts/service' import { looksLikeSwedishPersonalNumber, normalizeReroutedPersonalNumber, @@ -9817,6 +9818,71 @@ export const tools: McpTool[] = [ }, }, + + { + name: 'gnubok_list_cash_accounts', + title: 'List Cash Accounts', + description: 'List the company bank/cash accounts (cash_accounts): BAS ledger, currency, IBAN, primary flag. Use cash_account_id to filter transaction listings and account_number (ledger_account) for gnubok_get_reconciliation_status.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + enabled_only: { type: 'boolean', description: 'Only accounts that sync (default false)' }, + }, + }, + outputSchema: { + type: 'object', + additionalProperties: false, + properties: { + cash_accounts: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + cash_account_id: { type: 'string' }, + ledger_account: { type: 'string', description: 'BAS account, e.g. "1930"' }, + name: { type: ['string', 'null'] }, + currency: { type: 'string' }, + iban: { type: ['string', 'null'] }, + is_primary: { type: 'boolean' }, + enabled: { type: 'boolean' }, + source: { type: 'string', enum: ['enable_banking', 'manual', 'sie_import'] }, + }, + required: ['cash_account_id', 'ledger_account', 'name', 'currency', 'iban', 'is_primary', 'enabled', 'source'], + }, + }, + count: { type: 'number' }, + }, + required: ['cash_accounts', 'count'], + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + // Search-only: a discovery helper for the transaction listings and the + // reconciliation tool, not part of the default catalog (tools/list budget). + catalogVisibility: 'search', + async execute(args, companyId, _userId, supabase) { + const rows = await listCashAccountsForCompany(supabase, companyId, { + enabledOnly: args.enabled_only === true, + }) + const cashAccounts = rows.map((row) => ({ + cash_account_id: row.id, + ledger_account: row.ledger_account, + name: row.name ?? null, + currency: row.currency, + iban: row.iban ?? null, + is_primary: row.is_primary === true, + enabled: row.enabled !== false, + source: row.source, + })) + return { cash_accounts: cashAccounts, count: cashAccounts.length } + }, + }, + // ── Document Inbox Tools ──────────────────────────────────── { diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 0b96447b..b0fd3c47 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -167,6 +167,7 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_update_company_settings: 'companies:write', // Transactions gnubok_list_uncategorized_transactions: 'transactions:read', + gnubok_list_cash_accounts: 'transactions:read', gnubok_list_transactions_without_documents: 'transactions:read', gnubok_create_transactions: 'transactions:write', gnubok_categorize_transaction: 'transactions:write',