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:
Marc-André Bombeck committed 2026-06-28 01:20:49 +02:00
1 parent fb986bc09d
commit 7e77a974d2
4 files changed
+474 -6

No files matched your search

+105 -1
View File
@@ -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();
});
});
+83 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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;