feat(mcp): add a local stdio MCP server for read-only health data

Introduce a read-only Model Context Protocol surface built on the official
@modelcontextprotocol/sdk so a user can query their own HealthLog data from an
MCP-capable assistant over local stdio with a pasted, scoped Bearer token — no
extra infrastructure.

The tool and resource layer in src/lib/mcp is transport-agnostic: five read
tools — list_metrics, get_metric_series, get_medication_compliance, get_labs,
get_correlations — each a thin wrapper over the existing server-authoritative
retrieval paths, returning structured values, units, reference bands, and
provenance with an explicit { present: false } for absence and no prose
verdicts. Two resources expose the user's profile (data-minimised) and
medication list. A stdio entrypoint (src/cli/mcp-stdio.ts, pnpm mcp:stdio)
resolves the token through the shared Bearer path and binds the session to
<user>:<token>; with no token it refuses to start.

Read-only and admin-unreachable by construction: only read tools are
registered, and requireAdmin stays cookie-only so the Bearer/MCP wire can never
elevate. Covered by unit tests for every tool and resource, the auth/scope
model, and the cookie-only admin boundary. The server ships in the existing
container; docker compose up is unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 13:25:53 +02:00
1 parent f3e004998a
commit d947bb1104
13 files changed
+1061 -1

No files matched your search

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