Files
HealthLog/src/cli/mcp-stdio.ts
T
Marc-André Bombeck d947bb1104 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.
2026-06-27 13:25:53 +02:00

66 lines
2.1 KiB
TypeScript

/**
* 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);
});