mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Combine the remote MCP server (stdio + remote /mcp + OAuth 2.1 + read tools + deep-value reads + a confirmed, audience-bound write surface) with the v1.23 account-security line (MFA, known/trusted devices, free-text note encryption). - schema: keep every v1.23 User relation (MFA, known/trusted devices) alongside the McpOAuthConnection relation; the MeasurementSource enum carries the MCP value. - api-handler: the MCP audience guard (a token scoped to health:read/health:write is bound to /mcp and refused a REST write) and the step-up MFA helpers coexist. - maintenance queues: register the MCP token-cleanup sweep next to the note-encryption backfill and key-rotation queues; shift it to 04:00 so the two daily discovery ticks do not collide. - migrations: renumber the MCP migrations to 0206/0207 so they follow the v1.23 chain (0199-0205). - MCP mood writes route the note through the v1.23 at-rest cipher.
334 lines
9.8 KiB
TypeScript
334 lines
9.8 KiB
TypeScript
/**
|
|
* MCP write data cores — in-process measurement + mood logging.
|
|
*
|
|
* These mirror the proven server-authoritative Telegram capture helpers
|
|
* (`src/lib/measurements/create-from-telegram.ts`,
|
|
* `src/lib/mood/create-from-telegram.ts`) one-for-one — same range guard,
|
|
* same canonical unit, same cache invalidation + rollup recompute — but pin
|
|
* `source` to the dedicated `MCP` provenance and use an MCP idempotency
|
|
* namespace. The two surfaces are kept as deliberate parallels rather than a
|
|
* shared core so a future change to one cannot silently alter the other's
|
|
* validation or dedup contract.
|
|
*
|
|
* `userId` is ALWAYS the resolved session id (`McpAuthContext.userId`), never
|
|
* a caller-supplied field. The write tools (`src/lib/mcp/write-tools.ts`) own
|
|
* the confirm gate; these cores only execute a confirmed write.
|
|
*/
|
|
import { createHash } from "node:crypto";
|
|
|
|
import type { MeasurementType } from "@/generated/prisma/client";
|
|
import { prisma } from "@/lib/db";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import { encryptNote, readNote } from "@/lib/crypto/note-cipher";
|
|
import { annotate, getEvent } from "@/lib/logging/context";
|
|
import {
|
|
getUnitForType,
|
|
validateMeasurementRange,
|
|
} from "@/lib/validations/measurement";
|
|
import { isTelegramCapturableType } from "@/lib/measurements/create-from-telegram";
|
|
import {
|
|
invalidateUserMeasurements,
|
|
invalidateUserMood,
|
|
} from "@/lib/cache/invalidate";
|
|
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
|
|
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
|
import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels";
|
|
import { getScoreForMood } from "@/lib/validations/moodlog";
|
|
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
|
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
|
|
|
|
/** The MCP write provenance — distinct from MANUAL / TELEGRAM. */
|
|
const MCP_SOURCE = "MCP" as const;
|
|
|
|
/**
|
|
* Derive the stable per-write `externalId` from the caller's idempotency key.
|
|
* A SHA-256 hash keeps the column bounded and opaque regardless of how long
|
|
* the assistant's key is, and gives the MCP writes their own dedup namespace
|
|
* `(userId, type, MCP, externalId)` so a key can never collide with a
|
|
* Telegram or manual row.
|
|
*/
|
|
function mcpExternalId(
|
|
prefix: "measure" | "mood",
|
|
idempotencyKey: string,
|
|
): string {
|
|
const digest = createHash("sha256").update(idempotencyKey).digest("hex");
|
|
return `mcp:${prefix}:${digest}`;
|
|
}
|
|
|
|
/** Normalized record echoed back to the assistant (preview + commit result). */
|
|
export interface McpMeasurementRecord {
|
|
type: MeasurementType;
|
|
value: number;
|
|
unit: string;
|
|
measuredAt: string;
|
|
source: typeof MCP_SOURCE;
|
|
}
|
|
|
|
export type McpMeasurementResult =
|
|
| { status: "unsupported_type" }
|
|
| { status: "out_of_range"; reason: string }
|
|
| { status: "written"; measurement: McpMeasurementRecord }
|
|
| { status: "already_logged"; measurement: McpMeasurementRecord };
|
|
|
|
/**
|
|
* Log one single-value measurement on behalf of the resolved MCP session.
|
|
*
|
|
* Mirrors `logTelegramMeasurement`: gates the type on the SAME
|
|
* `isTelegramCapturableType` allowlist (the safe, user-loggable subset that
|
|
* excludes COMPUTED / WHOOP / clinical-only types), validates the plausibility
|
|
* range, and is idempotent on `(userId, type, MCP, externalId)`.
|
|
*/
|
|
export async function logMcpMeasurement(input: {
|
|
userId: string;
|
|
type: MeasurementType;
|
|
value: number;
|
|
unit?: string;
|
|
measuredAt?: Date;
|
|
idempotencyKey: string;
|
|
}): Promise<McpMeasurementResult> {
|
|
if (!isTelegramCapturableType(input.type)) {
|
|
annotate({
|
|
action: { name: "mcp.tool.write" },
|
|
meta: { tool: "log_measurement", status: "unsupported_type" },
|
|
});
|
|
return { status: "unsupported_type" };
|
|
}
|
|
|
|
const rangeError = validateMeasurementRange(input.type, input.value);
|
|
if (rangeError !== null) {
|
|
annotate({
|
|
action: { name: "mcp.tool.write" },
|
|
meta: { tool: "log_measurement", status: "out_of_range" },
|
|
});
|
|
return { status: "out_of_range", reason: rangeError };
|
|
}
|
|
|
|
const unit = input.unit ?? getUnitForType(input.type);
|
|
const measuredAt = input.measuredAt ?? new Date();
|
|
const externalId = mcpExternalId("measure", input.idempotencyKey);
|
|
|
|
const record: McpMeasurementRecord = {
|
|
type: input.type,
|
|
value: input.value,
|
|
unit,
|
|
measuredAt: measuredAt.toISOString(),
|
|
source: MCP_SOURCE,
|
|
};
|
|
|
|
const existing = await prisma.measurement.findUnique({
|
|
where: {
|
|
userId_type_source_externalId: {
|
|
userId: input.userId,
|
|
type: input.type,
|
|
source: MCP_SOURCE,
|
|
externalId,
|
|
},
|
|
},
|
|
select: { id: true, value: true, unit: true, measuredAt: true },
|
|
});
|
|
if (existing) {
|
|
annotate({
|
|
action: { name: "mcp.tool.write" },
|
|
meta: { tool: "log_measurement", status: "already_logged" },
|
|
});
|
|
return {
|
|
status: "already_logged",
|
|
measurement: {
|
|
type: input.type,
|
|
value: existing.value,
|
|
unit: existing.unit ?? unit,
|
|
measuredAt: existing.measuredAt.toISOString(),
|
|
source: MCP_SOURCE,
|
|
},
|
|
};
|
|
}
|
|
|
|
// No mass assignment — every column is set explicitly from validated input.
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: input.userId,
|
|
type: input.type,
|
|
value: input.value,
|
|
unit,
|
|
source: MCP_SOURCE,
|
|
measuredAt,
|
|
externalId,
|
|
},
|
|
});
|
|
|
|
invalidateUserMeasurements(input.userId, { evict: true });
|
|
|
|
// Best-effort rollup refresh — a cache tier, never a write-path invariant.
|
|
try {
|
|
await recomputeBucketsForMeasurement(input.userId, input.type, measuredAt);
|
|
} catch (rollupErr) {
|
|
getEvent()?.addMeta(
|
|
"mcp_measurement_rollup_failed",
|
|
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
|
|
);
|
|
}
|
|
|
|
await auditLog("mcp.write.measurement", {
|
|
userId: input.userId,
|
|
details: {
|
|
type: input.type,
|
|
source: MCP_SOURCE,
|
|
idempotencyKey: input.idempotencyKey,
|
|
},
|
|
});
|
|
annotate({
|
|
action: { name: "mcp.tool.write" },
|
|
meta: { tool: "log_measurement", status: "written" },
|
|
});
|
|
|
|
return { status: "written", measurement: record };
|
|
}
|
|
|
|
/** Normalized mood record echoed back to the assistant. */
|
|
export interface McpMoodRecord {
|
|
score: number;
|
|
mood: string;
|
|
note: string | null;
|
|
date: string;
|
|
source: typeof MCP_SOURCE;
|
|
}
|
|
|
|
export type McpMoodResult =
|
|
| { status: "invalid_score" }
|
|
| { status: "written"; moodEntry: McpMoodRecord }
|
|
| { status: "already_logged"; moodEntry: McpMoodRecord };
|
|
|
|
/**
|
|
* Log one mood entry on behalf of the resolved MCP session.
|
|
*
|
|
* Mirrors `logTelegramMood`: maps the 1..5 score to the canonical mood enum,
|
|
* anchors the date key in the user's timezone, and is idempotent on the
|
|
* NULL-distinct `(userId, source, externalId)` unique with `source = "MCP"`.
|
|
*/
|
|
export async function logMcpMood(input: {
|
|
userId: string;
|
|
score: number;
|
|
note?: string | null;
|
|
tz?: string | null;
|
|
idempotencyKey: string;
|
|
}): Promise<McpMoodResult> {
|
|
const mood = MOOD_ENUM_BY_SCORE[input.score];
|
|
if (!mood) {
|
|
annotate({
|
|
action: { name: "mcp.tool.write" },
|
|
meta: { tool: "log_mood", status: "invalid_score" },
|
|
});
|
|
return { status: "invalid_score" };
|
|
}
|
|
|
|
const score = getScoreForMood(mood);
|
|
const note = input.note ? input.note.slice(0, 500) : null;
|
|
const moodLoggedAt = new Date();
|
|
const tz = input.tz ?? DEFAULT_TIMEZONE;
|
|
const date = moodDateKey(moodLoggedAt, tz);
|
|
const externalId = mcpExternalId("mood", input.idempotencyKey);
|
|
|
|
const existing = await prisma.moodEntry.findUnique({
|
|
where: {
|
|
userId_source_externalId: {
|
|
userId: input.userId,
|
|
source: MCP_SOURCE,
|
|
externalId,
|
|
},
|
|
},
|
|
select: {
|
|
mood: true,
|
|
score: true,
|
|
note: true,
|
|
noteEncrypted: true,
|
|
date: true,
|
|
},
|
|
});
|
|
if (existing) {
|
|
annotate({
|
|
action: { name: "mcp.tool.write" },
|
|
meta: { tool: "log_mood", status: "already_logged" },
|
|
});
|
|
return {
|
|
status: "already_logged",
|
|
moodEntry: {
|
|
score: existing.score,
|
|
mood: existing.mood,
|
|
note: readNote(existing.noteEncrypted, existing.note),
|
|
date: existing.date,
|
|
source: MCP_SOURCE,
|
|
},
|
|
};
|
|
}
|
|
|
|
// No mass assignment — every column is set explicitly from validated input.
|
|
// v1.23 — encrypt the note at rest; legacy plaintext column nulled.
|
|
const entry = await prisma.moodEntry.create({
|
|
data: {
|
|
userId: input.userId,
|
|
date,
|
|
tz,
|
|
mood,
|
|
score,
|
|
note: null,
|
|
noteEncrypted: encryptNote(note),
|
|
source: MCP_SOURCE,
|
|
externalId,
|
|
moodLoggedAt,
|
|
},
|
|
select: {
|
|
date: true,
|
|
mood: true,
|
|
note: true,
|
|
noteEncrypted: true,
|
|
tags: true,
|
|
},
|
|
});
|
|
|
|
const savedNote = readNote(entry.noteEncrypted, entry.note);
|
|
|
|
invalidateUserMood(input.userId);
|
|
|
|
// Best-effort rollup refresh — a cache tier, never a write-path invariant.
|
|
try {
|
|
await recomputeMoodBucketsForEntry(input.userId, moodLoggedAt);
|
|
} catch (rollupErr) {
|
|
getEvent()?.addMeta(
|
|
"mcp_mood_rollup_failed",
|
|
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
|
|
);
|
|
}
|
|
|
|
// Reverse-sync to MoodLog (fire-and-forget; never throws).
|
|
void pushMoodEntriesToMoodLog(input.userId, [
|
|
{
|
|
date: entry.date,
|
|
moodLoggedAt,
|
|
mood: entry.mood,
|
|
note: savedNote,
|
|
tags: entry.tags,
|
|
source: MCP_SOURCE,
|
|
},
|
|
]).catch(() => {});
|
|
|
|
await auditLog("mcp.write.mood", {
|
|
userId: input.userId,
|
|
details: { source: MCP_SOURCE, idempotencyKey: input.idempotencyKey },
|
|
});
|
|
annotate({
|
|
action: { name: "mcp.tool.write" },
|
|
meta: { tool: "log_mood", status: "written" },
|
|
});
|
|
|
|
return {
|
|
status: "written",
|
|
moodEntry: {
|
|
score,
|
|
mood: entry.mood,
|
|
note: savedNote,
|
|
date: entry.date,
|
|
source: MCP_SOURCE,
|
|
},
|
|
};
|
|
}
|