Merge branch 'feat/v125-mcp-completeness' into release/v1.25.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-28 17:45:03 +02:00
commit 444da8fbb3
6 files changed
+345 -1

No files matched your search

+27
View File
@@ -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:<KEY>`
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:<analyte>`). 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
+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),
},