Files
HealthLog/src/lib/mcp/resources.ts
T
Marc-André Bombeck 1d9cc4b3d2 refactor(mcp): share one doctor-visit summariser across the resource and prompt
The doctor-visit summariser was duplicated and had drifted: the
healthlog://report/doctor-visit resource omitted mood, the glucose
panel, the wellness composites, and illness episodes that the
doctor_visit_summary prompt already carried. Extract one shared reducer
(doctor-visit-summary.ts) using the richer superset and have both
surfaces call it, so they ground identically and a future change reaches
both wires at once. Both keep the grounded latest+aggregate shape and
omit absent sections honestly.
2026-06-28 02:00:26 +02:00

495 lines
18 KiB
TypeScript

/**
* Transport-agnostic MCP resource registry.
*
* Resources expose read-only context the assistant can surface alongside tool
* results (REQ-R10). Like the tools, they are server-authoritative reads of the
* canonical tables scoped to the session `userId` — no new analytics, no
* caller-supplied id. Absence is explicit via `{ present: false }` (REQ-SEC-4).
*
* Two shapes live here:
* - FIXED resources (`MCP_RESOURCES`) — one concrete URI each (profile,
* medications, the biomarker catalogue, the data inventory, the
* doctor-visit summary).
* - resource TEMPLATES (`MCP_RESOURCE_TEMPLATES`) — RFC 6570 URI templates
* (`healthlog://metric/{type}`, `healthlog://lab/{analyte}`,
* `healthlog://medication/{id}`, …) that give a host a per-item address it
* can attach without a tool round-trip. Each template is a thin façade over
* the SAME server-authoritative read path the matching tool already uses, so
* the wire shape can never fork. Per-variable completion + per-template
* listing are user-scoped, so they double as honest discovery: a host only
* ever sees the metrics / analytes / medications the user actually has.
*
* The profile resource is deliberately data-minimised: it ships only the
* health-relevant context an assistant needs (age in whole years, gender,
* height, timezone, unit preferences) and omits direct identifiers (email,
* username, role, insurance number) — narrowing the surface on the
* external-assistant boundary.
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory";
import {
BIOMARKER_CATALOG,
BIOMARKER_PANELS,
} from "@/lib/labs/biomarker-catalog";
import { collectDoctorReportData } from "@/lib/doctor-report-data";
import { summariseForVisit } from "./doctor-visit-summary";
import type { McpAuthContext } from "./auth";
export interface McpResourceDefinition {
name: string;
/** Fixed resource URI (e.g. `healthlog://profile`). */
uri: string;
title: string;
description: string;
mimeType: string;
read: (ctx: McpAuthContext) => Promise<unknown>;
}
/**
* A parameterised resource (RFC 6570 URI template). Bound to one session in
* `createMcpServer`, so both `complete` and `list` are user-scoped — they only
* ever surface the data this user owns. `read` resolves one concrete instance
* from its matched template variables.
*/
export interface McpResourceTemplateDefinition {
name: string;
/** RFC 6570 URI template (e.g. `healthlog://metric/{type}/{window}`). */
uriTemplate: string;
title: string;
description: string;
mimeType: string;
/**
* Per-variable autocomplete sources, user-scoped. The host calls these as the
* user types a template variable; returning only the user's own metrics /
* analytes makes completion double as honest discovery.
*/
complete?: Record<
string,
(ctx: McpAuthContext, value: string) => Promise<string[]> | string[]
>;
/**
* Optional user-scoped enumeration of the concrete resources this template
* resolves to. Gives a host a browseable list without a tool round-trip.
*/
list?: (
ctx: McpAuthContext,
) => Promise<Array<{ uri: string; name: string; title?: string }>>;
/** Resolve one instance from its matched template variables (user-scoped). */
read: (
ctx: McpAuthContext,
variables: Record<string, string | string[] | undefined>,
) => Promise<unknown>;
}
/** The fixed trailing windows the windowed reads accept (mirrors the Coach). */
export const MCP_WINDOW_VALUES = [
"last7days",
"last30days",
"last90days",
"lastYear",
"allTime",
] as const;
/** Trailing-day count per window, capped at the doctor-report 365-day ceiling. */
const WINDOW_DAYS: Record<string, number> = {
last7days: 7,
last30days: 30,
last90days: 90,
lastYear: 365,
allTime: 365,
};
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** Whole-year age from a date of birth, or null when unknown. */
function ageYears(dateOfBirth: Date | null): number | null {
if (!dateOfBirth) return null;
const now = new Date();
let age = now.getUTCFullYear() - dateOfBirth.getUTCFullYear();
const monthDelta = now.getUTCMonth() - dateOfBirth.getUTCMonth();
if (
monthDelta < 0 ||
(monthDelta === 0 && now.getUTCDate() < dateOfBirth.getUTCDate())
) {
age -= 1;
}
return age >= 0 && age < 150 ? age : null;
}
/** First value of a template variable that may arrive exploded as an array. */
function firstVar(value: string | string[] | undefined): string {
if (Array.isArray(value)) return value[0] ?? "";
return value ?? "";
}
export const MCP_RESOURCES: McpResourceDefinition[] = [
{
name: "profile",
uri: "healthlog://profile",
title: "User profile",
description:
"The user's health-relevant profile context: age, gender, height, timezone, and unit preferences. Identifiers are omitted.",
mimeType: "application/json",
async read(ctx) {
const user = await prisma.user.findUnique({
where: { id: ctx.userId },
select: {
heightCm: true,
dateOfBirth: true,
gender: true,
timezone: true,
unitPreference: true,
glucoseUnit: true,
},
});
annotate({
action: { name: "mcp.resource.read" },
meta: { resource: "profile", present: user !== null },
});
if (!user) return { present: false };
return {
present: true,
ageYears: ageYears(user.dateOfBirth),
gender: user.gender ?? null,
heightCm: user.heightCm ?? null,
timezone: user.timezone,
unitPreference:
user.unitPreference === "imperial" ? "imperial" : "metric",
glucoseUnit: user.glucoseUnit ?? "mg/dL",
};
},
},
{
name: "medications",
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.",
mimeType: "application/json",
async read(ctx) {
const medications = await prisma.medication.findMany({
where: { userId: ctx.userId },
include: { schedules: true },
orderBy: { createdAt: "desc" },
});
annotate({
action: { name: "mcp.resource.read" },
meta: { resource: "medications", present: medications.length > 0 },
});
if (medications.length === 0) return { present: false, count: 0 };
return {
present: true,
count: medications.length,
medications: medications.map((med) => ({
name: med.name,
dose: med.dose,
treatmentClass: med.treatmentClass,
asNeeded: med.asNeeded,
paused: med.pausedAt !== null,
startsOn: med.startsOn ? med.startsOn.toISOString() : null,
endsOn: med.endsOn ? med.endsOn.toISOString() : null,
schedules: med.schedules.map((s) => ({
label: s.label ?? null,
dose: s.dose ?? null,
windowStart: s.windowStart,
windowEnd: s.windowEnd,
timesOfDay: s.timesOfDay,
daysOfWeek: s.daysOfWeek ?? null,
rrule: s.rrule ?? null,
rollingIntervalDays: s.rollingIntervalDays ?? null,
scheduleType: s.scheduleType,
})),
})),
};
},
},
{
name: "labs-catalogue",
uri: "healthlog://labs/catalogue",
title: "Lab biomarker catalogue",
description:
"The curated catalogue of common lab biomarkers with their canonical units and suggested reference bounds, grouped by panel. These are EDITABLE DEFAULTS, not gospel — reference windows vary by lab, sex, and age. Use healthlog://lab/{analyte} or get_labs for the user's own readings.",
mimeType: "application/json",
async read() {
annotate({
action: { name: "mcp.resource.read" },
meta: { resource: "labs-catalogue", present: true },
});
return {
present: true,
note: "Suggested reference bounds are editable defaults, not clinical limits.",
panels: BIOMARKER_PANELS,
biomarkers: BIOMARKER_CATALOG.map((b) => ({
slug: b.slug,
panel: b.panel,
unit: b.unit,
suggestedRange: { low: b.lowerBound, high: b.upperBound },
})),
};
},
},
{
name: "measurements-inventory",
uri: "healthlog://measurements/inventory",
title: "Data inventory",
description:
"What health data the user has and which tool retrieves each domain — one row per domain with presence + approximate sample count. The same manifest list_metrics returns; read this first to discover what is available before fetching figures.",
mimeType: "application/json",
async read(ctx) {
const inventory = await buildCoachDataInventory(ctx.userId, undefined);
annotate({
action: { name: "mcp.resource.read" },
meta: { resource: "measurements-inventory", present: true },
});
return {
present: true,
window: inventory.window,
restMode: inventory.restMode,
cycleEnabled: inventory.cycleEnabled,
metrics: inventory.entries,
};
},
},
{
name: "doctor-visit-report",
uri: "healthlog://report/doctor-visit",
title: "Doctor-visit summary",
description:
"A grounded, clinician-oriented summary of the user's own records over the last 90 days — vitals (with units + reference bands), medications & adherence, and labs (with reference ranges). Reuses the doctor-report data path so the numbers match the exported PDF. Use healthlog://report/doctor-visit/{window} for a different window. Data + context only: no diagnosis, no verdict.",
mimeType: "application/json",
async read(ctx) {
return readDoctorVisit(ctx, "last90days");
},
},
];
// ── Doctor-visit summariser ──────────────────────────────────────────
// The reducer is shared with the `doctor_visit_summary` prompt
// (`doctor-visit-summary.ts`) so both surfaces ground identically.
async function readDoctorVisit(
ctx: McpAuthContext,
window: string,
): Promise<unknown> {
const days = WINDOW_DAYS[window] ?? 90;
const end = new Date();
const start = new Date(end.getTime() - days * MS_PER_DAY);
const data = await collectDoctorReportData(ctx.userId, { start, end, days });
annotate({
action: { name: "mcp.resource.read" },
meta: { resource: "doctor-visit-report", days },
});
return summariseForVisit(data);
}
// ── User-scoped completion + listing sources ─────────────────────────
/** Complete the trailing-window enum by prefix. Not user-specific. */
function completeWindow(_ctx: McpAuthContext, value: string): string[] {
const v = value.toLowerCase();
return MCP_WINDOW_VALUES.filter((w) => w.toLowerCase().startsWith(v));
}
/** The user's own present metric types (the `metric` arg get_metric_series takes). */
async function userMetricTypes(ctx: McpAuthContext): Promise<string[]> {
const inventory = await buildCoachDataInventory(ctx.userId, undefined);
const seen = new Set<string>();
for (const entry of inventory.entries) {
if (entry.present && entry.metric) seen.add(entry.metric);
}
return [...seen];
}
async function completeMetric(
ctx: McpAuthContext,
value: string,
): Promise<string[]> {
const v = value.toLowerCase();
return (await userMetricTypes(ctx)).filter((m) =>
m.toLowerCase().includes(v),
);
}
/** The user's own lab analytes (distinct, non-deleted). */
async function userAnalytes(ctx: McpAuthContext): Promise<string[]> {
const rows = await prisma.labResult.findMany({
where: { userId: ctx.userId, deletedAt: null },
select: { analyte: true },
distinct: ["analyte"],
take: 200,
});
return rows.map((r) => r.analyte);
}
async function completeAnalyte(
ctx: McpAuthContext,
value: string,
): Promise<string[]> {
const v = value.toLowerCase();
return (await userAnalytes(ctx)).filter((a) => a.toLowerCase().includes(v));
}
export const MCP_RESOURCE_TEMPLATES: McpResourceTemplateDefinition[] = [
{
name: "metric",
uriTemplate: "healthlog://metric/{type}",
title: "Metric time series",
description:
"One metric's time series for the user (e.g. healthlog://metric/weight) over the default window. Same server-authoritative read get_metric_series serves: aggregate + recent timelines, units, and population reference bands. Returns { present: false } when the user has no data for that metric.",
mimeType: "application/json",
complete: { type: completeMetric },
async list(ctx) {
return (await userMetricTypes(ctx)).map((type) => ({
uri: `healthlog://metric/${type}`,
name: type,
title: `Metric: ${type}`,
}));
},
async read(ctx, variables) {
const metric = firstVar(variables.type);
const result = await executeCoachTool({
userId: ctx.userId,
name: "get_metric_series",
rawArguments: JSON.stringify({ metric }),
});
annotate({
action: { name: "mcp.resource.read" },
meta: { resource: "metric", present: result.present },
});
return result;
},
},
{
name: "metric-windowed",
uriTemplate: "healthlog://metric/{type}/{window}",
title: "Metric time series over a window",
description:
"One metric's time series for the user over a chosen trailing window (e.g. healthlog://metric/weight/last30days). Window is one of last7days, last30days, last90days, lastYear, allTime. Returns { present: false } when no data exists.",
mimeType: "application/json",
complete: { type: completeMetric, window: completeWindow },
async read(ctx, variables) {
const metric = firstVar(variables.type);
const window = firstVar(variables.window);
const result = await executeCoachTool({
userId: ctx.userId,
name: "get_metric_series",
rawArguments: JSON.stringify(window ? { metric, window } : { metric }),
});
annotate({
action: { name: "mcp.resource.read" },
meta: { resource: "metric-windowed", present: result.present },
});
return result;
},
},
{
name: "lab",
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.",
mimeType: "application/json",
complete: { analyte: completeAnalyte },
async list(ctx) {
return (await userAnalytes(ctx)).map((analyte) => ({
uri: `healthlog://lab/${encodeURIComponent(analyte)}`,
name: analyte,
title: `Lab: ${analyte}`,
}));
},
async read(ctx, variables) {
const analyte = decodeURIComponent(firstVar(variables.analyte));
const result = await executeCoachTool({
userId: ctx.userId,
name: "get_labs",
rawArguments: JSON.stringify({ analyte }),
});
annotate({
action: { name: "mcp.resource.read" },
meta: { resource: "lab", present: result.present },
});
return result;
},
},
{
name: "medication",
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.",
mimeType: "application/json",
async list(ctx) {
const meds = await prisma.medication.findMany({
where: { userId: ctx.userId },
select: { id: true, name: true, dose: true },
orderBy: { createdAt: "desc" },
take: 200,
});
return meds.map((m) => ({
uri: `healthlog://medication/${m.id}`,
name: m.dose ? `${m.name} ${m.dose}` : m.name,
title: m.name,
}));
},
async read(ctx, variables) {
const id = firstVar(variables.id);
// user-scoped: a medication that is not this user's resolves to absent,
// never another tenant's row.
const med = await prisma.medication.findFirst({
where: { id, userId: ctx.userId },
include: { schedules: true },
});
annotate({
action: { name: "mcp.resource.read" },
meta: { resource: "medication", present: med !== null },
});
if (!med) return { present: false };
return {
present: true,
name: med.name,
dose: med.dose,
treatmentClass: med.treatmentClass,
asNeeded: med.asNeeded,
paused: med.pausedAt !== null,
startsOn: med.startsOn ? med.startsOn.toISOString() : null,
endsOn: med.endsOn ? med.endsOn.toISOString() : null,
schedules: med.schedules.map((s) => ({
label: s.label ?? null,
dose: s.dose ?? null,
windowStart: s.windowStart,
windowEnd: s.windowEnd,
timesOfDay: s.timesOfDay,
daysOfWeek: s.daysOfWeek ?? null,
rrule: s.rrule ?? null,
rollingIntervalDays: s.rollingIntervalDays ?? null,
scheduleType: s.scheduleType,
})),
};
},
},
{
name: "doctor-visit-windowed",
uriTemplate: "healthlog://report/doctor-visit/{window}",
title: "Doctor-visit summary over a window",
description:
"The grounded doctor-visit summary over a chosen trailing window (e.g. healthlog://report/doctor-visit/last30days). Window is one of last7days, last30days, last90days, lastYear, allTime. Reuses the doctor-report data path; data + context only, no diagnosis.",
mimeType: "application/json",
complete: { window: completeWindow },
async read(ctx, variables) {
const window = firstVar(variables.window) || "last90days";
return readDoctorVisit(ctx, window);
},
},
];
/** Stable list of the registered fixed resource URIs. */
export const MCP_RESOURCE_URIS: readonly string[] = MCP_RESOURCES.map(
(r) => r.uri,
);
/** Stable list of the registered resource-template URI patterns. */
export const MCP_RESOURCE_TEMPLATE_URIS: readonly string[] =
MCP_RESOURCE_TEMPLATES.map((t) => t.uriTemplate);