From a70c98d2b4d68334176315cff401a1d6108bbf70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 07:54:03 +0200 Subject: [PATCH] feat(mcp): surface metric-status-only rich metrics for discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/mcp/__tests__/route.test.ts | 4 + src/lib/mcp/__tests__/resources.test.ts | 49 ++++++++++++ src/lib/mcp/__tests__/rich-reads.test.ts | 72 ++++++++++++++++- src/lib/mcp/__tests__/tools.test.ts | 90 +++++++++++++++++++++- src/lib/mcp/resources.ts | 10 ++- src/lib/mcp/rich-reads.ts | 98 ++++++++++++++++++++++++ src/lib/mcp/tools.ts | 66 ++++++++++------ 7 files changed, 360 insertions(+), 29 deletions(-) diff --git a/src/app/mcp/__tests__/route.test.ts b/src/app/mcp/__tests__/route.test.ts index 80d5ed9a2..738817f6a 100644 --- a/src/app/mcp/__tests__/route.test.ts +++ b/src/app/mcp/__tests__/route.test.ts @@ -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. */ diff --git a/src/lib/mcp/__tests__/resources.test.ts b/src/lib/mcp/__tests__/resources.test.ts index 10a7a6eef..cf55c8b57 100644 --- a/src/lib/mcp/__tests__/resources.test.ts +++ b/src/lib/mcp/__tests__/resources.test.ts @@ -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> }; + + 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 diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts index 91460c6cc..f60b47b95 100644 --- a/src/lib/mcp/__tests__/rich-reads.test.ts +++ b/src/lib/mcp/__tests__/rich-reads.test.ts @@ -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); + }); +}); diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 401e65eef..31936bee4 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -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>; + }; + 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; + 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({ diff --git a/src/lib/mcp/resources.ts b/src/lib/mcp/resources.ts index c13c9b5ea..f2d0d675c 100644 --- a/src/lib/mcp/resources.ts +++ b/src/lib/mcp/resources.ts @@ -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], }; }, }, diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index 6849f4858..73eb7016d 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -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 diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index a1b91702c..cc1ae9145 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -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:` - // 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:` 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], }; }, },