From c6b8d33b0cc4cd65bb98dbcd95e80ab663e31fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 07:38:25 +0200 Subject: [PATCH] feat(mcp): add get_intraday_pulse read tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds MCP coverage for the intraday pulse / "shape of the day" — previously unreachable, since get_metric_series only serves daily aggregates and cannot answer "what happened this afternoon?" or "was I tense during the meeting?". get_intraday_pulse is a thin wrapper over loadIntradayPulse, returning the same DTO GET /api/insights/pulse/intraday serves verbatim: the 10-minute mean shape (dense-90d, hourly-grain fallback outside that window per the resolution field), the resting baseline, and the cautious elevated-at-rest tension window when every confidence gate holds. Gated on the insights module; never fabricates a reading for a day with no data. --- src/lib/mcp/__tests__/tools.test.ts | 81 ++++++++++++++++++++++++ src/lib/mcp/tools.ts | 98 +++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index ff5e4e2f8..66a372a48 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -68,6 +68,21 @@ vi.mock("@/lib/mcp/nutrients-read", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getNutrients: vi.fn(async () => ({ present: true })) }; }); +// v1.30 (G2) — `get_intraday_pulse` delegates to the shared IO seam; stub it +// so the tool-wiring tests below never reach a real DB read. The engine's own +// logic (dense-90d + hourly fallback + tension) is covered elsewhere. +vi.mock("@/lib/analytics/intraday-pulse-io", () => ({ + loadIntradayPulse: vi.fn(async () => ({ + dateKey: "2026-07-10", + timezone: "UTC", + bucketMinutes: 10, + series: [{ startMinute: 0, mean: 60, count: 3 }], + baseline: 58, + baselineSource: "resting", + tension: null, + resolution: "tenMin", + })), +})); // Phase 4 — the deep-value reads delegate to the rich-reads engines; stub them // so the registry-wide loops (surface / annotation / no-verdict) never reach a // real engine. Their own logic is covered in `rich-reads.test.ts`. @@ -98,6 +113,7 @@ import { getIntegrationStatus } from "@/lib/integrations/status"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; import { isModuleEnabled } from "@/lib/modules/gate"; import { getNutrients } from "@/lib/mcp/nutrients-read"; +import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io"; import type { McpAuthContext } from "../auth"; const CTX: McpAuthContext = { @@ -150,6 +166,8 @@ describe("MCP tool registry — surface", () => { "get_preventive_care", // v1.30 coverage review (G1) — the nutrients pipeline. "get_nutrients", + // v1.30 coverage review (G2) — the intraday pulse / shape of the day. + "get_intraday_pulse", ].sort(), ); }); @@ -804,3 +822,66 @@ describe("nutrients on search / fetch (v1.30 coverage review G1)", () => { expect(result.title).toBe("Not found"); }); }); + +describe("get_intraday_pulse — v1.30 coverage review (G2)", () => { + it("returns the engine's DTO verbatim (present, resolution, tension included)", async () => { + const result = (await tool("get_intraday_pulse").run(CTX, {})) as { + present: boolean; + dateKey: string; + resolution: string; + series: unknown[]; + tension: unknown; + }; + // No `date` arg → today's local day; assert the session tz was threaded + // through without pinning a calendar date the test would rot on. + expect(loadIntradayPulse).toHaveBeenCalledWith( + "user-1", + "UTC", + expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + ); + expect(result.present).toBe(true); + // The mocked engine's own DTO carries this fixed dateKey verbatim. + expect(result.dateKey).toBe("2026-07-10"); + expect(result.resolution).toBe("tenMin"); + expect(result.series).toHaveLength(1); + expect(result.tension).toBeNull(); + }); + + it("passes an explicit `date` arg straight through to the engine", async () => { + await tool("get_intraday_pulse").run(CTX, { date: "2026-06-01" }); + expect(loadIntradayPulse).toHaveBeenCalledWith( + "user-1", + "UTC", + "2026-06-01", + ); + }); + + it("returns { present: false } (never fabricates) when the day has no pulse data", async () => { + vi.mocked(loadIntradayPulse).mockResolvedValueOnce({ + dateKey: "2026-07-10", + timezone: "UTC", + bucketMinutes: 10, + series: [], + baseline: null, + baselineSource: "none", + tension: null, + resolution: "tenMin", + } as never); + const result = (await tool("get_intraday_pulse").run(CTX, {})) as { + present: boolean; + reason?: string; + }; + expect(result.present).toBe(false); + expect(result.reason).toBe("no_data"); + }); + + it("returns { present: false, reason: module_disabled } when the `insights` module is off", async () => { + vi.mocked(isModuleEnabled).mockResolvedValueOnce(false); + const result = (await tool("get_intraday_pulse").run(CTX, {})) as { + present: boolean; + reason?: string; + }; + expect(result).toEqual({ present: false, reason: "module_disabled" }); + expect(loadIntradayPulse).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 3fd782df7..3e6afa403 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -46,6 +46,8 @@ import { type NutrientsDailyResult, } from "@/lib/mcp/nutrients-read"; import { NUTRIENT_CODES } from "@/lib/nutrients/catalog"; +import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; import { encodeOffsetCursor, decodeOffsetCursor } from "@/lib/mcp/pagination"; import { computeDisplayDue, @@ -889,6 +891,44 @@ const getNutrientsOutput: z.ZodRawShape = { .optional(), }; +/** + * Output schema for `get_intraday_pulse` — the 10-minute "shape of the day" + * plus, when every confidence gate holds, one cautious elevated-at-rest + * ("tension") window. Verbatim re-export of `IntradayPulseResult` + * (`intraday-pulse-io.ts`); `resolution` tells the caller whether `series` is + * the dense 10-minute grain or the coarser hourly fallback. + */ +const getIntradayPulseOutput: z.ZodRawShape = { + present: z.boolean(), + reason: z.string().optional(), + dateKey: z.string().optional(), + timezone: z.string().optional(), + bucketMinutes: z.number().optional(), + resolution: z.enum(["tenMin", "hourly"]).optional(), + series: z + .array( + z.object({ + startMinute: z.number(), + mean: z.number(), + count: z.number(), + }), + ) + .optional(), + baseline: z.number().nullable().optional(), + baselineSource: z.enum(["resting", "proxy", "none"]).optional(), + tension: z + .object({ + startMinute: z.number(), + endMinute: z.number(), + partOfDay: z.enum(["morning", "afternoon", "evening", "night"]), + meanHr: z.number(), + baseline: z.number(), + hrvConfirmed: z.boolean(), + }) + .nullable() + .optional(), +}; + export const MCP_TOOLS: McpToolDefinition[] = [ { name: "list_metrics", @@ -1496,6 +1536,64 @@ export const MCP_TOOLS: McpToolDefinition[] = [ return result; }, }, + // ── v1.30 coverage review (G2) — the intraday pulse / "shape of the day" ── + { + name: "get_intraday_pulse", + title: "Get the intraday pulse shape", + description: + "Fetch the user's own 10-minute heart-rate shape for ONE local day — 'what happened this afternoon?', 'was I tense during the meeting?'. When every confidence gate holds, also carries a single cautious elevated-at-rest ('tension') window: a DESCRIPTIVE pattern only, NEVER a stress diagnosis. Falls back to an hourly-grain shape for a day outside the dense-retention window — see the `resolution` field ('tenMin' vs 'hourly'); `tension` is only ever computed at the dense grain. Gated on the `insights` module. Returns { present: false, reason: \"module_disabled\" } when the module is off, or { present: false } when the day has no pulse data.", + inputShape: { + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional() + .describe( + "YYYY-MM-DD in the user's own timezone. Defaults to the user's local today.", + ), + }, + annotations: READ_ONLY_ANNOTATIONS, + outputShape: getIntradayPulseOutput, + async run(ctx, args) { + const enabled = await isModuleEnabled(ctx.userId, "insights"); + if (!enabled) { + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_intraday_pulse", present: false }, + }); + return { present: false, reason: "module_disabled" }; + } + + const user = await prisma.user.findUnique({ + where: { id: ctx.userId }, + select: { timezone: true }, + }); + const timezone = user?.timezone || DEFAULT_TIMEZONE; + const dateKey = + typeof args.date === "string" && args.date + ? args.date + : userDayKey(new Date(), timezone); + + const result = await loadIntradayPulse(ctx.userId, timezone, dateKey); + const present = result.series.length > 0; + + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_intraday_pulse", present }, + }); + return { + present, + ...(present ? {} : { reason: "no_data" }), + dateKey: result.dateKey, + timezone: result.timezone, + bucketMinutes: result.bucketMinutes, + resolution: result.resolution, + series: result.series, + baseline: result.baseline, + baselineSource: result.baselineSource, + tension: result.tension, + }; + }, + }, ...searchAndFetchTools(), ];