mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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.
This commit is contained in:
1 parent
fb986bc09d
commit
7e77a974d2
4 files changed
+474
-6
No files matched your search
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
expect(result.written).toBe(false);
|
||||
expect(logMcpBloodPressure).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
+101
-1
@@ -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. */
|
||||
|
||||
+185
-1
@@ -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<McpBloodPressureResult> {
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user