From cfffb693c3b5af618307e36f606901a035561b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 15:34:11 +0200 Subject: [PATCH] feat(mcp): add deep-value reads and a doctor-visit prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/mcp/__tests__/route.test.ts | 5 + src/lib/mcp/__tests__/prompts.test.ts | 188 +++++++ src/lib/mcp/__tests__/rich-reads.test.ts | 366 +++++++++++++ src/lib/mcp/__tests__/tools.test.ts | 16 +- src/lib/mcp/index.ts | 15 + src/lib/mcp/prompts.ts | 262 +++++++++ src/lib/mcp/rich-reads.ts | 671 +++++++++++++++++++++++ src/lib/mcp/server.ts | 24 + src/lib/mcp/tools.ts | 172 ++++++ 9 files changed, 1718 insertions(+), 1 deletion(-) create mode 100644 src/lib/mcp/__tests__/prompts.test.ts create mode 100644 src/lib/mcp/__tests__/rich-reads.test.ts create mode 100644 src/lib/mcp/prompts.ts create mode 100644 src/lib/mcp/rich-reads.ts diff --git a/src/app/mcp/__tests__/route.test.ts b/src/app/mcp/__tests__/route.test.ts index e9115ba4b..52784e2f9 100644 --- a/src/app/mcp/__tests__/route.test.ts +++ b/src/app/mcp/__tests__/route.test.ts @@ -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. */ diff --git a/src/lib/mcp/__tests__/prompts.test.ts b/src/lib/mcp/__tests__/prompts.test.ts new file mode 100644 index 000000000..92a6b1f74 --- /dev/null +++ b/src/lib/mcp/__tests__/prompts.test.ts @@ -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 { + 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 = {}, +): Record { + 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>; + 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>; + 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>; + 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; + expect(patient).not.toHaveProperty("fullName"); + expect(patient).not.toHaveProperty("username"); + expect(patient.dateOfBirth).toBe("1990-01-01"); + }); +}); diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts new file mode 100644 index 000000000..077449238 --- /dev/null +++ b/src/lib/mcp/__tests__/rich-reads.test.ts @@ -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(); + 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"); + } + }); +}); diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 468152565..74e276571 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -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(), ); }); diff --git a/src/lib/mcp/index.ts b/src/lib/mcp/index.ts index 82fc91438..da135b81b 100644 --- a/src/lib/mcp/index.ts +++ b/src/lib/mcp/index.ts @@ -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, diff --git a/src/lib/mcp/prompts.ts b/src/lib/mcp/prompts.ts new file mode 100644 index 000000000..f3a403c1a --- /dev/null +++ b/src/lib/mcp/prompts.ts @@ -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 = { + 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, + ) => Promise; +} + +/** + * 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 = { + 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 { + 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, +); diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts new file mode 100644 index 000000000..0da7d7c66 --- /dev/null +++ b/src/lib/mcp/rich-reads.ts @@ -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 = { + 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 = { + 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 = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; +} diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts index f40ec07bc..92e8b6bf8 100644 --- a/src/lib/mcp/server.ts +++ b/src/lib/mcp/server.ts @@ -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) => { + // 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, diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 894dacba0..1bd741a3a 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -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(), ];