diff --git a/package.json b/package.json index 348c30ddf..dca61878d 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "e2e:ui": "playwright test --ui", "openapi:generate": "npx tsx scripts/generate-openapi.ts", "openapi:check": "npx tsx scripts/check-openapi.ts", - "check-env": "npx tsx scripts/check-env.ts" + "check-env": "npx tsx scripts/check-env.ts", + "mcp:stdio": "npx tsx src/cli/mcp-stdio.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.1048.0", @@ -55,6 +56,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.4.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@node-rs/argon2": "^2.0.2", "@parse/node-apn": "^8.1.0", "@prisma/adapter-pg": "^7.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 525664542..3d5b8b856 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@hookform/resolvers': specifier: ^5.4.0 version: 5.4.0(react-hook-form@7.78.0(react@19.2.7)) + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.4.3) '@node-rs/argon2': specifier: ^2.0.2 version: 2.0.2 @@ -7867,6 +7870,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.25) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.25 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@mswjs/interceptors@0.41.9': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -13839,6 +13864,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod-validation-error@4.0.2(zod@4.4.3): dependencies: zod: 4.4.3 diff --git a/src/cli/mcp-stdio.ts b/src/cli/mcp-stdio.ts new file mode 100644 index 000000000..880fc1efc --- /dev/null +++ b/src/cli/mcp-stdio.ts @@ -0,0 +1,65 @@ +/** + * Local stdio entrypoint for the HealthLog MCP server. + * + * A power-user runs this in a plain Node process and points an MCP client + * (Claude Desktop, etc.) at it. Authentication is a pasted, scoped `hlk_` + * Bearer token — no OAuth infrastructure (REQ-T1). The token is read from the + * `HEALTHLOG_MCP_TOKEN` environment variable (preferred — keeps the secret out + * of the process argument list) or, as a fallback, the first CLI argument. + * + * Off by default: with no token the server refuses to start. Running it with a + * minted token IS the opt-in. + * + * Run it with tsx (the standalone production image strips tsx, so this is a + * local-only tool): + * + * HEALTHLOG_MCP_TOKEN=hlk_... \ + * API_TOKEN_HMAC_KEY=... DATABASE_URL=... ENCRYPTION_KEYS=... \ + * pnpm dlx tsx src/cli/mcp-stdio.ts + * + * stdout is the JSON-RPC channel — every diagnostic goes to stderr so it can + * never corrupt the protocol stream. + */ +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +import { createMcpServer } from "@/lib/mcp/server"; +import { resolveMcpAuthContext } from "@/lib/mcp/auth"; + +function readToken(): string | null { + const fromEnv = process.env.HEALTHLOG_MCP_TOKEN?.trim(); + if (fromEnv) return fromEnv; + const fromArg = process.argv[2]?.trim(); + if (fromArg) return fromArg; + return null; +} + +async function main(): Promise { + const token = readToken(); + if (!token) { + console.error( + "HealthLog MCP: no token. Set HEALTHLOG_MCP_TOKEN (a scoped hlk_ Bearer token) or pass it as the first argument.", + ); + process.exit(1); + } + + let ctx; + try { + ctx = await resolveMcpAuthContext(token); + } catch { + // Do not echo the token or the underlying reason to stderr. + console.error("HealthLog MCP: token rejected."); + process.exit(1); + } + + const server = createMcpServer(ctx); + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error( + `HealthLog MCP server running on stdio (session ${ctx.binding}).`, + ); +} + +main().catch((error) => { + console.error("HealthLog MCP: fatal error during startup.", error); + process.exit(1); +}); diff --git a/src/lib/mcp/__tests__/admin-boundary.test.ts b/src/lib/mcp/__tests__/admin-boundary.test.ts new file mode 100644 index 000000000..e1f329aea --- /dev/null +++ b/src/lib/mcp/__tests__/admin-boundary.test.ts @@ -0,0 +1,67 @@ +/** + * Guard: the MCP / Bearer wire can never reach an admin surface (REQ-SEC-7, + * ADR-005). + * + * `requireAdmin()` authorises off a cookie session ONLY. The MCP transports + * authenticate by Bearer token and never mint a cookie session, so in an MCP + * process `getSession()` is always null and `requireAdmin()` always rejects — + * regardless of the token's scope (even `["*"]`). This test pins that structural + * boundary so a future change that softens it fails loudly. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + apiToken: { findUnique: vi.fn(), update: vi.fn() }, + user: { findUnique: vi.fn() }, + }, +})); +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() })); + +const headersGet = vi.fn<(name: string) => string | null>(); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: headersGet })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { requireAdmin } from "@/lib/api-handler"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("requireAdmin is cookie-only — MCP/Bearer cannot elevate", () => { + it("rejects with 401 when there is no cookie session (the MCP reality)", async () => { + vi.mocked(getSession).mockResolvedValue(null); + // Even with a Bearer header present, requireAdmin must not consult it. + headersGet.mockImplementation((name) => + name.toLowerCase() === "authorization" + ? `Bearer hlk_${"a".repeat(64)}` + : null, + ); + + await expect(requireAdmin()).rejects.toMatchObject({ statusCode: 401 }); + // The token table is never touched — admin auth ignores Bearer entirely. + expect(prisma.apiToken.findUnique).not.toHaveBeenCalled(); + }); + + it("rejects a non-admin cookie session with 403", async () => { + vi.mocked(getSession).mockResolvedValue({ + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) }, + user: { id: "user-1", role: "USER" }, + } as never); + + await expect(requireAdmin()).rejects.toMatchObject({ statusCode: 403 }); + }); +}); diff --git a/src/lib/mcp/__tests__/auth.test.ts b/src/lib/mcp/__tests__/auth.test.ts new file mode 100644 index 000000000..c73861031 --- /dev/null +++ b/src/lib/mcp/__tests__/auth.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/auth/bearer", () => ({ + resolveBearerToken: vi.fn(), +})); + +import { resolveMcpAuthContext } from "../auth"; +import { resolveBearerToken } from "@/lib/auth/bearer"; + +const FAKE_USER = { id: "user-1", role: "USER" } as never; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("resolveMcpAuthContext", () => { + it("binds the session to : (REQ-SEC-11) via the canonical Bearer path", async () => { + vi.mocked(resolveBearerToken).mockResolvedValue({ + user: FAKE_USER, + tokenId: "token-9", + permissions: ["health:read"], + expiresAt: new Date(), + }); + + const ctx = await resolveMcpAuthContext(" hlk_abc "); + + // Trimmed token resolved through the shared validator (no requiredPermission for reads). + expect(resolveBearerToken).toHaveBeenCalledWith("hlk_abc"); + expect(ctx.userId).toBe("user-1"); + expect(ctx.tokenId).toBe("token-9"); + expect(ctx.binding).toBe("user-1:token-9"); + expect(ctx.canRead).toBe(true); + expect(ctx.canWrite).toBe(false); + }); + + it("grants write capability only when the token carries health:write", async () => { + vi.mocked(resolveBearerToken).mockResolvedValue({ + user: FAKE_USER, + tokenId: "token-w", + permissions: ["health:write"], + expiresAt: new Date(), + }); + const ctx = await resolveMcpAuthContext("hlk_xyz"); + expect(ctx.canWrite).toBe(true); + }); + + it("rejects an empty token without calling the validator", async () => { + await expect(resolveMcpAuthContext(" ")).rejects.toThrow(); + expect(resolveBearerToken).not.toHaveBeenCalled(); + }); + + it("propagates a rejection from the Bearer validator (invalid / revoked / expired)", async () => { + vi.mocked(resolveBearerToken).mockRejectedValue(new Error("revoked")); + await expect(resolveMcpAuthContext("hlk_bad")).rejects.toThrow(); + }); +}); diff --git a/src/lib/mcp/__tests__/resources.test.ts b/src/lib/mcp/__tests__/resources.test.ts new file mode 100644 index 000000000..94b25e3f1 --- /dev/null +++ b/src/lib/mcp/__tests__/resources.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + medication: { findMany: vi.fn() }, + }, +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { MCP_RESOURCES, MCP_RESOURCE_URIS } from "../resources"; +import { prisma } from "@/lib/db"; +import type { McpAuthContext } from "../auth"; + +const CTX: McpAuthContext = { + userId: "user-1", + tokenId: "token-1", + scopes: ["health:read"], + binding: "user-1:token-1", + canRead: true, + canWrite: false, +}; + +function resource(uri: string) { + const def = MCP_RESOURCES.find((r) => r.uri === uri); + if (!def) throw new Error(`resource ${uri} not registered`); + return def; +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("MCP resource surface", () => { + it("registers exactly the Phase-1 resources, none admin", () => { + expect([...MCP_RESOURCE_URIS].sort()).toEqual([ + "healthlog://medications", + "healthlog://profile", + ]); + for (const uri of MCP_RESOURCE_URIS) { + expect(uri).not.toMatch(/admin/i); + } + }); +}); + +describe("healthlog://profile", () => { + it("returns minimised, health-relevant profile data and omits identifiers", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + heightCm: 180, + dateOfBirth: new Date("1990-01-01T00:00:00Z"), + gender: "MALE", + timezone: "Europe/Berlin", + unitPreference: "metric", + glucoseUnit: "mmol/L", + } as never); + + const result = (await resource("healthlog://profile").read(CTX)) as Record< + string, + unknown + >; + + // userId is narrowed from the session, never a caller argument. + expect(prisma.user.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: "user-1" } }), + ); + expect(result.present).toBe(true); + expect(result.heightCm).toBe(180); + expect(typeof result.ageYears).toBe("number"); + expect(result.glucoseUnit).toBe("mmol/L"); + // No direct identifiers leak to the assistant. + expect(Object.keys(result)).not.toContain("email"); + expect(Object.keys(result)).not.toContain("username"); + expect(Object.keys(result)).not.toContain("role"); + }); + + it("returns { present: false } when the user row is missing", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + const result = (await resource("healthlog://profile").read(CTX)) as { + present: boolean; + }; + expect(result.present).toBe(false); + }); +}); + +describe("healthlog://medications", () => { + it("lists medications + schedules scoped to the session user", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + { + name: "Med A", + dose: "10mg", + treatmentClass: "GENERIC", + asNeeded: false, + pausedAt: null, + startsOn: null, + endsOn: null, + schedules: [ + { + label: "Morning", + dose: null, + windowStart: "08:00", + windowEnd: "10:00", + timesOfDay: ["08:00"], + daysOfWeek: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + }, + ], + }, + ] as never); + + const result = (await resource("healthlog://medications").read( + CTX, + )) as Record; + + expect(prisma.medication.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: "user-1" } }), + ); + expect(result.present).toBe(true); + expect(result.count).toBe(1); + expect(Array.isArray(result.medications)).toBe(true); + }); + + it("returns { present: false } when no medications are tracked", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + const result = (await resource("healthlog://medications").read(CTX)) as { + present: boolean; + count: number; + }; + expect(result.present).toBe(false); + expect(result.count).toBe(0); + }); +}); diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts new file mode 100644 index 000000000..454290f23 --- /dev/null +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// --- Mocks hoisted before importing the module under test. --- +vi.mock("@/lib/ai/coach/tools/executor", () => ({ + executeCoachTool: vi.fn(), +})); +vi.mock("@/lib/ai/coach/tools/inventory", () => ({ + buildCoachDataInventory: vi.fn(), +})); +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +import { MCP_TOOLS, MCP_TOOL_NAMES } from "../tools"; +import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; +import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory"; +import type { McpAuthContext } from "../auth"; + +const CTX: McpAuthContext = { + userId: "user-1", + tokenId: "token-1", + scopes: ["health:read"], + binding: "user-1:token-1", + canRead: true, + canWrite: false, +}; + +function tool(name: string) { + const def = MCP_TOOLS.find((t) => t.name === name); + if (!def) throw new Error(`tool ${name} not registered`); + return def; +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("MCP tool registry — surface", () => { + it("registers exactly the Phase-1 read tools", () => { + expect([...MCP_TOOL_NAMES].sort()).toEqual( + [ + "get_correlations", + "get_labs", + "get_medication_compliance", + "get_metric_series", + "list_metrics", + ].sort(), + ); + }); + + it("exposes no admin / write tool", () => { + for (const name of MCP_TOOL_NAMES) { + expect(name).not.toMatch(/admin/i); + expect(name).not.toMatch( + /^(log_|create_|update_|delete_|export_|write_)/, + ); + } + }); +}); + +describe("list_metrics", () => { + it("enumerates available metrics with coverage from the inventory", async () => { + vi.mocked(buildCoachDataInventory).mockResolvedValue({ + entries: [ + { + tool: "get_metric_series", + metric: "bp", + domain: "blood pressure", + present: true, + count: 42, + }, + { + tool: "get_metric_series", + metric: "weight", + domain: "weight", + present: false, + }, + ], + restMode: false, + cycleEnabled: false, + window: "last30days", + probeScope: { sources: [], window: "last30days" }, + } as never); + + const result = (await tool("list_metrics").run(CTX, {})) as { + present: boolean; + window: string; + metrics: unknown[]; + }; + + expect(buildCoachDataInventory).toHaveBeenCalledWith("user-1", undefined); + expect(result.present).toBe(true); + expect(result.window).toBe("last30days"); + expect(result.metrics).toHaveLength(2); + }); +}); + +describe("get_metric_series", () => { + it("forwards validated args + session userId to the F1 executor and returns the grounded result", async () => { + vi.mocked(executeCoachTool).mockResolvedValue({ + present: true, + data: { metric: "bp", section: { unit: "mmHg" } }, + grounding: "Population band 120/80 mmHg.", + }); + + const result = (await tool("get_metric_series").run(CTX, { + metric: "bp", + window: "last90days", + })) as { present: boolean; grounding?: string }; + + expect(executeCoachTool).toHaveBeenCalledWith({ + userId: "user-1", + name: "get_metric_series", + rawArguments: JSON.stringify({ metric: "bp", window: "last90days" }), + }); + expect(result.present).toBe(true); + // Units / reference bands ride the result (ADR-004). + expect(result.grounding).toContain("mmHg"); + }); + + it("returns { present: false } for an absent metric (never a silent zero)", async () => { + vi.mocked(executeCoachTool).mockResolvedValue({ + present: false, + reason: "no_data", + }); + const result = (await tool("get_metric_series").run(CTX, { + metric: "weight", + })) as { present: boolean; reason?: string }; + expect(result.present).toBe(false); + expect(result.reason).toBe("no_data"); + }); +}); + +describe("get_medication_compliance", () => { + it("forwards to the F1 executor under its name", async () => { + vi.mocked(executeCoachTool).mockResolvedValue({ + present: true, + data: { compliance: { short: { rate: 90 } } }, + }); + const result = (await tool("get_medication_compliance").run(CTX, {})) as { + present: boolean; + }; + expect(executeCoachTool).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + name: "get_medication_compliance", + }), + ); + expect(result.present).toBe(true); + }); +}); + +describe("get_labs", () => { + it("forwards the optional analyte filter and returns readings with units + bands", async () => { + vi.mocked(executeCoachTool).mockResolvedValue({ + present: true, + data: { + recent: [ + { + analyte: "LDL", + value: 120, + unit: "mg/dL", + referenceHigh: 116, + rangeStatus: "above", + }, + ], + }, + }); + const result = (await tool("get_labs").run(CTX, { analyte: "LDL" })) as { + present: boolean; + }; + expect(executeCoachTool).toHaveBeenCalledWith({ + userId: "user-1", + name: "get_labs", + rawArguments: JSON.stringify({ analyte: "LDL" }), + }); + expect(result.present).toBe(true); + }); +}); + +describe("get_correlations", () => { + it("forwards to the F1 executor and returns FDR-controlled drivers", async () => { + vi.mocked(executeCoachTool).mockResolvedValue({ + present: true, + data: { drivers: [], pairsTested: 20, windowDays: 180 }, + }); + const result = (await tool("get_correlations").run(CTX, {})) as { + present: boolean; + }; + expect(executeCoachTool).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", name: "get_correlations" }), + ); + expect(result.present).toBe(true); + }); +}); + +describe("no prose verdict (ADR-004 / REQ-SEC-2)", () => { + it("every tool result is a structured object without verdict/diagnosis fields", async () => { + vi.mocked(executeCoachTool).mockResolvedValue({ present: true, data: {} }); + vi.mocked(buildCoachDataInventory).mockResolvedValue({ + entries: [], + restMode: false, + cycleEnabled: false, + window: "last30days", + probeScope: { sources: [] }, + } as never); + + for (const def of MCP_TOOLS) { + const args = def.name === "get_metric_series" ? { metric: "bp" } : {}; + const result = await def.run(CTX, args); + expect(typeof result).toBe("object"); + const keys = Object.keys(result as object); + expect(keys).not.toContain("verdict"); + expect(keys).not.toContain("diagnosis"); + expect(keys).not.toContain("advice"); + } + }); +}); diff --git a/src/lib/mcp/auth.ts b/src/lib/mcp/auth.ts new file mode 100644 index 000000000..7d6fb57dd --- /dev/null +++ b/src/lib/mcp/auth.ts @@ -0,0 +1,60 @@ +/** + * MCP auth context. + * + * Resolves a pasted `hlk_` Bearer token to a session-narrowed identity for + * the MCP transports. Authentication is delegated to the canonical + * `resolveBearerToken` (`@/lib/auth/bearer`) — the SAME validation the HTTP edge + * runs — so revoked / expired / scope semantics are identical across both wires + * (REQ-T1, REQ-SEC-5). + * + * Security properties: + * - The id is narrowed to one `userId`; tools and resources feed it straight + * into the Prisma `where`, never accepting a caller-supplied user id. + * - The session is bound to `:` (REQ-SEC-11), never to a + * cookie session — the MCP wire carries no cookie. + * - Admin is unreachable by construction: `requireAdmin()` is cookie-only and + * this path never mints a cookie session, so no MCP context can elevate + * (REQ-SEC-7 / ADR-005). The guard test pins it. + */ +import { resolveBearerToken } from "@/lib/auth/bearer"; +import { sessionBinding, tokenAllowsRead, tokenAllowsWrite } from "./scopes"; + +export interface McpAuthContext { + /** The single user this session acts as. Feeds every Prisma `where`. */ + userId: string; + /** The presented `ApiToken` id (the token half of the binding). */ + tokenId: string; + /** The token's granted scopes. */ + scopes: string[]; + /** `:` — the session binding (REQ-SEC-11). */ + binding: string; + /** Whether this session may read (always true for a valid token). */ + canRead: boolean; + /** Whether this session may write (requires `health:write`; no write tools yet). */ + canWrite: boolean; +} + +/** + * Resolve a raw Bearer token into an `McpAuthContext`, or throw on an invalid / + * revoked / expired token. Reads require no special scope (the underlying reads + * are unscoped), so no `requiredPermission` is passed. + */ +export async function resolveMcpAuthContext( + rawToken: string, +): Promise { + const trimmed = rawToken.trim(); + if (!trimmed) { + throw new Error("Missing MCP Bearer token"); + } + + const { user, tokenId, permissions } = await resolveBearerToken(trimmed); + + return { + userId: user.id, + tokenId, + scopes: permissions, + binding: sessionBinding(user.id, tokenId), + canRead: tokenAllowsRead(), + canWrite: tokenAllowsWrite(permissions), + }; +} diff --git a/src/lib/mcp/index.ts b/src/lib/mcp/index.ts new file mode 100644 index 000000000..82fc91438 --- /dev/null +++ b/src/lib/mcp/index.ts @@ -0,0 +1,25 @@ +/** + * HealthLog MCP server — barrel. + * + * A read-only-by-default Model Context Protocol surface that re-exports + * HealthLog's existing server-authoritative read paths over the MCP wire, so a + * user can query their own health data from any MCP-capable assistant. The tool + * / resource layer here is transport-agnostic; transports (stdio now, remote + * `/mcp` later) attach to `createMcpServer(ctx)`. + */ +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_RESOURCES, + MCP_RESOURCE_URIS, + type McpResourceDefinition, +} from "./resources"; +export { + SCOPE_HEALTH_READ, + SCOPE_HEALTH_WRITE, + SCOPE_WILDCARD, + tokenAllowsRead, + tokenAllowsWrite, + sessionBinding, +} from "./scopes"; diff --git a/src/lib/mcp/resources.ts b/src/lib/mcp/resources.ts new file mode 100644 index 000000000..10c010851 --- /dev/null +++ b/src/lib/mcp/resources.ts @@ -0,0 +1,130 @@ +/** + * Transport-agnostic MCP resource registry. + * + * Resources expose read-only context the assistant can surface alongside tool + * results (REQ-R10). Like the tools, they are server-authoritative reads of the + * canonical tables scoped to the session `userId` — no new analytics, no + * caller-supplied id. Absence is explicit via `{ present: false }` (REQ-SEC-4). + * + * The profile resource is deliberately data-minimised: it ships only the + * health-relevant context an assistant needs (age in whole years, gender, + * height, timezone, unit preferences) and omits direct identifiers (email, + * username, role, insurance number) — narrowing the surface on the + * external-assistant boundary. + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import type { McpAuthContext } from "./auth"; + +export interface McpResourceDefinition { + name: string; + /** Fixed resource URI (e.g. `healthlog://profile`). */ + uri: string; + title: string; + description: string; + mimeType: string; + read: (ctx: McpAuthContext) => Promise; +} + +/** Whole-year age from a date of birth, or null when unknown. */ +function ageYears(dateOfBirth: Date | null): number | null { + if (!dateOfBirth) return null; + const now = new Date(); + let age = now.getUTCFullYear() - dateOfBirth.getUTCFullYear(); + const monthDelta = now.getUTCMonth() - dateOfBirth.getUTCMonth(); + if ( + monthDelta < 0 || + (monthDelta === 0 && now.getUTCDate() < dateOfBirth.getUTCDate()) + ) { + age -= 1; + } + return age >= 0 && age < 150 ? age : null; +} + +export const MCP_RESOURCES: McpResourceDefinition[] = [ + { + name: "profile", + uri: "healthlog://profile", + title: "User profile", + description: + "The user's health-relevant profile context: age, gender, height, timezone, and unit preferences. Identifiers are omitted.", + mimeType: "application/json", + async read(ctx) { + const user = await prisma.user.findUnique({ + where: { id: ctx.userId }, + select: { + heightCm: true, + dateOfBirth: true, + gender: true, + timezone: true, + unitPreference: true, + glucoseUnit: true, + }, + }); + annotate({ + action: { name: "mcp.resource.read" }, + meta: { resource: "profile", present: user !== null }, + }); + if (!user) return { present: false }; + return { + present: true, + ageYears: ageYears(user.dateOfBirth), + gender: user.gender ?? null, + heightCm: user.heightCm ?? null, + timezone: user.timezone, + unitPreference: + user.unitPreference === "imperial" ? "imperial" : "metric", + glucoseUnit: user.glucoseUnit ?? "mg/dL", + }; + }, + }, + { + name: "medications", + uri: "healthlog://medications", + title: "Medications", + description: + "The user's tracked medications with their schedules (dose, cadence, time windows). Read-only list; use get_medication_compliance for adherence figures.", + mimeType: "application/json", + async read(ctx) { + const medications = await prisma.medication.findMany({ + where: { userId: ctx.userId }, + include: { schedules: true }, + orderBy: { createdAt: "desc" }, + }); + annotate({ + action: { name: "mcp.resource.read" }, + meta: { resource: "medications", present: medications.length > 0 }, + }); + if (medications.length === 0) return { present: false, count: 0 }; + return { + present: true, + count: medications.length, + medications: medications.map((med) => ({ + name: med.name, + dose: med.dose, + treatmentClass: med.treatmentClass, + asNeeded: med.asNeeded, + paused: med.pausedAt !== null, + startsOn: med.startsOn ? med.startsOn.toISOString() : null, + endsOn: med.endsOn ? med.endsOn.toISOString() : null, + schedules: med.schedules.map((s) => ({ + label: s.label ?? null, + dose: s.dose ?? null, + windowStart: s.windowStart, + windowEnd: s.windowEnd, + timesOfDay: s.timesOfDay, + daysOfWeek: s.daysOfWeek ?? null, + rrule: s.rrule ?? null, + rollingIntervalDays: s.rollingIntervalDays ?? null, + scheduleType: s.scheduleType, + })), + })), + }; + }, + }, +]; + +/** Stable list of the registered resource URIs. */ +export const MCP_RESOURCE_URIS: readonly string[] = MCP_RESOURCES.map( + (r) => r.uri, +); diff --git a/src/lib/mcp/scopes.ts b/src/lib/mcp/scopes.ts new file mode 100644 index 000000000..b04d57d7e --- /dev/null +++ b/src/lib/mcp/scopes.ts @@ -0,0 +1,58 @@ +/** + * MCP scope model. + * + * The MCP wire reuses the existing `hlk_` Bearer tokens as its auth + * substrate (no parallel identity system). Two scope handles frame the + * least-privilege contract: + * + * - `health:read` — read the user's own health data. The default MCP posture. + * - `health:write` — mutate (log a measurement / intake / mood). Reserved for + * the write tools, which are NOT registered in this milestone phase. + * + * Read tools impose no scope requirement of their own: every underlying read + * path is already an unscoped authenticated route, so a narrow-scope token works + * and a wildcard token is never required (REQ-SEC-5). The strong "read-only by + * default" guarantee (REQ-SEC-1 / ADR-003) is structural — only read tools are + * registered — not a runtime flag a future token could flip. Write registration + * (a later phase) gates on `health:write` via `tokenAllowsWrite`. + */ + +/** Wildcard scope — grants every capability the token model can express. */ +export const SCOPE_WILDCARD = "*"; + +/** Read the authenticated user's own health data over MCP. */ +export const SCOPE_HEALTH_READ = "health:read"; + +/** Mutate the authenticated user's data over MCP (reserved; no write tools yet). */ +export const SCOPE_HEALTH_WRITE = "health:write"; + +/** + * Whether a token's scopes admit the read surface. Any valid token qualifies — + * the underlying reads are unscoped — so this is permissive by design and exists + * to document the contract and to give a single place to tighten later. + */ +export function tokenAllowsRead(): boolean { + return true; +} + +/** + * Whether a token's scopes admit the write surface. A token must carry the + * explicit `health:write` scope (or the wildcard). Used to decide whether write + * tools are registered for a session. + */ +export function tokenAllowsWrite(permissions: readonly string[]): boolean { + return ( + permissions.includes(SCOPE_WILDCARD) || + permissions.includes(SCOPE_HEALTH_WRITE) + ); +} + +/** + * The `:` binding (REQ-SEC-11). Identifies a session by the + * user AND the specific token presented — never the user alone — so audit, + * annotation, and (later phases) rate-limit buckets are keyed to the credential + * actually in use. + */ +export function sessionBinding(userId: string, tokenId: string): string { + return `${userId}:${tokenId}`; +} diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts new file mode 100644 index 000000000..52905c5af --- /dev/null +++ b/src/lib/mcp/server.ts @@ -0,0 +1,77 @@ +/** + * MCP server factory. + * + * Composes an `McpServer` from the official SDK and registers the read tools + + * resources from the transport-agnostic registries, bound to one resolved + * session context. This factory is intentionally transport-free: the stdio + * adapter (this phase) and the future remote `/mcp` adapter both call + * `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). + */ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import type { McpAuthContext } from "./auth"; +import { MCP_TOOLS } from "./tools"; +import { MCP_RESOURCES } from "./resources"; + +export const MCP_SERVER_NAME = "healthlog"; +export const MCP_SERVER_VERSION = "1.0.0"; + +/** + * Build an MCP server for one authenticated session. The returned server is not + * yet connected to a transport — the caller attaches stdio (or, later, the + * Streamable-HTTP transport). + */ +export function createMcpServer(ctx: McpAuthContext): McpServer { + const server = new McpServer({ + name: MCP_SERVER_NAME, + version: MCP_SERVER_VERSION, + }); + + for (const tool of MCP_TOOLS) { + server.registerTool( + tool.name, + { + title: tool.title, + description: tool.description, + inputSchema: tool.inputShape, + }, + async (args: Record) => { + const result = await tool.run(ctx, args ?? {}); + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + }; + }, + ); + } + + for (const resource of MCP_RESOURCES) { + server.registerResource( + resource.name, + resource.uri, + { + title: resource.title, + description: resource.description, + mimeType: resource.mimeType, + }, + async (uri: URL) => { + const data = await resource.read(ctx); + return { + contents: [ + { + uri: uri.href, + mimeType: resource.mimeType, + text: JSON.stringify(data), + }, + ], + }; + }, + ); + } + + return server; +} diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts new file mode 100644 index 000000000..ac44f5426 --- /dev/null +++ b/src/lib/mcp/tools.ts @@ -0,0 +1,140 @@ +/** + * Transport-agnostic MCP read-tool registry. + * + * Each tool is a THIN wrapper over an existing server-authoritative read path — + * the Coach F1 retrieval layer (`@/lib/ai/coach/tools`). No new analytics is + * computed here (REQ-WONT-2); the MCP wire only re-exports what HealthLog + * already computes. The same registry feeds both transports: the stdio adapter + * (this phase) and the remote `/mcp` adapter (a later phase) register from it, + * so the tool contract can never fork between wires (ADR-002). + * + * Grounding contract (REQ-SEC-2/3/4, ADR-004): every tool returns structured + * values + units + reference bands + provenance and uses `{ present: false }` + * for absence — never a silent zero, never a prose verdict or diagnosis. The + * assistant narrates; the server only ships facts it computed. + * + * Read-only contract (REQ-SEC-1, ADR-003): the registry contains read tools + * only. `userId` is always taken from the resolved session context, never from a + * tool argument (REQ-SEC-5). + */ +import { z } from "zod/v4"; + +import { annotate } from "@/lib/logging/context"; +import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; +import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory"; +import { + coachScopeSourceSchema, + coachScopeWindowSchema, +} from "@/lib/ai/coach/types"; +import type { McpAuthContext } from "./auth"; + +/** + * A registry entry. `inputShape` is a zod/v4 raw shape handed straight to the + * SDK's `registerTool` for argument validation; `run` receives the validated + * arguments and the session context and returns a plain structured object. + */ +export interface McpToolDefinition { + name: string; + title: string; + description: string; + inputShape: z.ZodRawShape; + run: (ctx: McpAuthContext, args: Record) => Promise; +} + +/** + * Run one Coach F1 retrieval tool through its executor. The executor re-parses + * and re-validates the arguments against its own Zod schema, runs the + * server-authoritative snapshot builder scoped to the requested domain, and + * returns the grounded `{ present, reason?, data?, grounding? }` result. It + * never throws and never widens scope. + */ +async function runCoachTool( + ctx: McpAuthContext, + name: string, + args: Record, +): Promise { + const result = await executeCoachTool({ + userId: ctx.userId, + name, + rawArguments: JSON.stringify(args ?? {}), + }); + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: name, present: result.present }, + }); + return result; +} + +export const MCP_TOOLS: McpToolDefinition[] = [ + { + name: "list_metrics", + title: "List available metrics", + description: + "Enumerate which of the user's health data exists and how to fetch it: one row per domain with whether data is present, an approximate sample count, and the tool that retrieves it. Call this first to discover what is available before fetching figures.", + inputShape: {}, + async run(ctx) { + const inventory = await buildCoachDataInventory(ctx.userId, undefined); + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "list_metrics", present: true }, + }); + return { + present: true, + window: inventory.window, + restMode: inventory.restMode, + cycleEnabled: inventory.cycleEnabled, + metrics: inventory.entries, + }; + }, + }, + { + name: "get_metric_series", + title: "Get a metric time series", + description: + "Fetch the user's own time series for ONE metric (e.g. bp, weight, pulse, hrv, resting_hr, steps, sleep, body composition, vo2_max). Returns an aggregate (count, min, max, mean, slope) plus recent-daily and weekly timelines, with units and population reference bands for safe phrasing. Returns { present: false } when the user has no data for that metric.", + inputShape: { + metric: coachScopeSourceSchema, + window: coachScopeWindowSchema.optional(), + }, + run(ctx, args) { + return runCoachTool(ctx, "get_metric_series", args); + }, + }, + { + name: "get_medication_compliance", + title: "Get medication compliance", + description: + "Fetch the user's cadence-aware medication adherence: the dose-weighted compliance rate, expected vs taken/missed counts, current-cycle status, and any GLP-1 titration context. Returns { present: false } when no medications are tracked.", + inputShape: { + window: coachScopeWindowSchema.optional(), + }, + run(ctx, args) { + return runCoachTool(ctx, "get_medication_compliance", args); + }, + }, + { + name: "get_labs", + title: "Get lab results", + description: + "Fetch the user's most recent lab results — the latest reading per biomarker over the last 12 months, optionally filtered to one named analyte. Each reading carries its value, unit, reference range, and in-range/below/above status. Returns { present: false } when no labs are on file.", + inputShape: { + analyte: z.string().min(1).max(80).optional(), + }, + run(ctx, args) { + return runCoachTool(ctx, "get_labs", args); + }, + }, + { + name: "get_correlations", + title: "Get discovered correlations", + description: + "Fetch the user's statistically-vetted (FDR-controlled) day-to-next-day driver pairs between behaviours (daylight, mood, glucose, blood pressure, steps) and outcomes (sleep, HRV, resting HR, weight), each with direction, lag, sample size, and a descriptive — never causal — note over a fixed trailing window. Returns { present: false } when too little paired data exists.", + inputShape: {}, + run(ctx, args) { + return runCoachTool(ctx, "get_correlations", args); + }, + }, +]; + +/** Stable list of the registered tool names. */ +export const MCP_TOOL_NAMES: readonly string[] = MCP_TOOLS.map((t) => t.name);