diff --git a/docs/api/mcp-capabilities.md b/docs/api/mcp-capabilities.md index 8c0b6dd8e..66874523d 100644 --- a/docs/api/mcp-capabilities.md +++ b/docs/api/mcp-capabilities.md @@ -95,6 +95,33 @@ Every read tool is annotated read-only / non-destructive / idempotent / closed-world — the read-only guarantee is structural (only read tools are registered for a read session), and the annotation merely advertises it. +### Clinical signals + +The v1.25 clinical-signals set — **grip strength**, **pain (0–10 NRS)**, +**waist circumference**, and **waist-to-height ratio** — is reachable +through the rollup-backed reads `get_metric_baseline`, `compare_metric`, +and `detect_changepoints` (pass the signal name, e.g. `grip strength` or +`waist-to-height ratio`), and through `search` → `fetch` (`search` +surfaces a signal only when the user has recorded it; `fetch metric:` +hydrates it). Each carries its unit and population reference band from the +single signal registry; absence is the usual `{ present: false }`. They +sit off the Coach data inventory by design, so `list_metrics` / +`get_metric_series` do not enumerate them — use the reads above. + +The **longevity lab panel** (ApoB, Lp(a), hs-CRP, HbA1c, fasting glucose, +fasting insulin, eGFR, GGT, ferritin, omega-3 index) is reached like any +other biomarker: `get_labs` (latest per analyte or, with `history:true`, a +paginated trajectory), the `healthlog://lab/{analyte}` resource template, +and `search` → `fetch` (`lab:`). Reference bounds come from the +curated biomarker catalogue. + +**Off the MCP surface by construction.** The PHQ-9 / GAD-7 mental-health +screeners (item content and totals) are excluded from AI / MCP, and the +environmental-context signals (`ENV_*`: temperature, sunshine, daylight, +precipitation, barometric pressure) are off MCP in v1. None of them +resolve through any read tool — they are not on the MCP exposure +allowlist. + ## Resources Read-only context the assistant can attach alongside tool results. Like diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts index ab61d1ff2..0c8cfc34e 100644 --- a/src/lib/mcp/__tests__/rich-reads.test.ts +++ b/src/lib/mcp/__tests__/rich-reads.test.ts @@ -80,6 +80,49 @@ describe("resolveRichMetric", () => { expect(resolveRichMetric("bananas")).toBeNull(); expect(resolveRichMetric("")).toBeNull(); }); + + // ── v1.25 clinical signals (registry-grounded, MCP allowlist) ────────── + it("resolves the v1.25 clinical signals from the registry with units + bands", () => { + const grip = resolveRichMetric("grip strength"); + expect(grip?.measurementType).toBe("GRIP_STRENGTH"); + expect(grip?.unit).toBe("kg"); + expect(grip?.band).toEqual({ low: 16, high: 60 }); + + expect(resolveRichMetric("pain")?.measurementType).toBe("PAIN_NRS"); + expect(resolveRichMetric("waist")?.measurementType).toBe( + "WAIST_CIRCUMFERENCE", + ); + + const whtr = resolveRichMetric("waist-to-height ratio"); + expect(whtr?.measurementType).toBe("WAIST_TO_HEIGHT"); + expect(whtr?.band).toEqual({ low: 0, high: 0.5 }); + + // The exact registry key resolves too. + expect(resolveRichMetric("GRIP_STRENGTH")?.measurementType).toBe( + "GRIP_STRENGTH", + ); + }); + + it("NEVER resolves the mental-health screeners or environmental signals (safety)", () => { + // PHQ-9 / GAD-7 item content + totals are excluded from AI/MCP by + // construction; the environmental signals are off MCP in v1. None of them + // are on the MCP clinical allowlist, so resolution must return null. + for (const off of [ + "PHQ9_SCORE", + "phq9 score", + "phq-9", + "GAD7_SCORE", + "gad7 score", + "gad-7", + "ENV_TEMP_MEAN", + "ENV_SUNSHINE", + "ENV_PRESSURE_MEAN", + "daily temperature", + "barometric pressure", + ]) { + expect(resolveRichMetric(off)).toBeNull(); + } + }); }); // ── 1. get_correlation ─────────────────────────────────────────────── diff --git a/src/lib/mcp/__tests__/search-fetch.test.ts b/src/lib/mcp/__tests__/search-fetch.test.ts index a576b1ff2..551f0b88f 100644 --- a/src/lib/mcp/__tests__/search-fetch.test.ts +++ b/src/lib/mcp/__tests__/search-fetch.test.ts @@ -19,6 +19,8 @@ vi.mock("@/lib/db", () => ({ prisma: { medication: { findMany: vi.fn(), findFirst: vi.fn() }, labResult: { findMany: vi.fn() }, + // v1.25 — `search` probes the clinical-signal measurement types. + measurement: { groupBy: vi.fn(async () => []) }, }, })); diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 8007fb7c4..8291d1f1e 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -19,6 +19,8 @@ vi.mock("@/lib/db", () => ({ findFirst: vi.fn(async () => null), }, labResult: { findMany: vi.fn(async () => []) }, + // v1.25 — `search` probes the clinical-signal measurement types. + measurement: { groupBy: vi.fn(async () => []) }, // v1.24 — operational reads (schedule / integration status / preventive care). user: { findUnique: vi.fn(async () => ({ timezone: "UTC" })) }, medicationIntakeEvent: { @@ -56,9 +58,18 @@ vi.mock("../rich-reads", () => ({ detectChangepoints: vi.fn(async () => ({ present: true })), getLabHistory: vi.fn(async () => ({ present: true })), LAB_HISTORY_MAX_LIMIT: 50, + // v1.25 — the clinical-signal allowlist `search` / `fetch` consume. + MCP_CLINICAL_SIGNALS: [ + { + key: "GRIP_STRENGTH", + measurementType: "GRIP_STRENGTH", + label: "Grip strength", + }, + ], })); import { MCP_TOOLS, MCP_TOOL_NAMES } from "../tools"; +import { getMetricBaseline } from "../rich-reads"; import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory"; import { prisma } from "@/lib/db"; @@ -567,3 +578,66 @@ describe("search — cursor pagination", () => { expect(page2.nextCursor).toBeUndefined(); }); }); + +describe("v1.25 clinical signals on the MCP surface", () => { + beforeEach(() => { + vi.mocked(buildCoachDataInventory).mockResolvedValue({ + entries: [], + restMode: false, + cycleEnabled: false, + window: "last30days", + probeScope: { sources: [] }, + } as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); + }); + + it("search surfaces a present clinical signal as metric:", async () => { + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ + { type: "GRIP_STRENGTH" }, + ] as never); + const result = (await tool("search").run(CTX, { query: "grip" })) as { + results: Array<{ id: string; title: string; url: string }>; + }; + const hit = result.results.find((r) => r.id === "metric:GRIP_STRENGTH"); + expect(hit).toBeDefined(); + expect(hit?.title).toBe("Grip strength"); + expect(hit?.url).toContain("/insights?metric=GRIP_STRENGTH"); + }); + + it("search omits a clinical signal with no recorded data", async () => { + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); + const result = (await tool("search").run(CTX, { query: "grip" })) as { + results: Array<{ id: string }>; + }; + expect(result.results.some((r) => r.id.startsWith("metric:"))).toBe(false); + }); + + it("fetch hydrates a clinical signal via the baseline read (not the Coach path)", async () => { + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); + vi.mocked(getMetricBaseline).mockResolvedValue({ + present: true, + metric: "Grip strength", + unit: "kg", + latest: 34, + placement: "within", + baseline: { low: 30, high: 38, sampleDays: 21 }, + referenceBand: { low: 16, high: 60 }, + } as never); + + const result = (await tool("fetch").run(CTX, { + id: "metric:GRIP_STRENGTH", + })) as Record; + + // Resolved through the rollup-backed baseline read, never the Coach executor. + expect(getMetricBaseline).toHaveBeenCalledWith("user-1", { + metric: "GRIP_STRENGTH", + }); + expect(executeCoachTool).not.toHaveBeenCalled(); + expect(result.title).toBe("Grip strength"); + // Plain-text prose, grounded with the value + band; never a JSON blob. + expect(result.text as string).not.toContain("{"); + expect(result.text as string).toContain("34"); + expect(result.text as string).toContain("16–60"); + }); +}); diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index ddb60504f..4f06697a7 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -43,6 +43,7 @@ import { getMetricStatusMeta, METRIC_STATUS_IDS, } from "@/lib/insights/metric-status-registry"; +import { getSignal } from "@/lib/signals/registry"; import { classifyReferenceRange } from "@/lib/labs/reference-range"; import { resolveLabFields } from "@/lib/labs/serialise"; import { sanitizeValueText } from "@/lib/ai/coach/labs-snapshot"; @@ -177,6 +178,104 @@ function fromRegistry(id: string): RichMetric | null { }; } +// ── v1.25 clinical signals (registry-grounded, MCP-owned exposure) ──── +// +// The v1.25 clinical-signals wave added a set of physical / clinical +// measurements to the signal registry. They sit OFF the Coach snapshot by +// design (`coachSnapshot:false ⇒ surfaces.mcp:false`), so the Coach-driven +// reads (`get_metric_series`, the data inventory) never surface them. The MCP +// layer exposes them here through the rollup-backed rich reads — `compare_metric`, +// `get_metric_baseline`, `detect_changepoints` — and through `search` / `fetch`. +// +// Exposure is an EXPLICIT allowlist owned by this module, not the registry's own +// `surfaces.mcp` flag: the registry keeps these off the Coach surface, while the +// MCP read surface opts them in one signal at a time. The registry remains the +// single source of truth for grounding (the backing `MeasurementType`, unit, and +// population band). Deliberately ABSENT and therefore unreachable over MCP: the +// PHQ-9 / GAD-7 mental-health screeners and every environmental (`ENV_*`) signal +// — they never reach AI / MCP by construction. +export const MCP_CLINICAL_SIGNAL_KEYS = [ + "GRIP_STRENGTH", + "PAIN_NRS", + "WAIST_CIRCUMFERENCE", + "WAIST_TO_HEIGHT", +] as const; + +/** Build a `RichMetric` from a registry signal key (measurement-kind only). */ +function richMetricFromSignal(key: string): RichMetric | null { + const sig = getSignal(key); + if (!sig || sig.kind !== "measurement") return null; + return { + measurementType: sig.source.measurementType, + label: sig.displayName, + unit: sig.unit, + band: sig.normalRange + ? { low: sig.normalRange.low, high: sig.normalRange.high } + : null, + }; +} + +/** Resolved clinical signals, keyed by their registry key (built once). */ +const CLINICAL_SIGNAL_BY_KEY = new Map(); +for (const key of MCP_CLINICAL_SIGNAL_KEYS) { + const metric = richMetricFromSignal(key); + if (metric) CLINICAL_SIGNAL_BY_KEY.set(key, metric); +} + +/** + * The clinical signals the MCP surface exposes, for the `search` discovery + * probe: the registry key (the `metric:` id `fetch` resolves), the backing + * `MeasurementType` (the presence probe), and the display label. + */ +export const MCP_CLINICAL_SIGNALS: ReadonlyArray<{ + key: string; + measurementType: MeasurementType; + label: string; +}> = [...CLINICAL_SIGNAL_BY_KEY.entries()].map(([key, m]) => ({ + key, + measurementType: m.measurementType, + label: m.label, +})); + +/** Friendly aliases (NL phrasings) → clinical signal key. */ +const CLINICAL_ALIASES: Record = { + grip: "GRIP_STRENGTH", + grip_strength: "GRIP_STRENGTH", + hand_grip: "GRIP_STRENGTH", + hand_grip_strength: "GRIP_STRENGTH", + pain: "PAIN_NRS", + pain_nrs: "PAIN_NRS", + pain_score: "PAIN_NRS", + waist: "WAIST_CIRCUMFERENCE", + waist_circumference: "WAIST_CIRCUMFERENCE", + whtr: "WAIST_TO_HEIGHT", + waist_to_height: "WAIST_TO_HEIGHT", + waist_to_height_ratio: "WAIST_TO_HEIGHT", + waist_height_ratio: "WAIST_TO_HEIGHT", +}; + +/** + * Resolve a free-text name to a clinical signal the MCP layer exposes, or + * `null`. Closed by construction to the `MCP_CLINICAL_SIGNAL_KEYS` allowlist, so + * a screener key (PHQ9_SCORE / GAD7_SCORE) or an `ENV_*` key can never resolve + * here even though they exist in the registry. + */ +function resolveClinicalSignal(key: string): RichMetric | null { + const aliased = CLINICAL_ALIASES[key]; + if (aliased) return CLINICAL_SIGNAL_BY_KEY.get(aliased) ?? null; + const upper = key.toUpperCase(); + if (CLINICAL_SIGNAL_BY_KEY.has(upper)) { + return CLINICAL_SIGNAL_BY_KEY.get(upper) ?? null; + } + // Display-name match over the allowlist (e.g. "waist-to-height ratio"). + for (const [, metric] of CLINICAL_SIGNAL_BY_KEY) { + if (metric.label.toLowerCase().replace(/[\s-]+/g, "_") === key) { + return metric; + } + } + return 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 @@ -192,6 +291,13 @@ export function resolveRichMetric(input: string): RichMetric | null { // 1. explicit supplement (weight / pulse / bmi). if (SUPPLEMENT[key]) return SUPPLEMENT[key]; + // 1b. v1.25 clinical signals the MCP layer opts in (grip strength, pain NRS, + // waist circumference, waist-to-height). Registry-grounded; the allowlist is + // closed, so a mental-health screener or an environmental signal never + // resolves here. + const clinical = resolveClinicalSignal(key); + if (clinical) return clinical; + // 2. friendly alias → registry id. const aliased = ALIASES[key]; if (aliased) return fromRegistry(aliased); diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 8dc4a4e78..6c6c16dac 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -35,6 +35,7 @@ import { detectChangepoints, getLabHistory, LAB_HISTORY_MAX_LIMIT, + MCP_CLINICAL_SIGNALS, type DateRange, } from "@/lib/mcp/rich-reads"; import { encodeOffsetCursor, decodeOffsetCursor } from "@/lib/mcp/pagination"; @@ -188,6 +189,55 @@ function plainLabText(rid: string, result: unknown): string { ); } +/** + * Render a `fetch` result for a v1.25 clinical signal (grip strength, pain NRS, + * waist circumference, waist-to-height) as a plain-text citation sentence. These + * sit off the Coach snapshot, so they hydrate through the rollup-backed baseline + * read (`get_metric_baseline`) rather than the Coach `get_metric_series` path: + * the latest reading, where it sits against the user's own usual range, and the + * population reference band. Quotes only values the result carried; never + * fabricates a figure or asserts a clinical verdict. + */ +function plainClinicalText( + label: string, + result: { + present?: boolean; + reason?: string; + unit?: string; + latest?: number; + placement?: "within" | "above" | "below"; + baseline?: { low: number; high: number }; + referenceBand?: { low: number; high: number } | null; + }, +): string { + const unit = result.unit ? ` ${result.unit}` : ""; + const band = result.referenceBand + ? ` Reference band ${result.referenceBand.low}–${result.referenceBand.high}${unit}.` + : ""; + if (!result.present) { + if (result.reason === "insufficient_history") { + return `${label}: still learning your usual range — too little history yet.${band}`; + } + return `No recent ${label.toLowerCase()} data is on file.`; + } + const parts: string[] = []; + if (typeof result.latest === "number") { + parts.push(`latest ${round1(result.latest)}${unit}`); + } + if (result.placement && result.baseline) { + parts.push( + `${result.placement} your usual ${round1(result.baseline.low)}–${round1( + result.baseline.high, + )}${unit}`, + ); + } + const head = + parts.length > 0 + ? `${label}: ${parts.join(", ")}.` + : `${label}: a reading is on file.`; + return `${head}${band}`.trim(); +} + /** * The `search` + `fetch` pair — the de-facto two-tool retrieval convention and, * critically, the ONLY tools ChatGPT can call in its default (non-Developer) @@ -288,6 +338,30 @@ function searchAndFetchTools(): McpToolDefinition[] { }); } + // v1.25 clinical signals (grip strength, pain NRS, waist / WHtR) — off + // the Coach data inventory by design, so they are surfaced here directly. + // One grouped presence probe over their backing measurement types; + // present-only, in a stable (allowlist) order. `fetch metric:` + // hydrates each via the rollup-backed baseline read. + const clinicalPresent = await prisma.measurement.groupBy({ + by: ["type"], + where: { + userId: ctx.userId, + type: { in: MCP_CLINICAL_SIGNALS.map((s) => s.measurementType) }, + }, + }); + const presentTypes = new Set(clinicalPresent.map((r) => r.type)); + for (const sig of MCP_CLINICAL_SIGNALS) { + if (!presentTypes.has(sig.measurementType)) continue; + const hay = `${sig.label} ${sig.key}`.toLowerCase(); + if (query && !hay.includes(query)) continue; + results.push({ + id: `metric:${sig.key}`, + title: sig.label, + url: `${origin}/insights?metric=${encodeURIComponent(sig.key)}`, + }); + } + // Cursor pagination over the assembled result set (was a silent // slice(0,50)). The set is rebuilt deterministically each call (stable // ordering: metrics → medications → labs), so an opaque offset cursor @@ -334,6 +408,24 @@ function searchAndFetchTools(): McpToolDefinition[] { }); if (kind === "metric" && rid) { + // v1.25 clinical signals sit off the Coach snapshot, so hydrate them + // through the rollup-backed baseline read rather than the Coach + // `get_metric_series` path (which would report no data for them). + const clinical = MCP_CLINICAL_SIGNALS.find( + (s) => s.key.toLowerCase() === rid.toLowerCase(), + ); + if (clinical) { + const baseline = await getMetricBaseline(ctx.userId, { + metric: clinical.key, + }); + return { + id, + title: clinical.label, + text: plainClinicalText(clinical.label, baseline), + url: `${origin}/insights?metric=${encodeURIComponent(clinical.key)}`, + metadata: { type: "metric", metric: clinical.key }, + }; + } const result = await executeCoachTool({ userId: ctx.userId, name: "get_metric_series", @@ -823,7 +915,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [ 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.", + "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. Also resolves the clinical-signal metrics (grip strength, pain 0–10 NRS, waist circumference, waist-to-height ratio). 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), },