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:
Jakob Wennberg
2026-08-13 15:30:12 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 36393b1f8d
commit 7ae3477b36
6 changed files with 404 additions and 66 deletions
@@ -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] = []
+99 -45
View File
@@ -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' },