feat(mcp): add get_ecg_recordings read tool

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 07:56:14 +02:00
1 parent c6b8d33b0c
commit fdc8ed81a0
2 files changed
+205

No files matched your search

+97
View File
@@ -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<Record<string, unknown>>;
};
// Mirrors the app route's own select exactly — never `waveformEncrypted`.
const call = vi.mocked(prisma.ecgRecording.findMany).mock.calls[0][0] as {
select: Record<string, unknown>;
};
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();
});
});
+108
View File
@@ -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(),
];