feat(mcp): expose v1.25 clinical signals as grounded reads

Bring the new physical clinical signals onto the MCP read surface through
the existing rollup-backed reads. Grip strength, pain (0-10 NRS), waist
circumference, and waist-to-height ratio now resolve in get_metric_baseline,
compare_metric, and detect_changepoints, are discoverable through search
(present-only), and hydrate through fetch (metric:<KEY>).

These signals sit off the Coach snapshot by design, so the MCP layer owns
an explicit exposure allowlist and consumes the signal registry only for
grounding (backing measurement type, unit, population band). The PHQ-9 /
GAD-7 mental-health screeners and the environmental signals are absent from
the allowlist, so they never resolve through any read tool. The longevity
lab panel is already reachable through get_labs and the lab resource
template; no change needed there.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 17:37:40 +02:00
1 parent a27a1ee71d
commit 7cd67c8dc8
5 files changed
+318 -1

No files matched your search

+43
View File
@@ -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 ───────────────────────────────────────────────
@@ -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 () => []) },
},
}));
+74
View File
@@ -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:<KEY>", 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<string, unknown>;
// 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");
});
});
+106
View File
@@ -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<string, RichMetric>();
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<string, string> = {
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);
+93 -1
View File
@@ -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:<KEY>`
// 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),
},