fix: paginate MCP inbox items (#1329)

* fix: paginate MCP inbox items

* fix: validate MCP inbox cursors

* test: reset MCP inbox pagination state
This commit is contained in:
Mattsson
2026-08-01 18:50:42 +02:00
committed by GitHub
parent bcf919d995
commit bfbd926950
2 changed files with 223 additions and 27 deletions
+56 -2
View File
@@ -9167,6 +9167,12 @@ export const tools: McpTool[] = [
status: { type: 'string', enum: ['received', 'error'], description: 'Filter by status' },
unprocessed_only: { type: 'boolean', description: 'When true, only return items with no terminal link yet (not matched to a transaction, supplier invoice, or journal entry), i.e. documents that still need handling. Default false.' },
limit: { type: 'number', description: 'Max results (default 20, max 50)' },
cursor: {
type: 'string',
maxLength: 100,
pattern: '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})(?:__[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})?$',
description: 'Composite "<created_at>__<inbox_item_id>" from previous page (exclusive). Pass next_cursor verbatim.',
},
},
},
outputSchema: {
@@ -9187,6 +9193,7 @@ export const tools: McpTool[] = [
},
},
count: { type: 'number' },
next_cursor: { type: 'string', description: 'Pass as cursor on next call. Absent = no more pages.' },
},
required: ['items', 'count'],
},
@@ -9200,6 +9207,29 @@ export const tools: McpTool[] = [
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50)
const status = args.status as string | undefined
const unprocessedOnly = args.unprocessed_only === true
const cursor = typeof args.cursor === 'string' ? args.cursor : null
// Composite cursor: "<created_at>__<id>". Falls back to plain timestamp
// for backward compatibility with older callers.
let cursorTs: string | null = null
let cursorId: string | null = null
if (cursor) {
const sep = cursor.indexOf('__')
if (sep === -1) {
cursorTs = cursor
} else {
cursorTs = cursor.slice(0, sep)
cursorId = cursor.slice(sep + 2)
}
}
if (cursorTs && !z.string().datetime({ offset: true }).safeParse(cursorTs).success) {
throw new Error('Invalid cursor timestamp. Pass next_cursor verbatim.')
}
if (cursorId && !z.string().uuid().safeParse(cursorId).success) {
throw new Error('Invalid cursor inbox item ID. Pass next_cursor verbatim.')
}
const fetchSize = unprocessedOnly ? 200 : limit
let query = supabase
.from('invoice_inbox_items')
@@ -9210,11 +9240,19 @@ export const tools: McpTool[] = [
`)
.eq('company_id', companyId)
.order('created_at', { ascending: false })
.order('id', { ascending: false })
// Fetch a wider window when filtering client-side so the limit
// applies to the post-filter set rather than truncating before it.
.limit(unprocessedOnly ? 200 : limit)
.limit(fetchSize)
if (status) query = query.eq('status', status)
if (cursorTs && cursorId) {
query = query.or(
`created_at.lt.${cursorTs},and(created_at.eq.${cursorTs},id.lt.${cursorId})`
)
} else if (cursorTs) {
query = query.lt('created_at', cursorTs)
}
const { data, error } = await query
if (error) throw new Error(`Database error: ${error.message}`)
@@ -9268,7 +9306,23 @@ export const tools: McpTool[] = [
const filtered = unprocessedOnly ? mapped.filter((i) => !i.processed) : mapped
const items = filtered.slice(0, limit)
return { items, count: items.length }
// A full returned page continues after its last item. When client-side
// filtering yields a short page from a full scan window, continue after
// the last inspected row so older unprocessed items remain reachable.
let nextCursor: string | null = null
if (items.length === limit) {
const last = items[items.length - 1]
nextCursor = `${last.created_at}__${last.id}`
} else if (data && data.length === fetchSize) {
const last = data[data.length - 1]
nextCursor = `${last.created_at}__${last.id}`
}
return {
items,
count: items.length,
...(nextCursor ? { next_cursor: nextCursor } : {}),
}
},
},