From f3e004998ac1e6c0c9d37d2556a6b7e26fa24d5e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 27 Jun 2026 13:25:43 +0200
Subject: [PATCH 01/11] refactor(auth): extract Bearer-token validation into a
reusable resolver
Move the canonical Bearer-token validation out of the api-handler request
wrapper into src/lib/auth/bearer.ts so it can run outside a Next.js request
scope without dragging next/server, next/headers, or the Wide-Event machinery
into the import graph. requireAuth's Bearer path now delegates to it and keeps
the existing auth.bearer.* audit trail and Wide-Event annotation, so the
revoked / expired / scope / lastUsedAt semantics live in one place and cannot
drift between callers.
Covered by a focused unit test pinning the hash-only lookup and the
revoked / expired / insufficient-permission rejections; the existing
require-auth Bearer suite stays green.
---
src/lib/api-handler.ts | 140 +++++++------------------
src/lib/auth/__tests__/bearer.test.ts | 130 +++++++++++++++++++++++
src/lib/auth/bearer.ts | 144 ++++++++++++++++++++++++++
3 files changed, 312 insertions(+), 102 deletions(-)
create mode 100644 src/lib/auth/__tests__/bearer.test.ts
create mode 100644 src/lib/auth/bearer.ts
diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts
index 4733b9ac2..ba78ad92f 100644
--- a/src/lib/api-handler.ts
+++ b/src/lib/api-handler.ts
@@ -6,9 +6,8 @@ import { eventStorage, getEvent } from "./logging/context";
import { emitIfSampled } from "./logging/transports";
import { redactOptional, redactSecrets } from "./logging/redact";
import { getSession } from "./auth/session";
-import { hashToken } from "./auth/hmac";
-import { prisma } from "./db";
import { auditLog } from "./auth/audit";
+import { resolveBearerToken, BearerAuthError } from "./auth/bearer";
import { AssistantDisabledError } from "./feature-flags";
import { ConsentRequiredError } from "./ai/consent-guard";
@@ -291,110 +290,52 @@ export async function requireAuth(
/**
* Authenticate a raw Bearer token against `ApiToken`.
- * Annotates the Wide Event with `auth_method: "api_key"` and writes audit-log
- * entries for both success and failure.
+ *
+ * The validation itself lives in the transport-agnostic `resolveBearerToken`
+ * (`./auth/bearer`) — the single source of truth shared with the MCP wire. This
+ * wrapper adds the HTTP-edge concerns: the `auth.bearer.*` audit trail for both
+ * outcomes and the Wide-Event `auth_method: "bearer"` annotation, then maps the
+ * result onto the `AuthContext` contract (`session.id` carries the token id).
+ *
+ * Authorisation contract (unchanged): a route that declares no
+ * `requiredPermission` accepts any valid token; one that declares a scope
+ * accepts wildcard (`["*"]`) tokens and narrow-scope tokens that list it.
*/
async function authenticateBearer(
rawToken: string,
requiredPermission: string | undefined,
): Promise {
- const tokenHashValue = hashToken(rawToken);
-
- const apiToken = await prisma.apiToken.findUnique({
- where: { tokenHash: tokenHashValue },
- select: {
- id: true,
- userId: true,
- permissions: true,
- revoked: true,
- expiresAt: true,
- },
- });
-
- if (!apiToken) {
- auditLog("auth.bearer.failure", {
- details: { reason: "unknown_token" },
- }).catch(() => {});
- throw new HttpError(401, "Invalid token");
+ let resolution;
+ try {
+ resolution = await resolveBearerToken(rawToken, requiredPermission);
+ } catch (err) {
+ if (err instanceof BearerAuthError) {
+ auditLog("auth.bearer.failure", {
+ userId: err.userId ?? null,
+ details: {
+ reason: err.reason,
+ ...(err.tokenId ? { tokenId: err.tokenId } : {}),
+ ...(err.reason === "insufficient_permissions"
+ ? { required: requiredPermission }
+ : {}),
+ },
+ }).catch(() => {});
+ const message =
+ err.statusCode === 403
+ ? "Insufficient permissions"
+ : err.reason === "expired"
+ ? "Token expired"
+ : "Invalid token";
+ throw new HttpError(err.statusCode, message);
+ }
+ throw err;
}
- if (apiToken.revoked) {
- auditLog("auth.bearer.failure", {
- userId: apiToken.userId,
- details: { reason: "revoked", tokenId: apiToken.id },
- }).catch(() => {});
- throw new HttpError(401, "Invalid token");
- }
-
- if (apiToken.expiresAt && apiToken.expiresAt <= new Date()) {
- auditLog("auth.bearer.failure", {
- userId: apiToken.userId,
- details: { reason: "expired", tokenId: apiToken.id },
- }).catch(() => {});
- throw new HttpError(401, "Token expired");
- }
-
- // `["*"]` is the wildcard scope: granted to the iOS app on login so it
- // can call any authenticated route. Narrow scopes (e.g.
- // `["medication:ingest"]`) gate specific routes.
- //
- // Authorisation contract:
- // - Route declares no `requiredPermission`: any valid token passes
- // (both wildcard and narrow-scope). The route does not gate on
- // scope, so authentication alone is sufficient.
- // - Route declares a `requiredPermission`: wildcard tokens pass;
- // narrow-scope tokens must list the required permission.
- //
- // v1.4.25 W10 reconcile (code-review H2): the previous "no
- // requiredPermission ⇒ wildcard only" rule misread the contract.
- // Routes without a declared scope (`/api/measurements/by-external-ids`,
- // `/api/personal-records`, `/api/medications/[id]/glp1`,
- // `/api/dashboard/glp1`) 403'd every narrow-scope token even though
- // those routes did not intend to restrict by scope at all. Allowing
- // any authenticated token through when the route does not declare a
- // scope unblocks the v1.5 iOS endpoints while keeping the scoped-
- // route gating below intact.
- const hasWildcardPermission = apiToken.permissions.includes("*");
-
- if (
- requiredPermission &&
- !hasWildcardPermission &&
- !apiToken.permissions.includes(requiredPermission)
- ) {
- auditLog("auth.bearer.failure", {
- userId: apiToken.userId,
- details: {
- reason: "insufficient_permissions",
- tokenId: apiToken.id,
- required: requiredPermission,
- },
- }).catch(() => {});
- throw new HttpError(403, "Insufficient permissions");
- }
-
- const user = await prisma.user.findUnique({
- where: { id: apiToken.userId },
- });
-
- if (!user) {
- auditLog("auth.bearer.failure", {
- userId: apiToken.userId,
- details: { reason: "user_missing", tokenId: apiToken.id },
- }).catch(() => {});
- throw new HttpError(401, "Invalid token");
- }
-
- // Fire-and-forget: refresh lastUsedAt without blocking the request.
- prisma.apiToken
- .update({
- where: { id: apiToken.id },
- data: { lastUsedAt: new Date() },
- })
- .catch(() => {});
+ const { user, tokenId, expiresAt } = resolution;
auditLog("auth.bearer.success", {
userId: user.id,
- details: { tokenId: apiToken.id },
+ details: { tokenId },
}).catch(() => {});
const evt = getEvent();
@@ -406,13 +347,8 @@ async function authenticateBearer(
});
}
- // Use the token expiry as the session expiry; fall back to a 30-day window if
- // the token has no fixed expiry so the contract `{ expiresAt: Date }` holds.
- const expiresAt =
- apiToken.expiresAt ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
-
return {
- session: { id: apiToken.id, expiresAt },
+ session: { id: tokenId, expiresAt },
user,
};
}
diff --git a/src/lib/auth/__tests__/bearer.test.ts b/src/lib/auth/__tests__/bearer.test.ts
new file mode 100644
index 000000000..f3dc89159
--- /dev/null
+++ b/src/lib/auth/__tests__/bearer.test.ts
@@ -0,0 +1,130 @@
+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/hmac", () => ({ hashToken: vi.fn() }));
+
+import { resolveBearerToken, BearerAuthError } from "../bearer";
+import { prisma } from "@/lib/db";
+import { hashToken } from "@/lib/auth/hmac";
+
+const FAKE_HASH = "deadbeefcafef00d";
+const RAW_TOKEN = "hlk_" + "a".repeat(64);
+const FAKE_USER = { id: "user-1", role: "USER" };
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ vi.mocked(hashToken).mockReturnValue(FAKE_HASH);
+ vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
+});
+
+describe("resolveBearerToken", () => {
+ it("looks the token up by HMAC hash, never the plaintext", async () => {
+ vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
+ id: "token-1",
+ userId: "user-1",
+ permissions: ["*"],
+ revoked: false,
+ expiresAt: new Date(Date.now() + 86_400_000),
+ } as never);
+ vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
+
+ const res = await resolveBearerToken(RAW_TOKEN);
+
+ expect(hashToken).toHaveBeenCalledWith(RAW_TOKEN);
+ expect(prisma.apiToken.findUnique).toHaveBeenCalledWith(
+ expect.objectContaining({ where: { tokenHash: FAKE_HASH } }),
+ );
+ expect(res.user.id).toBe("user-1");
+ expect(res.tokenId).toBe("token-1");
+ expect(res.permissions).toEqual(["*"]);
+ expect(prisma.apiToken.update).toHaveBeenCalledWith({
+ where: { id: "token-1" },
+ data: { lastUsedAt: expect.any(Date) },
+ });
+ });
+
+ it("rejects an unknown token (401)", async () => {
+ vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null as never);
+ await expect(resolveBearerToken(RAW_TOKEN)).rejects.toMatchObject({
+ statusCode: 401,
+ reason: "unknown_token",
+ } satisfies Partial);
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
+ });
+
+ it("rejects a revoked token (401)", async () => {
+ vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
+ id: "token-2",
+ userId: "user-1",
+ permissions: [],
+ revoked: true,
+ expiresAt: null,
+ } as never);
+ await expect(resolveBearerToken(RAW_TOKEN)).rejects.toMatchObject({
+ statusCode: 401,
+ reason: "revoked",
+ });
+ });
+
+ it("rejects an expired token (401)", async () => {
+ vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
+ id: "token-3",
+ userId: "user-1",
+ permissions: [],
+ revoked: false,
+ expiresAt: new Date(Date.now() - 60_000),
+ } as never);
+ await expect(resolveBearerToken(RAW_TOKEN)).rejects.toMatchObject({
+ statusCode: 401,
+ reason: "expired",
+ });
+ });
+
+ it("rejects a narrow-scope token missing the required permission (403)", async () => {
+ vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
+ id: "token-4",
+ userId: "user-1",
+ permissions: ["something:else"],
+ revoked: false,
+ expiresAt: null,
+ } as never);
+ await expect(
+ resolveBearerToken(RAW_TOKEN, "health:write"),
+ ).rejects.toMatchObject({
+ statusCode: 403,
+ reason: "insufficient_permissions",
+ });
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
+ });
+
+ it("admits any valid token when no permission is required (REQ-SEC-5)", async () => {
+ vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
+ id: "token-5",
+ userId: "user-1",
+ permissions: ["medication:ingest"],
+ revoked: false,
+ expiresAt: null,
+ } as never);
+ vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
+ const res = await resolveBearerToken(RAW_TOKEN);
+ expect(res.user.id).toBe("user-1");
+ });
+
+ it("admits a wildcard token for any required permission", async () => {
+ vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
+ id: "token-6",
+ userId: "user-1",
+ permissions: ["*"],
+ revoked: false,
+ expiresAt: null,
+ } as never);
+ vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
+ const res = await resolveBearerToken(RAW_TOKEN, "health:write");
+ expect(res.user.id).toBe("user-1");
+ });
+});
diff --git a/src/lib/auth/bearer.ts b/src/lib/auth/bearer.ts
new file mode 100644
index 000000000..694f27f14
--- /dev/null
+++ b/src/lib/auth/bearer.ts
@@ -0,0 +1,144 @@
+/**
+ * Canonical Bearer-token validation — the single source of truth for resolving
+ * a raw `hlk_` token to a user.
+ *
+ * Extracted from `api-handler.ts` so it can be reused outside a Next.js request
+ * scope (the local MCP stdio transport runs in a plain Node process and must not
+ * drag `next/server` / `next/headers` / the Wide-Event machinery into its import
+ * graph). The HTTP edge (`requireAuth` → `authenticateBearer`) and the MCP edge
+ * both call this one function, so the revoked / expired / scope / `lastUsedAt`
+ * semantics can never drift between the two wires.
+ *
+ * This module is deliberately lean: it depends only on the HMAC hasher and
+ * Prisma. Audit-log emission + Wide-Event annotation stay with each caller (the
+ * HTTP path keeps its existing `auth.bearer.*` audit trail), so this layer adds
+ * no I/O beyond the two reads the validation needs.
+ */
+import type { User } from "@/generated/prisma/client";
+import { prisma } from "@/lib/db";
+import { hashToken } from "@/lib/auth/hmac";
+
+/** Why a Bearer token was rejected. Stable handles for audit details. */
+export type BearerAuthReason =
+ | "unknown_token"
+ | "revoked"
+ | "expired"
+ | "insufficient_permissions"
+ | "user_missing";
+
+/**
+ * Thrown when a token fails validation. Carries the HTTP status the HTTP edge
+ * should surface plus the machine `reason` (and, when known, the owning
+ * `userId` / `tokenId`) so a caller can write a faithful audit entry without
+ * re-deriving them.
+ */
+export class BearerAuthError extends Error {
+ constructor(
+ public readonly statusCode: 401 | 403,
+ public readonly reason: BearerAuthReason,
+ public readonly userId?: string,
+ public readonly tokenId?: string,
+ ) {
+ super(reason);
+ this.name = "BearerAuthError";
+ }
+}
+
+/** The validated identity behind a Bearer token. */
+export interface BearerResolution {
+ user: User;
+ /** The `ApiToken` row id (the token-id half of the `:` binding). */
+ tokenId: string;
+ /** The token's granted scopes (`["*"]` = wildcard). */
+ permissions: string[];
+ /** Token expiry, or a 30-day fallback window when the token has no fixed expiry. */
+ expiresAt: Date;
+}
+
+/**
+ * Resolve a raw `hlk_` token to its user, or throw `BearerAuthError`.
+ *
+ * Lookups are by HMAC hash only — the plaintext token is never compared against
+ * a stored value. When `requiredPermission` is set, a non-wildcard token must
+ * list it; an unset `requiredPermission` admits any valid token (matching the
+ * "route declares no scope ⇒ authentication alone is sufficient" contract).
+ *
+ * `lastUsedAt` is refreshed fire-and-forget on success.
+ */
+export async function resolveBearerToken(
+ rawToken: string,
+ requiredPermission?: string,
+): Promise {
+ const tokenHashValue = hashToken(rawToken);
+
+ const apiToken = await prisma.apiToken.findUnique({
+ where: { tokenHash: tokenHashValue },
+ select: {
+ id: true,
+ userId: true,
+ permissions: true,
+ revoked: true,
+ expiresAt: true,
+ },
+ });
+
+ if (!apiToken) {
+ throw new BearerAuthError(401, "unknown_token");
+ }
+
+ if (apiToken.revoked) {
+ throw new BearerAuthError(401, "revoked", apiToken.userId, apiToken.id);
+ }
+
+ if (apiToken.expiresAt && apiToken.expiresAt <= new Date()) {
+ throw new BearerAuthError(401, "expired", apiToken.userId, apiToken.id);
+ }
+
+ // `["*"]` is the wildcard scope. A route that declares no `requiredPermission`
+ // accepts any valid token; one that declares a scope accepts wildcard tokens
+ // and narrow-scope tokens that list that scope.
+ const hasWildcardPermission = apiToken.permissions.includes("*");
+ if (
+ requiredPermission &&
+ !hasWildcardPermission &&
+ !apiToken.permissions.includes(requiredPermission)
+ ) {
+ throw new BearerAuthError(
+ 403,
+ "insufficient_permissions",
+ apiToken.userId,
+ apiToken.id,
+ );
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { id: apiToken.userId },
+ });
+
+ if (!user) {
+ throw new BearerAuthError(
+ 401,
+ "user_missing",
+ apiToken.userId,
+ apiToken.id,
+ );
+ }
+
+ // Fire-and-forget: refresh lastUsedAt without blocking the caller.
+ prisma.apiToken
+ .update({
+ where: { id: apiToken.id },
+ data: { lastUsedAt: new Date() },
+ })
+ .catch(() => {});
+
+ const expiresAt =
+ apiToken.expiresAt ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
+
+ return {
+ user,
+ tokenId: apiToken.id,
+ permissions: apiToken.permissions,
+ expiresAt,
+ };
+}
From d947bb1104b7b3c030dacd3d8fc105d3e4c9e1c1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 27 Jun 2026 13:25:53 +0200
Subject: [PATCH 02/11] feat(mcp): add a local stdio MCP server for read-only
health data
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
:; 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.
---
package.json | 4 +-
pnpm-lock.yaml | 29 +++
src/cli/mcp-stdio.ts | 65 ++++++
src/lib/mcp/__tests__/admin-boundary.test.ts | 67 ++++++
src/lib/mcp/__tests__/auth.test.ts | 56 +++++
src/lib/mcp/__tests__/resources.test.ts | 133 +++++++++++
src/lib/mcp/__tests__/tools.test.ts | 218 +++++++++++++++++++
src/lib/mcp/auth.ts | 60 +++++
src/lib/mcp/index.ts | 25 +++
src/lib/mcp/resources.ts | 130 +++++++++++
src/lib/mcp/scopes.ts | 58 +++++
src/lib/mcp/server.ts | 77 +++++++
src/lib/mcp/tools.ts | 140 ++++++++++++
13 files changed, 1061 insertions(+), 1 deletion(-)
create mode 100644 src/cli/mcp-stdio.ts
create mode 100644 src/lib/mcp/__tests__/admin-boundary.test.ts
create mode 100644 src/lib/mcp/__tests__/auth.test.ts
create mode 100644 src/lib/mcp/__tests__/resources.test.ts
create mode 100644 src/lib/mcp/__tests__/tools.test.ts
create mode 100644 src/lib/mcp/auth.ts
create mode 100644 src/lib/mcp/index.ts
create mode 100644 src/lib/mcp/resources.ts
create mode 100644 src/lib/mcp/scopes.ts
create mode 100644 src/lib/mcp/server.ts
create mode 100644 src/lib/mcp/tools.ts
diff --git a/package.json b/package.json
index 348c30ddf..dca61878d 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 525664542..3d5b8b856 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
diff --git a/src/cli/mcp-stdio.ts b/src/cli/mcp-stdio.ts
new file mode 100644
index 000000000..880fc1efc
--- /dev/null
+++ b/src/cli/mcp-stdio.ts
@@ -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_`
+ * 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 {
+ 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);
+});
diff --git a/src/lib/mcp/__tests__/admin-boundary.test.ts b/src/lib/mcp/__tests__/admin-boundary.test.ts
new file mode 100644
index 000000000..e1f329aea
--- /dev/null
+++ b/src/lib/mcp/__tests__/admin-boundary.test.ts
@@ -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 });
+ });
+});
diff --git a/src/lib/mcp/__tests__/auth.test.ts b/src/lib/mcp/__tests__/auth.test.ts
new file mode 100644
index 000000000..c73861031
--- /dev/null
+++ b/src/lib/mcp/__tests__/auth.test.ts
@@ -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 : (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();
+ });
+});
diff --git a/src/lib/mcp/__tests__/resources.test.ts b/src/lib/mcp/__tests__/resources.test.ts
new file mode 100644
index 000000000..94b25e3f1
--- /dev/null
+++ b/src/lib/mcp/__tests__/resources.test.ts
@@ -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;
+
+ 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);
+ });
+});
diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts
new file mode 100644
index 000000000..454290f23
--- /dev/null
+++ b/src/lib/mcp/__tests__/tools.test.ts
@@ -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");
+ }
+ });
+});
diff --git a/src/lib/mcp/auth.ts b/src/lib/mcp/auth.ts
new file mode 100644
index 000000000..7d6fb57dd
--- /dev/null
+++ b/src/lib/mcp/auth.ts
@@ -0,0 +1,60 @@
+/**
+ * MCP auth context.
+ *
+ * Resolves a pasted `hlk_` 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 `:` (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[];
+ /** `:` — 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 {
+ 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),
+ };
+}
diff --git a/src/lib/mcp/index.ts b/src/lib/mcp/index.ts
new file mode 100644
index 000000000..82fc91438
--- /dev/null
+++ b/src/lib/mcp/index.ts
@@ -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";
diff --git a/src/lib/mcp/resources.ts b/src/lib/mcp/resources.ts
new file mode 100644
index 000000000..10c010851
--- /dev/null
+++ b/src/lib/mcp/resources.ts
@@ -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;
+}
+
+/** 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,
+);
diff --git a/src/lib/mcp/scopes.ts b/src/lib/mcp/scopes.ts
new file mode 100644
index 000000000..b04d57d7e
--- /dev/null
+++ b/src/lib/mcp/scopes.ts
@@ -0,0 +1,58 @@
+/**
+ * MCP scope model.
+ *
+ * The MCP wire reuses the existing `hlk_` 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 `:` 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}`;
+}
diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts
new file mode 100644
index 000000000..52905c5af
--- /dev/null
+++ b/src/lib/mcp/server.ts
@@ -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) => {
+ 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;
+}
diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts
new file mode 100644
index 000000000..ac44f5426
--- /dev/null
+++ b/src/lib/mcp/tools.ts
@@ -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) => Promise;
+}
+
+/**
+ * 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,
+): Promise {
+ 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);
From 03d41bc5c00c54d596dcfa2456a7a2b41a153c5e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 27 Jun 2026 13:47:09 +0200
Subject: [PATCH 03/11] feat(mcp): serve the read-only tools over remote
Streamable HTTP at /mcp
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 : binding
(mcp:), 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.
---
docs/api/openapi.yaml | 6 +
messages/de.json | 4 +
messages/en.json | 4 +
messages/es.json | 4 +
messages/fr.json | 4 +
messages/it.json | 4 +
messages/pl.json | 4 +
src/app/mcp/__tests__/route.test.ts | 269 ++++++++++++++++++
src/app/mcp/route.ts | 163 +++++++++++
src/components/settings/modules-section.tsx | 3 +
src/lib/dashboard/__tests__/snapshot.test.ts | 1 +
src/lib/modules/__tests__/gate.test.ts | 52 ++++
src/lib/modules/gate.ts | 14 +-
src/lib/modules/registry.ts | 40 ++-
src/lib/rate-limit.ts | 18 ++
src/proxy.ts | 10 +
.../doctor-report-sections.test.ts | 1 +
17 files changed, 599 insertions(+), 2 deletions(-)
create mode 100644 src/app/mcp/__tests__/route.test.ts
create mode 100644 src/app/mcp/route.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 17cfd4dd7..586a1f894 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -8258,6 +8258,8 @@ components:
type: boolean
doctorReport:
type: boolean
+ mcp:
+ type: boolean
additionalProperties: false
description: Partial module enable/disable update. Each key is an optional boolean; an omitted key is left untouched,
`false` disables the module, `true` (re-)enables it. Only the toggleable "secondary domains" are accepted — a
@@ -14465,6 +14467,9 @@ components:
doctorReport:
type: boolean
description: Whether the "doctorReport" module is enabled for this account.
+ mcp:
+ type: boolean
+ description: Whether the "mcp" module is enabled for this account.
required:
- cycle
- mood
@@ -14479,6 +14484,7 @@ components:
- insights
- medications
- doctorReport
+ - mcp
additionalProperties: false
description: Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means
the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`;
diff --git a/messages/de.json b/messages/de.json
index c07f8cf1f..3bfa2f640 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -6861,6 +6861,10 @@
"illness": {
"label": "Krankheitstagebuch",
"description": "Krankheits- und Beschwerde-Episoden mit täglichem Symptomverlauf festhalten."
+ },
+ "mcp": {
+ "label": "Externer Assistentenzugriff (MCP)",
+ "description": "Einem MCP-fähigen Assistenten Lesezugriff auf deine Gesundheitsdaten über ein begrenztes Token erlauben. Standardmäßig aus."
}
},
"illness": {
diff --git a/messages/en.json b/messages/en.json
index 4e8e9c6f5..0ac7a69b0 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -6861,6 +6861,10 @@
"illness": {
"label": "Illness journal",
"description": "Track illness and condition episodes with a daily symptom timeline."
+ },
+ "mcp": {
+ "label": "External assistant access (MCP)",
+ "description": "Let an MCP-capable assistant read your health data over a scoped token. Off by default."
}
},
"illness": {
diff --git a/messages/es.json b/messages/es.json
index fe481268d..d4b0c0842 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -6861,6 +6861,10 @@
"illness": {
"label": "Diario de enfermedad",
"description": "Registra episodios de enfermedad y dolencias con un historial diario de síntomas."
+ },
+ "mcp": {
+ "label": "Acceso de asistente externo (MCP)",
+ "description": "Permite que un asistente compatible con MCP lea tus datos de salud mediante un token con alcance limitado. Desactivado de forma predeterminada."
}
},
"illness": {
diff --git a/messages/fr.json b/messages/fr.json
index 11ab9eee5..a7f6ebc03 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -6861,6 +6861,10 @@
"illness": {
"label": "Journal de maladie",
"description": "Suivez les épisodes de maladie et d'affections avec un historique quotidien des symptômes."
+ },
+ "mcp": {
+ "label": "Accès assistant externe (MCP)",
+ "description": "Autorise un assistant compatible MCP à lire vos données de santé via un jeton à portée limitée. Désactivé par défaut."
}
},
"illness": {
diff --git a/messages/it.json b/messages/it.json
index b3f1f0c92..2f07c29a2 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -6861,6 +6861,10 @@
"illness": {
"label": "Diario delle malattie",
"description": "Registra episodi di malattia e disturbi con una cronologia giornaliera dei sintomi."
+ },
+ "mcp": {
+ "label": "Accesso assistente esterno (MCP)",
+ "description": "Consente a un assistente compatibile con MCP di leggere i tuoi dati sanitari tramite un token con ambito limitato. Disattivato per impostazione predefinita."
}
},
"illness": {
diff --git a/messages/pl.json b/messages/pl.json
index d227d654f..101899b35 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -6861,6 +6861,10 @@
"illness": {
"label": "Dziennik chorób",
"description": "Zapisuj epizody chorób i dolegliwości z dziennym przebiegiem objawów."
+ },
+ "mcp": {
+ "label": "Dostęp asystenta zewnętrznego (MCP)",
+ "description": "Pozwól asystentowi obsługującemu MCP czytać Twoje dane zdrowotne za pomocą tokena o ograniczonym zakresie. Domyślnie wyłączone."
}
},
"illness": {
diff --git a/src/app/mcp/__tests__/route.test.ts b/src/app/mcp/__tests__/route.test.ts
new file mode 100644
index 000000000..90f9ca47c
--- /dev/null
+++ b/src/app/mcp/__tests__/route.test.ts
@@ -0,0 +1,269 @@
+/**
+ * Remote MCP endpoint (`/mcp`) — transport + gate tests.
+ *
+ * Drives the route handler with Web-standard `Request`s and asserts the
+ * four front gates plus the read-only / admin-unreachable posture over the
+ * HTTP wire:
+ *
+ * - serves the tool list with a valid token (module on, under budget);
+ * - rejects a missing / malformed / rejected (revoked / expired / unknown)
+ * token with one blunt 401;
+ * - hides the surface (404) when the off-by-default `mcp` module is off;
+ * - trips 429 when the per-credential rate limit is exhausted;
+ * - never consults a cookie session, so the cookie-only `requireAdmin()`
+ * boundary is unreachable here regardless of token scope — a `["*"]`
+ * wildcard token still sees only the read tools (ADR-003 / ADR-005).
+ *
+ * The Bearer validator, module gate, and rate limiter are mocked so the
+ * test exercises the route's own wiring without a DB; the structural
+ * cookie-only admin boundary itself is pinned by the sibling
+ * `src/lib/mcp/__tests__/admin-boundary.test.ts`.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/auth/bearer", () => ({
+ resolveBearerToken: vi.fn(),
+ BearerAuthError: class BearerAuthError extends Error {},
+}));
+vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() }));
+vi.mock("@/lib/rate-limit", () => ({
+ checkMcpRateLimit: vi.fn(),
+ rateLimitHeaders: vi.fn((r: { remaining: number; resetAt: number }) => ({
+ "X-RateLimit-Remaining": String(r.remaining),
+ "X-RateLimit-Reset": new Date(r.resetAt).toISOString(),
+ })),
+}));
+// The factory pulls the Coach retrieval layer through `tools.ts`; stub the
+// heavy executor + inventory so importing the route never reaches a DB. The
+// list/reject paths never execute a tool, so the stubs are never called.
+vi.mock("@/lib/ai/coach/tools/executor", () => ({ executeCoachTool: vi.fn() }));
+vi.mock("@/lib/ai/coach/tools/inventory", () => ({
+ buildCoachDataInventory: vi.fn(),
+}));
+// A cookie path must NEVER be reached from the MCP wire. Mock both cookie
+// sources so the test can assert they are never consulted.
+const getSession = vi.fn();
+vi.mock("@/lib/auth/session", () => ({ getSession }));
+const cookiesGet = vi.fn(() => undefined);
+const headersGet = vi.fn(() => null);
+vi.mock("next/headers", () => ({
+ cookies: vi.fn(async () => ({
+ get: cookiesGet,
+ set: () => {},
+ delete: () => {},
+ })),
+ headers: vi.fn(async () => ({ get: headersGet })),
+}));
+
+import { POST, GET } from "../route";
+import { resolveBearerToken } from "@/lib/auth/bearer";
+import { isModuleEnabled } from "@/lib/modules/gate";
+import { checkMcpRateLimit } from "@/lib/rate-limit";
+
+const READ_TOOLS = [
+ "get_correlations",
+ "get_labs",
+ "get_medication_compliance",
+ "get_metric_series",
+ "list_metrics",
+].sort();
+
+/** A valid, narrow-scope (`health:read`) token resolution. */
+function validToken(permissions: string[] = ["health:read"]) {
+ vi.mocked(resolveBearerToken).mockResolvedValue({
+ user: { id: "user-1" } as never,
+ tokenId: "token-1",
+ permissions,
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000),
+ });
+}
+
+function postRpc(body: unknown, headers: Record = {}): Request {
+ return new Request("http://localhost/mcp", {
+ method: "POST",
+ headers: {
+ authorization: "Bearer hlk_" + "a".repeat(64),
+ "content-type": "application/json",
+ accept: "application/json, text/event-stream",
+ ...headers,
+ },
+ body: JSON.stringify(body),
+ });
+}
+
+const TOOLS_LIST = { jsonrpc: "2.0", id: 1, method: "tools/list" };
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ cookiesGet.mockReturnValue(undefined);
+ headersGet.mockReturnValue(null);
+ // Default happy-path: module on, under budget.
+ vi.mocked(isModuleEnabled).mockResolvedValue(true);
+ vi.mocked(checkMcpRateLimit).mockResolvedValue({
+ allowed: true,
+ remaining: 119,
+ resetAt: Date.now() + 60_000,
+ });
+});
+
+describe("/mcp — happy path", () => {
+ it("serves the read-tool list over HTTP with a valid token", async () => {
+ validToken();
+ const res = await POST(postRpc(TOOLS_LIST));
+
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ const names = (body.result.tools as Array<{ name: string }>)
+ .map((t) => t.name)
+ .sort();
+ expect(names).toEqual(READ_TOOLS);
+
+ // The gate ran in the documented order: auth → rate-limit → module.
+ expect(resolveBearerToken).toHaveBeenCalledTimes(1);
+ expect(checkMcpRateLimit).toHaveBeenCalledWith("user-1:token-1");
+ expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "mcp");
+ });
+
+ it("completes the initialize handshake with the HealthLog server identity", async () => {
+ validToken();
+ const res = await POST(
+ postRpc({
+ jsonrpc: "2.0",
+ id: 1,
+ method: "initialize",
+ params: {
+ protocolVersion: "2025-06-18",
+ capabilities: {},
+ clientInfo: { name: "test", version: "1" },
+ },
+ }),
+ );
+
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.result.serverInfo.name).toBe("healthlog");
+ // Tools + resources are advertised; no admin capability exists.
+ expect(body.result.capabilities.tools).toBeDefined();
+ });
+});
+
+describe("/mcp — authentication", () => {
+ it("rejects a request with no Authorization header (401 + challenge)", async () => {
+ const res = await POST(
+ new Request("http://localhost/mcp", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(TOOLS_LIST),
+ }),
+ );
+
+ expect(res.status).toBe(401);
+ expect(res.headers.get("www-authenticate")).toMatch(/Bearer/);
+ // The token table is never consulted when no token is presented.
+ expect(resolveBearerToken).not.toHaveBeenCalled();
+ // No module read, no rate-limit hit before auth fails.
+ expect(isModuleEnabled).not.toHaveBeenCalled();
+ expect(checkMcpRateLimit).not.toHaveBeenCalled();
+ });
+
+ it("rejects a malformed Authorization header (not Bearer) with 401", async () => {
+ const res = await POST(
+ postRpc(TOOLS_LIST, { authorization: "Basic abc123" }),
+ );
+ expect(res.status).toBe(401);
+ expect(resolveBearerToken).not.toHaveBeenCalled();
+ });
+
+ it("rejects a revoked / expired / unknown token with a blunt 401", async () => {
+ vi.mocked(resolveBearerToken).mockRejectedValue(new Error("revoked"));
+ const res = await POST(postRpc(TOOLS_LIST));
+
+ expect(res.status).toBe(401);
+ const body = await res.json();
+ // No oracle: the body never echoes the rejection reason or the token.
+ expect(JSON.stringify(body)).not.toMatch(/revoked|expired|hlk_/);
+ // A rejected token never reaches the module gate or the transport.
+ expect(isModuleEnabled).not.toHaveBeenCalled();
+ });
+});
+
+describe("/mcp — module gate (off by default)", () => {
+ it("hides the surface with 404 when the mcp module is off", async () => {
+ validToken();
+ vi.mocked(isModuleEnabled).mockResolvedValue(false);
+
+ const res = await POST(postRpc(TOOLS_LIST));
+ expect(res.status).toBe(404);
+ // Authenticated first (so the module is resolved per-user), then hidden.
+ expect(resolveBearerToken).toHaveBeenCalledTimes(1);
+ expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "mcp");
+ });
+});
+
+describe("/mcp — rate limit", () => {
+ it("trips 429 once the per-credential budget is exhausted", async () => {
+ validToken();
+ vi.mocked(checkMcpRateLimit).mockResolvedValue({
+ allowed: false,
+ remaining: 0,
+ resetAt: Date.now() + 30_000,
+ });
+
+ const res = await POST(postRpc(TOOLS_LIST));
+ expect(res.status).toBe(429);
+ expect(res.headers.get("x-ratelimit-remaining")).toBe("0");
+ // The limiter is keyed by the `:` binding.
+ expect(checkMcpRateLimit).toHaveBeenCalledWith("user-1:token-1");
+ // A throttled request never reaches the module gate or the transport.
+ expect(isModuleEnabled).not.toHaveBeenCalled();
+ });
+});
+
+describe("/mcp — admin unreachable over HTTP (read-only, cookie-only boundary)", () => {
+ it("never consults a cookie session, even with a wildcard token", async () => {
+ validToken(["*"]);
+ const res = await POST(postRpc(TOOLS_LIST));
+
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ const names = (body.result.tools as Array<{ name: string }>).map(
+ (t) => t.name,
+ );
+
+ // A `["*"]` token still sees ONLY the read tools — no write tool is
+ // registered and no admin tool exists (read-only by construction).
+ expect(names.sort()).toEqual(READ_TOOLS);
+ for (const name of names) {
+ expect(name).not.toMatch(/admin/i);
+ expect(name).not.toMatch(/^(log_|create_|update_|delete_|write_)/);
+ }
+
+ // The route authenticates purely by Bearer: a cookie session is never
+ // read, so `requireAdmin()` (cookie-only) can never be reached here.
+ expect(getSession).not.toHaveBeenCalled();
+ expect(cookiesGet).not.toHaveBeenCalled();
+ });
+});
+
+describe("/mcp — GET transport", () => {
+ it("is wired for GET (SSE stream open) behind the same gates", async () => {
+ validToken();
+ const res = await GET(
+ new Request("http://localhost/mcp", {
+ method: "GET",
+ headers: {
+ authorization: "Bearer hlk_" + "a".repeat(64),
+ accept: "text/event-stream",
+ },
+ }),
+ );
+ // Auth + gates ran; the transport answers (a stream open or a
+ // method-specific status), never a 401/404/429 on the happy path.
+ expect([200, 405]).toContain(res.status);
+ expect(resolveBearerToken).toHaveBeenCalledTimes(1);
+ expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "mcp");
+ // Release any opened stream so the test process can exit cleanly.
+ await res.body?.cancel().catch(() => {});
+ });
+});
diff --git a/src/app/mcp/route.ts b/src/app/mcp/route.ts
new file mode 100644
index 000000000..c75463026
--- /dev/null
+++ b/src/app/mcp/route.ts
@@ -0,0 +1,163 @@
+/**
+ * Remote MCP endpoint — Streamable HTTP transport.
+ *
+ * Exposes the SAME read-only tool / resource layer the stdio transport
+ * serves (`src/lib/mcp/`) over HTTP, so a remote MCP client (Claude.ai /
+ * ChatGPT / the hosted-or-iOS crowd) can connect with a manually-minted
+ * `hlk_` Bearer token before full OAuth lands (ADR-002 / ADR-006). The
+ * wire protocol — JSON-RPC framing, capability negotiation, the
+ * GET/POST/DELETE method matrix — is the official SDK's
+ * `WebStandardStreamableHTTPServerTransport`; this route only owns the four
+ * HealthLog-specific gates in front of it:
+ *
+ * 1. AUTH — `Authorization: Bearer hlk_…` resolved by the SAME validator
+ * the HTTP edge uses (`resolveMcpAuthContext` → `resolveBearerToken`),
+ * so revoked / expired / scope semantics never drift between the stdio,
+ * remote, and REST wires. A cookie is never consulted — the wire
+ * carries none — so the cookie-only `requireAdmin()` boundary is
+ * unreachable here by construction (ADR-005 / REQ-SEC-7). No token,
+ * a malformed header, or a rejected token all answer one blunt 401.
+ *
+ * 2. RATE LIMIT — the Postgres limiter keyed by the `:`
+ * binding (`mcp:`), so a single leaked / shared credential
+ * cannot drain the account's other tokens (REQ-SEC-9 / REQ-SEC-11).
+ *
+ * 3. MODULE GATE — the `mcp` module is OPT-IN (off by default). With the
+ * module off the surface answers 404 and reveals nothing — a
+ * self-hoster who never opts in gets zero new exposure (ADR-007 /
+ * REQ-OPS-1).
+ *
+ * 4. TRANSPORT — a fresh stateless server + transport per request
+ * (`sessionIdGenerator: undefined`, `enableJsonResponse: true`), torn
+ * down once the buffered JSON-RPC response is built.
+ *
+ * Read-only: the factory registers read tools only and `requireAdmin()` is
+ * cookie-only, so neither writes nor admin can be reached over this wire
+ * regardless of token scope — including `["*"]` (ADR-003 / ADR-005). The
+ * `health:write` scope stays reserved and unreachable until the write
+ * tools land in a later phase.
+ *
+ * Same-origin / no-CORS by construction: no `Access-Control-Allow-Origin`
+ * header is emitted anywhere here, matching the app-wide posture.
+ */
+import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
+
+import { createMcpServer, resolveMcpAuthContext } from "@/lib/mcp";
+import { withBackgroundEvent } from "@/lib/logging/background";
+import { annotate } from "@/lib/logging/context";
+import { isModuleEnabled } from "@/lib/modules/gate";
+import { checkMcpRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
+import type { ModuleKey } from "@/lib/modules/registry";
+
+// The Bearer resolver + module gate + rate limiter all touch Prisma, so the
+// route must run on the Node runtime, never the Edge runtime.
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+const MCP_MODULE: ModuleKey = "mcp";
+
+/** One blunt 401 for every authentication failure class — no oracle. */
+function unauthorized(): Response {
+ return Response.json(
+ { error: "unauthorized" },
+ {
+ status: 401,
+ // Pre-OAuth bridge: a bare Bearer challenge. The RFC 9728
+ // `resource_metadata` parameter is added when OAuth discovery lands.
+ headers: { "WWW-Authenticate": 'Bearer realm="healthlog-mcp"' },
+ },
+ );
+}
+
+/**
+ * Module off → the surface does not exist for this account. A 404 (not a
+ * 403) keeps the remote endpoint invisible until the operator / user opts
+ * in, so a disabled instance leaks nothing about it.
+ */
+function moduleDisabled(): Response {
+ return Response.json({ error: "not_found" }, { status: 404 });
+}
+
+/** 429 with the standard rate-limit headers. */
+function tooManyRequests(resetAt: number, remaining: number): Response {
+ const response = Response.json({ error: "rate_limited" }, { status: 429 });
+ for (const [k, v] of Object.entries(
+ rateLimitHeaders({ allowed: false, remaining, resetAt }),
+ )) {
+ response.headers.set(k, v);
+ }
+ return response;
+}
+
+/** Extract the raw token from an `Authorization: Bearer ` header. */
+function readBearer(request: Request): string | null {
+ const header = request.headers.get("authorization");
+ if (!header) return null;
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim());
+ if (!match) return null;
+ const token = match[1].trim();
+ return token.length > 0 ? token : null;
+}
+
+async function handleMcp(request: Request): Promise {
+ return withBackgroundEvent("mcp.request", async () => {
+ // 1. AUTH — Bearer only; a cookie is never consulted (admin unreachable).
+ const raw = readBearer(request);
+ if (!raw) {
+ annotate({ action: { name: "mcp.auth.missing" } });
+ return unauthorized();
+ }
+
+ let ctx;
+ try {
+ ctx = await resolveMcpAuthContext(raw);
+ } catch {
+ // Do not echo the token or the rejection reason — one blunt 401.
+ annotate({ action: { name: "mcp.auth.rejected" } });
+ return unauthorized();
+ }
+
+ // 2. RATE LIMIT — per `:` credential binding.
+ const rl = await checkMcpRateLimit(ctx.binding);
+ if (!rl.allowed) {
+ annotate({ action: { name: "mcp.rate_limited" } });
+ return tooManyRequests(rl.resetAt, rl.remaining);
+ }
+
+ // 3. MODULE GATE — off by default; hide the surface entirely when off.
+ if (!(await isModuleEnabled(ctx.userId, MCP_MODULE))) {
+ annotate({ action: { name: "mcp.module.disabled" } });
+ return moduleDisabled();
+ }
+
+ // 4. TRANSPORT — stateless: one fresh server + transport per request.
+ annotate({
+ action: { name: "mcp.request.served" },
+ meta: { method: request.method },
+ });
+
+ const server = createMcpServer(ctx);
+ const transport = new WebStandardStreamableHTTPServerTransport({
+ // Stateless mode — no server-held session; each request stands alone.
+ sessionIdGenerator: undefined,
+ // Buffer a single JSON-RPC response instead of opening an SSE stream;
+ // the read tools return one structured result per call.
+ enableJsonResponse: true,
+ });
+
+ try {
+ await server.connect(transport);
+ // In JSON-response stateless mode the returned Response is fully
+ // buffered before this resolves, so tearing the transport down in the
+ // `finally` cannot truncate it.
+ return await transport.handleRequest(request);
+ } finally {
+ await transport.close().catch(() => {});
+ await server.close().catch(() => {});
+ }
+ });
+}
+
+export const GET = handleMcp;
+export const POST = handleMcp;
+export const DELETE = handleMcp;
diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx
index 72db906b7..f6c5a5f43 100644
--- a/src/components/settings/modules-section.tsx
+++ b/src/components/settings/modules-section.tsx
@@ -34,6 +34,7 @@ import {
FileText,
MessageCircleHeart,
Moon,
+ Plug,
Pill,
Smile,
Sparkles,
@@ -71,6 +72,8 @@ const MODULE_ICONS: Record = {
// v1.18.1 (D3) — medications graduated from CORE to a toggleable module.
medications: Pill,
doctorReport: FileText,
+ // v1.22.0 — the remote MCP endpoint (opt-in connectivity module).
+ mcp: Plug,
};
export function ModulesSection() {
diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts
index 72799e24f..d1939cf09 100644
--- a/src/lib/dashboard/__tests__/snapshot.test.ts
+++ b/src/lib/dashboard/__tests__/snapshot.test.ts
@@ -83,6 +83,7 @@ function moduleMap(
insights: true,
medications: true,
doctorReport: true,
+ mcp: true,
};
return { ...base, ...overrides };
}
diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts
index 87e83011b..5b76f000c 100644
--- a/src/lib/modules/__tests__/gate.test.ts
+++ b/src/lib/modules/__tests__/gate.test.ts
@@ -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"]]) {
diff --git a/src/lib/modules/gate.ts b/src/lib/modules/gate.ts
index 34a4797a9..c568356a5 100644
--- a/src/lib/modules/gate.ts
+++ b/src/lib/modules/gate.ts
@@ -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;
}
diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts
index 2c6800775..0ae94722a 100644
--- a/src/lib/modules/registry.ts
+++ b/src/lib/modules/registry.ts
@@ -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> =
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 = 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;
+}
diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts
index 57e970e8e..90f1c5968 100644
--- a/src/lib/rate-limit.ts
+++ b/src/lib/rate-limit.ts
@@ -87,6 +87,24 @@ export async function checkAnalyticsReadRateLimit(
);
}
+/**
+ * v1.22.0 — per-credential ceiling for the remote MCP endpoint (`/mcp`).
+ * The bucket is keyed by the `:` binding the Bearer
+ * resolver surfaces — NOT the user alone — so a single leaked / shared
+ * token cannot exhaust the budget for the account's other tokens, and the
+ * cap is scoped to the exact credential in use (REQ-SEC-9, REQ-SEC-11).
+ * An MCP client legitimately fans several tool calls out per turn, so the
+ * window is generous; it only caps a runaway loop or a scripted drain.
+ */
+const MCP_LIMIT = 120;
+const MCP_WINDOW_MS = 60 * 1000;
+
+export async function checkMcpRateLimit(
+ binding: string,
+): Promise {
+ return checkRateLimit(`mcp:${binding}`, MCP_LIMIT, MCP_WINDOW_MS);
+}
+
/**
* v1.16.16 — per-user ceiling for the AI-consent surfaces
* (`POST /api/consent/ai`, `POST /api/consent/ai/web`,
diff --git a/src/proxy.ts b/src/proxy.ts
index 7cd5119e2..0061456bb 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -86,6 +86,16 @@ function isPublicPath(pathname: string): boolean {
if (pathname === "/onboarding" || pathname.startsWith("/onboarding/")) {
return true;
}
+ // v1.22.0 — the remote MCP endpoint authenticates by `Authorization:
+ // Bearer hlk_…`, never by a session cookie, so it must skip the page
+ // cookie-redirect (mirroring the webhook / `/api/c/` header-authed
+ // surfaces). The route handler resolves the Bearer token itself, gates on
+ // the off-by-default `mcp` module, and rate-limits per credential. Exact
+ // `/mcp` plus `/mcp/` only — a `/mcp-something` page can never inherit
+ // the bypass.
+ if (pathname === "/mcp" || pathname.startsWith("/mcp/")) {
+ return true;
+ }
if (WELL_KNOWN_PUBLIC_PATHS.has(pathname)) {
return true;
}
diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts
index c43a8e404..574237115 100644
--- a/tests/integration/doctor-report-sections.test.ts
+++ b/tests/integration/doctor-report-sections.test.ts
@@ -317,6 +317,7 @@ describe("doctor-report — module enable/disable gating", () => {
insights: true,
medications: true,
doctorReport: true,
+ mcp: true,
} as const;
it("includes every module's data when all modules are enabled", async () => {
From 7ec3bf0dad19722f96ca3bd2ae7d770b61e41e7d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 27 Jun 2026 14:27:09 +0200
Subject: [PATCH 04/11] feat(mcp): add an OAuth 2.1 authorization-server bridge
for remote connectors
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Let a remote MCP client (Claude.ai, ChatGPT) self-register and connect to
the `/mcp` endpoint over OAuth 2.1 + PKCE, bridged onto the existing `hlk_`
Bearer model — no parallel identity store.
- Discovery: RFC 9728 Protected Resource Metadata and RFC 8414 Authorization
Server Metadata at `/.well-known/oauth-protected-resource` and
`/.well-known/oauth-authorization-server` (added to the proxy allowlist).
- `/api/mcp/oauth/authorize` — consent gate backed by the existing session;
a code is only issued on an authenticated POST decision, never on a GET.
- `/api/mcp/oauth/token` — authorization-code and refresh-token grants with
mandatory PKCE S256, single-use codes, and rotating refresh tokens. The
access token is an ordinary short-lived `hlk_` token scoped `health:read`.
- `/api/mcp/oauth/register` — RFC 7591 DCR fallback; CIMD (SEP-991) is the
preferred path and resolves client metadata through `safeFetch`.
- Audience binding (RFC 8707): every token is bound to the canonical `/mcp`
resource URI; a mismatched `resource` is rejected at authorize and token.
- The grant artifacts (code, refresh, DCR client id) are HMAC-signed,
self-describing, and domain-separated; the central log redactors scrub
them plus the PKCE verifier.
---
.../oauth-authorization-server/route.ts | 18 +
.../oauth-protected-resource/route.ts | 19 +
.../api/mcp/oauth/__tests__/authorize.test.ts | 218 +++++++++++
.../api/mcp/oauth/__tests__/register.test.ts | 72 ++++
src/app/api/mcp/oauth/__tests__/token.test.ts | 204 ++++++++++
src/app/api/mcp/oauth/authorize/route.ts | 363 ++++++++++++++++++
src/app/api/mcp/oauth/register/route.ts | 144 +++++++
src/app/api/mcp/oauth/token/route.ts | 316 +++++++++++++++
src/lib/auth/issue-token.ts | 13 +-
src/lib/logging/redact.ts | 8 +
.../mcp/oauth/__tests__/oauth-bridge.test.ts | 216 +++++++++++
src/lib/mcp/oauth/artifacts.ts | 128 ++++++
src/lib/mcp/oauth/clients.ts | 222 +++++++++++
src/lib/mcp/oauth/config.ts | 88 +++++
src/lib/mcp/oauth/metadata.ts | 92 +++++
src/lib/mcp/oauth/pkce.ts | 56 +++
src/lib/observability/redact-payload.ts | 5 +
src/proxy.ts | 7 +
18 files changed, 2186 insertions(+), 3 deletions(-)
create mode 100644 src/app/.well-known/oauth-authorization-server/route.ts
create mode 100644 src/app/.well-known/oauth-protected-resource/route.ts
create mode 100644 src/app/api/mcp/oauth/__tests__/authorize.test.ts
create mode 100644 src/app/api/mcp/oauth/__tests__/register.test.ts
create mode 100644 src/app/api/mcp/oauth/__tests__/token.test.ts
create mode 100644 src/app/api/mcp/oauth/authorize/route.ts
create mode 100644 src/app/api/mcp/oauth/register/route.ts
create mode 100644 src/app/api/mcp/oauth/token/route.ts
create mode 100644 src/lib/mcp/oauth/__tests__/oauth-bridge.test.ts
create mode 100644 src/lib/mcp/oauth/artifacts.ts
create mode 100644 src/lib/mcp/oauth/clients.ts
create mode 100644 src/lib/mcp/oauth/config.ts
create mode 100644 src/lib/mcp/oauth/metadata.ts
create mode 100644 src/lib/mcp/oauth/pkce.ts
diff --git a/src/app/.well-known/oauth-authorization-server/route.ts b/src/app/.well-known/oauth-authorization-server/route.ts
new file mode 100644
index 000000000..f52c79d27
--- /dev/null
+++ b/src/app/.well-known/oauth-authorization-server/route.ts
@@ -0,0 +1,18 @@
+/**
+ * RFC 8414 — OAuth Authorization Server Metadata for the built-in bridge AS.
+ *
+ * Public, unauthenticated discovery (added to the proxy `.well-known` allowlist).
+ * Advertises the authorize / token / register endpoints, `S256`-only PKCE, and
+ * CIMD support (`client_id_metadata_document_supported: true` + `none` auth) so
+ * Claude.ai / ChatGPT can self-register and complete the OAuth 2.1 + PKCE flow.
+ */
+import { authorizationServerMetadata } from "@/lib/mcp/oauth/metadata";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export function GET(request: Request): Response {
+ return Response.json(authorizationServerMetadata(request.url), {
+ headers: { "Cache-Control": "public, max-age=300" },
+ });
+}
diff --git a/src/app/.well-known/oauth-protected-resource/route.ts b/src/app/.well-known/oauth-protected-resource/route.ts
new file mode 100644
index 000000000..46a4a79fb
--- /dev/null
+++ b/src/app/.well-known/oauth-protected-resource/route.ts
@@ -0,0 +1,19 @@
+/**
+ * RFC 9728 — OAuth Protected Resource Metadata for the `/mcp` resource.
+ *
+ * Public, unauthenticated discovery (added to the proxy `.well-known` allowlist).
+ * A remote MCP client reads this after the `/mcp` 401 to learn which
+ * Authorization Server backs the resource and what scopes it accepts. The
+ * `resource` value is the canonical `/mcp` URI and MUST match the URL the user
+ * pasted into Claude.ai / ChatGPT.
+ */
+import { protectedResourceMetadata } from "@/lib/mcp/oauth/metadata";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export function GET(request: Request): Response {
+ return Response.json(protectedResourceMetadata(request.url), {
+ headers: { "Cache-Control": "public, max-age=300" },
+ });
+}
diff --git a/src/app/api/mcp/oauth/__tests__/authorize.test.ts b/src/app/api/mcp/oauth/__tests__/authorize.test.ts
new file mode 100644
index 000000000..fc65471c3
--- /dev/null
+++ b/src/app/api/mcp/oauth/__tests__/authorize.test.ts
@@ -0,0 +1,218 @@
+/**
+ * Authorize endpoint — consent gate + audience + PKCE + redirect-URI binding.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
+process.env.APP_URL = "https://health.example";
+
+vi.mock("@/lib/logging/background", () => ({
+ withBackgroundEvent: (_n: string, fn: () => unknown) => fn(),
+}));
+vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
+vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
+vi.mock("@/lib/rate-limit", () => ({
+ checkAuthSurfaceRateLimit: vi.fn(async () => ({
+ allowed: true,
+ remaining: 59,
+ resetAt: Date.now() + 60_000,
+ ip: "1.2.3.4",
+ })),
+ rateLimitHeaders: vi.fn(() => ({})),
+}));
+
+import { GET, POST } from "../authorize/route";
+import { getSession } from "@/lib/auth/session";
+import { registerDcrClient } from "@/lib/mcp/oauth/clients";
+import { s256Challenge } from "@/lib/mcp/oauth/pkce";
+
+const REDIRECT = "https://claude.ai/api/mcp/auth_callback";
+const VERIFIER = "a".repeat(64);
+const CHALLENGE = s256Challenge(VERIFIER);
+const RESOURCE = "https://health.example/mcp";
+
+const CLIENT = registerDcrClient({
+ clientName: "Claude",
+ redirectUris: [REDIRECT],
+});
+
+function authorizeUrl(
+ overrides: Record = {},
+): string {
+ const params: Record = {
+ response_type: "code",
+ client_id: CLIENT.clientId,
+ redirect_uri: REDIRECT,
+ code_challenge: CHALLENGE,
+ code_challenge_method: "S256",
+ scope: "health:read",
+ state: "xyz",
+ resource: RESOURCE,
+ ...overrides,
+ };
+ const u = new URL("https://health.example/api/mcp/oauth/authorize");
+ for (const [k, v] of Object.entries(params)) {
+ if (v !== undefined) u.searchParams.set(k, v);
+ }
+ return u.toString();
+}
+
+function getReq(url: string): Request {
+ return new Request(url, { method: "GET" });
+}
+
+function postReq(fields: Record): Request {
+ const body = new URLSearchParams(fields);
+ return new Request("https://health.example/api/mcp/oauth/authorize", {
+ method: "POST",
+ headers: { "content-type": "application/x-www-form-urlencoded" },
+ body: body.toString(),
+ });
+}
+
+function signedIn() {
+ vi.mocked(getSession).mockResolvedValue({
+ session: { id: "s1", expiresAt: new Date(Date.now() + 1e6) },
+ user: { id: "user-1" } as never,
+ });
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getSession).mockResolvedValue(null);
+});
+
+describe("GET /authorize — consent gate", () => {
+ it("renders a consent screen for an authenticated user (no code issued)", async () => {
+ signedIn();
+ const res = await GET(getReq(authorizeUrl()) as never);
+ expect(res.status).toBe(200);
+ const body = await res.text();
+ expect(body).toMatch(/Authorize access/);
+ expect(body).toMatch(/Claude/);
+ expect(body).toMatch(/decision/); // the Allow/Deny form
+ // A GET never mints a code.
+ expect(body).not.toMatch(/hlac_/);
+ });
+
+ it("prompts sign-in (with a return link) when there is no session", async () => {
+ const res = await GET(getReq(authorizeUrl()) as never);
+ expect(res.status).toBe(200);
+ const body = await res.text();
+ expect(body).toMatch(/\/auth\/login/);
+ expect(body).not.toMatch(/hlac_/);
+ });
+});
+
+describe("GET /authorize — rejected requests", () => {
+ it("rejects an audience mismatch with 400 invalid_target", async () => {
+ signedIn();
+ const res = await GET(
+ getReq(authorizeUrl({ resource: "https://evil.example/mcp" })) as never,
+ );
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error).toBe("invalid_target");
+ });
+
+ it("rejects a missing PKCE challenge with 400", async () => {
+ signedIn();
+ const res = await GET(
+ getReq(
+ authorizeUrl({
+ code_challenge: undefined,
+ code_challenge_method: undefined,
+ }),
+ ) as never,
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("rejects a non-S256 PKCE method with 400", async () => {
+ signedIn();
+ const res = await GET(
+ getReq(authorizeUrl({ code_challenge_method: "plain" })) as never,
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("rejects an unknown client with 400 invalid_client", async () => {
+ signedIn();
+ const res = await GET(
+ getReq(authorizeUrl({ client_id: "bogus" })) as never,
+ );
+ expect(res.status).toBe(400);
+ expect((await res.json()).error).toBe("invalid_client");
+ });
+
+ it("rejects a redirect_uri not registered to the client", async () => {
+ signedIn();
+ const res = await GET(
+ getReq(
+ authorizeUrl({ redirect_uri: "https://evil.example/cb" }),
+ ) as never,
+ );
+ expect(res.status).toBe(400);
+ });
+});
+
+describe("POST /authorize — decision", () => {
+ it("requires a session (401 when unauthenticated)", async () => {
+ const res = await POST(
+ postReq({
+ response_type: "code",
+ client_id: CLIENT.clientId,
+ redirect_uri: REDIRECT,
+ code_challenge: CHALLENGE,
+ code_challenge_method: "S256",
+ scope: "health:read",
+ resource: RESOURCE,
+ decision: "allow",
+ }) as never,
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it("mints a code and 302s back on allow", async () => {
+ signedIn();
+ const res = await POST(
+ postReq({
+ response_type: "code",
+ client_id: CLIENT.clientId,
+ redirect_uri: REDIRECT,
+ code_challenge: CHALLENGE,
+ code_challenge_method: "S256",
+ scope: "health:read",
+ state: "xyz",
+ resource: RESOURCE,
+ decision: "allow",
+ }) as never,
+ );
+ expect(res.status).toBe(302);
+ const loc = res.headers.get("location") ?? "";
+ expect(loc.startsWith(REDIRECT)).toBe(true);
+ const code = new URL(loc).searchParams.get("code");
+ expect(code).toMatch(/^hlac_/);
+ expect(new URL(loc).searchParams.get("state")).toBe("xyz");
+ });
+
+ it("302s with access_denied on deny", async () => {
+ signedIn();
+ const res = await POST(
+ postReq({
+ response_type: "code",
+ client_id: CLIENT.clientId,
+ redirect_uri: REDIRECT,
+ code_challenge: CHALLENGE,
+ code_challenge_method: "S256",
+ scope: "health:read",
+ resource: RESOURCE,
+ decision: "deny",
+ }) as never,
+ );
+ expect(res.status).toBe(302);
+ const loc = res.headers.get("location") ?? "";
+ expect(new URL(loc).searchParams.get("error")).toBe("access_denied");
+ });
+});
diff --git a/src/app/api/mcp/oauth/__tests__/register.test.ts b/src/app/api/mcp/oauth/__tests__/register.test.ts
new file mode 100644
index 000000000..bcf37c2be
--- /dev/null
+++ b/src/app/api/mcp/oauth/__tests__/register.test.ts
@@ -0,0 +1,72 @@
+/**
+ * DCR registration (RFC 7591) — stateless `hlc_` client-id issuance.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
+
+vi.mock("@/lib/logging/background", () => ({
+ withBackgroundEvent: (_n: string, fn: () => unknown) => fn(),
+}));
+vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
+vi.mock("@/lib/rate-limit", () => ({
+ checkAuthSurfaceRateLimit: vi.fn(async () => ({
+ allowed: true,
+ remaining: 19,
+ resetAt: Date.now() + 60_000,
+ ip: "1.2.3.4",
+ })),
+ rateLimitHeaders: vi.fn(() => ({})),
+}));
+
+import { POST } from "../register/route";
+
+function jsonReq(body: unknown): Request {
+ return new Request("https://health.example/api/mcp/oauth/register", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
+
+beforeEach(() => vi.clearAllMocks());
+
+describe("POST /register", () => {
+ it("registers a public client and returns an hlc_ client_id", async () => {
+ const res = await POST(
+ jsonReq({
+ client_name: "ChatGPT",
+ redirect_uris: [
+ "https://chatgpt.com/connector_platform_oauth_redirect",
+ ],
+ }) as never,
+ );
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.client_id).toMatch(/^hlc_/);
+ expect(body.token_endpoint_auth_method).toBe("none");
+ expect(body.redirect_uris).toEqual([
+ "https://chatgpt.com/connector_platform_oauth_redirect",
+ ]);
+ });
+
+ it("accepts a loopback redirect (Claude Code)", async () => {
+ const res = await POST(
+ jsonReq({ redirect_uris: ["http://127.0.0.1:8976/callback"] }) as never,
+ );
+ expect(res.status).toBe(201);
+ });
+
+ it("rejects a missing redirect_uris with invalid_client_metadata", async () => {
+ const res = await POST(jsonReq({ client_name: "x" }) as never);
+ expect(res.status).toBe(400);
+ expect((await res.json()).error).toBe("invalid_client_metadata");
+ });
+
+ it("rejects a non-https, non-loopback redirect_uri", async () => {
+ const res = await POST(
+ jsonReq({ redirect_uris: ["http://evil.example/cb"] }) as never,
+ );
+ expect(res.status).toBe(400);
+ });
+});
diff --git a/src/app/api/mcp/oauth/__tests__/token.test.ts b/src/app/api/mcp/oauth/__tests__/token.test.ts
new file mode 100644
index 000000000..c48bf9872
--- /dev/null
+++ b/src/app/api/mcp/oauth/__tests__/token.test.ts
@@ -0,0 +1,204 @@
+/**
+ * Token endpoint — PKCE verification, audience binding, single-use codes,
+ * refresh-token rotation.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
+process.env.APP_URL = "https://health.example";
+
+vi.mock("@/lib/logging/background", () => ({
+ withBackgroundEvent: (_n: string, fn: () => unknown) => fn(),
+}));
+vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
+vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
+vi.mock("@/lib/auth/issue-token", () => ({
+ issueApiToken: vi.fn(async () => ({
+ token: "hlk_" + "a".repeat(64),
+ expiresAt: new Date(Date.now() + 3_600_000),
+ tokenId: "tok-1",
+ name: "MCP connector",
+ })),
+}));
+vi.mock("@/lib/rate-limit", () => ({
+ checkAuthSurfaceRateLimit: vi.fn(async () => ({
+ allowed: true,
+ remaining: 100,
+ resetAt: Date.now() + 60_000,
+ ip: "1.2.3.4",
+ })),
+ checkRateLimit: vi.fn(async () => ({
+ allowed: true,
+ remaining: 0,
+ resetAt: Date.now() + 60_000,
+ })),
+ rateLimitHeaders: vi.fn(() => ({})),
+}));
+
+import { randomUUID } from "node:crypto";
+import { POST } from "../token/route";
+import { issueApiToken } from "@/lib/auth/issue-token";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { signArtifact } from "@/lib/mcp/oauth/artifacts";
+import { s256Challenge } from "@/lib/mcp/oauth/pkce";
+
+const CLIENT_ID = "https://app.example/client.json";
+const REDIRECT = "https://claude.ai/api/mcp/auth_callback";
+const VERIFIER = "a".repeat(64);
+const CHALLENGE = s256Challenge(VERIFIER);
+const RESOURCE = "https://health.example/mcp";
+
+function authCode(overrides: Record = {}): string {
+ return signArtifact(
+ "authCode",
+ {
+ jti: randomUUID(),
+ sub: "user-1",
+ client_id: CLIENT_ID,
+ redirect_uri: REDIRECT,
+ code_challenge: CHALLENGE,
+ scope: "health:read offline_access",
+ resource: RESOURCE,
+ ...overrides,
+ },
+ 120_000,
+ );
+}
+
+function tokenReq(fields: Record): Request {
+ return new Request("https://health.example/api/mcp/oauth/token", {
+ method: "POST",
+ headers: { "content-type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams(fields).toString(),
+ });
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: true,
+ remaining: 0,
+ resetAt: Date.now() + 60_000,
+ });
+});
+
+describe("authorization_code grant", () => {
+ it("exchanges a valid code + verifier for an access token (+ refresh)", async () => {
+ const res = await POST(
+ tokenReq({
+ grant_type: "authorization_code",
+ code: authCode(),
+ code_verifier: VERIFIER,
+ client_id: CLIENT_ID,
+ redirect_uri: REDIRECT,
+ }) as never,
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.token_type).toBe("Bearer");
+ expect(body.access_token).toMatch(/^hlk_/);
+ expect(body.refresh_token).toMatch(/^hlrt_/);
+ expect(body.scope).toContain("health:read");
+ expect(issueApiToken).toHaveBeenCalledWith(
+ expect.objectContaining({ permissions: ["health:read"] }),
+ );
+ });
+
+ it("rejects a wrong PKCE verifier with invalid_grant", async () => {
+ const res = await POST(
+ tokenReq({
+ grant_type: "authorization_code",
+ code: authCode(),
+ code_verifier: "b".repeat(64),
+ client_id: CLIENT_ID,
+ redirect_uri: REDIRECT,
+ }) as never,
+ );
+ expect(res.status).toBe(400);
+ expect((await res.json()).error).toBe("invalid_grant");
+ expect(issueApiToken).not.toHaveBeenCalled();
+ });
+
+ it("rejects a code bound to a different audience", async () => {
+ const res = await POST(
+ tokenReq({
+ grant_type: "authorization_code",
+ code: authCode({ resource: "https://evil.example/mcp" }),
+ code_verifier: VERIFIER,
+ client_id: CLIENT_ID,
+ redirect_uri: REDIRECT,
+ }) as never,
+ );
+ expect(res.status).toBe(400);
+ expect((await res.json()).error).toBe("invalid_target");
+ });
+
+ it("rejects a code presented by a different client", async () => {
+ const res = await POST(
+ tokenReq({
+ grant_type: "authorization_code",
+ code: authCode(),
+ code_verifier: VERIFIER,
+ client_id: "https://other.example/client.json",
+ redirect_uri: REDIRECT,
+ }) as never,
+ );
+ expect(res.status).toBe(400);
+ expect((await res.json()).error).toBe("invalid_grant");
+ });
+
+ it("rejects a replayed code (single-use jti already claimed)", async () => {
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: false,
+ remaining: 0,
+ resetAt: Date.now() + 60_000,
+ });
+ const res = await POST(
+ tokenReq({
+ grant_type: "authorization_code",
+ code: authCode(),
+ code_verifier: VERIFIER,
+ client_id: CLIENT_ID,
+ redirect_uri: REDIRECT,
+ }) as never,
+ );
+ expect(res.status).toBe(400);
+ expect((await res.json()).error).toBe("invalid_grant");
+ expect(issueApiToken).not.toHaveBeenCalled();
+ });
+});
+
+describe("refresh_token grant", () => {
+ it("rotates: issues a new access + refresh pair", async () => {
+ const refresh = signArtifact(
+ "refreshToken",
+ {
+ jti: randomUUID(),
+ sub: "user-1",
+ client_id: CLIENT_ID,
+ scope: "health:read offline_access",
+ resource: RESOURCE,
+ },
+ 1_000_000,
+ );
+ const res = await POST(
+ tokenReq({
+ grant_type: "refresh_token",
+ refresh_token: refresh,
+ client_id: CLIENT_ID,
+ }) as never,
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.access_token).toMatch(/^hlk_/);
+ expect(body.refresh_token).toMatch(/^hlrt_/);
+ });
+});
+
+describe("grant validation", () => {
+ it("rejects an unsupported grant_type", async () => {
+ const res = await POST(tokenReq({ grant_type: "password" }) as never);
+ expect(res.status).toBe(400);
+ expect((await res.json()).error).toBe("unsupported_grant_type");
+ });
+});
diff --git a/src/app/api/mcp/oauth/authorize/route.ts b/src/app/api/mcp/oauth/authorize/route.ts
new file mode 100644
index 000000000..068fe7974
--- /dev/null
+++ b/src/app/api/mcp/oauth/authorize/route.ts
@@ -0,0 +1,363 @@
+/**
+ * Authorization endpoint (OAuth 2.1 authorization-code flow + PKCE S256).
+ *
+ * This is the consent gate of the bridge AS. It is backed by HealthLog's
+ * existing Postgres session (passkey / password login) — there is NO parallel
+ * user store (ADR-006). The flow:
+ *
+ * GET — validate the request; if the caller has no session, prompt them to
+ * sign in (returning here); if they do, render a per-client CONSENT
+ * screen naming the client + the exact scope. NO code is ever issued on
+ * a GET (confused-deputy mitigation: a third party cannot silently
+ * obtain a grant — an authenticated human must click "Allow").
+ * POST — the consent decision. Requires a session, re-validates every
+ * parameter, and on "Allow" mints a single-use, short-lived,
+ * self-describing authorization code bound to this user + client +
+ * redirect URI + PKCE challenge + scope + audience, then 302s back.
+ *
+ * Mandatory gates, every request: PKCE `S256` (a missing / non-S256 challenge is
+ * rejected), audience binding (`resource` MUST equal the canonical `/mcp` URI —
+ * RFC 8707), and an exact redirect-URI match against the resolved client (with
+ * port-agnostic loopback tolerance). Errors are returned directly (never
+ * redirected to an unvalidated URI) so the endpoint is not an open redirector.
+ */
+import { randomUUID } from "node:crypto";
+
+import { NextRequest } from "next/server";
+import { z } from "zod/v4";
+
+import { annotate } from "@/lib/logging/context";
+import { withBackgroundEvent } from "@/lib/logging/background";
+import { auditLog } from "@/lib/auth/audit";
+import { getSession } from "@/lib/auth/session";
+import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
+import {
+ audienceMatches,
+ AUTH_CODE_TTL_SECONDS,
+ SCOPE_HEALTH_READ,
+} from "@/lib/mcp/oauth/config";
+import { redirectUriAllowed, resolveClient } from "@/lib/mcp/oauth/clients";
+import { isValidChallenge } from "@/lib/mcp/oauth/pkce";
+import { signArtifact } from "@/lib/mcp/oauth/artifacts";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+const AUTHORIZE_LIMIT = 60;
+const AUTHORIZE_WINDOW_MS = 15 * 60 * 1000;
+
+const baseParams = {
+ response_type: z.string(),
+ client_id: z.string().min(1).max(4096),
+ redirect_uri: z.string().min(1).max(2048),
+ code_challenge: z.string().min(1).max(256).optional(),
+ code_challenge_method: z.string().optional(),
+ scope: z.string().max(256).optional(),
+ state: z.string().max(2048).optional(),
+ resource: z.string().max(2048).optional(),
+};
+
+function htmlEscape(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function badRequest(error: string, description: string): Response {
+ return Response.json(
+ { error, error_description: description },
+ { status: 400 },
+ );
+}
+
+function html(body: string, status = 200): Response {
+ return new Response(
+ `HealthLog — Authorize access${body}`,
+ {
+ status,
+ headers: { "content-type": "text/html; charset=utf-8" },
+ },
+ );
+}
+
+/** The single scope this read surface grants, plus optional refresh continuity. */
+function grantScope(requested: string | undefined): string {
+ const wants = new Set(
+ (requested ?? SCOPE_HEALTH_READ).split(/\s+/).filter(Boolean),
+ );
+ const granted = [SCOPE_HEALTH_READ];
+ if (wants.has("offline_access")) granted.push("offline_access");
+ return granted.join(" ");
+}
+
+interface ValidatedRequest {
+ clientId: string;
+ clientName: string;
+ redirectUri: string;
+ codeChallenge: string;
+ scope: string;
+ state?: string;
+ resource: string;
+}
+
+/** Shared validation for GET + POST. Returns a ready error Response on failure. */
+async function validate(
+ params: Record,
+ requestUrl: string,
+): Promise<{ ok: true; v: ValidatedRequest } | { ok: false; res: Response }> {
+ const parsed = z.object(baseParams).safeParse(params);
+ if (!parsed.success) {
+ return {
+ ok: false,
+ res: badRequest("invalid_request", "Missing or malformed parameters"),
+ };
+ }
+ const p = parsed.data;
+
+ // 1. Client must resolve (CIMD URL or stateless DCR id). Unknown → cannot
+ // safely redirect anywhere, so error directly.
+ const resolved = await resolveClient(p.client_id);
+ if (!resolved.ok) {
+ return {
+ ok: false,
+ res: badRequest("invalid_client", "Unknown or unresolvable client"),
+ };
+ }
+
+ // 2. Redirect URI MUST match the registration before we trust it.
+ if (!redirectUriAllowed(p.redirect_uri, resolved.client.redirectUris)) {
+ return {
+ ok: false,
+ res: badRequest(
+ "invalid_request",
+ "redirect_uri does not match the registered client",
+ ),
+ };
+ }
+
+ // 3. response_type — code only.
+ if (p.response_type !== "code") {
+ return {
+ ok: false,
+ res: badRequest(
+ "unsupported_response_type",
+ "Only response_type=code is supported",
+ ),
+ };
+ }
+
+ // 4. PKCE S256 is MANDATORY.
+ if (
+ p.code_challenge_method !== "S256" ||
+ !isValidChallenge(p.code_challenge)
+ ) {
+ return {
+ ok: false,
+ res: badRequest(
+ "invalid_request",
+ "PKCE with code_challenge_method=S256 is required",
+ ),
+ };
+ }
+
+ // 5. Audience binding (RFC 8707) — resource MUST equal the canonical /mcp URI.
+ if (!audienceMatches(p.resource, requestUrl)) {
+ return {
+ ok: false,
+ res: badRequest(
+ "invalid_target",
+ "resource must equal this server's MCP endpoint",
+ ),
+ };
+ }
+
+ return {
+ ok: true,
+ v: {
+ clientId: p.client_id,
+ clientName: resolved.client.clientName,
+ redirectUri: p.redirect_uri,
+ codeChallenge: p.code_challenge as string,
+ scope: grantScope(p.scope),
+ state: p.state,
+ resource: p.resource as string,
+ },
+ };
+}
+
+function redirectBack(
+ redirectUri: string,
+ query: Record,
+): Response {
+ const url = new URL(redirectUri);
+ for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v);
+ return Response.redirect(url.toString(), 302);
+}
+
+export async function GET(request: NextRequest): Promise {
+ return withBackgroundEvent("mcp.oauth.authorize", async () => {
+ const rl = await checkAuthSurfaceRateLimit(
+ request,
+ "mcp:oauth:authorize",
+ AUTHORIZE_LIMIT,
+ AUTHORIZE_WINDOW_MS,
+ );
+ if (!rl.allowed) {
+ return Response.json(
+ { error: "temporarily_unavailable" },
+ {
+ status: 429,
+ headers: rateLimitHeaders({
+ allowed: false,
+ remaining: rl.remaining,
+ resetAt: rl.resetAt,
+ }),
+ },
+ );
+ }
+
+ const url = new URL(request.url);
+ const params = Object.fromEntries(url.searchParams.entries());
+ const result = await validate(params, request.url);
+ if (!result.ok) {
+ annotate({ action: { name: "mcp.oauth.authorize.invalid" } });
+ return result.res;
+ }
+ const { v } = result;
+
+ const session = await getSession();
+ if (!session) {
+ // No identity yet — send the human to sign in, returning to this exact
+ // request. NO grant is issued without an authenticated decision.
+ const next = url.pathname + url.search;
+ const loginHref = `/auth/login?next=${encodeURIComponent(next)}`;
+ annotate({ action: { name: "mcp.oauth.authorize.login_required" } });
+ return html(
+ `Sign in to HealthLog
To connect ${htmlEscape(v.clientName)} you must sign in first.
Sign in and continue
`,
+ );
+ }
+
+ annotate({ action: { name: "mcp.oauth.authorize.consent_shown" } });
+ // Consent screen — a plain POST form (no inline script/style, CSP-safe).
+ const hidden = (name: string, value: string) =>
+ ``;
+ return html(
+ `
+ Authorize access
+ ${htmlEscape(v.clientName)} is requesting access to your HealthLog data.
+ Scope: ${htmlEscape(v.scope)} — read-only access to your own health records.
+
+ `,
+ );
+ });
+}
+
+export async function POST(request: NextRequest): Promise {
+ return withBackgroundEvent("mcp.oauth.authorize", async () => {
+ const rl = await checkAuthSurfaceRateLimit(
+ request,
+ "mcp:oauth:authorize",
+ AUTHORIZE_LIMIT,
+ AUTHORIZE_WINDOW_MS,
+ );
+ if (!rl.allowed) {
+ return Response.json(
+ { error: "temporarily_unavailable" },
+ {
+ status: 429,
+ headers: rateLimitHeaders({
+ allowed: false,
+ remaining: rl.remaining,
+ resetAt: rl.resetAt,
+ }),
+ },
+ );
+ }
+
+ // The consent decision MUST come from an authenticated human.
+ const session = await getSession();
+ if (!session) {
+ annotate({
+ action: { name: "mcp.oauth.authorize.decision_unauthenticated" },
+ });
+ return Response.json(
+ { error: "access_denied", error_description: "Sign in required" },
+ { status: 401 },
+ );
+ }
+
+ const form = await request.formData();
+ const params: Record = {};
+ for (const key of [
+ "response_type",
+ "client_id",
+ "redirect_uri",
+ "code_challenge",
+ "code_challenge_method",
+ "scope",
+ "state",
+ "resource",
+ "decision",
+ ]) {
+ const value = form.get(key);
+ params[key] = typeof value === "string" ? value : undefined;
+ }
+
+ const result = await validate(params, request.url);
+ if (!result.ok) {
+ annotate({ action: { name: "mcp.oauth.authorize.invalid" } });
+ return result.res;
+ }
+ const { v } = result;
+
+ if (params.decision !== "allow") {
+ annotate({ action: { name: "mcp.oauth.authorize.denied" } });
+ return redirectBack(v.redirectUri, {
+ error: "access_denied",
+ ...(v.state !== undefined ? { state: v.state } : {}),
+ });
+ }
+
+ const code = signArtifact(
+ "authCode",
+ {
+ // `jti` makes the code single-use: the token endpoint claims it once
+ // through the atomic rate-limit upsert, so a replay within the TTL is
+ // rejected.
+ jti: randomUUID(),
+ sub: session.user.id,
+ client_id: v.clientId,
+ redirect_uri: v.redirectUri,
+ code_challenge: v.codeChallenge,
+ scope: v.scope,
+ resource: v.resource,
+ },
+ AUTH_CODE_TTL_SECONDS * 1000,
+ );
+
+ await auditLog("mcp.oauth.authorize.granted", {
+ userId: session.user.id,
+ details: { client_id: v.clientId, scope: v.scope },
+ });
+ annotate({ action: { name: "mcp.oauth.authorize.granted" } });
+
+ return redirectBack(v.redirectUri, {
+ code,
+ ...(v.state !== undefined ? { state: v.state } : {}),
+ });
+ });
+}
diff --git a/src/app/api/mcp/oauth/register/route.ts b/src/app/api/mcp/oauth/register/route.ts
new file mode 100644
index 000000000..9e49e8192
--- /dev/null
+++ b/src/app/api/mcp/oauth/register/route.ts
@@ -0,0 +1,144 @@
+/**
+ * Dynamic Client Registration (RFC 7591) — the DCR fallback for clients that
+ * do not present a CIMD `client_id` URL.
+ *
+ * Registration is STATELESS: the issued `client_id` is a signed, self-describing
+ * `hlc_` artifact carrying the registered `redirect_uris` + name (see
+ * `clients.ts` / `artifacts.ts`), so no client row is written. The endpoint only
+ * validates the metadata, enforces a per-IP rate limit (it is unauthenticated),
+ * and returns the RFC 7591 client-information response for a PUBLIC client
+ * (`token_endpoint_auth_method: "none"` — PKCE is the proof, there is no secret).
+ *
+ * Redirect URIs are shape-validated here; the AUTHORITATIVE redirect-URI match
+ * happens at `/authorize` against the decoded registration.
+ */
+import { NextRequest } from "next/server";
+import { z } from "zod/v4";
+
+import { annotate } from "@/lib/logging/context";
+import { withBackgroundEvent } from "@/lib/logging/background";
+import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
+import { registerDcrClient } from "@/lib/mcp/oauth/clients";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+const REGISTER_LIMIT = 20;
+const REGISTER_WINDOW_MS = 60 * 60 * 1000;
+const MAX_BODY_BYTES = 16 * 1024;
+
+/** A redirect URI must be an absolute HTTPS URL or an http loopback URL. */
+function isAllowableRedirectUri(value: string): boolean {
+ let url: URL;
+ try {
+ url = new URL(value);
+ } catch {
+ return false;
+ }
+ if (url.protocol === "https:") return true;
+ if (
+ url.protocol === "http:" &&
+ ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname)
+ ) {
+ return true;
+ }
+ return false;
+}
+
+const registrationSchema = z.object({
+ redirect_uris: z
+ .array(z.string().min(1).max(2048))
+ .min(1)
+ .max(12)
+ .refine((uris) => uris.every(isAllowableRedirectUri), {
+ message: "redirect_uris must be HTTPS or http loopback URLs",
+ }),
+ client_name: z.string().min(1).max(200).optional(),
+ // Accepted-and-ignored RFC 7591 fields (we never read them but tolerate them).
+ grant_types: z.array(z.string()).optional(),
+ response_types: z.array(z.string()).optional(),
+ token_endpoint_auth_method: z.string().optional(),
+ scope: z.string().optional(),
+});
+
+function oauthError(
+ error: string,
+ description: string,
+ status: number,
+ extraHeaders: Record = {},
+): Response {
+ return Response.json(
+ { error, error_description: description },
+ { status, headers: extraHeaders },
+ );
+}
+
+export async function POST(request: NextRequest): Promise {
+ return withBackgroundEvent("mcp.oauth.register", async () => {
+ const rl = await checkAuthSurfaceRateLimit(
+ request,
+ "mcp:oauth:register",
+ REGISTER_LIMIT,
+ REGISTER_WINDOW_MS,
+ );
+ if (!rl.allowed) {
+ annotate({ action: { name: "mcp.oauth.register.rate_limited" } });
+ return Response.json(
+ { error: "temporarily_unavailable" },
+ {
+ status: 429,
+ headers: rateLimitHeaders({
+ allowed: false,
+ remaining: rl.remaining,
+ resetAt: rl.resetAt,
+ }),
+ },
+ );
+ }
+
+ const raw = await request.text();
+ if (raw.length > MAX_BODY_BYTES) {
+ return oauthError("invalid_client_metadata", "Payload too large", 400);
+ }
+ let body: unknown;
+ try {
+ body = JSON.parse(raw);
+ } catch {
+ return oauthError("invalid_client_metadata", "Invalid JSON", 400);
+ }
+
+ const parsed = registrationSchema.safeParse(body);
+ if (!parsed.success) {
+ annotate({ action: { name: "mcp.oauth.register.invalid" } });
+ return oauthError(
+ "invalid_client_metadata",
+ "redirect_uris is required and must contain HTTPS or loopback URLs",
+ 400,
+ );
+ }
+
+ const client = registerDcrClient({
+ clientName: parsed.data.client_name ?? "MCP client",
+ redirectUris: parsed.data.redirect_uris,
+ });
+
+ annotate({
+ action: { name: "mcp.oauth.register.issued" },
+ meta: { source: "dcr", redirect_uri_count: client.redirectUris.length },
+ });
+
+ // RFC 7591 client-information response for a public client.
+ return Response.json(
+ {
+ client_id: client.clientId,
+ client_id_issued_at: Math.floor(Date.now() / 1000),
+ redirect_uris: client.redirectUris,
+ client_name: client.clientName,
+ token_endpoint_auth_method: "none",
+ grant_types: ["authorization_code", "refresh_token"],
+ response_types: ["code"],
+ },
+ { status: 201 },
+ );
+ });
+}
diff --git a/src/app/api/mcp/oauth/token/route.ts b/src/app/api/mcp/oauth/token/route.ts
new file mode 100644
index 000000000..0d8d628dd
--- /dev/null
+++ b/src/app/api/mcp/oauth/token/route.ts
@@ -0,0 +1,316 @@
+/**
+ * Token endpoint (OAuth 2.1) — `authorization_code` + `refresh_token` grants.
+ *
+ * Exchanges the bridge AS's self-describing artifacts for a REAL `hlk_`
+ * `ApiToken` (the access token) minted through `issue-token.ts` with the narrow
+ * `health:read` scope (ADR-006). The access token is therefore an ordinary
+ * Bearer the `/mcp` resolver already understands — bound to `:`
+ * (REQ-SEC-11), revocable, and short-lived. There is no token passthrough: the
+ * MCP layer never forwards a client-presented upstream credential.
+ *
+ * Gates:
+ * - PKCE — the presented `code_verifier` MUST hash (S256) to the
+ * `code_challenge` bound into the authorization code.
+ * - Single use — the code's `jti` is claimed once through the atomic
+ * rate-limit upsert; a replay within the TTL is rejected.
+ * - Audience — the code's bound `resource` MUST equal the canonical `/mcp`
+ * URI (RFC 8707); a `resource` parameter, when sent, must match too.
+ * - Client binding — the `client_id` presented at `/token` MUST equal the one
+ * the code was issued to.
+ *
+ * Refresh tokens rotate on every use (OAuth 2.1 public-client rotation): a fresh
+ * access + refresh pair is returned and the presented refresh `jti` is claimed
+ * so it cannot be reused.
+ */
+import { randomUUID } from "node:crypto";
+
+import { NextRequest } from "next/server";
+
+import { annotate } from "@/lib/logging/context";
+import { withBackgroundEvent } from "@/lib/logging/background";
+import { auditLog } from "@/lib/auth/audit";
+import { issueApiToken } from "@/lib/auth/issue-token";
+import {
+ checkRateLimit,
+ checkAuthSurfaceRateLimit,
+ rateLimitHeaders,
+} from "@/lib/rate-limit";
+import {
+ ACCESS_TOKEN_TTL_MINUTES,
+ audienceMatches,
+ REFRESH_TOKEN_TTL_DAYS,
+ SCOPE_HEALTH_READ,
+} from "@/lib/mcp/oauth/config";
+import { signArtifact, verifyArtifact } from "@/lib/mcp/oauth/artifacts";
+import { verifyPkceS256 } from "@/lib/mcp/oauth/pkce";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+const TOKEN_LIMIT = 120;
+const TOKEN_WINDOW_MS = 15 * 60 * 1000;
+
+function oauthError(
+ error: string,
+ description: string,
+ status = 400,
+): Response {
+ return Response.json(
+ { error, error_description: description },
+ { status, headers: { "Cache-Control": "no-store", Pragma: "no-cache" } },
+ );
+}
+
+function tokenSuccess(body: Record): Response {
+ return Response.json(body, {
+ headers: { "Cache-Control": "no-store", Pragma: "no-cache" },
+ });
+}
+
+/** Claim a one-time-use jti through the atomic limiter (limit 1 ⇒ single use). */
+async function claimJti(
+ kind: string,
+ jti: string,
+ ttlMs: number,
+): Promise {
+ if (typeof jti !== "string" || jti.length === 0) return false;
+ const rl = await checkRateLimit(`oauth:jti:${kind}:${jti}`, 1, ttlMs);
+ return rl.allowed;
+}
+
+interface AuthCodeClaims {
+ jti: string;
+ sub: string;
+ client_id: string;
+ redirect_uri: string;
+ code_challenge: string;
+ scope: string;
+ resource: string;
+}
+
+interface RefreshClaims {
+ jti: string;
+ sub: string;
+ client_id: string;
+ scope: string;
+ resource: string;
+}
+
+async function mintTokenPair(args: {
+ userId: string;
+ clientId: string;
+ scope: string;
+ resource: string;
+ grant: "authorization_code" | "refresh_token";
+}): Promise {
+ const access = await issueApiToken({
+ userId: args.userId,
+ name: "MCP connector",
+ permissions: [SCOPE_HEALTH_READ],
+ expiresInMinutes: ACCESS_TOKEN_TTL_MINUTES,
+ });
+
+ const includeRefresh = args.scope.split(/\s+/).includes("offline_access");
+ const refresh = includeRefresh
+ ? signArtifact(
+ "refreshToken",
+ {
+ jti: randomUUID(),
+ sub: args.userId,
+ client_id: args.clientId,
+ scope: args.scope,
+ resource: args.resource,
+ },
+ REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000,
+ )
+ : undefined;
+
+ await auditLog("mcp.oauth.token.issued", {
+ userId: args.userId,
+ details: { client_id: args.clientId, grant: args.grant, scope: args.scope },
+ });
+ annotate({
+ action: { name: "mcp.oauth.token.issued" },
+ meta: { grant: args.grant },
+ });
+
+ return tokenSuccess({
+ access_token: access.token,
+ token_type: "Bearer",
+ expires_in: ACCESS_TOKEN_TTL_MINUTES * 60,
+ scope: args.scope,
+ ...(refresh ? { refresh_token: refresh } : {}),
+ });
+}
+
+export async function POST(request: NextRequest): Promise {
+ return withBackgroundEvent("mcp.oauth.token", async () => {
+ const rl = await checkAuthSurfaceRateLimit(
+ request,
+ "mcp:oauth:token",
+ TOKEN_LIMIT,
+ TOKEN_WINDOW_MS,
+ );
+ if (!rl.allowed) {
+ return Response.json(
+ { error: "temporarily_unavailable" },
+ {
+ status: 429,
+ headers: rateLimitHeaders({
+ allowed: false,
+ remaining: rl.remaining,
+ resetAt: rl.resetAt,
+ }),
+ },
+ );
+ }
+
+ let form: FormData;
+ try {
+ form = await request.formData();
+ } catch {
+ return oauthError(
+ "invalid_request",
+ "Expected application/x-www-form-urlencoded body",
+ );
+ }
+ const get = (k: string): string | undefined => {
+ const v = form.get(k);
+ return typeof v === "string" ? v : undefined;
+ };
+
+ const grantType = get("grant_type");
+
+ if (grantType === "authorization_code") {
+ const code = get("code");
+ const verifier = get("code_verifier");
+ const clientId = get("client_id");
+ const redirectUri = get("redirect_uri");
+
+ if (!code || !verifier || !clientId || !redirectUri) {
+ return oauthError(
+ "invalid_request",
+ "Missing code, code_verifier, client_id, or redirect_uri",
+ );
+ }
+
+ const verified = verifyArtifact("authCode", code);
+ if (!verified.ok) {
+ annotate({ action: { name: "mcp.oauth.token.code_rejected" } });
+ return oauthError(
+ "invalid_grant",
+ "Authorization code is invalid or expired",
+ );
+ }
+ const claims = verified.claims;
+
+ // Client + redirect binding.
+ if (
+ claims.client_id !== clientId ||
+ claims.redirect_uri !== redirectUri
+ ) {
+ return oauthError(
+ "invalid_grant",
+ "Authorization code was issued to a different client/redirect",
+ );
+ }
+ // Audience: a resource parameter, if present, must also match.
+ const resourceParam = get("resource");
+ if (resourceParam && !audienceMatches(resourceParam, request.url)) {
+ return oauthError(
+ "invalid_target",
+ "resource does not match the bound audience",
+ );
+ }
+ if (!audienceMatches(claims.resource, request.url)) {
+ return oauthError(
+ "invalid_target",
+ "Authorization code is bound to a different audience",
+ );
+ }
+ // PKCE.
+ if (!verifyPkceS256(verifier, claims.code_challenge)) {
+ annotate({ action: { name: "mcp.oauth.token.pkce_failed" } });
+ return oauthError("invalid_grant", "PKCE verification failed");
+ }
+ // Single use — claim the code's jti.
+ if (!(await claimJti("code", claims.jti, 5 * 60 * 1000))) {
+ annotate({ action: { name: "mcp.oauth.token.code_replayed" } });
+ return oauthError(
+ "invalid_grant",
+ "Authorization code has already been used",
+ );
+ }
+
+ return mintTokenPair({
+ userId: claims.sub,
+ clientId: claims.client_id,
+ scope: claims.scope,
+ resource: claims.resource,
+ grant: "authorization_code",
+ });
+ }
+
+ if (grantType === "refresh_token") {
+ const refreshToken = get("refresh_token");
+ const clientId = get("client_id");
+ if (!refreshToken || !clientId) {
+ return oauthError(
+ "invalid_request",
+ "Missing refresh_token or client_id",
+ );
+ }
+
+ const verified = verifyArtifact(
+ "refreshToken",
+ refreshToken,
+ );
+ if (!verified.ok) {
+ return oauthError(
+ "invalid_grant",
+ "Refresh token is invalid or expired",
+ );
+ }
+ const claims = verified.claims;
+ if (claims.client_id !== clientId) {
+ return oauthError(
+ "invalid_grant",
+ "Refresh token was issued to a different client",
+ );
+ }
+ if (!audienceMatches(claims.resource, request.url)) {
+ return oauthError(
+ "invalid_target",
+ "Refresh token is bound to a different audience",
+ );
+ }
+ // Rotation: claim the presented refresh jti so it cannot be reused.
+ if (
+ !(await claimJti(
+ "refresh",
+ claims.jti,
+ REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000,
+ ))
+ ) {
+ annotate({ action: { name: "mcp.oauth.token.refresh_replayed" } });
+ return oauthError(
+ "invalid_grant",
+ "Refresh token has already been used",
+ );
+ }
+
+ return mintTokenPair({
+ userId: claims.sub,
+ clientId: claims.client_id,
+ scope: claims.scope,
+ resource: claims.resource,
+ grant: "refresh_token",
+ });
+ }
+
+ return oauthError(
+ "unsupported_grant_type",
+ "Only authorization_code and refresh_token are supported",
+ );
+ });
+}
diff --git a/src/lib/auth/issue-token.ts b/src/lib/auth/issue-token.ts
index 214cb8732..bf4375bf2 100644
--- a/src/lib/auth/issue-token.ts
+++ b/src/lib/auth/issue-token.ts
@@ -23,6 +23,12 @@ export interface IssueTokenOptions {
permissions?: string[];
/** Lifetime in days. */
expiresInDays?: number;
+ /**
+ * Lifetime in minutes. When set it WINS over `expiresInDays` — used for the
+ * short-lived OAuth access tokens the MCP bridge mints (the refresh token
+ * carries continuity). Ignored when `expiresInDays` alone is supplied.
+ */
+ expiresInMinutes?: number;
}
/**
@@ -35,9 +41,10 @@ export async function issueApiToken(
): Promise {
const rawToken = `hlk_${randomBytes(32).toString("hex")}`;
const tokenHashValue = hashToken(rawToken);
- const expiresAt = new Date(
- Date.now() + (opts.expiresInDays ?? 90) * 24 * 60 * 60 * 1000,
- );
+ const expiresAt =
+ opts.expiresInMinutes !== undefined
+ ? new Date(Date.now() + opts.expiresInMinutes * 60 * 1000)
+ : new Date(Date.now() + (opts.expiresInDays ?? 90) * 24 * 60 * 60 * 1000);
const created = await prisma.apiToken.create({
data: {
diff --git a/src/lib/logging/redact.ts b/src/lib/logging/redact.ts
index 6aaa70f0b..90e7649a7 100644
--- a/src/lib/logging/redact.ts
+++ b/src/lib/logging/redact.ts
@@ -89,6 +89,14 @@ export function redactSecrets(input: string): string {
// these prefixes (CLAUDE.md headless-client-API patterns); this
// is the matching egress guard for log/error surfaces.
.replace(/(^|[^A-Za-z0-9])hl[kr]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]")
+ // v1.22.0 — the MCP OAuth bridge artifacts: the authorization code
+ // (`hlac_`), refresh token (`hlrt_`), and DCR client id (`hlc_`). All
+ // are signed, base64url-bodied, and credential-bearing, so they are
+ // scrubbed at the egress boundary like the native token formats above.
+ .replace(
+ /(^|[^A-Za-z0-9])hl(?:ac|rt|c)_[A-Za-z0-9_.-]{16,}/g,
+ "$1[REDACTED]",
+ )
// v1.7.0 — `insurance`/`insuranceNumber`/`kvnr` (German statutory-
// insurance number) added as defence-in-depth: the value is
// encrypted at rest and never deliberately logged, but a stray
diff --git a/src/lib/mcp/oauth/__tests__/oauth-bridge.test.ts b/src/lib/mcp/oauth/__tests__/oauth-bridge.test.ts
new file mode 100644
index 000000000..e923dc7fa
--- /dev/null
+++ b/src/lib/mcp/oauth/__tests__/oauth-bridge.test.ts
@@ -0,0 +1,216 @@
+/**
+ * OAuth bridge — library-level unit tests (artifacts, PKCE, clients, metadata,
+ * audience). These pin the security-load-bearing primitives without a DB.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+// A fixed signing key so artifact MACs are deterministic across the run.
+process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
+process.env.APP_URL = "https://health.example";
+
+vi.mock("@/lib/safe-fetch", () => ({
+ safeFetch: vi.fn(),
+ SafeFetchError: class SafeFetchError extends Error {},
+}));
+
+import { safeFetch } from "@/lib/safe-fetch";
+import { signArtifact, verifyArtifact } from "../artifacts";
+import { s256Challenge, verifyPkceS256, isValidVerifier } from "../pkce";
+import {
+ registerDcrClient,
+ resolveClient,
+ redirectUriAllowed,
+} from "../clients";
+import {
+ protectedResourceMetadata,
+ authorizationServerMetadata,
+ wwwAuthenticateChallenge,
+} from "../metadata";
+import { audienceMatches, canonicalResource } from "../config";
+
+describe("artifacts — sign / verify / tamper / expiry / domain separation", () => {
+ it("round-trips claims and enforces expiry", () => {
+ const token = signArtifact("authCode", { sub: "u1" }, 60_000);
+ const ok = verifyArtifact<{ sub: string }>("authCode", token);
+ expect(ok.ok).toBe(true);
+ if (ok.ok) expect(ok.claims.sub).toBe("u1");
+ });
+
+ it("rejects an expired artifact", () => {
+ const token = signArtifact("authCode", { sub: "u1" }, -1);
+ const res = verifyArtifact("authCode", token);
+ expect(res).toEqual({ ok: false, reason: "expired" });
+ });
+
+ it("rejects a tampered payload (bad signature)", () => {
+ const token = signArtifact("authCode", { sub: "u1" }, 60_000);
+ // Flip a character in the payload segment.
+ const [prefixAndPayload, sig] = [
+ token.slice(0, token.lastIndexOf(".")),
+ token.slice(token.lastIndexOf(".") + 1),
+ ];
+ const tampered = `${prefixAndPayload}A.${sig}`;
+ const res = verifyArtifact("authCode", tampered);
+ expect(res.ok).toBe(false);
+ });
+
+ it("domain-separates the three artifact classes", () => {
+ const code = signArtifact("authCode", { sub: "u1" }, 60_000);
+ // An auth code must not verify as a refresh token or a client id.
+ expect(verifyArtifact("refreshToken", code).ok).toBe(false);
+ expect(verifyArtifact("clientId", code).ok).toBe(false);
+ });
+});
+
+describe("PKCE — S256 only", () => {
+ it("verifies a correct verifier against its challenge", () => {
+ const verifier = "a".repeat(64);
+ const challenge = s256Challenge(verifier);
+ expect(verifyPkceS256(verifier, challenge)).toBe(true);
+ });
+
+ it("rejects a wrong verifier", () => {
+ const challenge = s256Challenge("a".repeat(64));
+ expect(verifyPkceS256("b".repeat(64), challenge)).toBe(false);
+ });
+
+ it("rejects a too-short verifier (RFC 7636 §4.1)", () => {
+ expect(isValidVerifier("short")).toBe(false);
+ expect(verifyPkceS256("short", s256Challenge("short"))).toBe(false);
+ });
+});
+
+describe("clients — DCR (stateless) round-trip", () => {
+ it("registers and resolves a DCR client with its redirect URIs", async () => {
+ const reg = registerDcrClient({
+ clientName: "Claude",
+ redirectUris: ["https://claude.ai/api/mcp/auth_callback"],
+ });
+ expect(reg.clientId.startsWith("hlc_")).toBe(true);
+
+ const resolved = await resolveClient(reg.clientId);
+ expect(resolved.ok).toBe(true);
+ if (resolved.ok) {
+ expect(resolved.client.source).toBe("dcr");
+ expect(resolved.client.redirectUris).toEqual([
+ "https://claude.ai/api/mcp/auth_callback",
+ ]);
+ }
+ });
+
+ it("rejects an unknown client id", async () => {
+ const res = await resolveClient("not-a-client");
+ expect(res.ok).toBe(false);
+ });
+});
+
+describe("clients — CIMD via safeFetch (SSRF-safe)", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("fetches a CIMD document through safeFetch with requirePublicHost", async () => {
+ const clientId = "https://app.example/mcp-client.json";
+ vi.mocked(safeFetch).mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ client_id: clientId,
+ client_name: "ChatGPT",
+ redirect_uris: [
+ "https://chatgpt.com/connector_platform_oauth_redirect",
+ ],
+ }),
+ { status: 200 },
+ ),
+ );
+
+ const res = await resolveClient(clientId);
+ expect(res.ok).toBe(true);
+ // The SSRF guard is requested.
+ const opts = vi.mocked(safeFetch).mock.calls[0][2];
+ expect(opts?.requirePublicHost).toBe(true);
+ });
+
+ it("rejects a CIMD doc whose client_id does not match its URL (SEP-991)", async () => {
+ const clientId = "https://app.example/mcp-client.json";
+ vi.mocked(safeFetch).mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ client_id: "https://attacker.example/other.json",
+ redirect_uris: ["https://app.example/cb"],
+ }),
+ { status: 200 },
+ ),
+ );
+ const res = await resolveClient(clientId);
+ expect(res).toEqual({ ok: false, reason: "invalid_metadata" });
+ });
+
+ it("surfaces an SSRF block as ssrf_blocked", async () => {
+ vi.mocked(safeFetch).mockRejectedValue(new Error("private host"));
+ const res = await resolveClient("https://169.254.169.254/meta.json");
+ expect(res).toEqual({ ok: false, reason: "ssrf_blocked" });
+ });
+});
+
+describe("clients — redirect URI matching", () => {
+ it("matches an exact registered URI (claude.ai callback)", () => {
+ const registered = ["https://claude.ai/api/mcp/auth_callback"];
+ expect(
+ redirectUriAllowed("https://claude.ai/api/mcp/auth_callback", registered),
+ ).toBe(true);
+ });
+
+ it("matches loopback port-agnostically", () => {
+ const registered = ["http://localhost:1234/callback"];
+ expect(
+ redirectUriAllowed("http://localhost:55999/callback", registered),
+ ).toBe(true);
+ expect(
+ redirectUriAllowed("http://127.0.0.1:1/callback", [
+ "http://127.0.0.1:9/callback",
+ ]),
+ ).toBe(true);
+ });
+
+ it("rejects a non-loopback mismatch and a path mismatch", () => {
+ expect(
+ redirectUriAllowed("https://evil.example/cb", [
+ "https://claude.ai/api/mcp/auth_callback",
+ ]),
+ ).toBe(false);
+ expect(
+ redirectUriAllowed("http://localhost:1/evil", [
+ "http://localhost:2/callback",
+ ]),
+ ).toBe(false);
+ });
+});
+
+describe("metadata + audience", () => {
+ it("PRM names the canonical /mcp resource", () => {
+ const prm = protectedResourceMetadata();
+ expect(prm.resource).toBe("https://health.example/mcp");
+ expect(prm.authorization_servers).toEqual(["https://health.example"]);
+ });
+
+ it("AS metadata advertises S256-only PKCE + CIMD + none auth", () => {
+ const as = authorizationServerMetadata();
+ expect(as.code_challenge_methods_supported).toEqual(["S256"]);
+ expect(as.token_endpoint_auth_methods_supported).toEqual(["none"]);
+ expect(as.client_id_metadata_document_supported).toBe(true);
+ expect(as.grant_types_supported).toContain("authorization_code");
+ expect(as.grant_types_supported).toContain("refresh_token");
+ });
+
+ it("the WWW-Authenticate challenge points at the PRM", () => {
+ expect(wwwAuthenticateChallenge()).toMatch(
+ /resource_metadata="https:\/\/health\.example\/\.well-known\/oauth-protected-resource"/,
+ );
+ });
+
+ it("audience binding accepts the canonical resource and rejects look-alikes", () => {
+ expect(audienceMatches(canonicalResource())).toBe(true);
+ expect(audienceMatches("https://health.example/mcp/")).toBe(true);
+ expect(audienceMatches("https://evil.example/mcp")).toBe(false);
+ expect(audienceMatches(undefined)).toBe(false);
+ });
+});
diff --git a/src/lib/mcp/oauth/artifacts.ts b/src/lib/mcp/oauth/artifacts.ts
new file mode 100644
index 000000000..510c2688a
--- /dev/null
+++ b/src/lib/mcp/oauth/artifacts.ts
@@ -0,0 +1,128 @@
+/**
+ * Signed, self-describing OAuth artifacts (stateless bridge).
+ *
+ * The minimal AS keeps NO new identity tables (ADR-006). The three short-lived
+ * OAuth artifacts it mints — the authorization code, the refresh token, and the
+ * Dynamic-Client-Registration client id — are instead HMAC-signed, tamper-proof,
+ * self-describing strings. Each carries its own claims and is verified by
+ * recomputing the signature in constant time; nothing is looked up in a table.
+ *
+ * Only the ACCESS token is a real persisted row: it is an ordinary `hlk_`
+ * `ApiToken` minted through `issue-token.ts`, so the existing revoke / expiry /
+ * `lastUsedAt` machinery and the `:` binding all apply to it
+ * unchanged. The artifacts here exist only to carry grant state BETWEEN the
+ * authorize and token steps without inventing a parallel store.
+ *
+ * Signing key: `API_TOKEN_HMAC_KEY` (the same fail-closed key the Bearer hasher
+ * uses — `hashToken` validates length ≥ 32). Each artifact class is domain-
+ * separated by a context label folded into the MAC input, so a code can never be
+ * presented as a refresh token or a client id, and vice-versa.
+ *
+ * The signed value is `..`. The
+ * payload always carries an `exp` (epoch ms) which `verifyArtifact` enforces.
+ */
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+/** Artifact classes, each with a stable prefix + domain-separation label. */
+export const ARTIFACT_KINDS = {
+ authCode: { prefix: "hlac_", context: "mcp.oauth.auth_code.v1" },
+ refreshToken: { prefix: "hlrt_", context: "mcp.oauth.refresh.v1" },
+ clientId: { prefix: "hlc_", context: "mcp.oauth.client.v1" },
+} as const;
+
+export type ArtifactKind = keyof typeof ARTIFACT_KINDS;
+
+function signingKey(): string {
+ const key = process.env.API_TOKEN_HMAC_KEY;
+ if (!key || key.length < 32) {
+ // Fail closed — never sign an OAuth artifact off weak / missing key
+ // material (mirrors `hashToken`).
+ throw new Error(
+ "API_TOKEN_HMAC_KEY must be set and at least 32 characters",
+ );
+ }
+ return key;
+}
+
+function b64url(input: Buffer | string): string {
+ return Buffer.from(input)
+ .toString("base64")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/, "");
+}
+
+function b64urlDecode(input: string): Buffer {
+ return Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/"), "base64");
+}
+
+function mac(context: string, payloadB64: string): string {
+ return createHmac("sha256", signingKey())
+ .update(`${context}.${payloadB64}`)
+ .digest("hex");
+}
+
+/**
+ * Mint a signed artifact. `claims` is merged with `exp` (now + `ttlMs`) and
+ * serialised; the caller never sets `exp` itself.
+ */
+export function signArtifact(
+ kind: ArtifactKind,
+ claims: Record,
+ ttlMs: number,
+): string {
+ const { prefix, context } = ARTIFACT_KINDS[kind];
+ const payload = { ...claims, exp: Date.now() + ttlMs };
+ const payloadB64 = b64url(JSON.stringify(payload));
+ // `mac` returns hex; base64url the RAW bytes so `verifyArtifact` can compare
+ // against `Buffer.from(expectedSig, "hex")` byte-for-byte.
+ const sig = b64url(Buffer.from(mac(context, payloadB64), "hex"));
+ return `${prefix}${payloadB64}.${sig}`;
+}
+
+export type VerifyResult =
+ | { ok: true; claims: T }
+ | { ok: false; reason: "malformed" | "bad_signature" | "expired" };
+
+/**
+ * Verify + decode a signed artifact. Returns the claims on success, or a stable
+ * machine reason on failure. The signature is checked in constant time and the
+ * `exp` claim is enforced. A wrong-kind artifact fails on `malformed` (prefix
+ * mismatch) or `bad_signature` (domain-separation label mismatch).
+ */
+export function verifyArtifact>(
+ kind: ArtifactKind,
+ value: string,
+): VerifyResult {
+ const { prefix, context } = ARTIFACT_KINDS[kind];
+ if (typeof value !== "string" || !value.startsWith(prefix)) {
+ return { ok: false, reason: "malformed" };
+ }
+ const body = value.slice(prefix.length);
+ const dot = body.lastIndexOf(".");
+ if (dot <= 0) return { ok: false, reason: "malformed" };
+
+ const payloadB64 = body.slice(0, dot);
+ const sigB64 = body.slice(dot + 1);
+
+ const expectedSig = mac(context, payloadB64);
+ const presented = b64urlDecode(sigB64);
+ const expected = Buffer.from(expectedSig, "hex");
+ if (
+ presented.length !== expected.length ||
+ !timingSafeEqual(presented, expected)
+ ) {
+ return { ok: false, reason: "bad_signature" };
+ }
+
+ let claims: T & { exp?: number };
+ try {
+ claims = JSON.parse(b64urlDecode(payloadB64).toString("utf8"));
+ } catch {
+ return { ok: false, reason: "malformed" };
+ }
+ if (typeof claims.exp !== "number" || claims.exp <= Date.now()) {
+ return { ok: false, reason: "expired" };
+ }
+ return { ok: true, claims };
+}
diff --git a/src/lib/mcp/oauth/clients.ts b/src/lib/mcp/oauth/clients.ts
new file mode 100644
index 000000000..74bcf2624
--- /dev/null
+++ b/src/lib/mcp/oauth/clients.ts
@@ -0,0 +1,222 @@
+/**
+ * OAuth client resolution — CIMD-first, DCR fallback (Phase 3 of the MCP
+ * milestone; changes B's DCR-first plan per the deep-value research).
+ *
+ * Both Claude.ai and ChatGPT now prefer **CIMD** (Client ID Metadata Documents,
+ * SEP-991): the `client_id` IS an HTTPS URL that resolves to a JSON metadata
+ * document describing the client. CIMD removes the per-connection client-row
+ * explosion that DCR causes — and, here, removes the need for ANY client store.
+ * A CIMD client is resolved by fetching its document THROUGH `safeFetch`
+ * (SSRF-safe: `requirePublicHost`, manual redirects, bounded timeout) so a
+ * malicious `client_id` cannot point the AS at an internal host (R-SEC-6).
+ *
+ * For clients that still use **DCR** (RFC 7591) we register them statelessly:
+ * the issued `client_id` is itself a signed, self-describing `hlc_` artifact
+ * (see `artifacts.ts`) carrying the registered `redirect_uris` + name. Decoding
+ * it locally recovers the registration with no table — and tampering with it
+ * fails the HMAC.
+ *
+ * Redirect-URI matching is exact against the registered set, with ONE tolerated
+ * relaxation: loopback redirect URIs match port-agnostically (Claude Code /
+ * Desktop bind an ephemeral localhost port), per the connector spec. The
+ * `https://claude.ai/api/mcp/auth_callback` hosted callback is matched as an
+ * ordinary registered entry.
+ */
+import { safeFetch } from "@/lib/safe-fetch";
+import { ARTIFACT_KINDS, signArtifact, verifyArtifact } from "./artifacts";
+
+export interface ResolvedClient {
+ clientId: string;
+ clientName: string;
+ redirectUris: string[];
+ /** CIMD + DCR public clients authenticate with `none` (PKCE is the proof). */
+ isPublic: true;
+ source: "cimd" | "dcr";
+}
+
+export type ClientResolution =
+ | { ok: true; client: ResolvedClient }
+ | {
+ ok: false;
+ reason: "unknown_client" | "invalid_metadata" | "ssrf_blocked";
+ };
+
+const MAX_REDIRECT_URIS = 12;
+const CIMD_MAX_BYTES = 16 * 1024;
+
+function asStringArray(value: unknown): string[] | null {
+ if (!Array.isArray(value)) return null;
+ const out: string[] = [];
+ for (const v of value) {
+ if (typeof v !== "string" || v.length === 0 || v.length > 2048) return null;
+ out.push(v);
+ }
+ if (out.length === 0 || out.length > MAX_REDIRECT_URIS) return null;
+ return out;
+}
+
+/** A `client_id` that is an HTTPS URL is a CIMD client (SEP-991). */
+export function isCimdClientId(clientId: string): boolean {
+ try {
+ return new URL(clientId).protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
+/** A `client_id` minted by our stateless DCR endpoint. */
+export function isDcrClientId(clientId: string): boolean {
+ return clientId.startsWith(ARTIFACT_KINDS.clientId.prefix);
+}
+
+/**
+ * Register a client via DCR (RFC 7591). The returned `client_id` is a signed
+ * `hlc_` artifact that encodes the registration — no row is written. DCR clients
+ * never expire (the artifact has a long horizon) but carry no secret.
+ */
+export function registerDcrClient(input: {
+ clientName: string;
+ redirectUris: string[];
+}): ResolvedClient {
+ const clientId = signArtifact(
+ "clientId",
+ { name: input.clientName, redirect_uris: input.redirectUris },
+ // Ten-year horizon: a registration is long-lived but still self-expiring.
+ 10 * 365 * 24 * 60 * 60 * 1000,
+ );
+ return {
+ clientId,
+ clientName: input.clientName,
+ redirectUris: input.redirectUris,
+ isPublic: true,
+ source: "dcr",
+ };
+}
+
+function resolveDcrClient(clientId: string): ClientResolution {
+ const verified = verifyArtifact<{ name?: string; redirect_uris?: unknown }>(
+ "clientId",
+ clientId,
+ );
+ if (!verified.ok) return { ok: false, reason: "unknown_client" };
+ const redirectUris = asStringArray(verified.claims.redirect_uris);
+ if (!redirectUris) return { ok: false, reason: "invalid_metadata" };
+ return {
+ ok: true,
+ client: {
+ clientId,
+ clientName:
+ typeof verified.claims.name === "string"
+ ? verified.claims.name
+ : "MCP client",
+ redirectUris,
+ isPublic: true,
+ source: "dcr",
+ },
+ };
+}
+
+async function resolveCimdClient(clientId: string): Promise {
+ let res: Response;
+ try {
+ // SSRF floor: the metadata host is fully user-controlled, so pin the
+ // connect-time public-host check + manual redirects + bounded timeout.
+ res = await safeFetch(
+ clientId,
+ { method: "GET", headers: { accept: "application/json" } },
+ { requirePublicHost: true, timeoutMs: 8000 },
+ );
+ } catch {
+ return { ok: false, reason: "ssrf_blocked" };
+ }
+ if (!res.ok) return { ok: false, reason: "unknown_client" };
+
+ const raw = await res.text();
+ if (raw.length > CIMD_MAX_BYTES) {
+ return { ok: false, reason: "invalid_metadata" };
+ }
+ let doc: Record;
+ try {
+ doc = JSON.parse(raw);
+ } catch {
+ return { ok: false, reason: "invalid_metadata" };
+ }
+
+ // SEP-991: the document's `client_id` MUST equal the URL it was fetched from.
+ if (doc.client_id !== clientId) {
+ return { ok: false, reason: "invalid_metadata" };
+ }
+ const redirectUris = asStringArray(doc.redirect_uris);
+ if (!redirectUris) return { ok: false, reason: "invalid_metadata" };
+
+ return {
+ ok: true,
+ client: {
+ clientId,
+ clientName:
+ typeof doc.client_name === "string" ? doc.client_name : "MCP client",
+ redirectUris,
+ isPublic: true,
+ source: "cimd",
+ },
+ };
+}
+
+/**
+ * Resolve a `client_id` to its registration. HTTPS URLs are CIMD (fetched
+ * SSRF-safely); `hlc_` ids are stateless DCR; anything else is unknown.
+ */
+export async function resolveClient(
+ clientId: string,
+): Promise {
+ if (typeof clientId !== "string" || clientId.length === 0) {
+ return { ok: false, reason: "unknown_client" };
+ }
+ if (isCimdClientId(clientId)) return resolveCimdClient(clientId);
+ if (isDcrClientId(clientId)) return resolveDcrClient(clientId);
+ return { ok: false, reason: "unknown_client" };
+}
+
+const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
+
+function isLoopback(url: URL): boolean {
+ return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
+}
+
+/**
+ * Whether `requested` is an allowed redirect URI for `registered`. Exact match
+ * by default; loopback URIs match port-agnostically (the AS ignores the port for
+ * `localhost` / `127.0.0.1` / `::1`, per the connector spec) so Claude Code /
+ * Desktop's ephemeral callback port still matches its registration.
+ */
+export function redirectUriAllowed(
+ requested: string,
+ registered: readonly string[],
+): boolean {
+ if (registered.includes(requested)) return true;
+
+ let req: URL;
+ try {
+ req = new URL(requested);
+ } catch {
+ return false;
+ }
+ if (!isLoopback(req)) return false;
+
+ for (const entry of registered) {
+ let reg: URL;
+ try {
+ reg = new URL(entry);
+ } catch {
+ continue;
+ }
+ if (
+ isLoopback(reg) &&
+ reg.hostname === req.hostname &&
+ reg.pathname === req.pathname
+ ) {
+ return true; // ignore the port for loopback
+ }
+ }
+ return false;
+}
diff --git a/src/lib/mcp/oauth/config.ts b/src/lib/mcp/oauth/config.ts
new file mode 100644
index 000000000..b36eaec5b
--- /dev/null
+++ b/src/lib/mcp/oauth/config.ts
@@ -0,0 +1,88 @@
+/**
+ * OAuth bridge configuration — origin + canonical resource resolution.
+ *
+ * The MCP authorization surface is a minimal in-repo Authorization Server that
+ * BRIDGES onto HealthLog's existing `hlk_` Bearer model (ADR-006) — it keeps no
+ * parallel identity store. Every metadata document, redirect-URI match, and
+ * audience check is anchored to ONE canonical origin so a token minted for this
+ * deployment can never be replayed against another (RFC 8707 audience binding).
+ *
+ * Origin precedence mirrors the passkey RP-origin / invite-URL resolution: the
+ * operator-configured `APP_URL` / `NEXT_PUBLIC_APP_URL` win over the request
+ * origin (which may be an internal hostname behind the reverse proxy). The
+ * canonical resource identifier is the `/mcp` endpoint exactly — the same URL a
+ * user pastes into Claude.ai / ChatGPT — so the Protected Resource Metadata
+ * `resource` value matches the user-entered URL (a Claude.ai requirement).
+ */
+
+/**
+ * Resolve the public origin of this deployment, e.g. `https://health.example`.
+ * Operator config wins; the request URL is the last resort.
+ */
+export function resolveBaseOrigin(requestUrl?: string): string {
+ const candidates = [
+ process.env.APP_URL,
+ process.env.NEXT_PUBLIC_APP_URL,
+ requestUrl,
+ ]
+ .map((value) => value?.trim())
+ .filter((value): value is string => Boolean(value));
+
+ for (const candidate of candidates) {
+ try {
+ return new URL(candidate).origin;
+ } catch {
+ // try the next candidate
+ }
+ }
+ return "http://localhost:3000";
+}
+
+/**
+ * The canonical MCP resource identifier (RFC 8707 audience). This is the value
+ * the AS binds every issued token to and the value a client MUST send as
+ * `resource=` — any other value is rejected.
+ */
+export function canonicalResource(requestUrl?: string): string {
+ return `${resolveBaseOrigin(requestUrl)}/mcp`;
+}
+
+/**
+ * Whether a client-supplied `resource` value matches our canonical resource.
+ * Trailing slashes are tolerated (`/mcp` vs `/mcp/`) but the origin + path must
+ * otherwise be exact — no substring / prefix matching that could admit a
+ * look-alike host (RFC 8707, R-SEC-5).
+ */
+export function audienceMatches(
+ resource: string | null | undefined,
+ requestUrl?: string,
+): boolean {
+ if (!resource) return false;
+ const want = canonicalResource(requestUrl).replace(/\/+$/, "");
+ const got = resource.trim().replace(/\/+$/, "");
+ return want === got;
+}
+
+/** The single scope the read MCP surface issues. */
+export const SCOPE_HEALTH_READ = "health:read";
+
+/**
+ * Scopes the AS advertises + will issue. `health:write` is advertised so a
+ * future incremental-consent (SEP-835) challenge can request it, but the read
+ * surface only ever issues `health:read`. `offline_access` enables refresh
+ * tokens (OAuth 2.1 public-client rotation) — Claude appends it when advertised.
+ */
+export const SUPPORTED_SCOPES = [
+ SCOPE_HEALTH_READ,
+ "health:write",
+ "offline_access",
+] as const;
+
+/** Access-token lifetime (minutes). Short-lived; refresh tokens carry continuity. */
+export const ACCESS_TOKEN_TTL_MINUTES = 60;
+
+/** Refresh-token lifetime (days). */
+export const REFRESH_TOKEN_TTL_DAYS = 30;
+
+/** Authorization-code lifetime (seconds). Single-redemption, short window. */
+export const AUTH_CODE_TTL_SECONDS = 120;
diff --git a/src/lib/mcp/oauth/metadata.ts b/src/lib/mcp/oauth/metadata.ts
new file mode 100644
index 000000000..ef2f4412a
--- /dev/null
+++ b/src/lib/mcp/oauth/metadata.ts
@@ -0,0 +1,92 @@
+/**
+ * OAuth discovery documents — RFC 9728 (Protected Resource Metadata) + RFC 8414
+ * (Authorization Server Metadata).
+ *
+ * These two JSON documents are how a remote MCP client bootstraps the flow from
+ * nothing but the pasted `/mcp` URL:
+ *
+ * 1. The client calls `/mcp`, gets a `401` whose `WWW-Authenticate` points at
+ * `/.well-known/oauth-protected-resource` (PRM).
+ * 2. PRM names this `resource` (the canonical `/mcp` URI — it MUST match the
+ * user-entered URL) and lists its `authorization_servers`.
+ * 3. The client fetches the AS metadata and learns the `/authorize` + `/token`
+ * + `/register` endpoints, that **PKCE S256 is mandatory**, and that the AS
+ * accepts **CIMD** clients (`client_id_metadata_document_supported: true` +
+ * `none` auth) with **DCR** as a fallback.
+ *
+ * Everything is anchored to the canonical origin (`config.ts`) so the documents
+ * are deterministic and the audience can be validated against them.
+ */
+import {
+ canonicalResource,
+ resolveBaseOrigin,
+ SUPPORTED_SCOPES,
+} from "./config";
+
+export interface ProtectedResourceMetadata {
+ resource: string;
+ authorization_servers: string[];
+ scopes_supported: string[];
+ bearer_methods_supported: string[];
+ resource_documentation?: string;
+}
+
+export interface AuthorizationServerMetadata {
+ issuer: string;
+ authorization_endpoint: string;
+ token_endpoint: string;
+ registration_endpoint: string;
+ scopes_supported: string[];
+ response_types_supported: string[];
+ grant_types_supported: string[];
+ token_endpoint_auth_methods_supported: string[];
+ code_challenge_methods_supported: string[];
+ client_id_metadata_document_supported: boolean;
+}
+
+/** RFC 9728 — the Protected Resource Metadata for the `/mcp` resource. */
+export function protectedResourceMetadata(
+ requestUrl?: string,
+): ProtectedResourceMetadata {
+ const origin = resolveBaseOrigin(requestUrl);
+ return {
+ resource: canonicalResource(requestUrl),
+ authorization_servers: [origin],
+ scopes_supported: [...SUPPORTED_SCOPES],
+ bearer_methods_supported: ["header"],
+ };
+}
+
+/** RFC 8414 — the Authorization Server Metadata for the built-in bridge AS. */
+export function authorizationServerMetadata(
+ requestUrl?: string,
+): AuthorizationServerMetadata {
+ const origin = resolveBaseOrigin(requestUrl);
+ return {
+ issuer: origin,
+ authorization_endpoint: `${origin}/api/mcp/oauth/authorize`,
+ token_endpoint: `${origin}/api/mcp/oauth/token`,
+ registration_endpoint: `${origin}/api/mcp/oauth/register`,
+ scopes_supported: [...SUPPORTED_SCOPES],
+ response_types_supported: ["code"],
+ grant_types_supported: ["authorization_code", "refresh_token"],
+ // Public clients only — PKCE is the proof of possession, not a secret.
+ token_endpoint_auth_methods_supported: ["none"],
+ // S256 ONLY — `plain` is structurally unsupported (RFC 7636 + connectors).
+ code_challenge_methods_supported: ["S256"],
+ // CIMD is the preferred registration path (SEP-991); DCR is the fallback.
+ client_id_metadata_document_supported: true,
+ };
+}
+
+/** Build the `WWW-Authenticate` value pointing a 401 at the PRM (RFC 9728). */
+export function wwwAuthenticateChallenge(
+ requestUrl?: string,
+ scope?: string,
+): string {
+ const origin = resolveBaseOrigin(requestUrl);
+ const prm = `${origin}/.well-known/oauth-protected-resource`;
+ const parts = ['Bearer realm="healthlog-mcp"', `resource_metadata="${prm}"`];
+ if (scope) parts.push(`scope="${scope}"`);
+ return parts.join(", ");
+}
diff --git a/src/lib/mcp/oauth/pkce.ts b/src/lib/mcp/oauth/pkce.ts
new file mode 100644
index 000000000..aaf8156a0
--- /dev/null
+++ b/src/lib/mcp/oauth/pkce.ts
@@ -0,0 +1,56 @@
+/**
+ * PKCE (RFC 7636) — S256 only.
+ *
+ * Both Claude.ai and ChatGPT send `code_challenge_method=S256` on every
+ * authorization request, and the AS advertises ONLY `S256` in
+ * `code_challenge_methods_supported`. The `plain` method is structurally
+ * unsupported here — a missing or non-S256 challenge is rejected at `/authorize`,
+ * and a verifier that does not hash to the bound challenge is rejected at
+ * `/token`. PKCE is therefore mandatory on the whole authorization-code flow.
+ */
+import { createHash, timingSafeEqual } from "node:crypto";
+
+/** Base64url-encode without padding (the PKCE wire form). */
+function b64url(buf: Buffer): string {
+ return buf
+ .toString("base64")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/, "");
+}
+
+/** The S256 transform: BASE64URL(SHA256(ASCII(verifier))). */
+export function s256Challenge(verifier: string): string {
+ return b64url(createHash("sha256").update(verifier, "ascii").digest());
+}
+
+/** RFC 7636 §4.1 — verifier is 43–128 chars of the unreserved set. */
+export function isValidVerifier(verifier: unknown): verifier is string {
+ return (
+ typeof verifier === "string" && /^[A-Za-z0-9\-._~]{43,128}$/.test(verifier)
+ );
+}
+
+/** A non-empty challenge string (the exact length is the client's concern). */
+export function isValidChallenge(challenge: unknown): challenge is string {
+ return (
+ typeof challenge === "string" &&
+ challenge.length >= 43 &&
+ challenge.length <= 128
+ );
+}
+
+/**
+ * Constant-time check that `verifier` hashes (S256) to `challenge`. Returns
+ * false for any malformed input rather than throwing.
+ */
+export function verifyPkceS256(verifier: string, challenge: string): boolean {
+ if (!isValidVerifier(verifier) || !isValidChallenge(challenge)) {
+ return false;
+ }
+ const computed = Buffer.from(s256Challenge(verifier));
+ const expected = Buffer.from(challenge);
+ return (
+ computed.length === expected.length && timingSafeEqual(computed, expected)
+ );
+}
diff --git a/src/lib/observability/redact-payload.ts b/src/lib/observability/redact-payload.ts
index f9dd8b9be..9e0ca7b4e 100644
--- a/src/lib/observability/redact-payload.ts
+++ b/src/lib/observability/redact-payload.ts
@@ -81,6 +81,11 @@ export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [
/telegram[_-]?chat[_-]?id/i,
/chatid/i,
/chat[_-]id/i,
+ // v1.22.0 — the PKCE `code_verifier` (the proof-of-possession secret the MCP
+ // OAuth token endpoint receives). `access_token` / `refresh_token` /
+ // `client_secret` are already covered by `/token/i` + `/secret/i`; the
+ // verifier is the one secret-shaped key those patterns miss.
+ /verifier/i,
];
const REDACTED = "[redacted]";
diff --git a/src/proxy.ts b/src/proxy.ts
index 0061456bb..d686583e4 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -71,6 +71,13 @@ const PUBLIC_PATHS = [
*/
const WELL_KNOWN_PUBLIC_PATHS = new Set([
"/.well-known/apple-app-site-association",
+ // v1.22.0 — OAuth discovery for the remote MCP connector. A remote client
+ // (Claude.ai / ChatGPT) reads these unauthenticated to bootstrap the OAuth
+ // 2.1 + PKCE flow from the pasted `/mcp` URL alone: RFC 9728 Protected
+ // Resource Metadata + RFC 8414 Authorization Server Metadata. Both are
+ // public, deterministic, secret-free discovery documents.
+ "/.well-known/oauth-protected-resource",
+ "/.well-known/oauth-authorization-server",
]);
/**
From 63e4b1784310c48d6736db5145986940483f07f5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 27 Jun 2026 14:27:20 +0200
Subject: [PATCH 05/11] feat(mcp): ship search/fetch tools, annotations, and
the Origin gate
- Add the `search` + `fetch` retrieval pair with the exact ChatGPT wire
shapes (`{id,title,url}[]` and `{id,title,text,url,metadata}`), backed by
the same server-authoritative reads and returning real deep-link URLs into
the web app for citations. Without these the server is invisible in
default-mode ChatGPT.
- Annotate every read tool `readOnlyHint`/`destructiveHint:false`/
`openWorldHint:false`; structured tools also declare an `outputSchema` and
return `structuredContent`. Omitting the hints is a validation error in the
connectors.
- Reject a cross-origin browser request to `/mcp` with 403 (DNS-rebinding
defense) and point the 401 `WWW-Authenticate` at the resource metadata.
---
src/app/mcp/__tests__/route.test.ts | 44 ++++
src/app/mcp/route.ts | 44 +++-
src/lib/mcp/__tests__/search-fetch.test.ts | 141 +++++++++++++
src/lib/mcp/__tests__/tools.test.ts | 22 ++
src/lib/mcp/server.ts | 21 +-
src/lib/mcp/tools.ts | 224 +++++++++++++++++++++
6 files changed, 486 insertions(+), 10 deletions(-)
create mode 100644 src/lib/mcp/__tests__/search-fetch.test.ts
diff --git a/src/app/mcp/__tests__/route.test.ts b/src/app/mcp/__tests__/route.test.ts
index 90f9ca47c..b838cd9a8 100644
--- a/src/app/mcp/__tests__/route.test.ts
+++ b/src/app/mcp/__tests__/route.test.ts
@@ -67,6 +67,8 @@ const READ_TOOLS = [
"get_medication_compliance",
"get_metric_series",
"list_metrics",
+ "search",
+ "fetch",
].sort();
/** A valid, narrow-scope (`health:read`) token resolution. */
@@ -148,6 +150,48 @@ describe("/mcp — happy path", () => {
});
});
+describe("/mcp — Origin gate (DNS-rebinding defense, spec PR #1439)", () => {
+ it("rejects a cross-origin browser request with 403 before auth", async () => {
+ validToken();
+ const res = await POST(
+ postRpc(TOOLS_LIST, { origin: "https://evil.example" }),
+ );
+ expect(res.status).toBe(403);
+ // The Origin check runs first — the token table is never consulted.
+ expect(resolveBearerToken).not.toHaveBeenCalled();
+ });
+
+ it("allows a same-origin request", async () => {
+ validToken();
+ const res = await POST(postRpc(TOOLS_LIST, { origin: "http://localhost" }));
+ expect(res.status).toBe(200);
+ });
+
+ it("allows a request with no Origin header (non-browser MCP client)", async () => {
+ validToken();
+ const res = await POST(postRpc(TOOLS_LIST));
+ expect(res.status).toBe(200);
+ });
+});
+
+describe("/mcp — 401 carries the RFC 9728 resource_metadata pointer", () => {
+ it("includes resource_metadata in the WWW-Authenticate challenge", async () => {
+ const res = await POST(
+ new Request("http://localhost/mcp", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(TOOLS_LIST),
+ }),
+ );
+ expect(res.status).toBe(401);
+ const challenge = res.headers.get("www-authenticate") ?? "";
+ expect(challenge).toMatch(/Bearer/);
+ expect(challenge).toMatch(
+ /resource_metadata="[^"]*\/\.well-known\/oauth-protected-resource"/,
+ );
+ });
+});
+
describe("/mcp — authentication", () => {
it("rejects a request with no Authorization header (401 + challenge)", async () => {
const res = await POST(
diff --git a/src/app/mcp/route.ts b/src/app/mcp/route.ts
index c75463026..792182441 100644
--- a/src/app/mcp/route.ts
+++ b/src/app/mcp/route.ts
@@ -47,6 +47,8 @@ import { withBackgroundEvent } from "@/lib/logging/background";
import { annotate } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
import { checkMcpRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
+import { resolveBaseOrigin } from "@/lib/mcp/oauth/config";
+import { wwwAuthenticateChallenge } from "@/lib/mcp/oauth/metadata";
import type { ModuleKey } from "@/lib/modules/registry";
// The Bearer resolver + module gate + rate limiter all touch Prisma, so the
@@ -56,19 +58,41 @@ export const dynamic = "force-dynamic";
const MCP_MODULE: ModuleKey = "mcp";
-/** One blunt 401 for every authentication failure class — no oracle. */
-function unauthorized(): Response {
+/**
+ * One blunt 401 for every authentication failure class — no oracle. The
+ * `WWW-Authenticate` carries the RFC 9728 `resource_metadata` pointer so a
+ * remote client can discover the Authorization Server and start the OAuth flow
+ * (REQ-T3). An optional `scope` drives incremental consent (SEP-835).
+ */
+function unauthorized(request: Request, scope?: string): Response {
return Response.json(
{ error: "unauthorized" },
{
status: 401,
- // Pre-OAuth bridge: a bare Bearer challenge. The RFC 9728
- // `resource_metadata` parameter is added when OAuth discovery lands.
- headers: { "WWW-Authenticate": 'Bearer realm="healthlog-mcp"' },
+ headers: {
+ "WWW-Authenticate": wwwAuthenticateChallenge(request.url, scope),
+ },
},
);
}
+/**
+ * DNS-rebinding defense (MCP spec PR #1439): reject a request whose `Origin`
+ * header does not match this deployment's own origin with a 403. A non-browser
+ * MCP client (Claude.ai / ChatGPT server-side) sends no `Origin`, so the absence
+ * of the header is allowed; only a present, mismatched browser `Origin` is
+ * refused — closing the rebinding hole without breaking the real clients.
+ */
+function originAllowed(request: Request): boolean {
+ const origin = request.headers.get("origin");
+ if (!origin) return true;
+ return origin === resolveBaseOrigin(request.url);
+}
+
+function forbiddenOrigin(): Response {
+ return Response.json({ error: "forbidden_origin" }, { status: 403 });
+}
+
/**
* Module off → the surface does not exist for this account. A 404 (not a
* 403) keeps the remote endpoint invisible until the operator / user opts
@@ -101,11 +125,17 @@ function readBearer(request: Request): string | null {
async function handleMcp(request: Request): Promise {
return withBackgroundEvent("mcp.request", async () => {
+ // 0. ORIGIN — refuse a cross-origin browser request before anything else.
+ if (!originAllowed(request)) {
+ annotate({ action: { name: "mcp.origin.rejected" } });
+ return forbiddenOrigin();
+ }
+
// 1. AUTH — Bearer only; a cookie is never consulted (admin unreachable).
const raw = readBearer(request);
if (!raw) {
annotate({ action: { name: "mcp.auth.missing" } });
- return unauthorized();
+ return unauthorized(request);
}
let ctx;
@@ -114,7 +144,7 @@ async function handleMcp(request: Request): Promise {
} catch {
// Do not echo the token or the rejection reason — one blunt 401.
annotate({ action: { name: "mcp.auth.rejected" } });
- return unauthorized();
+ return unauthorized(request);
}
// 2. RATE LIMIT — per `:` credential binding.
diff --git a/src/lib/mcp/__tests__/search-fetch.test.ts b/src/lib/mcp/__tests__/search-fetch.test.ts
new file mode 100644
index 000000000..a9280e939
--- /dev/null
+++ b/src/lib/mcp/__tests__/search-fetch.test.ts
@@ -0,0 +1,141 @@
+/**
+ * `search` + `fetch` — the ChatGPT default-mode retrieval pair.
+ *
+ * Pins the EXACT wire shapes OpenAI mandates (`{id,title,url}[]` /
+ * `{id,title,text,url,metadata}`), real deep-link URLs, and the read-only
+ * annotations.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { z } from "zod/v4";
+
+process.env.APP_URL = "https://health.example";
+
+vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
+vi.mock("@/lib/ai/coach/tools/inventory", () => ({
+ buildCoachDataInventory: vi.fn(),
+}));
+vi.mock("@/lib/ai/coach/tools/executor", () => ({ executeCoachTool: vi.fn() }));
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ medication: { findMany: vi.fn(), findFirst: vi.fn() },
+ labResult: { findMany: vi.fn() },
+ },
+}));
+
+import { MCP_TOOLS } from "../tools";
+import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory";
+import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
+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 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.clearAllMocks();
+ vi.mocked(buildCoachDataInventory).mockResolvedValue({
+ entries: [
+ {
+ tool: "get_metric_series",
+ metric: "weight",
+ domain: "weight",
+ present: true,
+ count: 10,
+ },
+ ],
+ window: "90d",
+ restMode: false,
+ cycleEnabled: false,
+ } as never);
+ vi.mocked(prisma.medication.findMany).mockResolvedValue([
+ { id: "med-1", name: "Ramipril", dose: "5mg" },
+ ] as never);
+ vi.mocked(prisma.labResult.findMany).mockResolvedValue([
+ { analyte: "LDL" },
+ ] as never);
+});
+
+describe("search", () => {
+ it("returns { results: [{id,title,url}] } with real deep-link URLs", async () => {
+ const def = tool("search");
+ const result = (await def.run(CTX, { query: "" })) as {
+ results: Array<{ id: string; title: string; url: string }>;
+ };
+
+ // Output conforms to the declared schema.
+ const schema = z.object(def.outputShape!);
+ expect(schema.safeParse(result).success).toBe(true);
+
+ expect(result.results.length).toBeGreaterThan(0);
+ for (const r of result.results) {
+ expect(r.id).toMatch(/^(metric|med|lab|domain):/);
+ // ChatGPT only cites when `url` is a non-empty absolute URL.
+ expect(r.url.startsWith("https://health.example/")).toBe(true);
+ }
+ // The internal id stays separate from the citation url.
+ const med = result.results.find((r) => r.id === "med:med-1");
+ expect(med?.url).toBe("https://health.example/medications");
+ });
+
+ it("filters by query (case-insensitive substring)", async () => {
+ const def = tool("search");
+ const result = (await def.run(CTX, { query: "ldl" })) as {
+ results: Array<{ id: string }>;
+ };
+ expect(result.results.map((r) => r.id)).toContain("lab:LDL");
+ expect(result.results.some((r) => r.id.startsWith("med:"))).toBe(false);
+ });
+});
+
+describe("fetch", () => {
+ it("returns { id,title,text,url,metadata } for a metric id", async () => {
+ vi.mocked(executeCoachTool).mockResolvedValue({
+ present: true,
+ data: { mean: 80 },
+ } as never);
+ const def = tool("fetch");
+ const result = (await def.run(CTX, { id: "metric:weight" })) as Record<
+ string,
+ unknown
+ >;
+ const schema = z.object(def.outputShape!);
+ expect(schema.safeParse(result).success).toBe(true);
+ expect(result.id).toBe("metric:weight");
+ expect(result.url).toBe("https://health.example/insights");
+ expect(typeof result.text).toBe("string");
+ });
+
+ it("returns a not-found shape for an unknown id (still schema-valid)", async () => {
+ const def = tool("fetch");
+ const result = (await def.run(CTX, { id: "bogus" })) as Record<
+ string,
+ unknown
+ >;
+ const schema = z.object(def.outputShape!);
+ expect(schema.safeParse(result).success).toBe(true);
+ expect(result.title).toBe("Not found");
+ });
+});
+
+describe("annotations", () => {
+ it("search + fetch are read-only, non-destructive, closed-world", () => {
+ for (const name of ["search", "fetch"]) {
+ const a = tool(name).annotations;
+ expect(a.readOnlyHint).toBe(true);
+ expect(a.destructiveHint).toBe(false);
+ expect(a.openWorldHint).toBe(false);
+ }
+ });
+});
diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts
index 454290f23..468152565 100644
--- a/src/lib/mcp/__tests__/tools.test.ts
+++ b/src/lib/mcp/__tests__/tools.test.ts
@@ -10,6 +10,17 @@ vi.mock("@/lib/ai/coach/tools/inventory", () => ({
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
+// v1.22.0 — `search` reads the record directly via Prisma; stub it so the
+// registry-wide loops never reach a DB.
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ medication: {
+ findMany: vi.fn(async () => []),
+ findFirst: vi.fn(async () => null),
+ },
+ labResult: { findMany: vi.fn(async () => []) },
+ },
+}));
import { MCP_TOOLS, MCP_TOOL_NAMES } from "../tools";
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
@@ -44,10 +55,21 @@ describe("MCP tool registry — surface", () => {
"get_medication_compliance",
"get_metric_series",
"list_metrics",
+ // v1.22.0 — the ChatGPT default-mode retrieval pair.
+ "search",
+ "fetch",
].sort(),
);
});
+ it("annotates every tool read-only / closed-world (cloud-connector requirement)", () => {
+ for (const def of MCP_TOOLS) {
+ expect(def.annotations.readOnlyHint).toBe(true);
+ expect(def.annotations.destructiveHint).toBe(false);
+ expect(def.annotations.openWorldHint).toBe(false);
+ }
+ });
+
it("exposes no admin / write tool", () => {
for (const name of MCP_TOOL_NAMES) {
expect(name).not.toMatch(/admin/i);
diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts
index 52905c5af..f40ec07bc 100644
--- a/src/lib/mcp/server.ts
+++ b/src/lib/mcp/server.ts
@@ -39,12 +39,27 @@ export function createMcpServer(ctx: McpAuthContext): McpServer {
title: tool.title,
description: tool.description,
inputSchema: tool.inputShape,
+ // Annotations are MANDATORY for the cloud connectors — the ChatGPT
+ // Apps SDK treats an omitted hint as a validation error. Every read
+ // tool is read-only, non-destructive, closed-world (ADR-003).
+ annotations: tool.annotations,
+ ...(tool.outputShape ? { outputSchema: tool.outputShape } : {}),
},
async (args: Record) => {
const result = await tool.run(ctx, args ?? {});
- return {
- content: [{ type: "text" as const, text: JSON.stringify(result) }],
- };
+ const content = [
+ { type: "text" as const, text: JSON.stringify(result) },
+ ];
+ // When the tool declares an outputSchema (search / fetch), also return
+ // the object as `structuredContent` so ChatGPT + the model can read the
+ // typed shape; the JSON-in-`content` keeps backwards compatibility.
+ if (tool.outputShape) {
+ return {
+ content,
+ structuredContent: result as Record,
+ };
+ }
+ return { content };
},
);
}
diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts
index ac44f5426..894dacba0 100644
--- a/src/lib/mcp/tools.ts
+++ b/src/lib/mcp/tools.ts
@@ -19,6 +19,7 @@
*/
import { z } from "zod/v4";
+import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory";
@@ -26,8 +27,24 @@ import {
coachScopeSourceSchema,
coachScopeWindowSchema,
} from "@/lib/ai/coach/types";
+import { resolveBaseOrigin } from "@/lib/mcp/oauth/config";
import type { McpAuthContext } from "./auth";
+/**
+ * Tool annotations (MCP 2025-11-25). The cloud connectors REQUIRE these on every
+ * tool — the ChatGPT Apps SDK treats an omitted hint as a validation error, and
+ * the Claude directory requires a safety hint per tool. Every tool the MCP
+ * surface exposes is read-only over a closed personal record, so they all share
+ * the same shape (ADR-003): the read-only guarantee is structural, the
+ * annotation merely advertises it.
+ */
+const READ_ONLY_ANNOTATIONS = {
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+} as const;
+
/**
* A registry entry. `inputShape` is a zod/v4 raw shape handed straight to the
* SDK's `registerTool` for argument validation; `run` receives the validated
@@ -38,6 +55,19 @@ export interface McpToolDefinition {
title: string;
description: string;
inputShape: z.ZodRawShape;
+ /** Tool annotations — mandatory for the cloud connectors (see above). */
+ annotations: {
+ readOnlyHint: boolean;
+ destructiveHint: boolean;
+ idempotentHint: boolean;
+ openWorldHint: boolean;
+ };
+ /**
+ * When set, the tool declares a structured `outputSchema` and its result is
+ * returned as `structuredContent`. Used by `search` / `fetch` so ChatGPT can
+ * consume the exact `{id,title,url}[]` / `{id,title,text,url,metadata}` shapes.
+ */
+ outputShape?: z.ZodRawShape;
run: (ctx: McpAuthContext, args: Record) => Promise;
}
@@ -65,6 +95,194 @@ async function runCoachTool(
return result;
}
+/**
+ * The `search` + `fetch` pair — the de-facto two-tool retrieval convention and,
+ * critically, the ONLY tools ChatGPT can call in its default (non-Developer)
+ * mode. Without them HealthLog is invisible in default ChatGPT. The exact wire
+ * shapes are mandated by OpenAI:
+ *
+ * - `search({ query })` → `{ results: [{ id, title, url }] }`
+ * - `fetch({ id })` → `{ id, title, text, url, metadata? }`
+ *
+ * Every result carries a REAL, user-openable HTTPS deep link (`url`) into the
+ * HealthLog web app — ChatGPT only creates citation metadata when `url` is a
+ * non-empty string. The internal `id` stays separate from `url`. Both tools are
+ * thin façades over the SAME server-authoritative read paths the other tools
+ * use; free-text fields (medication names, lab analytes) are returned as DATA,
+ * never interpreted as instructions (R-SEC-2).
+ */
+function searchAndFetchTools(): McpToolDefinition[] {
+ return [
+ {
+ name: "search",
+ title: "Search your health records",
+ description:
+ "Search the user's own health record — metric domains, medications, and lab biomarkers — for items matching a free-text query. Returns a list of { id, title, url }; pass an id to the `fetch` tool to hydrate it. Each `url` deep-links into the HealthLog web app for citation. Returns an empty list when nothing matches.",
+ inputShape: { query: z.string().max(200) },
+ annotations: READ_ONLY_ANNOTATIONS,
+ outputShape: {
+ results: z.array(
+ z.object({
+ id: z.string(),
+ title: z.string(),
+ url: z.string(),
+ }),
+ ),
+ },
+ async run(ctx, args) {
+ const query =
+ typeof args.query === "string" ? args.query.trim().toLowerCase() : "";
+ const origin = resolveBaseOrigin();
+ const results: Array<{ id: string; title: string; url: string }> = [];
+
+ const inventory = await buildCoachDataInventory(ctx.userId, undefined);
+ for (const entry of inventory.entries) {
+ if (!entry.present) continue;
+ const hay = `${entry.domain} ${entry.metric ?? ""}`.toLowerCase();
+ if (query && !hay.includes(query)) continue;
+ results.push({
+ id: entry.metric
+ ? `metric:${entry.metric}`
+ : `domain:${entry.domain}`,
+ title: entry.domain,
+ url: `${origin}/insights`,
+ });
+ }
+
+ const meds = await prisma.medication.findMany({
+ where: { userId: ctx.userId },
+ select: { id: true, name: true, dose: true },
+ orderBy: { createdAt: "desc" },
+ take: 200,
+ });
+ for (const med of meds) {
+ if (query && !med.name.toLowerCase().includes(query)) continue;
+ results.push({
+ id: `med:${med.id}`,
+ title: med.dose ? `${med.name} ${med.dose}` : med.name,
+ url: `${origin}/medications`,
+ });
+ }
+
+ const labs = await prisma.labResult.findMany({
+ where: { userId: ctx.userId, deletedAt: null },
+ select: { analyte: true },
+ distinct: ["analyte"],
+ take: 200,
+ });
+ for (const lab of labs) {
+ if (query && !lab.analyte.toLowerCase().includes(query)) continue;
+ results.push({
+ id: `lab:${lab.analyte}`,
+ title: lab.analyte,
+ url: `${origin}/labs`,
+ });
+ }
+
+ const capped = results.slice(0, 50);
+ annotate({
+ action: { name: "mcp.tool.invoked" },
+ meta: { tool: "search", present: capped.length > 0 },
+ });
+ return { results: capped };
+ },
+ },
+ {
+ name: "fetch",
+ title: "Fetch one health record",
+ description:
+ "Hydrate a single record returned by `search`, by its id (e.g. `metric:weight`, `med:`, `lab:LDL`). Returns { id, title, text, url, metadata } where `text` is a server-authoritative, plain-text summary suitable for citation and `url` deep-links into HealthLog. Returns a not-found message when the id does not resolve.",
+ inputShape: { id: z.string().min(1).max(200) },
+ annotations: READ_ONLY_ANNOTATIONS,
+ outputShape: {
+ id: z.string(),
+ title: z.string(),
+ text: z.string(),
+ url: z.string(),
+ metadata: z.record(z.string(), z.unknown()).optional(),
+ },
+ async run(ctx, args) {
+ const id = typeof args.id === "string" ? args.id : "";
+ const origin = resolveBaseOrigin();
+ const sep = id.indexOf(":");
+ const kind = sep > 0 ? id.slice(0, sep) : "";
+ const rid = sep > 0 ? id.slice(sep + 1) : "";
+
+ annotate({
+ action: { name: "mcp.tool.invoked" },
+ meta: { tool: "fetch", kind: kind || "unknown" },
+ });
+
+ if (kind === "metric" && rid) {
+ const result = await executeCoachTool({
+ userId: ctx.userId,
+ name: "get_metric_series",
+ rawArguments: JSON.stringify({ metric: rid }),
+ });
+ return {
+ id,
+ title: rid,
+ text: JSON.stringify(result),
+ url: `${origin}/insights`,
+ metadata: { type: "metric", metric: rid },
+ };
+ }
+
+ if (kind === "lab" && rid) {
+ const result = await executeCoachTool({
+ userId: ctx.userId,
+ name: "get_labs",
+ rawArguments: JSON.stringify({ analyte: rid }),
+ });
+ return {
+ id,
+ title: rid,
+ text: JSON.stringify(result),
+ url: `${origin}/labs`,
+ metadata: { type: "lab", analyte: rid },
+ };
+ }
+
+ if (kind === "med" && rid) {
+ const med = await prisma.medication.findFirst({
+ where: { id: rid, userId: ctx.userId },
+ select: {
+ name: true,
+ dose: true,
+ treatmentClass: true,
+ asNeeded: true,
+ },
+ });
+ if (!med) {
+ return {
+ id,
+ title: "Not found",
+ text: "No medication matches this id.",
+ url: `${origin}/medications`,
+ metadata: { type: "medication" },
+ };
+ }
+ return {
+ id,
+ title: med.name,
+ text: JSON.stringify(med),
+ url: `${origin}/medications`,
+ metadata: { type: "medication" },
+ };
+ }
+
+ return {
+ id,
+ title: "Not found",
+ text: `No record matches the id "${id}".`,
+ url: `${origin}/insights`,
+ metadata: { type: "unknown" },
+ };
+ },
+ },
+ ];
+}
+
export const MCP_TOOLS: McpToolDefinition[] = [
{
name: "list_metrics",
@@ -72,6 +290,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
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: {},
+ annotations: READ_ONLY_ANNOTATIONS,
async run(ctx) {
const inventory = await buildCoachDataInventory(ctx.userId, undefined);
annotate({
@@ -96,6 +315,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
metric: coachScopeSourceSchema,
window: coachScopeWindowSchema.optional(),
},
+ annotations: READ_ONLY_ANNOTATIONS,
run(ctx, args) {
return runCoachTool(ctx, "get_metric_series", args);
},
@@ -108,6 +328,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
inputShape: {
window: coachScopeWindowSchema.optional(),
},
+ annotations: READ_ONLY_ANNOTATIONS,
run(ctx, args) {
return runCoachTool(ctx, "get_medication_compliance", args);
},
@@ -120,6 +341,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
inputShape: {
analyte: z.string().min(1).max(80).optional(),
},
+ annotations: READ_ONLY_ANNOTATIONS,
run(ctx, args) {
return runCoachTool(ctx, "get_labs", args);
},
@@ -130,10 +352,12 @@ export const MCP_TOOLS: McpToolDefinition[] = [
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: {},
+ annotations: READ_ONLY_ANNOTATIONS,
run(ctx, args) {
return runCoachTool(ctx, "get_correlations", args);
},
},
+ ...searchAndFetchTools(),
];
/** Stable list of the registered tool names. */
From 06363771ee2d6e260912855a2d37153c611401af Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 27 Jun 2026 14:27:30 +0200
Subject: [PATCH 06/11] feat(settings): add an MCP connector card with
health:read tokens
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A dedicated Settings card co-locates the off-by-default MCP module toggle
with token management for connecting an external assistant:
- Mint / list / revoke a dedicated `health:read`-scoped token (shown once,
copy to clipboard) via new `/api/mcp/tokens` routes — never the
`medication:ingest` scope, never the wildcard.
- Surface the connector URL and the enable switch in one place, wired to
the existing module-preference endpoint.
- New section wired into the Settings shell next to API & Tokens, with
EN + DE strings (and the other locales) under `settings.mcp` and
`settings.sections.mcp`.
---
messages/de.json | 18 +-
messages/en.json | 18 +-
messages/es.json | 18 +-
messages/fr.json | 18 +-
messages/it.json | 18 +-
messages/pl.json | 18 +-
src/app/api/mcp/tokens/[id]/route.ts | 44 +++
src/app/api/mcp/tokens/route.ts | 98 +++++
src/app/settings/[section]/page.tsx | 2 +
.../__tests__/settings-shell.test.tsx | 1 +
src/components/settings/mcp-section.tsx | 369 ++++++++++++++++++
.../settings/section-placeholder.tsx | 2 +
src/components/settings/section-slugs.ts | 5 +
src/components/settings/settings-shell.tsx | 5 +
src/lib/query-keys/integrations.ts | 4 +
15 files changed, 632 insertions(+), 6 deletions(-)
create mode 100644 src/app/api/mcp/tokens/[id]/route.ts
create mode 100644 src/app/api/mcp/tokens/route.ts
create mode 100644 src/components/settings/mcp-section.tsx
diff --git a/messages/de.json b/messages/de.json
index 3bfa2f640..a41e7f47c 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4162,6 +4162,10 @@
"title": "Kanäle",
"description": "Zustellung über Telegram, ntfy, Web-Push, Webhook und E-Mail.",
"subtitle": "Wohin Erinnerungen und Hinweise zugestellt werden."
+ },
+ "mcp": {
+ "title": "MCP-Connector",
+ "subtitle": "Verbinde einen externen Assistenten mit deinen eigenen Gesundheitsdaten."
}
},
"profile": "Profil",
@@ -4892,7 +4896,19 @@
"polarDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
"ouraDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
"nightscoutDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
- "integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben."
+ "integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben.",
+ "mcp": {
+ "enableTitle": "Remote-MCP-Endpunkt",
+ "enableDescription": "Erlaube einem MCP-fähigen Assistenten (Claude.ai, ChatGPT), deine Gesundheitsdaten über ein begrenztes Token zu lesen. Standardmäßig aus.",
+ "enabled": "Aktiv",
+ "disabled": "Inaktiv",
+ "enableToggleLabel": "MCP-Endpunkt aktivieren",
+ "endpointLabel": "Connector-URL",
+ "connectDescription": "Füge diese URL in die Einrichtung des eigenen Connectors deines Assistenten ein und autorisiere dich mit dem Token unten oder melde dich bei Aufforderung an.",
+ "tokensTitle": "Connector-Tokens",
+ "tokensDescription": "Erzeuge ein schreibgeschütztes Token, um einen Assistenten zu verbinden. Das Token wird nur einmal angezeigt.",
+ "scopeNote": "Tokens sind auf health:read beschränkt — nur Lesezugriff auf deine eigenen Daten. Sie können niemals Daten schreiben oder Admin-Funktionen erreichen."
+ }
},
"admin": {
"title": "Administration",
diff --git a/messages/en.json b/messages/en.json
index 0ac7a69b0..0a94608d8 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4162,6 +4162,10 @@
"title": "Channels",
"description": "Telegram, ntfy, Web Push, Webhook, and Email delivery.",
"subtitle": "Where reminders and alerts are delivered."
+ },
+ "mcp": {
+ "title": "MCP Connector",
+ "subtitle": "Connect an external assistant to your own health data."
}
},
"profile": "Profile",
@@ -4892,7 +4896,19 @@
"polarDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
"ouraDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
"nightscoutDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
- "integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above."
+ "integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above.",
+ "mcp": {
+ "enableTitle": "Remote MCP endpoint",
+ "enableDescription": "Let an MCP-capable assistant (Claude.ai, ChatGPT) read your health data over a scoped token. Off by default.",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "enableToggleLabel": "Enable the MCP endpoint",
+ "endpointLabel": "Connector URL",
+ "connectDescription": "Paste this URL into your assistant's custom-connector setup, then authorize with the token below or sign in when prompted.",
+ "tokensTitle": "Connector tokens",
+ "tokensDescription": "Mint a read-only token to connect an assistant. The token is shown once.",
+ "scopeNote": "Tokens are scoped to health:read — read-only access to your own records. They can never write data or reach admin functions."
+ }
},
"admin": {
"title": "Administration",
diff --git a/messages/es.json b/messages/es.json
index d4b0c0842..19d8b2a1a 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4162,6 +4162,10 @@
"title": "Canales",
"description": "Envío por Telegram, ntfy, Web Push, Webhook y correo electrónico.",
"subtitle": "Dónde se entregan los recordatorios y avisos."
+ },
+ "mcp": {
+ "title": "Conector MCP",
+ "subtitle": "Conecta un asistente externo a tus propios datos de salud."
}
},
"profile": "Perfil",
@@ -4892,7 +4896,19 @@
"polarDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
"ouraDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
"nightscoutDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
- "integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba."
+ "integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba.",
+ "mcp": {
+ "enableTitle": "Endpoint MCP remoto",
+ "enableDescription": "Permite que un asistente compatible con MCP (Claude.ai, ChatGPT) lea tus datos de salud mediante un token limitado. Desactivado por defecto.",
+ "enabled": "Activado",
+ "disabled": "Desactivado",
+ "enableToggleLabel": "Activar el endpoint MCP",
+ "endpointLabel": "URL del conector",
+ "connectDescription": "Pega esta URL en la configuración del conector personalizado de tu asistente y autoriza con el token de abajo o inicia sesión cuando se te solicite.",
+ "tokensTitle": "Tokens del conector",
+ "tokensDescription": "Genera un token de solo lectura para conectar un asistente. El token se muestra una sola vez.",
+ "scopeNote": "Los tokens se limitan a health:read: acceso de solo lectura a tus propios registros. Nunca pueden escribir datos ni acceder a funciones de administración."
+ }
},
"admin": {
"title": "Administration",
diff --git a/messages/fr.json b/messages/fr.json
index a7f6ebc03..9861439ee 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4162,6 +4162,10 @@
"title": "Canaux",
"description": "Distribution via Telegram, ntfy, Web Push, Webhook et e-mail.",
"subtitle": "Où sont envoyés les rappels et alertes."
+ },
+ "mcp": {
+ "title": "Connecteur MCP",
+ "subtitle": "Connectez un assistant externe à vos propres données de santé."
}
},
"profile": "Profil",
@@ -4892,7 +4896,19 @@
"polarDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
"ouraDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
"nightscoutDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
- "integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus."
+ "integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus.",
+ "mcp": {
+ "enableTitle": "Point de terminaison MCP distant",
+ "enableDescription": "Autorisez un assistant compatible MCP (Claude.ai, ChatGPT) à lire vos données de santé via un jeton restreint. Désactivé par défaut.",
+ "enabled": "Activé",
+ "disabled": "Désactivé",
+ "enableToggleLabel": "Activer le point de terminaison MCP",
+ "endpointLabel": "URL du connecteur",
+ "connectDescription": "Collez cette URL dans la configuration du connecteur personnalisé de votre assistant, puis autorisez avec le jeton ci-dessous ou connectez-vous lorsque vous y êtes invité.",
+ "tokensTitle": "Jetons du connecteur",
+ "tokensDescription": "Générez un jeton en lecture seule pour connecter un assistant. Le jeton n'est affiché qu'une seule fois.",
+ "scopeNote": "Les jetons sont limités à health:read : accès en lecture seule à vos propres données. Ils ne peuvent jamais écrire de données ni atteindre les fonctions d'administration."
+ }
},
"admin": {
"title": "Administration",
diff --git a/messages/it.json b/messages/it.json
index 2f07c29a2..4ba9e434d 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4162,6 +4162,10 @@
"title": "Canali",
"description": "Recapito tramite Telegram, ntfy, Web Push, Webhook ed e-mail.",
"subtitle": "Dove vengono recapitati promemoria e avvisi."
+ },
+ "mcp": {
+ "title": "Connettore MCP",
+ "subtitle": "Collega un assistente esterno ai tuoi dati sulla salute."
}
},
"profile": "Profilo",
@@ -4892,7 +4896,19 @@
"polarDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
"ouraDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
"nightscoutDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
- "integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra."
+ "integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra.",
+ "mcp": {
+ "enableTitle": "Endpoint MCP remoto",
+ "enableDescription": "Consenti a un assistente compatibile con MCP (Claude.ai, ChatGPT) di leggere i tuoi dati sulla salute tramite un token limitato. Disattivato per impostazione predefinita.",
+ "enabled": "Attivo",
+ "disabled": "Inattivo",
+ "enableToggleLabel": "Attiva l'endpoint MCP",
+ "endpointLabel": "URL del connettore",
+ "connectDescription": "Incolla questo URL nella configurazione del connettore personalizzato del tuo assistente e autorizza con il token qui sotto o accedi quando richiesto.",
+ "tokensTitle": "Token del connettore",
+ "tokensDescription": "Genera un token in sola lettura per collegare un assistente. Il token viene mostrato una sola volta.",
+ "scopeNote": "I token sono limitati a health:read: accesso in sola lettura ai tuoi dati. Non possono mai scrivere dati né raggiungere funzioni di amministrazione."
+ }
},
"admin": {
"title": "Administration",
diff --git a/messages/pl.json b/messages/pl.json
index 101899b35..735be10f4 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4162,6 +4162,10 @@
"title": "Kanały",
"description": "Dostarczanie przez Telegram, ntfy, Web Push, Webhook i e-mail.",
"subtitle": "Gdzie dostarczane są przypomnienia i powiadomienia."
+ },
+ "mcp": {
+ "title": "Łącznik MCP",
+ "subtitle": "Połącz zewnętrznego asystenta z własnymi danymi zdrowotnymi."
}
},
"profile": "Profil",
@@ -4892,7 +4896,19 @@
"polarDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
"ouraDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
"nightscoutDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
- "integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej."
+ "integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej.",
+ "mcp": {
+ "enableTitle": "Zdalny punkt końcowy MCP",
+ "enableDescription": "Zezwól asystentowi obsługującemu MCP (Claude.ai, ChatGPT) na odczyt danych zdrowotnych za pomocą ograniczonego tokenu. Domyślnie wyłączone.",
+ "enabled": "Włączony",
+ "disabled": "Wyłączony",
+ "enableToggleLabel": "Włącz punkt końcowy MCP",
+ "endpointLabel": "Adres URL łącznika",
+ "connectDescription": "Wklej ten adres URL w konfiguracji własnego łącznika asystenta, a następnie autoryzuj za pomocą poniższego tokenu lub zaloguj się po wyświetleniu monitu.",
+ "tokensTitle": "Tokeny łącznika",
+ "tokensDescription": "Wygeneruj token tylko do odczytu, aby połączyć asystenta. Token jest wyświetlany tylko raz.",
+ "scopeNote": "Tokeny są ograniczone do health:read — dostęp tylko do odczytu własnych danych. Nigdy nie mogą zapisywać danych ani uzyskać dostępu do funkcji administracyjnych."
+ }
},
"admin": {
"title": "Administracja",
diff --git a/src/app/api/mcp/tokens/[id]/route.ts b/src/app/api/mcp/tokens/[id]/route.ts
new file mode 100644
index 000000000..03e18f2f6
--- /dev/null
+++ b/src/app/api/mcp/tokens/[id]/route.ts
@@ -0,0 +1,44 @@
+/**
+ * Revoke an MCP connector token. Only the owning user can revoke; the scope
+ * filter keeps this endpoint to `health:read` tokens so it can never revoke a
+ * differently-scoped credential by id alone.
+ */
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiError, apiSuccess } from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import { auditLog } from "@/lib/auth/audit";
+import { isApiGloballyEnabled } from "@/lib/app-settings";
+import { prisma } from "@/lib/db";
+import { SCOPE_HEALTH_READ } from "@/lib/mcp/oauth/config";
+
+type RouteParams = { params: Promise<{ id: string }> };
+
+export const DELETE = apiHandler(
+ async (_request: Request, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+ annotate({ action: { name: "mcp.tokens.revoke" } });
+
+ if (!(await isApiGloballyEnabled())) {
+ return apiError("API is globally disabled", 403);
+ }
+
+ const { id } = await params;
+ const token = await prisma.apiToken.findUnique({ where: { id } });
+
+ if (
+ !token ||
+ token.userId !== user.id ||
+ !token.permissions.includes(SCOPE_HEALTH_READ)
+ ) {
+ return apiError("Token not found", 404);
+ }
+
+ await prisma.apiToken.update({ where: { id }, data: { revoked: true } });
+ await auditLog("mcp.tokens.revoke", {
+ userId: user.id,
+ details: { tokenId: id },
+ });
+
+ return apiSuccess({ revoked: true });
+ },
+);
diff --git a/src/app/api/mcp/tokens/route.ts b/src/app/api/mcp/tokens/route.ts
new file mode 100644
index 000000000..c6f6542f9
--- /dev/null
+++ b/src/app/api/mcp/tokens/route.ts
@@ -0,0 +1,98 @@
+/**
+ * MCP connector tokens — list + mint.
+ *
+ * The MCP settings card mints a DEDICATED `health:read`-scoped Bearer here (NOT
+ * the `medication:ingest` scope the generic `/api/tokens` mints, and NEVER the
+ * `["*"]` wildcard). The raw `hlk_` value is shown once. This is the manual /
+ * stdio / power-user path; the OAuth bridge (`/api/mcp/oauth/*`) mints the same
+ * `health:read` scope automatically for cloud connectors. Both surface here so
+ * the user can see and revoke every credential that can read over MCP.
+ *
+ * `userId` is always narrowed from the session — never a body field. Scope is
+ * hardcoded `["health:read"]`, so this endpoint can never be coerced into
+ * minting a broader grant (no mass assignment).
+ */
+import { NextRequest } from "next/server";
+import { z } from "zod/v4";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import {
+ apiError,
+ apiSuccess,
+ returnAllZodIssues,
+ safeJson,
+} from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import { auditLog } from "@/lib/auth/audit";
+import { issueApiToken } from "@/lib/auth/issue-token";
+import { isApiGloballyEnabled } from "@/lib/app-settings";
+import { prisma } from "@/lib/db";
+import { SCOPE_HEALTH_READ } from "@/lib/mcp/oauth/config";
+
+const createSchema = z.object({
+ name: z.string().min(1, "Name required").max(100),
+ expiresInDays: z.number().int().min(1).max(365).optional(),
+});
+
+export const GET = apiHandler(async () => {
+ const { user } = await requireAuth();
+ annotate({ action: { name: "mcp.tokens.list" } });
+
+ if (!(await isApiGloballyEnabled())) {
+ return apiError("API is globally disabled", 403);
+ }
+
+ const tokens = await prisma.apiToken.findMany({
+ where: { userId: user.id, permissions: { has: SCOPE_HEALTH_READ } },
+ select: {
+ id: true,
+ name: true,
+ permissions: true,
+ lastUsedAt: true,
+ expiresAt: true,
+ createdAt: true,
+ revoked: true,
+ },
+ orderBy: { createdAt: "desc" },
+ });
+
+ return apiSuccess(tokens);
+});
+
+export const POST = apiHandler(async (request: NextRequest) => {
+ const { user } = await requireAuth();
+ annotate({ action: { name: "mcp.tokens.create" } });
+
+ if (!(await isApiGloballyEnabled())) {
+ return apiError("API is globally disabled", 403);
+ }
+
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 16 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = createSchema.safeParse(body);
+ if (!parsed.success) {
+ return returnAllZodIssues(parsed.error, 422);
+ }
+
+ const issued = await issueApiToken({
+ userId: user.id,
+ name: parsed.data.name,
+ // Dedicated, least-privilege MCP read scope — never wildcard, never write.
+ permissions: [SCOPE_HEALTH_READ],
+ expiresInDays: parsed.data.expiresInDays ?? 90,
+ });
+
+ await auditLog("mcp.tokens.create", {
+ userId: user.id,
+ details: { tokenId: issued.tokenId, scope: SCOPE_HEALTH_READ },
+ });
+
+ // Return the raw token ONCE — it can never be retrieved again.
+ return apiSuccess(
+ { token: issued.token, name: issued.name, expiresAt: issued.expiresAt },
+ 201,
+ );
+});
diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx
index 8e7d77599..55cc92c84 100644
--- a/src/app/settings/[section]/page.tsx
+++ b/src/app/settings/[section]/page.tsx
@@ -16,6 +16,7 @@ import { IntegrationsSection } from "@/components/settings/integrations-section"
import { LabsSection } from "@/components/settings/labs-section";
import { LayoutSection } from "@/components/settings/layout-section";
import { MedicationsSection } from "@/components/settings/medications-section";
+import { McpSection } from "@/components/settings/mcp-section";
import { ModulesSection } from "@/components/settings/modules-section";
import { MoodSection } from "@/components/settings/mood-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
@@ -73,6 +74,7 @@ const SECTION_COMPONENTS: Record<
vorsorge: VorsorgeSection,
thresholds: ThresholdsSection,
api: ApiSection,
+ mcp: McpSection,
gesundheitsakte: GesundheitsakteSection,
sharing: SharingSection,
export: ExportSection,
diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx
index ebdc1945e..de16315d1 100644
--- a/src/components/settings/__tests__/settings-shell.test.tsx
+++ b/src/components/settings/__tests__/settings-shell.test.tsx
@@ -116,6 +116,7 @@ describe("SETTINGS_SECTION_SLUGS", () => {
"ai",
"coach",
"api",
+ "mcp",
"gesundheitsakte",
"sharing",
"export",
diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx
new file mode 100644
index 000000000..f645b398a
--- /dev/null
+++ b/src/components/settings/mcp-section.tsx
@@ -0,0 +1,369 @@
+"use client";
+
+/**
+ * `` — Settings → MCP connector. v1.22.0.
+ *
+ * One card co-locating the two things a self-hoster needs to connect an external
+ * assistant (Claude.ai / ChatGPT) to their own data over the remote MCP
+ * endpoint:
+ *
+ * 1. the off-by-default `mcp` module toggle (the opt-in switch that turns the
+ * `/mcp` surface on for this account — it answers 404 until then), and
+ * 2. a dedicated `health:read`-scoped token (mint shown once, copy, list with
+ * last-used, revoke). This is the manual / stdio path; cloud connectors get
+ * the same scope automatically through the OAuth flow.
+ *
+ * The minted token is NEVER `medication:ingest` and NEVER `["*"]` — it is the
+ * least-privilege read scope and nothing more.
+ */
+import { useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Check, Copy, Key, Loader2, Plug, Trash2 } from "lucide-react";
+import { toast } from "sonner";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Switch } from "@/components/ui/switch";
+import { SettingsCard } from "@/components/settings/settings-card";
+import { SettingsCardHeader } from "@/components/settings/_card-header";
+import { useAuth } from "@/hooks/use-auth";
+import { formatDate, formatDateTime } from "@/lib/format";
+import { useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+import { apiFetchRaw, apiGet, apiPatch } from "@/lib/api/api-fetch";
+
+interface McpTokenInfo {
+ id: string;
+ name: string;
+ permissions: string[];
+ lastUsedAt: string | null;
+ expiresAt: string | null;
+ createdAt: string;
+ revoked: boolean;
+}
+
+export function McpSection() {
+ return (
+
+
+
+
+ );
+}
+
+function McpEnableCard() {
+ const { t } = useTranslations();
+ const { user } = useAuth();
+ const queryClient = useQueryClient();
+
+ // The opt-in module is OFF until explicitly enabled — `user.modules.mcp` is
+ // the resolved state (true only when the user turned it on).
+ const enabled = user?.modules?.mcp === true;
+ const endpoint =
+ typeof window !== "undefined" ? `${window.location.origin}/mcp` : "/mcp";
+
+ const toggle = useMutation({
+ mutationKey: queryKeys.modulesPrefs(),
+ mutationFn: async (next: boolean) =>
+ apiPatch("/api/auth/me/modules", { mcp: next }),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
+ toast.success(t("settings.sections.modules.saved"));
+ },
+ onError: () => toast.error(t("settings.sections.modules.error")),
+ });
+
+ return (
+
+
+ {t("settings.mcp.enabled")}
+
+ ) : (
+ {t("settings.mcp.disabled")}
+ )
+ }
+ />
+
+
+
+
+ {t("settings.mcp.enableToggleLabel")}
+
+ toggle.mutate(next)}
+ aria-label={t("settings.mcp.enableToggleLabel")}
+ />
+
+
+
+
+ {t("settings.mcp.endpointLabel")}
+
+
+ {endpoint}
+
+
+ {t("settings.mcp.connectDescription")}
+
+
+
+
+ );
+}
+
+function McpTokensCard() {
+ const { t } = useTranslations();
+ const { isAuthenticated } = useAuth();
+ const queryClient = useQueryClient();
+ const [newName, setNewName] = useState("");
+ const [newToken, setNewToken] = useState(null);
+ const [creating, setCreating] = useState(false);
+ const [tokenMsg, setTokenMsg] = useState(null);
+ const [tokenCopied, setTokenCopied] = useState(false);
+
+ const { data: tokens } = useQuery({
+ queryKey: queryKeys.mcpTokens(),
+ queryFn: async () => apiGet("/api/mcp/tokens"),
+ enabled: isAuthenticated,
+ });
+
+ async function handleCreate(e: React.FormEvent) {
+ e.preventDefault();
+ setCreating(true);
+ setTokenMsg(null);
+ setNewToken(null);
+ try {
+ const res = await apiFetchRaw("/api/mcp/tokens", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: newName.trim() }),
+ });
+ const json = await res.json();
+ if (res.ok) {
+ setNewToken(json.data.token);
+ setNewName("");
+ queryClient.invalidateQueries({ queryKey: queryKeys.mcpTokens() });
+ } else {
+ setTokenMsg(json.error || t("common.error"));
+ }
+ } catch {
+ setTokenMsg(t("common.networkError"));
+ } finally {
+ setCreating(false);
+ }
+ }
+
+ async function handleCopyToken() {
+ if (!newToken) return;
+ try {
+ await navigator.clipboard.writeText(newToken);
+ setTokenCopied(true);
+ toast.success(t("settings.tokenCopied"));
+ setTimeout(() => setTokenCopied(false), 2_000);
+ } catch {
+ toast.error(t("settings.tokenCopyFailed"));
+ }
+ }
+
+ async function handleRevoke(tokenId: string) {
+ try {
+ const res = await apiFetchRaw(`/api/mcp/tokens/${tokenId}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ toast.error(t("settings.tokenRevokeFailed"));
+ return;
+ }
+ queryClient.invalidateQueries({ queryKey: queryKeys.mcpTokens() });
+ } catch {
+ toast.error(t("settings.tokenRevokeFailed"));
+ }
+ }
+
+ const activeTokens = (tokens ?? []).filter((tok) => !tok.revoked);
+
+ return (
+
+
+
+
+
+ {t("settings.mcp.scopeNote")}
+
+
+
+
+ {newToken && (
+
+
+ {t("settings.tokenCreated")}
+
+
+
+ {newToken}
+
+
+
+
+ )}
+
+ {tokenMsg && (
+
+ {tokenMsg}
+
+ )}
+
+
+
+ {t("settings.activeTokensTitle")}
+
+ {activeTokens.length === 0 ? (
+
+ {t("settings.noActiveTokens")}
+
+ ) : (
+
+ {activeTokens.map((tok) => {
+ const isExpired =
+ tok.expiresAt && new Date(tok.expiresAt) < new Date();
+ return (
+ -
+
+
+ {tok.name}
+
+ {isExpired ? (
+
+ {t("settings.tokenExpired")}
+
+ ) : (
+
+ {t("settings.tokenActive")}
+
+ )}
+
+
+
+ {t("settings.tokenTablePermissions")}:
+ {" "}
+ {tok.permissions.join(", ")}
+
+
+
+ {t("settings.tokenTableCreated")}:
+ {" "}
+ {formatDate(tok.createdAt)}
+
+
+
+ {t("settings.tokenTableLastUsed")}:
+ {" "}
+ {tok.lastUsedAt
+ ? formatDateTime(tok.lastUsedAt)
+ : t("settings.tokenNeverUsed")}
+
+
+
+
+
+
+
+
+ {t("settings.tokenRevoke")}
+
+
+ {t("settings.tokenRevokeDescription")}
+
+
+
+
+ {t("common.cancel")}
+
+ handleRevoke(tok.id)}
+ >
+ {t("settings.tokenRevoked")}
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx
index 577f0c2dc..80fa2a7e5 100644
--- a/src/components/settings/section-placeholder.tsx
+++ b/src/components/settings/section-placeholder.tsx
@@ -21,6 +21,7 @@ import {
FlaskConical,
Info,
KeyRound,
+ Plug,
Layers,
LayoutDashboard,
Link2,
@@ -60,6 +61,7 @@ const SLUG_ICON: Record = {
ai: Sparkles,
coach: Bot,
api: KeyRound,
+ mcp: Plug,
gesundheitsakte: FileHeart,
sharing: Share2,
export: Download,
diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts
index 48419670d..496516701 100644
--- a/src/components/settings/section-slugs.ts
+++ b/src/components/settings/section-slugs.ts
@@ -73,6 +73,11 @@ export const SETTINGS_SECTION_SLUGS = [
// the coach module. The `ai` entry keeps provider / model / BYOK only.
"coach",
"api",
+ // v1.22.0 — the remote MCP connector card: enable the opt-in `mcp` module and
+ // mint / revoke the dedicated `health:read` token used to connect an external
+ // assistant (Claude.ai / ChatGPT). Sits next to `api` (both are programmatic
+ // access surfaces). Not nav-gated — the card carries its own enable toggle.
+ "mcp",
"gesundheitsakte",
// v1.18.7 — `sharing` (clinician share links) sits directly after
// `gesundheitsakte`, before `export`: minting a time-boxed read-only link
diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx
index d9397a3ac..ed117fcec 100644
--- a/src/components/settings/settings-shell.tsx
+++ b/src/components/settings/settings-shell.tsx
@@ -27,6 +27,7 @@ import {
FlaskConical,
Info,
KeyRound,
+ Plug,
LayoutDashboard,
Layers,
Link2,
@@ -208,6 +209,10 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
moduleGate: "coach",
},
{ slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound },
+ // v1.22.0 — remote MCP connector. Not module-gated: the card carries its own
+ // enable toggle (mirroring `gesundheitsakte`), so the entry-point stays
+ // reachable even with the opt-in module off.
+ { slug: "mcp", titleKey: "settings.sections.mcp.title", icon: Plug },
// v1.18.0 (S5) — the full health record (PDF + FHIR R4 + zip package)
// earns its own home, lifted out of Export & Import.
//
diff --git a/src/lib/query-keys/integrations.ts b/src/lib/query-keys/integrations.ts
index 17c459337..0434b52d4 100644
--- a/src/lib/query-keys/integrations.ts
+++ b/src/lib/query-keys/integrations.ts
@@ -5,6 +5,10 @@
*/
export const integrationKeys = {
tokens: () => ["tokens"] as const,
+ // v1.22.0 — MCP connector tokens (the dedicated `health:read` scope minted
+ // for the remote MCP endpoint). Distinct key from `tokens()` so the MCP
+ // settings card's mint / revoke mutations invalidate only this list.
+ mcpTokens: () => ["mcp-tokens"] as const,
telegram: () => ["telegram"] as const,
telegramSettings: () => ["telegram", "settings"] as const,
withings: () => ["withings"] as const,
From 22eb9cba0d647bba3d787619bf2d2ff968665daa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 27 Jun 2026 15:08:52 +0200
Subject: [PATCH 07/11] fix(mcp): close OAuth 2.1 resource-server gaps before
the remote surface ships
Harden the remote MCP endpoint and the in-repo OAuth bridge against the
adversarial review of the milestone branch. The two ship-blockers made the
"read-only, revocable" promise false; the rest tightened the surface.
Audience binding at the resource server: a token whose only grant is the new
`health:read` scope is the MCP read credential. The REST edge now accepts it on
safe (read) methods and refuses it on writes/deletes with 403, so a connector
token can read over `/mcp` and GET routes but can never POST/DELETE the record
despite a "read-only" consent. The `/mcp` resolver is unaffected.
Revocable, theft-detectable refresh tokens: refresh artifacts now anchor to a
persistent connection row. Revoking a connection stops every future refresh and
kills its access tokens; a replayed (already-rotated) refresh is treated as
theft and revokes the whole connection. Connections surface in settings as the
unit a user can disconnect. The old access token is revoked on rotation.
Also: fail closed on the OAuth/`/mcp` surface when no canonical origin is
configured and mark the discovery documents non-cacheable; honour the global API
kill-switch on `/mcp`, `/token`, `/authorize`, and `/register`; cap the CIMD and
registration body reads while streaming instead of after; show the verified
client origin on consent; validate CIMD redirect-URI schemes; assert same-origin
on the consent POST; and add a daily prune for expired connector tokens.
New migration 0195 adds the connection anchor and links access tokens to it.
---
messages/de.json | 11 +-
messages/en.json | 11 +-
messages/es.json | 11 +-
messages/fr.json | 11 +-
messages/it.json | 11 +-
messages/pl.json | 11 +-
.../migration.sql | 41 ++++
prisma/schema.prisma | 42 +++-
.../__tests__/oauth-discovery.test.ts | 59 ++++++
.../oauth-authorization-server/route.ts | 13 +-
.../oauth-protected-resource/route.ts | 13 +-
src/app/api/mcp/connections/[id]/route.ts | 41 ++++
src/app/api/mcp/connections/route.ts | 26 +++
.../api/mcp/oauth/__tests__/authorize.test.ts | 91 ++++++++
.../api/mcp/oauth/__tests__/register.test.ts | 18 +-
src/app/api/mcp/oauth/__tests__/token.test.ts | 131 +++++++++++-
src/app/api/mcp/oauth/authorize/route.ts | 78 +++++++
src/app/api/mcp/oauth/register/route.ts | 38 ++--
src/app/api/mcp/oauth/token/route.ts | 128 +++++++++---
src/app/api/mcp/tokens/route.ts | 9 +-
src/app/mcp/__tests__/route.test.ts | 19 ++
src/app/mcp/route.ts | 15 +-
src/components/settings/mcp-section.tsx | 123 ++++++++++-
src/lib/__tests__/require-auth-bearer.test.ts | 85 +++++++-
src/lib/api-handler.ts | 46 ++++-
src/lib/auth/issue-token.ts | 9 +
src/lib/http/__tests__/read-capped.test.ts | 68 ++++++
src/lib/http/read-capped.ts | 62 ++++++
.../__tests__/mcp-token-cleanup-queue.test.ts | 58 ++++++
src/lib/jobs/mcp-token-cleanup.ts | 55 +++++
src/lib/jobs/reminder/cleanup-handlers.ts | 27 +++
src/lib/jobs/reminder/register-maintenance.ts | 23 +++
src/lib/logging/event-builder.ts | 5 +
.../mcp/oauth/__tests__/connections.test.ts | 195 ++++++++++++++++++
.../mcp/oauth/__tests__/oauth-bridge.test.ts | 29 +++
src/lib/mcp/oauth/clients.ts | 38 +++-
src/lib/mcp/oauth/config.ts | 53 ++++-
src/lib/mcp/oauth/connections.ts | 190 +++++++++++++++++
src/lib/query-keys/integrations.ts | 4 +
39 files changed, 1826 insertions(+), 72 deletions(-)
create mode 100644 prisma/migrations/0195_mcp_phase3_oauth_connection/migration.sql
create mode 100644 src/app/.well-known/__tests__/oauth-discovery.test.ts
create mode 100644 src/app/api/mcp/connections/[id]/route.ts
create mode 100644 src/app/api/mcp/connections/route.ts
create mode 100644 src/lib/http/__tests__/read-capped.test.ts
create mode 100644 src/lib/http/read-capped.ts
create mode 100644 src/lib/jobs/__tests__/mcp-token-cleanup-queue.test.ts
create mode 100644 src/lib/jobs/mcp-token-cleanup.ts
create mode 100644 src/lib/mcp/oauth/__tests__/connections.test.ts
create mode 100644 src/lib/mcp/oauth/connections.ts
diff --git a/messages/de.json b/messages/de.json
index a41e7f47c..0e5669e2b 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4907,7 +4907,16 @@
"connectDescription": "Füge diese URL in die Einrichtung des eigenen Connectors deines Assistenten ein und autorisiere dich mit dem Token unten oder melde dich bei Aufforderung an.",
"tokensTitle": "Connector-Tokens",
"tokensDescription": "Erzeuge ein schreibgeschütztes Token, um einen Assistenten zu verbinden. Das Token wird nur einmal angezeigt.",
- "scopeNote": "Tokens sind auf health:read beschränkt — nur Lesezugriff auf deine eigenen Daten. Sie können niemals Daten schreiben oder Admin-Funktionen erreichen."
+ "scopeNote": "Tokens sind auf health:read beschränkt — nur Lesezugriff auf deine eigenen Daten. Sie können niemals Daten schreiben oder Admin-Funktionen erreichen.",
+ "connectionsTitle": "Verbundene Assistenten",
+ "connectionsDescription": "Assistenten, die du über die Anmeldung autorisiert hast. Das Widerrufen einer Verbindung beendet den Zugriff sofort, einschließlich der Möglichkeit zur Erneuerung.",
+ "noConnections": "Keine verbundenen Assistenten.",
+ "connectionConnected": "Verbunden",
+ "connectionLastUsed": "Zuletzt verwendet",
+ "connectionNeverUsed": "Nie verwendet",
+ "connectionRevoke": "Trennen",
+ "connectionRevokeDescription": "Dies beendet den Zugriff des Assistenten sofort und verhindert eine Erneuerung. Für eine erneute Verbindung ist eine neue Autorisierung nötig.",
+ "connectionRevokeFailed": "Assistent konnte nicht getrennt werden."
}
},
"admin": {
diff --git a/messages/en.json b/messages/en.json
index 0a94608d8..0b1200347 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4907,7 +4907,16 @@
"connectDescription": "Paste this URL into your assistant's custom-connector setup, then authorize with the token below or sign in when prompted.",
"tokensTitle": "Connector tokens",
"tokensDescription": "Mint a read-only token to connect an assistant. The token is shown once.",
- "scopeNote": "Tokens are scoped to health:read — read-only access to your own records. They can never write data or reach admin functions."
+ "scopeNote": "Tokens are scoped to health:read — read-only access to your own records. They can never write data or reach admin functions.",
+ "connectionsTitle": "Connected assistants",
+ "connectionsDescription": "Assistants you authorized through the sign-in flow. Revoking a connection ends its access immediately, including its ability to refresh.",
+ "noConnections": "No connected assistants.",
+ "connectionConnected": "Connected",
+ "connectionLastUsed": "Last used",
+ "connectionNeverUsed": "Never used",
+ "connectionRevoke": "Disconnect",
+ "connectionRevokeDescription": "This ends the assistant's access immediately and stops it from renewing. It will need to be re-authorized to reconnect.",
+ "connectionRevokeFailed": "Could not disconnect the assistant."
}
},
"admin": {
diff --git a/messages/es.json b/messages/es.json
index 19d8b2a1a..cac512da5 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4907,7 +4907,16 @@
"connectDescription": "Pega esta URL en la configuración del conector personalizado de tu asistente y autoriza con el token de abajo o inicia sesión cuando se te solicite.",
"tokensTitle": "Tokens del conector",
"tokensDescription": "Genera un token de solo lectura para conectar un asistente. El token se muestra una sola vez.",
- "scopeNote": "Los tokens se limitan a health:read: acceso de solo lectura a tus propios registros. Nunca pueden escribir datos ni acceder a funciones de administración."
+ "scopeNote": "Los tokens se limitan a health:read: acceso de solo lectura a tus propios registros. Nunca pueden escribir datos ni acceder a funciones de administración.",
+ "connectionsTitle": "Asistentes conectados",
+ "connectionsDescription": "Asistentes que autorizaste mediante el inicio de sesión. Revocar una conexión finaliza su acceso de inmediato, incluida su capacidad de renovación.",
+ "noConnections": "No hay asistentes conectados.",
+ "connectionConnected": "Conectado",
+ "connectionLastUsed": "Último uso",
+ "connectionNeverUsed": "Nunca usado",
+ "connectionRevoke": "Desconectar",
+ "connectionRevokeDescription": "Esto finaliza el acceso del asistente de inmediato e impide su renovación. Será necesario autorizarlo de nuevo para volver a conectarlo.",
+ "connectionRevokeFailed": "No se pudo desconectar el asistente."
}
},
"admin": {
diff --git a/messages/fr.json b/messages/fr.json
index 9861439ee..ff0d19256 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4907,7 +4907,16 @@
"connectDescription": "Collez cette URL dans la configuration du connecteur personnalisé de votre assistant, puis autorisez avec le jeton ci-dessous ou connectez-vous lorsque vous y êtes invité.",
"tokensTitle": "Jetons du connecteur",
"tokensDescription": "Générez un jeton en lecture seule pour connecter un assistant. Le jeton n'est affiché qu'une seule fois.",
- "scopeNote": "Les jetons sont limités à health:read : accès en lecture seule à vos propres données. Ils ne peuvent jamais écrire de données ni atteindre les fonctions d'administration."
+ "scopeNote": "Les jetons sont limités à health:read : accès en lecture seule à vos propres données. Ils ne peuvent jamais écrire de données ni atteindre les fonctions d'administration.",
+ "connectionsTitle": "Assistants connectés",
+ "connectionsDescription": "Assistants que vous avez autorisés via la connexion. Révoquer une connexion met fin à son accès immédiatement, y compris sa capacité à se renouveler.",
+ "noConnections": "Aucun assistant connecté.",
+ "connectionConnected": "Connecté",
+ "connectionLastUsed": "Dernière utilisation",
+ "connectionNeverUsed": "Jamais utilisé",
+ "connectionRevoke": "Déconnecter",
+ "connectionRevokeDescription": "Cela met fin immédiatement à l'accès de l'assistant et l'empêche de se renouveler. Une nouvelle autorisation sera nécessaire pour le reconnecter.",
+ "connectionRevokeFailed": "Impossible de déconnecter l'assistant."
}
},
"admin": {
diff --git a/messages/it.json b/messages/it.json
index 4ba9e434d..40fd73e45 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4907,7 +4907,16 @@
"connectDescription": "Incolla questo URL nella configurazione del connettore personalizzato del tuo assistente e autorizza con il token qui sotto o accedi quando richiesto.",
"tokensTitle": "Token del connettore",
"tokensDescription": "Genera un token in sola lettura per collegare un assistente. Il token viene mostrato una sola volta.",
- "scopeNote": "I token sono limitati a health:read: accesso in sola lettura ai tuoi dati. Non possono mai scrivere dati né raggiungere funzioni di amministrazione."
+ "scopeNote": "I token sono limitati a health:read: accesso in sola lettura ai tuoi dati. Non possono mai scrivere dati né raggiungere funzioni di amministrazione.",
+ "connectionsTitle": "Assistenti collegati",
+ "connectionsDescription": "Assistenti che hai autorizzato tramite l'accesso. Revocare una connessione ne termina subito l'accesso, inclusa la capacità di rinnovarsi.",
+ "noConnections": "Nessun assistente collegato.",
+ "connectionConnected": "Collegato",
+ "connectionLastUsed": "Ultimo utilizzo",
+ "connectionNeverUsed": "Mai usato",
+ "connectionRevoke": "Disconnetti",
+ "connectionRevokeDescription": "Questo termina immediatamente l'accesso dell'assistente e ne impedisce il rinnovo. Per riconnetterlo sarà necessaria una nuova autorizzazione.",
+ "connectionRevokeFailed": "Impossibile disconnettere l'assistente."
}
},
"admin": {
diff --git a/messages/pl.json b/messages/pl.json
index 735be10f4..cfbf4ef2b 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4907,7 +4907,16 @@
"connectDescription": "Wklej ten adres URL w konfiguracji własnego łącznika asystenta, a następnie autoryzuj za pomocą poniższego tokenu lub zaloguj się po wyświetleniu monitu.",
"tokensTitle": "Tokeny łącznika",
"tokensDescription": "Wygeneruj token tylko do odczytu, aby połączyć asystenta. Token jest wyświetlany tylko raz.",
- "scopeNote": "Tokeny są ograniczone do health:read — dostęp tylko do odczytu własnych danych. Nigdy nie mogą zapisywać danych ani uzyskać dostępu do funkcji administracyjnych."
+ "scopeNote": "Tokeny są ograniczone do health:read — dostęp tylko do odczytu własnych danych. Nigdy nie mogą zapisywać danych ani uzyskać dostępu do funkcji administracyjnych.",
+ "connectionsTitle": "Połączeni asystenci",
+ "connectionsDescription": "Asystenci autoryzowani podczas logowania. Cofnięcie połączenia natychmiast kończy dostęp, w tym możliwość odnawiania.",
+ "noConnections": "Brak połączonych asystentów.",
+ "connectionConnected": "Połączono",
+ "connectionLastUsed": "Ostatnio użyto",
+ "connectionNeverUsed": "Nigdy nie użyto",
+ "connectionRevoke": "Rozłącz",
+ "connectionRevokeDescription": "To natychmiast kończy dostęp asystenta i uniemożliwia odnawianie. Ponowne połączenie będzie wymagać nowej autoryzacji.",
+ "connectionRevokeFailed": "Nie udało się rozłączyć asystenta."
}
},
"admin": {
diff --git a/prisma/migrations/0195_mcp_phase3_oauth_connection/migration.sql b/prisma/migrations/0195_mcp_phase3_oauth_connection/migration.sql
new file mode 100644
index 000000000..8df886ee4
--- /dev/null
+++ b/prisma/migrations/0195_mcp_phase3_oauth_connection/migration.sql
@@ -0,0 +1,41 @@
+-- MCP Phase 3 security fix (H2) — revocable, theft-detectable refresh chains.
+--
+-- The remote MCP OAuth bridge minted stateless `hlrt_` refresh tokens that
+-- referenced no server row: they could not be revoked (the settings "revoke"
+-- only killed the 60-minute access token) and a replayed, already-rotated
+-- refresh token was not treated as theft. This migration adds the persistent
+-- anchor the refresh artifact lacked.
+--
+-- `mcp_oauth_connections` is the revocable unit: one row per connector
+-- connection that requested `offline_access`. `current_jti` is the only refresh
+-- `jti` valid at any moment — every rotation advances it, so a presented `jti`
+-- that is not `current_jti` is a replay (theft signal) that revokes the row.
+-- `revoked_at` is the kill switch the settings card flips; revoking the row
+-- stops every future refresh.
+CREATE TABLE "mcp_oauth_connections" (
+ "id" TEXT NOT NULL,
+ "user_id" TEXT NOT NULL,
+ "client_id" TEXT NOT NULL,
+ "client_name" TEXT NOT NULL,
+ "scope" TEXT NOT NULL,
+ "resource" TEXT NOT NULL,
+ "current_jti" TEXT NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "last_used_at" TIMESTAMP(3),
+ "revoked_at" TIMESTAMP(3),
+
+ CONSTRAINT "mcp_oauth_connections_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "mcp_oauth_connections_user_id_idx" ON "mcp_oauth_connections"("user_id");
+
+ALTER TABLE "mcp_oauth_connections" ADD CONSTRAINT "mcp_oauth_connections_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- Link each OAuth-minted access token back to its connection so a connection
+-- revoke kills every access row it issued, and so the connector token list can
+-- exclude the transient access rows (they would otherwise flood it — M2).
+ALTER TABLE "api_tokens" ADD COLUMN "mcp_connection_id" TEXT;
+
+CREATE INDEX "api_tokens_mcp_connection_id_idx" ON "api_tokens"("mcp_connection_id");
+
+ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_mcp_connection_id_fkey" FOREIGN KEY ("mcp_connection_id") REFERENCES "mcp_oauth_connections"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index b0c8bb68b..172aafc9d 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -536,6 +536,7 @@ model User {
medications Medication[]
intakeEvents MedicationIntakeEvent[]
apiTokens ApiToken[]
+ mcpOAuthConnections McpOAuthConnection[]
refreshTokens RefreshToken[]
auditLogs AuditLog[]
withingsConnection WithingsConnection?
@@ -2369,13 +2370,52 @@ model ApiToken {
expiresAt DateTime? @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
revoked Boolean @default(false)
+ // The MCP OAuth connection this access token belongs to, when it was minted
+ // through the `/api/mcp/oauth` bridge. Null for manually-minted tokens and
+ // native-client tokens. Lets a connection revoke kill every access row it
+ // ever issued (H2), and lets the connector token list exclude the transient
+ // 60-minute access rows so it does not flood (M2).
+ mcpConnectionId String? @map("mcp_connection_id")
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull)
@@index([userId])
+ @@index([mcpConnectionId])
@@map("api_tokens")
}
+// MCP OAuth connection — the revocable, theft-detectable anchor for a remote
+// connector's refresh-token chain (Phase 3 security fix, H2).
+//
+// The stateless `hlrt_` refresh artifact carries this row's `id` (`cid`) plus
+// the rotation `jti`; the row is the persistent server state the artifact alone
+// lacks. `currentJti` is the only refresh `jti` valid for the connection at any
+// moment — every rotation advances it. A presented refresh whose `jti` is not
+// `currentJti` is a replay of an already-rotated token (theft signal) and
+// revokes the connection (reuse-detection family revocation, mirroring
+// `refresh-token.ts`). `revokedAt` is the kill switch the settings card flips,
+// so revoking a connection stops every future refresh and (via the
+// `ApiToken.mcpConnectionId` back-link) kills its outstanding access tokens.
+model McpOAuthConnection {
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ clientId String @map("client_id")
+ clientName String @map("client_name")
+ scope String
+ resource String
+ currentJti String @map("current_jti")
+ createdAt DateTime @default(now()) @map("created_at")
+ lastUsedAt DateTime? @map("last_used_at")
+ revokedAt DateTime? @map("revoked_at")
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ accessTokens ApiToken[]
+
+ @@index([userId])
+ @@map("mcp_oauth_connections")
+}
+
// v1.15.20 — registration invite tokens. When the operator has disabled
// open registration (`AppSettings.registrationEnabled = false`), a valid,
// unexpired invite token still admits a signup. The raw `hlv_` token
diff --git a/src/app/.well-known/__tests__/oauth-discovery.test.ts b/src/app/.well-known/__tests__/oauth-discovery.test.ts
new file mode 100644
index 000000000..52f9fd35e
--- /dev/null
+++ b/src/app/.well-known/__tests__/oauth-discovery.test.ts
@@ -0,0 +1,59 @@
+/**
+ * OAuth discovery documents — M1 fail-closed + non-cacheable.
+ *
+ * Without a pinned origin the documents would derive the issuer / endpoints /
+ * audience from the Host header, so the routes 404. When configured they must
+ * not be `public`-cacheable (a host-derived document keyed on path alone could
+ * be served cross-tenant).
+ */
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+
+const PRM_URL = "https://health.example/.well-known/oauth-protected-resource";
+const ASM_URL = "https://health.example/.well-known/oauth-authorization-server";
+
+let GET_PRM: (r: Request) => Response;
+let GET_ASM: (r: Request) => Response;
+
+beforeEach(async () => {
+ GET_PRM = (await import("../oauth-protected-resource/route")).GET;
+ GET_ASM = (await import("../oauth-authorization-server/route")).GET;
+});
+
+afterEach(() => {
+ delete process.env.APP_URL;
+ delete process.env.NEXT_PUBLIC_APP_URL;
+});
+
+describe("OAuth discovery — M1 origin config", () => {
+ it("404s the PRM when no origin is configured", () => {
+ delete process.env.APP_URL;
+ delete process.env.NEXT_PUBLIC_APP_URL;
+ const res = GET_PRM(new Request(PRM_URL));
+ expect(res.status).toBe(404);
+ });
+
+ it("404s the AS metadata when no origin is configured", () => {
+ delete process.env.APP_URL;
+ delete process.env.NEXT_PUBLIC_APP_URL;
+ const res = GET_ASM(new Request(ASM_URL));
+ expect(res.status).toBe(404);
+ });
+
+ it("serves the PRM with private/no-store when configured", async () => {
+ process.env.APP_URL = "https://health.example";
+ const res = GET_PRM(new Request(PRM_URL));
+ expect(res.status).toBe(200);
+ expect(res.headers.get("cache-control")).toBe("private, no-store");
+ const body = await res.json();
+ expect(body.resource).toBe("https://health.example/mcp");
+ });
+
+ it("serves the AS metadata with private/no-store when configured", async () => {
+ process.env.APP_URL = "https://health.example";
+ const res = GET_ASM(new Request(ASM_URL));
+ expect(res.status).toBe(200);
+ expect(res.headers.get("cache-control")).toBe("private, no-store");
+ const body = await res.json();
+ expect(body.issuer).toBe("https://health.example");
+ });
+});
diff --git a/src/app/.well-known/oauth-authorization-server/route.ts b/src/app/.well-known/oauth-authorization-server/route.ts
index f52c79d27..9b22bb096 100644
--- a/src/app/.well-known/oauth-authorization-server/route.ts
+++ b/src/app/.well-known/oauth-authorization-server/route.ts
@@ -7,12 +7,23 @@
* Claude.ai / ChatGPT can self-register and complete the OAuth 2.1 + PKCE flow.
*/
import { authorizationServerMetadata } from "@/lib/mcp/oauth/metadata";
+import { isMcpOriginConfigured } from "@/lib/mcp/oauth/config";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export function GET(request: Request): Response {
+ // M1 — fail closed when no origin is pinned; never derive the issuer /
+ // endpoint URLs from the attacker-influenceable Host header.
+ if (!isMcpOriginConfigured()) {
+ return Response.json(
+ { error: "not_found" },
+ { status: 404, headers: { "Cache-Control": "no-store" } },
+ );
+ }
return Response.json(authorizationServerMetadata(request.url), {
- headers: { "Cache-Control": "public, max-age=300" },
+ // M1 — `private, no-store`: the document is host-derived, so a shared cache
+ // keyed on path (ignoring Host) must never serve it cross-tenant.
+ headers: { "Cache-Control": "private, no-store" },
});
}
diff --git a/src/app/.well-known/oauth-protected-resource/route.ts b/src/app/.well-known/oauth-protected-resource/route.ts
index 46a4a79fb..b91c2910e 100644
--- a/src/app/.well-known/oauth-protected-resource/route.ts
+++ b/src/app/.well-known/oauth-protected-resource/route.ts
@@ -8,12 +8,23 @@
* pasted into Claude.ai / ChatGPT.
*/
import { protectedResourceMetadata } from "@/lib/mcp/oauth/metadata";
+import { isMcpOriginConfigured } from "@/lib/mcp/oauth/config";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export function GET(request: Request): Response {
+ // M1 — fail closed when no origin is pinned; never derive the `resource` /
+ // authorization-server URLs from the attacker-influenceable Host header.
+ if (!isMcpOriginConfigured()) {
+ return Response.json(
+ { error: "not_found" },
+ { status: 404, headers: { "Cache-Control": "no-store" } },
+ );
+ }
return Response.json(protectedResourceMetadata(request.url), {
- headers: { "Cache-Control": "public, max-age=300" },
+ // M1 — `private, no-store`: the document is host-derived, so a shared cache
+ // keyed on path (ignoring Host) must never serve it cross-tenant.
+ headers: { "Cache-Control": "private, no-store" },
});
}
diff --git a/src/app/api/mcp/connections/[id]/route.ts b/src/app/api/mcp/connections/[id]/route.ts
new file mode 100644
index 000000000..63df749c4
--- /dev/null
+++ b/src/app/api/mcp/connections/[id]/route.ts
@@ -0,0 +1,41 @@
+/**
+ * Revoke an MCP OAuth connection (H2).
+ *
+ * Revoking a connection terminates its whole refresh chain: it stamps
+ * `revokedAt` (so every future refresh fails) and revokes every access token
+ * the connection ever issued. Ownership is enforced inside
+ * `revokeConnectionForUser`, so a connection id alone cannot revoke another
+ * user's connection.
+ */
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiError, apiSuccess } from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import { auditLog } from "@/lib/auth/audit";
+import { isApiGloballyEnabled } from "@/lib/app-settings";
+import { revokeConnectionForUser } from "@/lib/mcp/oauth/connections";
+
+type RouteParams = { params: Promise<{ id: string }> };
+
+export const DELETE = apiHandler(
+ async (_request: Request, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+ annotate({ action: { name: "mcp.connections.revoke" } });
+
+ if (!(await isApiGloballyEnabled())) {
+ return apiError("API is globally disabled", 403);
+ }
+
+ const { id } = await params;
+ const revoked = await revokeConnectionForUser(user.id, id);
+ if (!revoked) {
+ return apiError("Connection not found", 404);
+ }
+
+ await auditLog("mcp.connections.revoke", {
+ userId: user.id,
+ details: { connectionId: id },
+ });
+
+ return apiSuccess({ revoked: true });
+ },
+);
diff --git a/src/app/api/mcp/connections/route.ts b/src/app/api/mcp/connections/route.ts
new file mode 100644
index 000000000..3bd24e169
--- /dev/null
+++ b/src/app/api/mcp/connections/route.ts
@@ -0,0 +1,26 @@
+/**
+ * MCP OAuth connections — list (H2).
+ *
+ * A connection is the revocable unit for a remote connector authorized through
+ * the OAuth bridge (Claude.ai / ChatGPT / a third-party client). Unlike the
+ * transient 60-minute access-token rows, the connection persists across
+ * refreshes, so revoking it terminates the whole refresh chain. `userId` is
+ * always narrowed from the session — never a body field.
+ */
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiError, apiSuccess } from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import { isApiGloballyEnabled } from "@/lib/app-settings";
+import { listConnectionsForUser } from "@/lib/mcp/oauth/connections";
+
+export const GET = apiHandler(async () => {
+ const { user } = await requireAuth();
+ annotate({ action: { name: "mcp.connections.list" } });
+
+ if (!(await isApiGloballyEnabled())) {
+ return apiError("API is globally disabled", 403);
+ }
+
+ const connections = await listConnectionsForUser(user.id);
+ return apiSuccess(connections);
+});
diff --git a/src/app/api/mcp/oauth/__tests__/authorize.test.ts b/src/app/api/mcp/oauth/__tests__/authorize.test.ts
index fc65471c3..7079af430 100644
--- a/src/app/api/mcp/oauth/__tests__/authorize.test.ts
+++ b/src/app/api/mcp/oauth/__tests__/authorize.test.ts
@@ -21,9 +21,13 @@ vi.mock("@/lib/rate-limit", () => ({
})),
rateLimitHeaders: vi.fn(() => ({})),
}));
+vi.mock("@/lib/app-settings", () => ({
+ isApiGloballyEnabled: vi.fn(async () => true),
+}));
import { GET, POST } from "../authorize/route";
import { getSession } from "@/lib/auth/session";
+import { isApiGloballyEnabled } from "@/lib/app-settings";
import { registerDcrClient } from "@/lib/mcp/oauth/clients";
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
@@ -81,6 +85,7 @@ function signedIn() {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(null);
+ vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
});
describe("GET /authorize — consent gate", () => {
@@ -157,6 +162,92 @@ describe("GET /authorize — rejected requests", () => {
});
});
+describe("GET /authorize — consent provenance (L1)", () => {
+ it("labels a DCR client as unverified (self-asserted name)", async () => {
+ signedIn();
+ const res = await GET(getReq(authorizeUrl()) as never);
+ const body = await res.text();
+ expect(body).toMatch(/Unverified application/);
+ });
+});
+
+describe("authorize — surface availability (M1/M4)", () => {
+ it("GET returns 503 when the API is globally disabled", async () => {
+ signedIn();
+ vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
+ const res = await GET(getReq(authorizeUrl()) as never);
+ expect(res.status).toBe(503);
+ });
+
+ it("POST returns 503 when the API is globally disabled", async () => {
+ signedIn();
+ vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
+ const res = await POST(
+ postReq({
+ response_type: "code",
+ client_id: CLIENT.clientId,
+ redirect_uri: REDIRECT,
+ code_challenge: CHALLENGE,
+ code_challenge_method: "S256",
+ scope: "health:read",
+ resource: RESOURCE,
+ decision: "allow",
+ }) as never,
+ );
+ expect(res.status).toBe(503);
+ });
+});
+
+describe("POST /authorize — cross-origin guard (L3)", () => {
+ it("rejects a cross-origin Origin with 403", async () => {
+ signedIn();
+ const body = new URLSearchParams({
+ response_type: "code",
+ client_id: CLIENT.clientId,
+ redirect_uri: REDIRECT,
+ code_challenge: CHALLENGE,
+ code_challenge_method: "S256",
+ scope: "health:read",
+ resource: RESOURCE,
+ decision: "allow",
+ });
+ const req = new Request("https://health.example/api/mcp/oauth/authorize", {
+ method: "POST",
+ headers: {
+ "content-type": "application/x-www-form-urlencoded",
+ origin: "https://evil.example",
+ },
+ body: body.toString(),
+ });
+ const res = await POST(req as never);
+ expect(res.status).toBe(403);
+ });
+
+ it("rejects a cross-site Sec-Fetch-Site with 403", async () => {
+ signedIn();
+ const body = new URLSearchParams({
+ response_type: "code",
+ client_id: CLIENT.clientId,
+ redirect_uri: REDIRECT,
+ code_challenge: CHALLENGE,
+ code_challenge_method: "S256",
+ scope: "health:read",
+ resource: RESOURCE,
+ decision: "allow",
+ });
+ const req = new Request("https://health.example/api/mcp/oauth/authorize", {
+ method: "POST",
+ headers: {
+ "content-type": "application/x-www-form-urlencoded",
+ "sec-fetch-site": "cross-site",
+ },
+ body: body.toString(),
+ });
+ const res = await POST(req as never);
+ expect(res.status).toBe(403);
+ });
+});
+
describe("POST /authorize — decision", () => {
it("requires a session (401 when unauthenticated)", async () => {
const res = await POST(
diff --git a/src/app/api/mcp/oauth/__tests__/register.test.ts b/src/app/api/mcp/oauth/__tests__/register.test.ts
index bcf37c2be..9bacbea67 100644
--- a/src/app/api/mcp/oauth/__tests__/register.test.ts
+++ b/src/app/api/mcp/oauth/__tests__/register.test.ts
@@ -18,8 +18,12 @@ vi.mock("@/lib/rate-limit", () => ({
})),
rateLimitHeaders: vi.fn(() => ({})),
}));
+vi.mock("@/lib/app-settings", () => ({
+ isApiGloballyEnabled: vi.fn(async () => true),
+}));
import { POST } from "../register/route";
+import { isApiGloballyEnabled } from "@/lib/app-settings";
function jsonReq(body: unknown): Request {
return new Request("https://health.example/api/mcp/oauth/register", {
@@ -29,7 +33,10 @@ function jsonReq(body: unknown): Request {
});
}
-beforeEach(() => vi.clearAllMocks());
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
+});
describe("POST /register", () => {
it("registers a public client and returns an hlc_ client_id", async () => {
@@ -69,4 +76,13 @@ describe("POST /register", () => {
);
expect(res.status).toBe(400);
});
+
+ it("returns 503 when the API is globally disabled (M4)", async () => {
+ vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
+ const res = await POST(
+ jsonReq({ redirect_uris: ["https://chatgpt.com/cb"] }) as never,
+ );
+ expect(res.status).toBe(503);
+ expect((await res.json()).error).toBe("temporarily_unavailable");
+ });
});
diff --git a/src/app/api/mcp/oauth/__tests__/token.test.ts b/src/app/api/mcp/oauth/__tests__/token.test.ts
index c48bf9872..76e3fbb07 100644
--- a/src/app/api/mcp/oauth/__tests__/token.test.ts
+++ b/src/app/api/mcp/oauth/__tests__/token.test.ts
@@ -34,11 +34,23 @@ vi.mock("@/lib/rate-limit", () => ({
})),
rateLimitHeaders: vi.fn(() => ({})),
}));
+vi.mock("@/lib/app-settings", () => ({
+ isApiGloballyEnabled: vi.fn(async () => true),
+}));
+vi.mock("@/lib/mcp/oauth/connections", () => ({
+ createConnection: vi.fn(async () => "conn-1"),
+ rotateConnection: vi.fn(async () => ({ ok: true })),
+}));
import { randomUUID } from "node:crypto";
import { POST } from "../token/route";
import { issueApiToken } from "@/lib/auth/issue-token";
import { checkRateLimit } from "@/lib/rate-limit";
+import { isApiGloballyEnabled } from "@/lib/app-settings";
+import {
+ createConnection,
+ rotateConnection,
+} from "@/lib/mcp/oauth/connections";
import { signArtifact } from "@/lib/mcp/oauth/artifacts";
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
@@ -80,8 +92,27 @@ beforeEach(() => {
remaining: 0,
resetAt: Date.now() + 60_000,
});
+ vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
+ vi.mocked(createConnection).mockResolvedValue("conn-1");
+ vi.mocked(rotateConnection).mockResolvedValue({ ok: true });
});
+function refreshToken(overrides: Record = {}): string {
+ return signArtifact(
+ "refreshToken",
+ {
+ jti: randomUUID(),
+ cid: "conn-1",
+ sub: "user-1",
+ client_id: CLIENT_ID,
+ scope: "health:read offline_access",
+ resource: RESOURCE,
+ ...overrides,
+ },
+ 1_000_000,
+ );
+}
+
describe("authorization_code grant", () => {
it("exchanges a valid code + verifier for an access token (+ refresh)", async () => {
const res = await POST(
@@ -104,6 +135,22 @@ describe("authorization_code grant", () => {
);
});
+ it("creates a revocable connection anchor and links the access token (H2)", async () => {
+ await POST(
+ tokenReq({
+ grant_type: "authorization_code",
+ code: authCode(),
+ code_verifier: VERIFIER,
+ client_id: CLIENT_ID,
+ redirect_uri: REDIRECT,
+ }) as never,
+ );
+ expect(createConnection).toHaveBeenCalledTimes(1);
+ expect(issueApiToken).toHaveBeenCalledWith(
+ expect.objectContaining({ mcpConnectionId: "conn-1" }),
+ );
+ });
+
it("rejects a wrong PKCE verifier with invalid_grant", async () => {
const res = await POST(
tokenReq({
@@ -169,8 +216,61 @@ describe("authorization_code grant", () => {
});
describe("refresh_token grant", () => {
- it("rotates: issues a new access + refresh pair", async () => {
- const refresh = signArtifact(
+ it("rotates: issues a new access + refresh pair against a live connection", async () => {
+ const res = await POST(
+ tokenReq({
+ grant_type: "refresh_token",
+ refresh_token: refreshToken(),
+ client_id: CLIENT_ID,
+ }) as never,
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.access_token).toMatch(/^hlk_/);
+ expect(body.refresh_token).toMatch(/^hlrt_/);
+ expect(rotateConnection).toHaveBeenCalledWith(
+ expect.objectContaining({ connectionId: "conn-1", clientId: CLIENT_ID }),
+ );
+ });
+
+ it("rejects a refresh whose connection was revoked (H2 — settings revoke)", async () => {
+ vi.mocked(rotateConnection).mockResolvedValue({
+ ok: false,
+ reason: "revoked",
+ });
+ const res = await POST(
+ tokenReq({
+ grant_type: "refresh_token",
+ refresh_token: refreshToken(),
+ client_id: CLIENT_ID,
+ }) as never,
+ );
+ expect(res.status).toBe(400);
+ expect((await res.json()).error).toBe("invalid_grant");
+ expect(issueApiToken).not.toHaveBeenCalled();
+ });
+
+ it("rejects a replayed refresh and reports reuse detection (H2)", async () => {
+ vi.mocked(rotateConnection).mockResolvedValue({
+ ok: false,
+ reason: "reuse_detected",
+ });
+ const res = await POST(
+ tokenReq({
+ grant_type: "refresh_token",
+ refresh_token: refreshToken(),
+ client_id: CLIENT_ID,
+ }) as never,
+ );
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error).toBe("invalid_grant");
+ expect(body.error_description).toMatch(/reuse/i);
+ expect(issueApiToken).not.toHaveBeenCalled();
+ });
+
+ it("rejects a legacy refresh token with no connection binding", async () => {
+ const legacy = signArtifact(
"refreshToken",
{
jti: randomUUID(),
@@ -184,14 +284,31 @@ describe("refresh_token grant", () => {
const res = await POST(
tokenReq({
grant_type: "refresh_token",
- refresh_token: refresh,
+ refresh_token: legacy,
client_id: CLIENT_ID,
}) as never,
);
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body.access_token).toMatch(/^hlk_/);
- expect(body.refresh_token).toMatch(/^hlrt_/);
+ expect(res.status).toBe(400);
+ expect((await res.json()).error).toBe("invalid_grant");
+ expect(rotateConnection).not.toHaveBeenCalled();
+ });
+});
+
+describe("operator kill-switch + origin config (M1/M4)", () => {
+ it("refuses to mint when the API is globally disabled", async () => {
+ vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
+ const res = await POST(
+ tokenReq({
+ grant_type: "authorization_code",
+ code: authCode(),
+ code_verifier: VERIFIER,
+ client_id: CLIENT_ID,
+ redirect_uri: REDIRECT,
+ }) as never,
+ );
+ expect(res.status).toBe(503);
+ expect((await res.json()).error).toBe("temporarily_unavailable");
+ expect(issueApiToken).not.toHaveBeenCalled();
});
});
diff --git a/src/app/api/mcp/oauth/authorize/route.ts b/src/app/api/mcp/oauth/authorize/route.ts
index 068fe7974..70288aa07 100644
--- a/src/app/api/mcp/oauth/authorize/route.ts
+++ b/src/app/api/mcp/oauth/authorize/route.ts
@@ -31,9 +31,12 @@ import { withBackgroundEvent } from "@/lib/logging/background";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
+import { isApiGloballyEnabled } from "@/lib/app-settings";
import {
audienceMatches,
AUTH_CODE_TTL_SECONDS,
+ isMcpOriginConfigured,
+ resolveBaseOrigin,
SCOPE_HEALTH_READ,
} from "@/lib/mcp/oauth/config";
import { redirectUriAllowed, resolveClient } from "@/lib/mcp/oauth/clients";
@@ -96,6 +99,10 @@ function grantScope(requested: string | undefined): string {
interface ValidatedRequest {
clientId: string;
clientName: string;
+ /** The VERIFIED client origin shown on the consent screen (L1). */
+ clientOrigin: string;
+ /** Registration provenance — drives the trust label on consent (L1). */
+ clientSource: "cimd" | "dcr";
redirectUri: string;
codeChallenge: string;
scope: string;
@@ -174,11 +181,28 @@ async function validate(
};
}
+ // L1 — the VERIFIED origin. For CIMD the `client_id` IS the document URL, so
+ // its host is cryptographically tied to the fetched-and-matched document; for
+ // DCR there is no verified origin (anyone can self-register), so the consent
+ // screen labels it unverified rather than trusting the self-asserted name.
+ const clientOrigin =
+ resolved.client.source === "cimd"
+ ? (() => {
+ try {
+ return new URL(p.client_id).host;
+ } catch {
+ return p.client_id;
+ }
+ })()
+ : "";
+
return {
ok: true,
v: {
clientId: p.client_id,
clientName: resolved.client.clientName,
+ clientOrigin,
+ clientSource: resolved.client.source,
redirectUri: p.redirect_uri,
codeChallenge: p.code_challenge as string,
scope: grantScope(p.scope),
@@ -188,6 +212,20 @@ async function validate(
};
}
+/**
+ * L3 — same-origin assertion for the consent POST. A non-browser caller sends
+ * neither `Origin` nor `Sec-Fetch-Site`, so their absence is allowed; a present
+ * `Origin` that is not our own origin, or a `Sec-Fetch-Site` that is not
+ * `same-origin`, is refused. Mirrors the `/mcp` DNS-rebinding posture.
+ */
+function consentOriginAllowed(request: NextRequest): boolean {
+ const origin = request.headers.get("origin");
+ if (origin && origin !== resolveBaseOrigin(request.url)) return false;
+ const site = request.headers.get("sec-fetch-site");
+ if (site && site !== "same-origin") return false;
+ return true;
+}
+
function redirectBack(
redirectUri: string,
query: Record,
@@ -197,8 +235,19 @@ function redirectBack(
return Response.redirect(url.toString(), 302);
}
+/** Whether the surface is available (M1 origin pinned + M4 kill-switch off). */
+async function surfaceAvailable(): Promise {
+ return isMcpOriginConfigured() && (await isApiGloballyEnabled());
+}
+
export async function GET(request: NextRequest): Promise {
return withBackgroundEvent("mcp.oauth.authorize", async () => {
+ if (!(await surfaceAvailable())) {
+ return Response.json(
+ { error: "temporarily_unavailable" },
+ { status: 503 },
+ );
+ }
const rl = await checkAuthSurfaceRateLimit(
request,
"mcp:oauth:authorize",
@@ -244,10 +293,17 @@ export async function GET(request: NextRequest): Promise {
// Consent screen — a plain POST form (no inline script/style, CSP-safe).
const hidden = (name: string, value: string) =>
``;
+ // L1 — show the VERIFIED origin (or an explicit "unverified" label for DCR)
+ // next to the self-asserted name so the user authorizes who, not just what.
+ const provenance =
+ v.clientSource === "cimd"
+ ? `Verified origin: ${htmlEscape(v.clientOrigin)}
`
+ : `Unverified application (dynamically registered — the name above is self-reported and not verified).
`;
return html(
`
Authorize access
${htmlEscape(v.clientName)} is requesting access to your HealthLog data.
+ ${provenance}
Scope: ${htmlEscape(v.scope)} — read-only access to your own health records.
+
+
+
+ {t("settings.mcp.writeScopeToggleLabel")}
+
+
+
+
+ {t("settings.mcp.writeScopeNote")}
+
+
+