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

+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(),
];