mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +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
@@ -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(),
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user