Files
HealthLog/src/lib/mcp/write-tools.ts
T
Marc-André Bombeck 576be8b746 Harden the MCP write surface and fence its free text
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.
2026-07-19 05:38:56 +02:00

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,
);