diff --git a/src/app/mcp/__tests__/route.test.ts b/src/app/mcp/__tests__/route.test.ts index 80d5ed9a2..738817f6a 100644 --- a/src/app/mcp/__tests__/route.test.ts +++ b/src/app/mcp/__tests__/route.test.ts @@ -93,6 +93,10 @@ const READ_TOOLS = [ "get_medication_schedule", "get_integration_status", "get_preventive_care", + // v1.30 coverage review — nutrients, intraday pulse, ECG metadata. + "get_nutrients", + "get_intraday_pulse", + "get_ecg_recordings", ].sort(); /** A valid, narrow-scope (`health:read`) token resolution. */ diff --git a/src/lib/mcp/__tests__/resources.test.ts b/src/lib/mcp/__tests__/resources.test.ts index 10a7a6eef..cf55c8b57 100644 --- a/src/lib/mcp/__tests__/resources.test.ts +++ b/src/lib/mcp/__tests__/resources.test.ts @@ -10,10 +10,20 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/doctor-report-data", () => ({ collectDoctorReportData: vi.fn(), })); +vi.mock("@/lib/ai/coach/tools/inventory", () => ({ + buildCoachDataInventory: vi.fn(), +})); +// v1.30 (G5/C4) — `measurements-inventory` appends the metric-status-only +// discovery rows; stub the engine so this file never reaches a real DB read. +vi.mock("@/lib/mcp/rich-reads", () => ({ + metricStatusDiscoveryRows: vi.fn(async () => []), +})); import { MCP_RESOURCES, MCP_RESOURCE_URIS } from "../resources"; import { prisma } from "@/lib/db"; import { collectDoctorReportData } from "@/lib/doctor-report-data"; +import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory"; +import { metricStatusDiscoveryRows } from "@/lib/mcp/rich-reads"; import type { McpAuthContext } from "../auth"; const CTX: McpAuthContext = { @@ -139,6 +149,45 @@ describe("healthlog://medications", () => { }); }); +describe("healthlog://measurements/inventory", () => { + it("merges the Coach inventory with the metric-status-only discovery rows (G5/C4)", async () => { + vi.mocked(buildCoachDataInventory).mockResolvedValue({ + entries: [ + { + tool: "get_metric_series", + metric: "weight", + domain: "weight", + present: true, + count: 5, + }, + ], + restMode: false, + cycleEnabled: false, + window: "last30days", + probeScope: { sources: [] }, + } as never); + vi.mocked(metricStatusDiscoveryRows).mockResolvedValue([ + { + tool: "compare_metric", + domain: "Wrist temperature", + present: false, + metric: "WRIST_TEMPERATURE", + }, + ] as never); + + const result = (await resource("healthlog://measurements/inventory").read( + CTX, + )) as { present: boolean; metrics: Array> }; + + expect(metricStatusDiscoveryRows).toHaveBeenCalledWith("user-1"); + expect(result.present).toBe(true); + expect(result.metrics).toHaveLength(2); + expect( + result.metrics.find((m) => m.metric === "WRIST_TEMPERATURE"), + ).toMatchObject({ present: false }); + }); +}); + describe("healthlog://report/doctor-visit", () => { // The resource now reuses the SHARED summariser (doctor-visit-summary.ts), so // it grounds the identical rich superset the doctor_visit_summary prompt does diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts index 91460c6cc..f60b47b95 100644 --- a/src/lib/mcp/__tests__/rich-reads.test.ts +++ b/src/lib/mcp/__tests__/rich-reads.test.ts @@ -17,10 +17,9 @@ vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => { }); const { labResult, measurement } = vi.hoisted(() => ({ labResult: { findMany: vi.fn() }, - // v1.30.4 (G4) — `withHrvFallback`'s presence probe. Unused by every - // metric that doesn't carry a `fallbackMeasurementType` (everything but - // HRV today); those tests never touch this mock. - measurement: { count: vi.fn() }, + // `withHrvFallback`'s presence probe (`count`) + the discovery presence + // query (`groupBy`). Unused by metrics that don't exercise them. + measurement: { count: vi.fn(), groupBy: vi.fn(async () => []) }, })); vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement } })); @@ -31,6 +30,8 @@ import { detectChangepoints, getLabHistory, resolveRichMetric, + MCP_METRIC_STATUS_DISCOVERY, + metricStatusDiscoveryRows, } from "../rich-reads"; import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; @@ -602,3 +603,66 @@ describe("get_lab_history", () => { expect(res.reason).toBe("analyte_not_found"); }); }); + +// ── v1.30 coverage review (G5/C4) — metric-status-only discovery ──────── +describe("MCP_METRIC_STATUS_DISCOVERY", () => { + it("carries the reviewed metric-status-only allowlist with resolved units/labels", () => { + const keys = MCP_METRIC_STATUS_DISCOVERY.map((s) => s.key).sort(); + expect(keys).toEqual( + [ + "WRIST_TEMPERATURE", + "CARDIO_RECOVERY", + "SLEEP_SCORE", + "BREATHING_DISTURBANCES", + "ANS_CHARGE", + "DAY_STRAIN", + "WORKOUT_STRAIN", + "CARDIO_LOAD", + "FALL_COUNT", + "SIX_MINUTE_WALK_DISTANCE", + "STAIR_ASCENT_SPEED", + "STAIR_DESCENT_SPEED", + "ENERGY_EXPENDITURE_KJ", + ].sort(), + ); + const wristTemp = MCP_METRIC_STATUS_DISCOVERY.find( + (s) => s.key === "WRIST_TEMPERATURE", + ); + expect(wristTemp?.measurementType).toBe("WRIST_TEMPERATURE"); + expect(wristTemp?.label).toBe("Wrist temperature"); + }); + + it("every discovery id is ALSO resolvable via resolveRichMetric (compare_metric/baseline stay reachable)", () => { + for (const sig of MCP_METRIC_STATUS_DISCOVERY) { + expect(resolveRichMetric(sig.key)?.measurementType).toBe( + sig.measurementType, + ); + } + }); +}); + +describe("metricStatusDiscoveryRows", () => { + it("reports present:true + count for a logged id, present:false for the rest", async () => { + measurement.groupBy.mockResolvedValue([ + { type: "WRIST_TEMPERATURE", _count: { _all: 7 } }, + ] as never); + const rows = await metricStatusDiscoveryRows(USER); + expect(rows).toHaveLength(MCP_METRIC_STATUS_DISCOVERY.length); + const wristTemp = rows.find((r) => r.metric === "WRIST_TEMPERATURE"); + expect(wristTemp).toMatchObject({ + tool: "compare_metric", + domain: "Wrist temperature", + present: true, + count: 7, + }); + const untouched = rows.find((r) => r.metric === "DAY_STRAIN"); + expect(untouched).toMatchObject({ present: false }); + expect(untouched).not.toHaveProperty("count"); + }); + + it("reports present:false for every id when nothing is logged", async () => { + measurement.groupBy.mockResolvedValue([]); + const rows = await metricStatusDiscoveryRows(USER); + expect(rows.every((r) => r.present === false)).toBe(true); + }); +}); diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 401e65eef..31936bee4 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -115,10 +115,19 @@ vi.mock("../rich-reads", () => ({ label: "Grip strength", }, ], + // v1.30 (G5/C4) — the metric-status-only discovery allowlist. + MCP_METRIC_STATUS_DISCOVERY: [ + { + key: "WRIST_TEMPERATURE", + measurementType: "WRIST_TEMPERATURE", + label: "Wrist skin temperature", + }, + ], + metricStatusDiscoveryRows: vi.fn(async () => []), })); import { MCP_TOOLS, MCP_TOOL_NAMES } from "../tools"; -import { getMetricBaseline } from "../rich-reads"; +import { getMetricBaseline, metricStatusDiscoveryRows } from "../rich-reads"; import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory"; import { prisma } from "@/lib/db"; @@ -267,6 +276,42 @@ describe("list_metrics", () => { expect(result.window).toBe("last30days"); expect(result.metrics).toHaveLength(2); }); + + it("appends the v1.30 metric-status-only discovery rows (G5/C4) alongside the Coach inventory", async () => { + vi.mocked(buildCoachDataInventory).mockResolvedValue({ + entries: [ + { + tool: "get_metric_series", + metric: "weight", + domain: "weight", + present: true, + count: 5, + }, + ], + restMode: false, + cycleEnabled: false, + window: "last30days", + probeScope: { sources: [], window: "last30days" }, + } as never); + vi.mocked(metricStatusDiscoveryRows).mockResolvedValue([ + { + tool: "compare_metric", + domain: "Wrist skin temperature", + present: true, + count: 3, + metric: "WRIST_TEMPERATURE", + }, + ] as never); + + const result = (await tool("list_metrics").run(CTX, {})) as { + metrics: Array>; + }; + expect(metricStatusDiscoveryRows).toHaveBeenCalledWith("user-1"); + expect(result.metrics).toHaveLength(2); + expect( + result.metrics.find((m) => m.metric === "WRIST_TEMPERATURE"), + ).toMatchObject({ tool: "compare_metric", present: true }); + }); }); describe("get_metric_series", () => { @@ -701,6 +746,49 @@ describe("v1.25 clinical signals on the MCP surface", () => { }); }); +describe("metric-status-only discovery on search/fetch (v1.30 coverage review G5/C4)", () => { + beforeEach(() => { + vi.mocked(buildCoachDataInventory).mockResolvedValue({ + entries: [], + restMode: false, + cycleEnabled: false, + window: "last30days", + probeScope: { sources: [] }, + } as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); + }); + + it("search surfaces a present metric-status-only id (undiscoverable before this wave)", async () => { + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ + { type: "WRIST_TEMPERATURE" }, + ] as never); + const result = (await tool("search").run(CTX, { query: "wrist" })) as { + results: Array<{ id: string; title: string }>; + }; + const hit = result.results.find((r) => r.id === "metric:WRIST_TEMPERATURE"); + expect(hit).toBeDefined(); + }); + + it("fetch hydrates a metric-status-only id via the baseline read", async () => { + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); + vi.mocked(getMetricBaseline).mockResolvedValue({ + present: true, + metric: "Wrist skin temperature", + unit: "°C", + latest: 33.2, + } as never); + const result = (await tool("fetch").run(CTX, { + id: "metric:WRIST_TEMPERATURE", + })) as Record; + expect(getMetricBaseline).toHaveBeenCalledWith("user-1", { + metric: "WRIST_TEMPERATURE", + }); + expect(executeCoachTool).not.toHaveBeenCalled(); + expect(result.title).toBe("Wrist skin temperature"); + }); +}); + describe("get_nutrients — v1.30 coverage review (G1)", () => { it("forwards the optional nutrient + days args to the engine and returns its result verbatim", async () => { vi.mocked(getNutrients).mockResolvedValue({ diff --git a/src/lib/mcp/resources.ts b/src/lib/mcp/resources.ts index c13c9b5ea..f2d0d675c 100644 --- a/src/lib/mcp/resources.ts +++ b/src/lib/mcp/resources.ts @@ -37,6 +37,7 @@ import { collectDoctorReportData } from "@/lib/doctor-report-data"; import { isModuleEnabled } from "@/lib/modules/gate"; import { NUTRIENT_CODES, type NutrientCode } from "@/lib/nutrients/catalog"; import { getNutrients, NUTRIENT_LABELS } from "@/lib/mcp/nutrients-read"; +import { metricStatusDiscoveryRows } from "@/lib/mcp/rich-reads"; import { summariseForVisit } from "./doctor-visit-summary"; import type { McpAuthContext } from "./auth"; @@ -241,7 +242,12 @@ export const MCP_RESOURCES: McpResourceDefinition[] = [ "What health data the user has and which tool retrieves each domain — one row per domain with presence + approximate sample count. The same manifest list_metrics returns; read this first to discover what is available before fetching figures.", mimeType: "application/json", async read(ctx) { - const inventory = await buildCoachDataInventory(ctx.userId, undefined); + const [inventory, statusDiscoveryRows] = await Promise.all([ + buildCoachDataInventory(ctx.userId, undefined), + // v1.30 coverage review (G5/C4) — mirrors the same supplement rows + // `list_metrics` appends, so the two discovery surfaces stay in sync. + metricStatusDiscoveryRows(ctx.userId), + ]); annotate({ action: { name: "mcp.resource.read" }, meta: { resource: "measurements-inventory", present: true }, @@ -251,7 +257,7 @@ export const MCP_RESOURCES: McpResourceDefinition[] = [ window: inventory.window, restMode: inventory.restMode, cycleEnabled: inventory.cycleEnabled, - metrics: inventory.entries, + metrics: [...inventory.entries, ...statusDiscoveryRows], }; }, }, diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index 6849f4858..73eb7016d 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -42,6 +42,7 @@ import { import { getMetricStatusMeta, METRIC_STATUS_IDS, + type MetricStatusMetricId, } from "@/lib/insights/metric-status-registry"; import { allSignals, getSignal } from "@/lib/signals/registry"; import { classifyReferenceRange } from "@/lib/labs/reference-range"; @@ -367,6 +368,103 @@ function resolveClinicalSignal(key: string): RichMetric | null { return null; } +// ── v1.30 coverage review (G5/C4) — metric-status-only discovery ───── +// +// `resolveRichMetric` already resolves any `metric-status-registry` id via an +// exact-id or display-name match (steps 3–4 above), so `compare_metric` / +// `get_metric_baseline` / `detect_changepoints` CAN serve these metrics today. +// But none of them is in the Coach data inventory (`list_metrics`, the +// `measurements-inventory` resource) or the `search` probe, so a +// discover-before-fetch assistant — which the server's own instructions +// direct — never learns they exist. The v1.28.52 dashboard-tile wave +// surfaced several of these ("collected but unsurfaced") in the app; the MCP +// wire still had that gap. +// +// Deliberately a fixed, reviewed allowlist (not derived from `surfaces.mcp` — +// reconciling that flag against this resolution path is a separate follow-up) +// so no metric becomes discoverable here without a human having named it. + +const METRIC_STATUS_DISCOVERY_IDS = [ + "WRIST_TEMPERATURE", + "CARDIO_RECOVERY", + "SLEEP_SCORE", + "BREATHING_DISTURBANCES", + "ANS_CHARGE", + "DAY_STRAIN", + "WORKOUT_STRAIN", + "CARDIO_LOAD", + "FALL_COUNT", + "SIX_MINUTE_WALK_DISTANCE", + "STAIR_ASCENT_SPEED", + "STAIR_DESCENT_SPEED", + "ENERGY_EXPENDITURE_KJ", +] as const satisfies readonly MetricStatusMetricId[]; + +/** + * The metric-status-only ids `list_metrics` / the inventory resource / + * `search` can now discover: the registry id (also the `metric:` id `fetch` + * resolves), the backing `MeasurementType` (the presence probe), and the + * display label. Mirrors the shape of `MCP_CLINICAL_SIGNALS`. + */ +export const MCP_METRIC_STATUS_DISCOVERY: ReadonlyArray<{ + key: MetricStatusMetricId; + measurementType: MeasurementType; + label: string; +}> = METRIC_STATUS_DISCOVERY_IDS.map((id) => { + const meta = getMetricStatusMeta(id); + if (!meta) { + // Would only trip if the registry ever dropped one of these ids — fail + // loudly at module load rather than silently under-advertising. + throw new Error(`metric-status registry is missing discovery id ${id}`); + } + return { + key: id, + measurementType: meta.measurementType, + label: meta.displayName, + }; +}); + +/** + * Presence + approximate sample count for the metric-status-only discovery + * set, in the `list_metrics` inventory-row shape — `tool` fixed to + * `compare_metric` (a resolver-closed metric with no Coach-snapshot series + * fetches through `compare_metric` / `get_metric_baseline`, never + * `get_metric_series`). One grouped presence query, mirroring the + * `MCP_CLINICAL_SIGNALS` `search` probe. `list_metrics` and the + * `measurements-inventory` resource both append these rows to their own + * inventory so a discover-before-fetch assistant can find a metric that IS + * resolvable but sits off the Coach snapshot. + */ +export async function metricStatusDiscoveryRows(userId: string): Promise< + Array<{ + tool: string; + domain: string; + present: boolean; + count?: number; + metric: string; + }> +> { + const rows = await prisma.measurement.groupBy({ + by: ["type"], + where: { + userId, + type: { in: MCP_METRIC_STATUS_DISCOVERY.map((s) => s.measurementType) }, + }, + _count: { _all: true }, + }); + const countByType = new Map(rows.map((r) => [r.type, r._count._all])); + return MCP_METRIC_STATUS_DISCOVERY.map((sig) => { + const count = countByType.get(sig.measurementType); + return { + tool: "compare_metric", + domain: sig.label, + present: count !== undefined, + ...(count !== undefined ? { count } : {}), + metric: sig.key, + }; + }); +} + /** * Resolve a free-text metric name to a single scalar series. Forgiving for an * NL assistant (alias, exact id, display-name match) but closed — an diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index a1b91702c..cc1ae9145 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -38,6 +38,8 @@ import { getLabHistory, LAB_HISTORY_MAX_LIMIT, MCP_CLINICAL_SIGNALS, + MCP_METRIC_STATUS_DISCOVERY, + metricStatusDiscoveryRows, type DateRange, } from "@/lib/mcp/rich-reads"; import { @@ -384,20 +386,27 @@ function searchAndFetchTools(): McpToolDefinition[] { }); } - // v1.25 clinical signals (grip strength, pain NRS, waist / WHtR) — off - // the Coach data inventory by design, so they are surfaced here directly. - // One grouped presence probe over their backing measurement types; - // present-only, in a stable (allowlist) order. `fetch metric:` - // hydrates each via the rollup-backed baseline read. - const clinicalPresent = await prisma.measurement.groupBy({ + // v1.25 clinical signals (grip strength, pain NRS, waist / WHtR) PLUS + // the v1.30 coverage-review metric-status-only set (wrist + // temperature, cardio recovery, sleep score, breathing disturbances, + // strain/load, …) — both sit off the Coach data inventory by design, + // so they are surfaced here directly. One grouped presence probe over + // the combined backing measurement types; present-only, in a stable + // (allowlist) order. `fetch metric:` hydrates each via the + // rollup-backed baseline read. + const discoverableSignals = [ + ...MCP_CLINICAL_SIGNALS, + ...MCP_METRIC_STATUS_DISCOVERY, + ]; + const discoverablePresent = await prisma.measurement.groupBy({ by: ["type"], where: { userId: ctx.userId, - type: { in: MCP_CLINICAL_SIGNALS.map((s) => s.measurementType) }, + type: { in: discoverableSignals.map((s) => s.measurementType) }, }, }); - const presentTypes = new Set(clinicalPresent.map((r) => r.type)); - for (const sig of MCP_CLINICAL_SIGNALS) { + const presentTypes = new Set(discoverablePresent.map((r) => r.type)); + for (const sig of discoverableSignals) { if (!presentTypes.has(sig.measurementType)) continue; const hay = `${sig.label} ${sig.key}`.toLowerCase(); if (query && !hay.includes(query)) continue; @@ -483,22 +492,24 @@ function searchAndFetchTools(): McpToolDefinition[] { }); if (kind === "metric" && rid) { - // v1.25 clinical signals sit off the Coach snapshot, so hydrate them - // through the rollup-backed baseline read rather than the Coach + // v1.25 clinical signals AND the v1.30 metric-status-only discovery + // set both sit off the Coach snapshot, so hydrate them through the + // rollup-backed baseline read rather than the Coach // `get_metric_series` path (which would report no data for them). - const clinical = MCP_CLINICAL_SIGNALS.find( - (s) => s.key.toLowerCase() === rid.toLowerCase(), - ); - if (clinical) { + const discoverable = [ + ...MCP_CLINICAL_SIGNALS, + ...MCP_METRIC_STATUS_DISCOVERY, + ].find((s) => s.key.toLowerCase() === rid.toLowerCase()); + if (discoverable) { const baseline = await getMetricBaseline(ctx.userId, { - metric: clinical.key, + metric: discoverable.key, }); return { id, - title: clinical.label, - text: plainClinicalText(clinical.label, baseline), - url: `${origin}/insights?metric=${encodeURIComponent(clinical.key)}`, - metadata: { type: "metric", metric: clinical.key }, + title: discoverable.label, + text: plainClinicalText(discoverable.label, baseline), + url: `${origin}/insights?metric=${encodeURIComponent(discoverable.key)}`, + metadata: { type: "metric", metric: discoverable.key }, }; } const result = await executeCoachTool({ @@ -972,7 +983,18 @@ export const MCP_TOOLS: McpToolDefinition[] = [ annotations: READ_ONLY_ANNOTATIONS, outputShape: listMetricsOutput, async run(ctx) { - const inventory = await buildCoachDataInventory(ctx.userId, undefined); + const [inventory, statusDiscoveryRows] = await Promise.all([ + buildCoachDataInventory(ctx.userId, undefined), + // v1.30 coverage review (G5/C4) — the metric-status-only set + // (wrist temperature, cardio recovery, sleep score, breathing + // disturbances, strain/load, …) is resolvable via `compare_metric` / + // `get_metric_baseline` but sits off the Coach snapshot inventory, so + // it never advertised itself here. Appended as supplement rows + // rather than folded into `buildCoachDataInventory` (Coach-owned) + // to keep the addition MCP-side, mirroring how the `search` probe + // already surfaces the sibling v1.25 clinical-signal set. + metricStatusDiscoveryRows(ctx.userId), + ]); annotate({ action: { name: "mcp.tool.invoked" }, meta: { tool: "list_metrics", present: true }, @@ -982,7 +1004,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [ window: inventory.window, restMode: inventory.restMode, cycleEnabled: inventory.cycleEnabled, - metrics: inventory.entries, + metrics: [...inventory.entries, ...statusDiscoveryRows], }; }, },