fix(mcp): say when a tool's list was cut

get_ecg_recordings stopped at 200, get_visits at 50, the booked
appointments in get_preventive_care at 25, and search at its per-source
scan, all without a word, so a model read the last row as the oldest there
is. capped() reads through listPage with the same semantics as the REST
list cap: one row past the limit, truncated true only when that row exists.

The ECG and visit results carry truncated and limit, the preventive-care
result carries appointmentsTruncated and appointmentsLimit, and search
carries truncated with the sources that were cut, on every page. Every
capped read orders by its id as a tiebreaker; list-page-guard now checks
capped() reads too, and accepts a distinct read ordered by its own key.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 14:06:43 +02:00
1 parent 08d731fca9
commit c402e23656
5 files changed
+325 -77

No files matched your search

+33 -4
View File
@@ -30,7 +30,12 @@ import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/** The modules allowed to cut a list by hand: the helper and its primitive. */
const LIST_MODULES = new Set(["lib/api/list-cap.ts", "lib/http/list-query.ts"]);
const LIST_MODULES = new Set([
"lib/api/list-cap.ts",
"lib/http/list-query.ts",
// The MCP tools' name for `listPage`; its reads are checked at its callers.
"lib/mcp/capped.ts",
]);
function stripComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
@@ -93,6 +98,16 @@ function endsInId(order: string, file: string): boolean {
return decl ? literal(decl[1]) : false;
}
/**
* A `distinct` read returns one row per key, so ordering by that key alone is
* already total: `distinct: ["analyte"]` with `orderBy: { analyte: "asc" }`.
*/
function ordersByDistinctKey(body: string, order: string): boolean {
const distinct = /\bdistinct\s*:\s*\[\s*["'](\w+)["']\s*\]/.exec(body);
if (!distinct) return false;
return new RegExp(`^\\{\\s*${distinct[1]}\\s*:[^,}]+\\}$`).test(order.trim());
}
describe("capped lists go through listPage", () => {
const files = walkSourceFiles(SRC, { floor: 3000 }).filter(
(file) =>
@@ -136,6 +151,12 @@ describe("capped lists go through listPage", () => {
listPage(limit, (take) => prisma.x.findMany({ where, orderBy, take }))`;
const [short] = callBodies(viaVariable, "listPage");
expect(endsInId(orderByValue(short)!, viaVariable)).toBe(true);
// A distinct read is ordered whole by its own key.
const distinctRead = `listPage(limit, (take) =>
prisma.x.findMany({ distinct: ["analyte"], orderBy: { analyte: "asc" }, take }))`;
const [dist] = callBodies(distinctRead, "listPage");
expect(ordersByDistinctKey(dist, orderByValue(dist)!)).toBe(true);
expect(ordersByDistinctKey(dist, '{ createdAt: "desc" }')).toBe(false);
const viaBadVariable = `const orderBy = [primaryOrder];
listPage(limit, (take) => prisma.x.findMany({ where, orderBy, take }))`;
const [bad] = callBodies(viaBadVariable, "listPage");
@@ -172,16 +193,24 @@ describe("capped lists go through listPage", () => {
expect(guessed).toEqual([]);
});
it("every listPage read orders by something that ends in the row id", () => {
it("every listPage or capped read orders by something that ends in the row id", () => {
const unordered: string[] = [];
let reads = 0;
for (const f of files) {
if (LIST_MODULES.has(f)) continue;
const code = read(f);
for (const body of callBodies(code, "listPage")) {
for (const body of [
...callBodies(code, "listPage"),
...callBodies(code, "capped"),
]) {
reads += 1;
const order = orderByValue(body);
if (!order || !endsInId(order, code)) unordered.push(f);
if (
!order ||
!(endsInId(order, code) || ordersByDistinctKey(body, order))
) {
unordered.push(f);
}
}
}
expect(reads).toBeGreaterThanOrEqual(18);