diff --git a/src/lib/mcp/__tests__/__snapshots__/tools-list.write-session.snap b/src/lib/mcp/__tests__/__snapshots__/tools-list.write-session.snap index 1fc5a0e5f..7982c44bc 100644 --- a/src/lib/mcp/__tests__/__snapshots__/tools-list.write-session.snap +++ b/src/lib/mcp/__tests__/__snapshots__/tools-list.write-session.snap @@ -411,6 +411,74 @@ "grounding": { "type": "string" }, + "searchedWindow": { + "type": "string" + }, + "available": { + "type": "object", + "properties": { + "count": { + "type": "number" + }, + "firstDate": { + "type": "string" + }, + "lastDate": { + "type": "string" + }, + "series": { + "type": "array", + "items": { + "type": "object", + "properties": { + "series": { + "type": "string" + }, + "unit": { + "type": [ + "string", + "null" + ] + }, + "count": { + "type": "number" + }, + "mean": { + "type": "number" + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + } + }, + "required": [ + "series", + "unit", + "count", + "mean", + "min", + "max" + ], + "additionalProperties": false + } + }, + "reachableWithWindow": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "count", + "firstDate", + "lastDate", + "reachableWithWindow" + ], + "additionalProperties": false + }, "analyte": { "type": "string" }, diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 5b9d9a8c1..3de4475d4 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -510,6 +510,52 @@ describe("get_illness_recovery — condition body site", () => { }); describe("get_labs", () => { + it.each([{}, { analyte: "LDL" }])( + "declares empty-read availability for ordinary and filtered reads: %j", + async (args) => { + const { z } = await import("zod/v4"); + const empty = { + present: false, + reason: "out_of_window", + searchedWindow: "lastYear", + available: { + count: 2, + firstDate: "2020-01-01", + lastDate: "2020-01-02", + reachableWithWindow: null, + }, + }; + vi.mocked(executeCoachTool).mockResolvedValue(empty); + const def = tool("get_labs"); + const result = await def.run(CTX, args); + expect(executeCoachTool).toHaveBeenCalledWith({ + userId: CTX.userId, + name: "get_labs", + rawArguments: JSON.stringify(args), + }); + expect(z.object(def.outputShape!).strict().parse(result)).toEqual(empty); + }, + ); + + it("keeps output validation closed and availability typed", async () => { + const { z } = await import("zod/v4"); + const schema = z.object(tool("get_labs").outputShape!).strict(); + expect(z.toJSONSchema(schema).additionalProperties).toBe(false); + expect(schema.safeParse({ present: false, unexpected: true }).success).toBe( + false, + ); + expect( + schema.safeParse({ present: false, searchedWindow: 7 }).success, + ).toBe(false); + expect( + schema.safeParse({ present: false, available: { count: "2" } }).success, + ).toBe(false); + expect(schema.parse({ present: false, reason: "no_data" })).toEqual({ + present: false, + reason: "no_data", + }); + }); + it("forwards the optional analyte filter and returns readings with units + bands", async () => { vi.mocked(executeCoachTool).mockResolvedValue({ present: true, diff --git a/src/lib/mcp/tools/get-labs.ts b/src/lib/mcp/tools/get-labs.ts index 31f693772..a8fc47d29 100644 --- a/src/lib/mcp/tools/get-labs.ts +++ b/src/lib/mcp/tools/get-labs.ts @@ -4,16 +4,14 @@ import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve"; import { getLabHistory, LAB_HISTORY_MAX_LIMIT } from "@/lib/mcp/rich-reads"; import { decodeOffsetCursor } from "@/lib/mcp/pagination"; import type { McpAuthContext } from "../auth"; +import { coachReadOutput } from "./output-shapes"; import { runCoachTool, whenModuleOn } from "./shared"; import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types"; /** Output schema for `get_labs` — latest-per-biomarker OR a paginated history. */ const getLabsOutput: z.ZodRawShape = { - present: z.boolean(), - reason: z.string().optional(), - // Latest-mode payload (the Coach labs section, carried opaque). - data: z.unknown().optional(), - grounding: z.string().optional(), + // Latest-mode payload, including typed empty-read availability metadata. + ...coachReadOutput, // History-mode payload (per-analyte trajectory + pagination). analyte: z.string().optional(), readings: z