feat(mcp): expose medication schedule, integration status, and preventive-care reads

Add three grounded read tools, each a thin façade over an existing
server-authoritative engine (no new analytics, userId from the session):

- get_medication_schedule: next-due / overdue per active medication via
  the canonical recurrence engine (computeDisplayDue), with as-needed
  flagged and no scheduled due time.
- get_integration_status: per-provider sync health (connected, last sync,
  reauth-required/failing) from the integration-status ledger, so a user
  can see why their data is stale. No secrets or tokens.
- get_preventive_care: the user's already-configured Vorsorge reminders
  with their persisted next-due dates and an overdue flag; it surfaces
  configured reminders only and invents no screening logic.

Each declares an output schema, read-only annotations, and {present:false}
on absence. Page the search tool across the full assembled result set
(the prior 200-row scan made later items unreachable even through the
cursor) and give each result a per-item deep link where an id exists.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 02:00:37 +02:00
1 parent 1d9cc4b3d2
commit b9c164c4bb
6 files changed
+611 -8

No files matched your search

+9 -1
View File
@@ -80,6 +80,9 @@ server-authoritative path; no new analytics is computed at the wire.
| `compare_metric` | One metric vs another over the same horizon, **or** one metric across two horizons (fixed windows or `{from,to}` ranges), with per-side stats + a delta. |
| `get_metric_baseline` | Where the latest reading sits against the user's own usual range (median ± robust deviation), plus the strongest lagged driver. Needs ≥ 7 days of history. |
| `detect_changepoints` | Points where a metric's level shifted over a window or `{from,to}` range — date, direction, before/after means. High firing bar. |
| `get_medication_schedule` | When each active medication is next due and which are overdue right now — name, dose, next-due, overdue flag, as-needed flag. Reuses the recurrence engine. |
| `get_integration_status` | Sync health of connected devices/services — connected, last sync, reauth-required/failing — to answer "why is my data stale?". No secrets or tokens. |
| `get_preventive_care` | The user's own configured preventive-care (Vorsorge) reminders — upcoming/overdue checkups with next-due dates. Surfaces configured reminders, invents nothing. |
| `search` | Free-text search over the user's record (metric domains, medications, lab analytes). Returns `{ results: [{ id, title, url }], nextCursor? }`. |
| `fetch` | Hydrate one record by the id `search` returned (`metric:weight`, `med:<id>`, `lab:LDL`). Returns `{ id, title, text, url, metadata }` with a citation deep-link. |
@@ -175,7 +178,12 @@ explicit **confirm-flag preview→commit** handshake instead:
the exact normalized record it _would_ write, plus
`requiresConfirmation:true` and an instruction to confirm the value
with the user and re-call with `confirm:true` and the **same**
`idempotencyKey`.
`idempotencyKey`. The preview runs the **same** type / range / instant
validation the commit runs: if the value would be refused, the preview
carries `wouldFail:true` with the `error` + `reason` the commit would
return — so a preview and its commit never disagree. The `measuredAt`
instant is bounded exactly like the manual route (no future beyond a
5-minute skew, nothing before 1900).
- **`confirm:true`** → the write executes. The idempotency derived from
`idempotencyKey` makes a retried call a safe no-op
(`alreadyLogged:true`) rather than a duplicate.
+4
View File
@@ -89,6 +89,10 @@ const READ_TOOLS = [
"get_cycle",
// v1.24 — multi-metric fan-out.
"get_metrics",
// v1.24 — operational reads.
"get_medication_schedule",
"get_integration_status",
"get_preventive_care",
].sort();
/** A valid, narrow-scope (`health:read`) token resolution. */
+7 -2
View File
@@ -84,9 +84,14 @@ describe("search", () => {
// ChatGPT only cites when `url` is a non-empty absolute URL.
expect(r.url.startsWith("https://health.example/")).toBe(true);
}
// The internal id stays separate from the citation url.
// The internal id stays separate from the citation url; each result
// deep-links to its own item where an id exists (mirrors `fetch`).
const med = result.results.find((r) => r.id === "med:med-1");
expect(med?.url).toBe("https://health.example/medications");
expect(med?.url).toBe("https://health.example/medications/med-1");
const metric = result.results.find((r) => r.id === "metric:weight");
expect(metric?.url).toBe("https://health.example/insights?metric=weight");
const lab = result.results.find((r) => r.id === "lab:LDL");
expect(lab?.url).toBe("https://health.example/labs?analyte=LDL");
});
it("filters by query (case-insensitive substring)", async () => {
+176
View File
@@ -19,8 +19,33 @@ vi.mock("@/lib/db", () => ({
findFirst: vi.fn(async () => null),
},
labResult: { findMany: vi.fn(async () => []) },
// v1.24 — operational reads (schedule / integration status / preventive care).
user: { findUnique: vi.fn(async () => ({ timezone: "UTC" })) },
medicationIntakeEvent: {
groupBy: vi.fn(async () => []),
findMany: vi.fn(async () => []),
},
medicationScheduleRevision: { groupBy: vi.fn(async () => []) },
integrationStatus: { findMany: vi.fn(async () => []) },
measurementReminder: { findMany: vi.fn(async () => []) },
},
}));
// v1.24 — the operational reads delegate to existing server-authoritative
// engines; stub them so the registry-wide loops never reach a real engine.
vi.mock("@/lib/medications/scheduling/next-due", () => ({
computeDisplayDue: vi.fn(() => null),
OVERDUE_LOOKBACK_MS: 1000,
toResolvedSlotMark: vi.fn((e) => ({
at: e.scheduledFor,
slotAnchored: true,
})),
}));
vi.mock("@/lib/integrations/status", () => ({
getIntegrationStatus: vi.fn(),
}));
vi.mock("@/lib/measurement-reminders/dto", () => ({
toMeasurementReminderDto: vi.fn((r) => r),
}));
// Phase 4 — the deep-value reads delegate to the rich-reads engines; stub them
// so the registry-wide loops (surface / annotation / no-verdict) never reach a
// real engine. Their own logic is covered in `rich-reads.test.ts`.
@@ -37,6 +62,9 @@ import { MCP_TOOLS, MCP_TOOL_NAMES } from "../tools";
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory";
import { prisma } from "@/lib/db";
import { computeDisplayDue } from "@/lib/medications/scheduling/next-due";
import { getIntegrationStatus } from "@/lib/integrations/status";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
import type { McpAuthContext } from "../auth";
const CTX: McpAuthContext = {
@@ -83,6 +111,10 @@ describe("MCP tool registry — surface", () => {
"get_cycle",
// v1.24 — multi-metric fan-out.
"get_metrics",
// v1.24 — operational reads.
"get_medication_schedule",
"get_integration_status",
"get_preventive_care",
].sort(),
);
});
@@ -361,6 +393,150 @@ describe("get_metrics — multi-metric fan-out + pagination", () => {
});
});
describe("get_medication_schedule", () => {
it("returns per-medication next-due + overdue, scoped to the session user", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{
id: "m-1",
name: "Ramipril",
dose: "5 mg",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
asNeeded: false,
schedules: [],
},
] as never);
vi.mocked(computeDisplayDue).mockReturnValue({
at: new Date("2026-06-28T08:00:00Z"),
overdue: true,
});
const result = (await tool("get_medication_schedule").run(CTX, {})) as {
present: boolean;
medications: Array<{
name: string;
nextDueAt: string | null;
overdue: boolean;
asNeeded: boolean;
}>;
};
expect(prisma.medication.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: "user-1", active: true } }),
);
expect(result.present).toBe(true);
expect(result.medications).toHaveLength(1);
expect(result.medications[0]).toMatchObject({
name: "Ramipril",
overdue: true,
asNeeded: false,
});
expect(result.medications[0].nextDueAt).toBe("2026-06-28T08:00:00.000Z");
});
it("returns { present: false } when no medications are tracked", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
const result = (await tool("get_medication_schedule").run(CTX, {})) as {
present: boolean;
};
expect(result.present).toBe(false);
});
});
describe("get_integration_status", () => {
it("reports per-provider sync health and carries no secrets", async () => {
vi.mocked(prisma.integrationStatus.findMany).mockResolvedValue([
{ integration: "withings" },
] as never);
vi.mocked(getIntegrationStatus).mockResolvedValue({
integration: "withings",
state: "error_reauth",
lastSuccessAt: "2026-06-01T00:00:00.000Z",
lastAttemptAt: "2026-06-27T00:00:00.000Z",
lastError: "token revoked",
consecutiveFailuresByKind: null,
});
const result = (await tool("get_integration_status").run(CTX, {})) as {
present: boolean;
providers: Array<Record<string, unknown>>;
};
expect(result.present).toBe(true);
expect(result.providers).toHaveLength(1);
const p = result.providers[0];
expect(p).toMatchObject({
provider: "withings",
state: "error_reauth",
connected: true,
reauthRequired: true,
});
// No secret / token / raw-error fields leak to the assistant.
expect(p).not.toHaveProperty("lastError");
expect(JSON.stringify(p)).not.toContain("token revoked");
});
it("returns { present: false } when nothing has ever synced", async () => {
vi.mocked(prisma.integrationStatus.findMany).mockResolvedValue([] as never);
const result = (await tool("get_integration_status").run(CTX, {})) as {
present: boolean;
};
expect(result.present).toBe(false);
});
});
describe("get_preventive_care", () => {
it("surfaces the configured reminder due-list with overdue flags", async () => {
vi.mocked(prisma.measurementReminder.findMany).mockResolvedValue([
{ id: "r-1" },
] as never);
vi.mocked(toMeasurementReminderDto).mockReturnValue({
id: "r-1",
label: "Blood pressure check",
measurementType: "BLOOD_PRESSURE_SYS",
intervalDays: 30,
rrule: null,
anchorDate: null,
endsOn: null,
origin: "VORSORGE",
notifyHour: 9,
location: null,
nextDueAt: "2000-01-01T00:00:00.000Z",
lastSatisfiedAt: null,
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
const result = (await tool("get_preventive_care").run(CTX, {})) as {
present: boolean;
checkups: Array<Record<string, unknown>>;
};
expect(prisma.measurementReminder.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1", deletedAt: null, enabled: true },
}),
);
expect(result.present).toBe(true);
expect(result.checkups[0]).toMatchObject({
label: "Blood pressure check",
measurementType: "BLOOD_PRESSURE_SYS",
overdue: true, // a year-2000 due date is in the past
});
});
it("returns { present: false } when no reminders are configured", async () => {
vi.mocked(prisma.measurementReminder.findMany).mockResolvedValue(
[] as never,
);
const result = (await tool("get_preventive_care").run(CTX, {})) as {
present: boolean;
};
expect(result.present).toBe(false);
});
});
describe("search — cursor pagination", () => {
it("returns a bounded page and an opaque nextCursor when more results exist", async () => {
// 60 lab analytes → exceeds the 50-result page.
+301 -5
View File
@@ -38,6 +38,17 @@ import {
type DateRange,
} from "@/lib/mcp/rich-reads";
import { encodeOffsetCursor, decodeOffsetCursor } from "@/lib/mcp/pagination";
import {
computeDisplayDue,
OVERDUE_LOOKBACK_MS,
toResolvedSlotMark,
type ResolvedSlotMark,
} from "@/lib/medications/scheduling/next-due";
import {
getIntegrationStatus,
type IntegrationKey,
} from "@/lib/integrations/status";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
import type { McpAuthContext } from "./auth";
/**
@@ -232,12 +243,16 @@ function searchAndFetchTools(): McpToolDefinition[] {
if (!entry.present) continue;
const hay = `${entry.domain} ${entry.metric ?? ""}`.toLowerCase();
if (query && !hay.includes(query)) continue;
// Per-item deep-link where a metric id exists (mirrors `fetch`),
// else the generic insights landing for a whole-domain row.
results.push({
id: entry.metric
? `metric:${entry.metric}`
: `domain:${entry.domain}`,
title: entry.domain,
url: `${origin}/insights`,
url: entry.metric
? `${origin}/insights?metric=${encodeURIComponent(entry.metric)}`
: `${origin}/insights`,
});
}
@@ -245,14 +260,15 @@ function searchAndFetchTools(): McpToolDefinition[] {
where: { userId: ctx.userId },
select: { id: true, name: true, dose: true },
orderBy: { createdAt: "desc" },
take: 200,
take: SEARCH_RESULT_SCAN_CAP,
});
for (const med of meds) {
if (query && !med.name.toLowerCase().includes(query)) continue;
results.push({
id: `med:${med.id}`,
title: med.dose ? `${med.name} ${med.dose}` : med.name,
url: `${origin}/medications`,
// Per-item deep-link to the medication detail page (mirrors `fetch`).
url: `${origin}/medications/${encodeURIComponent(med.id)}`,
});
}
@@ -260,14 +276,15 @@ function searchAndFetchTools(): McpToolDefinition[] {
where: { userId: ctx.userId, deletedAt: null },
select: { analyte: true },
distinct: ["analyte"],
take: 200,
take: SEARCH_RESULT_SCAN_CAP,
});
for (const lab of labs) {
if (query && !lab.analyte.toLowerCase().includes(query)) continue;
results.push({
id: `lab:${lab.analyte}`,
title: lab.analyte,
url: `${origin}/labs`,
// Per-item deep-link to the labs surface filtered to this analyte.
url: `${origin}/labs?analyte=${encodeURIComponent(lab.analyte)}`,
});
}
@@ -441,6 +458,14 @@ const MAX_METRICS_PER_CALL = 24;
const METRICS_PAGE_SIZE = 8;
/** Results per `search` page. */
const SEARCH_PAGE_SIZE = 50;
/**
* Per-source scan ceiling the `search` assembler reads before paging. Raised
* from the prior 200 (which made any item past the 200th unreachable, even
* through the cursor) to a generous documented cap so the opaque offset cursor
* pages the FULL assembled set. A user with more medications or distinct lab
* analytes than this ceiling is well outside any realistic personal record.
*/
const SEARCH_RESULT_SCAN_CAP = 2000;
const getCorrelationOutput: z.ZodRawShape = {
present: z.boolean(),
@@ -588,6 +613,56 @@ const detectChangepointsOutput: z.ZodRawShape = {
.optional(),
};
/** Output schema for `get_medication_schedule` — per-medication next-due. */
const getMedicationScheduleOutput: z.ZodRawShape = {
present: z.boolean(),
medications: z
.array(
z.object({
name: z.string(),
dose: z.string().nullable(),
nextDueAt: z.string().nullable(),
overdue: z.boolean(),
asNeeded: z.boolean(),
}),
)
.optional(),
};
/** Output schema for `get_integration_status` — per-provider sync health. */
const getIntegrationStatusOutput: z.ZodRawShape = {
present: z.boolean(),
providers: z
.array(
z.object({
provider: z.string(),
state: z.string(),
connected: z.boolean(),
reauthRequired: z.boolean(),
lastSuccessAt: z.string().nullable(),
lastAttemptAt: z.string().nullable(),
}),
)
.optional(),
};
/** Output schema for `get_preventive_care` — the Vorsorge due-list. */
const getPreventiveCareOutput: z.ZodRawShape = {
present: z.boolean(),
checkups: z
.array(
z.object({
label: z.string(),
measurementType: z.string().nullable(),
nextDueAt: z.string().nullable(),
overdue: z.boolean(),
location: z.string().nullable(),
lastSatisfiedAt: z.string().nullable(),
}),
)
.optional(),
};
export const MCP_TOOLS: McpToolDefinition[] = [
{
name: "list_metrics",
@@ -935,6 +1010,227 @@ export const MCP_TOOLS: McpToolDefinition[] = [
};
},
},
// ── Operational reads (not Coach snapshot domains) ──────────────────
// These answer "what should I do / why is my data stale" rather than "what
// are my figures". They are NOT in the Coach data inventory (which enumerates
// metric domains); each is a thin façade over an existing server-authoritative
// engine — the medication recurrence engine, the integration-status ledger,
// and the persisted Vorsorge reminder due dates — and never recomputes.
{
name: "get_medication_schedule",
title: "Get the medication schedule",
description:
"Fetch when the user's active medications are next due, and which are overdue right now. Reuses the same server-authoritative recurrence engine the medication cards render (open overdue slots win over future ones). Returns one row per active medication: name, dose, next-due instant, an overdue flag, and an as-needed (PRN) flag. As-needed medications have no scheduled due time (nextDueAt: null). Returns { present: false } when no medications are tracked.",
inputShape: {},
annotations: READ_ONLY_ANNOTATIONS,
outputShape: getMedicationScheduleOutput,
async run(ctx) {
const now = new Date();
const [user, medications] = await Promise.all([
prisma.user.findUnique({
where: { id: ctx.userId },
select: { timezone: true },
}),
prisma.medication.findMany({
where: { userId: ctx.userId, active: true },
include: { schedules: true },
orderBy: { createdAt: "desc" },
}),
]);
if (medications.length === 0) {
annotate({
action: { name: "mcp.tool.invoked" },
meta: { tool: "get_medication_schedule", present: false },
});
return { present: false };
}
const userTz = user?.timezone || "Europe/Berlin";
// Same feeder reads + horizon the medications list route / dashboard
// builder use, so the open-overdue detection matches the in-app cards.
const resolvedWindowStart = new Date(now.getTime() - OVERDUE_LOOKBACK_MS);
const resolvedWindowEnd = new Date(
now.getTime() + 2 * 24 * 60 * 60 * 1000,
);
const [latestIntakes, resolvedEvents, eraFloors] = await Promise.all([
prisma.medicationIntakeEvent.groupBy({
by: ["medicationId"],
where: {
userId: ctx.userId,
deletedAt: null,
skipped: false,
takenAt: { not: null },
},
_max: { takenAt: true },
}),
prisma.medicationIntakeEvent.findMany({
where: {
userId: ctx.userId,
deletedAt: null,
scheduledFor: { gte: resolvedWindowStart, lte: resolvedWindowEnd },
OR: [
{ takenAt: { not: null } },
{ skipped: true },
{ autoMissed: true },
],
},
select: { medicationId: true, scheduledFor: true, takenAt: true },
}),
prisma.medicationScheduleRevision.groupBy({
by: ["medicationId"],
where: {
medication: { userId: ctx.userId },
supersededByRevisionId: null,
},
_max: { validUntil: true },
}),
]);
const lastTakenAtByMedId = new Map<string, Date | null>(
latestIntakes.map((e) => [e.medicationId, e._max.takenAt]),
);
const resolvedSlotsByMedId = new Map<string, ResolvedSlotMark[]>();
for (const e of resolvedEvents) {
const mark = toResolvedSlotMark(e);
const list = resolvedSlotsByMedId.get(e.medicationId);
if (list) list.push(mark);
else resolvedSlotsByMedId.set(e.medicationId, [mark]);
}
const eraStartByMedId = new Map<string, Date>();
for (const f of eraFloors) {
if (f._max.validUntil)
eraStartByMedId.set(f.medicationId, f._max.validUntil);
}
const rows = medications.map((m) => {
const display = computeDisplayDue({
medication: {
id: m.id,
startsOn: m.startsOn,
endsOn: m.endsOn,
oneShot: m.oneShot,
createdAt: m.createdAt,
},
schedules: m.schedules,
now,
userTz,
lastIntakeAt: lastTakenAtByMedId.get(m.id) ?? null,
resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [],
eraStart: eraStartByMedId.get(m.id) ?? null,
});
return {
name: m.name,
dose: m.dose,
nextDueAt: display ? display.at.toISOString() : null,
overdue: display ? display.overdue : false,
asNeeded: m.asNeeded,
};
});
annotate({
action: { name: "mcp.tool.invoked" },
meta: { tool: "get_medication_schedule", present: true },
});
return { present: true, medications: rows };
},
},
{
name: "get_integration_status",
title: "Get device & service sync status",
description:
"Fetch the sync health of the user's connected devices and services (Withings, WHOOP, Fitbit, Nightscout, Polar, Oura, moodLog) — which are connected, when each last synced, and whether one needs reconnecting or is failing. Answers 'why is my data stale?'. Reuses the integration-status ledger; carries no secrets or tokens. Returns { present: false } when no integration has ever attempted a sync.",
inputShape: {},
annotations: READ_ONLY_ANNOTATIONS,
outputShape: getIntegrationStatusOutput,
async run(ctx) {
// Only providers with a status row have actually attempted a sync — a
// synthetic "never attempted" provider must NOT be reported as connected.
const rows = await prisma.integrationStatus.findMany({
where: { userId: ctx.userId },
select: { integration: true },
});
if (rows.length === 0) {
annotate({
action: { name: "mcp.tool.invoked" },
meta: { tool: "get_integration_status", present: false },
});
return { present: false };
}
const providers = await Promise.all(
rows.map(async (r) => {
const s = await getIntegrationStatus(
ctx.userId,
r.integration as IntegrationKey,
);
return {
provider: s.integration,
state: s.state,
connected: s.state !== "disconnected",
reauthRequired: s.state === "error_reauth" || s.state === "parked",
lastSuccessAt: s.lastSuccessAt,
lastAttemptAt: s.lastAttemptAt,
};
}),
);
annotate({
action: { name: "mcp.tool.invoked" },
meta: { tool: "get_integration_status", present: true },
});
return { present: true, providers };
},
},
{
name: "get_preventive_care",
title: "Get preventive-care due-list",
description:
"Fetch the user's own configured preventive-care (Vorsorge) reminders — upcoming and overdue checkups with their next-due dates. Surfaces only the reminders the user has already set up (it never invents screening recommendations). Each item carries its label, optional measurement type, next-due instant, an overdue flag, and last-completed date. Returns { present: false } when no reminders are configured.",
inputShape: {},
annotations: READ_ONLY_ANNOTATIONS,
outputShape: getPreventiveCareOutput,
async run(ctx) {
const reminders = await prisma.measurementReminder.findMany({
where: { userId: ctx.userId, deletedAt: null, enabled: true },
// Most-urgent first; a null next-due (uncomputable) sinks to the end.
orderBy: [
{ nextDueAt: { sort: "asc", nulls: "last" } },
{ createdAt: "asc" },
],
});
if (reminders.length === 0) {
annotate({
action: { name: "mcp.tool.invoked" },
meta: { tool: "get_preventive_care", present: false },
});
return { present: false };
}
const nowMs = Date.now();
const checkups = reminders.map((r) => {
const dto = toMeasurementReminderDto(r);
return {
label: dto.label,
measurementType: dto.measurementType,
nextDueAt: dto.nextDueAt,
overdue:
dto.nextDueAt !== null
? new Date(dto.nextDueAt).getTime() < nowMs
: false,
location: dto.location,
lastSatisfiedAt: dto.lastSatisfiedAt,
};
});
annotate({
action: { name: "mcp.tool.invoked" },
meta: { tool: "get_preventive_care", present: true },
});
return { present: true, checkups };
},
},
...searchAndFetchTools(),
];
@@ -18,9 +18,28 @@ process.env.ENCRYPTION_KEY ??=
import { logMcpBloodPressure, logMcpMeasurement } from "@/lib/mcp/writes";
import { compareMetric } from "@/lib/mcp/rich-reads";
import { MCP_TOOLS } from "@/lib/mcp/tools";
import type { McpAuthContext } from "@/lib/mcp/auth";
import { getPrismaClient, truncateAllTables } from "./setup";
function readCtx(userId: string): McpAuthContext {
return {
userId,
tokenId: "token-int",
scopes: ["health:read"],
binding: `${userId}:token-int`,
canRead: true,
canWrite: false,
};
}
function readTool(name: string) {
const def = MCP_TOOLS.find((t) => t.name === name);
if (!def) throw new Error(`tool ${name} not registered`);
return def;
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
@@ -107,6 +126,101 @@ describe("logMcpBloodPressure — real DB", () => {
});
});
describe("measuredAt instant bound — real DB", () => {
it("refuses a far-future / ancient measuredAt and persists nothing (measurement + BP)", async () => {
const prisma = getPrismaClient();
const user = await makeUser("instant");
const future = await logMcpMeasurement({
userId: user.id,
type: "WEIGHT",
value: 80,
measuredAt: new Date("9999-01-01T00:00:00Z"),
idempotencyKey: "instant-future",
});
expect(future.status).toBe("out_of_range");
const ancient = await logMcpMeasurement({
userId: user.id,
type: "WEIGHT",
value: 80,
measuredAt: new Date("1000-01-01T00:00:00Z"),
idempotencyKey: "instant-ancient",
});
expect(ancient.status).toBe("out_of_range");
const bpFuture = await logMcpBloodPressure({
userId: user.id,
systolic: 120,
diastolic: 80,
measuredAt: new Date("9999-01-01T00:00:00Z"),
idempotencyKey: "instant-bp-future",
});
expect(bpFuture.status).toBe("out_of_range");
expect(await prisma.measurement.count({ where: { userId: user.id } })).toBe(
0,
);
});
it("accepts a now-ish measuredAt", async () => {
const prisma = getPrismaClient();
const user = await makeUser("instant-ok");
const result = await logMcpMeasurement({
userId: user.id,
type: "WEIGHT",
value: 80,
measuredAt: new Date(),
idempotencyKey: "instant-now",
});
expect(result.status).toBe("written");
expect(await prisma.measurement.count({ where: { userId: user.id } })).toBe(
1,
);
});
});
describe("get_preventive_care — real DB", () => {
it("surfaces the user's configured reminders with overdue flags, {present:false} when none", async () => {
const prisma = getPrismaClient();
const user = await makeUser("vorsorge");
const empty = (await readTool("get_preventive_care").run(
readCtx(user.id),
{},
)) as { present: boolean };
expect(empty.present).toBe(false);
await prisma.measurementReminder.create({
data: {
userId: user.id,
label: "Blood pressure check",
measurementType: "BLOOD_PRESSURE_SYS",
intervalDays: 30,
notifyHour: 9,
enabled: true,
// A past due date so the read flags it overdue.
nextDueAt: new Date("2000-01-01T00:00:00Z"),
},
});
const result = (await readTool("get_preventive_care").run(
readCtx(user.id),
{},
)) as {
present: boolean;
checkups: Array<{ label: string; overdue: boolean }>;
};
expect(result.present).toBe(true);
expect(result.checkups).toHaveLength(1);
expect(result.checkups[0]).toMatchObject({
label: "Blood pressure check",
overdue: true,
});
});
});
describe("compareMetric — explicit {from,to} range over real rollups", () => {
it("filters each side's buckets to its range and computes a delta", async () => {
const user = await makeUser("range");