mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(mcp): surface metric-status-only rich metrics for discovery
Wrist temperature, cardio recovery, sleep score, breathing disturbances, strain/load, and the rest of the v1.10/v1.18.1 metric-status-registry ids were already resolvable through compare_metric / get_metric_baseline / detect_changepoints (an exact registry-id or display-name match), but sat off the Coach snapshot inventory entirely — so list_metrics, the measurements-inventory resource, and search never advertised them. A discover-before-fetch assistant, which the server's own instructions direct, could never learn they existed. Adds a fixed, reviewed allowlist (MCP_METRIC_STATUS_DISCOVERY) plus a metricStatusDiscoveryRows() presence probe, mirroring the existing MCP_CLINICAL_SIGNALS pattern: one grouped presence query, appended as supplement rows to list_metrics and the inventory resource, and merged into the same probe search already runs for the v1.25 clinical signals. fetch(id) now hydrates these ids via the baseline read the same way it already does for clinical signals.
This commit is contained in:
1 parent
fdc8ed81a0
commit
a70c98d2b4
7 files changed
+360
-29
No files matched your search
@@ -93,6 +93,10 @@ const READ_TOOLS = [
|
||||
"get_medication_schedule",
|
||||
"get_integration_status",
|
||||
"get_preventive_care",
|
||||
// v1.30 coverage review — nutrients, intraday pulse, ECG metadata.
|
||||
"get_nutrients",
|
||||
"get_intraday_pulse",
|
||||
"get_ecg_recordings",
|
||||
].sort();
|
||||
|
||||
/** A valid, narrow-scope (`health:read`) token resolution. */
|
||||
|
||||
@@ -10,10 +10,20 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/doctor-report-data", () => ({
|
||||
collectDoctorReportData: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/tools/inventory", () => ({
|
||||
buildCoachDataInventory: vi.fn(),
|
||||
}));
|
||||
// v1.30 (G5/C4) — `measurements-inventory` appends the metric-status-only
|
||||
// discovery rows; stub the engine so this file never reaches a real DB read.
|
||||
vi.mock("@/lib/mcp/rich-reads", () => ({
|
||||
metricStatusDiscoveryRows: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
import { MCP_RESOURCES, MCP_RESOURCE_URIS } from "../resources";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory";
|
||||
import { metricStatusDiscoveryRows } from "@/lib/mcp/rich-reads";
|
||||
import type { McpAuthContext } from "../auth";
|
||||
|
||||
const CTX: McpAuthContext = {
|
||||
@@ -139,6 +149,45 @@ describe("healthlog://medications", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("healthlog://measurements/inventory", () => {
|
||||
it("merges the Coach inventory with the metric-status-only discovery rows (G5/C4)", async () => {
|
||||
vi.mocked(buildCoachDataInventory).mockResolvedValue({
|
||||
entries: [
|
||||
{
|
||||
tool: "get_metric_series",
|
||||
metric: "weight",
|
||||
domain: "weight",
|
||||
present: true,
|
||||
count: 5,
|
||||
},
|
||||
],
|
||||
restMode: false,
|
||||
cycleEnabled: false,
|
||||
window: "last30days",
|
||||
probeScope: { sources: [] },
|
||||
} as never);
|
||||
vi.mocked(metricStatusDiscoveryRows).mockResolvedValue([
|
||||
{
|
||||
tool: "compare_metric",
|
||||
domain: "Wrist temperature",
|
||||
present: false,
|
||||
metric: "WRIST_TEMPERATURE",
|
||||
},
|
||||
] as never);
|
||||
|
||||
const result = (await resource("healthlog://measurements/inventory").read(
|
||||
CTX,
|
||||
)) as { present: boolean; metrics: Array<Record<string, unknown>> };
|
||||
|
||||
expect(metricStatusDiscoveryRows).toHaveBeenCalledWith("user-1");
|
||||
expect(result.present).toBe(true);
|
||||
expect(result.metrics).toHaveLength(2);
|
||||
expect(
|
||||
result.metrics.find((m) => m.metric === "WRIST_TEMPERATURE"),
|
||||
).toMatchObject({ present: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("healthlog://report/doctor-visit", () => {
|
||||
// The resource now reuses the SHARED summariser (doctor-visit-summary.ts), so
|
||||
// it grounds the identical rich superset the doctor_visit_summary prompt does
|
||||
|
||||
@@ -17,10 +17,9 @@ vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => {
|
||||
});
|
||||
const { labResult, measurement } = vi.hoisted(() => ({
|
||||
labResult: { findMany: vi.fn() },
|
||||
// v1.30.4 (G4) — `withHrvFallback`'s presence probe. Unused by every
|
||||
// metric that doesn't carry a `fallbackMeasurementType` (everything but
|
||||
// HRV today); those tests never touch this mock.
|
||||
measurement: { count: vi.fn() },
|
||||
// `withHrvFallback`'s presence probe (`count`) + the discovery presence
|
||||
// query (`groupBy`). Unused by metrics that don't exercise them.
|
||||
measurement: { count: vi.fn(), groupBy: vi.fn(async () => []) },
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement } }));
|
||||
|
||||
@@ -31,6 +30,8 @@ import {
|
||||
detectChangepoints,
|
||||
getLabHistory,
|
||||
resolveRichMetric,
|
||||
MCP_METRIC_STATUS_DISCOVERY,
|
||||
metricStatusDiscoveryRows,
|
||||
} from "../rich-reads";
|
||||
import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read";
|
||||
import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read";
|
||||
@@ -602,3 +603,66 @@ describe("get_lab_history", () => {
|
||||
expect(res.reason).toBe("analyte_not_found");
|
||||
});
|
||||
});
|
||||
|
||||
// ── v1.30 coverage review (G5/C4) — metric-status-only discovery ────────
|
||||
describe("MCP_METRIC_STATUS_DISCOVERY", () => {
|
||||
it("carries the reviewed metric-status-only allowlist with resolved units/labels", () => {
|
||||
const keys = MCP_METRIC_STATUS_DISCOVERY.map((s) => s.key).sort();
|
||||
expect(keys).toEqual(
|
||||
[
|
||||
"WRIST_TEMPERATURE",
|
||||
"CARDIO_RECOVERY",
|
||||
"SLEEP_SCORE",
|
||||
"BREATHING_DISTURBANCES",
|
||||
"ANS_CHARGE",
|
||||
"DAY_STRAIN",
|
||||
"WORKOUT_STRAIN",
|
||||
"CARDIO_LOAD",
|
||||
"FALL_COUNT",
|
||||
"SIX_MINUTE_WALK_DISTANCE",
|
||||
"STAIR_ASCENT_SPEED",
|
||||
"STAIR_DESCENT_SPEED",
|
||||
"ENERGY_EXPENDITURE_KJ",
|
||||
].sort(),
|
||||
);
|
||||
const wristTemp = MCP_METRIC_STATUS_DISCOVERY.find(
|
||||
(s) => s.key === "WRIST_TEMPERATURE",
|
||||
);
|
||||
expect(wristTemp?.measurementType).toBe("WRIST_TEMPERATURE");
|
||||
expect(wristTemp?.label).toBe("Wrist temperature");
|
||||
});
|
||||
|
||||
it("every discovery id is ALSO resolvable via resolveRichMetric (compare_metric/baseline stay reachable)", () => {
|
||||
for (const sig of MCP_METRIC_STATUS_DISCOVERY) {
|
||||
expect(resolveRichMetric(sig.key)?.measurementType).toBe(
|
||||
sig.measurementType,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("metricStatusDiscoveryRows", () => {
|
||||
it("reports present:true + count for a logged id, present:false for the rest", async () => {
|
||||
measurement.groupBy.mockResolvedValue([
|
||||
{ type: "WRIST_TEMPERATURE", _count: { _all: 7 } },
|
||||
] as never);
|
||||
const rows = await metricStatusDiscoveryRows(USER);
|
||||
expect(rows).toHaveLength(MCP_METRIC_STATUS_DISCOVERY.length);
|
||||
const wristTemp = rows.find((r) => r.metric === "WRIST_TEMPERATURE");
|
||||
expect(wristTemp).toMatchObject({
|
||||
tool: "compare_metric",
|
||||
domain: "Wrist temperature",
|
||||
present: true,
|
||||
count: 7,
|
||||
});
|
||||
const untouched = rows.find((r) => r.metric === "DAY_STRAIN");
|
||||
expect(untouched).toMatchObject({ present: false });
|
||||
expect(untouched).not.toHaveProperty("count");
|
||||
});
|
||||
|
||||
it("reports present:false for every id when nothing is logged", async () => {
|
||||
measurement.groupBy.mockResolvedValue([]);
|
||||
const rows = await metricStatusDiscoveryRows(USER);
|
||||
expect(rows.every((r) => r.present === false)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -115,10 +115,19 @@ vi.mock("../rich-reads", () => ({
|
||||
label: "Grip strength",
|
||||
},
|
||||
],
|
||||
// v1.30 (G5/C4) — the metric-status-only discovery allowlist.
|
||||
MCP_METRIC_STATUS_DISCOVERY: [
|
||||
{
|
||||
key: "WRIST_TEMPERATURE",
|
||||
measurementType: "WRIST_TEMPERATURE",
|
||||
label: "Wrist skin temperature",
|
||||
},
|
||||
],
|
||||
metricStatusDiscoveryRows: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
import { MCP_TOOLS, MCP_TOOL_NAMES } from "../tools";
|
||||
import { getMetricBaseline } from "../rich-reads";
|
||||
import { getMetricBaseline, metricStatusDiscoveryRows } from "../rich-reads";
|
||||
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
|
||||
import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -267,6 +276,42 @@ describe("list_metrics", () => {
|
||||
expect(result.window).toBe("last30days");
|
||||
expect(result.metrics).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("appends the v1.30 metric-status-only discovery rows (G5/C4) alongside the Coach inventory", async () => {
|
||||
vi.mocked(buildCoachDataInventory).mockResolvedValue({
|
||||
entries: [
|
||||
{
|
||||
tool: "get_metric_series",
|
||||
metric: "weight",
|
||||
domain: "weight",
|
||||
present: true,
|
||||
count: 5,
|
||||
},
|
||||
],
|
||||
restMode: false,
|
||||
cycleEnabled: false,
|
||||
window: "last30days",
|
||||
probeScope: { sources: [], window: "last30days" },
|
||||
} as never);
|
||||
vi.mocked(metricStatusDiscoveryRows).mockResolvedValue([
|
||||
{
|
||||
tool: "compare_metric",
|
||||
domain: "Wrist skin temperature",
|
||||
present: true,
|
||||
count: 3,
|
||||
metric: "WRIST_TEMPERATURE",
|
||||
},
|
||||
] as never);
|
||||
|
||||
const result = (await tool("list_metrics").run(CTX, {})) as {
|
||||
metrics: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(metricStatusDiscoveryRows).toHaveBeenCalledWith("user-1");
|
||||
expect(result.metrics).toHaveLength(2);
|
||||
expect(
|
||||
result.metrics.find((m) => m.metric === "WRIST_TEMPERATURE"),
|
||||
).toMatchObject({ tool: "compare_metric", present: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("get_metric_series", () => {
|
||||
@@ -701,6 +746,49 @@ describe("v1.25 clinical signals on the MCP surface", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("metric-status-only discovery on search/fetch (v1.30 coverage review G5/C4)", () => {
|
||||
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 metric-status-only id (undiscoverable before this wave)", async () => {
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
|
||||
{ type: "WRIST_TEMPERATURE" },
|
||||
] as never);
|
||||
const result = (await tool("search").run(CTX, { query: "wrist" })) as {
|
||||
results: Array<{ id: string; title: string }>;
|
||||
};
|
||||
const hit = result.results.find((r) => r.id === "metric:WRIST_TEMPERATURE");
|
||||
expect(hit).toBeDefined();
|
||||
});
|
||||
|
||||
it("fetch hydrates a metric-status-only id via the baseline read", async () => {
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
|
||||
vi.mocked(getMetricBaseline).mockResolvedValue({
|
||||
present: true,
|
||||
metric: "Wrist skin temperature",
|
||||
unit: "°C",
|
||||
latest: 33.2,
|
||||
} as never);
|
||||
const result = (await tool("fetch").run(CTX, {
|
||||
id: "metric:WRIST_TEMPERATURE",
|
||||
})) as Record<string, unknown>;
|
||||
expect(getMetricBaseline).toHaveBeenCalledWith("user-1", {
|
||||
metric: "WRIST_TEMPERATURE",
|
||||
});
|
||||
expect(executeCoachTool).not.toHaveBeenCalled();
|
||||
expect(result.title).toBe("Wrist skin temperature");
|
||||
});
|
||||
});
|
||||
|
||||
describe("get_nutrients — v1.30 coverage review (G1)", () => {
|
||||
it("forwards the optional nutrient + days args to the engine and returns its result verbatim", async () => {
|
||||
vi.mocked(getNutrients).mockResolvedValue({
|
||||
|
||||
@@ -37,6 +37,7 @@ import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { NUTRIENT_CODES, type NutrientCode } from "@/lib/nutrients/catalog";
|
||||
import { getNutrients, NUTRIENT_LABELS } from "@/lib/mcp/nutrients-read";
|
||||
import { metricStatusDiscoveryRows } from "@/lib/mcp/rich-reads";
|
||||
import { summariseForVisit } from "./doctor-visit-summary";
|
||||
import type { McpAuthContext } from "./auth";
|
||||
|
||||
@@ -241,7 +242,12 @@ export const MCP_RESOURCES: McpResourceDefinition[] = [
|
||||
"What health data the user has and which tool retrieves each domain — one row per domain with presence + approximate sample count. The same manifest list_metrics returns; read this first to discover what is available before fetching figures.",
|
||||
mimeType: "application/json",
|
||||
async read(ctx) {
|
||||
const inventory = await buildCoachDataInventory(ctx.userId, undefined);
|
||||
const [inventory, statusDiscoveryRows] = await Promise.all([
|
||||
buildCoachDataInventory(ctx.userId, undefined),
|
||||
// v1.30 coverage review (G5/C4) — mirrors the same supplement rows
|
||||
// `list_metrics` appends, so the two discovery surfaces stay in sync.
|
||||
metricStatusDiscoveryRows(ctx.userId),
|
||||
]);
|
||||
annotate({
|
||||
action: { name: "mcp.resource.read" },
|
||||
meta: { resource: "measurements-inventory", present: true },
|
||||
@@ -251,7 +257,7 @@ export const MCP_RESOURCES: McpResourceDefinition[] = [
|
||||
window: inventory.window,
|
||||
restMode: inventory.restMode,
|
||||
cycleEnabled: inventory.cycleEnabled,
|
||||
metrics: inventory.entries,
|
||||
metrics: [...inventory.entries, ...statusDiscoveryRows],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
import {
|
||||
getMetricStatusMeta,
|
||||
METRIC_STATUS_IDS,
|
||||
type MetricStatusMetricId,
|
||||
} from "@/lib/insights/metric-status-registry";
|
||||
import { allSignals, getSignal } from "@/lib/signals/registry";
|
||||
import { classifyReferenceRange } from "@/lib/labs/reference-range";
|
||||
@@ -367,6 +368,103 @@ function resolveClinicalSignal(key: string): RichMetric | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── v1.30 coverage review (G5/C4) — metric-status-only discovery ─────
|
||||
//
|
||||
// `resolveRichMetric` already resolves any `metric-status-registry` id via an
|
||||
// exact-id or display-name match (steps 3–4 above), so `compare_metric` /
|
||||
// `get_metric_baseline` / `detect_changepoints` CAN serve these metrics today.
|
||||
// But none of them is in the Coach data inventory (`list_metrics`, the
|
||||
// `measurements-inventory` resource) or the `search` probe, so a
|
||||
// discover-before-fetch assistant — which the server's own instructions
|
||||
// direct — never learns they exist. The v1.28.52 dashboard-tile wave
|
||||
// surfaced several of these ("collected but unsurfaced") in the app; the MCP
|
||||
// wire still had that gap.
|
||||
//
|
||||
// Deliberately a fixed, reviewed allowlist (not derived from `surfaces.mcp` —
|
||||
// reconciling that flag against this resolution path is a separate follow-up)
|
||||
// so no metric becomes discoverable here without a human having named it.
|
||||
|
||||
const METRIC_STATUS_DISCOVERY_IDS = [
|
||||
"WRIST_TEMPERATURE",
|
||||
"CARDIO_RECOVERY",
|
||||
"SLEEP_SCORE",
|
||||
"BREATHING_DISTURBANCES",
|
||||
"ANS_CHARGE",
|
||||
"DAY_STRAIN",
|
||||
"WORKOUT_STRAIN",
|
||||
"CARDIO_LOAD",
|
||||
"FALL_COUNT",
|
||||
"SIX_MINUTE_WALK_DISTANCE",
|
||||
"STAIR_ASCENT_SPEED",
|
||||
"STAIR_DESCENT_SPEED",
|
||||
"ENERGY_EXPENDITURE_KJ",
|
||||
] as const satisfies readonly MetricStatusMetricId[];
|
||||
|
||||
/**
|
||||
* The metric-status-only ids `list_metrics` / the inventory resource /
|
||||
* `search` can now discover: the registry id (also the `metric:` id `fetch`
|
||||
* resolves), the backing `MeasurementType` (the presence probe), and the
|
||||
* display label. Mirrors the shape of `MCP_CLINICAL_SIGNALS`.
|
||||
*/
|
||||
export const MCP_METRIC_STATUS_DISCOVERY: ReadonlyArray<{
|
||||
key: MetricStatusMetricId;
|
||||
measurementType: MeasurementType;
|
||||
label: string;
|
||||
}> = METRIC_STATUS_DISCOVERY_IDS.map((id) => {
|
||||
const meta = getMetricStatusMeta(id);
|
||||
if (!meta) {
|
||||
// Would only trip if the registry ever dropped one of these ids — fail
|
||||
// loudly at module load rather than silently under-advertising.
|
||||
throw new Error(`metric-status registry is missing discovery id ${id}`);
|
||||
}
|
||||
return {
|
||||
key: id,
|
||||
measurementType: meta.measurementType,
|
||||
label: meta.displayName,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Presence + approximate sample count for the metric-status-only discovery
|
||||
* set, in the `list_metrics` inventory-row shape — `tool` fixed to
|
||||
* `compare_metric` (a resolver-closed metric with no Coach-snapshot series
|
||||
* fetches through `compare_metric` / `get_metric_baseline`, never
|
||||
* `get_metric_series`). One grouped presence query, mirroring the
|
||||
* `MCP_CLINICAL_SIGNALS` `search` probe. `list_metrics` and the
|
||||
* `measurements-inventory` resource both append these rows to their own
|
||||
* inventory so a discover-before-fetch assistant can find a metric that IS
|
||||
* resolvable but sits off the Coach snapshot.
|
||||
*/
|
||||
export async function metricStatusDiscoveryRows(userId: string): Promise<
|
||||
Array<{
|
||||
tool: string;
|
||||
domain: string;
|
||||
present: boolean;
|
||||
count?: number;
|
||||
metric: string;
|
||||
}>
|
||||
> {
|
||||
const rows = await prisma.measurement.groupBy({
|
||||
by: ["type"],
|
||||
where: {
|
||||
userId,
|
||||
type: { in: MCP_METRIC_STATUS_DISCOVERY.map((s) => s.measurementType) },
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
const countByType = new Map(rows.map((r) => [r.type, r._count._all]));
|
||||
return MCP_METRIC_STATUS_DISCOVERY.map((sig) => {
|
||||
const count = countByType.get(sig.measurementType);
|
||||
return {
|
||||
tool: "compare_metric",
|
||||
domain: sig.label,
|
||||
present: count !== undefined,
|
||||
...(count !== undefined ? { count } : {}),
|
||||
metric: sig.key,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
+44
-22
@@ -38,6 +38,8 @@ import {
|
||||
getLabHistory,
|
||||
LAB_HISTORY_MAX_LIMIT,
|
||||
MCP_CLINICAL_SIGNALS,
|
||||
MCP_METRIC_STATUS_DISCOVERY,
|
||||
metricStatusDiscoveryRows,
|
||||
type DateRange,
|
||||
} from "@/lib/mcp/rich-reads";
|
||||
import {
|
||||
@@ -384,20 +386,27 @@ 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({
|
||||
// v1.25 clinical signals (grip strength, pain NRS, waist / WHtR) PLUS
|
||||
// the v1.30 coverage-review metric-status-only set (wrist
|
||||
// temperature, cardio recovery, sleep score, breathing disturbances,
|
||||
// strain/load, …) — both sit off the Coach data inventory by design,
|
||||
// so they are surfaced here directly. One grouped presence probe over
|
||||
// the combined backing measurement types; present-only, in a stable
|
||||
// (allowlist) order. `fetch metric:<KEY>` hydrates each via the
|
||||
// rollup-backed baseline read.
|
||||
const discoverableSignals = [
|
||||
...MCP_CLINICAL_SIGNALS,
|
||||
...MCP_METRIC_STATUS_DISCOVERY,
|
||||
];
|
||||
const discoverablePresent = await prisma.measurement.groupBy({
|
||||
by: ["type"],
|
||||
where: {
|
||||
userId: ctx.userId,
|
||||
type: { in: MCP_CLINICAL_SIGNALS.map((s) => s.measurementType) },
|
||||
type: { in: discoverableSignals.map((s) => s.measurementType) },
|
||||
},
|
||||
});
|
||||
const presentTypes = new Set(clinicalPresent.map((r) => r.type));
|
||||
for (const sig of MCP_CLINICAL_SIGNALS) {
|
||||
const presentTypes = new Set(discoverablePresent.map((r) => r.type));
|
||||
for (const sig of discoverableSignals) {
|
||||
if (!presentTypes.has(sig.measurementType)) continue;
|
||||
const hay = `${sig.label} ${sig.key}`.toLowerCase();
|
||||
if (query && !hay.includes(query)) continue;
|
||||
@@ -483,22 +492,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
|
||||
// v1.25 clinical signals AND the v1.30 metric-status-only discovery
|
||||
// set both 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 discoverable = [
|
||||
...MCP_CLINICAL_SIGNALS,
|
||||
...MCP_METRIC_STATUS_DISCOVERY,
|
||||
].find((s) => s.key.toLowerCase() === rid.toLowerCase());
|
||||
if (discoverable) {
|
||||
const baseline = await getMetricBaseline(ctx.userId, {
|
||||
metric: clinical.key,
|
||||
metric: discoverable.key,
|
||||
});
|
||||
return {
|
||||
id,
|
||||
title: clinical.label,
|
||||
text: plainClinicalText(clinical.label, baseline),
|
||||
url: `${origin}/insights?metric=${encodeURIComponent(clinical.key)}`,
|
||||
metadata: { type: "metric", metric: clinical.key },
|
||||
title: discoverable.label,
|
||||
text: plainClinicalText(discoverable.label, baseline),
|
||||
url: `${origin}/insights?metric=${encodeURIComponent(discoverable.key)}`,
|
||||
metadata: { type: "metric", metric: discoverable.key },
|
||||
};
|
||||
}
|
||||
const result = await executeCoachTool({
|
||||
@@ -972,7 +983,18 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: listMetricsOutput,
|
||||
async run(ctx) {
|
||||
const inventory = await buildCoachDataInventory(ctx.userId, undefined);
|
||||
const [inventory, statusDiscoveryRows] = await Promise.all([
|
||||
buildCoachDataInventory(ctx.userId, undefined),
|
||||
// v1.30 coverage review (G5/C4) — the metric-status-only set
|
||||
// (wrist temperature, cardio recovery, sleep score, breathing
|
||||
// disturbances, strain/load, …) is resolvable via `compare_metric` /
|
||||
// `get_metric_baseline` but sits off the Coach snapshot inventory, so
|
||||
// it never advertised itself here. Appended as supplement rows
|
||||
// rather than folded into `buildCoachDataInventory` (Coach-owned)
|
||||
// to keep the addition MCP-side, mirroring how the `search` probe
|
||||
// already surfaces the sibling v1.25 clinical-signal set.
|
||||
metricStatusDiscoveryRows(ctx.userId),
|
||||
]);
|
||||
annotate({
|
||||
action: { name: "mcp.tool.invoked" },
|
||||
meta: { tool: "list_metrics", present: true },
|
||||
@@ -982,7 +1004,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
window: inventory.window,
|
||||
restMode: inventory.restMode,
|
||||
cycleEnabled: inventory.cycleEnabled,
|
||||
metrics: inventory.entries,
|
||||
metrics: [...inventory.entries, ...statusDiscoveryRows],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user