Files
HealthLog/src/lib/mcp/auth.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

61 lines
2.3 KiB
TypeScript

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