mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
1d9cc4b3d2
commit
b9c164c4bb
6 files changed
+611
-8
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user