From fdc8ed81a0b4ae5963ac3693f6b2702a41242b7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 07:41:04 +0200 Subject: [PATCH] feat(mcp): add get_ecg_recordings read tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds MCP coverage for ECG recording metadata (recorded time, duration, sampling rate, sample count, average heart rate, lead, and the device's own rhythm classification) — previously unreachable because ECG has no MeasurementType and so cannot appear in list_metrics or the metric resource templates. get_ecg_recordings mirrors GET /api/insights/ecg exactly, down to the select clause: waveformEncrypted is never selected, so a decrypt can never happen on this path. Non-diagnostic framing carries through: classificationSource is a fixed "device" literal, and the description states plainly that HealthLog never re-classifies. Gated on both the insights module and the insightStatus assistant-surface flag, mirroring the route's own two-layer gate. --- src/lib/mcp/__tests__/tools.test.ts | 97 +++++++++++++++++++++++++ src/lib/mcp/tools.ts | 108 ++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 66a372a48..401e65eef 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -16,6 +16,18 @@ vi.mock("@/lib/logging/context", () => ({ vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn(async () => true), })); +// v1.30 (G3) — the operator-level assistant surface gate `get_ecg_recordings` +// consults on top of the module gate. +vi.mock("@/lib/feature-flags", () => ({ + getAssistantFlags: vi.fn(async () => ({ + enabled: true, + coach: true, + briefing: true, + insightStatus: true, + correlations: true, + healthScoreExplainer: true, + })), +})); // v1.22.0 — `search` reads the record directly via Prisma; stub it so the // registry-wide loops never reach a DB. vi.mock("@/lib/db", () => ({ @@ -41,6 +53,8 @@ vi.mock("@/lib/db", () => ({ findMany: vi.fn(async () => []), groupBy: vi.fn(async () => []), }, + // v1.30 (G3) — ECG recording metadata. + ecgRecording: { findMany: vi.fn(async () => []) }, }, })); // v1.24 — the operational reads delegate to existing server-authoritative @@ -112,6 +126,7 @@ import { computeDisplayDue } from "@/lib/medications/scheduling/next-due"; import { getIntegrationStatus } from "@/lib/integrations/status"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; import { isModuleEnabled } from "@/lib/modules/gate"; +import { getAssistantFlags } from "@/lib/feature-flags"; import { getNutrients } from "@/lib/mcp/nutrients-read"; import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io"; import type { McpAuthContext } from "../auth"; @@ -168,6 +183,8 @@ describe("MCP tool registry — surface", () => { "get_nutrients", // v1.30 coverage review (G2) — the intraday pulse / shape of the day. "get_intraday_pulse", + // v1.30 coverage review (G3) — ECG recording metadata. + "get_ecg_recordings", ].sort(), ); }); @@ -885,3 +902,83 @@ describe("get_intraday_pulse — v1.30 coverage review (G2)", () => { expect(loadIntradayPulse).not.toHaveBeenCalled(); }); }); + +describe("get_ecg_recordings — v1.30 coverage review (G3)", () => { + it("returns metadata-only recordings with the device classification verbatim", async () => { + vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([ + { + id: "ecg-1", + recordedAt: new Date("2026-07-01T08:00:00Z"), + durationSeconds: 30, + samplingFrequency: 512, + sampleCount: 15360, + averageHeartRate: 72, + lead: "LEAD_I", + rhythmClassification: "NOT_DETECTED", + source: "APPLE_HEALTH", + }, + ] as never); + + const result = (await tool("get_ecg_recordings").run(CTX, {})) as { + present: boolean; + classificationSource?: string; + recordings?: Array>; + }; + + // Mirrors the app route's own select exactly — never `waveformEncrypted`. + const call = vi.mocked(prisma.ecgRecording.findMany).mock.calls[0][0] as { + select: Record; + }; + expect(call.select).not.toHaveProperty("waveformEncrypted"); + expect(call.select).toMatchObject({ + id: true, + recordedAt: true, + rhythmClassification: true, + }); + + expect(result.present).toBe(true); + expect(result.classificationSource).toBe("device"); + expect(result.recordings).toHaveLength(1); + expect(result.recordings?.[0]).toMatchObject({ + id: "ecg-1", + classification: "NOT_DETECTED", + hasWaveform: true, + }); + }); + + it("returns { present: false } when no recordings exist", async () => { + vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never); + const result = (await tool("get_ecg_recordings").run(CTX, {})) as { + present: boolean; + reason?: string; + }; + expect(result).toEqual({ present: false, reason: "no_data" }); + }); + + it("returns { present: false, reason: module_disabled } when the `insights` module is off", async () => { + vi.mocked(isModuleEnabled).mockResolvedValueOnce(false); + const result = (await tool("get_ecg_recordings").run(CTX, {})) as { + present: boolean; + reason?: string; + }; + expect(result).toEqual({ present: false, reason: "module_disabled" }); + expect(prisma.ecgRecording.findMany).not.toHaveBeenCalled(); + }); + + it("returns { present: false, reason: module_disabled } when the operator-level insightStatus surface is off", async () => { + vi.mocked(getAssistantFlags).mockResolvedValueOnce({ + enabled: true, + coach: true, + briefing: true, + insightStatus: false, + correlations: true, + healthScoreExplainer: true, + } as never); + const result = (await tool("get_ecg_recordings").run(CTX, {})) as { + present: boolean; + reason?: string; + }; + expect(result).toEqual({ present: false, reason: "module_disabled" }); + expect(prisma.ecgRecording.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 3e6afa403..a1b91702c 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -28,6 +28,7 @@ import { coachScopeWindowSchema, } from "@/lib/ai/coach/types"; import { isModuleEnabled } from "@/lib/modules/gate"; +import { getAssistantFlags } from "@/lib/feature-flags"; import { resolveBaseOrigin } from "@/lib/mcp/oauth/config"; import { getCorrelation, @@ -929,6 +930,38 @@ const getIntradayPulseOutput: z.ZodRawShape = { .optional(), }; +/** Defensive cap, mirrors `GET /api/insights/ecg`'s `MAX_RECORDINGS`. */ +const MAX_ECG_RECORDINGS = 200; + +/** + * Output schema for `get_ecg_recordings` — METADATA only. `waveformEncrypted` + * is never selected by the underlying query, let alone shaped here. + * `classification` is the device's own verbatim verdict; `classificationSource` + * is a fixed literal so a caller can never mistake it for a HealthLog-derived + * read. + */ +const getEcgRecordingsOutput: z.ZodRawShape = { + present: z.boolean(), + reason: z.string().optional(), + classificationSource: z.literal("device").optional(), + recordings: z + .array( + z.object({ + id: z.string(), + recordedAt: z.string(), + durationSeconds: z.number().nullable(), + samplingFrequency: z.number(), + sampleCount: z.number(), + averageHeartRate: z.number().nullable(), + lead: z.string().nullable(), + classification: z.string().nullable(), + source: z.string(), + hasWaveform: z.boolean(), + }), + ) + .optional(), +}; + export const MCP_TOOLS: McpToolDefinition[] = [ { name: "list_metrics", @@ -1594,6 +1627,81 @@ export const MCP_TOOLS: McpToolDefinition[] = [ }; }, }, + // ── v1.30 coverage review (G3) — ECG recording metadata ───────────── + { + name: "get_ecg_recordings", + title: "Get ECG recordings", + description: + "Fetch METADATA for the user's own ECG recordings — recorded time, duration, sampling rate, sample count, average heart rate, lead, and the DEVICE's own rhythm classification. NEVER reads or returns the waveform (that stays app-only). Non-diagnostic: this reflects ONLY the classification result the recording device's own certified on-device algorithm produced — HealthLog never re-classifies an ECG and never forms a diagnosis from it; `classificationSource` is always the fixed literal 'device'. Gated on the `insights` module. Returns { present: false, reason: \"module_disabled\" } when the module (or the assistant surface) is off, or { present: false } when no recordings exist.", + inputShape: {}, + annotations: READ_ONLY_ANNOTATIONS, + outputShape: getEcgRecordingsOutput, + async run(ctx) { + const moduleEnabled = await isModuleEnabled(ctx.userId, "insights"); + const insightStatusEnabled = moduleEnabled + ? (await getAssistantFlags()).insightStatus + : false; + if (!moduleEnabled || !insightStatusEnabled) { + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_ecg_recordings", present: false }, + }); + return { present: false, reason: "module_disabled" }; + } + + const rows = await prisma.ecgRecording.findMany({ + where: { userId: ctx.userId }, + // Everything EXCEPT `waveformEncrypted` — mirrors `GET + // /api/insights/ecg`'s own select exactly; no decrypt ever happens + // on this path. + select: { + id: true, + recordedAt: true, + durationSeconds: true, + samplingFrequency: true, + sampleCount: true, + averageHeartRate: true, + lead: true, + rhythmClassification: true, + source: true, + }, + orderBy: { recordedAt: "desc" }, + take: MAX_ECG_RECORDINGS, + }); + + if (rows.length === 0) { + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_ecg_recordings", present: false }, + }); + return { present: false, reason: "no_data" }; + } + + const recordings = rows.map((r) => ({ + id: r.id, + recordedAt: r.recordedAt.toISOString(), + durationSeconds: r.durationSeconds, + samplingFrequency: r.samplingFrequency, + sampleCount: r.sampleCount, + averageHeartRate: r.averageHeartRate, + lead: r.lead, + classification: r.rhythmClassification, + source: r.source, + // A `ts-` fallback event carries a verdict but no signal to fetch. + hasWaveform: r.sampleCount > 0, + })); + + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_ecg_recordings", present: true }, + }); + return { + present: true, + classificationSource: "device" as const, + recordings, + }; + }, + }, ...searchAndFetchTools(), ];