feat(mcp): serve the read-only tools over remote Streamable HTTP at /mcp

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 <userId>:<tokenId> binding
  (mcp:<binding>), 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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 13:47:24 +02:00
1 parent d947bb1104
commit 03d41bc5c0
17 files changed
+599 -2

No files matched your search

+6
View File
@@ -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`;
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+269
View File
@@ -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<string, string> = {}): 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 `<userId>:<tokenId>` 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(() => {});
});
});
+163
View File
@@ -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_<hex>` 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 `<userId>:<tokenId>`
* binding (`mcp:<binding>`), 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 <token>` 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<Response> {
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 `<userId>:<tokenId>` 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;
@@ -34,6 +34,7 @@ import {
FileText,
MessageCircleHeart,
Moon,
Plug,
Pill,
Smile,
Sparkles,
@@ -71,6 +72,8 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
// 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() {
@@ -83,6 +83,7 @@ function moduleMap(
insights: true,
medications: true,
doctorReport: true,
mcp: true,
};
return { ...base, ...overrides };
}
+52
View File
@@ -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"]]) {
+13 -1
View File
@@ -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;
}
+39 -1
View File
@@ -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<Record<ModuleKey, ModuleDefinition>> =
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<string> = 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;
}
+18
View File
@@ -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 `<userId>:<tokenId>` 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<RateLimitResult> {
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`,
+10
View File
@@ -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;
}
@@ -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 () => {