From 7ae3477b36bb0c3ec7c20530ec704499e1b27991 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 13 Aug 2026 15:30:12 +0200 Subject: [PATCH] fix(mcp): page list tools past PostgREST's silent 1000-row cap (#1572) gnubok_list_accounts returned exactly 1000 rows for a full BAS 2026 chart (1290 accounts) with no truncation signal: PostgREST caps un-ranged selects at 1000. Wrap the query in fetchAllRows, paging on the unique account_number and re-sorting by sort_order in JS so the visible order is unchanged. Same fix for gnubok_list_customers, gnubok_list_suppliers and gnubok_list_articles (paged on id, re-sorted by name), the Accounted://chart-of-accounts resource, and the REST v1 accounts.list route. All output schemas and registry metadata unchanged. Also note on gnubok_audit_package download_url that the signed URL points at the Supabase storage host, so restricted-egress proxies may 403; offset the added prose by trimming the same tool's own descriptions to keep the tools/list payload under the 59K ceiling. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .../accounts/__tests__/route.test.ts | 48 ++++++ .../companies/[companyId]/accounts/route.ts | 49 ++++-- .../__tests__/account-tools.test.ts | 161 ++++++++++++++++++ .../mcp-server/__tests__/resources.test.ts | 40 +++++ .../mcp-server/resources/chart-of-accounts.ts | 28 +-- extensions/general/mcp-server/server.ts | 144 +++++++++++----- 6 files changed, 404 insertions(+), 66 deletions(-) diff --git a/app/api/v1/companies/[companyId]/accounts/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/accounts/__tests__/route.test.ts index 100b4f72..9d9dd485 100644 --- a/app/api/v1/companies/[companyId]/accounts/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/accounts/__tests__/route.test.ts @@ -106,6 +106,54 @@ describe('GET /api/v1/companies/:companyId/accounts', () => { expect(body.data.accounts[0].account_number).toBe('1930') }) + it('returns all rows when the chart exceeds the 1000-row PostgREST page', async () => { + const makeAccount = (n: number, sortOrder: number | null = n) => ({ + account_number: String(n), + account_name: `Konto ${n}`, + account_class: Math.floor(n / 1000), + account_group: String(n).slice(0, 2), + account_type: 'asset', + normal_balance: 'debit', + is_system_account: false, + is_active: true, + description: null, + default_vat_code: null, + sru_code: null, + sort_order: sortOrder, + }) + // Page 1: exactly 1000 rows (forces a second range request). Account 1000 + // gets sort_order 99999 and account 9998 (page 2) a null sort_order, so the + // response order proves the sort_order re-sort with nulls last. + const page1 = [ + makeAccount(1000, 99999), + ...Array.from({ length: 999 }, (_, i) => makeAccount(1001 + i)), + ] + const page2 = [ + ...Array.from({ length: 289 }, (_, i) => makeAccount(2000 + i)), + makeAccount(9998, null), + ] + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + chart_of_accounts: [ + { data: page1, error: null }, + { data: page2, error: null }, + ], + }), + ) + const res = await listAccounts( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/accounts`), + { params: Promise.resolve({ companyId: COMPANY_ID }) }, + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.accounts).toHaveLength(1290) + expect(body.data.accounts[0].account_number).toBe('1001') + // sort_order 99999 lands second-to-last; null sort_order lands last. + expect(body.data.accounts[1288].account_number).toBe('1000') + expect(body.data.accounts[1289].account_number).toBe('9998') + }) + it('rejects invalid class filter', async () => { mockServiceClient.mockReturnValue( makeFlexibleSupabase({ diff --git a/app/api/v1/companies/[companyId]/accounts/route.ts b/app/api/v1/companies/[companyId]/accounts/route.ts index 910c7337..cab15a34 100644 --- a/app/api/v1/companies/[companyId]/accounts/route.ts +++ b/app/api/v1/companies/[companyId]/accounts/route.ts @@ -6,6 +6,7 @@ * sort_order: agents can render the BAS hierarchy directly from this. */ import { z } from 'zod' +import { fetchAllRows } from '@/lib/supabase/fetch-all' import { ok } from '@/lib/api/v1/response' import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' @@ -103,17 +104,43 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( const f = parsed.data const activeOnly = f.active !== 'false' - let query = ctx.supabase - .from('chart_of_accounts') - .select(ACCOUNT_COLUMNS) - .eq('company_id', ctx.companyId!) - .order('sort_order', { ascending: true }) + // Paginated (fetchAllRows): PostgREST silently caps un-ranged selects at + // 1000 rows and a full BAS 2026 chart holds ~1290 accounts. Paging is on + // the unique account_number (fetchAllRows ordering invariant); the rows + // are re-sorted by sort_order afterwards to keep the documented response + // order (the BAS canonical sequence). + type AccountRow = { + account_number: string + sort_order: number | null + [key: string]: unknown + } + let accounts: AccountRow[] + try { + accounts = await fetchAllRows(({ from, to }) => { + let query = ctx.supabase + .from('chart_of_accounts') + .select(ACCOUNT_COLUMNS) + .eq('company_id', ctx.companyId!) + if (activeOnly) query = query.eq('is_active', true) + if (f.class) query = query.eq('account_class', parseInt(f.class, 10)) + // The concatenated ACCOUNT_COLUMNS string defeats supabase-js's + // template-literal column parser, so the row type is asserted here. + return query.order('account_number', { ascending: true }).range(from, to) as unknown as PromiseLike<{ + data: AccountRow[] | null + error: { message: string } | null + }> + }) + } catch (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } - if (activeOnly) query = query.eq('is_active', true) - if (f.class) query = query.eq('account_class', parseInt(f.class, 10)) - - const { data, error } = await query - if (error) return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) - return ok({ accounts: data ?? [] }, { requestId: ctx.requestId }) + // Postgres ordered by sort_order ascending with nulls last; keep that + // visible order, tie-breaking on account_number for determinism. + accounts.sort( + (a, b) => + (a.sort_order ?? Number.MAX_SAFE_INTEGER) - (b.sort_order ?? Number.MAX_SAFE_INTEGER) || + a.account_number.localeCompare(b.account_number), + ) + return ok({ accounts }, { requestId: ctx.requestId }) }, ) diff --git a/extensions/general/mcp-server/__tests__/account-tools.test.ts b/extensions/general/mcp-server/__tests__/account-tools.test.ts index eb2fca07..1b458b60 100644 --- a/extensions/general/mcp-server/__tests__/account-tools.test.ts +++ b/extensions/general/mcp-server/__tests__/account-tools.test.ts @@ -183,6 +183,167 @@ describe('gnubok_create_account: staging behaviour (dry_run)', () => { }) }) +describe('list tools: PostgREST 1000-row cap (fetchAllRows paging)', () => { + const listAccounts = tools.find((t) => t.name === 'gnubok_list_accounts')! + const listCustomers = tools.find((t) => t.name === 'gnubok_list_customers')! + const listSuppliers = tools.find((t) => t.name === 'gnubok_list_suppliers')! + const listArticles = tools.find((t) => t.name === 'gnubok_list_articles')! + + function makeChartRow(n: number, sortOrder: number | null = n) { + return { + account_number: String(n), + account_name: `Konto ${n}`, + account_class: Math.floor(n / 1000), + account_group: String(n).slice(0, 2), + account_type: 'asset', + normal_balance: 'debit', + is_active: true, + description: null, + sort_order: sortOrder, + } + } + + it('gnubok_list_accounts returns all 1290 accounts across two pages, ordered on account_number', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + // Page 1: exactly PAGE_SIZE rows so fetchAllRows requests a second page. + // Account 1000 gets a null sort_order (custom account): the JS re-sort + // must put it last (Postgres nulls-last semantics). + const page1 = Array.from({ length: 1000 }, (_, i) => + makeChartRow(1000 + i, i === 0 ? null : 1000 + i), + ) + const page2 = Array.from({ length: 290 }, (_, i) => makeChartRow(2000 + i)) + enqueue({ data: page1 }) + enqueue({ data: page2 }) + + const result = (await listAccounts.execute({}, 'company-1', 'user-1', supabase as never)) as { + accounts: { account_number: string }[] + count: number + } + + expect(result.count).toBe(1290) + expect(result.accounts).toHaveLength(1290) + // Paging invariant: ordered on the UNIQUE account_number, two ranges. + expect(findCalls('chart_of_accounts', 'order')).toEqual([ + ['account_number', { ascending: true }], + ['account_number', { ascending: true }], + ]) + expect(findCalls('chart_of_accounts', 'range')).toEqual([ + [0, 999], + [1000, 1999], + ]) + // Visible order: sort_order ascending with nulls last, as before the fix. + expect(result.accounts[0].account_number).toBe('1001') + expect(result.accounts[1288].account_number).toBe('2289') + expect(result.accounts[1289].account_number).toBe('1000') + // sort_order was fetched only for the re-sort and must not leak out. + expect('sort_order' in result.accounts[0]).toBe(false) + }) + + it('gnubok_list_customers pages on id and re-sorts by name', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + // Names descend while ids ascend, so the output order proves the re-sort. + const makeCustomer = (i: number) => ({ + id: `c${String(i).padStart(4, '0')}`, + name: `Kund ${String(1002 - i).padStart(4, '0')}`, + }) + enqueue({ data: Array.from({ length: 1000 }, (_, i) => makeCustomer(i)) }) + enqueue({ data: Array.from({ length: 2 }, (_, i) => makeCustomer(1000 + i)) }) + + const result = (await listCustomers.execute({}, 'company-1', 'user-1', supabase as never)) as { + customers: { id: string; name: string }[] + count: number + } + + expect(result.count).toBe(1002) + expect(findCalls('customers', 'order')).toEqual([ + ['id', { ascending: true }], + ['id', { ascending: true }], + ]) + expect(findCalls('customers', 'range')).toEqual([ + [0, 999], + [1000, 1999], + ]) + expect(result.customers[0].name).toBe('Kund 0001') + expect(result.customers[1001].name).toBe('Kund 1002') + }) + + it('gnubok_list_suppliers pages on id and re-sorts by name', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + const makeSupplier = (i: number) => ({ + id: `s${String(i).padStart(4, '0')}`, + name: `Leverantör ${String(1002 - i).padStart(4, '0')}`, + }) + enqueue({ data: Array.from({ length: 1000 }, (_, i) => makeSupplier(i)) }) + enqueue({ data: Array.from({ length: 2 }, (_, i) => makeSupplier(1000 + i)) }) + + const result = (await listSuppliers.execute({}, 'company-1', 'user-1', supabase as never)) as { + suppliers: { id: string; name: string }[] + count: number + } + + expect(result.count).toBe(1002) + expect(findCalls('suppliers', 'order')).toEqual([ + ['id', { ascending: true }], + ['id', { ascending: true }], + ]) + expect(findCalls('suppliers', 'range')).toEqual([ + [0, 999], + [1000, 1999], + ]) + expect(result.suppliers[0].name).toBe('Leverantör 0001') + }) + + it('gnubok_list_articles pages on id and re-sorts by name', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + const makeArticle = (i: number) => ({ + id: `a${String(i).padStart(4, '0')}`, + name: `Artikel ${String(1002 - i).padStart(4, '0')}`, + }) + enqueue({ data: Array.from({ length: 1000 }, (_, i) => makeArticle(i)) }) + enqueue({ data: Array.from({ length: 2 }, (_, i) => makeArticle(1000 + i)) }) + + const result = (await listArticles.execute({}, 'company-1', 'user-1', supabase as never)) as { + articles: { id: string; name: string }[] + count: number + } + + expect(result.count).toBe(1002) + expect(findCalls('articles', 'order')).toEqual([ + ['id', { ascending: true }], + ['id', { ascending: true }], + ]) + expect(findCalls('articles', 'range')).toEqual([ + [0, 999], + [1000, 1999], + ]) + expect(result.articles[0].name).toBe('Artikel 0001') + }) + + it('gnubok_list_accounts returns a single short page unchanged', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: [makeChartRow(1930), makeChartRow(1910)] }) + + const result = (await listAccounts.execute( + { account_class: 1 }, + 'company-1', 'user-1', supabase as never, + )) as { accounts: { account_number: string }[]; count: number } + + expect(result.count).toBe(2) + // One page only: 2 < PAGE_SIZE stops the loop. + expect(findCalls('chart_of_accounts', 'range')).toEqual([[0, 999]]) + // Filters still applied inside the paged query builder. + expect(findCalls('chart_of_accounts', 'eq')).toEqual( + expect.arrayContaining([ + ['company_id', 'company-1'], + ['is_active', true], + ['account_class', 1], + ]), + ) + // Re-sorted by sort_order: 1910 before 1930. + expect(result.accounts.map((a) => a.account_number)).toEqual(['1910', '1930']) + }) +}) + describe('gnubok_update_account', () => { it('rejects a non-4-digit account number before any DB call', async () => { await expect( diff --git a/extensions/general/mcp-server/__tests__/resources.test.ts b/extensions/general/mcp-server/__tests__/resources.test.ts index b5e1fc1f..cccf1af3 100644 --- a/extensions/general/mcp-server/__tests__/resources.test.ts +++ b/extensions/general/mcp-server/__tests__/resources.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' import { dataResources, findResource, parseResourceQuery } from '../resources' describe('mcp resource registry', () => { @@ -45,6 +46,45 @@ describe('mcp resource registry', () => { }) }) +describe('chart-of-accounts resource', () => { + it('pages past the PostgREST 1000-row cap and reports the full total', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + const makeAccount = (n: number) => ({ + account_number: String(n), + account_name: `Konto ${n}`, + account_class: Math.floor(n / 1000), + account_type: 'asset', + normal_balance: 'debit', + is_active: true, + default_vat_code: null, + }) + // Page 1: exactly PAGE_SIZE class-1 rows; page 2: 290 class-2 rows. + enqueue({ data: Array.from({ length: 1000 }, (_, i) => makeAccount(1000 + i)) }) + enqueue({ data: Array.from({ length: 290 }, (_, i) => makeAccount(2000 + i)) }) + + const r = findResource('Accounted://chart-of-accounts')! + const result = (await r.read({ + supabase: supabase as never, + companyId: 'company-1', + userId: 'user-1', + scopes: [], + })) as { total: number; classes: Record } + + expect(result.total).toBe(1290) + expect(result.classes['1'].accounts).toHaveLength(1000) + expect(result.classes['2'].accounts).toHaveLength(290) + // Paging invariant: ordered on the unique account_number, two ranges. + expect(findCalls('chart_of_accounts', 'order')).toEqual([ + ['account_number', { ascending: true }], + ['account_number', { ascending: true }], + ]) + expect(findCalls('chart_of_accounts', 'range')).toEqual([ + [0, 999], + [1000, 1999], + ]) + }) +}) + describe('vat-treatments resource', () => { it('returns matrix for all customer types without DB access', async () => { const r = findResource('Accounted://settings/vat-treatments')! diff --git a/extensions/general/mcp-server/resources/chart-of-accounts.ts b/extensions/general/mcp-server/resources/chart-of-accounts.ts index 6913455b..6cf7b305 100644 --- a/extensions/general/mcp-server/resources/chart-of-accounts.ts +++ b/extensions/general/mcp-server/resources/chart-of-accounts.ts @@ -1,3 +1,4 @@ +import { fetchAllRows } from '@/lib/supabase/fetch-all' import type { McpResource } from './types' interface AccountSummary { @@ -16,18 +17,25 @@ export const chartOfAccountsResource: McpResource = { description: 'The active BAS chart of accounts for the current company, grouped by account class (1=assets, 2=liabilities/equity, 3=revenue, 4=COGS, 5-7=expenses, 8=financial). Use to look up account numbers before booking entries.', mimeType: 'application/json', read: async ({ supabase, companyId }) => { - const { data, error } = await supabase - .from('chart_of_accounts') - .select('account_number, account_name, account_class, account_type, normal_balance, is_active, default_vat_code') - .eq('company_id', companyId) - .order('account_number', { ascending: true }) - - if (error) { - throw new Error(`Failed to read chart of accounts: ${error.message}`) + // Paginated (fetchAllRows): PostgREST silently caps un-ranged selects at + // 1000 rows and a full BAS 2026 chart holds ~1290 accounts. account_number + // is unique per company, so it doubles as the stable paging order. + let accounts: AccountSummary[] + try { + accounts = await fetchAllRows(({ from, to }) => + supabase + .from('chart_of_accounts') + .select('account_number, account_name, account_class, account_type, normal_balance, is_active, default_vat_code') + .eq('company_id', companyId) + .order('account_number', { ascending: true }) + .range(from, to) + ) + } catch (error) { + throw new Error( + `Failed to read chart of accounts: ${error instanceof Error ? error.message : 'unknown error'}` + ) } - const accounts = (data ?? []) as AccountSummary[] - const byClass: Record = {} for (const a of accounts) { if (!byClass[a.account_class]) byClass[a.account_class] = [] diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 6570cb5b..320cce97 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -4400,15 +4400,24 @@ export const tools: McpTool[] = [ openWorldHint: false, }, async execute(_args, companyId, userId, supabase) { - const { data, error } = await supabase - .from('customers') - .select('id, name, customer_type, email, org_number, vat_number, default_payment_terms, city, country') - .eq('company_id', companyId) - .order('name') + // Paginated (fetchAllRows): PostgREST silently caps un-ranged selects at + // 1000 rows. Page on the unique id, then re-sort by name for display. + let customers: { id: string; name: string }[] + try { + customers = await fetchAllRows<{ id: string; name: string }>(({ from, to }) => + supabase + .from('customers') + .select('id, name, customer_type, email, org_number, vat_number, default_payment_terms, city, country') + .eq('company_id', companyId) + .order('id', { ascending: true }) + .range(from, to) + ) + } catch (error) { + throw new Error(`Database error: ${error instanceof Error ? error.message : 'unknown error'}`) + } + customers.sort((a, b) => a.name.localeCompare(b.name, 'sv') || a.id.localeCompare(b.id)) - if (error) throw new Error(`Database error: ${error.message}`) - - return { customers: data, count: data?.length ?? 0 } + return { customers, count: customers.length } }, }, @@ -4645,24 +4654,33 @@ export const tools: McpTool[] = [ openWorldHint: false, }, async execute(args, companyId, userId, supabase) { - let q = supabase - .from('articles') - .select('id, article_number, name, name_en, type, unit, price_excl_vat, vat_rate, revenue_account, housework_type, active') - .eq('company_id', companyId) - if (!args.include_inactive) q = q.eq('active', true) - // Strip PostgREST filter metacharacters before interpolating into .or(): // commas/parens would otherwise let a query inject extra or-conditions, and // the ILIKE wildcards % and _ would turn a stray char into a match-all. const raw = typeof args.query === 'string' ? args.query : '' const safe = raw.replace(/[%_,()\\*]/g, ' ').trim() - if (safe) { - q = q.or(`name.ilike.%${safe}%,article_number.ilike.%${safe}%`) - } - const { data, error } = await q.order('name') - if (error) throw new Error(`Database error: ${error.message}`) - return { articles: data, count: data?.length ?? 0 } + // Paginated (fetchAllRows): PostgREST silently caps un-ranged selects at + // 1000 rows. Page on the unique id, then re-sort by name for display. + let articles: { id: string; name: string }[] + try { + articles = await fetchAllRows<{ id: string; name: string }>(({ from, to }) => { + let q = supabase + .from('articles') + .select('id, article_number, name, name_en, type, unit, price_excl_vat, vat_rate, revenue_account, housework_type, active') + .eq('company_id', companyId) + if (!args.include_inactive) q = q.eq('active', true) + if (safe) { + q = q.or(`name.ilike.%${safe}%,article_number.ilike.%${safe}%`) + } + return q.order('id', { ascending: true }).range(from, to) + }) + } catch (error) { + throw new Error(`Database error: ${error instanceof Error ? error.message : 'unknown error'}`) + } + articles.sort((a, b) => a.name.localeCompare(b.name, 'sv') || a.id.localeCompare(b.id)) + + return { articles, count: articles.length } }, }, @@ -5859,15 +5877,24 @@ export const tools: McpTool[] = [ openWorldHint: false, }, async execute(_args, companyId, userId, supabase) { - const { data, error } = await supabase - .from('suppliers') - .select('id, name, supplier_type, email, phone, org_number, vat_number, default_expense_account, default_payment_terms, default_currency, city, country') - .eq('company_id', companyId) - .order('name', { ascending: true }) + // Paginated (fetchAllRows): PostgREST silently caps un-ranged selects at + // 1000 rows. Page on the unique id, then re-sort by name for display. + let suppliers: { id: string; name: string }[] + try { + suppliers = await fetchAllRows<{ id: string; name: string }>(({ from, to }) => + supabase + .from('suppliers') + .select('id, name, supplier_type, email, phone, org_number, vat_number, default_expense_account, default_payment_terms, default_currency, city, country') + .eq('company_id', companyId) + .order('id', { ascending: true }) + .range(from, to) + ) + } catch (error) { + throw new Error(`Database error: ${error instanceof Error ? error.message : 'unknown error'}`) + } + suppliers.sort((a, b) => a.name.localeCompare(b.name, 'sv') || a.id.localeCompare(b.id)) - if (error) throw new Error(`Database error: ${error.message}`) - - return { suppliers: data ?? [], count: data?.length ?? 0 } + return { suppliers, count: suppliers.length } }, }, @@ -6270,20 +6297,47 @@ export const tools: McpTool[] = [ const activeOnly = args.active_only !== false const accountClass = args.account_class as number | undefined - let query = supabase - .from('chart_of_accounts') - .select('account_number, account_name, account_class, account_group, account_type, normal_balance, is_active, description') - .eq('company_id', companyId) - .order('sort_order') + // Paginated (fetchAllRows): PostgREST silently caps un-ranged selects at + // 1000 rows and a full BAS 2026 chart holds ~1290 accounts. Paging is on + // the unique account_number (fetchAllRows ordering invariant); sort_order + // is fetched only to restore the BAS canonical display order afterwards, + // then stripped so the row shape stays unchanged. + interface ChartAccountRow { + account_number: string + account_name: string + account_class: number + account_group: string + account_type: string + normal_balance: string + is_active: boolean + description: string | null + sort_order: number | null + } + let rows: ChartAccountRow[] + try { + rows = await fetchAllRows(({ from, to }) => { + let query = supabase + .from('chart_of_accounts') + .select('account_number, account_name, account_class, account_group, account_type, normal_balance, is_active, description, sort_order') + .eq('company_id', companyId) + if (activeOnly) query = query.eq('is_active', true) + if (accountClass !== undefined) query = query.eq('account_class', accountClass) + return query.order('account_number', { ascending: true }).range(from, to) + }) + } catch (error) { + throw new Error(`Database error: ${error instanceof Error ? error.message : 'unknown error'}`) + } - if (activeOnly) query = query.eq('is_active', true) - if (accountClass !== undefined) query = query.eq('account_class', accountClass) + // Postgres ordered by sort_order ascending with nulls last; keep that + // visible order, tie-breaking on account_number for determinism. + rows.sort( + (a, b) => + (a.sort_order ?? Number.MAX_SAFE_INTEGER) - (b.sort_order ?? Number.MAX_SAFE_INTEGER) || + a.account_number.localeCompare(b.account_number) + ) + const accounts = rows.map(({ sort_order: _sortOrder, ...rest }) => rest) - const { data, error } = await query - - if (error) throw new Error(`Database error: ${error.message}`) - - return { accounts: data ?? [], count: data?.length ?? 0 } + return { accounts, count: accounts.length } }, }, @@ -12926,14 +12980,14 @@ export const tools: McpTool[] = [ { name: 'gnubok_audit_package', title: 'Generate Audit Package', - description: "Single-call audit package for a fiscal period: SIE-4 + reports (trial balance, income statement, balance sheet, general ledger, journal, VAT) + receipts + audit log + voucher gaps, zipped. 1-hour signed URL.", + description: "Single-call audit package for a fiscal period: SIE-4 + reports (trial balance, income statement, balance sheet, general ledger, journal, VAT) + receipts + audit log + voucher gaps, zipped.", inputSchema: { type: 'object', additionalProperties: false, properties: { - fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to package' }, - include_documents: { type: 'boolean', description: 'Include receipts/document binaries in the zip (default true)' }, - estimate_only: { type: 'boolean', description: 'Return size estimate without generating (default false)' }, + fiscal_period_id: { type: 'string', description: 'Fiscal period UUID' }, + include_documents: { type: 'boolean', description: 'Include receipt/document binaries (default true)' }, + estimate_only: { type: 'boolean', description: 'Size estimate only, no zip (default false)' }, }, required: ['fiscal_period_id'], }, @@ -12941,7 +12995,7 @@ export const tools: McpTool[] = [ type: 'object', additionalProperties: false, properties: { - download_url: { type: ['string', 'null'], description: 'Signed Supabase Storage URL valid for 1 hour. Null when estimate_only=true.' }, + download_url: { type: ['string', 'null'], description: 'Signed Supabase Storage URL, valid 1 hour; null when estimate_only=true. Restricted-egress proxies may 403; needs a network with Supabase egress.' }, storage_path: { type: ['string', 'null'] }, file_name: { type: 'string' }, size_bytes: { type: 'number' },