fix(mcp): keep notes and held-back document text off the wire, gate every read

Follow-ups to the clinical-record search, test first:

- Condition and vaccination notes are no longer searched or returned.
  Both reads use an explicit column list without the note, as the Coach
  illness snapshot and the doctor report already do. A structural guard
  fails when any MCP read of either model names noteEncrypted or has no
  explicit select.
- A document uploaded with AI reading deferred is not searched by its
  text and returns no excerpt (metadata.reason "ai_read_deferred"); its
  title, kind and links stay findable.
- Documents older than the newest 500 are found by title and file name
  through a bounded SQL lookup on those plaintext columns.
- Query words shorter than four letters match whole words or word starts
  only, so "arm" no longer finds "Darm".
- A deleted practitioner is no longer named or matched, in search, fetch,
  get_visits and the appointments in get_preventive_care. The empty-query
  listing shows visits that happened, not booked ones.
- get_labs, get_medication_schedule, get_medication_compliance and the
  lab and medication resources (read, list, completion) answer
  { present: false, reason: "module_disabled" } when their module is off,
  without reading. get_visits withholds condition labels when illness is
  off and says so in conditionsReason.
- The consent screen and the tool descriptions name what a connection
  can read, the indexed text of stored documents included.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 13:26:55 +02:00
1 parent 639a30e133
commit 836f72605f
9 files changed
+810 -101

No files matched your search

@@ -106,6 +106,16 @@ describe("GET /authorize — consent gate", () => {
expect(body).not.toMatch(/hlac_/);
});
it("names what the connection can read, document text included", async () => {
signedIn();
const body = await (await GET(getReq(authorizeUrl()) as never)).text();
// v1.39.3: search and fetch reach visits, conditions and the indexed
// text of stored documents, so the consent says so before Allow.
expect(body).toMatch(/visits/);
expect(body).toMatch(/text of your stored documents/);
expect(body).toMatch(/held back from AI reading/);
});
it("prompts sign-in (with a return link) when there is no session", async () => {
const res = await GET(getReq(authorizeUrl()) as never);
expect(res.status).toBe(200);
+5
View File
@@ -375,6 +375,10 @@ async function authorizeGet(request: NextRequest): Promise<Response> {
const accessSummary = writeGranted
? `<p><strong>This grants read AND write access:</strong> the application will be able to read your own health records <strong>and log new measurements and mood entries</strong> to your account on your behalf. It cannot delete or change existing entries, edit medications, or reach admin functions.</p>`
: `<p>Scope: <code>${htmlEscape(v.scope)}</code> — read-only access to your own health records.</p>`;
// What a read reaches, in plain words, before the person clicks Allow.
// Document text is named on its own because it is the widest thing a
// connection can read (v1.39.3: search and fetch reach it).
const readSummary = `<p>What it can read: your measurements, medications, lab values, visits and procedures, conditions and vaccinations, and your stored documents, including the text of your stored documents that HealthLog has indexed. It never receives the files themselves. A document you held back from AI reading is shared by its title and details only, without its text. Anything in a module you have switched off is not shared.</p>`;
// The one response whose form may lead off-origin: allow exactly the
// redirect URI `validate()` just matched against the resolved client.
return withAuthorizeCsp(
@@ -385,6 +389,7 @@ async function authorizeGet(request: NextRequest): Promise<Response> {
${provenance}
<p>Scope: <code>${htmlEscape(v.scope)}</code></p>
${accessSummary}
${readSummary}
<form method="POST" action="/api/mcp/oauth/authorize">
${hidden("response_type", "code")}
${hidden("client_id", v.clientId)}
@@ -0,0 +1,186 @@
/**
* Every MCP read of medications, labs and conditions honours the module
* switch (v1.39.3 review).
*
* `search` and `fetch` gained the rule with the clinical-record kinds; the
* rest of the surface has to say the same thing, or the claim that a
* switched-off module is invisible to a connected assistant is only true of
* two tools. Each read below answers `{ present: false, reason:
* "module_disabled" }` (or lists nothing) without touching the table.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const disabled = new Set<string>();
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn(async (_u: string, key: string) => !disabled.has(key)),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/ai/coach/tools/executor", () => ({
executeCoachTool: vi.fn(async () => ({ present: true, data: {} })),
}));
vi.mock("@/lib/ai/coach/tools/inventory", () => ({
buildCoachDataInventory: vi.fn(),
}));
vi.mock("@/lib/mcp/rich-reads", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/mcp/rich-reads")>()),
getLabHistory: vi.fn(async () => ({ present: true, readings: [] })),
}));
vi.mock("@/lib/links", () => ({
listTargets: vi.fn(async () => []),
listTargetsBySource: vi.fn(
async () =>
new Map([["v-1", [{ id: "c-1", label: "Knee pain", date: null }]]]),
),
}));
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
decryptFromBytes: (b: Uint8Array) => Buffer.from(b).toString("utf8"),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(async () => ({ timezone: "UTC" })) },
medication: {
findMany: vi.fn(async () => [
{ id: "m-1", name: "Ramipril", dose: "5mg", schedules: [] },
]),
findFirst: vi.fn(async () => ({
id: "m-1",
name: "Ramipril",
dose: "5mg",
schedules: [],
})),
},
labResult: { findMany: vi.fn(async () => [{ analyte: "LDL" }]) },
encounter: {
findMany: vi.fn(async () => [
{
id: "v-1",
occurredAt: new Date("2025-01-01T00:00:00Z"),
status: "DONE",
kind: "ROUTINE",
reasonEncrypted: null,
outcomeEncrypted: null,
bodySiteEncrypted: null,
laterality: null,
practitioner: {
name: "Dr. Gone",
specialty: "Cardiology",
deletedAt: new Date(),
},
},
]),
},
},
}));
import { MCP_TOOLS } from "../tools";
import { MCP_RESOURCES, MCP_RESOURCE_TEMPLATES } from "../resources";
import { prisma } from "@/lib/db";
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
import { getLabHistory } from "@/lib/mcp/rich-reads";
import { listTargetsBySource } from "@/lib/links";
import type { McpAuthContext } from "../auth";
const CTX: McpAuthContext = {
userId: "u1",
tokenId: "t",
scopes: ["health:read"],
binding: "u1:t",
canRead: true,
canWrite: false,
};
const OFF = { present: false, reason: "module_disabled" };
const tool = (name: string) => MCP_TOOLS.find((t) => t.name === name)!;
const template = (name: string) =>
MCP_RESOURCE_TEMPLATES.find((t) => t.name === name)!;
const resource = (uri: string) => MCP_RESOURCES.find((r) => r.uri === uri)!;
beforeEach(() => {
vi.clearAllMocks();
disabled.clear();
});
describe("labs switched off", () => {
beforeEach(() => disabled.add("labs"));
it("get_labs refuses in both modes without reading", async () => {
expect(await tool("get_labs").run(CTX, {})).toEqual(OFF);
expect(
await tool("get_labs").run(CTX, { analyte: "LDL", history: true }),
).toEqual(OFF);
expect(executeCoachTool).not.toHaveBeenCalled();
expect(getLabHistory).not.toHaveBeenCalled();
});
it("the lab resource template reads, lists and completes nothing", async () => {
const lab = template("lab");
expect(await lab.read(CTX, { analyte: "LDL" })).toEqual(OFF);
expect(await lab.list!(CTX)).toEqual([]);
expect(await lab.complete!.analyte(CTX, "L")).toEqual([]);
expect(prisma.labResult.findMany).not.toHaveBeenCalled();
});
});
describe("medications switched off", () => {
beforeEach(() => disabled.add("medications"));
it("the schedule and compliance tools refuse without reading", async () => {
expect(await tool("get_medication_schedule").run(CTX, {})).toEqual(OFF);
expect(await tool("get_medication_compliance").run(CTX, {})).toEqual(OFF);
expect(prisma.medication.findMany).not.toHaveBeenCalled();
expect(executeCoachTool).not.toHaveBeenCalled();
});
it("the medication resources read and list nothing", async () => {
const med = template("medication");
expect(await med.read(CTX, { id: "m-1" })).toEqual(OFF);
expect(await med.list!(CTX)).toEqual([]);
expect(await resource("healthlog://medications").read(CTX)).toEqual(OFF);
expect(prisma.medication.findMany).not.toHaveBeenCalled();
expect(prisma.medication.findFirst).not.toHaveBeenCalled();
});
});
describe("modules on", () => {
it("the same reads still answer", async () => {
await tool("get_labs").run(CTX, {});
await tool("get_medication_compliance").run(CTX, {});
expect(executeCoachTool).toHaveBeenCalledTimes(2);
expect(await template("lab").list!(CTX)).toHaveLength(1);
expect(await template("medication").list!(CTX)).toHaveLength(1);
});
});
describe("get_visits", () => {
it("drops condition labels when illness is off, and says so", async () => {
disabled.add("illness");
const result = (await tool("get_visits").run(CTX, {})) as {
present: boolean;
conditionsReason?: string;
visits: Array<{ conditions: string[] }>;
};
expect(result.present).toBe(true);
expect(result.conditionsReason).toBe("module_disabled");
expect(result.visits[0].conditions).toEqual([]);
expect(listTargetsBySource).not.toHaveBeenCalled();
});
it("keeps condition labels when illness is on", async () => {
const result = (await tool("get_visits").run(CTX, {})) as {
conditionsReason?: string;
visits: Array<{ conditions: string[] }>;
};
expect(result.conditionsReason).toBeUndefined();
expect(result.visits[0].conditions).toEqual(["Knee pain"]);
});
it("does not name a deleted practitioner", async () => {
const result = (await tool("get_visits").run(CTX, {})) as {
visits: Array<{ practitioner: string | null; specialty: string | null }>;
};
expect(result.visits[0].practitioner).toBeNull();
expect(result.visits[0].specialty).toBeNull();
});
});
@@ -0,0 +1,134 @@
/**
* No MCP code path reads a condition or vaccination note (v1.39.3 review).
*
* A condition's note and a vaccination dose's note are never handed to an AI
* surface: the Coach illness snapshot and the doctor-report clinical records
* both leave them out on purpose. The MCP wire reaches an outside assistant,
* so the same rule holds here.
*
* The guard walks every non-test source file under `src/lib/mcp`, finds each
* Prisma call on `illnessEpisode` or `vaccinationRecord` (whitespace-tolerant,
* so a call split across lines is still found), and requires its argument to
* carry an explicit `select` without `noteEncrypted`. A read with no select
* would load every column, the note included, so it fails too.
*
* Proved both ways: the matcher must find calls in the tree (an empty match
* set is a failure, not a pass), and the same check must flag a planted call
* that reads the note or selects nothing.
*/
import { describe, expect, it } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
const MCP_ROOT = join(process.cwd(), "src/lib/mcp");
const CALL =
/\b(?:prisma|tx)\s*\.\s*(illnessEpisode|vaccinationRecord)\s*\.\s*(\w+)\s*\(/g;
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((entry) => {
const path = join(dir, entry);
if (statSync(path).isDirectory()) {
return entry === "__tests__" ? [] : sourceFiles(path);
}
return /\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry) ? [path] : [];
});
}
/** The text between a call's opening parenthesis and its matching close. */
function argumentOf(source: string, open: number): string {
let depth = 0;
for (let i = open; i < source.length; i += 1) {
if (source[i] === "(") depth += 1;
else if (source[i] === ")") {
depth -= 1;
if (depth === 0) return source.slice(open + 1, i);
}
}
return source.slice(open + 1);
}
/** The keys of the call's argument object, ignoring nested objects. */
function topLevelKeys(arg: string): string[] {
const keys: string[] = [];
let depth = 0;
for (let i = 0; i < arg.length; i += 1) {
const ch = arg[i];
if (ch === "{" || ch === "(" || ch === "[") depth += 1;
else if (ch === "}" || ch === ")" || ch === "]") depth -= 1;
else if (depth === 1) {
const key = /^([A-Za-z_]\w*)\s*:/.exec(arg.slice(i));
if (key && !/\w/.test(arg[i - 1] ?? "")) {
keys.push(key[1]);
i += key[0].length - 1;
}
}
}
return keys;
}
interface Violation {
call: string;
problem: string;
}
function violations(source: string): { calls: number; bad: Violation[] } {
const bad: Violation[] = [];
let calls = 0;
for (const match of source.matchAll(CALL)) {
calls += 1;
const call = `${match[1]}.${match[2]}`;
const arg = argumentOf(source, (match.index ?? 0) + match[0].length - 1);
const keys = topLevelKeys(arg);
if (/\bnoteEncrypted\b/.test(arg)) {
bad.push({ call, problem: "names noteEncrypted" });
} else if (!keys.includes("select")) {
// `include` alone, or nothing, loads every scalar column.
bad.push({ call, problem: "has no explicit select" });
}
}
return { calls, bad };
}
describe("MCP note-column guard", () => {
it("flags a planted read of the note, and a read with no select", () => {
const planted = `
await prisma.illnessEpisode.findFirst({
where: { id },
select: { id: true, noteEncrypted: true },
});
await prisma
.vaccinationRecord
.findMany({ where: { userId } });
await prisma.illnessEpisode.findMany({ where: {}, select: { id: true } });
await prisma.vaccinationRecord.findFirst({
where: { id },
include: { practitioner: { select: { name: true } } },
});
`;
const { calls, bad } = violations(planted);
expect(calls).toBe(4);
expect(bad.map((b) => b.problem)).toEqual([
"names noteEncrypted",
"has no explicit select",
// A nested select under `include` does not narrow the row itself.
"has no explicit select",
]);
});
it("no MCP read of a condition or a dose can load its note", () => {
const files = sourceFiles(MCP_ROOT);
expect(files.length).toBeGreaterThan(10);
let calls = 0;
const offenders: string[] = [];
for (const file of files) {
const result = violations(readFileSync(file, "utf8"));
calls += result.calls;
for (const v of result.bad) {
offenders.push(`${file}: ${v.call} ${v.problem}`);
}
}
// record-search reads both models; zero matches means a broken matcher.
expect(calls).toBeGreaterThanOrEqual(4);
expect(offenders).toEqual([]);
});
});
+152 -1
View File
@@ -56,6 +56,11 @@ const links: Array<{
function matches(row: Row, where: Record<string, unknown> = {}): boolean {
for (const [key, cond] of Object.entries(where)) {
const value = row[key];
if (key === "OR") {
if (!(cond as Record<string, unknown>[]).some((c) => matches(row, c)))
return false;
continue;
}
if (key === "document") {
const doc = store.inboundDocument.find((d) => d.id === row.documentId);
if (!doc || !matches(doc, cond as Record<string, unknown>)) return false;
@@ -64,6 +69,12 @@ function matches(row: Row, where: Record<string, unknown> = {}): boolean {
if (cond && typeof cond === "object" && !(cond instanceof Date)) {
const c = cond as Record<string, unknown>;
if ("in" in c && !(c.in as unknown[]).includes(value)) return false;
if ("contains" in c) {
if (typeof value !== "string") return false;
if (!value.toLowerCase().includes(String(c.contains).toLowerCase()))
return false;
}
if ("lte" in c && !((value as Date) <= (c.lte as Date))) return false;
if ("hasSome" in c) {
const set = new Set(value as string[]);
if (!(c.hasSome as string[]).some((h) => set.has(h))) return false;
@@ -214,7 +225,11 @@ beforeEach(() => {
bodySiteEncrypted: bytes("Knie"),
laterality: "LEFT",
reasonEncrypted: bytes("Arthroskopie, Meniskus"),
practitioner: { name: "Dr. Weber", specialty: "Orthopädie" },
practitioner: {
name: "Dr. Weber",
specialty: "Orthopädie",
deletedAt: null,
},
}),
encounter({
id: "v-shoulder",
@@ -231,6 +246,7 @@ beforeEach(() => {
practitioner: {
name: `Dr. Hausarzt ${USER_TEXT_FENCE_START}`,
specialty: null,
deletedAt: null,
},
}),
encounter({ id: "v-gone", deletedAt: new Date(), kind: "PROCEDURE" }),
@@ -282,6 +298,7 @@ beforeEach(() => {
createdAt: new Date("2023-05-10T00:00:00Z"),
mimeType: "application/pdf",
byteSize: 1234,
aiReadDeferred: false,
deletedAt: null,
},
{
@@ -295,6 +312,7 @@ beforeEach(() => {
createdAt: new Date("2024-01-01T00:00:00Z"),
mimeType: "image/jpeg",
byteSize: 99,
aiReadDeferred: false,
deletedAt: null,
},
{
@@ -308,6 +326,7 @@ beforeEach(() => {
createdAt: new Date("2023-05-10T00:00:00Z"),
mimeType: "application/pdf",
byteSize: 1,
aiReadDeferred: false,
deletedAt: null,
},
];
@@ -668,3 +687,135 @@ describe("fetch over records", () => {
expect(lab.metadata.reason).toBe("module_disabled");
});
});
// ── Review follow-ups (v1.39.3) ─────────────────────────────────────────
describe("notes never leave over MCP", () => {
it("does not search or return a condition or dose note", async () => {
store.vaccinationRecord[0].noteEncrypted = bytes("Arm swollen for days");
// "stairs" appears only in the condition note, "swollen" only in the dose note.
expect((await search(ME, "stairs")).results).toHaveLength(0);
expect((await search(ME, "swollen")).results).toHaveLength(0);
const condition = await fetchId(ME, "condition:c-knee");
const dose = await fetchId(ME, "vaccination:vac-1");
for (const hit of [condition, dose]) {
expect(JSON.stringify(hit)).not.toMatch(/stairs|swollen/i);
expect(hit.metadata).not.toHaveProperty("note");
}
});
it("never selects noteEncrypted from conditions or doses", async () => {
await search(ME, "knee tetanus");
await fetchId(ME, "condition:c-knee");
await fetchId(ME, "vaccination:vac-1");
const calls = [
...vi.mocked(prisma.illnessEpisode.findMany).mock.calls,
...vi.mocked(prisma.illnessEpisode.findFirst).mock.calls,
...vi.mocked(prisma.vaccinationRecord.findMany).mock.calls,
...vi.mocked(prisma.vaccinationRecord.findFirst).mock.calls,
];
expect(calls.length).toBeGreaterThanOrEqual(4);
for (const [args] of calls) {
// An explicit select, so a new column is never read by default.
const select = (args as { select?: Record<string, unknown> }).select;
expect(select).toBeDefined();
expect(select).not.toHaveProperty("noteEncrypted");
}
});
});
describe("documents held back from AI reading", () => {
it("are not found by their text and return no excerpt, but keep title and metadata", async () => {
store.inboundDocument[1].aiReadDeferred = true;
expect((await search(ME, "tibia")).results).toHaveLength(0);
// Still findable by file name.
expect((await search(ME, "scan-0001")).results[0]?.id).toBe(
"document:d-scan",
);
const hit = await fetchId(ME, "document:d-scan");
expect(hit.metadata.present).toBe(true);
expect(hit.metadata.excerpt).toBeNull();
expect(hit.metadata.reason).toBe("ai_read_deferred");
expect(hit.text).not.toMatch(/tibia/i);
expect(prisma.documentContentIndex.findFirst).not.toHaveBeenCalled();
});
});
describe("documents beyond the newest scan", () => {
it("are still found by title through the database", async () => {
// 600 newer documents push the letter out of the newest-500 window.
const filler = Array.from({ length: 600 }, (_, i) => ({
id: `d-fill-${String(i).padStart(3, "0")}`,
userId: "u1",
title: `Rechnung ${i}`,
filename: null,
kind: "INSURANCE",
documentDate: null,
reportDate: null,
createdAt: new Date(Date.UTC(2025, 0, 1) + i * 60_000),
mimeType: "application/pdf",
byteSize: 1,
aiReadDeferred: false,
deletedAt: null,
}));
store.inboundDocument = [...filler, ...store.inboundDocument];
const ids = (await search(ME, "Entlassungsbrief")).results.map((r) => r.id);
expect(ids).toContain("document:d-letter");
expect(ids).not.toContain("document:d-other");
});
});
describe("short query words", () => {
it("match whole words or word starts, never inside a word", () => {
const cand = (id: string, text: string): SearchCandidate => ({
id,
title: id,
url: "u",
kindOrder: 1_000_000,
date: 1,
record: true,
fields: [{ text, weight: 3 }],
});
const ids = rankCandidates(
[
cand("darm", "Darmspiegelung"),
cand("warm", "warm compress"),
cand("arm", "Left arm"),
cand("armpit", "Armpit rash"),
],
"arm",
).map((c) => c.id);
expect(ids.sort()).toEqual(["arm", "armpit"]);
});
});
describe("practitioners and planned visits", () => {
it("does not match or show a deleted practitioner's name", async () => {
store.encounter[0].practitioner = {
name: "Dr. Weber",
specialty: "Orthopädie",
deletedAt: new Date(),
};
expect((await search(ME, "weber")).results).toHaveLength(0);
const hit = await fetchId(ME, "visit:v-knee");
expect(hit.metadata.practitioner).toBeNull();
expect(hit.text).not.toContain("Weber");
});
it("lists past visits, not future planned ones, for an empty query", async () => {
store.encounter.push(
encounter({
id: "v-planned",
status: "PLANNED",
occurredAt: new Date(Date.now() + 30 * 86_400_000),
}),
);
const ids = (await search(ME, "")).results.map((r) => r.id);
expect(ids).not.toContain("visit:v-planned");
expect(ids).toContain("visit:v-knee");
// A query still finds it.
expect((await search(ME, "routine")).results.map((r) => r.id)).toContain(
"visit:v-planned",
);
});
});
+10 -2
View File
@@ -916,7 +916,11 @@ describe("get_preventive_care — upcoming appointments fold in", () => {
status: "PLANNED",
kind: "ROUTINE",
reasonEncrypted: null,
practitioner: { name: "Dr. Wolke", specialty: "General practice" },
practitioner: {
name: "Dr. Wolke",
specialty: "General practice",
deletedAt: null,
},
},
] as never);
const result = (await tool("get_preventive_care").run(CTX, {})) as {
@@ -964,7 +968,11 @@ describe("get_visits", () => {
kind: "SPECIALIST",
reasonEncrypted: Buffer.from("chest pain follow-up", "utf8"),
outcomeEncrypted: Buffer.from("all clear", "utf8"),
practitioner: { name: "Dr. Herz", specialty: "Cardiology" },
practitioner: {
name: "Dr. Herz",
specialty: "Cardiology",
deletedAt: null,
},
...overrides,
};
}
+169 -50
View File
@@ -80,8 +80,6 @@ export const MAX_RECORD_RESULTS = 50;
export const EMPTY_QUERY_PER_KIND = 10;
/** Characters of indexed document text one `fetch` returns. */
export const MAX_DOCUMENT_EXCERPT_CHARS = 1500;
/** Characters of a condition or dose note one `fetch` returns. */
export const MAX_NOTE_CHARS = 1000;
/** One-hop links one `fetch` returns. */
export const MAX_LINKS = 25;
@@ -275,6 +273,20 @@ const PHRASE_BONUS = 5;
* are wider than the word the person typed, and a substring match on them
* would find "rate" inside "moderate".
*/
/** Query words shorter than this match whole words or word starts only. */
export const MIN_SUBSTRING_WORD = 4;
/**
* Whether a query word hits a field by its own spelling. A word of four
* letters or more may sit anywhere ("knee" in "kneecap"); a shorter one has
* to start a word, so "arm" finds "Arm" and "Armpit" but not "Darm" or "warm".
*/
function hitsField(field: PreparedField, word: string): boolean {
if (word.length >= MIN_SUBSTRING_WORD) return field.folded.includes(word);
for (const token of field.tokens) if (token.startsWith(word)) return true;
return false;
}
export function scoreCandidate(
candidate: Pick<SearchCandidate, "fields" | "contentWords">,
words: readonly string[],
@@ -290,10 +302,7 @@ export function scoreCandidate(
let best = 0;
for (const field of fields) {
if (field.weight <= best) continue;
if (
field.folded.includes(word) ||
synonyms.some((s) => field.tokens.has(s))
) {
if (hitsField(field, word) || synonyms.some((s) => field.tokens.has(s))) {
best = field.weight;
}
}
@@ -307,7 +316,11 @@ export function scoreCandidate(
}
const phrase =
foldedQuery.length > 0 &&
fields.some((field) => field.folded.includes(foldedQuery));
fields.some((field) =>
foldedQuery.includes(" ")
? field.folded.includes(foldedQuery)
: hitsField(field, foldedQuery),
);
if (phrase) score += PHRASE_BONUS;
return { matched, score, phrase };
}
@@ -516,19 +529,51 @@ function visitTitle(args: {
return scrubFenceMarkers(`${parts.join(" · ")} (${args.day}${status})`);
}
/**
* The practitioner columns a record read selects. `deletedAt` rides along so
* a practitioner the person removed contributes nothing: the visit keeps its
* scalar id, but the name is no longer something the record says.
*/
const PRACTITIONER_SELECT = {
select: { name: true, specialty: true, deletedAt: true },
} as const;
type PractitionerRow = {
name: string;
specialty: string | null;
deletedAt: Date | null;
} | null;
/** The practitioner as the record may still show it, or null. */
export function livePractitioner(
row: PractitionerRow | undefined,
): { name: string; specialty: string | null } | null {
return row && row.deletedAt === null
? { name: row.name, specialty: row.specialty }
: null;
}
async function visitCandidates(
userId: string,
owner: Owner,
origin: string,
take: number,
empty: boolean,
): Promise<SearchCandidate[]> {
const rows = await prisma.encounter.findMany({
where: { userId, deletedAt: null },
// The empty-query listing is "what is in my record": things that
// happened. A booked appointment is still found by any query naming it.
where: {
userId,
deletedAt: null,
...(empty ? { occurredAt: { lte: new Date() } } : {}),
},
orderBy: [{ occurredAt: "desc" }, { id: "asc" }],
take,
include: { practitioner: { select: { name: true, specialty: true } } },
include: { practitioner: PRACTITIONER_SELECT },
});
return rows.map((row) => {
const practitioner = livePractitioner(row.practitioner);
const reason = decryptText(row.reasonEncrypted);
const outcome = decryptText(row.outcomeEncrypted);
const bodySite = decryptText(row.bodySiteEncrypted);
@@ -540,7 +585,7 @@ async function visitCandidates(
day,
bodySite: bodySite?.replace(/\s+/g, " ").trim() ?? null,
laterality: row.laterality,
practitioner: row.practitioner?.name ?? null,
practitioner: practitioner?.name ?? null,
status: row.status,
}),
url: recordUrl(origin, "visit", row.id),
@@ -550,8 +595,8 @@ async function visitCandidates(
fields: [
{ text: bodySite ?? "", weight: WEIGHT.primary },
{ text: reason ?? "", weight: WEIGHT.primary },
{ text: row.practitioner?.name ?? "", weight: WEIGHT.primary },
{ text: row.practitioner?.specialty ?? "", weight: WEIGHT.primary },
{ text: practitioner?.name ?? "", weight: WEIGHT.primary },
{ text: practitioner?.specialty ?? "", weight: WEIGHT.primary },
{
text: [
"visit",
@@ -572,6 +617,36 @@ async function visitCandidates(
});
}
/**
* The condition columns MCP reads. An explicit list, and the note is not on
* it: a condition's note is never handed to an AI surface (the Coach illness
* snapshot and the doctor report leave it out too), and an outside assistant
* is one. `no-note-columns-guard.test.ts` holds every MCP read to this.
*/
const CONDITION_SELECT = {
id: true,
label: true,
type: true,
lifecycle: true,
onsetAt: true,
resolvedAt: true,
bodySiteEncrypted: true,
laterality: true,
} as const;
/** The dose columns MCP reads; the note is left out for the same reason. */
const VACCINATION_SELECT = {
id: true,
occurredAt: true,
antigenSlug: true,
vaccineName: true,
doseNumber: true,
seriesDoses: true,
lotNumber: true,
site: true,
practitioner: PRACTITIONER_SELECT,
} as const;
async function conditionCandidates(
userId: string,
owner: Owner,
@@ -582,10 +657,10 @@ async function conditionCandidates(
where: { userId, deletedAt: null },
orderBy: [{ onsetAt: "desc" }, { id: "asc" }],
take,
select: CONDITION_SELECT,
});
return rows.map((row) => {
const bodySite = decryptText(row.bodySiteEncrypted);
const note = decryptText(row.noteEncrypted);
const day = userDayKey(row.onsetAt, owner.timezone);
const site = bodySite?.replace(/\s+/g, " ").trim();
return {
@@ -614,7 +689,6 @@ async function conditionCandidates(
text: sideTerms(row.laterality, owner.locale) ?? "",
weight: WEIGHT.secondary,
},
{ text: note ?? "", weight: WEIGHT.tertiary },
],
};
});
@@ -648,7 +722,9 @@ async function contentMatches(
where: {
userId,
searchTokens: { hasSome: hashes },
document: { deletedAt: null },
// A document held back from AI reading is not searched by its
// text over this wire either; its title and kind still are.
document: { deletedAt: null, aiReadDeferred: false },
},
select: { documentId: true },
take: MAX_ROWS_PER_KIND,
@@ -663,14 +739,36 @@ async function contentMatches(
return byDocument;
}
/**
* The terms a title or file name is looked up by in SQL: the folded query
* words plus the raw lower-cased ones, because the columns keep their accents
* and "Schädel" folds to "schadel". Short words stay out: a three-letter
* substring over every title is noise, and the in-memory pass still sees
* them for the newest documents.
*/
function documentNameTerms(query: string, words: readonly string[]): string[] {
const raw = query
.toLowerCase()
.split(/[^\p{L}\p{N}._-]+/u)
.filter((w) => w.length >= MIN_SUBSTRING_WORD);
return [
...new Set([
...words.filter((w) => w.length >= MIN_SUBSTRING_WORD),
...raw,
]),
].slice(0, MAX_QUERY_WORDS * 2);
}
async function documentCandidates(
userId: string,
owner: Owner,
origin: string,
take: number,
query: string,
words: readonly string[],
): Promise<SearchCandidate[]> {
const [rows, content] = await Promise.all([
const nameTerms = documentNameTerms(query, words);
const [rows, content, named] = await Promise.all([
prisma.inboundDocument.findMany({
where: { userId, deletedAt: null },
select: DOCUMENT_SELECT,
@@ -680,10 +778,30 @@ async function documentCandidates(
words.length > 0
? contentMatches(userId, words)
: Promise.resolve(new Map<string, Set<string>>()),
// Title and file name are plaintext columns, so an older document is
// found by them in SQL, the way the vault list finds it.
nameTerms.length > 0
? prisma.inboundDocument.findMany({
where: {
userId,
deletedAt: null,
OR: nameTerms.flatMap((term) => [
{ title: { contains: term, mode: "insensitive" as const } },
{ filename: { contains: term, mode: "insensitive" as const } },
]),
},
select: { id: true },
orderBy: [{ createdAt: "desc" }, { id: "asc" }],
take: MAX_ROWS_PER_KIND,
})
: Promise.resolve([] as Array<{ id: string }>),
]);
// A content hit on a document older than the newest `take` still counts.
// A content or name hit on a document older than the newest `take` still
// counts; the extra read is bounded like the others.
const seen = new Set(rows.map((r) => r.id));
const missing = [...content.keys()].filter((id) => !seen.has(id));
const missing = [
...new Set([...content.keys(), ...named.map((r) => r.id)]),
].filter((id) => !seen.has(id));
const extra =
missing.length > 0
? await prisma.inboundDocument.findMany({
@@ -735,9 +853,10 @@ async function vaccinationCandidates(
where: { userId, deletedAt: null },
orderBy: [{ occurredAt: "desc" }, { id: "asc" }],
take,
include: { practitioner: { select: { name: true, specialty: true } } },
select: VACCINATION_SELECT,
});
return rows.map((row) => {
const practitioner = livePractitioner(row.practitioner);
const entry = resolveCatalogEntry(row.antigenSlug);
const catalogEn = vaccineCatalogName(row.antigenSlug, "en");
const name =
@@ -768,9 +887,8 @@ async function vaccinationCandidates(
text: "vaccination vaccine immunization impfung",
weight: WEIGHT.secondary,
},
{ text: row.practitioner?.name ?? "", weight: WEIGHT.secondary },
{ text: row.practitioner?.specialty ?? "", weight: WEIGHT.secondary },
{ text: decryptText(row.noteEncrypted) ?? "", weight: WEIGHT.tertiary },
{ text: practitioner?.name ?? "", weight: WEIGHT.secondary },
{ text: practitioner?.specialty ?? "", weight: WEIGHT.secondary },
],
};
});
@@ -793,12 +911,12 @@ export async function loadRecordCandidates(
loadOwner(userId),
]);
const groups = await Promise.all([
visitCandidates(userId, owner, origin, take),
visitCandidates(userId, owner, origin, take, empty),
kinds.has("condition")
? conditionCandidates(userId, owner, origin, take)
: Promise.resolve([]),
kinds.has("document")
? documentCandidates(userId, owner, origin, take, words)
? documentCandidates(userId, owner, origin, take, query, words)
: Promise.resolve([]),
kinds.has("vaccination")
? vaccinationCandidates(userId, owner, origin, take)
@@ -929,7 +1047,7 @@ async function fetchVisit(
): Promise<FetchResult> {
const row = await prisma.encounter.findFirst({
where: { id: rid, userId, deletedAt: null },
include: { practitioner: { select: { name: true, specialty: true } } },
include: { practitioner: PRACTITIONER_SELECT },
});
if (!row) return notFound(id, "visit", origin);
@@ -981,8 +1099,9 @@ async function fetchVisit(
const bodySite = decryptText(row.bodySiteEncrypted);
const day = userDayKey(row.occurredAt, owner.timezone);
const kindLabel = encounterKindLabel(row.kind, "en");
const practitioner = row.practitioner?.name ?? null;
const specialty = row.practitioner?.specialty ?? null;
const live = livePractitioner(row.practitioner);
const practitioner = live?.name ?? null;
const specialty = live?.specialty ?? null;
const sentences = [
`Visit on ${day}: ${kindLabel}, status ${row.status}.`,
@@ -1037,6 +1156,7 @@ async function fetchCondition(
): Promise<FetchResult> {
const row = await prisma.illnessEpisode.findFirst({
where: { id: rid, userId, deletedAt: null },
select: CONDITION_SELECT,
});
if (!row) return notFound(id, "condition", origin);
@@ -1057,8 +1177,6 @@ async function fetchCondition(
]);
const bodySite = decryptText(row.bodySiteEncrypted);
const noteRaw = decryptText(row.noteEncrypted);
const note = noteRaw === null ? null : clip(noteRaw, MAX_NOTE_CHARS);
const onset = userDayKey(row.onsetAt, owner.timezone);
const resolved = row.resolvedAt
? userDayKey(row.resolvedAt, owner.timezone)
@@ -1070,7 +1188,6 @@ async function fetchCondition(
bodySite
? `Body site: ${fenceUserText(bodySite)}${row.laterality ? `, side ${row.laterality}` : ""}.`
: null,
note ? `Note: ${fenceUserText(note.text)}` : "No note recorded.",
linksSentence(links),
].filter((s): s is string => s !== null);
@@ -1092,8 +1209,6 @@ async function fetchCondition(
resolvedAt: row.resolvedAt ? row.resolvedAt.toISOString() : null,
bodySite: fenced(bodySite),
laterality: row.laterality,
note: note ? fenceUserText(note.text) : null,
noteTruncated: note?.truncated ?? false,
links,
linksTruncated,
},
@@ -1147,13 +1262,20 @@ async function fetchDocument(
...DOCUMENT_SELECT,
mimeType: true,
byteSize: true,
aiReadDeferred: true,
},
});
if (!row) return notFound(id, "document", origin);
// Held back from AI reading at upload: the indexed text is not read, let
// alone sent. The title, kind, dates and links are the record's metadata
// and still are.
const deferred = row.aiReadDeferred;
const source = { userId, sourceKind: "document" as const, sourceId: rid };
const [content, visits, conditions, doses] = await Promise.all([
documentExcerpt(userId, rid),
deferred
? Promise.resolve({ indexed: false, excerpt: null })
: documentExcerpt(userId, rid),
listTargets(prisma, { ...source, targetKind: "encounter" }),
kinds.has("condition")
? listTargets(prisma, { ...source, targetKind: "conditionEpisode" })
@@ -1173,11 +1295,13 @@ async function fetchDocument(
const dated = row.documentDate ?? row.reportDate;
const sentences = [
`Document ${fenceUserText(name)}: ${kindLabel}, ${dated ? `dated ${dateOnly(dated)}` : "undated"}, added ${dateOnly(row.createdAt)}.`,
content.excerpt
? `Indexed text${content.excerpt.truncated ? " (excerpt)" : ""}: ${fenceUserText(content.excerpt.text)}`
: content.indexed
? "The indexed text could not be read."
: "No indexed text is stored for this document.",
deferred
? "This document was stored without AI reading, so its text is not shared here."
: content.excerpt
? `Indexed text${content.excerpt.truncated ? " (excerpt)" : ""}: ${fenceUserText(content.excerpt.text)}`
: content.indexed
? "The indexed text could not be read."
: "No indexed text is stored for this document.",
linksSentence(links),
];
@@ -1199,6 +1323,7 @@ async function fetchDocument(
createdAt: row.createdAt.toISOString(),
mimeType: row.mimeType,
byteSize: row.byteSize,
...(deferred ? { reason: "ai_read_deferred" } : {}),
indexed: content.indexed,
excerpt: content.excerpt ? fenceUserText(content.excerpt.text) : null,
excerptTruncated: content.excerpt?.truncated ?? false,
@@ -1217,8 +1342,8 @@ async function fetchVaccination(
): Promise<FetchResult> {
const row = await prisma.vaccinationRecord.findFirst({
where: { id: rid, userId, deletedAt: null },
include: {
practitioner: { select: { name: true, specialty: true } },
select: {
...VACCINATION_SELECT,
encounter: {
select: { id: true, kind: true, occurredAt: true, deletedAt: true },
},
@@ -1252,8 +1377,7 @@ async function fetchVaccination(
const catalog = vaccineCatalogName(row.antigenSlug, "en");
const name = row.vaccineName ?? catalog ?? row.antigenSlug ?? "Vaccination";
const noteRaw = decryptText(row.noteEncrypted);
const note = noteRaw === null ? null : clip(noteRaw, MAX_NOTE_CHARS);
const practitioner = livePractitioner(row.practitioner);
const day = dateOnly(row.occurredAt);
const dose =
row.doseNumber !== null
@@ -1263,10 +1387,9 @@ async function fetchVaccination(
const sentences = [
`Vaccination ${fenceUserText(name)} on ${day}, ${dose}.`,
catalog && row.vaccineName ? `Catalogue entry: ${catalog}.` : null,
row.practitioner
? `Given by ${fenceUserText(row.practitioner.name)}.`
practitioner
? `Given by ${fenceUserText(practitioner.name)}.`
: "No practitioner recorded.",
note ? `Note: ${fenceUserText(note.text)}` : null,
linksSentence(links),
].filter((s): s is string => s !== null);
@@ -1288,11 +1411,7 @@ async function fetchVaccination(
seriesDoses: row.seriesDoses,
lotNumber: row.lotNumber ? scrubFenceMarkers(row.lotNumber) : null,
site: row.site,
practitioner: row.practitioner
? scrubFenceMarkers(row.practitioner.name)
: null,
note: note ? fenceUserText(note.text) : null,
noteTruncated: note?.truncated ?? false,
practitioner: practitioner ? scrubFenceMarkers(practitioner.name) : null,
links,
linksTruncated,
},
+21 -3
View File
@@ -124,6 +124,12 @@ function ageYears(dateOfBirth: Date | null): number | null {
}
/** First value of a template variable that may arrive exploded as an array. */
/**
* The explicit-absence answer for a resource whose module is switched off
* (v1.39.3), the same shape the tools return.
*/
const MODULE_DISABLED = { present: false, reason: "module_disabled" } as const;
function firstVar(value: string | string[] | undefined): string {
if (Array.isArray(value)) return value[0] ?? "";
return value ?? "";
@@ -171,9 +177,12 @@ export const MCP_RESOURCES: McpResourceDefinition[] = [
uri: "healthlog://medications",
title: "Medications",
description:
"The user's tracked medications with their schedules (dose, cadence, time windows). Read-only list; use get_medication_compliance for adherence figures.",
'The user\'s tracked medications with their schedules (dose, cadence, time windows). Read-only list; use get_medication_compliance for adherence figures. Returns { present: false, reason: "module_disabled" } when its module is switched off.',
mimeType: "application/json",
async read(ctx) {
if (!(await isModuleEnabled(ctx.userId, "medications"))) {
return MODULE_DISABLED;
}
const medications = await prisma.medication.findMany({
where: { userId: ctx.userId },
include: { schedules: true },
@@ -358,6 +367,8 @@ async function completeMetric(
/** The user's own lab analytes (distinct, non-deleted). */
async function userAnalytes(ctx: McpAuthContext): Promise<string[]> {
// A switched-off labs module lists and completes nothing (v1.39.3).
if (!(await isModuleEnabled(ctx.userId, "labs"))) return [];
const rows = await prisma.labResult.findMany({
where: { userId: ctx.userId, deletedAt: null },
select: { analyte: true },
@@ -459,7 +470,7 @@ export const MCP_RESOURCE_TEMPLATES: McpResourceTemplateDefinition[] = [
uriTemplate: "healthlog://lab/{analyte}",
title: "Lab analyte readings",
description:
"The user's recent readings for one named lab analyte (e.g. healthlog://lab/LDL) over the last 12 months — value, unit, reference range, and in-range/below/above status per reading. Same read get_labs serves. Returns { present: false } when no readings exist for that analyte.",
'The user\'s recent readings for one named lab analyte (e.g. healthlog://lab/LDL) over the last 12 months — value, unit, reference range, and in-range/below/above status per reading. Same read get_labs serves. Returns { present: false } when no readings exist for that analyte. Returns { present: false, reason: "module_disabled" } when its module is switched off.',
mimeType: "application/json",
complete: { analyte: completeAnalyte },
async list(ctx) {
@@ -470,6 +481,9 @@ export const MCP_RESOURCE_TEMPLATES: McpResourceTemplateDefinition[] = [
}));
},
async read(ctx, variables) {
if (!(await isModuleEnabled(ctx.userId, "labs"))) {
return MODULE_DISABLED;
}
const analyte = decodeURIComponent(firstVar(variables.analyte));
const result = await executeCoachTool({
userId: ctx.userId,
@@ -488,9 +502,10 @@ export const MCP_RESOURCE_TEMPLATES: McpResourceTemplateDefinition[] = [
uriTemplate: "healthlog://medication/{id}",
title: "Medication summary",
description:
"One of the user's medications by id, with its schedules. User-scoped: returns { present: false } when the id does not belong to this user. Use get_medication_compliance for adherence figures.",
'One of the user\'s medications by id, with its schedules. User-scoped: returns { present: false } when the id does not belong to this user. Use get_medication_compliance for adherence figures. Returns { present: false, reason: "module_disabled" } when its module is switched off.',
mimeType: "application/json",
async list(ctx) {
if (!(await isModuleEnabled(ctx.userId, "medications"))) return [];
const meds = await prisma.medication.findMany({
where: { userId: ctx.userId },
select: { id: true, name: true, dose: true },
@@ -504,6 +519,9 @@ export const MCP_RESOURCE_TEMPLATES: McpResourceTemplateDefinition[] = [
}));
},
async read(ctx, variables) {
if (!(await isModuleEnabled(ctx.userId, "medications"))) {
return MODULE_DISABLED;
}
const id = firstVar(variables.id);
// user-scoped: a medication that is not this user's resolves to absent,
// never another tenant's row.
+123 -45
View File
@@ -184,6 +184,33 @@ function fenceIllnessSites(result: unknown): unknown {
};
}
/**
* The explicit-absence answer for a read whose module is switched off
* (v1.39.3). Distinct from `{ present: false }` alone, which means "not
* recorded": here the data may well exist and is simply not shared.
*/
const MODULE_DISABLED = { present: false, reason: "module_disabled" } as const;
/**
* Run `read` only when `key` is on for this account; otherwise answer the
* explicit absence without touching the table behind it.
*/
async function whenModuleOn(
ctx: McpAuthContext,
key: "labs" | "medications",
tool: string,
read: () => Promise<unknown>,
): Promise<unknown> {
if (!(await isModuleEnabled(ctx.userId, key))) {
annotate({
action: { name: "mcp.tool.invoked" },
meta: { tool, present: false, refused: "module_disabled" },
});
return MODULE_DISABLED;
}
return read();
}
/** Finite-number guard for the citation summarisers. */
function num(v: unknown): number | null {
return typeof v === "number" && Number.isFinite(v) ? v : null;
@@ -366,7 +393,7 @@ function searchAndFetchTools(): McpToolDefinition[] {
name: "search",
title: "Search your health records",
description:
"Search the user's own health record for items matching a free-text query (English or the user's language). Covers metric domains, medications, lab biomarkers, and the clinical records: doctor visits including procedures and surgeries (`visit:<id>`, matched on visit kind, reason, outcome, body site and side, practitioner name and specialty), conditions (`condition:<id>`, matched on label, type, body site and side), documents (`document:<id>`, matched on title, file name, kind such as discharge letter, and whole words of the document's indexed text where one is stored) and vaccinations (`vaccination:<id>`, matched on vaccine name and catalogue name). A kind whose module the user has switched off never appears. Results are ranked by how many query words match, then newest first; record results are capped at 50. Returns { results: [{ id, title, url }], nextCursor? }; pass an id to the `fetch` tool to hydrate it. Each `url` deep-links into the HealthLog web app for citation. When more results exist, pass the opaque `nextCursor` back as `cursor` for the next page. Returns an empty list when nothing matches.",
"Search the user's own health record for items matching a free-text query (English or the user's language). Covers metric domains, medications, lab biomarkers, and the clinical records: doctor visits including procedures and surgeries (`visit:<id>`, matched on visit kind, reason, outcome, body site and side, practitioner name and specialty), conditions (`condition:<id>`, matched on label, type, body site and side), documents (`document:<id>`, matched on title, file name, kind such as discharge letter, and whole words of the document's own indexed text where one is stored; a document the user held back from AI reading is matched on its title and details only) and vaccinations (`vaccination:<id>`, matched on vaccine name and catalogue name). Condition and vaccination notes are never searched or returned. A kind whose module the user has switched off (conditions, documents, vaccinations, medications, labs) never appears. Results are ranked by how many query words match, then newest first; record results are capped at 50. Returns { results: [{ id, title, url }], nextCursor? }; pass an id to the `fetch` tool to hydrate it. Each `url` deep-links into the HealthLog web app for citation. When more results exist, pass the opaque `nextCursor` back as `cursor` for the next page. Returns an empty list when nothing matches.",
inputShape: {
query: z.string().max(200),
cursor: z
@@ -572,7 +599,7 @@ function searchAndFetchTools(): 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 indexed text, never the file. 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 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.',
inputShape: { id: z.string().min(1).max(200) },
annotations: READ_ONLY_ANNOTATIONS,
outputShape: {
@@ -950,6 +977,7 @@ const detectChangepointsOutput: z.ZodRawShape = {
/** Output schema for `get_medication_schedule` — per-medication next-due. */
const getMedicationScheduleOutput: z.ZodRawShape = {
present: z.boolean(),
reason: z.string().optional(),
medications: z
.array(
z.object({
@@ -1058,6 +1086,9 @@ const getVisitsOutput: z.ZodRawShape = {
}),
)
.optional(),
// "module_disabled" when the illness module is off and the condition
// labels are withheld rather than absent (v1.39.3).
conditionsReason: z.string().optional(),
};
/** Default trailing window for `get_visits` when the caller names none. */
@@ -1193,6 +1224,33 @@ const getEcgRecordingsOutput: z.ZodRawShape = {
.optional(),
};
/** `get_labs` behind its module gate: latest-per-biomarker or a history page. */
function readLabs(
ctx: McpAuthContext,
args: Record<string, unknown>,
): Promise<unknown> {
// History mode: one analyte's paginated trajectory over the labs read
// path. Requires an analyte — there is no whole-record history dump.
if (args.history === true) {
const analyte = typeof args.analyte === "string" ? args.analyte : "";
if (!analyte.trim()) {
return Promise.resolve({
present: false,
reason: "analyte_required_for_history",
});
}
return getLabHistory(ctx.userId, {
analyte,
offset: decodeOffsetCursor(args.cursor),
limit: LAB_HISTORY_MAX_LIMIT,
});
}
// Default: latest reading per biomarker via the Coach labs read.
return runCoachTool(ctx, "get_labs", {
...(typeof args.analyte === "string" ? { analyte: args.analyte } : {}),
});
}
export const MCP_TOOLS: McpToolDefinition[] = [
{
name: "list_metrics",
@@ -1247,21 +1305,23 @@ export const MCP_TOOLS: McpToolDefinition[] = [
name: "get_medication_compliance",
title: "Get medication compliance",
description:
"Fetch the user's cadence-aware medication adherence: the dose-weighted compliance rate, expected vs taken/missed counts, current-cycle status, and any GLP-1 titration context. Returns { present: false } when no medications are tracked.",
'Fetch the user\'s cadence-aware medication adherence: the dose-weighted compliance rate, expected vs taken/missed counts, current-cycle status, and any GLP-1 titration context. Returns { present: false } when no medications are tracked, and { present: false, reason: "module_disabled" } when the medications module is switched off.',
inputShape: {
window: coachScopeWindowSchema.optional(),
},
annotations: READ_ONLY_ANNOTATIONS,
outputShape: coachReadOutput,
run(ctx, args) {
return runCoachTool(ctx, "get_medication_compliance", args);
return whenModuleOn(ctx, "medications", "get_medication_compliance", () =>
runCoachTool(ctx, "get_medication_compliance", args),
);
},
},
{
name: "get_labs",
title: "Get lab results",
description:
"Fetch the user's lab results. By default returns the latest reading per biomarker over the last 12 months, optionally filtered to one named analyte. 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.",
"Fetch the user's lab results. By default returns the latest reading per biomarker over the last 12 months, optionally filtered to one named analyte. 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.",
inputShape: {
analyte: z.string().min(1).max(80).optional(),
history: z
@@ -1281,26 +1341,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
annotations: READ_ONLY_ANNOTATIONS,
outputShape: getLabsOutput,
run(ctx, args) {
// History mode: one analyte's paginated trajectory over the labs read
// path. Requires an analyte — there is no whole-record history dump.
if (args.history === true) {
const analyte = typeof args.analyte === "string" ? args.analyte : "";
if (!analyte.trim()) {
return Promise.resolve({
present: false,
reason: "analyte_required_for_history",
});
}
return getLabHistory(ctx.userId, {
analyte,
offset: decodeOffsetCursor(args.cursor),
limit: LAB_HISTORY_MAX_LIMIT,
});
}
// Default: latest reading per biomarker via the Coach labs read.
return runCoachTool(ctx, "get_labs", {
...(typeof args.analyte === "string" ? { analyte: args.analyte } : {}),
});
return whenModuleOn(ctx, "labs", "get_labs", () => readLabs(ctx, args));
},
},
{
@@ -1563,11 +1604,22 @@ export const MCP_TOOLS: 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.",
'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.',
inputShape: {},
annotations: READ_ONLY_ANNOTATIONS,
outputShape: getMedicationScheduleOutput,
async run(ctx) {
if (!(await isModuleEnabled(ctx.userId, "medications"))) {
annotate({
action: { name: "mcp.tool.invoked" },
meta: {
tool: "get_medication_schedule",
present: false,
refused: "module_disabled",
},
});
return MODULE_DISABLED;
}
const now = new Date();
const [user, medications] = await Promise.all([
prisma.user.findUnique({
@@ -1762,17 +1814,26 @@ export const MCP_TOOLS: McpToolDefinition[] = [
},
orderBy: { occurredAt: "asc" },
take: MAX_APPOINTMENTS,
include: { practitioner: { select: { name: true, specialty: true } } },
include: {
practitioner: {
select: { name: true, specialty: true, deletedAt: true },
},
},
});
const appointments = appointmentRows.map((r) => {
const reason = decryptVisitText(r.reasonEncrypted);
// A practitioner the person deleted is no longer named.
const practitioner =
r.practitioner && r.practitioner.deletedAt === null
? r.practitioner
: null;
return {
occurredAt: r.occurredAt.toISOString(),
practitioner: r.practitioner
? scrubFenceMarkers(r.practitioner.name)
practitioner: practitioner
? scrubFenceMarkers(practitioner.name)
: null,
specialty: r.practitioner?.specialty
? scrubFenceMarkers(r.practitioner.specialty)
specialty: practitioner?.specialty
? scrubFenceMarkers(practitioner.specialty)
: null,
kind: r.kind,
// A visit's reason is user- and document-derived free text, so it
@@ -2014,7 +2075,7 @@ export const MCP_TOOLS: 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.",
"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\".",
inputShape: {
months: z
.number()
@@ -2081,7 +2142,11 @@ export const MCP_TOOLS: McpToolDefinition[] = [
},
orderBy: { occurredAt: "desc" },
take: MAX_VISITS,
include: { practitioner: { select: { name: true, specialty: true } } },
include: {
practitioner: {
select: { name: true, specialty: true, deletedAt: true },
},
},
});
// Absence is explicit. An empty list is { present: false } — "you have
@@ -2097,26 +2162,34 @@ export const MCP_TOOLS: McpToolDefinition[] = [
// The linked condition labels, one grouped read for the whole page, from
// the same link service every other visit surface goes through.
const conditionsBySource = await listTargetsBySource(prisma, {
userId: ctx.userId,
sourceKind: "encounter",
sourceIds: rows.map((r) => r.id),
targetKind: "conditionEpisode",
});
// With the illness module off the condition table is not read, and
// the answer says why the labels are missing (v1.39.3).
const illnessOn = await isModuleEnabled(ctx.userId, "illness");
const conditionsBySource = illnessOn
? await listTargetsBySource(prisma, {
userId: ctx.userId,
sourceKind: "encounter",
sourceIds: rows.map((r) => r.id),
targetKind: "conditionEpisode",
})
: new Map<string, Array<{ label: string }>>();
const visits = rows.map((r) => {
const reason = decryptVisitText(r.reasonEncrypted);
const outcome = decryptVisitText(r.outcomeEncrypted);
const bodySite = decryptVisitText(r.bodySiteEncrypted);
// A practitioner the person deleted is no longer named.
const seenBy =
r.practitioner && r.practitioner.deletedAt === null
? r.practitioner
: null;
return {
occurredAt: r.occurredAt.toISOString(),
status: r.status,
kind: r.kind,
practitioner: r.practitioner
? scrubFenceMarkers(r.practitioner.name)
: null,
specialty: r.practitioner?.specialty
? scrubFenceMarkers(r.practitioner.specialty)
practitioner: seenBy ? scrubFenceMarkers(seenBy.name) : null,
specialty: seenBy?.specialty
? scrubFenceMarkers(seenBy.specialty)
: null,
// Reason and outcome are user- and document-derived free text, so
// they ride the USER_TEXT wrapping; the markers are stripped from the
@@ -2136,7 +2209,12 @@ export const MCP_TOOLS: McpToolDefinition[] = [
action: { name: "mcp.tool.invoked" },
meta: { tool: "get_visits", present: true },
});
return { present: true, windowMonths: months, visits };
return {
present: true,
windowMonths: months,
visits,
...(illnessOn ? {} : { conditionsReason: "module_disabled" }),
};
},
},
...searchAndFetchTools(),