feat(mcp): gnubok_list_cash_accounts, a search-only discovery tool for bank accounts (#1810)
Follow-up to #1809: transaction listings now carry cash_account_id, but an agent had no way to learn which cash accounts exist or which BAS ledger each maps to. The tool lists cash_accounts (cash_account_id, ledger_account, name, currency, iban, is_primary, enabled, source), optionally enabled only. Search-only (catalogVisibility 'search') so tools/list stays inside its context budget; gnubok_search_tools finds it on "bank account"/"cash account". Scope transactions:read. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof import('@/lib/cash-accounts/service')>('@/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<Record<string, unknown>>
|
||||
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 })
|
||||
})
|
||||
})
|
||||
@@ -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 ────────────────────────────────────
|
||||
|
||||
{
|
||||
|
||||
@@ -167,6 +167,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user