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`,