mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user