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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-02 20:24:28 +01:00
1 parent ea7c6c860e
commit 34302867aa
15 files changed
+564 -88

No files matched your search

+6
View File
@@ -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<typeof import("@/lib/ai/coach/labs-snapshot")>()),
buildLabsSnapshotBlock: vi.fn(async () => null),
}));
vi.mock("@/lib/mcp/server", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/mcp/server")>();
return {
+13
View File
@@ -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<CoachIllnessBlock | null> {
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 } : {}),
};
+15
View File
@@ -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<CoachLabsBlock | null> {
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,
},
});
}
@@ -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:<id>, 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:<id>, 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:<id>, 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:<id>`, `lab:LDL`, `visit:<id>`, `condition:<id>`, `document:<id>`, `vaccination:<id>`). 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:<id>`, `lab:LDL`, `visit:<id>`, `condition:<id>`, `document:<id>`, `vaccination:<id>`). 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": {
@@ -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<typeof import("@/lib/ai/coach/labs-snapshot")>()),
buildLabsSnapshotBlock: vi.fn(async () => null),
}));
vi.mock("@/lib/mcp/rich-reads", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/mcp/rich-reads")>()),
getLabHistory: vi.fn(async () => ({ present: true, readings: [] })),
+19 -3
View File
@@ -28,6 +28,19 @@ vi.mock("@/lib/mcp/record-search", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/mcp/record-search")>()),
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<typeof import("@/lib/ai/coach/labs-snapshot")>()),
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.
+11
View File
@@ -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<typeof import("@/lib/ai/coach/labs-snapshot")>()),
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<typeof import("@/lib/ai/coach/illness-snapshot")>()),
buildIllnessSnapshotBlock: vi.fn(async () => null),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
+107
View File
@@ -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<unknown>,
): Promise<unknown> {
const data = await buildLabsSnapshotBlock(
userId,
new Date(),
UNBOUNDED_REACH,
{ analyte, withIds: true },
);
return data ? { present: true, data } : fallback();
}
type IdReading = Record<string, unknown> & { 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<unknown> {
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<string, RecordLink>();
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) ?? []);
}
+8 -8
View File
@@ -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,
};
});
+108 -52
View File
@@ -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:<analyte>`: the latest reading, with what it is linked to. */
async function fetchLab(
userId: string,
id: string,
rid: string,
origin: string,
): Promise<FetchResult> {
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:<id>`: the medication, with what it is linked to. */
async function fetchMedication(
userId: string,
id: string,
rid: string,
origin: string,
): Promise<FetchResult> {
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:<id>`, `lab:LDL`, `visit:<id>`, `condition:<id>`, `document:<id>`, `vaccination:<id>`). 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:<id>`, `lab:LDL`, `visit:<id>`, `condition:<id>`, `document:<id>`, `vaccination:<id>`). 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) {
+51 -2
View File
@@ -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<string, unknown> & { id?: string };
/**
* Name each listed condition by the id `fetch` accepts (`condition:<id>`), 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<unknown> {
const r = result as {
data?: { illness?: Record<string, unknown> } & Record<string, unknown>;
};
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:<id>, 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),
),
);
},
};
+29 -5
View File
@@ -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))),
);
},
};
+30 -10
View File
@@ -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:<id>`, 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:<id>, 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) ?? []),
};
});
+4 -1
View File
@@ -28,6 +28,8 @@ const getVisitsOutput: z.ZodRawShape = {
visits: z
.array(
z.object({
// `visit:<id>`, 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:<id>, 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,
+22
View File
@@ -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(),
});