mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Three things the write surface claimed but did not hold. Scope: the run bodies never re-checked ctx.canWrite. Enforcement was real but lived entirely in the registration decision, and the test asserted registration only — so a refactor that registered the write tools unconditionally and meant to refuse at call time would open the surface with the suite still green. Wrap every run body in a scope guard at the registry level, so a new write tool inherits it, and drive the run bodies directly from a read-only context in the test. Free text: medication names, doses, treatment classes and lab analyte names were interpolated into tool prose with no delimiter. A lab analyte name is transcribed out of an uploaded document, so a hostile document chooses it, and the write tools ride the same wire. Fence the assembled prose with the shared data-fence markers and scrub short result titles, which would leak markers into a host's result list. Confirm: the header called the confirm flag a PROTOCOL-LEVEL CONFIRM GATE. It is a client-asserted boolean with nothing binding it to a prior preview. Binding it to a server-issued preview token would prove only that a preview call happened, which any client can do, so it buys no human-in-the-loop for a round trip and server state the stateless transport does not want. Describe it as the convention it is and name what actually bounds the blast radius.
434 lines
16 KiB
TypeScript
434 lines
16 KiB
TypeScript
/**
|
|
* Transport-agnostic MCP write-tool registry — the confirmed write surface.
|
|
*
|
|
* Registered ONLY for a `health:write`-scoped session (`ctx.canWrite`); a
|
|
* read-only session never sees these tools advertised, AND every run body
|
|
* re-asserts the scope in its own call path (see `withWriteScope`). Each tool
|
|
* wraps an in-process write core (`src/lib/mcp/writes.ts`) that mirrors the
|
|
* proven Telegram capture helpers, pinned to the `MCP` provenance.
|
|
*
|
|
* TWO-STEP CONFIRM CONVENTION — and what it is NOT. The remote `/mcp` wire is
|
|
* stateless (`enableJsonResponse: true`, no SSE) so server→client elicitation
|
|
* is impossible. Instead every write tool takes an explicit `confirm` flag:
|
|
*
|
|
* - `confirm:false` (default) → NOTHING is written. The tool returns the
|
|
* exact normalized record it WOULD write plus `requiresConfirmation:true`
|
|
* and an instruction telling the assistant to confirm the value with the
|
|
* user and re-call with `confirm:true` and the SAME `idempotencyKey`.
|
|
* - `confirm:true` → the write executes. The `(userId, …, externalId)`
|
|
* idempotency derived from `idempotencyKey` makes a retried call a no-op
|
|
* (`alreadyLogged:true`) rather than a duplicate.
|
|
*
|
|
* `confirm` is a CLIENT-ASSERTED BOOLEAN. Nothing binds it to a prior preview
|
|
* and nothing proves a human saw the value: a caller may send `confirm:true`
|
|
* on the first call and the write lands. Binding the flag to a server-issued
|
|
* preview token was considered and rejected — it would prove only that a
|
|
* preview call happened, which any client can trivially do, so it buys no
|
|
* human-in-the-loop while adding a round trip and server state the stateless
|
|
* transport does not want. The honest description is a CONVENTION that gives a
|
|
* cooperating host a natural confirmation point (and a place to hang its own
|
|
* human-in-the-loop, keyed off `readOnlyHint:false`), not a gate that
|
|
* constrains an uncooperative one.
|
|
*
|
|
* What actually bounds the blast radius is the surface itself, below: writes
|
|
* are append-only, idempotent, single self-reported readings, scope-gated
|
|
* twice, rate-limited per credential, and audience-bound to `/mcp`. That is
|
|
* the real control. `confirm` is ergonomics on top of it.
|
|
*
|
|
* DELIBERATE SAFETY BOUNDARY — what is NOT here and must NOT be added without
|
|
* a security review:
|
|
* - No medication create / intake / edit, no schedule or dose changes.
|
|
* - No lab write, no clinical-threshold or reference-range change.
|
|
* - No delete or update of any existing row (writes are append-only,
|
|
* idempotent inserts of single self-reported readings).
|
|
* - No `export_data` / data-export tool. If one is ever added it must be
|
|
* write-tier AND never co-registered with any egress on the same surface.
|
|
* The write surface is intentionally the smallest possible: a confirmed,
|
|
* append-only log of a measurement or a mood the user reports themselves.
|
|
*/
|
|
import { z } from "zod/v4";
|
|
|
|
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,
|
|
logMcpBloodPressure,
|
|
checkMcpMeasurement,
|
|
checkMcpBloodPressure,
|
|
} from "./writes";
|
|
|
|
/**
|
|
* Write-tool annotations (MCP 2025-11-25). NOT read-only and NOT destructive
|
|
* (an append-only, idempotent insert of a single self-reported reading), and
|
|
* idempotent so a retried call under the same `idempotencyKey` is safe. A host
|
|
* that adds human-in-the-loop confirmation keys off `readOnlyHint:false`.
|
|
*/
|
|
const WRITE_ANNOTATIONS = {
|
|
readOnlyHint: false,
|
|
destructiveHint: false,
|
|
idempotentHint: true,
|
|
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(),
|
|
// Set on a preview that would be REFUSED on commit (type / range / instant),
|
|
// with `error` + `reason` carrying the same verdict the commit would return —
|
|
// so a preview and its commit never disagree.
|
|
wouldFail: z.boolean().optional(),
|
|
};
|
|
|
|
/** Shared confirm-gate inputs every write tool carries. */
|
|
const confirmField = z
|
|
.boolean()
|
|
.default(false)
|
|
.describe(
|
|
"Set to true ONLY after confirming the value with the user. When false or absent the tool returns a preview and writes nothing.",
|
|
);
|
|
const idempotencyKeyField = z
|
|
.string()
|
|
.min(1)
|
|
.max(120)
|
|
.describe(
|
|
"A stable client-chosen key for this logical write. Re-calling with the same key (after a preview) commits exactly one row; a further retry is a safe no-op.",
|
|
);
|
|
|
|
/** The instruction the preview hands back to the assistant. */
|
|
const CONFIRM_INSTRUCTION =
|
|
"This writes to the user's own health record. Confirm the value with the user, then call this tool again with confirm:true and the SAME idempotencyKey to commit it.";
|
|
|
|
/**
|
|
* Enforce the tighter per-credential write budget before a commit. Returns a
|
|
* structured refusal when the budget is exhausted so a runaway client cannot
|
|
* flood writes (this is ON TOP of the `/mcp` per-binding request limiter).
|
|
*/
|
|
async function writeBudgetOk(binding: string): Promise<boolean> {
|
|
const rl = await checkMcpWriteRateLimit(binding);
|
|
if (!rl.allowed) {
|
|
annotate({
|
|
action: { name: "mcp.tool.write" },
|
|
meta: { tool: "write", status: "rate_limited" },
|
|
});
|
|
}
|
|
return rl.allowed;
|
|
}
|
|
|
|
/**
|
|
* DEFENCE IN DEPTH — re-assert the write scope inside the call path.
|
|
*
|
|
* The primary enforcement is structural: `createMcpServer` registers these
|
|
* tools only when `ctx.canWrite`, so a read-only session's write handler
|
|
* closure does not exist and the tool is not even advertised. That property is
|
|
* real, but it lives entirely in the registration decision — a refactor that
|
|
* registered the write tools unconditionally and meant to refuse at call time
|
|
* would open the write surface with the registration test still green.
|
|
*
|
|
* So every run body is wrapped here rather than each tool remembering to check.
|
|
* A new write tool added to the array inherits the guard by construction; there
|
|
* is no version of this file where a write handler runs without the scope
|
|
* having been checked in its own call path.
|
|
*/
|
|
function withWriteScope(tool: McpToolDefinition): McpToolDefinition {
|
|
return {
|
|
...tool,
|
|
async run(ctx, args) {
|
|
if (!ctx.canWrite) {
|
|
annotate({
|
|
action: { name: "mcp.tool.write" },
|
|
meta: { tool: tool.name, status: "scope_refused" },
|
|
});
|
|
return { written: false, error: "insufficient_scope" };
|
|
}
|
|
return tool.run(ctx, args);
|
|
},
|
|
};
|
|
}
|
|
|
|
const WRITE_TOOL_DEFINITIONS: McpToolDefinition[] = [
|
|
{
|
|
name: "log_measurement",
|
|
title: "Log a measurement",
|
|
description:
|
|
"Log ONE single-value health measurement to the user's own record (e.g. weight, pulse, blood glucose, body temperature, oxygen saturation, a body-composition value). Requires confirmation: call once to preview the exact record, confirm the value with the user, then call again with confirm:true and the same idempotencyKey. Blood pressure (two values) and computed/clinical-only metrics are not loggable here.",
|
|
inputShape: {
|
|
type: z
|
|
.string()
|
|
.min(1)
|
|
.max(60)
|
|
.describe(
|
|
"The measurement type, e.g. WEIGHT, PULSE, BLOOD_GLUCOSE, BODY_TEMPERATURE, OXYGEN_SATURATION, BODY_FAT. Not all types are loggable; an unsupported type is refused.",
|
|
),
|
|
value: z
|
|
.number()
|
|
.describe("The numeric reading in the type's canonical unit."),
|
|
unit: z
|
|
.string()
|
|
.min(1)
|
|
.max(20)
|
|
.optional()
|
|
.describe(
|
|
"Optional unit hint; the server uses the type's canonical unit when omitted.",
|
|
),
|
|
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 type = String(args.type ?? "") as MeasurementType;
|
|
const value = typeof args.value === "number" ? args.value : Number.NaN;
|
|
const unit = typeof args.unit === "string" ? args.unit : undefined;
|
|
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(value)) {
|
|
return { written: false, error: "invalid_number" };
|
|
}
|
|
|
|
if (!confirm) {
|
|
// Run the SAME validation the commit core runs so the preview cannot
|
|
// show a value that would then be refused on commit.
|
|
const check = checkMcpMeasurement(type, value, measuredAt);
|
|
annotate({
|
|
action: { name: "mcp.tool.write" },
|
|
meta: { tool: "log_measurement", status: "preview" },
|
|
});
|
|
return {
|
|
requiresConfirmation: true,
|
|
written: false,
|
|
preview: {
|
|
type,
|
|
value,
|
|
unit: unit ?? null,
|
|
measuredAt: measuredAt ? measuredAt.toISOString() : "now",
|
|
source: "MCP",
|
|
},
|
|
instruction: CONFIRM_INSTRUCTION,
|
|
...(check.ok
|
|
? {}
|
|
: { wouldFail: true, error: check.error, reason: check.reason }),
|
|
};
|
|
}
|
|
|
|
if (!(await writeBudgetOk(ctx.binding))) {
|
|
return { written: false, error: "rate_limited" };
|
|
}
|
|
|
|
const result = await logMcpMeasurement({
|
|
userId: ctx.userId,
|
|
type,
|
|
value,
|
|
unit,
|
|
measuredAt,
|
|
idempotencyKey,
|
|
});
|
|
|
|
if (result.status === "unsupported_type") {
|
|
return { written: false, error: "unsupported_type" };
|
|
}
|
|
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.measurement,
|
|
};
|
|
}
|
|
return { written: true, record: result.measurement };
|
|
},
|
|
},
|
|
{
|
|
name: "log_mood",
|
|
title: "Log a mood entry",
|
|
description:
|
|
"Log the user's mood for today on a 1-5 scale (1 = very low, 5 = very good) to their own record, with an optional short note. Requires confirmation: call once to preview, confirm with the user, then call again with confirm:true and the same idempotencyKey to commit.",
|
|
inputShape: {
|
|
score: z
|
|
.number()
|
|
.int()
|
|
.min(1)
|
|
.max(5)
|
|
.describe("Mood score 1-5 (1 = very low, 5 = very good)."),
|
|
note: z
|
|
.string()
|
|
.max(500)
|
|
.optional()
|
|
.describe("Optional short free-text note (max 500 chars)."),
|
|
confirm: confirmField,
|
|
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;
|
|
const confirm = args.confirm === true;
|
|
const idempotencyKey =
|
|
typeof args.idempotencyKey === "string" ? args.idempotencyKey : "";
|
|
|
|
if (!Number.isInteger(score) || score < 1 || score > 5) {
|
|
return { written: false, error: "invalid_score" };
|
|
}
|
|
|
|
if (!confirm) {
|
|
annotate({
|
|
action: { name: "mcp.tool.write" },
|
|
meta: { tool: "log_mood", status: "preview" },
|
|
});
|
|
return {
|
|
requiresConfirmation: true,
|
|
written: false,
|
|
preview: { score, note: note ?? null, source: "MCP" },
|
|
instruction: CONFIRM_INSTRUCTION,
|
|
};
|
|
}
|
|
|
|
if (!(await writeBudgetOk(ctx.binding))) {
|
|
return { written: false, error: "rate_limited" };
|
|
}
|
|
|
|
const result = await logMcpMood({
|
|
userId: ctx.userId,
|
|
score,
|
|
note,
|
|
idempotencyKey,
|
|
});
|
|
|
|
if (result.status === "invalid_score") {
|
|
return { written: false, error: "invalid_score" };
|
|
}
|
|
if (result.status === "already_logged") {
|
|
return {
|
|
written: false,
|
|
alreadyLogged: true,
|
|
record: result.moodEntry,
|
|
};
|
|
}
|
|
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) {
|
|
// Same validation the commit core runs — preview == commit verdict.
|
|
const check = checkMcpBloodPressure(systolic, diastolic, measuredAt);
|
|
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,
|
|
...(check.ok
|
|
? {}
|
|
: { wouldFail: true, error: check.error, reason: check.reason }),
|
|
};
|
|
}
|
|
|
|
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 };
|
|
},
|
|
},
|
|
];
|
|
|
|
export const MCP_WRITE_TOOLS: McpToolDefinition[] =
|
|
WRITE_TOOL_DEFINITIONS.map(withWriteScope);
|
|
|
|
/** Stable list of the registered write-tool names. */
|
|
export const MCP_WRITE_TOOL_NAMES: readonly string[] = MCP_WRITE_TOOLS.map(
|
|
(t) => t.name,
|
|
);
|