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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
36393b1f8d
commit
7ae3477b36
@@ -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({
|
||||
|
||||
@@ -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<AccountRow>(({ 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 })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, { accounts: unknown[] }> }
|
||||
|
||||
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')!
|
||||
|
||||
@@ -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<AccountSummary>(({ 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<number, AccountSummary[]> = {}
|
||||
for (const a of accounts) {
|
||||
if (!byClass[a.account_class]) byClass[a.account_class] = []
|
||||
|
||||
@@ -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<ChartAccountRow>(({ 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' },
|
||||
|
||||
Reference in New Issue
Block a user