diff --git a/DECISIONS.md b/DECISIONS.md index c049b441..d8fa7e34 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -729,3 +729,9 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-01] Cloud Backup OAuth redirect URIs resolve from NEXT_PUBLIC_APP_URL, with request origin only as a self-hosted fallback: Google and Dropbox require pre-registered callbacks, so deriving them from an old alias or preview host can reject the flow before consent; one resolver keeps authorization, exchange, revoke, and sync origins consistent. [2026-08-01] Do not claim a fixed Supabase Docker revision: Accounted does not maintain a tested Supabase stack pin, so the self-hosting guide requires one complete immutable upstream tag or commit instead of naming an unverified version. + +[2026-08-01] gnubok_list_invoices and gnubok_list_recurring_schedules use the same offset, has_more, and next_offset contract as the other paginated MCP tools, with id as a descending tie-break after the existing business-date order. Returning has_more without an offset left rows beyond the first 100 unreachable, while the secondary order prevents equal invoice dates or creation timestamps from reshuffling between page requests. + +[2026-08-01] MCP page offsets are declared as non-negative integers and defensively floored before PostgREST range calls: fractional offsets cannot name a stable row boundary and can produce invalid range bounds when execution bypasses schema validation. + +[2026-08-01] Paginated MCP invoice tools fetch one lookahead row and use it when Supabase omits the exact count: returning a conservative next_offset avoids falsely declaring the current page terminal and silently truncating callers, while exact-count responses and page sizes remain unchanged. diff --git a/extensions/general/mcp-server/__tests__/paginated-list-continuation.test.ts b/extensions/general/mcp-server/__tests__/paginated-list-continuation.test.ts new file mode 100644 index 00000000..7d6b2e37 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/paginated-list-continuation.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { eventBus } from '@/lib/events/bus' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { tools } from '../server' + +const invoiceRow = (id: string) => ({ + id, + invoice_number: 'INV-100', + status: 'sent', + customer_id: 'customer-1', + total: 1250, + currency: 'SEK', + invoice_date: '2026-08-01', + due_date: '2026-08-31', + document_type: 'invoice', + default_dimensions: null, + customers: { name: 'Example Customer AB' }, +}) + +const scheduleRow = (id: string) => ({ + id, + name: 'Monthly support', + status: 'active', + customer_id: 'customer-1', + day_of_month: 25, + send_hour: 9, + payment_terms_days: 30, + currency: 'SEK', + auto_send: false, + default_dimensions: null, + next_run_date: '2026-08-25', + last_run_at: null, + last_invoice_id: null, + last_run_warning: null, + generated_count: 0, + customer: { name: 'Example Customer AB' }, + items: [], +}) + +const cases = [ + { + name: 'gnubok_list_invoices', + table: 'invoices', + itemsKey: 'invoices', + primaryOrder: 'invoice_date', + row: invoiceRow, + }, + { + name: 'gnubok_list_recurring_schedules', + table: 'recurring_invoice_schedules', + itemsKey: 'schedules', + primaryOrder: 'created_at', + row: scheduleRow, + }, +] as const + +beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() +}) + +describe.each(cases)('$name continuation', ({ name, table, itemsKey, primaryOrder, row }) => { + const tool = tools.find((candidate) => candidate.name === name)! + + it('advertises optional offset input and the complete pagination envelope', () => { + expect(tool).toBeDefined() + + const input = tool.inputSchema as { + required?: string[] + properties: Record + } + expect(input.properties.offset).toMatchObject({ type: 'integer', minimum: 0 }) + expect(input.required ?? []).not.toContain('offset') + + const output = tool.outputSchema as { required: string[]; properties: Record } + expect(output.required).toEqual(expect.arrayContaining([itemsKey, 'count', 'total_count', 'has_more'])) + expect(output.properties.next_offset).toBeDefined() + }) + + it('continues from offset and returns the next offset when more rows exist', async () => { + const { supabase, enqueue, findCall, findCalls } = createQueuedMockSupabase() + enqueue({ data: [row('row-5'), row('row-6')], error: null, count: 9 }) + + const result = (await tool.execute( + { limit: 2, offset: 4 }, + 'company-1', + 'user-1', + supabase as never, + )) as Record + + expect(findCall(table, 'range')).toEqual([4, 6]) + expect(findCalls(table, 'order')).toEqual([ + [primaryOrder, { ascending: false }], + ['id', { ascending: false }], + ]) + expect((result[itemsKey] as unknown[])).toHaveLength(2) + expect(result).toMatchObject({ + count: 2, + total_count: 9, + has_more: true, + next_offset: 6, + }) + }) + + it('preserves the first-page default and omits next_offset on the last page', async () => { + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + enqueue({ data: [row('only-row')], error: null, count: 1 }) + + const result = (await tool.execute( + {}, + 'company-1', + 'user-1', + supabase as never, + )) as Record + + expect(findCall(table, 'range')).toEqual([0, 50]) + expect(result).toMatchObject({ + count: 1, + total_count: 1, + has_more: false, + }) + expect(result).not.toHaveProperty('next_offset') + }) + + it('returns an empty terminal page', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: [], error: null, count: 0 }) + + const result = (await tool.execute( + { limit: 2, offset: 4 }, + 'company-1', + 'user-1', + supabase as never, + )) as Record + + expect(result[itemsKey]).toEqual([]) + expect(result).toMatchObject({ + count: 0, + total_count: 0, + has_more: false, + }) + expect(result).not.toHaveProperty('next_offset') + }) + + it('normalizes fractional offsets for direct execution', async () => { + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + enqueue({ data: [row('row-5')], error: null, count: 6 }) + + const result = (await tool.execute( + { limit: 2, offset: 4.9 }, + 'company-1', + 'user-1', + supabase as never, + )) as Record + + expect(findCall(table, 'range')).toEqual([4, 6]) + expect(result).toMatchObject({ + count: 1, + total_count: 6, + has_more: true, + next_offset: 5, + }) + }) + + it('uses a lookahead row when the exact count is unavailable', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: [row('row-5'), row('row-6'), row('lookahead-row')], error: null, count: null }) + + const result = (await tool.execute( + { limit: 2, offset: 4 }, + 'company-1', + 'user-1', + supabase as never, + )) as Record + + expect(result[itemsKey]).toHaveLength(2) + expect(result).toMatchObject({ + count: 2, + total_count: 7, + has_more: true, + next_offset: 6, + }) + }) + + it('reports database errors', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: { message: 'connection reset' }, count: null }) + + await expect(tool.execute( + {}, + 'company-1', + 'user-1', + supabase as never, + )).rejects.toThrow('Database error: connection reset') + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 8e81d9fc..59e0b20d 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -4588,6 +4588,7 @@ export const tools: McpTool[] = [ description: 'Filter by invoice status', }, limit: { type: 'number', description: 'Max results (default 50, max 100)' }, + offset: { type: 'integer', minimum: 0, description: 'Number of results to skip for pagination (default 0)' }, }, }, outputSchema: paginatedSchema('invoices', { type: 'object' }), @@ -4599,6 +4600,7 @@ export const tools: McpTool[] = [ }, async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 50), 100) + const offset = Math.max(0, Math.floor(Number(args.offset) || 0)) const status = args.status as string | undefined let query = supabase @@ -4612,11 +4614,13 @@ export const tools: McpTool[] = [ const { data, error, count } = await query .order('invoice_date', { ascending: false }) - .limit(limit) + .order('id', { ascending: false }) + .range(offset, offset + limit) if (error) throw new Error(`Database error: ${error.message}`) - const invoices = (data ?? []).map((inv: Record) => ({ + const rows = data ?? [] + const invoices = rows.slice(0, limit).map((inv: Record) => ({ id: inv.id, invoice_number: inv.invoice_number, status: inv.status, @@ -4629,10 +4633,17 @@ export const tools: McpTool[] = [ default_dimensions: inv.default_dimensions ?? {}, })) + const hasMore = count == null + ? rows.length > limit + : offset + invoices.length < count + const total = count ?? offset + invoices.length + (hasMore ? 1 : 0) + return { invoices, count: invoices.length, - total_count: count ?? invoices.length, + total_count: total, + has_more: hasMore, + ...(hasMore ? { next_offset: offset + invoices.length } : {}), } }, }, @@ -15007,6 +15018,7 @@ export const tools: McpTool[] = [ description: 'Filter by schedule status', }, limit: { type: 'number', description: 'Max results (default 50, max 100)' }, + offset: { type: 'integer', minimum: 0, description: 'Number of results to skip for pagination (default 0)' }, }, }, outputSchema: paginatedSchema('schedules', { @@ -15057,6 +15069,7 @@ export const tools: McpTool[] = [ catalogVisibility: 'search', async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 50), 100) + const offset = Math.max(0, Math.floor(Number(args.offset) || 0)) const status = args.status as string | undefined let query = supabase @@ -15073,11 +15086,13 @@ export const tools: McpTool[] = [ const { data, error, count } = await query .order('created_at', { ascending: false }) - .limit(limit) + .order('id', { ascending: false }) + .range(offset, offset + limit) if (error) throw new Error(`Database error: ${error.message}`) - const schedules = (data ?? []).map((row: Record) => { + const rows = data ?? [] + const schedules = rows.slice(0, limit).map((row: Record) => { const items = ((row.items as Array>) ?? []) .slice() .sort((a, b) => Number(a.sort_order) - Number(b.sort_order)) @@ -15113,10 +15128,17 @@ export const tools: McpTool[] = [ } }) + const hasMore = count == null + ? rows.length > limit + : offset + schedules.length < count + const total = count ?? offset + schedules.length + (hasMore ? 1 : 0) + return { schedules, count: schedules.length, - total_count: count ?? schedules.length, + total_count: total, + has_more: hasMore, + ...(hasMore ? { next_offset: offset + schedules.length } : {}), } }, },