From b9c164c4bb697cfcd6b784075e8681f56ad439d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 02:00:37 +0200 Subject: [PATCH] feat(mcp): expose medication schedule, integration status, and preventive-care reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/api/mcp-capabilities.md | 10 +- src/app/mcp/__tests__/route.test.ts | 4 + src/lib/mcp/__tests__/search-fetch.test.ts | 9 +- src/lib/mcp/__tests__/tools.test.ts | 176 ++++++++++ src/lib/mcp/tools.ts | 306 +++++++++++++++++- .../mcp-write-and-range-read.test.ts | 114 +++++++ 6 files changed, 611 insertions(+), 8 deletions(-) diff --git a/docs/api/mcp-capabilities.md b/docs/api/mcp-capabilities.md index 769d20a44..8c0b6dd8e 100644 --- a/docs/api/mcp-capabilities.md +++ b/docs/api/mcp-capabilities.md @@ -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:`, `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. diff --git a/src/app/mcp/__tests__/route.test.ts b/src/app/mcp/__tests__/route.test.ts index 20aaf5d22..80d5ed9a2 100644 --- a/src/app/mcp/__tests__/route.test.ts +++ b/src/app/mcp/__tests__/route.test.ts @@ -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. */ diff --git a/src/lib/mcp/__tests__/search-fetch.test.ts b/src/lib/mcp/__tests__/search-fetch.test.ts index 514469a9a..a576b1ff2 100644 --- a/src/lib/mcp/__tests__/search-fetch.test.ts +++ b/src/lib/mcp/__tests__/search-fetch.test.ts @@ -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 () => { diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index f5a9dd76b..8007fb7c4 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -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>; + }; + 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>; + }; + 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. diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 3a932f679..8dc4a4e78 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -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( + latestIntakes.map((e) => [e.medicationId, e._max.takenAt]), + ); + const resolvedSlotsByMedId = new Map(); + 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(); + 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(), ]; diff --git a/tests/integration/mcp-write-and-range-read.test.ts b/tests/integration/mcp-write-and-range-read.test.ts index 034364b98..c68409b6c 100644 --- a/tests/integration/mcp-write-and-range-read.test.ts +++ b/tests/integration/mcp-write-and-range-read.test.ts @@ -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");