From a730ad188b2e47d17acb53f0c400377b972ed64b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 10 Aug 2026 01:10:52 +0200 Subject: [PATCH] feat(mcp): answer the visit history over the assistant wire Add get_visits, a bounded read of past visits (default the last 12 months, optional practitioner-name filter) returning date, status, kind, practitioner, specialty, the visit's own reason and outcome, and the linked condition labels, newest first. Absence is explicit: an account that has never recorded a visit reads { present: false }, never { present: true, visits: [] }. Fold upcoming appointments into get_preventive_care as their own named field rather than minting a second due-list tool, so a question about what is coming up gets one answer. The Vorsorge arm stays free of ENCOUNTER-origin reminders; the appointments are read from Encounter directly. Reason and outcome ride the USER_TEXT wrapping, and every row is scoped to the caller by construction. --- docs/api/mcp-capabilities.md | 45 +++--- src/app/mcp/__tests__/route.test.ts | 2 + src/lib/mcp/__tests__/tools.test.ts | 169 ++++++++++++++++++++ src/lib/mcp/tools.ts | 240 +++++++++++++++++++++++++++- 4 files changed, 429 insertions(+), 27 deletions(-) diff --git a/docs/api/mcp-capabilities.md b/docs/api/mcp-capabilities.md index e467969bf..2117f6b8b 100644 --- a/docs/api/mcp-capabilities.md +++ b/docs/api/mcp-capabilities.md @@ -66,28 +66,29 @@ optional so a `{ present: false }` miss and a full hit both validate. argument. Every read is a thin wrapper over an existing server-authoritative path; no new analytics is computed at the wire. -| Tool | What it returns | -| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `list_metrics` | One row per domain: whether data is present, an approximate sample count, and the tool that retrieves it. **Call this first.** | -| `get_metric_series` | One metric's aggregate (count, min, max, mean, slope) plus recent-daily and weekly timelines, with units and population reference bands. | -| `get_metrics` | Several metric series in one call — a paginated fan-out over `get_metric_series`, one grounded result per metric. | -| `get_glucose_panel` | Per-context daily means plus the trailing-30-day clinical panel (time-in-range, GMI, CV%, estimated A1c). | -| `get_sleep` | Per-night asleep + stage minutes plus the sleep-rhythm summary (sleep debt + chronotype). | -| `get_workouts` | Most recent sessions (sport, duration, energy, distance, avg/max HR) plus a per-sport rollup over the window. | -| `get_medication_compliance` | Cadence-aware adherence: dose-weighted compliance rate, expected vs taken/missed, current-cycle status, any GLP-1 titration context. | -| `get_labs` | Latest reading per biomarker over the last 12 months (optionally one analyte). With `history:true` + an analyte, that analyte's paginated reading trajectory. | -| `get_illness_recovery` | Rest mode, active and recently-resolved illnesses, recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). | -| `get_cycle` | Menstrual-cycle context: phase + day-of-cycle, next predicted event, headline phase-correlation. Gated on cycle tracking; descriptive only. | -| `get_correlations` | FDR-controlled day-to-next-day driver pairs between behaviours and outcomes, each with direction, lag, sample size, and a descriptive (never causal) note. | -| `get_correlation` | The vetted, lag-aware association between **two named metrics**: direction, lag, sample size, Pearson r, and a descriptive note. | -| `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. | +| Tool | What it returns | +| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `list_metrics` | One row per domain: whether data is present, an approximate sample count, and the tool that retrieves it. **Call this first.** | +| `get_metric_series` | One metric's aggregate (count, min, max, mean, slope) plus recent-daily and weekly timelines, with units and population reference bands. | +| `get_metrics` | Several metric series in one call — a paginated fan-out over `get_metric_series`, one grounded result per metric. | +| `get_glucose_panel` | Per-context daily means plus the trailing-30-day clinical panel (time-in-range, GMI, CV%, estimated A1c). | +| `get_sleep` | Per-night asleep + stage minutes plus the sleep-rhythm summary (sleep debt + chronotype). | +| `get_workouts` | Most recent sessions (sport, duration, energy, distance, avg/max HR) plus a per-sport rollup over the window. | +| `get_medication_compliance` | Cadence-aware adherence: dose-weighted compliance rate, expected vs taken/missed, current-cycle status, any GLP-1 titration context. | +| `get_labs` | Latest reading per biomarker over the last 12 months (optionally one analyte). With `history:true` + an analyte, that analyte's paginated reading trajectory. | +| `get_illness_recovery` | Rest mode, active and recently-resolved illnesses, recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). | +| `get_cycle` | Menstrual-cycle context: phase + day-of-cycle, next predicted event, headline phase-correlation. Gated on cycle tracking; descriptive only. | +| `get_correlations` | FDR-controlled day-to-next-day driver pairs between behaviours and outcomes, each with direction, lag, sample size, and a descriptive (never causal) note. | +| `get_correlation` | The vetted, lag-aware association between **two named metrics**: direction, lag, sample size, Pearson r, and a descriptive note. | +| `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` | What preventive care is coming up: the user's own configured Vorsorge reminders (upcoming/overdue checkups with next-due dates) plus the appointments booked as future visits. Surfaces configured items, invents nothing. | +| `get_visits` | The user's own past doctor visits over a bounded window (default 12 months, optional practitioner-name filter): date, status, kind, practitioner + specialty, the visit's own reason/outcome, and any linked condition labels. `{ present: false }` when none was ever recorded. | +| `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. | `search` + `fetch` are the de-facto two-tool retrieval convention and the **only** tools ChatGPT calls in its default (non-Developer) mode. Each diff --git a/src/app/mcp/__tests__/route.test.ts b/src/app/mcp/__tests__/route.test.ts index 7208acd96..465939dc1 100644 --- a/src/app/mcp/__tests__/route.test.ts +++ b/src/app/mcp/__tests__/route.test.ts @@ -112,6 +112,8 @@ const READ_TOOLS = [ "get_nutrients", "get_intraday_pulse", "get_ecg_recordings", + // v1.38 — the bounded visit history. + "get_visits", ].sort(); /** A valid, narrow-scope (`health:read`) token resolution. */ diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 52d635481..ed535fa87 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -47,6 +47,9 @@ vi.mock("@/lib/db", () => ({ medicationScheduleRevision: { groupBy: vi.fn(async () => []) }, integrationStatus: { findMany: vi.fn(async () => []) }, measurementReminder: { findMany: vi.fn(async () => []) }, + // v1.38 — the visit history read + the appointments folded into + // get_preventive_care both read the Encounter table. + encounter: { findMany: vi.fn(async () => []) }, // v1.30 (G1) — the nutrients pipeline. nutrientIntakeDay: { findMany: vi.fn(async () => []), @@ -72,6 +75,20 @@ vi.mock("@/lib/integrations/status", () => ({ vi.mock("@/lib/measurement-reminders/dto", () => ({ toMeasurementReminderDto: vi.fn((r) => r), })); +// v1.38 — get_visits resolves its linked condition labels through the shared +// link service; stub the batched read so the tool-wiring tests never reach a +// DB. Its own logic is covered in the link-service suite. +vi.mock("@/lib/links", () => ({ + listTargetsBySource: vi.fn(async () => new Map()), +})); +// v1.38 — get_visits decrypts a visit's reason/outcome; stub the codec so the +// wiring tests never need a live ENCRYPTION key. The fail-soft decrypt path is +// exercised by returning ciphertext bytes and asserting the fenced plaintext. +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: vi.fn((buf: Uint8Array) => + Buffer.from(buf).toString("utf8"), + ), +})); // v1.30 (G1) — `get_nutrients` delegates to the nutrients-read engine; stub // only the DB-touching entry point so the tool-wiring tests below never reach // a real engine. `NUTRIENT_LABELS` / `resolveNutrientCode` stay real (pure, @@ -137,6 +154,8 @@ import { isModuleEnabled } from "@/lib/modules/gate"; import { getAssistantFlags } from "@/lib/feature-flags"; import { getNutrients } from "@/lib/mcp/nutrients-read"; import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io"; +import { listTargetsBySource } from "@/lib/links"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import type { McpAuthContext } from "../auth"; const CTX: McpAuthContext = { @@ -193,6 +212,8 @@ describe("MCP tool registry — surface", () => { "get_intraday_pulse", // v1.30 coverage review (G3) — ECG recording metadata. "get_ecg_recordings", + // v1.38 — the bounded visit history. + "get_visits", ].sort(), ); }); @@ -639,6 +660,13 @@ describe("get_integration_status", () => { }); describe("get_preventive_care", () => { + // The global beforeEach resets every mock, so re-arm the appointments read + // (folded in v1.38) to an empty list; the checkup-focused cases below leave + // it empty and the appointments fold is exercised in its own describe. + beforeEach(() => { + vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never); + }); + it("surfaces the configured reminder due-list with overdue flags", async () => { vi.mocked(prisma.measurementReminder.findMany).mockResolvedValue([ { id: "r-1" }, @@ -785,6 +813,147 @@ describe("get_preventive_care", () => { }); }); +describe("get_preventive_care — upcoming appointments fold in", () => { + it("carries booked appointments as a named field alongside the checkups", async () => { + vi.mocked(prisma.measurementReminder.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.encounter.findMany).mockResolvedValue([ + { + id: "enc-2", + occurredAt: new Date("2030-01-01T09:00:00.000Z"), + status: "PLANNED", + kind: "ROUTINE", + reasonEncrypted: null, + practitioner: { name: "Dr. Wolke", specialty: "General practice" }, + }, + ] as never); + const result = (await tool("get_preventive_care").run(CTX, {})) as { + present: boolean; + checkups?: unknown; + appointments: Array>; + }; + // No Vorsorge reminder, but an appointment exists → present, appointments + // carried, and no empty `checkups` array left dangling. + expect(result.present).toBe(true); + expect(result.checkups).toBeUndefined(); + expect(result.appointments).toHaveLength(1); + expect(result.appointments[0]).toMatchObject({ + practitioner: "Dr. Wolke", + specialty: "General practice", + kind: "ROUTINE", + reason: null, + }); + // It reads FUTURE, PLANNED visits only — never the past history. + const arg = vi.mocked(prisma.encounter.findMany).mock.calls[0][0] as { + where: Record; + }; + expect(arg.where.status).toBe("PLANNED"); + expect(arg.where.userId).toBe("user-1"); + }); + + it("stays { present: false } when neither a reminder nor an appointment exists", async () => { + vi.mocked(prisma.measurementReminder.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never); + const result = (await tool("get_preventive_care").run(CTX, {})) as { + present: boolean; + }; + expect(result.present).toBe(false); + }); +}); + +describe("get_visits", () => { + function visitRow(overrides: Record = {}) { + return { + id: "enc-1", + occurredAt: new Date("2026-06-01T09:00:00.000Z"), + status: "DONE", + kind: "SPECIALIST", + reasonEncrypted: Buffer.from("chest pain follow-up", "utf8"), + outcomeEncrypted: Buffer.from("all clear", "utf8"), + practitioner: { name: "Dr. Herz", specialty: "Cardiology" }, + ...overrides, + }; + } + + it("returns the bounded list, newest first, with fenced free text and linked conditions", async () => { + vi.mocked(decryptFromBytes).mockImplementation((buf: Uint8Array) => + Buffer.from(buf).toString("utf8"), + ); + vi.mocked(prisma.encounter.findMany).mockResolvedValue([ + visitRow(), + ] as never); + vi.mocked(listTargetsBySource).mockResolvedValue( + new Map([["enc-1", [{ id: "ep-1", label: "Hypertension", date: null }]]]), + ); + + const result = (await tool("get_visits").run(CTX, {})) as { + present: boolean; + windowMonths: number; + visits: Array>; + }; + expect(result.present).toBe(true); + expect(result.windowMonths).toBe(12); + expect(result.visits).toHaveLength(1); + const v = result.visits[0]; + expect(v.status).toBe("DONE"); + expect(v.kind).toBe("SPECIALIST"); + expect(v.practitioner).toBe("Dr. Herz"); + expect(v.specialty).toBe("Cardiology"); + // Reason + outcome ride the USER_TEXT wrapping. + expect(v.reason).toContain("<<>>"); + expect(v.reason).toContain("chest pain follow-up"); + expect(v.outcome).toContain("all clear"); + expect(v.conditions).toEqual(["Hypertension"]); + }); + + // WR-10 — the absence contract, the check that must never be decorative. An + // empty list is honest absence, not empty success. { present: true, + // visits: [] } would tell the model "you have no visits" when the truth is + // "you have never recorded one". + it("returns { present: false } — never { present: true, visits: [] } — when the account has never recorded a visit", async () => { + vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never); + const result = (await tool("get_visits").run(CTX, {})) as { + present: boolean; + visits?: unknown; + }; + expect(result.present).toBe(false); + expect(result).not.toHaveProperty("visits"); + expect(result.visits).toBeUndefined(); + }); + + it("scopes the read to the caller and never accepts a userId argument", async () => { + vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never); + await tool("get_visits").run(CTX, { userId: "someone-else", months: 6 }); + const arg = vi.mocked(prisma.encounter.findMany).mock.calls[0][0] as { + where: Record; + take: number; + }; + expect(arg.where.userId).toBe("user-1"); + expect(arg.where.deletedAt).toBeNull(); + expect(arg.take).toBe(50); + // The window arg narrows the range; the injected userId body field is + // ignored entirely. + expect(arg.where.occurredAt).toBeDefined(); + }); + + it("narrows to one practitioner by a case-insensitive name substring", async () => { + vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never); + await tool("get_visits").run(CTX, { practitioner: "herz" }); + const arg = vi.mocked(prisma.encounter.findMany).mock.calls[0][0] as { + where: { + practitioner?: { is?: { name?: { contains?: string; mode?: string } } }; + }; + }; + expect(arg.where.practitioner?.is?.name).toEqual({ + contains: "herz", + mode: "insensitive", + }); + }); +}); + 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 e8e886fa4..97498db68 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -69,6 +69,8 @@ import { import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day"; import { fenceUserText, scrubFenceMarkers } from "@/lib/ai/coach/data-fence"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { listTargetsBySource } from "@/lib/links"; import type { McpAuthContext } from "./auth"; /** @@ -872,7 +874,16 @@ const getIntegrationStatusOutput: z.ZodRawShape = { .optional(), }; -/** Output schema for `get_preventive_care` — the Vorsorge due-list. */ +/** + * Output schema for `get_preventive_care` — the Vorsorge due-list, plus the + * upcoming appointments booked as visits. + * + * `appointments` is a NAMED field on this same result rather than a second + * due-list tool: an assistant asked "what is coming up" gets one answer instead + * of having to know there are two lists. The Vorsorge `checkups` arm stays free + * of `ENCOUNTER`-origin reminders (Phase 1 excluded them); the appointments are + * read from `Encounter` directly, so the two never blur. + */ const getPreventiveCareOutput: z.ZodRawShape = { present: z.boolean(), checkups: z @@ -887,8 +898,73 @@ const getPreventiveCareOutput: z.ZodRawShape = { }), ) .optional(), + appointments: z + .array( + z.object({ + occurredAt: z.string(), + practitioner: z.string().nullable(), + specialty: z.string().nullable(), + kind: z.string(), + reason: z.string().nullable(), + }), + ) + .optional(), }; +/** + * Output schema for `get_visits` — the account's own past visits, bounded. + * + * Follows `get_preventive_care`'s shape: every field beyond `present` is + * optional so a grounded `{ present: false }` miss and a full hit both validate + * against one schema (the ChatGPT Apps-SDK conformance rule the other reads + * follow). Absence is `{ present: false }`, never `{ present: true, visits: [] }` + * — "you have never recorded a visit" and "you have no visits" are different + * claims and only the first is true when the list is empty. + */ +const getVisitsOutput: z.ZodRawShape = { + present: z.boolean(), + windowMonths: z.number().optional(), + visits: z + .array( + z.object({ + occurredAt: z.string(), + status: z.string(), + kind: z.string(), + practitioner: z.string().nullable(), + specialty: z.string().nullable(), + reason: z.string().nullable(), + outcome: z.string().nullable(), + conditions: z.array(z.string()), + }), + ) + .optional(), +}; + +/** Default trailing window for `get_visits` when the caller names none. */ +const DEFAULT_VISITS_WINDOW_MONTHS = 12; +/** Hard cap on the `get_visits` window, so an unbounded read is impossible. */ +const MAX_VISITS_WINDOW_MONTHS = 60; +/** Newest-first cap on the visits one `get_visits` call returns. */ +const MAX_VISITS = 50; +/** Newest-first cap on the appointments folded into `get_preventive_care`. */ +const MAX_APPOINTMENTS = 25; + +/** + * Decrypt a visit's `Bytes` free-text column, fail-soft to null. + * + * A key-rotation gap on one row reads as a missing reason/outcome, never as a + * thrown tool call that takes the whole list down. Same codec + fail-soft + * contract as `src/lib/encounters/dto.ts`. + */ +function decryptVisitText(value: Uint8Array | null): string | null { + if (!value || value.byteLength === 0) return null; + try { + return decryptFromBytes(value); + } catch { + return null; + } +} + /** * Output schema for `get_nutrients` — either the presence overview (no * `nutrient` arg) or one nutrient's per-day series + reference. Every field @@ -1546,11 +1622,43 @@ export const MCP_TOOLS: McpToolDefinition[] = [ 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.", + "Fetch what preventive care is coming up: the user's own configured preventive-care (Vorsorge) reminders (upcoming and overdue checkups with their next-due dates) AND the appointments they have booked as future visits. Surfaces only the reminders the user has already set up (it never invents screening recommendations); each checkup carries its label, optional measurement type, next-due instant, an overdue flag, and last-completed date. Each `appointments` entry carries its date, practitioner name + specialty, visit kind, and the visit's own free-text reason. Returns { present: false } only when neither a reminder nor an appointment exists.", inputShape: {}, annotations: READ_ONLY_ANNOTATIONS, outputShape: getPreventiveCareOutput, async run(ctx) { + const now = new Date(); + // Upcoming appointments ride alongside the Vorsorge due-list, read from + // `Encounter` (a booked visit in the future). They are a NAMED field on + // this result, not a second tool, so "what is coming up" is one answer. + const appointmentRows = await prisma.encounter.findMany({ + where: { + userId: ctx.userId, + deletedAt: null, + status: "PLANNED", + occurredAt: { gt: now }, + }, + orderBy: { occurredAt: "asc" }, + take: MAX_APPOINTMENTS, + include: { practitioner: { select: { name: true, specialty: true } } }, + }); + const appointments = appointmentRows.map((r) => { + const reason = decryptVisitText(r.reasonEncrypted); + return { + occurredAt: r.occurredAt.toISOString(), + practitioner: r.practitioner + ? scrubFenceMarkers(r.practitioner.name) + : null, + specialty: r.practitioner?.specialty + ? scrubFenceMarkers(r.practitioner.specialty) + : null, + kind: r.kind, + // A visit's reason is user- and document-derived free text, so it + // rides the same USER_TEXT wrapping every other free-text field uses. + reason: reason !== null ? fenceUserText(reason) : null, + }; + }); + const reminders = await prisma.measurementReminder.findMany({ // A booked visit's one-shot reminder rides this same engine with // `origin: ENCOUNTER`. It is not a checkup and must not appear on a @@ -1570,7 +1678,11 @@ export const MCP_TOOLS: McpToolDefinition[] = [ { createdAt: "asc" }, ], }); - if (reminders.length === 0) { + // Present when EITHER arm carries something: a person with a booked + // appointment but no configured Vorsorge reminder still has preventive + // care coming up, and hiding the appointment behind `present: false` + // would be the silent-zero this contract forbids. + if (reminders.length === 0 && appointments.length === 0) { annotate({ action: { name: "mcp.tool.invoked" }, meta: { tool: "get_preventive_care", present: false }, @@ -1587,7 +1699,6 @@ export const MCP_TOOLS: McpToolDefinition[] = [ select: { timezone: true }, }); const timezone = user?.timezone || DEFAULT_TIMEZONE; - const now = new Date(); const checkups = reminders.map((r) => { const dto = toMeasurementReminderDto(r); return { @@ -1607,7 +1718,11 @@ export const MCP_TOOLS: McpToolDefinition[] = [ action: { name: "mcp.tool.invoked" }, meta: { tool: "get_preventive_care", present: true }, }); - return { present: true, checkups }; + return { + present: true, + ...(checkups.length > 0 ? { checkups } : {}), + ...(appointments.length > 0 ? { appointments } : {}), + }; }, }, // ── v1.30 coverage review (G1) — the nutrients pipeline ───────────── @@ -1782,6 +1897,121 @@ export const MCP_TOOLS: McpToolDefinition[] = [ }; }, }, + // ── v1.38 — the visit history, bounded ────────────────────────────── + { + name: "get_visits", + title: "Get past doctor visits", + description: + "Fetch the user's own past doctor visits over a bounded window (default: the last 12 months) so a question like 'when did I last see a cardiologist' is answerable from the record. Each visit carries its date, lifecycle status (DONE / CANCELLED / NO_SHOW — a no-show is not a visit that happened), kind, practitioner name + specialty, the visit's own free-text reason and outcome, and the labels of any conditions it was filed against. Optionally narrow to one practitioner by a name substring. Newest first, bounded. Returns { present: false } when the user has never recorded a visit — distinct from a filtered read that simply matched none.", + inputShape: { + months: z + .number() + .int() + .min(1) + .max(MAX_VISITS_WINDOW_MONTHS) + .optional() + .describe( + "Trailing window in months. Defaults to 12, capped at 60. Past visits only — a future appointment is surfaced by get_preventive_care, not here.", + ), + practitioner: z + .string() + .min(1) + .max(120) + .optional() + .describe( + "Optional case-insensitive name substring to narrow to one practitioner (e.g. a surname read back from an earlier result). Omit for every practitioner.", + ), + }, + annotations: READ_ONLY_ANNOTATIONS, + outputShape: getVisitsOutput, + async run(ctx, args) { + const months = + typeof args.months === "number" + ? args.months + : DEFAULT_VISITS_WINDOW_MONTHS; + const practitioner = + typeof args.practitioner === "string" && args.practitioner.trim() + ? args.practitioner.trim() + : undefined; + const now = new Date(); + const cutoff = new Date(now); + cutoff.setMonth(cutoff.getMonth() - months); + + const rows = await prisma.encounter.findMany({ + // `userId` is the resolved session's, never an argument — a visit + // belonging to another account is unreachable by construction. + where: { + userId: ctx.userId, + deletedAt: null, + occurredAt: { gte: cutoff, lte: now }, + ...(practitioner + ? { + practitioner: { + is: { + name: { contains: practitioner, mode: "insensitive" }, + deletedAt: null, + }, + }, + } + : {}), + }, + orderBy: { occurredAt: "desc" }, + take: MAX_VISITS, + include: { practitioner: { select: { name: true, specialty: true } } }, + }); + + // Absence is explicit. An empty list is { present: false } — "you have + // never recorded a visit" — NEVER { present: true, visits: [] }, which + // would read as "you have no visits", a claim the empty read cannot make. + if (rows.length === 0) { + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_visits", present: false }, + }); + return { present: false }; + } + + // The linked condition labels, one grouped read for the whole page, from + // the same link service every other visit surface goes through. + const conditionsBySource = await listTargetsBySource(prisma, { + userId: ctx.userId, + sourceKind: "encounter", + sourceIds: rows.map((r) => r.id), + targetKind: "conditionEpisode", + }); + + const visits = rows.map((r) => { + const reason = decryptVisitText(r.reasonEncrypted); + const outcome = decryptVisitText(r.outcomeEncrypted); + return { + occurredAt: r.occurredAt.toISOString(), + status: r.status, + kind: r.kind, + practitioner: r.practitioner + ? scrubFenceMarkers(r.practitioner.name) + : null, + specialty: r.practitioner?.specialty + ? scrubFenceMarkers(r.practitioner.specialty) + : null, + // Reason and outcome are user- and document-derived free text, so + // they ride the USER_TEXT wrapping; the markers are stripped from the + // content they wrap. A condition label is the user's own free text + // too — scrubbed of any forged marker before it enters the payload. + reason: reason !== null ? fenceUserText(reason) : null, + outcome: outcome !== null ? fenceUserText(outcome) : null, + conditions: (conditionsBySource.get(r.id) ?? []).map((c) => + scrubFenceMarkers(c.label), + ), + }; + }); + + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_visits", present: true }, + }); + return { present: true, windowMonths: months, visits }; + }, + }, ...searchAndFetchTools(), ];