From 7e77a974d225b4b71de702dece97e745e3ea4062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 01:20:49 +0200 Subject: [PATCH] feat(mcp): add a confirmed blood-pressure write Blood pressure is two values, so it cannot ride the single-value log_measurement tool. Add log_blood_pressure: it writes the systolic and diastolic rows atomically under one shared idempotency namespace and one measured-at instant, range-validates each value, rejects an implausible pair (systolic at or below diastolic), and keeps the same confirm-flag preview gate, per-credential write budget, MCP provenance, and audit trail the existing writes use. Declare a structured output schema on every write tool so a preview, a commit, an already-logged no-op, and a refusal all validate as structured content. --- src/lib/mcp/__tests__/write-tools.test.ts | 106 +++++++++++- src/lib/mcp/__tests__/writes.test.ts | 86 +++++++++- src/lib/mcp/write-tools.ts | 102 +++++++++++- src/lib/mcp/writes.ts | 186 +++++++++++++++++++++- 4 files changed, 474 insertions(+), 6 deletions(-) diff --git a/src/lib/mcp/__tests__/write-tools.test.ts b/src/lib/mcp/__tests__/write-tools.test.ts index a4f5f5315..ac6fa49b4 100644 --- a/src/lib/mcp/__tests__/write-tools.test.ts +++ b/src/lib/mcp/__tests__/write-tools.test.ts @@ -2,9 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const logMcpMeasurement = vi.fn(); const logMcpMood = vi.fn(); +const logMcpBloodPressure = vi.fn(); vi.mock("../writes", () => ({ logMcpMeasurement: (...a: unknown[]) => logMcpMeasurement(...a), logMcpMood: (...a: unknown[]) => logMcpMood(...a), + logMcpBloodPressure: (...a: unknown[]) => logMcpBloodPressure(...a), })); const checkMcpWriteRateLimit = vi.fn(); @@ -42,13 +44,20 @@ beforeEach(() => { }); describe("write-tool surface", () => { - it("exposes exactly log_measurement and log_mood", () => { + it("exposes exactly log_measurement, log_mood, and log_blood_pressure", () => { expect([...MCP_WRITE_TOOL_NAMES].sort()).toEqual([ + "log_blood_pressure", "log_measurement", "log_mood", ]); }); + it("every write tool declares a structured outputSchema", () => { + for (const t of MCP_WRITE_TOOLS) { + expect(t.outputShape, `${t.name} lacks outputShape`).toBeDefined(); + } + }); + it("annotates writes as non-read-only, non-destructive, idempotent", () => { for (const t of MCP_WRITE_TOOLS) { expect(t.annotations).toEqual({ @@ -201,3 +210,98 @@ describe("confirm gate — log_mood", () => { expect(logMcpMood).not.toHaveBeenCalled(); }); }); + +describe("confirm gate — log_blood_pressure", () => { + it("confirm:false previews BOTH values and writes nothing", async () => { + const result = (await tool("log_blood_pressure").run(CTX, { + systolic: 120, + diastolic: 80, + idempotencyKey: "bp-1", + })) as Record; + expect(result.requiresConfirmation).toBe(true); + expect(result.written).toBe(false); + expect(result.preview).toMatchObject({ + systolic: 120, + diastolic: 80, + unit: "mmHg", + source: "MCP", + }); + expect(logMcpBloodPressure).not.toHaveBeenCalled(); + expect(checkMcpWriteRateLimit).not.toHaveBeenCalled(); + }); + + it("confirm:true commits the paired reading", async () => { + logMcpBloodPressure.mockResolvedValue({ + status: "written", + bloodPressure: { + systolic: 120, + diastolic: 80, + unit: "mmHg", + measuredAt: "x", + source: "MCP", + }, + }); + const result = (await tool("log_blood_pressure").run(CTX, { + systolic: 120, + diastolic: 80, + confirm: true, + idempotencyKey: "bp-1", + })) as Record; + expect(logMcpBloodPressure).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "u-1", + systolic: 120, + diastolic: 80, + idempotencyKey: "bp-1", + }), + ); + expect(result.written).toBe(true); + }); + + it("an idempotent replay reports alreadyLogged", async () => { + logMcpBloodPressure.mockResolvedValue({ + status: "already_logged", + bloodPressure: { + systolic: 120, + diastolic: 80, + unit: "mmHg", + measuredAt: "x", + source: "MCP", + }, + }); + const result = (await tool("log_blood_pressure").run(CTX, { + systolic: 120, + diastolic: 80, + confirm: true, + idempotencyKey: "bp-1", + })) as Record; + expect(result.written).toBe(false); + expect(result.alreadyLogged).toBe(true); + }); + + it("surfaces an out-of-range refusal from the core", async () => { + logMcpBloodPressure.mockResolvedValue({ + status: "out_of_range", + reason: "Systolic must be greater than diastolic", + }); + const result = (await tool("log_blood_pressure").run(CTX, { + systolic: 80, + diastolic: 120, + confirm: true, + idempotencyKey: "bp-2", + })) as Record; + expect(result.written).toBe(false); + expect(result.error).toBe("out_of_range"); + }); + + it("rejects a non-numeric value before any write", async () => { + const result = (await tool("log_blood_pressure").run(CTX, { + systolic: Number.NaN, + diastolic: 80, + confirm: true, + idempotencyKey: "bp-3", + })) as Record; + expect(result.written).toBe(false); + expect(logMcpBloodPressure).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/mcp/__tests__/writes.test.ts b/src/lib/mcp/__tests__/writes.test.ts index 6019d3dfd..6498e76b1 100644 --- a/src/lib/mcp/__tests__/writes.test.ts +++ b/src/lib/mcp/__tests__/writes.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -const { measurement, moodEntry } = vi.hoisted(() => ({ +const { measurement, moodEntry, $transaction } = vi.hoisted(() => ({ measurement: { findUnique: vi.fn(), create: vi.fn(), @@ -9,10 +9,11 @@ const { measurement, moodEntry } = vi.hoisted(() => ({ findUnique: vi.fn(), create: vi.fn(), }, + $transaction: vi.fn(), })); vi.mock("@/lib/db", () => ({ - prisma: { measurement, moodEntry }, + prisma: { measurement, moodEntry, $transaction }, })); vi.mock("@/lib/cache/invalidate", () => ({ @@ -42,13 +43,14 @@ vi.mock("@/lib/logging/context", () => ({ getEvent: () => ({ addMeta: vi.fn() }), })); -import { logMcpMeasurement, logMcpMood } from "../writes"; +import { logMcpMeasurement, logMcpMood, logMcpBloodPressure } from "../writes"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; beforeEach(() => { vi.clearAllMocks(); measurement.findUnique.mockResolvedValue(null); measurement.create.mockResolvedValue({ id: "m-1" }); + $transaction.mockResolvedValue([{ id: "s-1" }, { id: "d-1" }]); moodEntry.findUnique.mockResolvedValue(null); moodEntry.create.mockResolvedValue({ date: "2026-06-27", @@ -193,3 +195,81 @@ describe("logMcpMood", () => { expect(moodEntry.create).not.toHaveBeenCalled(); }); }); + +describe("logMcpBloodPressure", () => { + it("writes BOTH systolic and diastolic rows atomically with one timestamp", async () => { + const result = await logMcpBloodPressure({ + userId: "u-1", + systolic: 120, + diastolic: 80, + idempotencyKey: "bp-key", + }); + + expect(result.status).toBe("written"); + // Two create calls (sys + dia), executed inside one transaction. + expect(measurement.create).toHaveBeenCalledTimes(2); + expect($transaction).toHaveBeenCalledTimes(1); + + const sys = measurement.create.mock.calls[0][0].data; + const dia = measurement.create.mock.calls[1][0].data; + expect(sys.type).toBe("BLOOD_PRESSURE_SYS"); + expect(sys.value).toBe(120); + expect(dia.type).toBe("BLOOD_PRESSURE_DIA"); + expect(dia.value).toBe(80); + expect(sys.source).toBe("MCP"); + expect(dia.source).toBe("MCP"); + expect(sys.unit).toBe("mmHg"); + // Same shared externalId namespace + the SAME measuredAt instant. + expect(sys.externalId).toMatch(/^mcp:bp:[0-9a-f]{64}$/); + expect(sys.externalId).toBe(dia.externalId); + expect(sys.measuredAt.getTime()).toBe(dia.measuredAt.getTime()); + expect(auditLog).toHaveBeenCalledWith( + "mcp.write.blood_pressure", + expect.objectContaining({ userId: "u-1" }), + ); + }); + + it("rejects an implausible pair (systolic ≤ diastolic) without writing", async () => { + const result = await logMcpBloodPressure({ + userId: "u-1", + systolic: 80, + diastolic: 120, + idempotencyKey: "bp-bad", + }); + expect(result.status).toBe("out_of_range"); + expect(measurement.create).not.toHaveBeenCalled(); + expect($transaction).not.toHaveBeenCalled(); + }); + + it("rejects an out-of-range value without writing", async () => { + const result = await logMcpBloodPressure({ + userId: "u-1", + systolic: 9999, + diastolic: 80, + idempotencyKey: "bp-oor", + }); + expect(result.status).toBe("out_of_range"); + expect(measurement.create).not.toHaveBeenCalled(); + }); + + it("is idempotent — a replay with the same key writes nothing", async () => { + measurement.findUnique + .mockResolvedValueOnce({ + value: 120, + measuredAt: new Date("2026-06-27T08:00:00Z"), + }) + .mockResolvedValueOnce({ value: 80 }); + const result = await logMcpBloodPressure({ + userId: "u-1", + systolic: 120, + diastolic: 80, + idempotencyKey: "bp-key", + }); + expect(result.status).toBe("already_logged"); + expect($transaction).not.toHaveBeenCalled(); + if (result.status === "already_logged") { + expect(result.bloodPressure.systolic).toBe(120); + expect(result.bloodPressure.diastolic).toBe(80); + } + }); +}); diff --git a/src/lib/mcp/write-tools.ts b/src/lib/mcp/write-tools.ts index eb1497ad2..feba0f78c 100644 --- a/src/lib/mcp/write-tools.ts +++ b/src/lib/mcp/write-tools.ts @@ -35,7 +35,7 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { annotate } from "@/lib/logging/context"; import { checkMcpWriteRateLimit } from "@/lib/rate-limit"; import type { McpToolDefinition } from "./tools"; -import { logMcpMeasurement, logMcpMood } from "./writes"; +import { logMcpMeasurement, logMcpMood, logMcpBloodPressure } from "./writes"; /** * Write-tool annotations (MCP 2025-11-25). NOT read-only and NOT destructive @@ -50,6 +50,24 @@ const WRITE_ANNOTATIONS = { openWorldHint: false, } as const; +/** + * Shared output schema for the write tools. `written` is the boolean anchor on + * every return path (preview, commit, refusal); everything else is optional so a + * preview, a committed write, an already-logged no-op, and a structured refusal + * all validate as `structuredContent` (ChatGPT Apps-SDK conformance). `preview` + * / `record` carry the normalized echo of the row (shape per tool). + */ +const writeOutput: z.ZodRawShape = { + written: z.boolean(), + requiresConfirmation: z.boolean().optional(), + alreadyLogged: z.boolean().optional(), + error: z.string().optional(), + reason: z.string().optional(), + instruction: z.string().optional(), + preview: z.unknown().optional(), + record: z.unknown().optional(), +}; + /** Shared confirm-gate inputs every write tool carries. */ const confirmField = z .boolean() @@ -120,6 +138,7 @@ export const MCP_WRITE_TOOLS: McpToolDefinition[] = [ idempotencyKey: idempotencyKeyField, }, annotations: WRITE_ANNOTATIONS, + outputShape: writeOutput, async run(ctx, args) { const type = String(args.type ?? "") as MeasurementType; const value = typeof args.value === "number" ? args.value : Number.NaN; @@ -205,6 +224,7 @@ export const MCP_WRITE_TOOLS: McpToolDefinition[] = [ idempotencyKey: idempotencyKeyField, }, annotations: WRITE_ANNOTATIONS, + outputShape: writeOutput, async run(ctx, args) { const score = typeof args.score === "number" ? args.score : Number.NaN; const note = typeof args.note === "string" ? args.note : undefined; @@ -253,6 +273,86 @@ export const MCP_WRITE_TOOLS: McpToolDefinition[] = [ return { written: true, record: result.moodEntry }; }, }, + { + name: "log_blood_pressure", + title: "Log a blood-pressure reading", + description: + "Log ONE blood-pressure reading (systolic AND diastolic, in mmHg) to the user's own record. Blood pressure is two values so it cannot use log_measurement. Requires confirmation: call once to preview the exact reading, confirm the values with the user, then call again with confirm:true and the same idempotencyKey to commit. Both values are written atomically with the same timestamp.", + inputShape: { + systolic: z.number().describe("Systolic (the higher number), in mmHg."), + diastolic: z.number().describe("Diastolic (the lower number), in mmHg."), + measuredAt: z.iso + .datetime({ offset: true }) + .optional() + .describe( + "Optional ISO-8601 instant the reading was taken; defaults to now.", + ), + confirm: confirmField, + idempotencyKey: idempotencyKeyField, + }, + annotations: WRITE_ANNOTATIONS, + outputShape: writeOutput, + async run(ctx, args) { + const systolic = + typeof args.systolic === "number" ? args.systolic : Number.NaN; + const diastolic = + typeof args.diastolic === "number" ? args.diastolic : Number.NaN; + const measuredAt = + typeof args.measuredAt === "string" + ? new Date(args.measuredAt) + : undefined; + const confirm = args.confirm === true; + const idempotencyKey = + typeof args.idempotencyKey === "string" ? args.idempotencyKey : ""; + + if (!Number.isFinite(systolic) || !Number.isFinite(diastolic)) { + return { written: false, error: "invalid_number" }; + } + + if (!confirm) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_blood_pressure", status: "preview" }, + }); + return { + requiresConfirmation: true, + written: false, + preview: { + systolic, + diastolic, + unit: "mmHg", + measuredAt: measuredAt ? measuredAt.toISOString() : "now", + source: "MCP", + }, + instruction: CONFIRM_INSTRUCTION, + }; + } + + if (!(await writeBudgetOk(ctx.binding))) { + return { written: false, error: "rate_limited" }; + } + + const result = await logMcpBloodPressure({ + userId: ctx.userId, + systolic, + diastolic, + measuredAt, + idempotencyKey, + }); + + if (result.status === "out_of_range") { + return { written: false, error: "out_of_range", reason: result.reason }; + } + if (result.status === "already_logged") { + return { + written: false, + alreadyLogged: true, + record: result.bloodPressure, + }; + } + return { written: true, record: result.bloodPressure }; + }, + }, ]; /** Stable list of the registered write-tool names. */ diff --git a/src/lib/mcp/writes.ts b/src/lib/mcp/writes.ts index 0ba6e3838..0f637e0a5 100644 --- a/src/lib/mcp/writes.ts +++ b/src/lib/mcp/writes.ts @@ -48,7 +48,7 @@ const MCP_SOURCE = "MCP" as const; * Telegram or manual row. */ function mcpExternalId( - prefix: "measure" | "mood", + prefix: "measure" | "mood" | "bp", idempotencyKey: string, ): string { const digest = createHash("sha256").update(idempotencyKey).digest("hex"); @@ -184,6 +184,190 @@ export async function logMcpMeasurement(input: { return { status: "written", measurement: record }; } +/** Normalized blood-pressure record echoed back (preview + commit result). */ +export interface McpBloodPressureRecord { + systolic: number; + diastolic: number; + unit: string; + measuredAt: string; + source: typeof MCP_SOURCE; +} + +export type McpBloodPressureResult = + | { status: "out_of_range"; reason: string } + | { status: "written"; bloodPressure: McpBloodPressureRecord } + | { status: "already_logged"; bloodPressure: McpBloodPressureRecord }; + +/** + * Log one blood-pressure reading on behalf of the resolved MCP session. + * + * BP is two values (systolic + diastolic) so it cannot go through the + * single-value `logMcpMeasurement`. Both rows are written ATOMICALLY (one + * transaction, the SAME `measuredAt`) and share one idempotency namespace: the + * externalId is derived once from the caller's key and applied to both rows, + * which stay distinct under the `(userId, type, source, externalId)` unique + * because the type differs (SYS vs DIA). Each value is range-validated with the + * same `validateMeasurementRange` the manual route uses, plus a systolic > + * diastolic plausibility guard. No notes; `source` is pinned to `MCP`. + */ +export async function logMcpBloodPressure(input: { + userId: string; + systolic: number; + diastolic: number; + measuredAt?: Date; + idempotencyKey: string; +}): Promise { + const sysError = validateMeasurementRange( + "BLOOD_PRESSURE_SYS", + input.systolic, + ); + const diaError = validateMeasurementRange( + "BLOOD_PRESSURE_DIA", + input.diastolic, + ); + if (sysError !== null || diaError !== null) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_blood_pressure", status: "out_of_range" }, + }); + return { + status: "out_of_range", + reason: sysError ?? diaError ?? "Value out of range", + }; + } + // Plausibility: systolic is always the higher number. Reject a swapped / + // implausible pair rather than persist a clinically nonsensical reading. + if (input.systolic <= input.diastolic) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_blood_pressure", status: "out_of_range" }, + }); + return { + status: "out_of_range", + reason: "Systolic must be greater than diastolic", + }; + } + + const unit = "mmHg"; + const measuredAt = input.measuredAt ?? new Date(); + // ONE externalId for both rows — the shared idempotency namespace. + const externalId = mcpExternalId("bp", input.idempotencyKey); + + const record: McpBloodPressureRecord = { + systolic: input.systolic, + diastolic: input.diastolic, + unit, + measuredAt: measuredAt.toISOString(), + source: MCP_SOURCE, + }; + + const [existingSys, existingDia] = await Promise.all([ + prisma.measurement.findUnique({ + where: { + userId_type_source_externalId: { + userId: input.userId, + type: "BLOOD_PRESSURE_SYS", + source: MCP_SOURCE, + externalId, + }, + }, + select: { value: true, measuredAt: true }, + }), + prisma.measurement.findUnique({ + where: { + userId_type_source_externalId: { + userId: input.userId, + type: "BLOOD_PRESSURE_DIA", + source: MCP_SOURCE, + externalId, + }, + }, + select: { value: true }, + }), + ]); + if (existingSys && existingDia) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_blood_pressure", status: "already_logged" }, + }); + return { + status: "already_logged", + bloodPressure: { + systolic: existingSys.value, + diastolic: existingDia.value, + unit, + measuredAt: existingSys.measuredAt.toISOString(), + source: MCP_SOURCE, + }, + }; + } + + // Both rows in one transaction so a partial BP pair can never persist. No + // mass assignment — every column is set explicitly from validated input. + await prisma.$transaction([ + prisma.measurement.create({ + data: { + userId: input.userId, + type: "BLOOD_PRESSURE_SYS", + value: input.systolic, + unit, + source: MCP_SOURCE, + measuredAt, + externalId, + }, + }), + prisma.measurement.create({ + data: { + userId: input.userId, + type: "BLOOD_PRESSURE_DIA", + value: input.diastolic, + unit, + source: MCP_SOURCE, + measuredAt, + externalId, + }, + }), + ]); + + invalidateUserMeasurements(input.userId, { evict: true }); + + // Best-effort rollup refresh for both series — a cache tier, never a + // write-path invariant. + try { + await Promise.all([ + recomputeBucketsForMeasurement( + input.userId, + "BLOOD_PRESSURE_SYS", + measuredAt, + ), + recomputeBucketsForMeasurement( + input.userId, + "BLOOD_PRESSURE_DIA", + measuredAt, + ), + ]); + } catch (rollupErr) { + getEvent()?.addMeta( + "mcp_blood_pressure_rollup_failed", + rollupErr instanceof Error ? rollupErr.message : String(rollupErr), + ); + } + + await auditLog("mcp.write.blood_pressure", { + userId: input.userId, + details: { + source: MCP_SOURCE, + idempotencyKey: input.idempotencyKey, + }, + }); + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_blood_pressure", status: "written" }, + }); + + return { status: "written", bloodPressure: record }; +} + /** Normalized mood record echoed back to the assistant. */ export interface McpMoodRecord { score: number;