mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
c6b8d33b0c
commit
fdc8ed81a0
2 files changed
+205
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user