From 6de61de010a6d004d08f963c8e15edf35b9ad6e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 17:38:42 +0200 Subject: [PATCH] feat(mcp): add a confirmed, audience-bound write surface for logging measurements and mood MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/mcp/__tests__/route.test.ts | 41 ++- src/app/mcp/route.ts | 13 +- src/lib/__tests__/require-auth-bearer.test.ts | 27 +- src/lib/api-handler.ts | 34 +- src/lib/mcp/__tests__/mcp-audience.test.ts | 45 +++ src/lib/mcp/__tests__/scopes.test.ts | 25 ++ .../server-write-registration.test.ts | 62 ++++ src/lib/mcp/__tests__/write-tools.test.ts | 203 +++++++++++ src/lib/mcp/__tests__/writes.test.ts | 195 +++++++++++ src/lib/mcp/index.ts | 7 + src/lib/mcp/oauth/config.ts | 12 +- src/lib/mcp/server.ts | 34 +- src/lib/mcp/write-tools.ts | 261 +++++++++++++++ src/lib/mcp/writes.ts | 316 ++++++++++++++++++ src/lib/rate-limit.ts | 22 ++ 15 files changed, 1263 insertions(+), 34 deletions(-) create mode 100644 src/lib/mcp/__tests__/mcp-audience.test.ts create mode 100644 src/lib/mcp/__tests__/scopes.test.ts create mode 100644 src/lib/mcp/__tests__/server-write-registration.test.ts create mode 100644 src/lib/mcp/__tests__/write-tools.test.ts create mode 100644 src/lib/mcp/__tests__/writes.test.ts create mode 100644 src/lib/mcp/write-tools.ts create mode 100644 src/lib/mcp/writes.ts diff --git a/src/app/mcp/__tests__/route.test.ts b/src/app/mcp/__tests__/route.test.ts index 52784e2f9..286daab63 100644 --- a/src/app/mcp/__tests__/route.test.ts +++ b/src/app/mcp/__tests__/route.test.ts @@ -288,7 +288,7 @@ describe("/mcp — rate limit", () => { }); }); -describe("/mcp — admin unreachable over HTTP (read-only, cookie-only boundary)", () => { +describe("/mcp — admin unreachable over HTTP (cookie-only boundary)", () => { it("never consults a cookie session, even with a wildcard token", async () => { validToken(["*"]); const res = await POST(postRpc(TOOLS_LIST)); @@ -299,12 +299,16 @@ describe("/mcp — admin unreachable over HTTP (read-only, cookie-only boundary) (t) => t.name, ); - // A `["*"]` token still sees ONLY the read tools — no write tool is - // registered and no admin tool exists (read-only by construction). - expect(names.sort()).toEqual(READ_TOOLS); + // A `["*"]` token carries write capability (`tokenAllowsWrite`), so it + // sees the confirmed write tools alongside the read tools — but NO admin + // tool exists, and admin stays unreachable here by construction. + for (const read of READ_TOOLS) { + expect(names).toContain(read); + } + expect(names).toContain("log_measurement"); + expect(names).toContain("log_mood"); for (const name of names) { expect(name).not.toMatch(/admin/i); - expect(name).not.toMatch(/^(log_|create_|update_|delete_|write_)/); } // The route authenticates purely by Bearer: a cookie session is never @@ -314,6 +318,33 @@ describe("/mcp — admin unreachable over HTTP (read-only, cookie-only boundary) }); }); +describe("/mcp — write tools gate on health:write", () => { + it("a health:read token lists NO write tools", async () => { + validToken(["health:read"]); + const res = await POST(postRpc(TOOLS_LIST)); + expect(res.status).toBe(200); + const body = await res.json(); + const names = (body.result.tools as Array<{ name: string }>).map( + (t) => t.name, + ); + expect(names.sort()).toEqual(READ_TOOLS); + expect(names).not.toContain("log_measurement"); + expect(names).not.toContain("log_mood"); + }); + + it("a health:read+write token can list (and thus invoke) the write tools", async () => { + validToken(["health:read", "health:write"]); + const res = await POST(postRpc(TOOLS_LIST)); + expect(res.status).toBe(200); + const body = await res.json(); + const names = (body.result.tools as Array<{ name: string }>).map( + (t) => t.name, + ); + expect(names).toContain("log_measurement"); + expect(names).toContain("log_mood"); + }); +}); + describe("/mcp — GET transport", () => { it("is wired for GET (SSE stream open) behind the same gates", async () => { validToken(); diff --git a/src/app/mcp/route.ts b/src/app/mcp/route.ts index 80bf1a323..db3b25980 100644 --- a/src/app/mcp/route.ts +++ b/src/app/mcp/route.ts @@ -31,11 +31,14 @@ * (`sessionIdGenerator: undefined`, `enableJsonResponse: true`), torn * down once the buffered JSON-RPC response is built. * - * Read-only: the factory registers read tools only and `requireAdmin()` is - * cookie-only, so neither writes nor admin can be reached over this wire - * regardless of token scope — including `["*"]` (ADR-003 / ADR-005). The - * `health:write` scope stays reserved and unreachable until the write - * tools land in a later phase. + * Write surface: the factory registers the confirmed write tools + * (`log_measurement` / `log_mood`) ONLY for a `health:write`-scoped session; + * a read-only token never sees them. Writes happen in-process here and are + * audience-bound to `/mcp` — a `health:write` MCP token is refused on every + * REST write/delete by the resource-server guard in `api-handler.ts`, so it + * can never become a general REST write credential. `requireAdmin()` stays + * cookie-only, so admin is unreachable over this wire regardless of token + * scope — including `["*"]` (ADR-003 / ADR-005). * * Same-origin / no-CORS by construction: no `Access-Control-Allow-Origin` * header is emitted anywhere here, matching the app-wide posture. diff --git a/src/lib/__tests__/require-auth-bearer.test.ts b/src/lib/__tests__/require-auth-bearer.test.ts index f1688bba0..7f5d2cb2e 100644 --- a/src/lib/__tests__/require-auth-bearer.test.ts +++ b/src/lib/__tests__/require-auth-bearer.test.ts @@ -273,7 +273,7 @@ describe("requireAuth — Bearer token path", () => { describe("requireAuth — H1 MCP health:read audience binding on REST", () => { // Drive the FULL apiHandler path so the request method is in event context — // the H1 check reads it to allow safe reads but block writes. - const route = apiHandler(async (_req: Request) => { + const route = apiHandler(async () => { await requireAuth(); return Response.json({ ok: true }); }); @@ -340,7 +340,10 @@ describe("requireAuth — H1 MCP health:read audience binding on REST", () => { expect(res.status).toBe(200); }); - it("does NOT restrict a health:read+write token on POST", async () => { + it("rejects a health:read+write token on POST with 403 (writes are /mcp-only)", async () => { + // A write-scoped MCP token is STILL audience-bound: its `health:write` + // grant admits the confirmed in-process `/mcp` write tools, never a REST + // write/delete. So it gets the same 403 as a read-only MCP token here. vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ id: "rw", userId: "user-1", @@ -349,6 +352,26 @@ describe("requireAuth — H1 MCP health:read audience binding on REST", () => { expiresAt: null, } as never); const res = await call("POST"); + expect(res.status).toBe(403); + expect(auditLog).toHaveBeenCalledWith( + "auth.bearer.failure", + expect.objectContaining({ + details: expect.objectContaining({ + reason: "mcp_audience_write_blocked", + }), + }), + ); + }); + + it("accepts a health:read+write token on a GET read route", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "rw", + userId: "user-1", + permissions: ["health:read", "health:write"], + revoked: false, + expiresAt: null, + } as never); + const res = await call("GET"); expect(res.status).toBe(200); }); }); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 0e59c1c84..04db41dfb 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -10,7 +10,7 @@ import { auditLog } from "./auth/audit"; import { resolveBearerToken, BearerAuthError } from "./auth/bearer"; import { AssistantDisabledError } from "./feature-flags"; import { ConsentRequiredError } from "./ai/consent-guard"; -import { SCOPE_HEALTH_READ } from "./mcp/oauth/config"; +import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config"; /** * HTTP methods a read-only credential may use on the REST surface. A request @@ -20,15 +20,20 @@ const READ_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); /** * Whether a token is MCP-audience-bound (H1). The MCP OAuth bridge and the - * connector settings card mint tokens whose ONLY grant is `health:read`. Such a - * token is bound to the read surface: the `/mcp` resolver accepts it, and so do - * safe (read) REST methods, but it must never reach a REST write/delete. A token - * carrying any broader or legacy grant (`*`, `medication:ingest`, an explicit - * `health:write`, …) is NOT MCP-audience-bound and keeps its existing reach. + * connector settings card mint tokens whose ONLY grants are `health:read` — + * and, when the user consents to logging, `health:read health:write`. Either + * shape is bound to the MCP surface: the `/mcp` resolver accepts it and so do + * safe (read) REST methods, but it must NEVER reach a REST write/delete. The + * `health:write` grant admits writes ONLY in-process over `/mcp` (the confirmed + * write tools), never over REST — so a write-scoped MCP token is exactly as + * audience-bound on this edge as a read-only one. A token carrying any broader + * or legacy grant (`*`, `medication:ingest`, …) is NOT MCP-audience-bound and + * keeps its existing reach. */ -function isMcpAudienceToken(permissions: readonly string[]): boolean { - return ( - permissions.length > 0 && permissions.every((p) => p === SCOPE_HEALTH_READ) +export function isMcpAudienceToken(permissions: readonly string[]): boolean { + if (permissions.length === 0) return false; + return permissions.every( + (p) => p === SCOPE_HEALTH_READ || p === SCOPE_HEALTH_WRITE, ); } @@ -354,10 +359,13 @@ async function authenticateBearer( const { user, tokenId, expiresAt, permissions } = resolution; - // H1 — audience binding at the resource server. A health:read-only token is - // the MCP read credential; it may reach `/mcp` (a separate resolver that - // never runs this edge) and safe REST reads, but a write/delete over REST is - // outside its audience and is refused. Fail closed when the method is unknown + // H1 — audience binding at the resource server. An MCP-audience token + // (`health:read`, or `health:read health:write`) is bound to the `/mcp` + // surface; it may reach `/mcp` (a separate resolver that never runs this + // edge) and safe REST reads, but a write/delete over REST is outside its + // audience and is refused — INCLUDING a write-scoped token, whose writes are + // confined to the in-process `/mcp` tools and never granted over REST. Fail + // closed when the method is unknown // (no event context) since every real REST request runs inside apiHandler, // which always sets the method — an unknown method means we cannot prove a // read, so we deny. This is RFC 8707 audience binding on the credential the diff --git a/src/lib/mcp/__tests__/mcp-audience.test.ts b/src/lib/mcp/__tests__/mcp-audience.test.ts new file mode 100644 index 000000000..031623bb4 --- /dev/null +++ b/src/lib/mcp/__tests__/mcp-audience.test.ts @@ -0,0 +1,45 @@ +/** + * Guard: the resource-server audience binding (H1). An MCP-audience token + * (`health:read`, or `health:read health:write`) is bound to the `/mcp` + * surface and refused on every REST write — a `health:write` MCP token can + * never become a general REST write credential. A wildcard or any broader / + * legacy grant is NOT audience-bound and keeps its existing reach. + */ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ get: () => undefined })), +})); + +import { isMcpAudienceToken } from "@/lib/api-handler"; + +describe("isMcpAudienceToken", () => { + it("read-only token IS audience-bound", () => { + expect(isMcpAudienceToken(["health:read"])).toBe(true); + }); + it("read+write token IS audience-bound (writes confined to /mcp)", () => { + expect(isMcpAudienceToken(["health:read", "health:write"])).toBe(true); + }); + it("write-only token IS audience-bound", () => { + expect(isMcpAudienceToken(["health:write"])).toBe(true); + }); + it("wildcard token is NOT audience-bound (keeps its reach)", () => { + expect(isMcpAudienceToken(["*"])).toBe(false); + }); + it("a mixed legacy grant is NOT audience-bound", () => { + expect(isMcpAudienceToken(["health:read", "medication:ingest"])).toBe( + false, + ); + }); + it("an empty scope set is NOT audience-bound", () => { + expect(isMcpAudienceToken([])).toBe(false); + }); +}); diff --git a/src/lib/mcp/__tests__/scopes.test.ts b/src/lib/mcp/__tests__/scopes.test.ts new file mode 100644 index 000000000..0a10753c0 --- /dev/null +++ b/src/lib/mcp/__tests__/scopes.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; + +import { + tokenAllowsWrite, + SCOPE_HEALTH_READ, + SCOPE_HEALTH_WRITE, + SCOPE_WILDCARD, +} from "../scopes"; + +describe("tokenAllowsWrite", () => { + it("is false for a read-only token", () => { + expect(tokenAllowsWrite([SCOPE_HEALTH_READ])).toBe(false); + }); + it("is true for a read+write token", () => { + expect(tokenAllowsWrite([SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE])).toBe( + true, + ); + }); + it("is true for the wildcard token", () => { + expect(tokenAllowsWrite([SCOPE_WILDCARD])).toBe(true); + }); + it("is false for an empty scope set", () => { + expect(tokenAllowsWrite([])).toBe(false); + }); +}); diff --git a/src/lib/mcp/__tests__/server-write-registration.test.ts b/src/lib/mcp/__tests__/server-write-registration.test.ts new file mode 100644 index 000000000..a78430431 --- /dev/null +++ b/src/lib/mcp/__tests__/server-write-registration.test.ts @@ -0,0 +1,62 @@ +/** + * Guard: the confirmed write tools are advertised ONLY to a write-scoped + * session. A read-only (`health:read`) session must see zero write tools — the + * read-only-by-default posture is structural, not a runtime flag. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { createMcpServer } from "../server"; +import { MCP_WRITE_TOOL_NAMES } from "../write-tools"; +import type { McpAuthContext } from "../auth"; + +function ctx(canWrite: boolean): McpAuthContext { + return { + userId: "u-1", + tokenId: "t-1", + scopes: canWrite ? ["health:read", "health:write"] : ["health:read"], + binding: "u-1:t-1", + canRead: true, + canWrite, + }; +} + +let registered: string[]; + +beforeEach(() => { + registered = []; + vi.spyOn(McpServer.prototype, "registerTool").mockImplementation(function ( + this: unknown, + name: string, + ) { + registered.push(name); + return {} as never; + }); + // Prompts / resources registration is irrelevant here — stub to no-ops. + vi.spyOn(McpServer.prototype, "registerPrompt").mockReturnValue({} as never); + vi.spyOn(McpServer.prototype, "registerResource").mockReturnValue( + {} as never, + ); +}); + +describe("write-tool registration gating", () => { + it("a read-only session sees NO write tools", () => { + createMcpServer(ctx(false)); + for (const name of MCP_WRITE_TOOL_NAMES) { + expect(registered).not.toContain(name); + } + // It still registers read tools. + expect(registered).toContain("list_metrics"); + }); + + it("a write-scoped session sees the write tools", () => { + createMcpServer(ctx(true)); + for (const name of MCP_WRITE_TOOL_NAMES) { + expect(registered).toContain(name); + } + expect(registered).toContain("log_measurement"); + expect(registered).toContain("log_mood"); + }); +}); diff --git a/src/lib/mcp/__tests__/write-tools.test.ts b/src/lib/mcp/__tests__/write-tools.test.ts new file mode 100644 index 000000000..a4f5f5315 --- /dev/null +++ b/src/lib/mcp/__tests__/write-tools.test.ts @@ -0,0 +1,203 @@ +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; + + 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; + + 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; + 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; + 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; + 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; + 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; + 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; + expect(result.written).toBe(false); + expect(logMcpMood).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/mcp/__tests__/writes.test.ts b/src/lib/mcp/__tests__/writes.test.ts new file mode 100644 index 000000000..6019d3dfd --- /dev/null +++ b/src/lib/mcp/__tests__/writes.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { measurement, moodEntry } = vi.hoisted(() => ({ + measurement: { + findUnique: vi.fn(), + create: vi.fn(), + }, + moodEntry: { + findUnique: vi.fn(), + create: vi.fn(), + }, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { measurement, moodEntry }, +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMeasurements: vi.fn(), + invalidateUserMood: vi.fn(), +})); + +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rollups/mood-rollups", () => ({ + recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/moodlog/push", () => ({ + pushMoodEntriesToMoodLog: vi.fn().mockResolvedValue(undefined), +})); + +const auditLog = vi.fn().mockResolvedValue(undefined); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: (...args: unknown[]) => auditLog(...args), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: () => ({ addMeta: vi.fn() }), +})); + +import { logMcpMeasurement, logMcpMood } from "../writes"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; + +beforeEach(() => { + vi.clearAllMocks(); + measurement.findUnique.mockResolvedValue(null); + measurement.create.mockResolvedValue({ id: "m-1" }); + moodEntry.findUnique.mockResolvedValue(null); + moodEntry.create.mockResolvedValue({ + date: "2026-06-27", + mood: "GUT", + note: null, + tags: [], + }); +}); + +describe("logMcpMeasurement", () => { + it("writes one row with source MCP for a capturable type", async () => { + const result = await logMcpMeasurement({ + userId: "u-1", + type: "WEIGHT", + value: 80, + idempotencyKey: "key-1", + }); + + expect(result.status).toBe("written"); + expect(measurement.create).toHaveBeenCalledTimes(1); + const data = measurement.create.mock.calls[0][0].data; + expect(data.source).toBe("MCP"); + expect(data.userId).toBe("u-1"); + expect(data.type).toBe("WEIGHT"); + expect(data.value).toBe(80); + expect(data.unit).toBe("kg"); + // externalId is namespaced + hashed (never the raw key). + expect(data.externalId).toMatch(/^mcp:measure:[0-9a-f]{64}$/); + expect(data.externalId).not.toContain("key-1"); + expect(invalidateUserMeasurements).toHaveBeenCalledWith("u-1", { + evict: true, + }); + expect(auditLog).toHaveBeenCalledWith( + "mcp.write.measurement", + expect.objectContaining({ userId: "u-1" }), + ); + }); + + it("refuses a non-capturable / clinical-only type without writing", async () => { + const result = await logMcpMeasurement({ + userId: "u-1", + type: "RECOVERY_SCORE" as never, + value: 50, + idempotencyKey: "key-x", + }); + expect(result.status).toBe("unsupported_type"); + expect(measurement.create).not.toHaveBeenCalled(); + }); + + it("refuses an out-of-range value without writing", async () => { + const result = await logMcpMeasurement({ + userId: "u-1", + type: "WEIGHT", + value: 99999, + idempotencyKey: "key-y", + }); + expect(result.status).toBe("out_of_range"); + expect(measurement.create).not.toHaveBeenCalled(); + }); + + it("is idempotent — a second call with the same key writes nothing", async () => { + measurement.findUnique.mockResolvedValue({ + id: "m-1", + value: 80, + unit: "kg", + measuredAt: new Date("2026-06-27T08:00:00Z"), + }); + const result = await logMcpMeasurement({ + userId: "u-1", + type: "WEIGHT", + value: 80, + idempotencyKey: "key-1", + }); + expect(result.status).toBe("already_logged"); + expect(measurement.create).not.toHaveBeenCalled(); + }); + + it("derives the same externalId for the same idempotencyKey", async () => { + await logMcpMeasurement({ + userId: "u-1", + type: "PULSE", + value: 60, + idempotencyKey: "stable", + }); + const first = measurement.create.mock.calls[0][0].data.externalId; + vi.clearAllMocks(); + measurement.findUnique.mockResolvedValue(null); + measurement.create.mockResolvedValue({ id: "m-2" }); + await logMcpMeasurement({ + userId: "u-1", + type: "PULSE", + value: 61, + idempotencyKey: "stable", + }); + const second = measurement.create.mock.calls[0][0].data.externalId; + expect(first).toBe(second); + }); +}); + +describe("logMcpMood", () => { + it("writes one mood row with source MCP", async () => { + const result = await logMcpMood({ + userId: "u-1", + score: 4, + idempotencyKey: "mood-1", + }); + expect(result.status).toBe("written"); + expect(moodEntry.create).toHaveBeenCalledTimes(1); + const data = moodEntry.create.mock.calls[0][0].data; + expect(data.source).toBe("MCP"); + expect(data.userId).toBe("u-1"); + expect(data.externalId).toMatch(/^mcp:mood:[0-9a-f]{64}$/); + expect(auditLog).toHaveBeenCalledWith( + "mcp.write.mood", + expect.objectContaining({ userId: "u-1" }), + ); + }); + + it("rejects an out-of-band score without writing", async () => { + const result = await logMcpMood({ + userId: "u-1", + score: 9, + idempotencyKey: "mood-bad", + }); + expect(result.status).toBe("invalid_score"); + expect(moodEntry.create).not.toHaveBeenCalled(); + }); + + it("is idempotent on the same key", async () => { + moodEntry.findUnique.mockResolvedValue({ + mood: "GUT", + score: 4, + note: null, + date: "2026-06-27", + }); + const result = await logMcpMood({ + userId: "u-1", + score: 4, + idempotencyKey: "mood-1", + }); + expect(result.status).toBe("already_logged"); + expect(moodEntry.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/mcp/index.ts b/src/lib/mcp/index.ts index da135b81b..1302cec27 100644 --- a/src/lib/mcp/index.ts +++ b/src/lib/mcp/index.ts @@ -10,6 +10,13 @@ export { createMcpServer, MCP_SERVER_NAME, MCP_SERVER_VERSION } from "./server"; export { resolveMcpAuthContext, type McpAuthContext } from "./auth"; export { MCP_TOOLS, MCP_TOOL_NAMES, type McpToolDefinition } from "./tools"; +export { MCP_WRITE_TOOLS, MCP_WRITE_TOOL_NAMES } from "./write-tools"; +export { + logMcpMeasurement, + logMcpMood, + type McpMeasurementResult, + type McpMoodResult, +} from "./writes"; export { MCP_RESOURCES, MCP_RESOURCE_URIS, diff --git a/src/lib/mcp/oauth/config.ts b/src/lib/mcp/oauth/config.ts index d0597af59..2fc116c4b 100644 --- a/src/lib/mcp/oauth/config.ts +++ b/src/lib/mcp/oauth/config.ts @@ -114,9 +114,17 @@ export function audienceMatches( return want === got; } -/** The single scope the read MCP surface issues. */ +/** The read scope the MCP surface issues — the default posture. */ export const SCOPE_HEALTH_READ = "health:read"; +/** + * The write scope. Granted only on explicit request + consent; it admits the + * confirmed `/mcp` write tools. A token carrying it is STILL MCP-audience-bound + * (see `isMcpAudienceToken`) — it can never become a general REST write + * credential. + */ +export const SCOPE_HEALTH_WRITE = "health:write"; + /** * Scopes the AS advertises + will issue. `health:write` is advertised so a * future incremental-consent (SEP-835) challenge can request it, but the read @@ -125,7 +133,7 @@ export const SCOPE_HEALTH_READ = "health:read"; */ export const SUPPORTED_SCOPES = [ SCOPE_HEALTH_READ, - "health:write", + SCOPE_HEALTH_WRITE, "offline_access", ] as const; diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts index 92e8b6bf8..67b1d6872 100644 --- a/src/lib/mcp/server.ts +++ b/src/lib/mcp/server.ts @@ -8,14 +8,18 @@ * `createMcpServer(ctx)` and then attach their own transport, so the tool / * resource surface is identical across wires (ADR-002). * - * Only read tools are registered (REQ-SEC-1 / ADR-003). There is no admin - * surface here and none can be added structurally — `requireAdmin()` is - * cookie-only and the MCP context carries no cookie (REQ-SEC-7 / ADR-005). + * Read tools are always registered. The write tools (`log_measurement` / + * `log_mood`) are registered ONLY for a `health:write`-scoped session + * (`ctx.canWrite`); a read-only session never has them advertised, so the + * read-only-by-default posture is structural (REQ-SEC-1 / ADR-003). There is + * no admin surface here and none can be added structurally — `requireAdmin()` + * is cookie-only and the MCP context carries no cookie (REQ-SEC-7 / ADR-005). */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { McpAuthContext } from "./auth"; -import { MCP_TOOLS } from "./tools"; +import { MCP_TOOLS, type McpToolDefinition } from "./tools"; +import { MCP_WRITE_TOOLS } from "./write-tools"; import { MCP_RESOURCES } from "./resources"; import { MCP_PROMPTS } from "./prompts"; @@ -33,7 +37,9 @@ export function createMcpServer(ctx: McpAuthContext): McpServer { version: MCP_SERVER_VERSION, }); - for (const tool of MCP_TOOLS) { + // Register one tool definition. Shared by the read registry and the + // write-scoped registry so the wire shape can never fork between them. + const registerTool = (tool: McpToolDefinition) => { server.registerTool( tool.name, { @@ -41,8 +47,9 @@ export function createMcpServer(ctx: McpAuthContext): McpServer { description: tool.description, inputSchema: tool.inputShape, // Annotations are MANDATORY for the cloud connectors — the ChatGPT - // Apps SDK treats an omitted hint as a validation error. Every read - // tool is read-only, non-destructive, closed-world (ADR-003). + // Apps SDK treats an omitted hint as a validation error. Read tools + // are read-only/non-destructive; write tools advertise + // `readOnlyHint:false` so a host can add human-in-the-loop (ADR-003). annotations: tool.annotations, ...(tool.outputShape ? { outputSchema: tool.outputShape } : {}), }, @@ -63,6 +70,19 @@ export function createMcpServer(ctx: McpAuthContext): McpServer { return { content }; }, ); + }; + + for (const tool of MCP_TOOLS) { + registerTool(tool); + } + + // Write tools are advertised ONLY to a write-scoped session. A read-only + // (`health:read`) token never sees them in the capability list — the + // read-only posture is structural, not a runtime flag a tool could flip. + if (ctx.canWrite) { + for (const tool of MCP_WRITE_TOOLS) { + registerTool(tool); + } } for (const prompt of MCP_PROMPTS) { diff --git a/src/lib/mcp/write-tools.ts b/src/lib/mcp/write-tools.ts new file mode 100644 index 000000000..eb1497ad2 --- /dev/null +++ b/src/lib/mcp/write-tools.ts @@ -0,0 +1,261 @@ +/** + * 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. 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. + * + * PROTOCOL-LEVEL CONFIRM GATE. 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. + * + * 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 } 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 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 { + const rl = await checkMcpWriteRateLimit(binding); + if (!rl.allowed) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "write", status: "rate_limited" }, + }); + } + return rl.allowed; +} + +export const MCP_WRITE_TOOLS: 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, + 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) { + 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, + }; + } + + 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, + 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 }; + }, + }, +]; + +/** Stable list of the registered write-tool names. */ +export const MCP_WRITE_TOOL_NAMES: readonly string[] = MCP_WRITE_TOOLS.map( + (t) => t.name, +); diff --git a/src/lib/mcp/writes.ts b/src/lib/mcp/writes.ts new file mode 100644 index 000000000..11e26bcf8 --- /dev/null +++ b/src/lib/mcp/writes.ts @@ -0,0 +1,316 @@ +/** + * 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 { 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 { + 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 { + 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, 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: existing.note ?? null, + date: existing.date, + source: MCP_SOURCE, + }, + }; + } + + // No mass assignment — every column is set explicitly from validated input. + const entry = await prisma.moodEntry.create({ + data: { + userId: input.userId, + date, + tz, + mood, + score, + note, + source: MCP_SOURCE, + externalId, + moodLoggedAt, + }, + select: { date: true, mood: true, note: true, tags: true }, + }); + + 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: entry.note ?? null, + 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: entry.note ?? null, + date: entry.date, + source: MCP_SOURCE, + }, + }; +} diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 90f1c5968..d72de6da5 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -105,6 +105,28 @@ export async function checkMcpRateLimit( return checkRateLimit(`mcp:${binding}`, MCP_LIMIT, MCP_WINDOW_MS); } +/** + * v1.22.0 — tighter per-credential ceiling for the remote MCP WRITE surface + * (the `log_measurement` / `log_mood` tools), applied ON TOP of the general + * `/mcp` per-binding request limiter. Keyed by the same `:` + * binding so a single leaked / shared write-scoped token cannot flood writes + * into the account, and the cap is scoped to the exact credential in use. + * Writes are deliberate, low-frequency events, so this is far tighter than the + * read budget — 60 confirmed writes per 5 minutes only caps a runaway loop. + */ +const MCP_WRITE_LIMIT = 60; +const MCP_WRITE_WINDOW_MS = 5 * 60 * 1000; + +export async function checkMcpWriteRateLimit( + binding: string, +): Promise { + return checkRateLimit( + `mcp-write:${binding}`, + MCP_WRITE_LIMIT, + MCP_WRITE_WINDOW_MS, + ); +} + /** * v1.16.16 — per-user ceiling for the AI-consent surfaces * (`POST /api/consent/ai`, `POST /api/consent/ai/web`,