feat(mcp): paginate record lists and describe document inputs

This commit is contained in:
2026-09-14 13:17:37 +02:00
parent 58fc252110
commit 342ad9764e
8 changed files with 302 additions and 123 deletions

View File

@@ -0,0 +1,63 @@
import assert from "node:assert/strict"
import test from "node:test"
import { getTableName } from "drizzle-orm"
import { drizzle } from "drizzle-orm/pg-proxy"
import * as schema from "../db/schema"
import { mcpTools } from "../src/mcp/registry"
import { limitFromArgs, offsetFromArgs, paginatedResult } from "../src/mcp/pagination"
const tables = Object.values(schema).filter((value: any) => {
try { return typeof getTableName(value) === "string" } catch { return false }
})
for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any)?.offset)) {
test(`${tool.name}: retrieves 205 records across stable, bounded pages`, async () => {
const db = drizzle(async (sql, params) => {
if (sql.includes('from "tenants"')) return { rows: [["skr03"]] }
const tableName = sql.match(/from "(\w+)"/)?.[1]
const table = tables.find((table: any) => getTableName(table) === tableName) as any
assert.ok(table, sql)
assert.match(sql, /order by .*"id"/)
if (table.tenant || table.tenantId) {
assert.match(sql, /where .*"tenant(?:_id)?" = \$\d+/)
assert.ok(params.includes(7), "active tenant must be bound")
}
if (table.archived) assert.match(sql, /"archived" = \$\d+/)
const limitParam = sql.match(/limit \$(\d+)/)
const offsetParam = sql.match(/offset \$(\d+)/)
assert.ok(limitParam, "query must be bounded")
const limit = Number(params[Number(limitParam[1]) - 1])
const offset = offsetParam ? Number(params[Number(offsetParam[1]) - 1]) : 0
assert.equal(limit, 101, "fetch one extra row for hasMore")
const selected = sql.slice(7, sql.indexOf(" from ")).split(", ")
const idIndex = selected.findIndex((column) => /"id"$/.test(column))
assert.ok(idIndex >= 0)
return { rows: Array.from({ length: Math.min(limit, Math.max(0, 205 - offset)) }, (_, i) => {
const values = selected.map(() => null as unknown)
values[idIndex] = offset + i + 1
return values
}) }
}, { schema })
const context = { server: { db }, tenantId: 7, userId: "test" } as any
const ids: unknown[] = []
let offset = 0
for (let page = 0; page < 3; page++) {
const result: any = await tool.handler(context, { id: 1, limit: 100, offset })
assert.equal(result.rows.length, page < 2 ? 100 : 5)
assert.equal(result.pagination.hasMore, page < 2)
assert.equal(result.pagination.nextOffset, page < 2 ? offset + 100 : null)
ids.push(...result.rows.map((row: any) => Number(row.id)))
offset = result.pagination.nextOffset
}
assert.deepEqual(ids, Array.from({ length: 205 }, (_, i) => i + 1))
})
}
test("pagination validates input and handles empty and exactly full final pages", () => {
for (const limit of [-1, 0, 101, 1.5, "10", Infinity]) assert.throws(() => limitFromArgs({ limit }))
for (const offset of [-1, 1.5, "10", Infinity]) assert.throws(() => offsetFromArgs({ offset }))
assert.equal(limitFromArgs({}), 25)
assert.equal(limitFromArgs({}, 50), 50)
assert.equal(paginatedResult([1, 2], { limit: 2 }).pagination.nextOffset, null)
assert.deepEqual(paginatedResult([], { offset: 300 }).pagination, { limit: 25, offset: 300, hasMore: false, nextOffset: null })
})