From 34302867aa607376db5a510b2edd988f1ebd4959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 2 Nov 2026 20:24:28 +0100 Subject: [PATCH] feat(mcp): list links on lab and medication reads, ids on visit and condition rows get_labs readings (the latest, earlier values in full, trajectory readings) carry the fetch ids of what they link to, with linksTruncated; each linked record is described once per answer in linkedRecords, because one report usually holds a whole panel and would otherwise repeat on every reading. fetch lab: and fetch med: list their links like the other record kinds, and get_medication_schedule rows carry their id and links inline. get_visits and get_illness_recovery rows carry visit: and condition: ids, so a row's links are one fetch away. The labs and illness blocks gain an opt-in to carry row ids; the Coach never asks for them. --- src/app/mcp/__tests__/route.test.ts | 6 + src/lib/ai/coach/illness-snapshot.ts | 13 ++ src/lib/ai/coach/labs-snapshot.ts | 15 ++ .../tools-list.write-session.snap | 142 +++++++++++++++- .../mcp/__tests__/module-gate-sweep.test.ts | 6 + src/lib/mcp/__tests__/search-fetch.test.ts | 22 ++- src/lib/mcp/__tests__/tools.test.ts | 11 ++ src/lib/mcp/lab-reads.ts | 107 ++++++++++++ src/lib/mcp/rich-reads.ts | 16 +- src/lib/mcp/tools/fetch.ts | 160 ++++++++++++------ src/lib/mcp/tools/get-illness-recovery.ts | 53 +++++- src/lib/mcp/tools/get-labs.ts | 34 +++- src/lib/mcp/tools/get-medication-schedule.ts | 40 +++-- src/lib/mcp/tools/get-visits.ts | 5 +- src/lib/mcp/tools/output-shapes.ts | 22 +++ 15 files changed, 564 insertions(+), 88 deletions(-) create mode 100644 src/lib/mcp/lab-reads.ts diff --git a/src/app/mcp/__tests__/route.test.ts b/src/app/mcp/__tests__/route.test.ts index 9a5589b20..d5c956613 100644 --- a/src/app/mcp/__tests__/route.test.ts +++ b/src/app/mcp/__tests__/route.test.ts @@ -52,6 +52,12 @@ vi.mock("@/lib/ai/coach/tools/executor", () => ({ executeCoachTool })); vi.mock("@/lib/ai/coach/tools/inventory", () => ({ buildCoachDataInventory: vi.fn(), })); +// `get_labs` builds its labs block first and asks the Coach only on a miss; +// a miss keeps the health-reader case on the executor it asserts. +vi.mock("@/lib/ai/coach/labs-snapshot", async (importOriginal) => ({ + ...(await importOriginal()), + buildLabsSnapshotBlock: vi.fn(async () => null), +})); vi.mock("@/lib/mcp/server", async (importOriginal) => { const actual = await importOriginal(); return { diff --git a/src/lib/ai/coach/illness-snapshot.ts b/src/lib/ai/coach/illness-snapshot.ts index 09e66f52c..211b6adc6 100644 --- a/src/lib/ai/coach/illness-snapshot.ts +++ b/src/lib/ai/coach/illness-snapshot.ts @@ -107,6 +107,8 @@ export interface CoachIllnessBlock { bodySite?: string; /** The side of `bodySite`, when one is stated. */ laterality?: string; + /** The episode's id; set only for a read that asks for it (`withIds`). */ + id?: string; }>; /** Recently-resolved episodes, newest first — light history context. */ recentResolved: Array<{ @@ -116,6 +118,7 @@ export interface CoachIllnessBlock { resolvedAt: string; bodySite?: string; laterality?: string; + id?: string; }>; /** * v1.40 — the person's own symptoms over the last 14 days, most frequent @@ -208,8 +211,14 @@ export async function buildIllnessSnapshotBlock( * symptoms are history and are read from inside the limit only. */ reach: CoachHistoryReach = UNBOUNDED_REACH, + /** + * `withIds` carries each episode's id, so an MCP read can name the + * condition a client fetches next. The Coach never asks for it. + */ + options: { withIds?: boolean } = {}, ): Promise { if (!(await isIllnessEnabled(userId))) return null; + const idOf = (e: { id: string }) => (options.withIds ? { id: e.id } : {}); const floor = reachFloor(reach, now); const [activeRows, resolvedRows, symptoms] = await Promise.all([ @@ -222,6 +231,7 @@ export async function buildIllnessSnapshotBlock( }, orderBy: { onsetAt: "asc" }, select: { + id: true, label: true, type: true, lifecycle: true, @@ -240,6 +250,7 @@ export async function buildIllnessSnapshotBlock( orderBy: { resolvedAt: "desc" }, take: RESOLVED_HISTORY_LIMIT, select: { + id: true, label: true, type: true, onsetAt: true, @@ -267,6 +278,7 @@ export async function buildIllnessSnapshotBlock( lifecycle: e.lifecycle, onsetAt: e.onsetAt.toISOString(), ...siteFields(e), + ...idOf(e), })), recentResolved: resolvedRows.map((e) => ({ label: sanitizeLabel(e.label), @@ -275,6 +287,7 @@ export async function buildIllnessSnapshotBlock( // `resolvedAt` is non-null by the query filter above. resolvedAt: (e.resolvedAt as Date).toISOString(), ...siteFields(e), + ...idOf(e), })), ...(symptoms.length > 0 ? { symptoms } : {}), }; diff --git a/src/lib/ai/coach/labs-snapshot.ts b/src/lib/ai/coach/labs-snapshot.ts index 85e102617..3b52a6132 100644 --- a/src/lib/ai/coach/labs-snapshot.ts +++ b/src/lib/ai/coach/labs-snapshot.ts @@ -128,6 +128,8 @@ export interface CoachLabReading { rangeStatus: "in-range" | "below" | "above" | "unknown"; /** Measured date (ISO). */ takenAt: string; + /** The lab result's id; set only for a read that asks for it (`withIds`). */ + id?: string; } /** One earlier numeric reading of a biomarker. */ @@ -136,6 +138,8 @@ export interface CoachLabHistoryPoint { /** in-range / below / above / unknown against that reading's own bounds. */ rangeStatus: "in-range" | "below" | "above" | "unknown"; takenAt: string; + /** The lab result's id; set only for a read that asks for it (`withIds`). */ + id?: string; } /** The readings of one calendar year (UTC), folded. */ @@ -174,6 +178,7 @@ export interface LabHistoryInput { value: number; takenAt: Date; rangeStatus: CoachLabHistoryPoint["rangeStatus"]; + id?: string; } const round2 = (value: number) => Math.round(value * 100) / 100; @@ -216,6 +221,7 @@ export function shapeLabHistory( value: reading.value, rangeStatus: reading.rangeStatus, takenAt: reading.takenAt.toISOString(), + ...(reading.id ? { id: reading.id } : {}), })), yearly, yearsOmitted: Math.max(0, years.length - LAB_HISTORY_MAX_YEARS), @@ -239,6 +245,11 @@ export async function buildLabsSnapshotBlock( * With it, each entry carries its `history`. */ analyte?: string; + /** + * Carry each reading's lab result id, so a caller can list what the + * reading is linked to. The Coach never asks: an id means nothing to it. + */ + withIds?: boolean; } = {}, ): Promise { if (!(await isModuleEnabled(userId, "labs"))) return null; @@ -272,6 +283,7 @@ export async function buildLabsSnapshotBlock( orderBy: { takenAt: "desc" }, take: MAX_ROWS, select: { + id: true, analyte: true, panel: true, value: true, @@ -318,6 +330,7 @@ export async function buildLabsSnapshotBlock( const resolved = resolveLabFields(row, row.biomarker); if (wanted && !resolved.analyte.toLowerCase().includes(wanted)) continue; const key = row.biomarkerId ?? `analyte:${resolved.analyte.toLowerCase()}`; + const id = options.withIds ? { id: row.id } : {}; const existing = entries.get(key); if (existing) { // One unit written two ways ("mg/dl", "mg/dL") is one series. @@ -333,6 +346,7 @@ export async function buildLabsSnapshotBlock( row.value, resolved.effectiveRange, ), + ...id, }); } continue; @@ -372,6 +386,7 @@ export async function buildLabsSnapshotBlock( resolved.effectiveRange, ), takenAt: row.takenAt.toISOString(), + ...id, }, }); } diff --git a/src/lib/mcp/__tests__/__snapshots__/tools-list.write-session.snap b/src/lib/mcp/__tests__/__snapshots__/tools-list.write-session.snap index 7982c44bc..0abc8fcce 100644 --- a/src/lib/mcp/__tests__/__snapshots__/tools-list.write-session.snap +++ b/src/lib/mcp/__tests__/__snapshots__/tools-list.write-session.snap @@ -378,7 +378,7 @@ { "name": "get_labs", "title": "Get lab results", - "description": "Fetch the user's lab results. By default returns the latest reading per biomarker over the whole record. Filtered to one named analyte, each matching marker also carries a summarised history (the newest earlier values in full, older ones as one mean / min / max / count per year). Pass history:true with an analyte to return that analyte's reading TRAJECTORY (newest first, paginated). Each reading carries its value, unit, reference range, and in-range/below/above status. Returns { present: false } when no labs match, and { present: false, reason: \"module_disabled\" } when the labs module is switched off.", + "description": "Fetch the user's lab results. By default returns the latest reading per biomarker over the whole record. Filtered to one named analyte, each matching marker also carries a summarised history (the newest earlier values in full, older ones as one mean / min / max / count per year). Pass history:true with an analyte to return that analyte's reading TRAJECTORY (newest first, paginated). Each reading carries its value, unit, reference range, and in-range/below/above status. Each reading (the latest, an earlier value in full, a trajectory reading; not a per-year summary) carries links: the ids of the records it is linked to (the report it was read from, the visits it is filed against), [] when none, at most 25 with linksTruncated; every linked record is described once in linkedRecords (id for fetch, kind, label, date, and for a document imported from Paperless-ngx or Papra its sourceSystem and sourceId). A document is listed only while the Documents module is on. Returns { present: false } when no labs match, and { present: false, reason: \"module_disabled\" } when the labs module is switched off.", "inputSchema": { "type": "object", "properties": { @@ -525,6 +525,15 @@ }, "takenAt": { "type": "string" + }, + "links": { + "type": "array", + "items": { + "type": "string" + } + }, + "linksTruncated": { + "type": "boolean" } }, "required": [ @@ -534,13 +543,67 @@ "referenceLow", "referenceHigh", "rangeStatus", - "takenAt" + "takenAt", + "links", + "linksTruncated" ], "additionalProperties": false } }, "nextCursor": { "type": "string" + }, + "linkedRecords": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "type": "string", + "enum": [ + "visit", + "condition", + "document", + "vaccination", + "lab", + "medication" + ] + }, + "label": { + "type": "string" + }, + "date": { + "type": [ + "string", + "null" + ] + }, + "sourceSystem": { + "type": "string", + "enum": [ + "PAPERLESS", + "PAPRA", + "OTHER" + ] + }, + "sourceId": { + "type": "string" + } + }, + "required": [ + "id", + "kind", + "label", + "date" + ], + "additionalProperties": false + } } }, "required": [ @@ -1672,7 +1735,7 @@ { "name": "get_illness_recovery", "title": "Get illness & recovery", - "description": "Fetch the user's illness + recovery context: rest mode, active and recently-resolved illnesses (with the body site and side when the user named one), the recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). Carries the rest-mode safety flag for framing. Returns { present: false } when there is nothing to report.", + "description": "Fetch the user's illness + recovery context: rest mode, active and recently-resolved illnesses (each with its id, condition:, which fetch accepts and answers with the condition's linked visits and documents, and with the body site and side when the user named one), the recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). Carries the rest-mode safety flag for framing. Returns { present: false } when there is nothing to report.", "inputSchema": { "type": "object", "properties": {} @@ -2917,7 +2980,7 @@ { "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, an as-needed (PRN) flag, and an intakeTracked flag. As-needed medications and medications whose intake tracking is off (intakeTracked: false, kept as a record only) have no due time (nextDueAt: null). Returns { present: false } when no medications are tracked, and { present: false, reason: \"module_disabled\" } when the medications module is switched off.", + "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: its id (med:, which fetch accepts), name, dose, next-due instant, an overdue flag, an as-needed (PRN) flag, an intakeTracked flag, and its directly linked records (links: conditions, documents and visits, each with an id fetch accepts, a label, a date, and for a document imported from Paperless-ngx or Papra its sourceSystem and sourceId; [] when none; at most 25 with linksTruncated; documents only while the Documents module is on). As-needed medications and medications whose intake tracking is off (intakeTracked: false, kept as a record only) have no due time (nextDueAt: null). Returns { present: false } when no medications are tracked, and { present: false, reason: \"module_disabled\" } when the medications module is switched off.", "inputSchema": { "type": "object", "properties": {} @@ -2936,6 +2999,9 @@ "items": { "type": "object", "properties": { + "id": { + "type": "string" + }, "name": { "type": "string" }, @@ -2959,15 +3025,73 @@ }, "intakeTracked": { "type": "boolean" + }, + "links": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "type": "string", + "enum": [ + "visit", + "condition", + "document", + "vaccination", + "lab", + "medication" + ] + }, + "label": { + "type": "string" + }, + "date": { + "type": [ + "string", + "null" + ] + }, + "sourceSystem": { + "type": "string", + "enum": [ + "PAPERLESS", + "PAPRA", + "OTHER" + ] + }, + "sourceId": { + "type": "string" + } + }, + "required": [ + "id", + "kind", + "label", + "date" + ], + "additionalProperties": false + } + }, + "linksTruncated": { + "type": "boolean" } }, "required": [ + "id", "name", "dose", "nextDueAt", "overdue", "asNeeded", - "intakeTracked" + "intakeTracked", + "links", + "linksTruncated" ], "additionalProperties": false } @@ -3623,7 +3747,7 @@ { "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, the body site and side (set on procedures), and the labels of any conditions it was filed against. Optionally narrow to one practitioner by a name substring, or to one kind. kind PROCEDURE is the procedure and surgery history: without a months argument it reads the whole record, because a surgery years ago is still the answer to 'what surgeries have I had'. Newest first, bounded. Returns { present: false } when the user has never recorded a visit — distinct from a filtered read that simply matched none. With the illness module switched off the condition labels are withheld and conditionsReason is \"module_disabled\".", + "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 id (visit:, which fetch accepts and answers with the visit's linked records), 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, the body site and side (set on procedures), and the labels of any conditions it was filed against. Optionally narrow to one practitioner by a name substring, or to one kind. kind PROCEDURE is the procedure and surgery history: without a months argument it reads the whole record, because a surgery years ago is still the answer to 'what surgeries have I had'. Newest first, bounded. Returns { present: false } when the user has never recorded a visit — distinct from a filtered read that simply matched none. With the illness module switched off the condition labels are withheld and conditionsReason is \"module_disabled\".", "inputSchema": { "type": "object", "properties": { @@ -3673,6 +3797,9 @@ "items": { "type": "object", "properties": { + "id": { + "type": "string" + }, "occurredAt": { "type": "string" }, @@ -3735,6 +3862,7 @@ } }, "required": [ + "id", "occurredAt", "status", "kind", @@ -3858,7 +3986,7 @@ { "name": "fetch", "title": "Fetch one health record", - "description": "Hydrate a single record returned by `search`, by its id (e.g. `metric:weight`, `med:`, `lab:LDL`, `visit:`, `condition:`, `document:`, `vaccination:`). Returns { id, title, text, url, metadata } where `text` is a server-authoritative, plain-text prose summary suitable for citation and `url` deep-links to the specific record in HealthLog. A visit, condition, document or vaccination carries its key fields and its directly linked records (metadata.links, each with an id `fetch` accepts); a document returns its metadata and at most a bounded excerpt of its own indexed text (the words of the stored document), never the file; a document held back from AI reading returns no excerpt and metadata.reason \"ai_read_deferred\". Fields that were never recorded are null. Returns a not-found message when the id does not resolve, and metadata.reason \"module_disabled\" when the record's module is switched off.", + "description": "Hydrate a single record returned by `search`, by its id (e.g. `metric:weight`, `med:`, `lab:LDL`, `visit:`, `condition:`, `document:`, `vaccination:`). Returns { id, title, text, url, metadata } where `text` is a server-authoritative, plain-text prose summary suitable for citation and `url` deep-links to the specific record in HealthLog. A visit, condition, document, vaccination, lab or medication carries its key fields and its directly linked records (metadata.links, each with an id `fetch` accepts, a label and a date, at most 25 with metadata.linksTruncated, and \"Linked records: none.\" in the text when there are none); a linked document imported from Paperless-ngx or Papra also names its sourceSystem and sourceId (never an address), and a document is listed only while the Documents module is on. A lab lists the links of its latest reading (the report it was read from, the visits it is filed against); a medication its conditions, documents and visits; a document also lists the lab results read from it and the medications filed against it; a document returns its metadata and at most a bounded excerpt of its own indexed text (the words of the stored document), never the file; a document held back from AI reading returns no excerpt and metadata.reason \"ai_read_deferred\". Fields that were never recorded are null. Returns a not-found message when the id does not resolve, and metadata.reason \"module_disabled\" when the record's module is switched off.", "inputSchema": { "type": "object", "properties": { diff --git a/src/lib/mcp/__tests__/module-gate-sweep.test.ts b/src/lib/mcp/__tests__/module-gate-sweep.test.ts index 88e88aa78..0313cdabc 100644 --- a/src/lib/mcp/__tests__/module-gate-sweep.test.ts +++ b/src/lib/mcp/__tests__/module-gate-sweep.test.ts @@ -22,6 +22,12 @@ vi.mock("@/lib/ai/coach/tools/executor", () => ({ vi.mock("@/lib/ai/coach/tools/inventory", () => ({ buildCoachDataInventory: vi.fn(), })); +// The labs read builds its block first and asks the Coach only on a miss; +// a miss here keeps every read on the executor this sweep counts. +vi.mock("@/lib/ai/coach/labs-snapshot", async (importOriginal) => ({ + ...(await importOriginal()), + buildLabsSnapshotBlock: vi.fn(async () => null), +})); vi.mock("@/lib/mcp/rich-reads", async (importOriginal) => ({ ...(await importOriginal()), getLabHistory: vi.fn(async () => ({ present: true, readings: [] })), diff --git a/src/lib/mcp/__tests__/search-fetch.test.ts b/src/lib/mcp/__tests__/search-fetch.test.ts index 1e35cc35a..ab21a939e 100644 --- a/src/lib/mcp/__tests__/search-fetch.test.ts +++ b/src/lib/mcp/__tests__/search-fetch.test.ts @@ -28,6 +28,19 @@ vi.mock("@/lib/mcp/record-search", async (importOriginal) => ({ ...(await importOriginal()), loadRecordCandidates: vi.fn(async () => []), })); +// A medication or lab fetch lists its links through the link service; none +// are filed here (the links themselves are covered against Postgres in +// `tests/integration/mcp-record-links.test.ts`). +vi.mock("@/lib/links", () => ({ + listTargets: vi.fn(async () => []), + listTargetsBySource: vi.fn(async () => new Map()), +})); +// The lab fetch builds its block first and asks the Coach only on a miss; +// a miss here keeps the lab prose on the executor result each test states. +vi.mock("@/lib/ai/coach/labs-snapshot", async (importOriginal) => ({ + ...(await importOriginal()), + buildLabsSnapshotBlock: vi.fn(async () => null), +})); vi.mock("@/lib/db", () => ({ prisma: { medication: { findMany: vi.fn(), findFirst: vi.fn() }, @@ -291,9 +304,12 @@ describe("free-text fencing", () => { // The block is fenced... expect(result.text.startsWith(USER_TEXT_FENCE_START)).toBe(true); - expect(result.text.endsWith(USER_TEXT_FENCE_END)).toBe(true); - // ...and the payload cannot close it early: exactly one end marker, at the - // very end, so the injected sentence stays inside the data region. + // ...and the payload cannot close it early: exactly one end marker, right + // before the server's own links sentence, so the injected sentence stays + // inside the data region. + expect( + result.text.endsWith(`${USER_TEXT_FENCE_END} Linked records: none.`), + ).toBe(true); expect(result.text.split(USER_TEXT_FENCE_END)).toHaveLength(2); expect(result.text.split(USER_TEXT_FENCE_START)).toHaveLength(2); // The data itself is still delivered — fencing marks it, never drops it. diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 7c1730685..e4fa0aa3e 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -7,6 +7,17 @@ vi.mock("@/lib/ai/coach/tools/executor", () => ({ vi.mock("@/lib/ai/coach/tools/inventory", () => ({ buildCoachDataInventory: vi.fn(), })); +// The labs read builds its block first and asks the Coach only on a miss; a +// miss here keeps the labs tests on the executor result each one states. +vi.mock("@/lib/ai/coach/labs-snapshot", async (importOriginal) => ({ + ...(await importOriginal()), + buildLabsSnapshotBlock: vi.fn(async () => null), +})); +// get_illness_recovery reads the illness block again for the condition ids. +vi.mock("@/lib/ai/coach/illness-snapshot", async (importOriginal) => ({ + ...(await importOriginal()), + buildIllnessSnapshotBlock: vi.fn(async () => null), +})); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), })); diff --git a/src/lib/mcp/lab-reads.ts b/src/lib/mcp/lab-reads.ts new file mode 100644 index 000000000..e4f807095 --- /dev/null +++ b/src/lib/mcp/lab-reads.ts @@ -0,0 +1,107 @@ +/** + * The latest-per-marker labs read behind `get_labs` and `fetch lab:`, with + * what each reading is linked to. + * + * A present read builds the same block the Coach's `get_labs` returns + * (`buildLabsSnapshotBlock`, the whole record, as an MCP read has no lookback + * limit), asking it to carry each reading's lab result id. A miss falls back + * to the caller's Coach read, which owns the grounded empty answer (what the + * record holds, `analyte_not_found`). + * + * The links of a reading are the report it was read from (a document, behind + * the Documents module) and the visits it is filed against. A labs answer + * lists many readings, and one report usually holds a whole panel, so the + * answer names each linked record once, in `linkedRecords`, and a reading + * carries the ids of its links only. Every id there is one `fetch` accepts. + */ +import { buildLabsSnapshotBlock } from "@/lib/ai/coach/labs-snapshot"; +import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach"; +import { boundLinks, linksBySource, type RecordLink } from "./record-links"; + +/** What a lab result lists as linked, in this order. */ +export const LAB_LINK_TARGETS = ["document", "encounter"] as const; + +/** The latest reading per marker with ids, or `fallback()` on a miss. */ +export async function readLatestLabs( + userId: string, + analyte: string | undefined, + fallback: () => Promise, +): Promise { + const data = await buildLabsSnapshotBlock( + userId, + new Date(), + UNBOUNDED_REACH, + { analyte, withIds: true }, + ); + return data ? { present: true, data } : fallback(); +} + +type IdReading = Record & { id: string }; + +/** Every reading of a labs answer that carries its lab result id. */ +function idReadings(result: unknown): IdReading[] { + const r = result as { + readings?: unknown[]; + data?: { recent?: Array<{ history?: { earlier?: unknown[] } }> }; + } | null; + const list: unknown[] = [ + ...(Array.isArray(r?.readings) ? r.readings : []), + ...(Array.isArray(r?.data?.recent) ? r.data.recent : []).flatMap( + (entry) => [entry, ...(entry?.history?.earlier ?? [])], + ), + ]; + return list.filter( + (x): x is IdReading => + typeof x === "object" && + x !== null && + typeof (x as { id?: unknown }).id === "string", + ); +} + +/** + * Replace each reading's lab result id with the ids of what it links to + * (`links`, bounded, with `linksTruncated`), and add the linked records once + * each as `linkedRecords`. A reading linked to nothing carries `links: []`. + * An answer without ids (a miss) comes back unchanged. `result` must be the + * caller's own copy: its readings are rewritten in place. + */ +export async function attachLabLinks( + userId: string, + result: unknown, +): Promise { + const readings = idReadings(result); + if (readings.length === 0) return result; + const bySource = await linksBySource( + userId, + "labResult", + [...new Set(readings.map((r) => r.id))], + LAB_LINK_TARGETS, + ); + const table = new Map(); + for (const reading of readings) { + const { links, linksTruncated } = boundLinks( + bySource.get(reading.id) ?? [], + ); + delete (reading as { id?: string }).id; + reading.links = links.flatMap((l) => (l.id ? [l.id] : [])); + reading.linksTruncated = linksTruncated; + for (const link of links) { + if (link.id && !table.has(link.id)) table.set(link.id, link); + } + } + return { ...(result as object), linkedRecords: [...table.values()] }; +} + +/** The links of one lab result, bounded, for a single-record `fetch`. */ +export async function labResultLinks( + userId: string, + labResultId: string, +): Promise<{ links: RecordLink[]; linksTruncated: boolean }> { + const bySource = await linksBySource( + userId, + "labResult", + [labResultId], + LAB_LINK_TARGETS, + ); + return boundLinks(bySource.get(labResultId) ?? []); +} diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index 391afdd26..721798b34 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -1314,6 +1314,8 @@ export interface LabHistoryReading { rangeStatus: "in-range" | "below" | "above" | "unknown"; /** Measured date (ISO). */ takenAt: string; + /** The lab result's id; `get_labs` turns it into the reading's links. */ + id: string; } export interface LabHistoryResult { @@ -1331,14 +1333,10 @@ export interface LabHistoryResult { export const LAB_HISTORY_MAX_LIMIT = 50; /** - * Return ONE analyte's reading trajectory (newest first) over the user's own - * lab record. Re-uses the SAME resolver + range classifier the latest-only labs - * snapshot and the Labs API use (`resolveLabFields` / `classifyReferenceRange`) - * — no new analytics, no fabricated value. DB-paginated by `offset` so a long - * history stays token-bounded: the page carries at most `limit` readings and a - * `nextCursor` when more exist. The encrypted `noteEncrypted` column is never - * selected, so a decrypted note can never reach the wire. `{ present: false }` - * when no reading matches the analyte. + * ONE analyte's reading trajectory (newest first), through the resolver and + * range classifier the labs snapshot and the Labs API use. Paginated by + * `offset` (at most `limit` readings, `nextCursor` when more exist); never + * selects `noteEncrypted`. `{ present: false }` when nothing matches. */ export async function getLabHistory( userId: string, @@ -1393,6 +1391,7 @@ export async function getLabHistory( skip: offset, take: limit + 1, select: { + id: true, analyte: true, panel: true, value: true, @@ -1443,6 +1442,7 @@ export async function getLabHistory( resolved.effectiveRange, ), takenAt: row.takenAt.toISOString(), + id: row.id, }; }); diff --git a/src/lib/mcp/tools/fetch.ts b/src/lib/mcp/tools/fetch.ts index 01ca9aac0..75d6bb742 100644 --- a/src/lib/mcp/tools/fetch.ts +++ b/src/lib/mcp/tools/fetch.ts @@ -17,7 +17,18 @@ import { type NutrientsDailyResult, } from "@/lib/mcp/nutrients-read"; import { fenceUserText, scrubFenceMarkers } from "@/lib/ai/coach/data-fence"; -import { fetchRecord, isRecordKind } from "@/lib/mcp/record-search"; +import { + fetchRecord, + isRecordKind, + type FetchResult, +} from "@/lib/mcp/record-search"; +import { labResultLinks, readLatestLabs } from "@/lib/mcp/lab-reads"; +import { + boundLinks, + linksBySource, + linksSentence, + MEDICATION_LINK_TARGETS, +} from "@/lib/mcp/record-links"; import { plainClinicalText, plainLabText, @@ -26,6 +37,99 @@ import { } from "./render/citation-text"; import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types"; +/** The lab result id of the reading `plainLabText` quotes, when it has one. */ +function latestLabId(result: unknown): string | null { + const r = result as { present?: boolean; data?: { recent?: unknown[] } }; + const first = r?.present && Array.isArray(r.data?.recent) && r.data.recent[0]; + const id = first ? (first as { id?: unknown }).id : undefined; + return typeof id === "string" ? id : null; +} + +/** `fetch lab:`: the latest reading, with what it is linked to. */ +async function fetchLab( + userId: string, + id: string, + rid: string, + origin: string, +): Promise { + const result = await readLatestLabs(userId, rid, () => + executeCoachTool({ + userId, + name: "get_labs", + rawArguments: JSON.stringify({ analyte: rid }), + }), + ); + // The links of the reading the text quotes, the latest one. + const latestId = latestLabId(result); + const linked = latestId ? await labResultLinks(userId, latestId) : null; + return { + id, + title: rid, + text: + plainLabText(rid, result) + + (linked ? ` ${linksSentence(linked.links)}` : ""), + // Deep-link to the labs surface filtered to this analyte. + url: `${origin}/labs?analyte=${encodeURIComponent(rid)}`, + metadata: { type: "lab", analyte: rid, ...linked }, + }; +} + +/** `fetch med:`: the medication, with what it is linked to. */ +async function fetchMedication( + userId: string, + id: string, + rid: string, + origin: string, +): Promise { + const med = await prisma.medication.findFirst({ + where: { id: rid, userId }, + select: { + name: true, + dose: true, + treatmentClass: true, + asNeeded: true, + }, + }); + if (!med) { + return { + id, + title: "Not found", + text: "No medication matches this id.", + url: `${origin}/medications`, + metadata: { type: "medication" }, + }; + } + // Plain-text prose, not a JSON blob. `name` / `dose` / + // `treatmentClass` are user-controlled free text returned as DATA, + // never interpreted — fenced so that contract is stated on the wire + // rather than only in the server instructions. + const text = fenceUserText( + `${med.name}${med.dose ? ` ${med.dose}` : ""}` + + `${med.asNeeded ? " (as needed)" : ""}` + + `${med.treatmentClass ? ` — ${med.treatmentClass}` : ""}.`, + ); + const linked = await linksBySource( + userId, + "medication", + [rid], + MEDICATION_LINK_TARGETS, + ); + const { links, linksTruncated } = boundLinks(linked.get(rid) ?? []); + return { + id, + title: scrubFenceMarkers(med.name), + text: `${text} ${linksSentence(links)}`, + // Per-item deep-link to the medication detail page. + url: `${origin}/medications/${encodeURIComponent(rid)}`, + metadata: { + type: "medication", + medicationId: rid, + links, + linksTruncated, + }, + }; +} + /** * `fetch`, the second half of the `search` + `fetch` pair (see `search.ts` * for the wire shapes ChatGPT mandates): hydrates one id into a plain-text @@ -35,7 +139,7 @@ export const fetchTool: McpToolDefinition = { name: "fetch", title: "Fetch one health record", description: - 'Hydrate a single record returned by `search`, by its id (e.g. `metric:weight`, `med:`, `lab:LDL`, `visit:`, `condition:`, `document:`, `vaccination:`). Returns { id, title, text, url, metadata } where `text` is a server-authoritative, plain-text prose summary suitable for citation and `url` deep-links to the specific record in HealthLog. A visit, condition, document or vaccination carries its key fields and its directly linked records (metadata.links, each with an id `fetch` accepts); a document returns its metadata and at most a bounded excerpt of its own indexed text (the words of the stored document), never the file; a document held back from AI reading returns no excerpt and metadata.reason \"ai_read_deferred\". Fields that were never recorded are null. Returns a not-found message when the id does not resolve, and metadata.reason "module_disabled" when the record\'s module is switched off.', + 'Hydrate a single record returned by `search`, by its id (e.g. `metric:weight`, `med:`, `lab:LDL`, `visit:`, `condition:`, `document:`, `vaccination:`). Returns { id, title, text, url, metadata } where `text` is a server-authoritative, plain-text prose summary suitable for citation and `url` deep-links to the specific record in HealthLog. A visit, condition, document, vaccination, lab or medication carries its key fields and its directly linked records (metadata.links, each with an id `fetch` accepts, a label and a date, at most 25 with metadata.linksTruncated, and \"Linked records: none.\" in the text when there are none); a linked document imported from Paperless-ngx or Papra also names its sourceSystem and sourceId (never an address), and a document is listed only while the Documents module is on. A lab lists the links of its latest reading (the report it was read from, the visits it is filed against); a medication its conditions, documents and visits; a document also lists the lab results read from it and the medications filed against it; a document returns its metadata and at most a bounded excerpt of its own indexed text (the words of the stored document), never the file; a document held back from AI reading returns no excerpt and metadata.reason \"ai_read_deferred\". Fields that were never recorded are null. Returns a not-found message when the id does not resolve, and metadata.reason "module_disabled" when the record\'s module is switched off.', inputShape: { id: z.string().min(1).max(200) }, annotations: READ_ONLY_ANNOTATIONS, outputShape: { @@ -140,58 +244,10 @@ export const fetchTool: McpToolDefinition = { }; } - if (kind === "lab" && rid) { - const result = await executeCoachTool({ - userId: ctx.userId, - name: "get_labs", - rawArguments: JSON.stringify({ analyte: rid }), - }); - return { - id, - title: rid, - text: plainLabText(rid, result), - // Deep-link to the labs surface filtered to this analyte. - url: `${origin}/labs?analyte=${encodeURIComponent(rid)}`, - metadata: { type: "lab", analyte: rid }, - }; - } + if (kind === "lab" && rid) return fetchLab(ctx.userId, id, rid, origin); if (kind === "med" && rid) { - const med = await prisma.medication.findFirst({ - where: { id: rid, userId: ctx.userId }, - select: { - name: true, - dose: true, - treatmentClass: true, - asNeeded: true, - }, - }); - if (!med) { - return { - id, - title: "Not found", - text: "No medication matches this id.", - url: `${origin}/medications`, - metadata: { type: "medication" }, - }; - } - // Plain-text prose, not a JSON blob. `name` / `dose` / - // `treatmentClass` are user-controlled free text returned as DATA, - // never interpreted — fenced so that contract is stated on the wire - // rather than only in the server instructions. - const text = fenceUserText( - `${med.name}${med.dose ? ` ${med.dose}` : ""}` + - `${med.asNeeded ? " (as needed)" : ""}` + - `${med.treatmentClass ? ` — ${med.treatmentClass}` : ""}.`, - ); - return { - id, - title: scrubFenceMarkers(med.name), - text, - // Per-item deep-link to the medication detail page. - url: `${origin}/medications/${encodeURIComponent(rid)}`, - metadata: { type: "medication", medicationId: rid }, - }; + return fetchMedication(ctx.userId, id, rid, origin); } if (isRecordKind(kind) && rid) { diff --git a/src/lib/mcp/tools/get-illness-recovery.ts b/src/lib/mcp/tools/get-illness-recovery.ts index f2a2d7dc7..6be7a4776 100644 --- a/src/lib/mcp/tools/get-illness-recovery.ts +++ b/src/lib/mcp/tools/get-illness-recovery.ts @@ -1,4 +1,6 @@ import { fenceUserText } from "@/lib/ai/coach/data-fence"; +import { buildIllnessSnapshotBlock } from "@/lib/ai/coach/illness-snapshot"; +import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach"; import { coachReadOutput } from "./output-shapes"; import { runCoachTool } from "./shared"; import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types"; @@ -48,17 +50,64 @@ function fenceIllnessSites(result: unknown): unknown { }; } +type IllnessEntry = Record & { id?: string }; + +/** + * Name each listed condition by the id `fetch` accepts (`condition:`), so + * its linked visits and documents are one call away. + * + * The Coach's block carries no ids (an id means nothing to it), so the block + * is read again by the same builder, asking for them, and its two lists take + * the place of the Coach's. Should the second read find nothing (a condition + * deleted in between), the Coach's lists stay as they were. + */ +async function withConditionIds( + userId: string, + result: unknown, +): Promise { + const r = result as { + data?: { illness?: Record } & Record; + }; + if (!r?.data?.illness) return result; + const fresh = await buildIllnessSnapshotBlock( + userId, + new Date(), + UNBOUNDED_REACH, + { withIds: true }, + ); + if (!fresh) return result; + const named = (list: IllnessEntry[]) => + list.map(({ id, ...entry }) => + id ? { id: `condition:${id}`, ...entry } : entry, + ); + return { + ...r, + data: { + ...r.data, + illness: { + ...r.data.illness, + restMode: fresh.restMode, + active: named(fresh.active), + recentResolved: named(fresh.recentResolved), + }, + }, + }; +} + export const getIllnessRecoveryTool: McpToolDefinition = { name: "get_illness_recovery", title: "Get illness & recovery", description: - "Fetch the user's illness + recovery context: rest mode, active and recently-resolved illnesses (with the body site and side when the user named one), the recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). Carries the rest-mode safety flag for framing. Returns { present: false } when there is nothing to report.", + "Fetch the user's illness + recovery context: rest mode, active and recently-resolved illnesses (each with its id, condition:, which fetch accepts and answers with the condition's linked visits and documents, and with the body site and side when the user named one), the recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). Carries the rest-mode safety flag for framing. Returns { present: false } when there is nothing to report.", inputShape: {}, annotations: READ_ONLY_ANNOTATIONS, outputShape: coachReadOutput, async run(ctx, args) { return fenceIllnessSites( - await runCoachTool(ctx, "get_illness_recovery", args), + await withConditionIds( + ctx.userId, + await runCoachTool(ctx, "get_illness_recovery", args), + ), ); }, }; diff --git a/src/lib/mcp/tools/get-labs.ts b/src/lib/mcp/tools/get-labs.ts index f9c3dec33..3439d9e13 100644 --- a/src/lib/mcp/tools/get-labs.ts +++ b/src/lib/mcp/tools/get-labs.ts @@ -4,8 +4,10 @@ import { fenceUserText } from "@/lib/ai/coach/data-fence"; import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve"; import { getLabHistory, LAB_HISTORY_MAX_LIMIT } from "@/lib/mcp/rich-reads"; import { decodeOffsetCursor } from "@/lib/mcp/pagination"; +import { annotate } from "@/lib/logging/context"; +import { attachLabLinks, readLatestLabs } from "@/lib/mcp/lab-reads"; import type { McpAuthContext } from "../auth"; -import { coachReadOutput } from "./output-shapes"; +import { coachReadOutput, recordLinkSchema } from "./output-shapes"; import { runCoachTool, whenModuleOn } from "./shared"; import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types"; @@ -25,10 +27,16 @@ const getLabsOutput: z.ZodRawShape = { referenceHigh: z.number().nullable(), rangeStatus: z.enum(["in-range", "below", "above", "unknown"]), takenAt: z.string(), + // The fetch ids of what this reading links to; each is described + // once in `linkedRecords`. + links: z.array(z.string()), + linksTruncated: z.boolean(), }), ) .optional(), nextCursor: z.string().optional(), + // Both modes: every record a listed reading links to, named once. + linkedRecords: z.array(recordLinkSchema).optional(), }; /** The free-text fields of a lab reading: a document's words, never instructions. */ @@ -83,14 +91,30 @@ function readLabs( // Default: latest reading per biomarker via the Coach labs read. A name that // resolves to one of the user's markers (an alias or a catalog abbreviation // such as "GPT" for "ALT") is read as that marker's own name (v1.43). + // A present read carries each reading's lab result id (see `lab-reads.ts`); + // a miss is the Coach read's grounded answer. return (async () => { const asked = typeof args.analyte === "string" ? args.analyte : null; const marker = asked?.trim() ? await resolveMarkerForRead(ctx.userId, asked) : null; - return runCoachTool(ctx, "get_labs", { - ...(asked !== null ? { analyte: marker?.name ?? asked } : {}), + const analyte = asked !== null ? (marker?.name ?? asked) : undefined; + let missed = false; + const result = await readLatestLabs(ctx.userId, analyte, () => { + missed = true; + return runCoachTool( + ctx, + "get_labs", + analyte !== undefined ? { analyte } : {}, + ); }); + if (!missed) { + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_labs", present: true }, + }); + } + return result; })(); } @@ -98,7 +122,7 @@ export const getLabsTool: McpToolDefinition = { name: "get_labs", title: "Get lab results", description: - "Fetch the user's lab results. By default returns the latest reading per biomarker over the whole record. Filtered to one named analyte, each matching marker also carries a summarised history (the newest earlier values in full, older ones as one mean / min / max / count per year). Pass history:true with an analyte to return that analyte's reading TRAJECTORY (newest first, paginated). Each reading carries its value, unit, reference range, and in-range/below/above status. Returns { present: false } when no labs match, and { present: false, reason: \"module_disabled\" } when the labs module is switched off.", + "Fetch the user's lab results. By default returns the latest reading per biomarker over the whole record. Filtered to one named analyte, each matching marker also carries a summarised history (the newest earlier values in full, older ones as one mean / min / max / count per year). Pass history:true with an analyte to return that analyte's reading TRAJECTORY (newest first, paginated). Each reading carries its value, unit, reference range, and in-range/below/above status. Each reading (the latest, an earlier value in full, a trajectory reading; not a per-year summary) carries links: the ids of the records it is linked to (the report it was read from, the visits it is filed against), [] when none, at most 25 with linksTruncated; every linked record is described once in linkedRecords (id for fetch, kind, label, date, and for a document imported from Paperless-ngx or Papra its sourceSystem and sourceId). A document is listed only while the Documents module is on. Returns { present: false } when no labs match, and { present: false, reason: \"module_disabled\" } when the labs module is switched off.", inputShape: { analyte: z.string().min(1).max(80).optional(), history: z @@ -119,7 +143,7 @@ export const getLabsTool: McpToolDefinition = { outputShape: getLabsOutput, run(ctx, args) { return whenModuleOn(ctx, "labs", "get_labs", async () => - fenceLabText(await readLabs(ctx, args)), + attachLabLinks(ctx.userId, fenceLabText(await readLabs(ctx, args))), ); }, }; diff --git a/src/lib/mcp/tools/get-medication-schedule.ts b/src/lib/mcp/tools/get-medication-schedule.ts index 27bcf479a..2f455385f 100644 --- a/src/lib/mcp/tools/get-medication-schedule.ts +++ b/src/lib/mcp/tools/get-medication-schedule.ts @@ -15,7 +15,13 @@ import { } from "@/lib/medications/scheduling/next-due"; import { dueSchedules } from "@/lib/medications/intake-tracking"; import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era"; +import { + boundLinks, + linksBySource, + MEDICATION_LINK_TARGETS, +} from "@/lib/mcp/record-links"; import { MODULE_DISABLED } from "./shared"; +import { recordLinkSchema } from "./output-shapes"; import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types"; import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; @@ -33,12 +39,16 @@ const getMedicationScheduleOutput: z.ZodRawShape = { medications: z .array( z.object({ + // `med:`, the id `fetch` accepts. + id: z.string(), name: z.string(), dose: z.string().nullable(), nextDueAt: z.string().nullable(), overdue: z.boolean(), asNeeded: z.boolean(), intakeTracked: z.boolean(), + links: z.array(recordLinkSchema), + linksTruncated: z.boolean(), }), ) .optional(), @@ -48,7 +58,7 @@ export const getMedicationScheduleTool: McpToolDefinition = { 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, an as-needed (PRN) flag, and an intakeTracked flag. As-needed medications and medications whose intake tracking is off (intakeTracked: false, kept as a record only) have no due time (nextDueAt: null). Returns { present: false } when no medications are tracked, and { present: false, reason: "module_disabled" } when the medications module is switched off.', + '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: its id (med:, which fetch accepts), name, dose, next-due instant, an overdue flag, an as-needed (PRN) flag, an intakeTracked flag, and its directly linked records (links: conditions, documents and visits, each with an id fetch accepts, a label, a date, and for a document imported from Paperless-ngx or Papra its sourceSystem and sourceId; [] when none; at most 25 with linksTruncated; documents only while the Documents module is on). As-needed medications and medications whose intake tracking is off (intakeTracked: false, kept as a record only) have no due time (nextDueAt: null). Returns { present: false } when no medications are tracked, and { present: false, reason: "module_disabled" } when the medications module is switched off.', inputShape: {}, annotations: READ_ONLY_ANNOTATIONS, outputShape: getMedicationScheduleOutput, @@ -140,15 +150,23 @@ export const getMedicationScheduleTool: McpToolDefinition = { // Under auto-take a rolling cadence stands where the job // will record next (see `auto-take.ts`). - const engineLastIntakeAt = await loadAutoTakeRollingAnchors(prisma, { - userId: ctx.userId, - medicationIds: medications - .filter((m) => m.autoMarkTaken) - .map((m) => m.id), - now, - userTz, - lastIntakeAt: (id) => lastTakenAtByMedId.get(id) ?? null, - }); + const [engineLastIntakeAt, linksByMedId] = await Promise.all([ + loadAutoTakeRollingAnchors(prisma, { + userId: ctx.userId, + medicationIds: medications + .filter((m) => m.autoMarkTaken) + .map((m) => m.id), + now, + userTz, + lastIntakeAt: (id) => lastTakenAtByMedId.get(id) ?? null, + }), + linksBySource( + ctx.userId, + "medication", + medications.map((m) => m.id), + MEDICATION_LINK_TARGETS, + ), + ]); const rows = medications.map((m) => { const display = computeDisplayDue({ @@ -168,6 +186,7 @@ export const getMedicationScheduleTool: McpToolDefinition = { eraStart: eraStartByMedId.get(m.id) ?? null, }); return { + id: `med:${m.id}`, // The person's own words: fenced as data, as the server // instructions promise for medication names. name: fenceUserText(m.name), @@ -176,6 +195,7 @@ export const getMedicationScheduleTool: McpToolDefinition = { overdue: display ? display.overdue : false, asNeeded: m.asNeeded, intakeTracked: m.trackIntake, + ...boundLinks(linksByMedId.get(m.id) ?? []), }; }); diff --git a/src/lib/mcp/tools/get-visits.ts b/src/lib/mcp/tools/get-visits.ts index 2b2792ed4..3d1b47ccc 100644 --- a/src/lib/mcp/tools/get-visits.ts +++ b/src/lib/mcp/tools/get-visits.ts @@ -28,6 +28,8 @@ const getVisitsOutput: z.ZodRawShape = { visits: z .array( z.object({ + // `visit:`, the id `fetch` accepts for the visit and its links. + id: z.string(), occurredAt: z.string(), status: z.string(), kind: z.string(), @@ -58,7 +60,7 @@ export const getVisitsTool: McpToolDefinition = { 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, the body site and side (set on procedures), and the labels of any conditions it was filed against. Optionally narrow to one practitioner by a name substring, or to one kind. kind PROCEDURE is the procedure and surgery history: without a months argument it reads the whole record, because a surgery years ago is still the answer to 'what surgeries have I had'. Newest first, bounded. Returns { present: false } when the user has never recorded a visit — distinct from a filtered read that simply matched none. With the illness module switched off the condition labels are withheld and conditionsReason is \"module_disabled\".", + "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 id (visit:, which fetch accepts and answers with the visit's linked records), 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, the body site and side (set on procedures), and the labels of any conditions it was filed against. Optionally narrow to one practitioner by a name substring, or to one kind. kind PROCEDURE is the procedure and surgery history: without a months argument it reads the whole record, because a surgery years ago is still the answer to 'what surgeries have I had'. Newest first, bounded. Returns { present: false } when the user has never recorded a visit — distinct from a filtered read that simply matched none. With the illness module switched off the condition labels are withheld and conditionsReason is \"module_disabled\".", inputShape: { months: z .number() @@ -167,6 +169,7 @@ export const getVisitsTool: McpToolDefinition = { // entry is deleted, as the web record does. const seenBy = r.practitioner; return { + id: `visit:${r.id}`, occurredAt: r.occurredAt.toISOString(), status: r.status, kind: r.kind, diff --git a/src/lib/mcp/tools/output-shapes.ts b/src/lib/mcp/tools/output-shapes.ts index e08d575e8..c880ac764 100644 --- a/src/lib/mcp/tools/output-shapes.ts +++ b/src/lib/mcp/tools/output-shapes.ts @@ -1,6 +1,7 @@ import { z } from "zod/v4"; import { isoDateTime } from "@/lib/validations/datetime"; +import { DOCUMENT_SOURCE_SYSTEMS } from "@/lib/validations/inbound-documents"; // ── Output schemas ─────────────────────────────────────────────────── // Every field except the presence anchor (`present`) is optional so a grounded @@ -66,3 +67,24 @@ export const dateRangeShape = z .describe( "An explicit inclusive date range. Use INSTEAD OF `window` for an arbitrary span (e.g. before vs after a date). Both bounds are ISO-8601 instants.", ); + +/** + * One linked record, as `fetch` lists it in `metadata.links` and the lab and + * medication reads list it (see `record-links.ts`). `id` is one `fetch` + * accepts; an imported document adds its source system and its id there. + */ +export const recordLinkSchema = z.object({ + id: z.string().nullable(), + kind: z.enum([ + "visit", + "condition", + "document", + "vaccination", + "lab", + "medication", + ]), + label: z.string(), + date: z.string().nullable(), + sourceSystem: z.enum(DOCUMENT_SOURCE_SYSTEMS).optional(), + sourceId: z.string().optional(), +});