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