mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
d947bb1104
commit
03d41bc5c0
17 files changed
+599
-2
No files matched your search
@@ -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`;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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(() => {});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user