Files
HealthLog/src/lib/mcp/__tests__/write-tools.test.ts
T
Marc-André Bombeck 6de61de010 feat(mcp): add a confirmed, audience-bound write surface for logging measurements and mood
Register two write tools — log_measurement and log_mood — only for a
health:write-scoped session; a read-only session never sees them advertised.
Each write mirrors the proven Telegram capture path (same range guard,
canonical unit, cache invalidation and rollup recompute) pinned to the MCP
source, and is idempotent on a hashed externalId derived from a client key, so
a retried call is a no-op rather than a duplicate.

Because the remote wire is stateless (no elicitation), the tools carry a
protocol-level confirm gate: without confirm:true they write nothing and
return the exact record they would write plus an instruction to confirm the
value with the user and re-call with the same idempotency key. Commits run
under a tighter per-credential write budget on top of the per-binding request
limiter.

Harden the resource-server audience binding: a token whose scopes are all in
{health:read, health:write} is bound to the MCP surface and refused on every
REST write/delete. A health:write token therefore admits the in-process MCP
write tools only and can never become a general REST write credential; admin
stays unreachable (cookie-only). Refuse, by construction, any medication, lab,
delete/update, or data-export tool on this surface.
2026-06-27 17:38:42 +02:00

204 lines
5.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
const logMcpMeasurement = vi.fn();
const logMcpMood = vi.fn();
vi.mock("../writes", () => ({
logMcpMeasurement: (...a: unknown[]) => logMcpMeasurement(...a),
logMcpMood: (...a: unknown[]) => logMcpMood(...a),
}));
const checkMcpWriteRateLimit = vi.fn();
vi.mock("@/lib/rate-limit", () => ({
checkMcpWriteRateLimit: (...a: unknown[]) => checkMcpWriteRateLimit(...a),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { MCP_WRITE_TOOLS, MCP_WRITE_TOOL_NAMES } from "../write-tools";
import type { McpAuthContext } from "../auth";
const CTX: McpAuthContext = {
userId: "u-1",
tokenId: "t-1",
scopes: ["health:read", "health:write"],
binding: "u-1:t-1",
canRead: true,
canWrite: true,
};
function tool(name: string) {
const t = MCP_WRITE_TOOLS.find((x) => x.name === name);
if (!t) throw new Error(`missing tool ${name}`);
return t;
}
beforeEach(() => {
vi.clearAllMocks();
checkMcpWriteRateLimit.mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 1000,
});
});
describe("write-tool surface", () => {
it("exposes exactly log_measurement and log_mood", () => {
expect([...MCP_WRITE_TOOL_NAMES].sort()).toEqual([
"log_measurement",
"log_mood",
]);
});
it("annotates writes as non-read-only, non-destructive, idempotent", () => {
for (const t of MCP_WRITE_TOOLS) {
expect(t.annotations).toEqual({
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
});
}
});
});
describe("confirm gate — log_measurement", () => {
it("confirm:false returns a preview and writes NOTHING", async () => {
const result = (await tool("log_measurement").run(CTX, {
type: "WEIGHT",
value: 80,
idempotencyKey: "k-1",
})) as Record<string, unknown>;
expect(result.requiresConfirmation).toBe(true);
expect(result.written).toBe(false);
expect(result.preview).toMatchObject({
type: "WEIGHT",
value: 80,
source: "MCP",
});
expect(logMcpMeasurement).not.toHaveBeenCalled();
expect(checkMcpWriteRateLimit).not.toHaveBeenCalled();
});
it("confirm:true executes the core and reports the written record", async () => {
logMcpMeasurement.mockResolvedValue({
status: "written",
measurement: {
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: "x",
source: "MCP",
},
});
const result = (await tool("log_measurement").run(CTX, {
type: "WEIGHT",
value: 80,
confirm: true,
idempotencyKey: "k-1",
})) as Record<string, unknown>;
expect(logMcpMeasurement).toHaveBeenCalledTimes(1);
expect(logMcpMeasurement).toHaveBeenCalledWith(
expect.objectContaining({
userId: "u-1",
type: "WEIGHT",
value: 80,
idempotencyKey: "k-1",
}),
);
expect(result.written).toBe(true);
});
it("an idempotent replay reports alreadyLogged, not a duplicate write", async () => {
logMcpMeasurement.mockResolvedValue({
status: "already_logged",
measurement: {
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: "x",
source: "MCP",
},
});
const result = (await tool("log_measurement").run(CTX, {
type: "WEIGHT",
value: 80,
confirm: true,
idempotencyKey: "k-1",
})) as Record<string, unknown>;
expect(result.written).toBe(false);
expect(result.alreadyLogged).toBe(true);
});
it("refuses to commit when the write budget is exhausted", async () => {
checkMcpWriteRateLimit.mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 1000,
});
const result = (await tool("log_measurement").run(CTX, {
type: "WEIGHT",
value: 80,
confirm: true,
idempotencyKey: "k-1",
})) as Record<string, unknown>;
expect(result.written).toBe(false);
expect(result.error).toBe("rate_limited");
expect(logMcpMeasurement).not.toHaveBeenCalled();
});
it("rejects a non-numeric value before any write", async () => {
const result = (await tool("log_measurement").run(CTX, {
type: "WEIGHT",
value: Number.NaN,
confirm: true,
idempotencyKey: "k-1",
})) as Record<string, unknown>;
expect(result.written).toBe(false);
expect(logMcpMeasurement).not.toHaveBeenCalled();
});
});
describe("confirm gate — log_mood", () => {
it("confirm:false returns a preview and writes NOTHING", async () => {
const result = (await tool("log_mood").run(CTX, {
score: 4,
idempotencyKey: "m-1",
})) as Record<string, unknown>;
expect(result.requiresConfirmation).toBe(true);
expect(result.written).toBe(false);
expect(logMcpMood).not.toHaveBeenCalled();
});
it("confirm:true commits the mood entry", async () => {
logMcpMood.mockResolvedValue({
status: "written",
moodEntry: {
score: 4,
mood: "GUT",
note: null,
date: "2026-06-27",
source: "MCP",
},
});
const result = (await tool("log_mood").run(CTX, {
score: 4,
confirm: true,
idempotencyKey: "m-1",
})) as Record<string, unknown>;
expect(logMcpMood).toHaveBeenCalledTimes(1);
expect(result.written).toBe(true);
});
it("rejects an out-of-band score before any write", async () => {
const result = (await tool("log_mood").run(CTX, {
score: 9,
confirm: true,
idempotencyKey: "m-1",
})) as Record<string, unknown>;
expect(result.written).toBe(false);
expect(logMcpMood).not.toHaveBeenCalled();
});
});