diff --git a/CHANGELOG.md b/CHANGELOG.md index de77c8a26..aae6e2bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +- **MCP search cursors keep lab results stable across pages.** Distinct analyte + names are ordered before offset pagination, preventing repeated or skipped + lab entries when a client follows `nextCursor`. + ## [1.31.7] — 2026-07-20 - **Current OpenAI reasoning models use their supported request contract.** diff --git a/src/lib/mcp/__tests__/search-fetch.test.ts b/src/lib/mcp/__tests__/search-fetch.test.ts index 092286234..c51889f04 100644 --- a/src/lib/mcp/__tests__/search-fetch.test.ts +++ b/src/lib/mcp/__tests__/search-fetch.test.ts @@ -120,6 +120,49 @@ describe("search", () => { expect(result.results.map((r) => r.id)).toContain("lab:LDL"); expect(result.results.some((r) => r.id.startsWith("med:"))).toBe(false); }); + it("keeps distinct lab analytes stable across cursor pages", async () => { + vi.mocked(buildCoachDataInventory).mockResolvedValue({ + entries: [], + window: "90d", + restMode: false, + cycleEnabled: false, + } as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([]); + + const analytes = Array.from({ length: 60 }, (_, index) => ({ + analyte: `Analyte ${String(index).padStart(2, "0")}`, + })); + let callCount = 0; + vi.mocked(prisma.labResult.findMany).mockImplementation((args) => { + const ordered = + (args as { orderBy?: { analyte?: string } }).orderBy?.analyte === "asc"; + const rows = + ordered || callCount++ % 2 === 0 ? analytes : [...analytes].reverse(); + return Promise.resolve(rows) as ReturnType< + typeof prisma.labResult.findMany + >; + }); + + const def = tool("search"); + const first = (await def.run(CTX, { query: "" })) as { + results: Array<{ id: string }>; + nextCursor?: string; + }; + const second = (await def.run(CTX, { + query: "", + cursor: first.nextCursor, + })) as { + results: Array<{ id: string }>; + }; + + const labIds = [...first.results, ...second.results] + .map((result) => result.id) + .filter((id) => id.startsWith("lab:")); + expect(new Set(labIds)).toHaveLength(60); + expect(prisma.labResult.findMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: { analyte: "asc" } }), + ); + }); }); describe("fetch", () => { diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index bee039104..729556d82 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -385,6 +385,7 @@ function searchAndFetchTools(): McpToolDefinition[] { where: { userId: ctx.userId, deletedAt: null }, select: { analyte: true }, distinct: ["analyte"], + orderBy: { analyte: "asc" }, take: SEARCH_RESULT_SCAN_CAP, }); for (const lab of labs) {