feat(mcp): add deep-value reads and a doctor-visit prompt

Expose four read-only relational tools and one prompt over the MCP wire,
each a thin re-export of an existing server-authoritative engine — no new
statistics, no new business logic:

- get_correlation: the FDR-controlled, lag-aware association between two
  named metrics, selected out of the existing correlation discovery the
  Coach and the insight route already run. Descriptive, never causal.
- compare_metric: two metrics over the same trailing window, or one metric
  across two trailing windows, computed over the WMY rollup readers with a
  delta only when both sides share a unit.
- get_metric_baseline: where today's reading sits against the user's own
  usual range (median ± robust deviation) plus the strongest lagged driver,
  reusing the metric-page baseline builder. Honest-null below the learning
  floor — never a fabricated range.
- detect_changepoints: level shifts over the rollup tier via a small in-repo
  CUSUM binary segmentation with a high firing bar.
- doctor_visit_summary prompt: a structured, grounded visit-prep summary
  assembled from the doctor-report data path so the numbers match the
  exported PDF, with the grounding framing injected once — data and context
  only, no diagnosis or verdict.

Every tool returns values, units, reference bands and provenance, declares
read-only / non-destructive / closed-world annotations plus an output schema,
and reports { present: false } for absence. All reads stay session-narrowed
and read-only; the existing security posture is unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 15:34:11 +02:00
1 parent 22eb9cba0d
commit cfffb693c3
9 files changed
+1718 -1

No files matched your search

+5
View File
@@ -76,6 +76,11 @@ const READ_TOOLS = [
"list_metrics",
"search",
"fetch",
// Phase 4 — deep-value reads.
"get_correlation",
"compare_metric",
"get_metric_baseline",
"detect_changepoints",
].sort();
/** A valid, narrow-scope (`health:read`) token resolution. */
+188
View File
@@ -0,0 +1,188 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/doctor-report-data", () => ({
collectDoctorReportData: vi.fn(),
}));
import { MCP_PROMPTS, MCP_PROMPT_NAMES } from "../prompts";
import { collectDoctorReportData } from "@/lib/doctor-report-data";
import type { McpAuthContext } from "../auth";
const CTX: McpAuthContext = {
userId: "user-1",
tokenId: "token-1",
scopes: ["health:read"],
binding: "user-1:token-1",
canRead: true,
canWrite: false,
};
function prompt(name: string) {
const def = MCP_PROMPTS.find((p) => p.name === name);
if (!def) throw new Error(`prompt ${name} not registered`);
return def;
}
/** Extract the JSON data block the prompt embeds in its user message. */
function parseData(text: string): Record<string, unknown> {
const marker = "JSON):\n";
const idx = text.indexOf(marker);
if (idx < 0) throw new Error("no data block in prompt message");
return JSON.parse(text.slice(idx + marker.length));
}
/** A minimal but representative doctor-report payload. */
function reportFixture(
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
return {
period: {
days: 90,
since: "2026-03-29T00:00:00.000Z",
start: "2026-03-29T00:00:00.000Z",
end: "2026-06-27T00:00:00.000Z",
},
patient: {
username: "tester",
fullName: "A Person",
dateOfBirth: "1990-01-01",
gender: "female",
heightCm: 170,
},
practiceName: null,
measurements: {},
stats: {
WEIGHT: { avg: 80.4, min: 79, max: 82, count: 30, latest: 80 },
RESTING_HEART_RATE: { avg: 58, min: 52, max: 66, count: 45, latest: 60 },
},
glucoseStats: {},
glucoseRanges: {},
glucoseUnit: "mgdl",
glucoseClinical: { stillLearning: true, stillLearningReason: "thin" },
bmi: 24.1,
compliance: {
Ramipril: { total: 90, taken: 86, skipped: 0, missed: 4 },
},
medications: [{ name: "Ramipril", dose: "5 mg", schedules: [] }],
mood: null,
...overrides,
};
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("MCP prompt registry — surface", () => {
it("registers the doctor_visit_summary prompt with an args schema", () => {
expect([...MCP_PROMPT_NAMES]).toContain("doctor_visit_summary");
const def = prompt("doctor_visit_summary");
expect(def.title).toBeTruthy();
expect(def.description).toBeTruthy();
expect(def.argsShape).toHaveProperty("window");
});
});
describe("doctor_visit_summary — grounding + structure", () => {
it("assembles a structured summary from the real doctor-report data", async () => {
vi.mocked(collectDoctorReportData).mockResolvedValue(
reportFixture() as never,
);
const res = await prompt("doctor_visit_summary").run(CTX, {
window: "last90days",
});
// One user message carrying framing + data.
expect(res.messages).toHaveLength(1);
expect(res.messages[0].role).toBe("user");
const text = res.messages[0].content.text;
// The window threads through to the data path (trailing 90 days).
expect(collectDoctorReportData).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({ days: 90 }),
);
const data = parseData(text);
const vitals = data.vitals as Array<Record<string, unknown>>;
const weight = vitals.find((v) => v.metric === "WEIGHT")!;
const rhr = vitals.find((v) => v.metric === "RESTING_HEART_RATE")!;
// Units + reference bands ride along, server-side.
expect(weight.unit).toBe("kg");
expect(weight.referenceBand).toBeNull(); // no universal weight band
expect(rhr.unit).toBe("bpm");
expect(rhr.referenceBand).toEqual({ low: 50, high: 100 });
// Values are the server-computed ones — no fabrication.
expect(weight.latest).toBe(80);
expect(rhr.avg).toBe(58);
// Compliance is the ledger-derived adherence (86/90 → 96 %).
const compliance = data.compliance as Array<Record<string, unknown>>;
expect(compliance[0].adherencePct).toBe(96);
});
it("injects the grounding framing — no diagnosis, no invented values", async () => {
vi.mocked(collectDoctorReportData).mockResolvedValue(
reportFixture() as never,
);
const res = await prompt("doctor_visit_summary").run(CTX, {});
const text = res.messages[0].content.text;
expect(text).toMatch(/server-side/i);
expect(text).toMatch(/do not.*diagnos/i);
expect(text).toMatch(/do not (invent|estimate|infer)/i);
});
it("omits the glucose panel while still learning and mood when absent", async () => {
vi.mocked(collectDoctorReportData).mockResolvedValue(
reportFixture() as never,
);
const data = parseData(
(await prompt("doctor_visit_summary").run(CTX, {})).messages[0].content
.text,
);
expect(data).not.toHaveProperty("glucosePanel"); // stillLearning → withheld
expect(data).not.toHaveProperty("mood"); // mood null
});
it("includes labs with reference ranges and caps the list (token budget)", async () => {
const labResults = Array.from({ length: 60 }, (_, i) => ({
panel: null,
analyte: `Analyte-${i}`,
value: i,
valueText: null,
unit: "mg/dL",
referenceLow: 0,
referenceHigh: 100,
takenAt: "2026-06-01T00:00:00.000Z",
count: 1,
}));
vi.mocked(collectDoctorReportData).mockResolvedValue(
reportFixture({ labResults }) as never,
);
const data = parseData(
(await prompt("doctor_visit_summary").run(CTX, {})).messages[0].content
.text,
);
const labs = data.labs as Array<Record<string, unknown>>;
expect(labs.length).toBe(50); // capped from 60
expect(labs[0]).toHaveProperty("referenceHigh", 100);
});
it("does not leak patient name into the summary", async () => {
vi.mocked(collectDoctorReportData).mockResolvedValue(
reportFixture() as never,
);
const data = parseData(
(await prompt("doctor_visit_summary").run(CTX, {})).messages[0].content
.text,
);
const patient = data.patient as Record<string, unknown>;
expect(patient).not.toHaveProperty("fullName");
expect(patient).not.toHaveProperty("username");
expect(patient.dateOfBirth).toBe("1990-01-01");
});
});
+366
View File
@@ -0,0 +1,366 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// --- Engine mocks hoisted before importing the module under test. ---
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({
readCoachCorrelations: vi.fn(),
}));
vi.mock("@/lib/insights/derived/coach-read", () => ({
buildCoachReadStrip: vi.fn(),
}));
// Keep the real `aggregateWmyBuckets` (pure linear composition); stub only the
// DB-backed reader so the rich reads compute over a synthetic bucket sequence.
vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/rollups/measurement-read-wmy")>();
return { ...actual, readBestGranularityRollups: vi.fn() };
});
import {
getCorrelation,
compareMetric,
getMetricBaseline,
detectChangepoints,
resolveRichMetric,
} from "../rich-reads";
import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read";
import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read";
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
const USER = "user-1";
/** Build a rollup bucket row with just the fields the rich reads consume. */
function row(bucketStart: string, mean: number) {
return {
bucketStart: new Date(bucketStart),
count: 1,
mean,
sd: null,
slope: null,
r2: null,
sumValue: null,
minValue: mean,
maxValue: mean,
};
}
beforeEach(() => {
vi.resetAllMocks();
});
// ── metric resolution ────────────────────────────────────────────────
describe("resolveRichMetric", () => {
it("resolves a friendly alias to the registry type + unit + band", () => {
const m = resolveRichMetric("resting_hr");
expect(m?.measurementType).toBe("RESTING_HEART_RATE");
expect(m?.unit).toBe("bpm");
expect(m?.band).toEqual({ low: 50, high: 100 });
});
it("resolves the headline supplement metrics (weight has no universal band)", () => {
const weight = resolveRichMetric("weight");
expect(weight?.measurementType).toBe("WEIGHT");
expect(weight?.unit).toBe("kg");
expect(weight?.band).toBeNull();
expect(resolveRichMetric("pulse")?.band).toEqual({ low: 60, high: 100 });
});
it("matches on display name", () => {
expect(resolveRichMetric("heart-rate variability")?.measurementType).toBe(
"HEART_RATE_VARIABILITY",
);
});
it("returns null for an unknown metric (never invents a series)", () => {
expect(resolveRichMetric("bananas")).toBeNull();
expect(resolveRichMetric("")).toBeNull();
});
});
// ── 1. get_correlation ───────────────────────────────────────────────
describe("get_correlation", () => {
const drivers = [
{
behaviour: "sleep duration",
outcome: "resting heart rate",
direction: "lower" as const,
lagDays: 1,
n: 60,
r: -0.42,
note: "Nights with more sleep tend to precede a lower next-day resting heart rate. Descriptive, not causal.",
},
{
behaviour: "steps",
outcome: "weight",
direction: "lower" as const,
lagDays: 1,
n: 40,
r: -0.2,
note: "Descriptive only.",
},
];
it("returns the matched FDR-controlled pair with a descriptive (non-causal) note", async () => {
vi.mocked(readCoachCorrelations).mockResolvedValue({
present: true,
drivers,
pairsTested: 24,
windowDays: 180,
});
const res = await getCorrelation(USER, {
metricA: "sleep",
metricB: "resting heart rate",
});
expect(res.present).toBe(true);
expect(res.pair?.behaviour).toBe("sleep duration");
expect(res.pair?.outcome).toBe("resting heart rate");
expect(res.pair?.r).toBe(-0.42);
expect(res.association).toBe("descriptive");
expect(res.windowDays).toBe(180);
});
it("honest-null when the pair has no surviving association", async () => {
vi.mocked(readCoachCorrelations).mockResolvedValue({
present: true,
drivers,
pairsTested: 24,
windowDays: 180,
});
const res = await getCorrelation(USER, {
metricA: "glucose",
metricB: "hrv",
});
expect(res.present).toBe(false);
expect(res.reason).toBe("no_significant_pattern_for_pair");
expect(res.pair).toBeUndefined();
});
it("honest-null when the engine surfaces nothing (sparse data)", async () => {
vi.mocked(readCoachCorrelations).mockResolvedValue({
present: false,
reason: "no_significant_pattern",
});
const res = await getCorrelation(USER, {
metricA: "sleep",
metricB: "weight",
});
expect(res.present).toBe(false);
expect(res.reason).toBe("no_significant_pattern");
});
});
// ── 2. compare_metric ────────────────────────────────────────────────
describe("compare_metric", () => {
it("compares two metrics over the same window and computes a delta when units match", async () => {
vi.mocked(readBestGranularityRollups)
.mockResolvedValueOnce({
granularity: "DAY",
rows: [row("2026-06-01", 60), row("2026-06-02", 62)],
})
.mockResolvedValueOnce({
granularity: "DAY",
rows: [row("2026-06-01", 70), row("2026-06-02", 72)],
});
const res = await compareMetric(USER, {
metric: "resting_hr",
metricB: "pulse",
window: "last30days",
});
expect(res.present).toBe(true);
expect(res.mode).toBe("metric_vs_metric");
expect(res.a?.mean).toBe(61);
expect(res.b?.mean).toBe(71);
// Both bpm → delta computed.
expect(res.delta?.mean).toBe(10);
expect(res.a?.unit).toBe("bpm");
});
it("compares one metric across two trailing windows", async () => {
vi.mocked(readBestGranularityRollups)
.mockResolvedValueOnce({
granularity: "DAY",
rows: [row("2026-06-01", 80), row("2026-06-02", 80)],
})
.mockResolvedValueOnce({
granularity: "WEEK",
rows: [row("2026-04-01", 82), row("2026-04-08", 84)],
});
const res = await compareMetric(USER, {
metric: "weight",
window: "last30days",
windowB: "last90days",
});
expect(res.present).toBe(true);
expect(res.mode).toBe("window_vs_window");
expect(res.delta?.mean).toBe(3); // 83 - 80
});
it("asks for a second metric or window when neither is given", async () => {
const res = await compareMetric(USER, { metric: "weight" });
expect(res.present).toBe(false);
expect(res.reason).toBe("specify_metricB_or_windowB");
});
it("honest-null on unknown metric", async () => {
const res = await compareMetric(USER, {
metric: "bananas",
metricB: "weight",
});
expect(res.present).toBe(false);
expect(res.reason).toBe("unknown_metric");
});
it("present:false when a side has no data", async () => {
vi.mocked(readBestGranularityRollups)
.mockResolvedValueOnce({
granularity: "DAY",
rows: [row("2026-06-01", 80)],
})
.mockResolvedValueOnce(null);
const res = await compareMetric(USER, {
metric: "weight",
window: "last30days",
windowB: "last90days",
});
expect(res.present).toBe(false);
expect(res.reason).toBe("no_data");
});
});
// ── 3. get_metric_baseline ───────────────────────────────────────────
describe("get_metric_baseline", () => {
it("returns the personal band + today's placement + reference band", async () => {
vi.mocked(buildCoachReadStrip).mockResolvedValue({
baseline: {
low: 55,
high: 65,
latest: 70,
placement: "above",
sampleDays: 30,
},
learning: false,
driver: {
note: "Descriptive only.",
behaviour: "sleep duration",
outcome: "resting heart rate",
},
});
const res = await getMetricBaseline(USER, { metric: "resting_hr" });
expect(res.present).toBe(true);
expect(res.baseline).toEqual({ low: 55, high: 65, sampleDays: 30 });
expect(res.latest).toBe(70);
expect(res.placement).toBe("above");
expect(res.unit).toBe("bpm");
expect(res.referenceBand).toEqual({ low: 50, high: 100 });
});
it("honest-null below the learning floor (never a fabricated range)", async () => {
vi.mocked(buildCoachReadStrip).mockResolvedValue({
baseline: null,
learning: true,
driver: null,
});
const res = await getMetricBaseline(USER, { metric: "weight" });
expect(res.present).toBe(false);
expect(res.reason).toBe("insufficient_history");
// The personal band is absent — no invented numbers.
expect(res.baseline).toBeUndefined();
});
it("honest-null on unknown metric", async () => {
const res = await getMetricBaseline(USER, { metric: "bananas" });
expect(res.present).toBe(false);
expect(res.reason).toBe("unknown_metric");
});
});
// ── 4. detect_changepoints ───────────────────────────────────────────
describe("detect_changepoints", () => {
it("detects a clear level shift over the rollup buckets", async () => {
// Twelve buckets: ~80 for six, then a clean jump to ~90 for six.
const rows = [
row("2026-03-01", 80),
row("2026-03-02", 81),
row("2026-03-03", 79),
row("2026-03-04", 80),
row("2026-03-05", 81),
row("2026-03-06", 80),
row("2026-03-07", 90),
row("2026-03-08", 91),
row("2026-03-09", 89),
row("2026-03-10", 90),
row("2026-03-11", 91),
row("2026-03-12", 90),
];
vi.mocked(readBestGranularityRollups).mockResolvedValue({
granularity: "DAY",
rows,
});
const res = await detectChangepoints(USER, {
metric: "weight",
window: "last90days",
});
expect(res.present).toBe(true);
expect(res.changepoints?.length).toBeGreaterThanOrEqual(1);
const cp = res.changepoints![0];
expect(cp.direction).toBe("increase");
expect(cp.at).toBe(new Date("2026-03-07").toISOString());
expect(res.granularity).toBe("DAY");
});
it("honest-null on a flat (noise-only) series", async () => {
const rows = Array.from({ length: 12 }, (_, i) =>
row(`2026-03-${String(i + 1).padStart(2, "0")}`, 80 + (i % 2)),
);
vi.mocked(readBestGranularityRollups).mockResolvedValue({
granularity: "DAY",
rows,
});
const res = await detectChangepoints(USER, { metric: "weight" });
expect(res.present).toBe(false);
expect(res.reason).toBe("no_changepoint");
expect(res.bucketsAnalysed).toBe(12);
});
it("honest-null when too few buckets exist", async () => {
vi.mocked(readBestGranularityRollups).mockResolvedValue({
granularity: "DAY",
rows: [row("2026-03-01", 80), row("2026-03-02", 81)],
});
const res = await detectChangepoints(USER, { metric: "weight" });
expect(res.present).toBe(false);
expect(res.reason).toBe("insufficient_data");
});
it("honest-null on unknown metric", async () => {
const res = await detectChangepoints(USER, { metric: "bananas" });
expect(res.present).toBe(false);
expect(res.reason).toBe("unknown_metric");
});
});
// ── grounding posture ────────────────────────────────────────────────
describe("grounding / no fabrication", () => {
it("no rich read returns a verdict / diagnosis / advice field", async () => {
vi.mocked(readCoachCorrelations).mockResolvedValue({ present: false });
vi.mocked(buildCoachReadStrip).mockResolvedValue({
baseline: null,
learning: true,
driver: null,
});
vi.mocked(readBestGranularityRollups).mockResolvedValue(null);
const results = [
await getCorrelation(USER, { metricA: "sleep", metricB: "weight" }),
await compareMetric(USER, { metric: "weight", windowB: "last90days" }),
await getMetricBaseline(USER, { metric: "weight" }),
await detectChangepoints(USER, { metric: "weight" }),
];
for (const res of results) {
const keys = Object.keys(res);
expect(keys).not.toContain("verdict");
expect(keys).not.toContain("diagnosis");
expect(keys).not.toContain("advice");
}
});
});
+15 -1
View File
@@ -21,6 +21,15 @@ vi.mock("@/lib/db", () => ({
labResult: { findMany: vi.fn(async () => []) },
},
}));
// Phase 4 — the deep-value reads delegate to the rich-reads engines; stub them
// so the registry-wide loops (surface / annotation / no-verdict) never reach a
// real engine. Their own logic is covered in `rich-reads.test.ts`.
vi.mock("../rich-reads", () => ({
getCorrelation: vi.fn(async () => ({ present: true })),
compareMetric: vi.fn(async () => ({ present: true })),
getMetricBaseline: vi.fn(async () => ({ present: true })),
detectChangepoints: vi.fn(async () => ({ present: true })),
}));
import { MCP_TOOLS, MCP_TOOL_NAMES } from "../tools";
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
@@ -47,7 +56,7 @@ beforeEach(() => {
});
describe("MCP tool registry — surface", () => {
it("registers exactly the Phase-1 read tools", () => {
it("registers exactly the read tools (Phase 1 + Phase 4)", () => {
expect([...MCP_TOOL_NAMES].sort()).toEqual(
[
"get_correlations",
@@ -58,6 +67,11 @@ describe("MCP tool registry — surface", () => {
// v1.22.0 — the ChatGPT default-mode retrieval pair.
"search",
"fetch",
// Phase 4 — deep-value reads.
"get_correlation",
"compare_metric",
"get_metric_baseline",
"detect_changepoints",
].sort(),
);
});
+15
View File
@@ -15,6 +15,21 @@ export {
MCP_RESOURCE_URIS,
type McpResourceDefinition,
} from "./resources";
export {
MCP_PROMPTS,
MCP_PROMPT_NAMES,
type McpPromptDefinition,
type McpPromptResult,
type McpPromptMessage,
} from "./prompts";
export {
getCorrelation,
compareMetric,
getMetricBaseline,
detectChangepoints,
resolveRichMetric,
type RichMetric,
} from "./rich-reads";
export {
SCOPE_HEALTH_READ,
SCOPE_HEALTH_WRITE,
+262
View File
@@ -0,0 +1,262 @@
/**
* Phase 4 — MCP prompts (user-controlled templated workflows).
*
* Prompts are the slash-command surface a host exposes to the user. Unlike
* tools (model-controlled), a prompt is picked by the user and returns a set of
* messages pre-loaded with REAL, server-retrieved data plus the safety framing
* injected once, centrally (catalogue §5 #2, ADR-004).
*
* `doctor_visit_summary` is the showcase capability: it assembles a grounded,
* structured visit-prep summary from the SAME doctor-report data path
* (`collectDoctorReportData`) that backs the PDF the user already trusts — so
* the numbers match the export byte-for-byte. It carries the identical grounding
* discipline as the Coach:
* - only values the server computed (no fabricated readings);
* - reference / clinical bands are server-side, never invented by the model;
* - DATA + CONTEXT only — never a diagnosis, verdict, or treatment change.
*
* Token budget (R-DEL-2): the prompt SUMMARISES (latest + aggregate per metric,
* latest reading per analyte) — it never dumps the full per-reading history.
*/
import { z } from "zod/v4";
import { annotate } from "@/lib/logging/context";
import {
collectDoctorReportData,
type DoctorReportData,
} from "@/lib/doctor-report-data";
import { getMetricStatusMeta } from "@/lib/insights/metric-status-registry";
import { coachScopeWindowSchema } from "@/lib/ai/coach/types";
import type { CoachScopeWindow } from "@/lib/ai/coach/types";
import type { McpAuthContext } from "./auth";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** Trailing-day count per window, capped at the doctor-report 365-day ceiling. */
const WINDOW_DAYS: Record<CoachScopeWindow, number> = {
last7days: 7,
last30days: 30,
last90days: 90,
lastYear: 365,
allTime: 365,
};
/** A returned prompt message — mirrors the SDK `GetPromptResult` shape. */
export interface McpPromptMessage {
role: "user" | "assistant";
content: { type: "text"; text: string };
}
export interface McpPromptResult {
description?: string;
messages: McpPromptMessage[];
}
/** A registry entry — transport-agnostic, mirroring `McpToolDefinition`. */
export interface McpPromptDefinition {
name: string;
title: string;
description: string;
/** zod/v4 raw shape handed to the SDK's `registerPrompt` for arg validation. */
argsShape: z.ZodRawShape;
run: (
ctx: McpAuthContext,
args: Record<string, unknown>,
) => Promise<McpPromptResult>;
}
/**
* Fallback units for the headline specialised metric types the generic
* `metric-status-registry` intentionally omits (it carries the synced/additive
* metrics only). The doctor-report stats map is keyed by `MeasurementType`.
*/
const FALLBACK_UNITS: Record<string, string> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",
BLOOD_PRESSURE_DIA: "mmHg",
PULSE: "bpm",
BODY_MASS_INDEX: "kg/m²",
SLEEP_DURATION: "min",
};
function unitAndBandFor(type: string): {
unit: string;
referenceBand: { low: number; high: number } | null;
} {
const meta = getMetricStatusMeta(type);
if (meta) {
return {
unit: meta.unit,
referenceBand: meta.normalRange
? { low: meta.normalRange.low, high: meta.normalRange.high }
: null,
};
}
return { unit: FALLBACK_UNITS[type] ?? "", referenceBand: null };
}
const round = (n: number): number => Math.round(n * 100) / 100;
/**
* Reduce the full doctor-report payload to a compact, clinician-oriented
* summary. Latest + aggregate per metric, latest reading per analyte — never
* the full per-reading history (R-DEL-2). Every numeric value is one the server
* computed; every band is server-side. No verdict, no diagnosis.
*/
function summariseForVisit(data: DoctorReportData): Record<string, unknown> {
const vitals = Object.entries(data.stats).map(([type, s]) => {
const { unit, referenceBand } = unitAndBandFor(type);
return {
metric: type,
unit,
referenceBand,
latest: round(s.latest),
avg: round(s.avg),
min: round(s.min),
max: round(s.max),
readings: s.count,
};
});
const compliance = Object.entries(data.compliance).map(([name, c]) => ({
medication: name,
takenDoses: c.taken,
expectedDoses: c.total,
missedDoses: c.missed,
adherencePct:
c.total > 0 ? Math.min(100, Math.round((c.taken / c.total) * 100)) : null,
}));
const medications = data.medications
.slice(0, 40)
.map((m) => ({ name: m.name, dose: m.dose }));
const labs = (data.labResults ?? []).slice(0, 50).map((l) => ({
analyte: l.analyte,
panel: l.panel,
value: l.value,
valueText: l.valueText,
unit: l.unit,
referenceLow: l.referenceLow,
referenceHigh: l.referenceHigh,
takenAt: l.takenAt,
readings: l.count,
}));
const wellness = (data.wellnessScores ?? []).map((w) => ({
score: w.type,
latest: round(w.latest),
avg: round(w.avg),
note: "descriptive composite, not a clinical assessment",
}));
const illness = (data.illnessEpisodes ?? []).map((e) => ({
label: e.label,
type: e.type,
lifecycle: e.lifecycle,
onsetAt: e.onsetAt,
resolvedAt: e.resolvedAt,
}));
return {
period: {
days: data.period.days,
start: data.period.start,
end: data.period.end,
},
patient: {
dateOfBirth: data.patient.dateOfBirth,
sex: data.patient.gender,
heightCm: data.patient.heightCm,
},
bmi: data.bmi,
vitals,
medications,
compliance,
// Mood is privacy-gated upstream; include only the aggregate when present.
...(data.mood
? {
mood: {
avg: round(data.mood.avg),
min: data.mood.min,
max: data.mood.max,
entries: data.mood.count,
},
}
: {}),
// Glucose clinical panel only when the user logs glucose (stillLearning
// flags a too-thin period — surfaced honestly, never asserted).
...(data.glucoseClinical && !data.glucoseClinical.stillLearning
? { glucosePanel: data.glucoseClinical }
: {}),
...(labs.length > 0 ? { labs } : {}),
...(wellness.length > 0 ? { wellnessScores: wellness } : {}),
...(illness.length > 0 ? { illnessEpisodes: illness } : {}),
};
}
/**
* The framing block prepended to the assembled data. Injected once, centrally:
* the assistant is told to organise + present, never to diagnose or invent.
*/
const VISIT_FRAMING = [
"You are preparing a concise, factual summary to help the user get ready for a doctor's visit.",
"Use ONLY the structured data below — every value, unit, and reference band is computed server-side from the user's own records.",
"Do NOT invent, estimate, or infer any reading that is not present. Do NOT provide a diagnosis, clinical verdict, risk score, or treatment/medication change — those are the clinician's role.",
"Organise the data into clear sections (vitals, medications & adherence, labs, notable changes). Where a value sits outside its reference band, state the value and band factually; do not interpret it.",
"If a section is empty, say it was not recorded in this period rather than guessing.",
].join(" ");
export const MCP_PROMPTS: McpPromptDefinition[] = [
{
name: "doctor_visit_summary",
title: "Doctor visit summary",
description:
"Assemble a structured, grounded visit-prep summary from the user's own records over a window — vitals (with units + reference bands), medications & adherence, labs (with reference ranges), and notable context. Reuses the doctor-report data path so the numbers match the exported PDF. Data + context only: no diagnosis, no verdict.",
argsShape: { window: coachScopeWindowSchema.optional() },
async run(ctx, args) {
const window = (
typeof args.window === "string" ? args.window : "last90days"
) as CoachScopeWindow;
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,
});
const summary = summariseForVisit(data);
annotate({
action: { name: "mcp.prompt.invoked" },
meta: {
prompt: "doctor_visit_summary",
days,
vitals: Array.isArray(summary.vitals) ? summary.vitals.length : 0,
},
});
return {
description: `Visit-prep summary over the last ${days} days, grounded in your own records.`,
messages: [
{
role: "user",
content: {
type: "text",
text: `${VISIT_FRAMING}\n\nDATA (server-authoritative, JSON):\n${JSON.stringify(
summary,
)}`,
},
},
],
};
},
},
];
/** Stable list of the registered prompt names. */
export const MCP_PROMPT_NAMES: readonly string[] = MCP_PROMPTS.map(
(p) => p.name,
);
+671
View File
@@ -0,0 +1,671 @@
/**
* Phase 4 — deep-value MCP read tools (engine glue).
*
* Four "bring data into relation" reads, each a THIN re-export of an existing
* server-authoritative engine — no new statistics, no new business logic
* (REQ-WONT-2 / catalogue §5):
*
* 1. get_correlation — one named driver pair out of the FDR-controlled,
* lag-aware discovery (`readCoachCorrelations`, the
* same engine the Coach `get_correlations` tool and
* the `/api/insights/correlations` route run).
* 2. compare_metric — two trailing-window or two-metric rollup snapshots
* side by side (`readBestGranularityRollups` +
* `aggregateWmyBuckets`) with structured deltas.
* 3. get_metric_baseline — the metric's personal usual range (median ± k·MAD)
* + today's placement, via `buildCoachReadStrip`
* (the same engine the metric-page "Coach read" strip
* renders).
* 4. detect_changepoints — level shifts in a metric over the rollup tier, via
* a minimal in-repo CUSUM binary-segmentation over the
* rollup bucket means (no heavy dependency, high
* firing bar).
*
* Grounding contract (REQ-SEC-2/3/4, ADR-004): every read returns structured
* values + units + reference bands + provenance and uses `{ present: false }`
* for absence / insufficiency — never a silent zero, never a prose verdict or
* diagnosis. Associations are described, never asserted as causal. `userId` is
* the session-narrowed id passed by the caller, never a tool argument
* (REQ-SEC-5). All reads are read-only.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { annotate } from "@/lib/logging/context";
import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read";
import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read";
import {
readBestGranularityRollups,
aggregateWmyBuckets,
} from "@/lib/rollups/measurement-read-wmy";
import {
getMetricStatusMeta,
METRIC_STATUS_IDS,
} from "@/lib/insights/metric-status-registry";
import type { CoachScopeWindow } from "@/lib/ai/coach/types";
/** Trailing-day count for each window the rich reads accept. */
const WINDOW_DAYS: Record<CoachScopeWindow, number> = {
last7days: 7,
last30days: 30,
last90days: 90,
lastYear: 365,
// The rollup tier caps "all time" timelines at one year elsewhere; keep the
// rich reads bounded to the same ceiling so the token budget stays sane.
allTime: 365,
};
function windowToDays(window: CoachScopeWindow | undefined): number {
return WINDOW_DAYS[window ?? "last30days"];
}
// ── Metric resolution ────────────────────────────────────────────────
//
// A scalar metric the rich reads can place on a single rollup series, carrying
// its canonical unit + population reference band (when a broadly-accepted one
// exists). Most entries are derived straight from the single-source
// `metric-status-registry`; a small explicit supplement covers the headline
// specialised metrics (weight / pulse / BMI) the registry intentionally omits.
export interface RichMetric {
/** The DB `MeasurementType` the rollup tier + baseline engine read against. */
measurementType: MeasurementType;
/** Human label for the resolved metric (provenance / narration). */
label: string;
/** Canonical storage unit. */
unit: string;
/** Population reference band, or `null` when no universal band exists. */
band: { low: number; high: number } | null;
}
/** Headline specialised metrics the status registry does not carry. */
const SUPPLEMENT: Record<string, RichMetric> = {
weight: {
measurementType: "WEIGHT",
label: "Weight",
unit: "kg",
// No universal healthy band — body-size dependent; defer to own baseline.
band: null,
},
pulse: {
measurementType: "PULSE",
label: "Pulse",
unit: "bpm",
band: { low: 60, high: 100 },
},
bmi: {
measurementType: "BODY_MASS_INDEX",
label: "Body-mass index",
unit: "kg/m²",
band: { low: 18.5, high: 25 },
},
};
/** Friendly aliases (Coach source slugs + natural phrasings) → registry id. */
const ALIASES: Record<string, string> = {
resting_hr: "RESTING_HEART_RATE",
resting_heart_rate: "RESTING_HEART_RATE",
hrv: "HEART_RATE_VARIABILITY",
spo2: "OXYGEN_SATURATION",
blood_oxygen: "OXYGEN_SATURATION",
vo2_max: "VO2_MAX",
vo2max: "VO2_MAX",
steps: "STEPS",
glucose: "BLOOD_GLUCOSE",
blood_glucose: "BLOOD_GLUCOSE",
sleep: "SLEEP_DURATION",
sleep_duration: "SLEEP_DURATION",
distance: "WALKING_RUNNING_DISTANCE",
active_energy: "ACTIVE_ENERGY",
body_temp: "BODY_TEMPERATURE",
body_temperature: "BODY_TEMPERATURE",
daylight: "TIME_IN_DAYLIGHT",
respiratory_rate: "RESPIRATORY_RATE",
walking_hr: "WALKING_HEART_RATE_AVERAGE",
};
function fromRegistry(id: string): RichMetric | null {
const meta = getMetricStatusMeta(id);
if (!meta) return null;
return {
measurementType: meta.measurementType,
label: meta.displayName,
unit: meta.unit,
band: meta.normalRange
? { low: meta.normalRange.low, high: meta.normalRange.high }
: null,
};
}
/**
* Resolve a free-text metric name to a single scalar series. Forgiving for an
* NL assistant (alias, exact id, display-name match) but closed — an
* unresolved name returns `null` so the tool reports `{ present: false }`
* rather than inventing a series. Multi-series metrics (blood pressure) are not
* resolvable here by design; the prompt + `get_metric_series` cover BP.
*/
export function resolveRichMetric(input: string): RichMetric | null {
const raw = input.trim();
if (!raw) return null;
const key = raw.toLowerCase().replace(/[\s-]+/g, "_");
// 1. explicit supplement (weight / pulse / bmi).
if (SUPPLEMENT[key]) return SUPPLEMENT[key];
// 2. friendly alias → registry id.
const aliased = ALIASES[key];
if (aliased) return fromRegistry(aliased);
// 3. exact registry id (case-insensitive).
const upper = key.toUpperCase();
if ((METRIC_STATUS_IDS as readonly string[]).includes(upper)) {
const direct = fromRegistry(upper);
if (direct) return direct;
}
// 4. display-name match (e.g. "heart-rate variability").
const needle = raw.toLowerCase();
for (const id of METRIC_STATUS_IDS) {
const meta = getMetricStatusMeta(id);
if (!meta) continue;
const name = meta.displayName.toLowerCase();
if (name === needle || name.includes(needle) || needle.includes(name)) {
return fromRegistry(id);
}
}
return null;
}
// ── 1. get_correlation ───────────────────────────────────────────────
export interface CorrelationResult {
present: boolean;
reason?: string;
/** The matched driver pair (descriptive, never causal). */
pair?: {
behaviour: string;
outcome: string;
direction: "higher" | "lower";
lagDays: number;
n: number;
r: number;
note: string;
};
/** Honest footer — pairs tested + window the discovery scanned. */
pairsTested?: number;
windowDays?: number;
/** Constant marker so the model never re-frames the link as causal. */
association?: "descriptive";
}
function norm(label: string): string {
return label.trim().toLowerCase();
}
/** True when `a`/`b` (either order) match the driver's behaviour/outcome. */
function pairMatches(
behaviour: string,
outcome: string,
a: string,
b: string,
): boolean {
const contains = (hay: string, needle: string) =>
hay.includes(needle) || needle.includes(hay);
return (
(contains(behaviour, a) && contains(outcome, b)) ||
(contains(behaviour, b) && contains(outcome, a))
);
}
/**
* Return the FDR-controlled, lag-aware association between two named metrics.
* Pure re-export: runs the SAME discovery the Coach `get_correlations` tool and
* the insight route run, then selects the surviving pair (strongest |r|) that
* matches the requested two metrics — never re-computing a relationship the
* engine did not surface. Honest `{ present: false }` when no surviving pair
* matches (sparse data, or the link did not clear the engine's floors).
*/
export async function getCorrelation(
userId: string,
args: { metricA: string; metricB: string },
): Promise<CorrelationResult> {
const a = norm(args.metricA);
const b = norm(args.metricB);
const result = await readCoachCorrelations(userId);
if (!result.present || !result.drivers || result.drivers.length === 0) {
annotate({
action: { name: "mcp.tool.invoked" },
meta: { tool: "get_correlation", present: false },
});
return {
present: false,
reason: result.reason ?? "no_significant_pattern",
pairsTested: result.pairsTested,
windowDays: result.windowDays,
};
}
const matches = result.drivers.filter((d) =>
pairMatches(norm(d.behaviour), norm(d.outcome), a, b),
);
if (matches.length === 0) {
annotate({
action: { name: "mcp.tool.invoked" },
meta: { tool: "get_correlation", present: false },
});
return {
present: false,
reason: "no_significant_pattern_for_pair",
pairsTested: result.pairsTested,
windowDays: result.windowDays,
};
}
const best = matches.reduce((acc, cur) =>
Math.abs(cur.r) > Math.abs(acc.r) ? cur : acc,
);
annotate({
action: { name: "mcp.tool.invoked" },
meta: { tool: "get_correlation", present: true },
});
return {
present: true,
pair: {
behaviour: best.behaviour,
outcome: best.outcome,
direction: best.direction,
lagDays: best.lagDays,
n: best.n,
r: best.r,
note: best.note,
},
pairsTested: result.pairsTested,
windowDays: result.windowDays,
association: "descriptive",
};
}
// ── 2. compare_metric ────────────────────────────────────────────────
interface MetricWindowSnapshot {
label: string;
unit: string;
band: { low: number; high: number } | null;
windowDays: number;
granularity: string;
count: number;
mean: number | null;
min: number | null;
max: number | null;
}
export interface CompareMetricResult {
present: boolean;
reason?: string;
mode?: "metric_vs_metric" | "window_vs_window";
a?: MetricWindowSnapshot;
b?: MetricWindowSnapshot;
/** Delta b − a, only when both sides share a unit; null otherwise. */
delta?: { mean: number; pct: number | null } | null;
}
async function snapshotMetricWindow(
userId: string,
metric: RichMetric,
windowDays: number,
): Promise<MetricWindowSnapshot | null> {
const read = await readBestGranularityRollups(
userId,
metric.measurementType,
windowDays,
);
if (!read || read.rows.length === 0) return null;
const agg = aggregateWmyBuckets(read.rows);
if (agg.count === 0) return null;
return {
label: metric.label,
unit: metric.unit,
band: metric.band,
windowDays,
granularity: read.granularity,
count: agg.count,
mean: agg.mean,
min: agg.min,
max: agg.max,
};
}
/**
* Compare a metric against another metric (same trailing window) OR a single
* metric across two trailing windows. Pure re-export of the WMY rollup reader +
* the linear `aggregateWmyBuckets` composition — no new math. Windows are
* trailing-to-now (e.g. last 30 days vs last 90 days); a delta is only computed
* when both sides carry the same unit, so the result never compares unlike
* scales. `{ present: false }` when neither side has data.
*/
export async function compareMetric(
userId: string,
args: {
metric: string;
metricB?: string;
window?: CoachScopeWindow;
windowB?: CoachScopeWindow;
},
): Promise<CompareMetricResult> {
const metricA = resolveRichMetric(args.metric);
if (!metricA) {
return { present: false, reason: "unknown_metric" };
}
const daysA = windowToDays(args.window);
const annotateMiss = () =>
annotate({
action: { name: "mcp.tool.invoked" },
meta: { tool: "compare_metric", present: false },
});
let mode: "metric_vs_metric" | "window_vs_window";
let metricB: RichMetric;
let daysB: number;
if (args.metricB) {
const resolvedB = resolveRichMetric(args.metricB);
if (!resolvedB) {
annotateMiss();
return { present: false, reason: "unknown_metric_b" };
}
mode = "metric_vs_metric";
metricB = resolvedB;
daysB = daysA;
} else if (args.windowB) {
mode = "window_vs_window";
metricB = metricA;
daysB = windowToDays(args.windowB);
} else {
annotateMiss();
return { present: false, reason: "specify_metricB_or_windowB" };
}
const [a, b] = await Promise.all([
snapshotMetricWindow(userId, metricA, daysA),
snapshotMetricWindow(userId, metricB, daysB),
]);
if (!a || !b) {
annotateMiss();
return {
present: false,
reason: "no_data",
mode,
...(a ? { a } : {}),
...(b ? { b } : {}),
};
}
let delta: { mean: number; pct: number | null } | null = null;
if (a.unit === b.unit && a.mean !== null && b.mean !== null) {
const diff = b.mean - a.mean;
delta = {
mean: diff,
pct: a.mean !== 0 ? (diff / Math.abs(a.mean)) * 100 : null,
};
}
annotate({
action: { name: "mcp.tool.invoked" },
meta: { tool: "compare_metric", present: true },
});
return { present: true, mode, a, b, delta };
}
// ── 3. get_metric_baseline ───────────────────────────────────────────
export interface MetricBaselineResult {
present: boolean;
reason?: string;
metric?: string;
unit?: string;
/** The user's personal usual range (median ± k·MAD) + sample transparency. */
baseline?: { low: number; high: number; sampleDays: number };
/** Today's latest reading. */
latest?: number;
/** Where the latest reading sits relative to the personal band. */
placement?: "within" | "above" | "below";
/** Population reference band, or `null` when none exists for the metric. */
referenceBand?: { low: number; high: number } | null;
/** The single strongest lagged driver of this metric, or `null`. */
driver?: { note: string; behaviour: string; outcome: string } | null;
}
/**
* Return where today's value sits against the user's own usual range. Pure
* re-export of `buildCoachReadStrip` — the SAME median ± k·MAD baseline engine
* (`computeVitalsBaseline`) + lagged-driver pick the metric page renders. Below
* the engine's 7-day history floor the band is not asserted (`{ present: false,
* reason: "insufficient_history" }`) — never a fabricated range. The population
* reference band rides along as general context even on a miss.
*/
export async function getMetricBaseline(
userId: string,
args: { metric: string },
): Promise<MetricBaselineResult> {
const metric = resolveRichMetric(args.metric);
if (!metric) {
return { present: false, reason: "unknown_metric" };
}
const strip = await buildCoachReadStrip(userId, metric.measurementType);
if (!strip.baseline) {
annotate({
action: { name: "mcp.tool.invoked" },
meta: { tool: "get_metric_baseline", present: false },
});
return {
present: false,
reason: strip.learning ? "insufficient_history" : "no_data",
metric: metric.label,
unit: metric.unit,
referenceBand: metric.band,
};
}
annotate({
action: { name: "mcp.tool.invoked" },
meta: { tool: "get_metric_baseline", present: true },
});
return {
present: true,
metric: metric.label,
unit: metric.unit,
baseline: {
low: strip.baseline.low,
high: strip.baseline.high,
sampleDays: strip.baseline.sampleDays,
},
latest: strip.baseline.latest,
placement: strip.baseline.placement,
referenceBand: metric.band,
driver: strip.driver,
};
}
// ── 4. detect_changepoints ───────────────────────────────────────────
export interface Changepoint {
/** Bucket-start ISO timestamp of the first bucket of the new regime. */
at: string;
direction: "increase" | "decrease";
beforeMean: number;
afterMean: number;
delta: number;
}
export interface ChangepointsResult {
present: boolean;
reason?: string;
metric?: string;
unit?: string;
granularity?: string;
windowDays?: number;
bucketsAnalysed?: number;
changepoints?: Changepoint[];
}
const MIN_SEGMENT = 5;
/** Mean shift must clear this many series standard deviations to fire. */
const SHIFT_SD_FLOOR = 1.5;
const MAX_CHANGEPOINTS = 5;
function mean(xs: number[]): number {
return xs.reduce((s, x) => s + x, 0) / xs.length;
}
function stdDev(xs: number[]): number {
if (xs.length < 2) return 0;
const m = mean(xs);
const variance = xs.reduce((s, x) => s + (x - m) ** 2, 0) / (xs.length - 1);
return Math.sqrt(variance);
}
/**
* Minimal CUSUM binary-segmentation changepoint detector over a 1-D series.
*
* For a segment, the cumulative sum of mean-centred deviations peaks at the most
* likely split. The split fires ONLY when the resulting mean shift clears a
* conservative effect-size floor (≥ SHIFT_SD_FLOOR · series SD) and both halves
* are at least MIN_SEGMENT long — a deliberately high bar so noise does not
* register (honest-null over false positives). Accepted splits recurse on each
* half; the result is capped to the strongest MAX_CHANGEPOINTS shifts by the
* caller.
*
* Indices are into the original series. Pure / dependency-free.
*/
function cusumSegment(
values: number[],
lo: number,
hi: number,
out: Array<{ index: number; beforeMean: number; afterMean: number }>,
): void {
const n = hi - lo;
if (n < 2 * MIN_SEGMENT) return;
const segment = values.slice(lo, hi);
const segMean = mean(segment);
const sd = stdDev(segment);
if (sd <= 0) return;
// Cumulative sum of mean-centred deviations; track the extreme position.
let cusum = 0;
let maxAbs = 0;
let splitRel = -1;
for (let i = 0; i < n; i++) {
cusum += segment[i] - segMean;
if (Math.abs(cusum) > maxAbs) {
maxAbs = Math.abs(cusum);
// The new regime starts at the bucket AFTER the accumulation peak.
splitRel = i + 1;
}
}
if (splitRel < MIN_SEGMENT || n - splitRel < MIN_SEGMENT) return;
const before = segment.slice(0, splitRel);
const after = segment.slice(splitRel);
const beforeMean = mean(before);
const afterMean = mean(after);
if (Math.abs(afterMean - beforeMean) < SHIFT_SD_FLOOR * sd) return;
out.push({ index: lo + splitRel, beforeMean, afterMean });
cusumSegment(values, lo, lo + splitRel, out);
cusumSegment(values, lo + splitRel, hi, out);
}
/**
* Surface level shifts in a metric over the rollup tier. Reads the trailing
* window's rollup buckets (`readBestGranularityRollups` — DAY for ≤90 days,
* coarser for longer windows; the resolved granularity is reported) and runs
* the minimal CUSUM above over the bucket means. High firing bar — returns
* `{ present: false }` when too few buckets exist or no shift clears the floor.
* Re-uses the rollup tier's already-computed bucket means; adds no new
* persisted analytics.
*/
export async function detectChangepoints(
userId: string,
args: { metric: string; window?: CoachScopeWindow },
): Promise<ChangepointsResult> {
const metric = resolveRichMetric(args.metric);
if (!metric) {
return { present: false, reason: "unknown_metric" };
}
const windowDays = windowToDays(args.window ?? "last90days");
const read = await readBestGranularityRollups(
userId,
metric.measurementType,
windowDays,
);
const miss = (reason: string): ChangepointsResult => {
annotate({
action: { name: "mcp.tool.invoked" },
meta: { tool: "detect_changepoints", present: false },
});
return {
present: false,
reason,
metric: metric.label,
unit: metric.unit,
windowDays,
};
};
if (!read || read.rows.length < 2 * MIN_SEGMENT) {
return miss("insufficient_data");
}
const values = read.rows.map((r) => r.mean);
const found: Array<{ index: number; beforeMean: number; afterMean: number }> =
[];
cusumSegment(values, 0, values.length, found);
if (found.length === 0) {
return {
...miss("no_changepoint"),
granularity: read.granularity,
bucketsAnalysed: values.length,
};
}
const changepoints: Changepoint[] = found
.sort(
(x, y) =>
Math.abs(y.afterMean - y.beforeMean) -
Math.abs(x.afterMean - x.beforeMean),
)
.slice(0, MAX_CHANGEPOINTS)
.map((cp) => ({
at: read.rows[cp.index].bucketStart.toISOString(),
direction:
cp.afterMean >= cp.beforeMean
? ("increase" as const)
: ("decrease" as const),
beforeMean: cp.beforeMean,
afterMean: cp.afterMean,
delta: cp.afterMean - cp.beforeMean,
}))
.sort((x, y) => (x.at < y.at ? -1 : 1));
annotate({
action: { name: "mcp.tool.invoked" },
meta: { tool: "detect_changepoints", present: true },
});
return {
present: true,
metric: metric.label,
unit: metric.unit,
granularity: read.granularity,
windowDays,
bucketsAnalysed: values.length,
changepoints,
};
}
+24
View File
@@ -17,6 +17,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { McpAuthContext } from "./auth";
import { MCP_TOOLS } from "./tools";
import { MCP_RESOURCES } from "./resources";
import { MCP_PROMPTS } from "./prompts";
export const MCP_SERVER_NAME = "healthlog";
export const MCP_SERVER_VERSION = "1.0.0";
@@ -64,6 +65,29 @@ export function createMcpServer(ctx: McpAuthContext): McpServer {
);
}
for (const prompt of MCP_PROMPTS) {
server.registerPrompt(
prompt.name,
{
title: prompt.title,
description: prompt.description,
argsSchema: prompt.argsShape,
},
async (args: Record<string, unknown>) => {
// The prompt assembles REAL, server-retrieved data + the central
// grounding framing; the result is a set of messages the host inserts
// into the conversation (ADR-004). Read-only, like every Phase-4
// surface — a prompt never mutates. Returned as a fresh literal so it
// conforms to the SDK's `GetPromptResult` (index-signatured) shape.
const result = await prompt.run(ctx, args ?? {});
return {
messages: result.messages,
...(result.description ? { description: result.description } : {}),
};
},
);
}
for (const resource of MCP_RESOURCES) {
server.registerResource(
resource.name,
+172
View File
@@ -28,6 +28,12 @@ import {
coachScopeWindowSchema,
} from "@/lib/ai/coach/types";
import { resolveBaseOrigin } from "@/lib/mcp/oauth/config";
import {
getCorrelation,
compareMetric,
getMetricBaseline,
detectChangepoints,
} from "@/lib/mcp/rich-reads";
import type { McpAuthContext } from "./auth";
/**
@@ -283,6 +289,97 @@ function searchAndFetchTools(): McpToolDefinition[] {
];
}
// ── Phase 4 deep-value output schemas ────────────────────────────────
// Every field except `present` is optional so a grounded `{ present: false }`
// miss and a full hit both conform — the SDK validates `structuredContent`
// against these (REQ: structuredContent + outputSchema on the rich reads).
const bandShape = z
.object({ low: z.number(), high: z.number() })
.nullable()
.optional();
const getCorrelationOutput: z.ZodRawShape = {
present: z.boolean(),
reason: z.string().optional(),
pair: z
.object({
behaviour: z.string(),
outcome: z.string(),
direction: z.enum(["higher", "lower"]),
lagDays: z.number(),
n: z.number(),
r: z.number(),
note: z.string(),
})
.optional(),
pairsTested: z.number().optional(),
windowDays: z.number().optional(),
association: z.literal("descriptive").optional(),
};
const metricWindowSnapshotSchema = z.object({
label: z.string(),
unit: z.string(),
band: bandShape,
windowDays: z.number(),
granularity: z.string(),
count: z.number(),
mean: z.number().nullable(),
min: z.number().nullable(),
max: z.number().nullable(),
});
const compareMetricOutput: z.ZodRawShape = {
present: z.boolean(),
reason: z.string().optional(),
mode: z.enum(["metric_vs_metric", "window_vs_window"]).optional(),
a: metricWindowSnapshotSchema.optional(),
b: metricWindowSnapshotSchema.optional(),
delta: z
.object({ mean: z.number(), pct: z.number().nullable() })
.nullable()
.optional(),
};
const getMetricBaselineOutput: z.ZodRawShape = {
present: z.boolean(),
reason: z.string().optional(),
metric: z.string().optional(),
unit: z.string().optional(),
baseline: z
.object({ low: z.number(), high: z.number(), sampleDays: z.number() })
.optional(),
latest: z.number().optional(),
placement: z.enum(["within", "above", "below"]).optional(),
referenceBand: bandShape,
driver: z
.object({ note: z.string(), behaviour: z.string(), outcome: z.string() })
.nullable()
.optional(),
};
const detectChangepointsOutput: z.ZodRawShape = {
present: z.boolean(),
reason: z.string().optional(),
metric: z.string().optional(),
unit: z.string().optional(),
granularity: z.string().optional(),
windowDays: z.number().optional(),
bucketsAnalysed: z.number().optional(),
changepoints: z
.array(
z.object({
at: z.string(),
direction: z.enum(["increase", "decrease"]),
beforeMean: z.number(),
afterMean: z.number(),
delta: z.number(),
}),
)
.optional(),
};
export const MCP_TOOLS: McpToolDefinition[] = [
{
name: "list_metrics",
@@ -357,6 +454,81 @@ export const MCP_TOOLS: McpToolDefinition[] = [
return runCoachTool(ctx, "get_correlations", args);
},
},
// ── Phase 4 deep-value reads (catalogue §5 #1, #3, #4, #7) ──────────
{
name: "get_correlation",
title: "Get a correlation between two metrics",
description:
"Fetch the statistically-vetted (FDR-controlled), lag-aware association between TWO named metrics the user tracks (e.g. 'sleep' and 'resting heart rate'). Re-exports the same discovery engine the other correlation surfaces run; returns the matched pair's direction, lag, sample size, Pearson r, and a descriptive — never causal — note. Returns { present: false } when no significant pattern links the pair (sparse data or the link did not clear the engine's floors).",
inputShape: {
metricA: z.string().min(1).max(60),
metricB: z.string().min(1).max(60),
},
annotations: READ_ONLY_ANNOTATIONS,
outputShape: getCorrelationOutput,
run(ctx, args) {
return getCorrelation(ctx.userId, {
metricA: String(args.metricA ?? ""),
metricB: String(args.metricB ?? ""),
});
},
},
{
name: "compare_metric",
title: "Compare metrics or windows",
description:
"Compare one metric against another over the same trailing window, OR a single metric across two trailing windows (e.g. last 30 days vs last 90 days). Returns each side's rollup statistics (count, mean, min, max) with units and reference bands, plus a delta + percent change when both sides share a unit. Windows are trailing-to-now. Returns { present: false } when a side has no data.",
inputShape: {
metric: z.string().min(1).max(60),
metricB: z.string().min(1).max(60).optional(),
window: coachScopeWindowSchema.optional(),
windowB: coachScopeWindowSchema.optional(),
},
annotations: READ_ONLY_ANNOTATIONS,
outputShape: compareMetricOutput,
run(ctx, args) {
return compareMetric(ctx.userId, {
metric: String(args.metric ?? ""),
metricB: typeof args.metricB === "string" ? args.metricB : undefined,
window: args.window as never,
windowB: args.windowB as never,
});
},
},
{
name: "get_metric_baseline",
title: "Get a metric's personal baseline",
description:
"Fetch where the user's latest reading for ONE metric sits against their own usual range (median ± robust deviation), plus the strongest lagged driver of that metric. Re-exports the same baseline engine the metric page renders. Returns the personal band, today's value + placement (within/above/below), and the population reference band. Returns { present: false } with reason 'insufficient_history' below the 7-day learning floor — never a fabricated range.",
inputShape: {
metric: z.string().min(1).max(60),
},
annotations: READ_ONLY_ANNOTATIONS,
outputShape: getMetricBaselineOutput,
run(ctx, args) {
return getMetricBaseline(ctx.userId, {
metric: String(args.metric ?? ""),
});
},
},
{
name: "detect_changepoints",
title: "Detect level shifts in a metric",
description:
"Surface points where a metric's level shifted over a trailing window (e.g. 'when did my weight trend change?'). Runs a conservative changepoint scan over the rollup tier's bucket means and reports each shift's date, direction, and before/after means with units. High firing bar — returns { present: false } when too little data exists or no shift clears the threshold.",
inputShape: {
metric: z.string().min(1).max(60),
window: coachScopeWindowSchema.optional(),
},
annotations: READ_ONLY_ANNOTATIONS,
outputShape: detectChangepointsOutput,
run(ctx, args) {
return detectChangepoints(ctx.userId, {
metric: String(args.metric ?? ""),
window: args.window as never,
});
},
},
...searchAndFetchTools(),
];