From 03d41bc5c00c54d596dcfa2456a7a2b41a153c5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 13:47:09 +0200 Subject: [PATCH] feat(mcp): serve the read-only tools over remote Streamable HTTP at /mcp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge the existing transport-agnostic MCP server factory to a Next.js route at /mcp using the SDK's Web-standard Streamable HTTP transport, so a remote client can connect with a manually-minted hlk_ Bearer token. The same tool/resource layer the local stdio transport serves is reused verbatim — no duplicated tool logic. The route owns four gates in front of the transport: - Auth: Authorization: Bearer hlk_… resolved by the same validator the HTTP edge uses, so revoked/expired/scope semantics never drift. A cookie is never consulted, so the cookie-only admin boundary stays unreachable here regardless of token scope. - Rate limit: the Postgres limiter keyed by the : binding (mcp:), so a single shared credential cannot drain the account. - Module gate: a new opt-in mcp module, OFF by default. With it off the endpoint answers 404 and reveals nothing — a self-hoster who never opts in gets zero new exposure. The Modules settings hub renders the toggle. - Transport: a fresh stateless server + transport per request. Read-only by construction (only read tools are registered) and same-origin by construction (no CORS headers). The proxy lets /mcp skip the page cookie redirect since it authenticates by header, mirroring the webhook surfaces. --- docs/api/openapi.yaml | 6 + messages/de.json | 4 + messages/en.json | 4 + messages/es.json | 4 + messages/fr.json | 4 + messages/it.json | 4 + messages/pl.json | 4 + src/app/mcp/__tests__/route.test.ts | 269 ++++++++++++++++++ src/app/mcp/route.ts | 163 +++++++++++ src/components/settings/modules-section.tsx | 3 + src/lib/dashboard/__tests__/snapshot.test.ts | 1 + src/lib/modules/__tests__/gate.test.ts | 52 ++++ src/lib/modules/gate.ts | 14 +- src/lib/modules/registry.ts | 40 ++- src/lib/rate-limit.ts | 18 ++ src/proxy.ts | 10 + .../doctor-report-sections.test.ts | 1 + 17 files changed, 599 insertions(+), 2 deletions(-) create mode 100644 src/app/mcp/__tests__/route.test.ts create mode 100644 src/app/mcp/route.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 17cfd4dd7..586a1f894 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8258,6 +8258,8 @@ components: type: boolean doctorReport: type: boolean + mcp: + type: boolean additionalProperties: false description: Partial module enable/disable update. Each key is an optional boolean; an omitted key is left untouched, `false` disables the module, `true` (re-)enables it. Only the toggleable "secondary domains" are accepted — a @@ -14465,6 +14467,9 @@ components: doctorReport: type: boolean description: Whether the "doctorReport" module is enabled for this account. + mcp: + type: boolean + description: Whether the "mcp" module is enabled for this account. required: - cycle - mood @@ -14479,6 +14484,7 @@ components: - insights - medications - doctorReport + - mcp additionalProperties: false description: Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; diff --git a/messages/de.json b/messages/de.json index c07f8cf1f..3bfa2f640 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6861,6 +6861,10 @@ "illness": { "label": "Krankheitstagebuch", "description": "Krankheits- und Beschwerde-Episoden mit täglichem Symptomverlauf festhalten." + }, + "mcp": { + "label": "Externer Assistentenzugriff (MCP)", + "description": "Einem MCP-fähigen Assistenten Lesezugriff auf deine Gesundheitsdaten über ein begrenztes Token erlauben. Standardmäßig aus." } }, "illness": { diff --git a/messages/en.json b/messages/en.json index 4e8e9c6f5..0ac7a69b0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6861,6 +6861,10 @@ "illness": { "label": "Illness journal", "description": "Track illness and condition episodes with a daily symptom timeline." + }, + "mcp": { + "label": "External assistant access (MCP)", + "description": "Let an MCP-capable assistant read your health data over a scoped token. Off by default." } }, "illness": { diff --git a/messages/es.json b/messages/es.json index fe481268d..d4b0c0842 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6861,6 +6861,10 @@ "illness": { "label": "Diario de enfermedad", "description": "Registra episodios de enfermedad y dolencias con un historial diario de síntomas." + }, + "mcp": { + "label": "Acceso de asistente externo (MCP)", + "description": "Permite que un asistente compatible con MCP lea tus datos de salud mediante un token con alcance limitado. Desactivado de forma predeterminada." } }, "illness": { diff --git a/messages/fr.json b/messages/fr.json index 11ab9eee5..a7f6ebc03 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6861,6 +6861,10 @@ "illness": { "label": "Journal de maladie", "description": "Suivez les épisodes de maladie et d'affections avec un historique quotidien des symptômes." + }, + "mcp": { + "label": "Accès assistant externe (MCP)", + "description": "Autorise un assistant compatible MCP à lire vos données de santé via un jeton à portée limitée. Désactivé par défaut." } }, "illness": { diff --git a/messages/it.json b/messages/it.json index b3f1f0c92..2f07c29a2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6861,6 +6861,10 @@ "illness": { "label": "Diario delle malattie", "description": "Registra episodi di malattia e disturbi con una cronologia giornaliera dei sintomi." + }, + "mcp": { + "label": "Accesso assistente esterno (MCP)", + "description": "Consente a un assistente compatibile con MCP di leggere i tuoi dati sanitari tramite un token con ambito limitato. Disattivato per impostazione predefinita." } }, "illness": { diff --git a/messages/pl.json b/messages/pl.json index d227d654f..101899b35 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6861,6 +6861,10 @@ "illness": { "label": "Dziennik chorób", "description": "Zapisuj epizody chorób i dolegliwości z dziennym przebiegiem objawów." + }, + "mcp": { + "label": "Dostęp asystenta zewnętrznego (MCP)", + "description": "Pozwól asystentowi obsługującemu MCP czytać Twoje dane zdrowotne za pomocą tokena o ograniczonym zakresie. Domyślnie wyłączone." } }, "illness": { diff --git a/src/app/mcp/__tests__/route.test.ts b/src/app/mcp/__tests__/route.test.ts new file mode 100644 index 000000000..90f9ca47c --- /dev/null +++ b/src/app/mcp/__tests__/route.test.ts @@ -0,0 +1,269 @@ +/** + * Remote MCP endpoint (`/mcp`) — transport + gate tests. + * + * Drives the route handler with Web-standard `Request`s and asserts the + * four front gates plus the read-only / admin-unreachable posture over the + * HTTP wire: + * + * - serves the tool list with a valid token (module on, under budget); + * - rejects a missing / malformed / rejected (revoked / expired / unknown) + * token with one blunt 401; + * - hides the surface (404) when the off-by-default `mcp` module is off; + * - trips 429 when the per-credential rate limit is exhausted; + * - never consults a cookie session, so the cookie-only `requireAdmin()` + * boundary is unreachable here regardless of token scope — a `["*"]` + * wildcard token still sees only the read tools (ADR-003 / ADR-005). + * + * The Bearer validator, module gate, and rate limiter are mocked so the + * test exercises the route's own wiring without a DB; the structural + * cookie-only admin boundary itself is pinned by the sibling + * `src/lib/mcp/__tests__/admin-boundary.test.ts`. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/auth/bearer", () => ({ + resolveBearerToken: vi.fn(), + BearerAuthError: class BearerAuthError extends Error {}, +})); +vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkMcpRateLimit: vi.fn(), + rateLimitHeaders: vi.fn((r: { remaining: number; resetAt: number }) => ({ + "X-RateLimit-Remaining": String(r.remaining), + "X-RateLimit-Reset": new Date(r.resetAt).toISOString(), + })), +})); +// The factory pulls the Coach retrieval layer through `tools.ts`; stub the +// heavy executor + inventory so importing the route never reaches a DB. The +// list/reject paths never execute a tool, so the stubs are never called. +vi.mock("@/lib/ai/coach/tools/executor", () => ({ executeCoachTool: vi.fn() })); +vi.mock("@/lib/ai/coach/tools/inventory", () => ({ + buildCoachDataInventory: vi.fn(), +})); +// A cookie path must NEVER be reached from the MCP wire. Mock both cookie +// sources so the test can assert they are never consulted. +const getSession = vi.fn(); +vi.mock("@/lib/auth/session", () => ({ getSession })); +const cookiesGet = vi.fn(() => undefined); +const headersGet = vi.fn(() => null); +vi.mock("next/headers", () => ({ + cookies: vi.fn(async () => ({ + get: cookiesGet, + set: () => {}, + delete: () => {}, + })), + headers: vi.fn(async () => ({ get: headersGet })), +})); + +import { POST, GET } from "../route"; +import { resolveBearerToken } from "@/lib/auth/bearer"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { checkMcpRateLimit } from "@/lib/rate-limit"; + +const READ_TOOLS = [ + "get_correlations", + "get_labs", + "get_medication_compliance", + "get_metric_series", + "list_metrics", +].sort(); + +/** A valid, narrow-scope (`health:read`) token resolution. */ +function validToken(permissions: string[] = ["health:read"]) { + vi.mocked(resolveBearerToken).mockResolvedValue({ + user: { id: "user-1" } as never, + tokenId: "token-1", + permissions, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }); +} + +function postRpc(body: unknown, headers: Record = {}): Request { + return new Request("http://localhost/mcp", { + method: "POST", + headers: { + authorization: "Bearer hlk_" + "a".repeat(64), + "content-type": "application/json", + accept: "application/json, text/event-stream", + ...headers, + }, + body: JSON.stringify(body), + }); +} + +const TOOLS_LIST = { jsonrpc: "2.0", id: 1, method: "tools/list" }; + +beforeEach(() => { + vi.clearAllMocks(); + cookiesGet.mockReturnValue(undefined); + headersGet.mockReturnValue(null); + // Default happy-path: module on, under budget. + vi.mocked(isModuleEnabled).mockResolvedValue(true); + vi.mocked(checkMcpRateLimit).mockResolvedValue({ + allowed: true, + remaining: 119, + resetAt: Date.now() + 60_000, + }); +}); + +describe("/mcp — happy path", () => { + it("serves the read-tool list over HTTP with a valid token", async () => { + validToken(); + 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) + .sort(); + expect(names).toEqual(READ_TOOLS); + + // The gate ran in the documented order: auth → rate-limit → module. + expect(resolveBearerToken).toHaveBeenCalledTimes(1); + expect(checkMcpRateLimit).toHaveBeenCalledWith("user-1:token-1"); + expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "mcp"); + }); + + it("completes the initialize handshake with the HealthLog server identity", async () => { + validToken(); + const res = await POST( + postRpc({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "test", version: "1" }, + }, + }), + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.result.serverInfo.name).toBe("healthlog"); + // Tools + resources are advertised; no admin capability exists. + expect(body.result.capabilities.tools).toBeDefined(); + }); +}); + +describe("/mcp — authentication", () => { + it("rejects a request with no Authorization header (401 + challenge)", async () => { + const res = await POST( + new Request("http://localhost/mcp", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(TOOLS_LIST), + }), + ); + + expect(res.status).toBe(401); + expect(res.headers.get("www-authenticate")).toMatch(/Bearer/); + // The token table is never consulted when no token is presented. + expect(resolveBearerToken).not.toHaveBeenCalled(); + // No module read, no rate-limit hit before auth fails. + expect(isModuleEnabled).not.toHaveBeenCalled(); + expect(checkMcpRateLimit).not.toHaveBeenCalled(); + }); + + it("rejects a malformed Authorization header (not Bearer) with 401", async () => { + const res = await POST( + postRpc(TOOLS_LIST, { authorization: "Basic abc123" }), + ); + expect(res.status).toBe(401); + expect(resolveBearerToken).not.toHaveBeenCalled(); + }); + + it("rejects a revoked / expired / unknown token with a blunt 401", async () => { + vi.mocked(resolveBearerToken).mockRejectedValue(new Error("revoked")); + const res = await POST(postRpc(TOOLS_LIST)); + + expect(res.status).toBe(401); + const body = await res.json(); + // No oracle: the body never echoes the rejection reason or the token. + expect(JSON.stringify(body)).not.toMatch(/revoked|expired|hlk_/); + // A rejected token never reaches the module gate or the transport. + expect(isModuleEnabled).not.toHaveBeenCalled(); + }); +}); + +describe("/mcp — module gate (off by default)", () => { + it("hides the surface with 404 when the mcp module is off", async () => { + validToken(); + vi.mocked(isModuleEnabled).mockResolvedValue(false); + + const res = await POST(postRpc(TOOLS_LIST)); + expect(res.status).toBe(404); + // Authenticated first (so the module is resolved per-user), then hidden. + expect(resolveBearerToken).toHaveBeenCalledTimes(1); + expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "mcp"); + }); +}); + +describe("/mcp — rate limit", () => { + it("trips 429 once the per-credential budget is exhausted", async () => { + validToken(); + vi.mocked(checkMcpRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 30_000, + }); + + const res = await POST(postRpc(TOOLS_LIST)); + expect(res.status).toBe(429); + expect(res.headers.get("x-ratelimit-remaining")).toBe("0"); + // The limiter is keyed by the `:` binding. + expect(checkMcpRateLimit).toHaveBeenCalledWith("user-1:token-1"); + // A throttled request never reaches the module gate or the transport. + expect(isModuleEnabled).not.toHaveBeenCalled(); + }); +}); + +describe("/mcp — admin unreachable over HTTP (read-only, cookie-only boundary)", () => { + it("never consults a cookie session, even with a wildcard token", async () => { + validToken(["*"]); + 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, + ); + + // 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); + 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 + // read, so `requireAdmin()` (cookie-only) can never be reached here. + expect(getSession).not.toHaveBeenCalled(); + expect(cookiesGet).not.toHaveBeenCalled(); + }); +}); + +describe("/mcp — GET transport", () => { + it("is wired for GET (SSE stream open) behind the same gates", async () => { + validToken(); + const res = await GET( + new Request("http://localhost/mcp", { + method: "GET", + headers: { + authorization: "Bearer hlk_" + "a".repeat(64), + accept: "text/event-stream", + }, + }), + ); + // Auth + gates ran; the transport answers (a stream open or a + // method-specific status), never a 401/404/429 on the happy path. + expect([200, 405]).toContain(res.status); + expect(resolveBearerToken).toHaveBeenCalledTimes(1); + expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "mcp"); + // Release any opened stream so the test process can exit cleanly. + await res.body?.cancel().catch(() => {}); + }); +}); diff --git a/src/app/mcp/route.ts b/src/app/mcp/route.ts new file mode 100644 index 000000000..c75463026 --- /dev/null +++ b/src/app/mcp/route.ts @@ -0,0 +1,163 @@ +/** + * Remote MCP endpoint — Streamable HTTP transport. + * + * Exposes the SAME read-only tool / resource layer the stdio transport + * serves (`src/lib/mcp/`) over HTTP, so a remote MCP client (Claude.ai / + * ChatGPT / the hosted-or-iOS crowd) can connect with a manually-minted + * `hlk_` Bearer token before full OAuth lands (ADR-002 / ADR-006). The + * wire protocol — JSON-RPC framing, capability negotiation, the + * GET/POST/DELETE method matrix — is the official SDK's + * `WebStandardStreamableHTTPServerTransport`; this route only owns the four + * HealthLog-specific gates in front of it: + * + * 1. AUTH — `Authorization: Bearer hlk_…` resolved by the SAME validator + * the HTTP edge uses (`resolveMcpAuthContext` → `resolveBearerToken`), + * so revoked / expired / scope semantics never drift between the stdio, + * remote, and REST wires. A cookie is never consulted — the wire + * carries none — so the cookie-only `requireAdmin()` boundary is + * unreachable here by construction (ADR-005 / REQ-SEC-7). No token, + * a malformed header, or a rejected token all answer one blunt 401. + * + * 2. RATE LIMIT — the Postgres limiter keyed by the `:` + * binding (`mcp:`), so a single leaked / shared credential + * cannot drain the account's other tokens (REQ-SEC-9 / REQ-SEC-11). + * + * 3. MODULE GATE — the `mcp` module is OPT-IN (off by default). With the + * module off the surface answers 404 and reveals nothing — a + * self-hoster who never opts in gets zero new exposure (ADR-007 / + * REQ-OPS-1). + * + * 4. TRANSPORT — a fresh stateless server + transport per request + * (`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. + * + * Same-origin / no-CORS by construction: no `Access-Control-Allow-Origin` + * header is emitted anywhere here, matching the app-wide posture. + */ +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; + +import { createMcpServer, resolveMcpAuthContext } from "@/lib/mcp"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { annotate } from "@/lib/logging/context"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { checkMcpRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import type { ModuleKey } from "@/lib/modules/registry"; + +// The Bearer resolver + module gate + rate limiter all touch Prisma, so the +// route must run on the Node runtime, never the Edge runtime. +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const MCP_MODULE: ModuleKey = "mcp"; + +/** One blunt 401 for every authentication failure class — no oracle. */ +function unauthorized(): Response { + return Response.json( + { error: "unauthorized" }, + { + status: 401, + // Pre-OAuth bridge: a bare Bearer challenge. The RFC 9728 + // `resource_metadata` parameter is added when OAuth discovery lands. + headers: { "WWW-Authenticate": 'Bearer realm="healthlog-mcp"' }, + }, + ); +} + +/** + * Module off → the surface does not exist for this account. A 404 (not a + * 403) keeps the remote endpoint invisible until the operator / user opts + * in, so a disabled instance leaks nothing about it. + */ +function moduleDisabled(): Response { + return Response.json({ error: "not_found" }, { status: 404 }); +} + +/** 429 with the standard rate-limit headers. */ +function tooManyRequests(resetAt: number, remaining: number): Response { + const response = Response.json({ error: "rate_limited" }, { status: 429 }); + for (const [k, v] of Object.entries( + rateLimitHeaders({ allowed: false, remaining, resetAt }), + )) { + response.headers.set(k, v); + } + return response; +} + +/** Extract the raw token from an `Authorization: Bearer ` header. */ +function readBearer(request: Request): string | null { + const header = request.headers.get("authorization"); + if (!header) return null; + const match = /^Bearer\s+(.+)$/i.exec(header.trim()); + if (!match) return null; + const token = match[1].trim(); + return token.length > 0 ? token : null; +} + +async function handleMcp(request: Request): Promise { + return withBackgroundEvent("mcp.request", async () => { + // 1. AUTH — Bearer only; a cookie is never consulted (admin unreachable). + const raw = readBearer(request); + if (!raw) { + annotate({ action: { name: "mcp.auth.missing" } }); + return unauthorized(); + } + + let ctx; + try { + ctx = await resolveMcpAuthContext(raw); + } catch { + // Do not echo the token or the rejection reason — one blunt 401. + annotate({ action: { name: "mcp.auth.rejected" } }); + return unauthorized(); + } + + // 2. RATE LIMIT — per `:` credential binding. + const rl = await checkMcpRateLimit(ctx.binding); + if (!rl.allowed) { + annotate({ action: { name: "mcp.rate_limited" } }); + return tooManyRequests(rl.resetAt, rl.remaining); + } + + // 3. MODULE GATE — off by default; hide the surface entirely when off. + if (!(await isModuleEnabled(ctx.userId, MCP_MODULE))) { + annotate({ action: { name: "mcp.module.disabled" } }); + return moduleDisabled(); + } + + // 4. TRANSPORT — stateless: one fresh server + transport per request. + annotate({ + action: { name: "mcp.request.served" }, + meta: { method: request.method }, + }); + + const server = createMcpServer(ctx); + const transport = new WebStandardStreamableHTTPServerTransport({ + // Stateless mode — no server-held session; each request stands alone. + sessionIdGenerator: undefined, + // Buffer a single JSON-RPC response instead of opening an SSE stream; + // the read tools return one structured result per call. + enableJsonResponse: true, + }); + + try { + await server.connect(transport); + // In JSON-response stateless mode the returned Response is fully + // buffered before this resolves, so tearing the transport down in the + // `finally` cannot truncate it. + return await transport.handleRequest(request); + } finally { + await transport.close().catch(() => {}); + await server.close().catch(() => {}); + } + }); +} + +export const GET = handleMcp; +export const POST = handleMcp; +export const DELETE = handleMcp; diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx index 72db906b7..f6c5a5f43 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -34,6 +34,7 @@ import { FileText, MessageCircleHeart, Moon, + Plug, Pill, Smile, Sparkles, @@ -71,6 +72,8 @@ const MODULE_ICONS: Record = { // v1.18.1 (D3) — medications graduated from CORE to a toggleable module. medications: Pill, doctorReport: FileText, + // v1.22.0 — the remote MCP endpoint (opt-in connectivity module). + mcp: Plug, }; export function ModulesSection() { diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 72799e24f..d1939cf09 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -83,6 +83,7 @@ function moduleMap( insights: true, medications: true, doctorReport: true, + mcp: true, }; return { ...base, ...overrides }; } diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts index 87e83011b..5b76f000c 100644 --- a/src/lib/modules/__tests__/gate.test.ts +++ b/src/lib/modules/__tests__/gate.test.ts @@ -21,6 +21,7 @@ import { import { isCoreDomain, isModuleKey, + isOptInModule, moduleDelegatesTo, MODULE_KEYS, CORE_DOMAIN_KEYS, @@ -309,6 +310,57 @@ describe("resolveModuleEnabled — operator layer (two-layer AND)", () => { }); }); +describe("resolveModuleEnabled — mcp module (opt-in, default-OFF)", () => { + it("is OFF when no preference is recorded (inverse of the default-on siblings)", () => { + expect(resolveModuleEnabled("mcp", inputs(), false, ALL_AVAILABLE)).toBe( + false, + ); + }); + + it("stays OFF on an explicit false", () => { + expect( + resolveModuleEnabled( + "mcp", + inputs({ modulePreferences: { mcp: false } }), + false, + ALL_AVAILABLE, + ), + ).toBe(false); + }); + + it("turns ON only on an explicit true (opt-in)", () => { + expect( + resolveModuleEnabled( + "mcp", + inputs({ modulePreferences: { mcp: true } }), + false, + ALL_AVAILABLE, + ), + ).toBe(true); + }); + + it("operator-off short-circuits even when the user opted in", () => { + expect( + resolveModuleEnabled( + "mcp", + inputs({ modulePreferences: { mcp: true } }), + false, + operator({ mcp: false }), + ), + ).toBe(false); + }); +}); + +describe("registry — opt-in marker", () => { + it("marks only the mcp module opt-in (every other module is default-on)", () => { + expect(isOptInModule("mcp")).toBe(true); + for (const key of MODULE_KEYS) { + if (key === "mcp") continue; + expect(isOptInModule(key)).toBe(false); + } + }); +}); + describe("resolveOperatorAvailability — disabled allowlist (default-available)", () => { it("treats null / non-object / array as all-available", () => { for (const raw of [null, undefined, "nope", ["mood"]]) { diff --git a/src/lib/modules/gate.ts b/src/lib/modules/gate.ts index 34a4797a9..c568356a5 100644 --- a/src/lib/modules/gate.ts +++ b/src/lib/modules/gate.ts @@ -53,7 +53,12 @@ import { getOperatorModuleAvailability, type OperatorModuleAvailability, } from "./operator-availability"; -import { MODULE_KEYS, moduleDelegatesTo, type ModuleKey } from "./registry"; +import { + MODULE_KEYS, + isOptInModule, + moduleDelegatesTo, + type ModuleKey, +} from "./registry"; export { getOperatorModuleAvailability, @@ -142,6 +147,13 @@ export function resolveModuleEnabled( return assistantCoach && !inputs.disableCoach; } + // Opt-in module (e.g. the remote MCP endpoint): OFF until the user + // records an explicit `true`. The inverse of the default-on posture so a + // new external surface ships dark (ADR-007 / REQ-OPS-1). + if (isOptInModule(key)) { + return inputs.modulePreferences[key] === true; + } + // Disabled allowlist: only an explicit `false` turns the module off. return inputs.modulePreferences[key] !== false; } diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 2c6800775..0ae94722a 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -49,7 +49,8 @@ export type ModuleCategory = | "device" // device / passive-sync domains (sleep, workouts, recovery) | "engagement" // gamification (achievements) | "intelligence" // AI-driven surfaces (coach, insights) - | "export"; // outbound reporting (doctor report / FHIR) + | "export" // outbound reporting (doctor report / FHIR) + | "integration"; // external connectivity (the remote MCP endpoint) export interface ModuleDefinition { /** Stable key — the literal used in `modulePreferencesJson` + the API. */ @@ -66,6 +67,17 @@ export interface ModuleDefinition { * there is exactly one source of truth. See `ModuleDelegation`. */ delegatesTo?: ModuleDelegation; + /** + * Inverts the per-user default for this key. Ordinary toggleable modules + * are a DISABLED allowlist (default-on; only an explicit `false` turns + * them off). An `optIn` module is the opposite: OFF until the user + * records an explicit `true`. Used for surfaces that expose a new + * external attack surface and must ship dark — the remote MCP endpoint + * (ADR-007 / REQ-OPS-1). The operator-availability layer still applies + * (an operator can disable it server-wide); this flag only flips the + * per-user default. + */ + optIn?: boolean; /** * For delegated modules only: where the real on/off control lives, so the * Modules hub can render a read-only "managed in X" row that deep-links to @@ -103,6 +115,14 @@ export const MODULE_KEYS = [ // re-enabling finds the rows intact. "medications", "doctorReport", + // v1.22.0 — the remote Model Context Protocol endpoint (`/mcp`). Unlike + // every other module this is OPT-IN (off by default): it exposes a new + // external-assistant attack surface, so it ships dark and the operator / + // user turns it on deliberately (ADR-007 / REQ-OPS-1). The `optIn` marker + // on its registry entry inverts the per-user default; everything else + // (operator-availability layer, PATCH plumbing, the Modules hub toggle) + // reuses the existing module machinery unchanged. + "mcp", ] as const; export type ModuleKey = (typeof MODULE_KEYS)[number]; @@ -236,6 +256,15 @@ export const MODULE_REGISTRY: Readonly> = descriptionKey: "modules.doctorReport.description", category: "export", }, + mcp: { + key: "mcp", + labelKey: "modules.mcp.label", + descriptionKey: "modules.mcp.description", + category: "integration", + // Off by default: exposes a remote external-assistant surface, so it + // must be turned on deliberately (ADR-007 / REQ-OPS-1). + optIn: true, + }, }); const MODULE_KEY_SET: ReadonlySet = new Set(MODULE_KEYS); @@ -251,3 +280,12 @@ export function moduleDelegatesTo( ): ModuleDelegation | undefined { return MODULE_REGISTRY[key].delegatesTo; } + +/** + * True for a module whose per-user default is OFF (opt-in). The gate reads + * such a key as enabled only on an explicit `true`, the inverse of the + * disabled-allowlist default-on posture. See `ModuleDefinition.optIn`. + */ +export function isOptInModule(key: ModuleKey): boolean { + return MODULE_REGISTRY[key].optIn === true; +} diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 57e970e8e..90f1c5968 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -87,6 +87,24 @@ export async function checkAnalyticsReadRateLimit( ); } +/** + * v1.22.0 — per-credential ceiling for the remote MCP endpoint (`/mcp`). + * The bucket is keyed by the `:` binding the Bearer + * resolver surfaces — NOT the user alone — so a single leaked / shared + * token cannot exhaust the budget for the account's other tokens, and the + * cap is scoped to the exact credential in use (REQ-SEC-9, REQ-SEC-11). + * An MCP client legitimately fans several tool calls out per turn, so the + * window is generous; it only caps a runaway loop or a scripted drain. + */ +const MCP_LIMIT = 120; +const MCP_WINDOW_MS = 60 * 1000; + +export async function checkMcpRateLimit( + binding: string, +): Promise { + return checkRateLimit(`mcp:${binding}`, MCP_LIMIT, MCP_WINDOW_MS); +} + /** * v1.16.16 — per-user ceiling for the AI-consent surfaces * (`POST /api/consent/ai`, `POST /api/consent/ai/web`, diff --git a/src/proxy.ts b/src/proxy.ts index 7cd5119e2..0061456bb 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -86,6 +86,16 @@ function isPublicPath(pathname: string): boolean { if (pathname === "/onboarding" || pathname.startsWith("/onboarding/")) { return true; } + // v1.22.0 — the remote MCP endpoint authenticates by `Authorization: + // Bearer hlk_…`, never by a session cookie, so it must skip the page + // cookie-redirect (mirroring the webhook / `/api/c/` header-authed + // surfaces). The route handler resolves the Bearer token itself, gates on + // the off-by-default `mcp` module, and rate-limits per credential. Exact + // `/mcp` plus `/mcp/` only — a `/mcp-something` page can never inherit + // the bypass. + if (pathname === "/mcp" || pathname.startsWith("/mcp/")) { + return true; + } if (WELL_KNOWN_PUBLIC_PATHS.has(pathname)) { return true; } diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts index c43a8e404..574237115 100644 --- a/tests/integration/doctor-report-sections.test.ts +++ b/tests/integration/doctor-report-sections.test.ts @@ -317,6 +317,7 @@ describe("doctor-report — module enable/disable gating", () => { insights: true, medications: true, doctorReport: true, + mcp: true, } as const; it("includes every module's data when all modules are enabled", async () => {