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.

+
+ ${hidden("response_type", "code")} + ${hidden("client_id", v.clientId)} + ${hidden("redirect_uri", v.redirectUri)} + ${hidden("code_challenge", v.codeChallenge)} + ${hidden("code_challenge_method", "S256")} + ${hidden("scope", v.scope)} + ${hidden("resource", v.resource)} + ${v.state !== undefined ? hidden("state", v.state) : ""} + + +
+
`, + ); + }); +} + +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")} +

+ +
+ setNewName(e.target.value)} + placeholder={t("settings.tokenNamePlaceholder")} + maxLength={100} + className="flex-1" + /> + +
+ + {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.

${hidden("response_type", "code")} @@ -268,6 +324,25 @@ export async function GET(request: NextRequest): Promise { export async function POST(request: NextRequest): Promise { return withBackgroundEvent("mcp.oauth.authorize", async () => { + if (!(await surfaceAvailable())) { + return Response.json( + { error: "temporarily_unavailable" }, + { status: 503 }, + ); + } + + // L3 — defence-in-depth same-origin assertion on the consent decision. The + // session cookie is already SameSite=Lax (a cross-site POST carries none), + // but reject an explicit cross-origin Origin / Sec-Fetch-Site outright so a + // forged "allow" cannot ride a present session. + if (!consentOriginAllowed(request)) { + annotate({ action: { name: "mcp.oauth.authorize.cross_origin" } }); + return Response.json( + { error: "access_denied", error_description: "Cross-origin POST" }, + { status: 403 }, + ); + } + const rl = await checkAuthSurfaceRateLimit( request, "mcp:oauth:authorize", @@ -341,6 +416,9 @@ export async function POST(request: NextRequest): Promise { jti: randomUUID(), sub: session.user.id, client_id: v.clientId, + // Carried so the token endpoint can label the persistent connection + // (H2) without re-resolving the client. + client_name: v.clientName, redirect_uri: v.redirectUri, code_challenge: v.codeChallenge, scope: v.scope, diff --git a/src/app/api/mcp/oauth/register/route.ts b/src/app/api/mcp/oauth/register/route.ts index 9e49e8192..513bf1b85 100644 --- a/src/app/api/mcp/oauth/register/route.ts +++ b/src/app/api/mcp/oauth/register/route.ts @@ -18,7 +18,12 @@ 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"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { readBodyCapped } from "@/lib/http/read-capped"; +import { + isAllowableRedirectUri, + registerDcrClient, +} from "@/lib/mcp/oauth/clients"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -27,24 +32,6 @@ 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)) @@ -96,13 +83,20 @@ export async function POST(request: NextRequest): Promise { ); } - const raw = await request.text(); - if (raw.length > MAX_BODY_BYTES) { + // M4 — the operator API kill-switch covers the OAuth surface too. + if (!(await isApiGloballyEnabled())) { + return oauthError("temporarily_unavailable", "API is disabled", 503); + } + + // M3 — enforce the cap WHILE reading the (unauthenticated) registration + // body so a hostile client cannot buffer an oversized payload first. + const read = await readBodyCapped(request, MAX_BODY_BYTES); + if (!read.ok) { return oauthError("invalid_client_metadata", "Payload too large", 400); } let body: unknown; try { - body = JSON.parse(raw); + body = JSON.parse(read.text); } catch { return oauthError("invalid_client_metadata", "Invalid JSON", 400); } diff --git a/src/app/api/mcp/oauth/token/route.ts b/src/app/api/mcp/oauth/token/route.ts index 0d8d628dd..749d2f917 100644 --- a/src/app/api/mcp/oauth/token/route.ts +++ b/src/app/api/mcp/oauth/token/route.ts @@ -30,6 +30,7 @@ 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 { isApiGloballyEnabled } from "@/lib/app-settings"; import { checkRateLimit, checkAuthSurfaceRateLimit, @@ -38,11 +39,16 @@ import { import { ACCESS_TOKEN_TTL_MINUTES, audienceMatches, + isMcpOriginConfigured, 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"; +import { + createConnection, + rotateConnection, +} from "@/lib/mcp/oauth/connections"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -82,6 +88,7 @@ interface AuthCodeClaims { jti: string; sub: string; client_id: string; + client_name?: string; redirect_uri: string; code_challenge: string; scope: string; @@ -90,40 +97,56 @@ interface AuthCodeClaims { interface RefreshClaims { jti: string; + /** Connection id — the persistent, revocable anchor for the chain (H2). */ + cid?: string; sub: string; client_id: string; scope: string; resource: string; } +/** + * Mint the access token (+ refresh artifact when `offline_access` is granted). + * + * `connectionId` is the persistent anchor for the refresh chain (H2): the access + * token is linked to it (so a connection revoke kills it) and the refresh + * artifact carries it (`cid`). `refreshJti` is the connection's new + * `currentJti` — for the `authorization_code` grant the caller has just created + * the connection seeded with it; for `refresh_token` the caller has just + * advanced the connection to it. + */ async function mintTokenPair(args: { userId: string; clientId: string; scope: string; resource: string; grant: "authorization_code" | "refresh_token"; + connectionId?: string; + refreshJti?: string; }): Promise { const access = await issueApiToken({ userId: args.userId, name: "MCP connector", permissions: [SCOPE_HEALTH_READ], expiresInMinutes: ACCESS_TOKEN_TTL_MINUTES, + ...(args.connectionId ? { mcpConnectionId: args.connectionId } : {}), }); - 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; + const refresh = + args.connectionId && args.refreshJti + ? signArtifact( + "refreshToken", + { + jti: args.refreshJti, + cid: args.connectionId, + 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, @@ -145,6 +168,16 @@ async function mintTokenPair(args: { export async function POST(request: NextRequest): Promise { return withBackgroundEvent("mcp.oauth.token", async () => { + // M1 — fail closed without a pinned origin; M4 — honour the operator's + // global API kill-switch. Either off and the bridge mints no tokens. + if (!isMcpOriginConfigured() || !(await isApiGloballyEnabled())) { + return oauthError( + "temporarily_unavailable", + "The MCP OAuth surface is unavailable", + 503, + ); + } + const rl = await checkAuthSurfaceRateLimit( request, "mcp:oauth:token", @@ -242,12 +275,34 @@ export async function POST(request: NextRequest): Promise { ); } + // H2 — when offline_access is granted, create the persistent, revocable + // connection anchor seeded with the first refresh jti. Without it the + // refresh chain would be stateless and un-revocable. + const offlineGranted = claims.scope + .split(/\s+/) + .includes("offline_access"); + let connectionId: string | undefined; + let refreshJti: string | undefined; + if (offlineGranted) { + refreshJti = randomUUID(); + connectionId = await createConnection({ + userId: claims.sub, + clientId: claims.client_id, + clientName: claims.client_name ?? "MCP client", + scope: claims.scope, + resource: claims.resource, + jti: refreshJti, + }); + } + return mintTokenPair({ userId: claims.sub, clientId: claims.client_id, scope: claims.scope, resource: claims.resource, grant: "authorization_code", + connectionId, + refreshJti, }); } @@ -284,18 +339,41 @@ export async function POST(request: NextRequest): Promise { "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" } }); + + // H2 — rotation against the persistent connection anchor. The connection's + // `currentJti` is the single valid refresh jti; presenting any other (an + // already-rotated artifact) is a replay that revokes the whole connection + // (reuse-detection family revocation). A revoked connection always fails, + // so the settings "revoke" terminates the chain. + if (!claims.cid) { return oauthError( "invalid_grant", - "Refresh token has already been used", + "Refresh token is not bound to a connection", + ); + } + const newJti = randomUUID(); + const rotation = await rotateConnection({ + connectionId: claims.cid, + presentedJti: claims.jti, + newJti, + clientId: claims.client_id, + userId: claims.sub, + }); + if (!rotation.ok) { + annotate({ + action: { + name: + rotation.reason === "reuse_detected" + ? "mcp.oauth.token.refresh_reuse_detected" + : "mcp.oauth.token.refresh_rejected", + }, + meta: { reason: rotation.reason }, + }); + return oauthError( + "invalid_grant", + rotation.reason === "reuse_detected" + ? "Refresh token reuse detected; the connection was revoked" + : "Refresh token is invalid, revoked, or already used", ); } @@ -305,6 +383,8 @@ export async function POST(request: NextRequest): Promise { scope: claims.scope, resource: claims.resource, grant: "refresh_token", + connectionId: claims.cid, + refreshJti: newJti, }); } diff --git a/src/app/api/mcp/tokens/route.ts b/src/app/api/mcp/tokens/route.ts index c6f6542f9..5140a001d 100644 --- a/src/app/api/mcp/tokens/route.ts +++ b/src/app/api/mcp/tokens/route.ts @@ -42,8 +42,15 @@ export const GET = apiHandler(async () => { return apiError("API is globally disabled", 403); } + // M2 — list only manually-minted connector tokens. OAuth access rows + // (`mcpConnectionId != null`) are transient 60-minute credentials that would + // flood this list; they are surfaced (and revoked) as connections instead. const tokens = await prisma.apiToken.findMany({ - where: { userId: user.id, permissions: { has: SCOPE_HEALTH_READ } }, + where: { + userId: user.id, + permissions: { has: SCOPE_HEALTH_READ }, + mcpConnectionId: null, + }, select: { id: true, name: true, diff --git a/src/app/mcp/__tests__/route.test.ts b/src/app/mcp/__tests__/route.test.ts index b838cd9a8..e9115ba4b 100644 --- a/src/app/mcp/__tests__/route.test.ts +++ b/src/app/mcp/__tests__/route.test.ts @@ -21,7 +21,13 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; +// The surface fails closed without a pinned origin (M1); pin it for the suite. +process.env.APP_URL = "http://localhost"; + vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/app-settings", () => ({ + isApiGloballyEnabled: vi.fn(async () => true), +})); vi.mock("@/lib/auth/bearer", () => ({ resolveBearerToken: vi.fn(), BearerAuthError: class BearerAuthError extends Error {}, @@ -60,6 +66,7 @@ import { POST, GET } from "../route"; import { resolveBearerToken } from "@/lib/auth/bearer"; import { isModuleEnabled } from "@/lib/modules/gate"; import { checkMcpRateLimit } from "@/lib/rate-limit"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; const READ_TOOLS = [ "get_correlations", @@ -102,6 +109,7 @@ beforeEach(() => { headersGet.mockReturnValue(null); // Default happy-path: module on, under budget. vi.mocked(isModuleEnabled).mockResolvedValue(true); + vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); vi.mocked(checkMcpRateLimit).mockResolvedValue({ allowed: true, remaining: 119, @@ -174,6 +182,17 @@ describe("/mcp — Origin gate (DNS-rebinding defense, spec PR #1439)", () => { }); }); +describe("/mcp — operator kill-switch (M4)", () => { + it("hides the surface (404) when the API is globally disabled", async () => { + validToken(); + vi.mocked(isApiGloballyEnabled).mockResolvedValue(false); + const res = await POST(postRpc(TOOLS_LIST)); + expect(res.status).toBe(404); + // Fails closed before the token table is consulted. + expect(resolveBearerToken).not.toHaveBeenCalled(); + }); +}); + describe("/mcp — 401 carries the RFC 9728 resource_metadata pointer", () => { it("includes resource_metadata in the WWW-Authenticate challenge", async () => { const res = await POST( diff --git a/src/app/mcp/route.ts b/src/app/mcp/route.ts index 792182441..80bf1a323 100644 --- a/src/app/mcp/route.ts +++ b/src/app/mcp/route.ts @@ -46,8 +46,12 @@ 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 { isApiGloballyEnabled } from "@/lib/app-settings"; import { checkMcpRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; -import { resolveBaseOrigin } from "@/lib/mcp/oauth/config"; +import { + isMcpOriginConfigured, + resolveBaseOrigin, +} from "@/lib/mcp/oauth/config"; import { wwwAuthenticateChallenge } from "@/lib/mcp/oauth/metadata"; import type { ModuleKey } from "@/lib/modules/registry"; @@ -131,6 +135,15 @@ async function handleMcp(request: Request): Promise { return forbiddenOrigin(); } + // 0a. KILL-SWITCH + ORIGIN CONFIG — M1 fails closed without a pinned + // origin; M4 honours the operator's global API switch. Both answer the + // same invisible 404 as the module-off posture so a disabled instance + // leaks nothing. + if (!isMcpOriginConfigured() || !(await isApiGloballyEnabled())) { + annotate({ action: { name: "mcp.surface.unavailable" } }); + return moduleDisabled(); + } + // 1. AUTH — Bearer only; a cookie is never consulted (admin unreachable). const raw = readBearer(request); if (!raw) { diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx index f645b398a..fdb8f58da 100644 --- a/src/components/settings/mcp-section.tsx +++ b/src/components/settings/mcp-section.tsx @@ -18,7 +18,7 @@ */ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, Copy, Key, Loader2, Plug, Trash2 } from "lucide-react"; +import { Check, Copy, Key, Link2, Loader2, Plug, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { @@ -54,15 +54,136 @@ interface McpTokenInfo { revoked: boolean; } +interface McpConnectionInfo { + id: string; + clientName: string; + scope: string; + createdAt: string; + lastUsedAt: string | null; +} + export function McpSection() { return (
+
); } +function McpConnectionsCard() { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + const queryClient = useQueryClient(); + + const { data: connections } = useQuery({ + queryKey: queryKeys.mcpConnections(), + queryFn: async () => apiGet("/api/mcp/connections"), + enabled: isAuthenticated, + }); + + async function handleRevoke(connectionId: string) { + try { + const res = await apiFetchRaw(`/api/mcp/connections/${connectionId}`, { + method: "DELETE", + }); + if (!res.ok) { + toast.error(t("settings.mcp.connectionRevokeFailed")); + return; + } + queryClient.invalidateQueries({ queryKey: queryKeys.mcpConnections() }); + } catch { + toast.error(t("settings.mcp.connectionRevokeFailed")); + } + } + + const list = connections ?? []; + + return ( + + + +
+ {list.length === 0 ? ( +

+ {t("settings.mcp.noConnections")} +

+ ) : ( +
    + {list.map((conn) => ( +
  • +
    +

    + {conn.clientName} +

    + + {t("settings.mcp.connectionConnected")} + +
    +

    + + {t("settings.tokenTablePermissions")}: + {" "} + {conn.scope} +

    +

    + + {t("settings.mcp.connectionLastUsed")}: + {" "} + {conn.lastUsedAt + ? formatDateTime(conn.lastUsedAt) + : t("settings.mcp.connectionNeverUsed")} +

    + + + + + + + + {t("settings.mcp.connectionRevoke")} + + + {t("settings.mcp.connectionRevokeDescription")} + + + + + {t("common.cancel")} + + handleRevoke(conn.id)} + > + {t("settings.mcp.connectionRevoke")} + + + + +
  • + ))} +
+ )} +
+
+ ); +} + function McpEnableCard() { const { t } = useTranslations(); const { user } = useAuth(); diff --git a/src/lib/__tests__/require-auth-bearer.test.ts b/src/lib/__tests__/require-auth-bearer.test.ts index a3c248933..f1688bba0 100644 --- a/src/lib/__tests__/require-auth-bearer.test.ts +++ b/src/lib/__tests__/require-auth-bearer.test.ts @@ -42,7 +42,7 @@ vi.mock("next/headers", () => ({ // --- Imports use the mocked modules above. --- -import { requireAuth, HttpError } from "../api-handler"; +import { requireAuth, HttpError, apiHandler } from "../api-handler"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { hashToken } from "@/lib/auth/hmac"; @@ -270,6 +270,89 @@ describe("requireAuth — Bearer token path", () => { }); }); +describe("requireAuth — H1 MCP health:read audience binding on REST", () => { + // Drive the FULL apiHandler path so the request method is in event context — + // the H1 check reads it to allow safe reads but block writes. + const route = apiHandler(async (_req: Request) => { + await requireAuth(); + return Response.json({ ok: true }); + }); + + function call(method: string): Promise { + setBearerHeader(`Bearer ${RAW_TOKEN}`); + return route( + new Request("https://health.example/api/measurements/batch", { + method, + }) as never, + ); + } + + beforeEach(() => { + vi.mocked(getSession).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never); + }); + + function mockHealthReadToken(): void { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "mcp-token", + userId: "user-1", + permissions: ["health:read"], + revoked: false, + expiresAt: null, + } as never); + } + + it("accepts a health:read token on a GET read route", async () => { + mockHealthReadToken(); + const res = await call("GET"); + expect(res.status).toBe(200); + }); + + it("rejects a health:read token on POST with 403", async () => { + mockHealthReadToken(); + const res = await call("POST"); + expect(res.status).toBe(403); + expect(auditLog).toHaveBeenCalledWith( + "auth.bearer.failure", + expect.objectContaining({ + details: expect.objectContaining({ + reason: "mcp_audience_write_blocked", + }), + }), + ); + }); + + it("rejects a health:read token on DELETE with 403", async () => { + mockHealthReadToken(); + const res = await call("DELETE"); + expect(res.status).toBe(403); + }); + + it("does NOT restrict a wildcard token on POST (not MCP-audience)", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "wild", + userId: "user-1", + permissions: ["*"], + revoked: false, + expiresAt: null, + } as never); + const res = await call("POST"); + expect(res.status).toBe(200); + }); + + it("does NOT restrict a health:read+write token on POST", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "rw", + userId: "user-1", + permissions: ["health:read", "health:write"], + revoked: false, + expiresAt: null, + } as never); + const res = await call("POST"); + expect(res.status).toBe(200); + }); +}); + describe("requireAuth — cookie path remains intact", () => { it("returns the session payload without consulting the Bearer header", async () => { const cookieSession = { diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index ba78ad92f..0e59c1c84 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -10,6 +10,27 @@ import { auditLog } from "./auth/audit"; import { resolveBearerToken, BearerAuthError } from "./auth/bearer"; import { AssistantDisabledError } from "./feature-flags"; import { ConsentRequiredError } from "./ai/consent-guard"; +import { SCOPE_HEALTH_READ } from "./mcp/oauth/config"; + +/** + * HTTP methods a read-only credential may use on the REST surface. A request + * with any other method (POST / PUT / PATCH / DELETE) is a write. + */ +const READ_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +/** + * Whether a token is MCP-audience-bound (H1). The MCP OAuth bridge and the + * connector settings card mint tokens whose ONLY grant is `health:read`. Such a + * token is bound to the read surface: the `/mcp` resolver accepts it, and so do + * safe (read) REST methods, but it must never reach a REST write/delete. A token + * carrying any broader or legacy grant (`*`, `medication:ingest`, an explicit + * `health:write`, …) is NOT MCP-audience-bound and keeps its existing reach. + */ +function isMcpAudienceToken(permissions: readonly string[]): boolean { + return ( + permissions.length > 0 && permissions.every((p) => p === SCOPE_HEALTH_READ) + ); +} /** * Custom error class for HTTP errors with status codes. @@ -331,7 +352,30 @@ async function authenticateBearer( throw err; } - const { user, tokenId, expiresAt } = resolution; + const { user, tokenId, expiresAt, permissions } = resolution; + + // H1 — audience binding at the resource server. A health:read-only token is + // the MCP read credential; it may reach `/mcp` (a separate resolver that + // never runs this edge) and safe REST reads, but a write/delete over REST is + // outside its audience and is refused. Fail closed when the method is unknown + // (no event context) since every real REST request runs inside apiHandler, + // which always sets the method — an unknown method means we cannot prove a + // read, so we deny. This is RFC 8707 audience binding on the credential the + // client actually holds, not only during the OAuth exchange. + if (isMcpAudienceToken(permissions)) { + const method = (getEvent()?.getHttpMethod() ?? "").toUpperCase(); + if (!READ_HTTP_METHODS.has(method)) { + auditLog("auth.bearer.failure", { + userId: user.id, + details: { + reason: "mcp_audience_write_blocked", + tokenId, + method: method || "unknown", + }, + }).catch(() => {}); + throw new HttpError(403, "Insufficient permissions"); + } + } auditLog("auth.bearer.success", { userId: user.id, diff --git a/src/lib/auth/issue-token.ts b/src/lib/auth/issue-token.ts index bf4375bf2..291b34342 100644 --- a/src/lib/auth/issue-token.ts +++ b/src/lib/auth/issue-token.ts @@ -29,6 +29,12 @@ export interface IssueTokenOptions { * carries continuity). Ignored when `expiresInDays` alone is supplied. */ expiresInMinutes?: number; + /** + * Links this access token to an MCP OAuth connection (H2). Set only by the + * `/api/mcp/oauth` bridge so a connection revoke can kill every access row it + * issued and the connector token list can exclude the transient access rows. + */ + mcpConnectionId?: string; } /** @@ -53,6 +59,9 @@ export async function issueApiToken( tokenHash: tokenHashValue, permissions: opts.permissions ?? ["*"], expiresAt, + ...(opts.mcpConnectionId + ? { mcpConnectionId: opts.mcpConnectionId } + : {}), }, select: { id: true }, }); diff --git a/src/lib/http/__tests__/read-capped.test.ts b/src/lib/http/__tests__/read-capped.test.ts new file mode 100644 index 000000000..97d0b525f --- /dev/null +++ b/src/lib/http/__tests__/read-capped.test.ts @@ -0,0 +1,68 @@ +/** + * Bounded body reader (M3) — reject oversized bodies WITHOUT buffering them. + */ +import { describe, it, expect } from "vitest"; + +import { readBodyCapped } from "../read-capped"; + +function streamOf(chunks: Uint8Array[]): ReadableStream { + let i = 0; + return new ReadableStream({ + pull(controller) { + if (i < chunks.length) controller.enqueue(chunks[i++]); + else controller.close(); + }, + }); +} + +const enc = (s: string) => new TextEncoder().encode(s); + +describe("readBodyCapped", () => { + it("returns the decoded text under the cap", async () => { + const res = await readBodyCapped( + { headers: new Headers(), body: streamOf([enc("hello "), enc("world")]) }, + 1024, + ); + expect(res).toEqual({ ok: true, text: "hello world" }); + }); + + it("rejects up front on an oversized Content-Length without consuming the body", async () => { + const body = streamOf([enc("x")]); + let getReaderCalled = false; + const original = body.getReader.bind(body); + body.getReader = ((...args: unknown[]) => { + getReaderCalled = true; + // @ts-expect-error — passthrough spy + return original(...args); + }) as typeof body.getReader; + const res = await readBodyCapped( + { headers: new Headers({ "content-length": "999999" }), body }, + 16, + ); + expect(res).toEqual({ ok: false, reason: "too_large" }); + expect(getReaderCalled).toBe(false); + }); + + it("aborts mid-stream the moment the running total exceeds the cap", async () => { + let chunksPulled = 0; + const body = new ReadableStream({ + pull(c) { + chunksPulled++; + // 10 bytes per chunk; cap is 16, so the 2nd chunk overflows. + c.enqueue(enc("0123456789")); + }, + }); + const res = await readBodyCapped({ headers: new Headers(), body }, 16); + expect(res).toEqual({ ok: false, reason: "too_large" }); + // It must stop pulling once it overflows, not drain the (infinite) stream. + expect(chunksPulled).toBeLessThanOrEqual(2); + }); + + it("treats a null body as empty", async () => { + const res = await readBodyCapped( + { headers: new Headers(), body: null }, + 16, + ); + expect(res).toEqual({ ok: true, text: "" }); + }); +}); diff --git a/src/lib/http/read-capped.ts b/src/lib/http/read-capped.ts new file mode 100644 index 000000000..9db36911a --- /dev/null +++ b/src/lib/http/read-capped.ts @@ -0,0 +1,62 @@ +/** + * Bounded body reader (M3). + * + * `await res.text()` / `await request.text()` buffer the WHOLE body before any + * size check runs, so a hostile peer can stream (or decompress) gigabytes into + * memory for the duration of the request timeout before a post-hoc + * `raw.length > MAX` rejection fires. This helper enforces the cap WHILE + * reading: it rejects up front on an oversized `Content-Length`, then streams + * the body and aborts the moment the running byte count exceeds the ceiling. + * + * Returns `{ ok: false }` on overflow (the caller maps it to its own error); + * returns the decoded UTF-8 text on success. + */ +export type CappedReadResult = + | { ok: true; text: string } + | { ok: false; reason: "too_large" }; + +export async function readBodyCapped( + source: { headers: Headers; body: ReadableStream | null }, + maxBytes: number, +): Promise { + // Up-front reject on a declared oversized length — cheap and stops the read + // before a single byte is buffered. + const declared = source.headers.get("content-length"); + if (declared) { + const n = Number(declared); + if (Number.isFinite(n) && n > maxBytes) { + return { ok: false, reason: "too_large" }; + } + } + + const body = source.body; + if (!body) return { ok: true, text: "" }; + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > maxBytes) { + // Stop pulling immediately — do not buffer past the ceiling. + await reader.cancel().catch(() => {}); + return { ok: false, reason: "too_large" }; + } + chunks.push(value); + } + } finally { + reader.releaseLock?.(); + } + + const merged = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.byteLength; + } + return { ok: true, text: new TextDecoder("utf-8").decode(merged) }; +} diff --git a/src/lib/jobs/__tests__/mcp-token-cleanup-queue.test.ts b/src/lib/jobs/__tests__/mcp-token-cleanup-queue.test.ts new file mode 100644 index 000000000..6215ec22f --- /dev/null +++ b/src/lib/jobs/__tests__/mcp-token-cleanup-queue.test.ts @@ -0,0 +1,58 @@ +/** + * MCP Phase 3 (M2) — mcp-token-cleanup queue registration guard. + * + * Source-text-grep guard like the other queue-wiring checks: assert the queue + * is in `allQueues`, scheduled, and wired to a `boss.work` handler. An + * unregistered queue silently never drains (the recurring v1.4.37 dead-queue + * bug), which would leave expired MCP connector access tokens growing unbounded. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const REGISTRAR_PATH = join( + __dirname, + "..", + "reminder", + "register-maintenance.ts", +); +const workerSource = + readFileSync(REGISTRAR_PATH, "utf8") + + readFileSync( + join(__dirname, "..", "reminder", "cleanup-handlers.ts"), + "utf8", + ); + +describe("reminder-worker — mcp-token-cleanup wiring", () => { + it("imports the cleanup handler from the cleanup-handlers module", () => { + expect(workerSource).toMatch(/\bhandleMcpTokenCleanup\b/); + expect(workerSource).toMatch(/\bMcpTokenCleanupPayload\b/); + }); + + it("registers the mcp-token-cleanup queue in the allQueues loop", () => { + const allQueuesMatch = workerSource.match( + /const allQueues\s*=\s*\[([\s\S]*?)\];/, + ); + expect(allQueuesMatch).not.toBeNull(); + expect(allQueuesMatch![1]).toMatch(/\bMCP_TOKEN_CLEANUP_QUEUE\b/); + }); + + it("schedules the mcp-token-cleanup cron", () => { + expect(workerSource).toMatch( + /\[MCP_TOKEN_CLEANUP_QUEUE,\s*MCP_TOKEN_CLEANUP_CRON\]/, + ); + }); + + it("registers a boss.work handler for the queue", () => { + expect(workerSource).toMatch( + /boss\.work[\s\S]{0,200}MCP_TOKEN_CLEANUP_QUEUE[\s\S]{0,200}handleMcpTokenCleanup/, + ); + }); + + it("prunes MCP tokens inside the handler", () => { + expect(workerSource).toMatch( + /handleMcpTokenCleanup[\s\S]{0,400}cleanupExpiredMcpTokens/, + ); + }); +}); diff --git a/src/lib/jobs/mcp-token-cleanup.ts b/src/lib/jobs/mcp-token-cleanup.ts new file mode 100644 index 000000000..d5d5869a9 --- /dev/null +++ b/src/lib/jobs/mcp-token-cleanup.ts @@ -0,0 +1,55 @@ +/** + * Daily cleanup for the MCP OAuth surface (M2). + * + * Every `authorization_code` exchange and every hourly refresh mints a fresh + * 60-minute `hlk_` access-token row through the bridge. Nothing deleted the + * expired/revoked rows, so a single connection refreshing hourly left ~720 dead + * rows per month, forever — unbounded table growth, and (before the connection + * model) a flooded connector list. + * + * This sweep hard-deletes MCP-connector access tokens that are past a short + * grace window after expiry, or revoked. It is scoped to `health:read`-only + * connector rows so it never touches native-client or wildcard tokens. Long- + * revoked connection anchors are pruned too once no access token references + * them, keeping the `mcp_oauth_connections` table bounded. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { SCOPE_HEALTH_READ } from "@/lib/mcp/oauth/config"; + +/** Grace window kept after expiry before a dead access row is deleted. */ +const ACCESS_TOKEN_GRACE_MS = 24 * 60 * 60 * 1000; +/** Revoked connections older than this are pruned (kept briefly for audit). */ +const CONNECTION_PRUNE_MS = 30 * 24 * 60 * 60 * 1000; + +export interface McpTokenCleanupResult { + accessTokensDeleted: number; + connectionsDeleted: number; +} + +export async function cleanupExpiredMcpTokens( + prisma: PrismaClient, +): Promise { + const now = Date.now(); + const expiryCutoff = new Date(now - ACCESS_TOKEN_GRACE_MS); + const connectionCutoff = new Date(now - CONNECTION_PRUNE_MS); + + // Connector access tokens only: permissions is exactly `["health:read"]`. + const access = await prisma.apiToken.deleteMany({ + where: { + permissions: { equals: [SCOPE_HEALTH_READ] }, + OR: [{ revoked: true }, { expiresAt: { lt: expiryCutoff } }], + }, + }); + + // Prune long-revoked connection anchors. SetNull on the access-token FK means + // any lingering linked rows simply lose the pointer; the predicate keeps the + // window generous so an in-flight rotation never races a delete. + const connections = await prisma.mcpOAuthConnection.deleteMany({ + where: { revokedAt: { lt: connectionCutoff } }, + }); + + return { + accessTokensDeleted: access.count, + connectionsDeleted: connections.count, + }; +} diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts index 9ae4568b3..e6530188b 100644 --- a/src/lib/jobs/reminder/cleanup-handlers.ts +++ b/src/lib/jobs/reminder/cleanup-handlers.ts @@ -11,6 +11,7 @@ import { cleanupExpiredWhoopOAuthStates, } from "@/lib/jobs/whoop-oauth-state-cleanup"; import { cleanupExpiredIdempotencyKeys } from "@/lib/jobs/idempotency-cleanup"; +import { cleanupExpiredMcpTokens } from "@/lib/jobs/mcp-token-cleanup"; import { cleanupOldAuditLogs } from "@/lib/jobs/audit-log-cleanup"; import { cleanupOldCoachMessages } from "@/lib/jobs/coach-message-cleanup"; import { cleanupExpiredWithingsOAuthStates } from "@/lib/jobs/withings-oauth-state-cleanup"; @@ -221,6 +222,32 @@ export async function handleCoachMessageCleanup( }); } +export interface McpTokenCleanupPayload { + triggeredAt: string; +} + +export async function handleMcpTokenCleanup( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.mcp_token_cleanup", async (evt) => { + const p = getWorkerPrisma(); + try { + const result = await cleanupExpiredMcpTokens(p); + evt.addMeta( + "mcp_token_cleanup_access_deleted", + result.accessTokensDeleted, + ); + evt.addMeta( + "mcp_token_cleanup_connections_deleted", + result.connectionsDeleted, + ); + } catch (err) { + evt.addWarning(`mcp-token-cleanup failed: ${err}`); + } + }); +} + export async function handleWithingsOAuthStateCleanup( jobs: Job[], ) { diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index e003d1d2a..ba765c5ea 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -79,6 +79,8 @@ import { handleAuditLogCleanup, CoachMessageCleanupPayload, handleCoachMessageCleanup, + McpTokenCleanupPayload, + handleMcpTokenCleanup, } from "./cleanup-handlers"; import { HostMetricSamplePayload, @@ -168,6 +170,14 @@ const MEASUREMENT_TOMBSTONE_CLEANUP_CRON = "40 3 * * *"; const COACH_MESSAGE_CLEANUP_QUEUE = "coach-message-cleanup"; const COACH_MESSAGE_CLEANUP_CRON = "50 3 * * *"; +// MCP Phase 3 (M2) — daily prune of expired/revoked MCP connector access +// tokens (every code exchange + hourly refresh mints a 60-minute row) and +// long-revoked OAuth connection anchors. Slots at 03:55 Europe/Berlin after +// the coach-message cleanup (03:50), inside the existing 03:xx window. + +const MCP_TOKEN_CLEANUP_QUEUE = "mcp-token-cleanup"; + +const MCP_TOKEN_CLEANUP_CRON = "55 3 * * *"; const allQueues = [ DATA_BACKUP_QUEUE, @@ -209,6 +219,10 @@ const allQueues = [ // the daily schedule silently no-ops and the encrypted coach_messages // table grows unbounded. COACH_MESSAGE_CLEANUP_QUEUE, + // MCP Phase 3 (M2) — expired/revoked MCP access-token + connection prune. + // Without this entry the daily schedule silently no-ops and the api_tokens + // table grows unbounded with dead 60-minute connector rows. + MCP_TOKEN_CLEANUP_QUEUE, ]; const schedules: ScheduleEntry[] = [ @@ -260,6 +274,8 @@ const schedules: ScheduleEntry[] = [ [MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE, MEASUREMENT_TOMBSTONE_CLEANUP_CRON], // v1.18.7 — daily 03:50 Europe/Berlin prune for stale Coach history. [COACH_MESSAGE_CLEANUP_QUEUE, COACH_MESSAGE_CLEANUP_CRON], + // MCP Phase 3 (M2) — daily 03:55 Europe/Berlin prune for dead MCP tokens. + [MCP_TOKEN_CLEANUP_QUEUE, MCP_TOKEN_CLEANUP_CRON], ]; /** @@ -349,6 +365,13 @@ export async function registerMaintenanceQueues( { localConcurrency: 1 }, handleCoachMessageCleanup, ); + // MCP Phase 3 (M2) — daily prune of dead MCP connector access tokens + + // long-revoked connection anchors. Single-flight like every other cleanup. + await boss.work( + MCP_TOKEN_CLEANUP_QUEUE, + { localConcurrency: 1 }, + handleMcpTokenCleanup, + ); await boss.work( PR_DETECTION_QUEUE, { localConcurrency: PR_DETECTION_CONCURRENCY }, diff --git a/src/lib/logging/event-builder.ts b/src/lib/logging/event-builder.ts index 1b6204d62..26bb8b309 100644 --- a/src/lib/logging/event-builder.ts +++ b/src/lib/logging/event-builder.ts @@ -164,6 +164,11 @@ export class WideEventBuilder { return this.highestLevel; } + /** The HTTP method of the current request, if one was attached. */ + getHttpMethod(): string | undefined { + return this.event.http?.method; + } + getRequestId(): string { return this.event.request_id!; } diff --git a/src/lib/mcp/oauth/__tests__/connections.test.ts b/src/lib/mcp/oauth/__tests__/connections.test.ts new file mode 100644 index 000000000..c9223fa9d --- /dev/null +++ b/src/lib/mcp/oauth/__tests__/connections.test.ts @@ -0,0 +1,195 @@ +/** + * MCP OAuth connection anchor — H2 revocation + reuse-detection. + * + * These pin the security contract the stateless refresh artifact alone cannot + * provide: a revoked connection stops every future refresh, a replayed + * (already-rotated) jti revokes the whole connection, and a settings revoke + * terminates the chain and kills the connection's access tokens. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + mcpOAuthConnection: { + create: vi.fn(), + findUnique: vi.fn(), + updateMany: vi.fn(), + findMany: vi.fn(), + }, + apiToken: { + updateMany: vi.fn(), + }, + }, +})); + +import { prisma } from "@/lib/db"; +import { + createConnection, + rotateConnection, + revokeConnectionForUser, +} from "../connections"; + +const CONN = { + id: "conn-1", + userId: "user-1", + clientId: "https://app.example/client.json", + currentJti: "jti-current", + revokedAt: null as Date | null, +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({ + count: 0, + } as never); + vi.mocked(prisma.mcpOAuthConnection.updateMany).mockResolvedValue({ + count: 1, + } as never); +}); + +describe("createConnection", () => { + it("seeds currentJti with the first refresh jti", async () => { + vi.mocked(prisma.mcpOAuthConnection.create).mockResolvedValue({ + id: "conn-1", + } as never); + const id = await createConnection({ + userId: "user-1", + clientId: CONN.clientId, + clientName: "Claude", + scope: "health:read offline_access", + resource: "https://health.example/mcp", + jti: "jti-1", + }); + expect(id).toBe("conn-1"); + expect(prisma.mcpOAuthConnection.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ currentJti: "jti-1" }), + }), + ); + }); +}); + +describe("rotateConnection — happy path", () => { + it("advances currentJti and revokes prior access tokens (L4)", async () => { + vi.mocked(prisma.mcpOAuthConnection.findUnique).mockResolvedValue({ + ...CONN, + } as never); + const out = await rotateConnection({ + connectionId: "conn-1", + presentedJti: "jti-current", + newJti: "jti-next", + clientId: CONN.clientId, + userId: "user-1", + }); + expect(out.ok).toBe(true); + expect(prisma.mcpOAuthConnection.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ currentJti: "jti-current" }), + data: expect.objectContaining({ currentJti: "jti-next" }), + }), + ); + // L4 — prior access tokens revoked on rotation. + expect(prisma.apiToken.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { mcpConnectionId: "conn-1", revoked: false }, + data: { revoked: true }, + }), + ); + }); +}); + +describe("rotateConnection — revoked connection stops refresh", () => { + it("rejects when the connection is revoked", async () => { + vi.mocked(prisma.mcpOAuthConnection.findUnique).mockResolvedValue({ + ...CONN, + revokedAt: new Date(), + } as never); + const out = await rotateConnection({ + connectionId: "conn-1", + presentedJti: "jti-current", + newJti: "jti-next", + clientId: CONN.clientId, + userId: "user-1", + }); + expect(out).toEqual({ ok: false, reason: "revoked" }); + expect(prisma.mcpOAuthConnection.updateMany).not.toHaveBeenCalled(); + }); +}); + +describe("rotateConnection — reuse detection revokes the family", () => { + it("revokes the connection when a stale jti is presented", async () => { + vi.mocked(prisma.mcpOAuthConnection.findUnique).mockResolvedValue({ + ...CONN, + currentJti: "jti-rotated-already", + } as never); + const out = await rotateConnection({ + connectionId: "conn-1", + presentedJti: "jti-old", // not the current jti → replay + newJti: "jti-next", + clientId: CONN.clientId, + userId: "user-1", + }); + expect(out).toEqual({ ok: false, reason: "reuse_detected" }); + // The whole connection is revoked + its access tokens killed. + expect(prisma.mcpOAuthConnection.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "conn-1", revokedAt: null }, + data: expect.objectContaining({ revokedAt: expect.any(Date) }), + }), + ); + expect(prisma.apiToken.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { mcpConnectionId: "conn-1", revoked: false }, + }), + ); + }); + + it("rejects a connection owned by another user", async () => { + vi.mocked(prisma.mcpOAuthConnection.findUnique).mockResolvedValue({ + ...CONN, + userId: "someone-else", + } as never); + const out = await rotateConnection({ + connectionId: "conn-1", + presentedJti: "jti-current", + newJti: "jti-next", + clientId: CONN.clientId, + userId: "user-1", + }); + expect(out).toEqual({ ok: false, reason: "not_found" }); + }); +}); + +describe("revokeConnectionForUser — settings revoke kills the chain", () => { + it("revokes a connection owned by the user and its access tokens", async () => { + vi.mocked(prisma.mcpOAuthConnection.findUnique).mockResolvedValue({ + id: "conn-1", + userId: "user-1", + revokedAt: null, + } as never); + const ok = await revokeConnectionForUser("user-1", "conn-1"); + expect(ok).toBe(true); + expect(prisma.mcpOAuthConnection.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ revokedAt: expect.any(Date) }), + }), + ); + expect(prisma.apiToken.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { mcpConnectionId: "conn-1", revoked: false }, + data: { revoked: true }, + }), + ); + }); + + it("refuses to revoke another user's connection", async () => { + vi.mocked(prisma.mcpOAuthConnection.findUnique).mockResolvedValue({ + id: "conn-1", + userId: "someone-else", + revokedAt: null, + } as never); + const ok = await revokeConnectionForUser("user-1", "conn-1"); + expect(ok).toBe(false); + expect(prisma.mcpOAuthConnection.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/mcp/oauth/__tests__/oauth-bridge.test.ts b/src/lib/mcp/oauth/__tests__/oauth-bridge.test.ts index e923dc7fa..ce94e51c3 100644 --- a/src/lib/mcp/oauth/__tests__/oauth-bridge.test.ts +++ b/src/lib/mcp/oauth/__tests__/oauth-bridge.test.ts @@ -149,6 +149,35 @@ describe("clients — CIMD via safeFetch (SSRF-safe)", () => { const res = await resolveClient("https://169.254.169.254/meta.json"); expect(res).toEqual({ ok: false, reason: "ssrf_blocked" }); }); + + it("rejects a CIMD doc with a non-https / non-loopback redirect (L2)", async () => { + const clientId = "https://app.example/mcp-client.json"; + vi.mocked(safeFetch).mockResolvedValue( + new Response( + JSON.stringify({ + client_id: clientId, + client_name: "Sketchy", + redirect_uris: ["http://evil.example/cb"], // non-loopback http + }), + { status: 200 }, + ), + ); + const res = await resolveClient(clientId); + expect(res).toEqual({ ok: false, reason: "invalid_metadata" }); + }); + + it("rejects an oversized CIMD body via the streaming cap (M3)", async () => { + const clientId = "https://app.example/mcp-client.json"; + // Declared Content-Length over the 16 KB cap → rejected before reading. + vi.mocked(safeFetch).mockResolvedValue( + new Response("x".repeat(64), { + status: 200, + headers: { "content-length": String(64 * 1024) }, + }), + ); + const res = await resolveClient(clientId); + expect(res).toEqual({ ok: false, reason: "invalid_metadata" }); + }); }); describe("clients — redirect URI matching", () => { diff --git a/src/lib/mcp/oauth/clients.ts b/src/lib/mcp/oauth/clients.ts index 74bcf2624..01717c1c1 100644 --- a/src/lib/mcp/oauth/clients.ts +++ b/src/lib/mcp/oauth/clients.ts @@ -23,6 +23,7 @@ * ordinary registered entry. */ import { safeFetch } from "@/lib/safe-fetch"; +import { readBodyCapped } from "@/lib/http/read-capped"; import { ARTIFACT_KINDS, signArtifact, verifyArtifact } from "./artifacts"; export interface ResolvedClient { @@ -55,6 +56,30 @@ function asStringArray(value: unknown): string[] | null { return out; } +const REDIRECT_LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "::1", "[::1]"]; + +/** + * A redirect URI must be an absolute HTTPS URL or an `http` loopback URL. This + * is the single source of truth shared by DCR (the `register` route) and CIMD + * (L2): both registration paths enforce the identical https/loopback floor. + */ +export 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:" && + REDIRECT_LOOPBACK_HOSTS.includes(url.hostname) + ) { + return true; + } + return false; +} + /** A `client_id` that is an HTTPS URL is a CIMD client (SEP-991). */ export function isCimdClientId(clientId: string): boolean { try { @@ -131,13 +156,16 @@ async function resolveCimdClient(clientId: string): Promise { } if (!res.ok) return { ok: false, reason: "unknown_client" }; - const raw = await res.text(); - if (raw.length > CIMD_MAX_BYTES) { + // M3 — enforce the byte cap WHILE reading: reject up front on an oversized + // Content-Length and abort the stream the moment it overflows, so a hostile + // CIMD host cannot buffer gigabytes into memory before a post-hoc check. + const read = await readBodyCapped(res, CIMD_MAX_BYTES); + if (!read.ok) { return { ok: false, reason: "invalid_metadata" }; } let doc: Record; try { - doc = JSON.parse(raw); + doc = JSON.parse(read.text); } catch { return { ok: false, reason: "invalid_metadata" }; } @@ -148,6 +176,10 @@ async function resolveCimdClient(clientId: string): Promise { } const redirectUris = asStringArray(doc.redirect_uris); if (!redirectUris) return { ok: false, reason: "invalid_metadata" }; + // L2 — CIMD redirect URIs get the same https/loopback floor DCR enforces. + if (!redirectUris.every(isAllowableRedirectUri)) { + return { ok: false, reason: "invalid_metadata" }; + } return { ok: true, diff --git a/src/lib/mcp/oauth/config.ts b/src/lib/mcp/oauth/config.ts index b36eaec5b..d0597af59 100644 --- a/src/lib/mcp/oauth/config.ts +++ b/src/lib/mcp/oauth/config.ts @@ -15,9 +15,49 @@ * `resource` value matches the user-entered URL (a Claude.ai requirement). */ +/** + * Thrown when the MCP/OAuth surface is asked to resolve its canonical origin but + * neither `APP_URL` nor `NEXT_PUBLIC_APP_URL` is configured. The surface fails + * closed in that case (M1): without an operator-pinned origin the issuer, + * endpoint URLs, and RFC 8707 audience would be derived from the attacker- + * influenceable `Host` header, so we refuse to serve rather than trust it. + */ +export class McpOriginNotConfiguredError extends Error { + constructor() { + super( + "APP_URL (or NEXT_PUBLIC_APP_URL) must be set to serve the MCP/OAuth surface", + ); + this.name = "McpOriginNotConfiguredError"; + } +} + +/** The operator-configured origin, env-only — `Host` is never trusted. */ +function configuredOrigin(): string | null { + for (const value of [process.env.APP_URL, process.env.NEXT_PUBLIC_APP_URL]) { + const trimmed = value?.trim(); + if (!trimmed) continue; + try { + return new URL(trimmed).origin; + } catch { + // try the next candidate + } + } + return null; +} + +/** Whether the operator has pinned a canonical origin (M1 fail-closed gate). */ +export function isMcpOriginConfigured(): boolean { + return configuredOrigin() !== null; +} + /** * Resolve the public origin of this deployment, e.g. `https://health.example`. - * Operator config wins; the request URL is the last resort. + * + * Operator config (`APP_URL` → `NEXT_PUBLIC_APP_URL`) is authoritative. The + * request URL is a last resort retained ONLY for non-security-bearing callers; + * every MCP/OAuth entry point first asserts `isMcpOriginConfigured()` and fails + * closed (M1), so on those paths the env value is always what is returned and + * the `Host`-derived fallback is never reached. */ export function resolveBaseOrigin(requestUrl?: string): string { const candidates = [ @@ -38,6 +78,17 @@ export function resolveBaseOrigin(requestUrl?: string): string { return "http://localhost:3000"; } +/** + * Like `resolveBaseOrigin` but fails closed: throws `McpOriginNotConfiguredError` + * when no operator origin is set. Every MCP/OAuth surface calls this so a + * missing `APP_URL` refuses service instead of trusting the `Host` header (M1). + */ +export function requireBaseOrigin(): string { + const origin = configuredOrigin(); + if (!origin) throw new McpOriginNotConfiguredError(); + return origin; +} + /** * 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 diff --git a/src/lib/mcp/oauth/connections.ts b/src/lib/mcp/oauth/connections.ts new file mode 100644 index 000000000..091143e01 --- /dev/null +++ b/src/lib/mcp/oauth/connections.ts @@ -0,0 +1,190 @@ +/** + * MCP OAuth connections — persistent, revocable, theft-detectable refresh + * state (Phase 3 security fix, H2). + * + * The bridge AS mints stateless `hlrt_` refresh artifacts (see `artifacts.ts`). + * On their own they reference no server row, so they could not be revoked and a + * replayed (already-rotated) artifact was not treated as theft. This module is + * the anchor the artifact lacked: one `McpOAuthConnection` row per connector + * connection that requested `offline_access`. + * + * - `createConnection` is called on the `authorization_code` exchange. It + * records the granted scope/audience and seeds `currentJti` with the jti of + * the first refresh artifact. + * - `rotateConnection` is called on every `refresh_token` exchange. It is the + * reuse-detection gate: a presented jti that is not the connection's + * `currentJti` is a replay of an already-rotated token → the connection is + * revoked (family revocation, mirroring `refresh-token.ts`) and the refresh + * fails. A revoked connection always fails. The happy path advances + * `currentJti` atomically (a `where currentJti = presented` guard so two + * concurrent refreshes cannot both win) and revokes the prior access tokens + * (L4). + * - `revokeConnection` is the kill switch the settings card flips: it stamps + * `revokedAt` and revokes every access token the connection ever issued, so + * a settings "revoke" terminates the whole refresh chain. + * + * The access tokens themselves stay ordinary `hlk_` `ApiToken` rows, linked + * back through `ApiToken.mcpConnectionId`. + */ +import { prisma } from "@/lib/db"; + +export interface ConnectionRotation { + /** The connection id (`cid`) embedded in the refresh artifact. */ + connectionId: string; + /** The jti presented in the refresh artifact being exchanged. */ + presentedJti: string; + /** The jti to advance the connection to (the new refresh artifact's jti). */ + newJti: string; + /** The client the refresh artifact claims — must match the stored client. */ + clientId: string; + /** The user the refresh artifact claims — must match the stored owner. */ + userId: string; +} + +export type RotationOutcome = + | { ok: true } + | { + ok: false; + reason: "not_found" | "revoked" | "reuse_detected" | "client_mismatch"; + }; + +/** Create the connection anchor for a freshly-granted `offline_access` flow. */ +export async function createConnection(args: { + userId: string; + clientId: string; + clientName: string; + scope: string; + resource: string; + jti: string; +}): Promise { + const row = await prisma.mcpOAuthConnection.create({ + data: { + userId: args.userId, + clientId: args.clientId, + clientName: args.clientName, + scope: args.scope, + resource: args.resource, + currentJti: args.jti, + }, + select: { id: true }, + }); + return row.id; +} + +/** + * Rotate a connection's refresh chain with reuse-detection. + * + * Returns `{ ok: true }` only when the presented jti is the connection's live + * `currentJti` and the connection is not revoked; on success `currentJti` is + * advanced to `newJti` and the connection's prior access tokens are revoked. + * A presented jti that does not match `currentJti` is a replay and revokes the + * connection before returning `reuse_detected`. + */ +export async function rotateConnection( + args: ConnectionRotation, +): Promise { + const connection = await prisma.mcpOAuthConnection.findUnique({ + where: { id: args.connectionId }, + select: { + id: true, + userId: true, + clientId: true, + currentJti: true, + revokedAt: true, + }, + }); + + if (!connection || connection.userId !== args.userId) { + return { ok: false, reason: "not_found" }; + } + if (connection.clientId !== args.clientId) { + return { ok: false, reason: "client_mismatch" }; + } + if (connection.revokedAt) { + return { ok: false, reason: "revoked" }; + } + if (connection.currentJti !== args.presentedJti) { + // Replay of an already-rotated refresh token — treat as theft and revoke + // the whole connection (reuse-detection family revocation). + await revokeConnectionRow(connection.id); + return { ok: false, reason: "reuse_detected" }; + } + + // Advance the chain atomically: only the caller presenting the current jti + // wins, so two concurrent refreshes cannot both rotate the same row. + const advanced = await prisma.mcpOAuthConnection.updateMany({ + where: { + id: connection.id, + currentJti: args.presentedJti, + revokedAt: null, + }, + data: { currentJti: args.newJti, lastUsedAt: new Date() }, + }); + if (advanced.count === 0) { + return { ok: false, reason: "reuse_detected" }; + } + + // L4 — revoke the access tokens minted before this rotation so a leaked + // 60-minute access token cannot outlive the refresh that spawned it. + await prisma.apiToken.updateMany({ + where: { mcpConnectionId: connection.id, revoked: false }, + data: { revoked: true }, + }); + + return { ok: true }; +} + +/** Internal — stamp `revokedAt` and revoke every linked access token. */ +async function revokeConnectionRow(connectionId: string): Promise { + await prisma.mcpOAuthConnection.updateMany({ + where: { id: connectionId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + await prisma.apiToken.updateMany({ + where: { mcpConnectionId: connectionId, revoked: false }, + data: { revoked: true }, + }); +} + +/** + * Revoke a connection on behalf of its owner (settings "revoke"). Ownership is + * enforced so a connection id alone cannot revoke another user's connection. + * Returns true when a live connection owned by the user was revoked. + */ +export async function revokeConnectionForUser( + userId: string, + connectionId: string, +): Promise { + const connection = await prisma.mcpOAuthConnection.findUnique({ + where: { id: connectionId }, + select: { id: true, userId: true, revokedAt: true }, + }); + if (!connection || connection.userId !== userId) return false; + await revokeConnectionRow(connection.id); + return true; +} + +export interface ConnectionSummary { + id: string; + clientName: string; + scope: string; + createdAt: Date; + lastUsedAt: Date | null; +} + +/** The user's live (non-revoked) connector connections for the settings list. */ +export async function listConnectionsForUser( + userId: string, +): Promise { + return prisma.mcpOAuthConnection.findMany({ + where: { userId, revokedAt: null }, + select: { + id: true, + clientName: true, + scope: true, + createdAt: true, + lastUsedAt: true, + }, + orderBy: { createdAt: "desc" }, + }); +} diff --git a/src/lib/query-keys/integrations.ts b/src/lib/query-keys/integrations.ts index 0434b52d4..361372960 100644 --- a/src/lib/query-keys/integrations.ts +++ b/src/lib/query-keys/integrations.ts @@ -9,6 +9,10 @@ export const integrationKeys = { // 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, + // MCP Phase 3 — OAuth connector connections (the revocable unit for a remote + // client authorized through the OAuth bridge). Distinct from `mcpTokens()` so + // a connection revoke invalidates only the connection list. + mcpConnections: () => ["mcp-connections"] as const, telegram: () => ["telegram"] as const, telegramSettings: () => ["telegram", "settings"] as const, withings: () => ["withings"] as const, From cfffb693c3b5af618307e36f606901a035561b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 15:34:11 +0200 Subject: [PATCH 08/11] feat(mcp): add deep-value reads and a doctor-visit prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose four read-only relational tools and one prompt over the MCP wire, each a thin re-export of an existing server-authoritative engine — no new statistics, no new business logic: - get_correlation: the FDR-controlled, lag-aware association between two named metrics, selected out of the existing correlation discovery the Coach and the insight route already run. Descriptive, never causal. - compare_metric: two metrics over the same trailing window, or one metric across two trailing windows, computed over the WMY rollup readers with a delta only when both sides share a unit. - get_metric_baseline: where today's reading sits against the user's own usual range (median ± robust deviation) plus the strongest lagged driver, reusing the metric-page baseline builder. Honest-null below the learning floor — never a fabricated range. - detect_changepoints: level shifts over the rollup tier via a small in-repo CUSUM binary segmentation with a high firing bar. - doctor_visit_summary prompt: a structured, grounded visit-prep summary assembled from the doctor-report data path so the numbers match the exported PDF, with the grounding framing injected once — data and context only, no diagnosis or verdict. Every tool returns values, units, reference bands and provenance, declares read-only / non-destructive / closed-world annotations plus an output schema, and reports { present: false } for absence. All reads stay session-narrowed and read-only; the existing security posture is unchanged. --- src/app/mcp/__tests__/route.test.ts | 5 + src/lib/mcp/__tests__/prompts.test.ts | 188 +++++++ src/lib/mcp/__tests__/rich-reads.test.ts | 366 +++++++++++++ src/lib/mcp/__tests__/tools.test.ts | 16 +- src/lib/mcp/index.ts | 15 + src/lib/mcp/prompts.ts | 262 +++++++++ src/lib/mcp/rich-reads.ts | 671 +++++++++++++++++++++++ src/lib/mcp/server.ts | 24 + src/lib/mcp/tools.ts | 172 ++++++ 9 files changed, 1718 insertions(+), 1 deletion(-) create mode 100644 src/lib/mcp/__tests__/prompts.test.ts create mode 100644 src/lib/mcp/__tests__/rich-reads.test.ts create mode 100644 src/lib/mcp/prompts.ts create mode 100644 src/lib/mcp/rich-reads.ts diff --git a/src/app/mcp/__tests__/route.test.ts b/src/app/mcp/__tests__/route.test.ts index e9115ba4b..52784e2f9 100644 --- a/src/app/mcp/__tests__/route.test.ts +++ b/src/app/mcp/__tests__/route.test.ts @@ -76,6 +76,11 @@ const READ_TOOLS = [ "list_metrics", "search", "fetch", + // Phase 4 — deep-value reads. + "get_correlation", + "compare_metric", + "get_metric_baseline", + "detect_changepoints", ].sort(); /** A valid, narrow-scope (`health:read`) token resolution. */ diff --git a/src/lib/mcp/__tests__/prompts.test.ts b/src/lib/mcp/__tests__/prompts.test.ts new file mode 100644 index 000000000..92a6b1f74 --- /dev/null +++ b/src/lib/mcp/__tests__/prompts.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/doctor-report-data", () => ({ + collectDoctorReportData: vi.fn(), +})); + +import { MCP_PROMPTS, MCP_PROMPT_NAMES } from "../prompts"; +import { collectDoctorReportData } from "@/lib/doctor-report-data"; +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 prompt(name: string) { + const def = MCP_PROMPTS.find((p) => p.name === name); + if (!def) throw new Error(`prompt ${name} not registered`); + return def; +} + +/** Extract the JSON data block the prompt embeds in its user message. */ +function parseData(text: string): Record { + const marker = "JSON):\n"; + const idx = text.indexOf(marker); + if (idx < 0) throw new Error("no data block in prompt message"); + return JSON.parse(text.slice(idx + marker.length)); +} + +/** A minimal but representative doctor-report payload. */ +function reportFixture( + overrides: Record = {}, +): Record { + return { + period: { + days: 90, + since: "2026-03-29T00:00:00.000Z", + start: "2026-03-29T00:00:00.000Z", + end: "2026-06-27T00:00:00.000Z", + }, + patient: { + username: "tester", + fullName: "A Person", + dateOfBirth: "1990-01-01", + gender: "female", + heightCm: 170, + }, + practiceName: null, + measurements: {}, + stats: { + WEIGHT: { avg: 80.4, min: 79, max: 82, count: 30, latest: 80 }, + RESTING_HEART_RATE: { avg: 58, min: 52, max: 66, count: 45, latest: 60 }, + }, + glucoseStats: {}, + glucoseRanges: {}, + glucoseUnit: "mgdl", + glucoseClinical: { stillLearning: true, stillLearningReason: "thin" }, + bmi: 24.1, + compliance: { + Ramipril: { total: 90, taken: 86, skipped: 0, missed: 4 }, + }, + medications: [{ name: "Ramipril", dose: "5 mg", schedules: [] }], + mood: null, + ...overrides, + }; +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("MCP prompt registry — surface", () => { + it("registers the doctor_visit_summary prompt with an args schema", () => { + expect([...MCP_PROMPT_NAMES]).toContain("doctor_visit_summary"); + const def = prompt("doctor_visit_summary"); + expect(def.title).toBeTruthy(); + expect(def.description).toBeTruthy(); + expect(def.argsShape).toHaveProperty("window"); + }); +}); + +describe("doctor_visit_summary — grounding + structure", () => { + it("assembles a structured summary from the real doctor-report data", async () => { + vi.mocked(collectDoctorReportData).mockResolvedValue( + reportFixture() as never, + ); + + const res = await prompt("doctor_visit_summary").run(CTX, { + window: "last90days", + }); + + // One user message carrying framing + data. + expect(res.messages).toHaveLength(1); + expect(res.messages[0].role).toBe("user"); + const text = res.messages[0].content.text; + + // The window threads through to the data path (trailing 90 days). + expect(collectDoctorReportData).toHaveBeenCalledWith( + "user-1", + expect.objectContaining({ days: 90 }), + ); + + const data = parseData(text); + const vitals = data.vitals as Array>; + const weight = vitals.find((v) => v.metric === "WEIGHT")!; + const rhr = vitals.find((v) => v.metric === "RESTING_HEART_RATE")!; + + // Units + reference bands ride along, server-side. + expect(weight.unit).toBe("kg"); + expect(weight.referenceBand).toBeNull(); // no universal weight band + expect(rhr.unit).toBe("bpm"); + expect(rhr.referenceBand).toEqual({ low: 50, high: 100 }); + + // Values are the server-computed ones — no fabrication. + expect(weight.latest).toBe(80); + expect(rhr.avg).toBe(58); + + // Compliance is the ledger-derived adherence (86/90 → 96 %). + const compliance = data.compliance as Array>; + expect(compliance[0].adherencePct).toBe(96); + }); + + it("injects the grounding framing — no diagnosis, no invented values", async () => { + vi.mocked(collectDoctorReportData).mockResolvedValue( + reportFixture() as never, + ); + const res = await prompt("doctor_visit_summary").run(CTX, {}); + const text = res.messages[0].content.text; + expect(text).toMatch(/server-side/i); + expect(text).toMatch(/do not.*diagnos/i); + expect(text).toMatch(/do not (invent|estimate|infer)/i); + }); + + it("omits the glucose panel while still learning and mood when absent", async () => { + vi.mocked(collectDoctorReportData).mockResolvedValue( + reportFixture() as never, + ); + const data = parseData( + (await prompt("doctor_visit_summary").run(CTX, {})).messages[0].content + .text, + ); + expect(data).not.toHaveProperty("glucosePanel"); // stillLearning → withheld + expect(data).not.toHaveProperty("mood"); // mood null + }); + + it("includes labs with reference ranges and caps the list (token budget)", async () => { + const labResults = Array.from({ length: 60 }, (_, i) => ({ + panel: null, + analyte: `Analyte-${i}`, + value: i, + valueText: null, + unit: "mg/dL", + referenceLow: 0, + referenceHigh: 100, + takenAt: "2026-06-01T00:00:00.000Z", + count: 1, + })); + vi.mocked(collectDoctorReportData).mockResolvedValue( + reportFixture({ labResults }) as never, + ); + const data = parseData( + (await prompt("doctor_visit_summary").run(CTX, {})).messages[0].content + .text, + ); + const labs = data.labs as Array>; + expect(labs.length).toBe(50); // capped from 60 + expect(labs[0]).toHaveProperty("referenceHigh", 100); + }); + + it("does not leak patient name into the summary", async () => { + vi.mocked(collectDoctorReportData).mockResolvedValue( + reportFixture() as never, + ); + const data = parseData( + (await prompt("doctor_visit_summary").run(CTX, {})).messages[0].content + .text, + ); + const patient = data.patient as Record; + expect(patient).not.toHaveProperty("fullName"); + expect(patient).not.toHaveProperty("username"); + expect(patient.dateOfBirth).toBe("1990-01-01"); + }); +}); diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts new file mode 100644 index 000000000..077449238 --- /dev/null +++ b/src/lib/mcp/__tests__/rich-reads.test.ts @@ -0,0 +1,366 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// --- Engine mocks hoisted before importing the module under test. --- +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({ + readCoachCorrelations: vi.fn(), +})); +vi.mock("@/lib/insights/derived/coach-read", () => ({ + buildCoachReadStrip: vi.fn(), +})); +// Keep the real `aggregateWmyBuckets` (pure linear composition); stub only the +// DB-backed reader so the rich reads compute over a synthetic bucket sequence. +vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, readBestGranularityRollups: vi.fn() }; +}); + +import { + getCorrelation, + compareMetric, + getMetricBaseline, + detectChangepoints, + resolveRichMetric, +} from "../rich-reads"; +import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; +import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; +import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy"; + +const USER = "user-1"; + +/** Build a rollup bucket row with just the fields the rich reads consume. */ +function row(bucketStart: string, mean: number) { + return { + bucketStart: new Date(bucketStart), + count: 1, + mean, + sd: null, + slope: null, + r2: null, + sumValue: null, + minValue: mean, + maxValue: mean, + }; +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +// ── metric resolution ──────────────────────────────────────────────── +describe("resolveRichMetric", () => { + it("resolves a friendly alias to the registry type + unit + band", () => { + const m = resolveRichMetric("resting_hr"); + expect(m?.measurementType).toBe("RESTING_HEART_RATE"); + expect(m?.unit).toBe("bpm"); + expect(m?.band).toEqual({ low: 50, high: 100 }); + }); + + it("resolves the headline supplement metrics (weight has no universal band)", () => { + const weight = resolveRichMetric("weight"); + expect(weight?.measurementType).toBe("WEIGHT"); + expect(weight?.unit).toBe("kg"); + expect(weight?.band).toBeNull(); + expect(resolveRichMetric("pulse")?.band).toEqual({ low: 60, high: 100 }); + }); + + it("matches on display name", () => { + expect(resolveRichMetric("heart-rate variability")?.measurementType).toBe( + "HEART_RATE_VARIABILITY", + ); + }); + + it("returns null for an unknown metric (never invents a series)", () => { + expect(resolveRichMetric("bananas")).toBeNull(); + expect(resolveRichMetric("")).toBeNull(); + }); +}); + +// ── 1. get_correlation ─────────────────────────────────────────────── +describe("get_correlation", () => { + const drivers = [ + { + behaviour: "sleep duration", + outcome: "resting heart rate", + direction: "lower" as const, + lagDays: 1, + n: 60, + r: -0.42, + note: "Nights with more sleep tend to precede a lower next-day resting heart rate. Descriptive, not causal.", + }, + { + behaviour: "steps", + outcome: "weight", + direction: "lower" as const, + lagDays: 1, + n: 40, + r: -0.2, + note: "Descriptive only.", + }, + ]; + + it("returns the matched FDR-controlled pair with a descriptive (non-causal) note", async () => { + vi.mocked(readCoachCorrelations).mockResolvedValue({ + present: true, + drivers, + pairsTested: 24, + windowDays: 180, + }); + const res = await getCorrelation(USER, { + metricA: "sleep", + metricB: "resting heart rate", + }); + expect(res.present).toBe(true); + expect(res.pair?.behaviour).toBe("sleep duration"); + expect(res.pair?.outcome).toBe("resting heart rate"); + expect(res.pair?.r).toBe(-0.42); + expect(res.association).toBe("descriptive"); + expect(res.windowDays).toBe(180); + }); + + it("honest-null when the pair has no surviving association", async () => { + vi.mocked(readCoachCorrelations).mockResolvedValue({ + present: true, + drivers, + pairsTested: 24, + windowDays: 180, + }); + const res = await getCorrelation(USER, { + metricA: "glucose", + metricB: "hrv", + }); + expect(res.present).toBe(false); + expect(res.reason).toBe("no_significant_pattern_for_pair"); + expect(res.pair).toBeUndefined(); + }); + + it("honest-null when the engine surfaces nothing (sparse data)", async () => { + vi.mocked(readCoachCorrelations).mockResolvedValue({ + present: false, + reason: "no_significant_pattern", + }); + const res = await getCorrelation(USER, { + metricA: "sleep", + metricB: "weight", + }); + expect(res.present).toBe(false); + expect(res.reason).toBe("no_significant_pattern"); + }); +}); + +// ── 2. compare_metric ──────────────────────────────────────────────── +describe("compare_metric", () => { + it("compares two metrics over the same window and computes a delta when units match", async () => { + vi.mocked(readBestGranularityRollups) + .mockResolvedValueOnce({ + granularity: "DAY", + rows: [row("2026-06-01", 60), row("2026-06-02", 62)], + }) + .mockResolvedValueOnce({ + granularity: "DAY", + rows: [row("2026-06-01", 70), row("2026-06-02", 72)], + }); + const res = await compareMetric(USER, { + metric: "resting_hr", + metricB: "pulse", + window: "last30days", + }); + expect(res.present).toBe(true); + expect(res.mode).toBe("metric_vs_metric"); + expect(res.a?.mean).toBe(61); + expect(res.b?.mean).toBe(71); + // Both bpm → delta computed. + expect(res.delta?.mean).toBe(10); + expect(res.a?.unit).toBe("bpm"); + }); + + it("compares one metric across two trailing windows", async () => { + vi.mocked(readBestGranularityRollups) + .mockResolvedValueOnce({ + granularity: "DAY", + rows: [row("2026-06-01", 80), row("2026-06-02", 80)], + }) + .mockResolvedValueOnce({ + granularity: "WEEK", + rows: [row("2026-04-01", 82), row("2026-04-08", 84)], + }); + const res = await compareMetric(USER, { + metric: "weight", + window: "last30days", + windowB: "last90days", + }); + expect(res.present).toBe(true); + expect(res.mode).toBe("window_vs_window"); + expect(res.delta?.mean).toBe(3); // 83 - 80 + }); + + it("asks for a second metric or window when neither is given", async () => { + const res = await compareMetric(USER, { metric: "weight" }); + expect(res.present).toBe(false); + expect(res.reason).toBe("specify_metricB_or_windowB"); + }); + + it("honest-null on unknown metric", async () => { + const res = await compareMetric(USER, { + metric: "bananas", + metricB: "weight", + }); + expect(res.present).toBe(false); + expect(res.reason).toBe("unknown_metric"); + }); + + it("present:false when a side has no data", async () => { + vi.mocked(readBestGranularityRollups) + .mockResolvedValueOnce({ + granularity: "DAY", + rows: [row("2026-06-01", 80)], + }) + .mockResolvedValueOnce(null); + const res = await compareMetric(USER, { + metric: "weight", + window: "last30days", + windowB: "last90days", + }); + expect(res.present).toBe(false); + expect(res.reason).toBe("no_data"); + }); +}); + +// ── 3. get_metric_baseline ─────────────────────────────────────────── +describe("get_metric_baseline", () => { + it("returns the personal band + today's placement + reference band", async () => { + vi.mocked(buildCoachReadStrip).mockResolvedValue({ + baseline: { + low: 55, + high: 65, + latest: 70, + placement: "above", + sampleDays: 30, + }, + learning: false, + driver: { + note: "Descriptive only.", + behaviour: "sleep duration", + outcome: "resting heart rate", + }, + }); + const res = await getMetricBaseline(USER, { metric: "resting_hr" }); + expect(res.present).toBe(true); + expect(res.baseline).toEqual({ low: 55, high: 65, sampleDays: 30 }); + expect(res.latest).toBe(70); + expect(res.placement).toBe("above"); + expect(res.unit).toBe("bpm"); + expect(res.referenceBand).toEqual({ low: 50, high: 100 }); + }); + + it("honest-null below the learning floor (never a fabricated range)", async () => { + vi.mocked(buildCoachReadStrip).mockResolvedValue({ + baseline: null, + learning: true, + driver: null, + }); + const res = await getMetricBaseline(USER, { metric: "weight" }); + expect(res.present).toBe(false); + expect(res.reason).toBe("insufficient_history"); + // The personal band is absent — no invented numbers. + expect(res.baseline).toBeUndefined(); + }); + + it("honest-null on unknown metric", async () => { + const res = await getMetricBaseline(USER, { metric: "bananas" }); + expect(res.present).toBe(false); + expect(res.reason).toBe("unknown_metric"); + }); +}); + +// ── 4. detect_changepoints ─────────────────────────────────────────── +describe("detect_changepoints", () => { + it("detects a clear level shift over the rollup buckets", async () => { + // Twelve buckets: ~80 for six, then a clean jump to ~90 for six. + const rows = [ + row("2026-03-01", 80), + row("2026-03-02", 81), + row("2026-03-03", 79), + row("2026-03-04", 80), + row("2026-03-05", 81), + row("2026-03-06", 80), + row("2026-03-07", 90), + row("2026-03-08", 91), + row("2026-03-09", 89), + row("2026-03-10", 90), + row("2026-03-11", 91), + row("2026-03-12", 90), + ]; + vi.mocked(readBestGranularityRollups).mockResolvedValue({ + granularity: "DAY", + rows, + }); + const res = await detectChangepoints(USER, { + metric: "weight", + window: "last90days", + }); + expect(res.present).toBe(true); + expect(res.changepoints?.length).toBeGreaterThanOrEqual(1); + const cp = res.changepoints![0]; + expect(cp.direction).toBe("increase"); + expect(cp.at).toBe(new Date("2026-03-07").toISOString()); + expect(res.granularity).toBe("DAY"); + }); + + it("honest-null on a flat (noise-only) series", async () => { + const rows = Array.from({ length: 12 }, (_, i) => + row(`2026-03-${String(i + 1).padStart(2, "0")}`, 80 + (i % 2)), + ); + vi.mocked(readBestGranularityRollups).mockResolvedValue({ + granularity: "DAY", + rows, + }); + const res = await detectChangepoints(USER, { metric: "weight" }); + expect(res.present).toBe(false); + expect(res.reason).toBe("no_changepoint"); + expect(res.bucketsAnalysed).toBe(12); + }); + + it("honest-null when too few buckets exist", async () => { + vi.mocked(readBestGranularityRollups).mockResolvedValue({ + granularity: "DAY", + rows: [row("2026-03-01", 80), row("2026-03-02", 81)], + }); + const res = await detectChangepoints(USER, { metric: "weight" }); + expect(res.present).toBe(false); + expect(res.reason).toBe("insufficient_data"); + }); + + it("honest-null on unknown metric", async () => { + const res = await detectChangepoints(USER, { metric: "bananas" }); + expect(res.present).toBe(false); + expect(res.reason).toBe("unknown_metric"); + }); +}); + +// ── grounding posture ──────────────────────────────────────────────── +describe("grounding / no fabrication", () => { + it("no rich read returns a verdict / diagnosis / advice field", async () => { + vi.mocked(readCoachCorrelations).mockResolvedValue({ present: false }); + vi.mocked(buildCoachReadStrip).mockResolvedValue({ + baseline: null, + learning: true, + driver: null, + }); + vi.mocked(readBestGranularityRollups).mockResolvedValue(null); + + const results = [ + await getCorrelation(USER, { metricA: "sleep", metricB: "weight" }), + await compareMetric(USER, { metric: "weight", windowB: "last90days" }), + await getMetricBaseline(USER, { metric: "weight" }), + await detectChangepoints(USER, { metric: "weight" }), + ]; + for (const res of results) { + const keys = Object.keys(res); + expect(keys).not.toContain("verdict"); + expect(keys).not.toContain("diagnosis"); + expect(keys).not.toContain("advice"); + } + }); +}); diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 468152565..74e276571 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -21,6 +21,15 @@ vi.mock("@/lib/db", () => ({ labResult: { findMany: vi.fn(async () => []) }, }, })); +// Phase 4 — the deep-value reads delegate to the rich-reads engines; stub them +// so the registry-wide loops (surface / annotation / no-verdict) never reach a +// real engine. Their own logic is covered in `rich-reads.test.ts`. +vi.mock("../rich-reads", () => ({ + getCorrelation: vi.fn(async () => ({ present: true })), + compareMetric: vi.fn(async () => ({ present: true })), + getMetricBaseline: vi.fn(async () => ({ present: true })), + detectChangepoints: vi.fn(async () => ({ present: true })), +})); import { MCP_TOOLS, MCP_TOOL_NAMES } from "../tools"; import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; @@ -47,7 +56,7 @@ beforeEach(() => { }); describe("MCP tool registry — surface", () => { - it("registers exactly the Phase-1 read tools", () => { + it("registers exactly the read tools (Phase 1 + Phase 4)", () => { expect([...MCP_TOOL_NAMES].sort()).toEqual( [ "get_correlations", @@ -58,6 +67,11 @@ describe("MCP tool registry — surface", () => { // v1.22.0 — the ChatGPT default-mode retrieval pair. "search", "fetch", + // Phase 4 — deep-value reads. + "get_correlation", + "compare_metric", + "get_metric_baseline", + "detect_changepoints", ].sort(), ); }); diff --git a/src/lib/mcp/index.ts b/src/lib/mcp/index.ts index 82fc91438..da135b81b 100644 --- a/src/lib/mcp/index.ts +++ b/src/lib/mcp/index.ts @@ -15,6 +15,21 @@ export { MCP_RESOURCE_URIS, type McpResourceDefinition, } from "./resources"; +export { + MCP_PROMPTS, + MCP_PROMPT_NAMES, + type McpPromptDefinition, + type McpPromptResult, + type McpPromptMessage, +} from "./prompts"; +export { + getCorrelation, + compareMetric, + getMetricBaseline, + detectChangepoints, + resolveRichMetric, + type RichMetric, +} from "./rich-reads"; export { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE, diff --git a/src/lib/mcp/prompts.ts b/src/lib/mcp/prompts.ts new file mode 100644 index 000000000..f3a403c1a --- /dev/null +++ b/src/lib/mcp/prompts.ts @@ -0,0 +1,262 @@ +/** + * Phase 4 — MCP prompts (user-controlled templated workflows). + * + * Prompts are the slash-command surface a host exposes to the user. Unlike + * tools (model-controlled), a prompt is picked by the user and returns a set of + * messages pre-loaded with REAL, server-retrieved data plus the safety framing + * injected once, centrally (catalogue §5 #2, ADR-004). + * + * `doctor_visit_summary` is the showcase capability: it assembles a grounded, + * structured visit-prep summary from the SAME doctor-report data path + * (`collectDoctorReportData`) that backs the PDF the user already trusts — so + * the numbers match the export byte-for-byte. It carries the identical grounding + * discipline as the Coach: + * - only values the server computed (no fabricated readings); + * - reference / clinical bands are server-side, never invented by the model; + * - DATA + CONTEXT only — never a diagnosis, verdict, or treatment change. + * + * Token budget (R-DEL-2): the prompt SUMMARISES (latest + aggregate per metric, + * latest reading per analyte) — it never dumps the full per-reading history. + */ +import { z } from "zod/v4"; + +import { annotate } from "@/lib/logging/context"; +import { + collectDoctorReportData, + type DoctorReportData, +} from "@/lib/doctor-report-data"; +import { getMetricStatusMeta } from "@/lib/insights/metric-status-registry"; +import { coachScopeWindowSchema } from "@/lib/ai/coach/types"; +import type { CoachScopeWindow } from "@/lib/ai/coach/types"; +import type { McpAuthContext } from "./auth"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** Trailing-day count per window, capped at the doctor-report 365-day ceiling. */ +const WINDOW_DAYS: Record = { + last7days: 7, + last30days: 30, + last90days: 90, + lastYear: 365, + allTime: 365, +}; + +/** A returned prompt message — mirrors the SDK `GetPromptResult` shape. */ +export interface McpPromptMessage { + role: "user" | "assistant"; + content: { type: "text"; text: string }; +} + +export interface McpPromptResult { + description?: string; + messages: McpPromptMessage[]; +} + +/** A registry entry — transport-agnostic, mirroring `McpToolDefinition`. */ +export interface McpPromptDefinition { + name: string; + title: string; + description: string; + /** zod/v4 raw shape handed to the SDK's `registerPrompt` for arg validation. */ + argsShape: z.ZodRawShape; + run: ( + ctx: McpAuthContext, + args: Record, + ) => Promise; +} + +/** + * Fallback units for the headline specialised metric types the generic + * `metric-status-registry` intentionally omits (it carries the synced/additive + * metrics only). The doctor-report stats map is keyed by `MeasurementType`. + */ +const FALLBACK_UNITS: Record = { + WEIGHT: "kg", + BLOOD_PRESSURE_SYS: "mmHg", + BLOOD_PRESSURE_DIA: "mmHg", + PULSE: "bpm", + BODY_MASS_INDEX: "kg/m²", + SLEEP_DURATION: "min", +}; + +function unitAndBandFor(type: string): { + unit: string; + referenceBand: { low: number; high: number } | null; +} { + const meta = getMetricStatusMeta(type); + if (meta) { + return { + unit: meta.unit, + referenceBand: meta.normalRange + ? { low: meta.normalRange.low, high: meta.normalRange.high } + : null, + }; + } + return { unit: FALLBACK_UNITS[type] ?? "", referenceBand: null }; +} + +const round = (n: number): number => Math.round(n * 100) / 100; + +/** + * Reduce the full doctor-report payload to a compact, clinician-oriented + * summary. Latest + aggregate per metric, latest reading per analyte — never + * the full per-reading history (R-DEL-2). Every numeric value is one the server + * computed; every band is server-side. No verdict, no diagnosis. + */ +function summariseForVisit(data: DoctorReportData): Record { + const vitals = Object.entries(data.stats).map(([type, s]) => { + const { unit, referenceBand } = unitAndBandFor(type); + return { + metric: type, + unit, + referenceBand, + latest: round(s.latest), + avg: round(s.avg), + min: round(s.min), + max: round(s.max), + readings: s.count, + }; + }); + + const compliance = Object.entries(data.compliance).map(([name, c]) => ({ + medication: name, + takenDoses: c.taken, + expectedDoses: c.total, + missedDoses: c.missed, + adherencePct: + c.total > 0 ? Math.min(100, Math.round((c.taken / c.total) * 100)) : null, + })); + + const medications = data.medications + .slice(0, 40) + .map((m) => ({ name: m.name, dose: m.dose })); + + const labs = (data.labResults ?? []).slice(0, 50).map((l) => ({ + analyte: l.analyte, + panel: l.panel, + value: l.value, + valueText: l.valueText, + unit: l.unit, + referenceLow: l.referenceLow, + referenceHigh: l.referenceHigh, + takenAt: l.takenAt, + readings: l.count, + })); + + const wellness = (data.wellnessScores ?? []).map((w) => ({ + score: w.type, + latest: round(w.latest), + avg: round(w.avg), + note: "descriptive composite, not a clinical assessment", + })); + + const illness = (data.illnessEpisodes ?? []).map((e) => ({ + label: e.label, + type: e.type, + lifecycle: e.lifecycle, + onsetAt: e.onsetAt, + resolvedAt: e.resolvedAt, + })); + + return { + period: { + days: data.period.days, + start: data.period.start, + end: data.period.end, + }, + patient: { + dateOfBirth: data.patient.dateOfBirth, + sex: data.patient.gender, + heightCm: data.patient.heightCm, + }, + bmi: data.bmi, + vitals, + medications, + compliance, + // Mood is privacy-gated upstream; include only the aggregate when present. + ...(data.mood + ? { + mood: { + avg: round(data.mood.avg), + min: data.mood.min, + max: data.mood.max, + entries: data.mood.count, + }, + } + : {}), + // Glucose clinical panel only when the user logs glucose (stillLearning + // flags a too-thin period — surfaced honestly, never asserted). + ...(data.glucoseClinical && !data.glucoseClinical.stillLearning + ? { glucosePanel: data.glucoseClinical } + : {}), + ...(labs.length > 0 ? { labs } : {}), + ...(wellness.length > 0 ? { wellnessScores: wellness } : {}), + ...(illness.length > 0 ? { illnessEpisodes: illness } : {}), + }; +} + +/** + * The framing block prepended to the assembled data. Injected once, centrally: + * the assistant is told to organise + present, never to diagnose or invent. + */ +const VISIT_FRAMING = [ + "You are preparing a concise, factual summary to help the user get ready for a doctor's visit.", + "Use ONLY the structured data below — every value, unit, and reference band is computed server-side from the user's own records.", + "Do NOT invent, estimate, or infer any reading that is not present. Do NOT provide a diagnosis, clinical verdict, risk score, or treatment/medication change — those are the clinician's role.", + "Organise the data into clear sections (vitals, medications & adherence, labs, notable changes). Where a value sits outside its reference band, state the value and band factually; do not interpret it.", + "If a section is empty, say it was not recorded in this period rather than guessing.", +].join(" "); + +export const MCP_PROMPTS: McpPromptDefinition[] = [ + { + name: "doctor_visit_summary", + title: "Doctor visit summary", + description: + "Assemble a structured, grounded visit-prep summary from the user's own records over a window — vitals (with units + reference bands), medications & adherence, labs (with reference ranges), and notable context. Reuses the doctor-report data path so the numbers match the exported PDF. Data + context only: no diagnosis, no verdict.", + argsShape: { window: coachScopeWindowSchema.optional() }, + async run(ctx, args) { + const window = ( + typeof args.window === "string" ? args.window : "last90days" + ) as CoachScopeWindow; + const days = WINDOW_DAYS[window] ?? 90; + const end = new Date(); + const start = new Date(end.getTime() - days * MS_PER_DAY); + + const data = await collectDoctorReportData(ctx.userId, { + start, + end, + days, + }); + const summary = summariseForVisit(data); + + annotate({ + action: { name: "mcp.prompt.invoked" }, + meta: { + prompt: "doctor_visit_summary", + days, + vitals: Array.isArray(summary.vitals) ? summary.vitals.length : 0, + }, + }); + + return { + description: `Visit-prep summary over the last ${days} days, grounded in your own records.`, + messages: [ + { + role: "user", + content: { + type: "text", + text: `${VISIT_FRAMING}\n\nDATA (server-authoritative, JSON):\n${JSON.stringify( + summary, + )}`, + }, + }, + ], + }; + }, + }, +]; + +/** Stable list of the registered prompt names. */ +export const MCP_PROMPT_NAMES: readonly string[] = MCP_PROMPTS.map( + (p) => p.name, +); diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts new file mode 100644 index 000000000..0da7d7c66 --- /dev/null +++ b/src/lib/mcp/rich-reads.ts @@ -0,0 +1,671 @@ +/** + * Phase 4 — deep-value MCP read tools (engine glue). + * + * Four "bring data into relation" reads, each a THIN re-export of an existing + * server-authoritative engine — no new statistics, no new business logic + * (REQ-WONT-2 / catalogue §5): + * + * 1. get_correlation — one named driver pair out of the FDR-controlled, + * lag-aware discovery (`readCoachCorrelations`, the + * same engine the Coach `get_correlations` tool and + * the `/api/insights/correlations` route run). + * 2. compare_metric — two trailing-window or two-metric rollup snapshots + * side by side (`readBestGranularityRollups` + + * `aggregateWmyBuckets`) with structured deltas. + * 3. get_metric_baseline — the metric's personal usual range (median ± k·MAD) + * + today's placement, via `buildCoachReadStrip` + * (the same engine the metric-page "Coach read" strip + * renders). + * 4. detect_changepoints — level shifts in a metric over the rollup tier, via + * a minimal in-repo CUSUM binary-segmentation over the + * rollup bucket means (no heavy dependency, high + * firing bar). + * + * Grounding contract (REQ-SEC-2/3/4, ADR-004): every read returns structured + * values + units + reference bands + provenance and uses `{ present: false }` + * for absence / insufficiency — never a silent zero, never a prose verdict or + * diagnosis. Associations are described, never asserted as causal. `userId` is + * the session-narrowed id passed by the caller, never a tool argument + * (REQ-SEC-5). All reads are read-only. + */ +import type { MeasurementType } from "@/generated/prisma/client"; + +import { annotate } from "@/lib/logging/context"; +import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; +import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; +import { + readBestGranularityRollups, + aggregateWmyBuckets, +} from "@/lib/rollups/measurement-read-wmy"; +import { + getMetricStatusMeta, + METRIC_STATUS_IDS, +} from "@/lib/insights/metric-status-registry"; +import type { CoachScopeWindow } from "@/lib/ai/coach/types"; + +/** Trailing-day count for each window the rich reads accept. */ +const WINDOW_DAYS: Record = { + last7days: 7, + last30days: 30, + last90days: 90, + lastYear: 365, + // The rollup tier caps "all time" timelines at one year elsewhere; keep the + // rich reads bounded to the same ceiling so the token budget stays sane. + allTime: 365, +}; + +function windowToDays(window: CoachScopeWindow | undefined): number { + return WINDOW_DAYS[window ?? "last30days"]; +} + +// ── Metric resolution ──────────────────────────────────────────────── +// +// A scalar metric the rich reads can place on a single rollup series, carrying +// its canonical unit + population reference band (when a broadly-accepted one +// exists). Most entries are derived straight from the single-source +// `metric-status-registry`; a small explicit supplement covers the headline +// specialised metrics (weight / pulse / BMI) the registry intentionally omits. + +export interface RichMetric { + /** The DB `MeasurementType` the rollup tier + baseline engine read against. */ + measurementType: MeasurementType; + /** Human label for the resolved metric (provenance / narration). */ + label: string; + /** Canonical storage unit. */ + unit: string; + /** Population reference band, or `null` when no universal band exists. */ + band: { low: number; high: number } | null; +} + +/** Headline specialised metrics the status registry does not carry. */ +const SUPPLEMENT: Record = { + weight: { + measurementType: "WEIGHT", + label: "Weight", + unit: "kg", + // No universal healthy band — body-size dependent; defer to own baseline. + band: null, + }, + pulse: { + measurementType: "PULSE", + label: "Pulse", + unit: "bpm", + band: { low: 60, high: 100 }, + }, + bmi: { + measurementType: "BODY_MASS_INDEX", + label: "Body-mass index", + unit: "kg/m²", + band: { low: 18.5, high: 25 }, + }, +}; + +/** Friendly aliases (Coach source slugs + natural phrasings) → registry id. */ +const ALIASES: Record = { + resting_hr: "RESTING_HEART_RATE", + resting_heart_rate: "RESTING_HEART_RATE", + hrv: "HEART_RATE_VARIABILITY", + spo2: "OXYGEN_SATURATION", + blood_oxygen: "OXYGEN_SATURATION", + vo2_max: "VO2_MAX", + vo2max: "VO2_MAX", + steps: "STEPS", + glucose: "BLOOD_GLUCOSE", + blood_glucose: "BLOOD_GLUCOSE", + sleep: "SLEEP_DURATION", + sleep_duration: "SLEEP_DURATION", + distance: "WALKING_RUNNING_DISTANCE", + active_energy: "ACTIVE_ENERGY", + body_temp: "BODY_TEMPERATURE", + body_temperature: "BODY_TEMPERATURE", + daylight: "TIME_IN_DAYLIGHT", + respiratory_rate: "RESPIRATORY_RATE", + walking_hr: "WALKING_HEART_RATE_AVERAGE", +}; + +function fromRegistry(id: string): RichMetric | null { + const meta = getMetricStatusMeta(id); + if (!meta) return null; + return { + measurementType: meta.measurementType, + label: meta.displayName, + unit: meta.unit, + band: meta.normalRange + ? { low: meta.normalRange.low, high: meta.normalRange.high } + : null, + }; +} + +/** + * Resolve a free-text metric name to a single scalar series. Forgiving for an + * NL assistant (alias, exact id, display-name match) but closed — an + * unresolved name returns `null` so the tool reports `{ present: false }` + * rather than inventing a series. Multi-series metrics (blood pressure) are not + * resolvable here by design; the prompt + `get_metric_series` cover BP. + */ +export function resolveRichMetric(input: string): RichMetric | null { + const raw = input.trim(); + if (!raw) return null; + const key = raw.toLowerCase().replace(/[\s-]+/g, "_"); + + // 1. explicit supplement (weight / pulse / bmi). + if (SUPPLEMENT[key]) return SUPPLEMENT[key]; + + // 2. friendly alias → registry id. + const aliased = ALIASES[key]; + if (aliased) return fromRegistry(aliased); + + // 3. exact registry id (case-insensitive). + const upper = key.toUpperCase(); + if ((METRIC_STATUS_IDS as readonly string[]).includes(upper)) { + const direct = fromRegistry(upper); + if (direct) return direct; + } + + // 4. display-name match (e.g. "heart-rate variability"). + const needle = raw.toLowerCase(); + for (const id of METRIC_STATUS_IDS) { + const meta = getMetricStatusMeta(id); + if (!meta) continue; + const name = meta.displayName.toLowerCase(); + if (name === needle || name.includes(needle) || needle.includes(name)) { + return fromRegistry(id); + } + } + return null; +} + +// ── 1. get_correlation ─────────────────────────────────────────────── + +export interface CorrelationResult { + present: boolean; + reason?: string; + /** The matched driver pair (descriptive, never causal). */ + pair?: { + behaviour: string; + outcome: string; + direction: "higher" | "lower"; + lagDays: number; + n: number; + r: number; + note: string; + }; + /** Honest footer — pairs tested + window the discovery scanned. */ + pairsTested?: number; + windowDays?: number; + /** Constant marker so the model never re-frames the link as causal. */ + association?: "descriptive"; +} + +function norm(label: string): string { + return label.trim().toLowerCase(); +} + +/** True when `a`/`b` (either order) match the driver's behaviour/outcome. */ +function pairMatches( + behaviour: string, + outcome: string, + a: string, + b: string, +): boolean { + const contains = (hay: string, needle: string) => + hay.includes(needle) || needle.includes(hay); + return ( + (contains(behaviour, a) && contains(outcome, b)) || + (contains(behaviour, b) && contains(outcome, a)) + ); +} + +/** + * Return the FDR-controlled, lag-aware association between two named metrics. + * Pure re-export: runs the SAME discovery the Coach `get_correlations` tool and + * the insight route run, then selects the surviving pair (strongest |r|) that + * matches the requested two metrics — never re-computing a relationship the + * engine did not surface. Honest `{ present: false }` when no surviving pair + * matches (sparse data, or the link did not clear the engine's floors). + */ +export async function getCorrelation( + userId: string, + args: { metricA: string; metricB: string }, +): Promise { + const a = norm(args.metricA); + const b = norm(args.metricB); + const result = await readCoachCorrelations(userId); + + if (!result.present || !result.drivers || result.drivers.length === 0) { + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_correlation", present: false }, + }); + return { + present: false, + reason: result.reason ?? "no_significant_pattern", + pairsTested: result.pairsTested, + windowDays: result.windowDays, + }; + } + + const matches = result.drivers.filter((d) => + pairMatches(norm(d.behaviour), norm(d.outcome), a, b), + ); + if (matches.length === 0) { + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_correlation", present: false }, + }); + return { + present: false, + reason: "no_significant_pattern_for_pair", + pairsTested: result.pairsTested, + windowDays: result.windowDays, + }; + } + + const best = matches.reduce((acc, cur) => + Math.abs(cur.r) > Math.abs(acc.r) ? cur : acc, + ); + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_correlation", present: true }, + }); + return { + present: true, + pair: { + behaviour: best.behaviour, + outcome: best.outcome, + direction: best.direction, + lagDays: best.lagDays, + n: best.n, + r: best.r, + note: best.note, + }, + pairsTested: result.pairsTested, + windowDays: result.windowDays, + association: "descriptive", + }; +} + +// ── 2. compare_metric ──────────────────────────────────────────────── + +interface MetricWindowSnapshot { + label: string; + unit: string; + band: { low: number; high: number } | null; + windowDays: number; + granularity: string; + count: number; + mean: number | null; + min: number | null; + max: number | null; +} + +export interface CompareMetricResult { + present: boolean; + reason?: string; + mode?: "metric_vs_metric" | "window_vs_window"; + a?: MetricWindowSnapshot; + b?: MetricWindowSnapshot; + /** Delta b − a, only when both sides share a unit; null otherwise. */ + delta?: { mean: number; pct: number | null } | null; +} + +async function snapshotMetricWindow( + userId: string, + metric: RichMetric, + windowDays: number, +): Promise { + const read = await readBestGranularityRollups( + userId, + metric.measurementType, + windowDays, + ); + if (!read || read.rows.length === 0) return null; + const agg = aggregateWmyBuckets(read.rows); + if (agg.count === 0) return null; + return { + label: metric.label, + unit: metric.unit, + band: metric.band, + windowDays, + granularity: read.granularity, + count: agg.count, + mean: agg.mean, + min: agg.min, + max: agg.max, + }; +} + +/** + * Compare a metric against another metric (same trailing window) OR a single + * metric across two trailing windows. Pure re-export of the WMY rollup reader + + * the linear `aggregateWmyBuckets` composition — no new math. Windows are + * trailing-to-now (e.g. last 30 days vs last 90 days); a delta is only computed + * when both sides carry the same unit, so the result never compares unlike + * scales. `{ present: false }` when neither side has data. + */ +export async function compareMetric( + userId: string, + args: { + metric: string; + metricB?: string; + window?: CoachScopeWindow; + windowB?: CoachScopeWindow; + }, +): Promise { + const metricA = resolveRichMetric(args.metric); + if (!metricA) { + return { present: false, reason: "unknown_metric" }; + } + const daysA = windowToDays(args.window); + + const annotateMiss = () => + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "compare_metric", present: false }, + }); + + let mode: "metric_vs_metric" | "window_vs_window"; + let metricB: RichMetric; + let daysB: number; + + if (args.metricB) { + const resolvedB = resolveRichMetric(args.metricB); + if (!resolvedB) { + annotateMiss(); + return { present: false, reason: "unknown_metric_b" }; + } + mode = "metric_vs_metric"; + metricB = resolvedB; + daysB = daysA; + } else if (args.windowB) { + mode = "window_vs_window"; + metricB = metricA; + daysB = windowToDays(args.windowB); + } else { + annotateMiss(); + return { present: false, reason: "specify_metricB_or_windowB" }; + } + + const [a, b] = await Promise.all([ + snapshotMetricWindow(userId, metricA, daysA), + snapshotMetricWindow(userId, metricB, daysB), + ]); + + if (!a || !b) { + annotateMiss(); + return { + present: false, + reason: "no_data", + mode, + ...(a ? { a } : {}), + ...(b ? { b } : {}), + }; + } + + let delta: { mean: number; pct: number | null } | null = null; + if (a.unit === b.unit && a.mean !== null && b.mean !== null) { + const diff = b.mean - a.mean; + delta = { + mean: diff, + pct: a.mean !== 0 ? (diff / Math.abs(a.mean)) * 100 : null, + }; + } + + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "compare_metric", present: true }, + }); + return { present: true, mode, a, b, delta }; +} + +// ── 3. get_metric_baseline ─────────────────────────────────────────── + +export interface MetricBaselineResult { + present: boolean; + reason?: string; + metric?: string; + unit?: string; + /** The user's personal usual range (median ± k·MAD) + sample transparency. */ + baseline?: { low: number; high: number; sampleDays: number }; + /** Today's latest reading. */ + latest?: number; + /** Where the latest reading sits relative to the personal band. */ + placement?: "within" | "above" | "below"; + /** Population reference band, or `null` when none exists for the metric. */ + referenceBand?: { low: number; high: number } | null; + /** The single strongest lagged driver of this metric, or `null`. */ + driver?: { note: string; behaviour: string; outcome: string } | null; +} + +/** + * Return where today's value sits against the user's own usual range. Pure + * re-export of `buildCoachReadStrip` — the SAME median ± k·MAD baseline engine + * (`computeVitalsBaseline`) + lagged-driver pick the metric page renders. Below + * the engine's 7-day history floor the band is not asserted (`{ present: false, + * reason: "insufficient_history" }`) — never a fabricated range. The population + * reference band rides along as general context even on a miss. + */ +export async function getMetricBaseline( + userId: string, + args: { metric: string }, +): Promise { + const metric = resolveRichMetric(args.metric); + if (!metric) { + return { present: false, reason: "unknown_metric" }; + } + + const strip = await buildCoachReadStrip(userId, metric.measurementType); + + if (!strip.baseline) { + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_metric_baseline", present: false }, + }); + return { + present: false, + reason: strip.learning ? "insufficient_history" : "no_data", + metric: metric.label, + unit: metric.unit, + referenceBand: metric.band, + }; + } + + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_metric_baseline", present: true }, + }); + return { + present: true, + metric: metric.label, + unit: metric.unit, + baseline: { + low: strip.baseline.low, + high: strip.baseline.high, + sampleDays: strip.baseline.sampleDays, + }, + latest: strip.baseline.latest, + placement: strip.baseline.placement, + referenceBand: metric.band, + driver: strip.driver, + }; +} + +// ── 4. detect_changepoints ─────────────────────────────────────────── + +export interface Changepoint { + /** Bucket-start ISO timestamp of the first bucket of the new regime. */ + at: string; + direction: "increase" | "decrease"; + beforeMean: number; + afterMean: number; + delta: number; +} + +export interface ChangepointsResult { + present: boolean; + reason?: string; + metric?: string; + unit?: string; + granularity?: string; + windowDays?: number; + bucketsAnalysed?: number; + changepoints?: Changepoint[]; +} + +const MIN_SEGMENT = 5; +/** Mean shift must clear this many series standard deviations to fire. */ +const SHIFT_SD_FLOOR = 1.5; +const MAX_CHANGEPOINTS = 5; + +function mean(xs: number[]): number { + return xs.reduce((s, x) => s + x, 0) / xs.length; +} + +function stdDev(xs: number[]): number { + if (xs.length < 2) return 0; + const m = mean(xs); + const variance = xs.reduce((s, x) => s + (x - m) ** 2, 0) / (xs.length - 1); + return Math.sqrt(variance); +} + +/** + * Minimal CUSUM binary-segmentation changepoint detector over a 1-D series. + * + * For a segment, the cumulative sum of mean-centred deviations peaks at the most + * likely split. The split fires ONLY when the resulting mean shift clears a + * conservative effect-size floor (≥ SHIFT_SD_FLOOR · series SD) and both halves + * are at least MIN_SEGMENT long — a deliberately high bar so noise does not + * register (honest-null over false positives). Accepted splits recurse on each + * half; the result is capped to the strongest MAX_CHANGEPOINTS shifts by the + * caller. + * + * Indices are into the original series. Pure / dependency-free. + */ +function cusumSegment( + values: number[], + lo: number, + hi: number, + out: Array<{ index: number; beforeMean: number; afterMean: number }>, +): void { + const n = hi - lo; + if (n < 2 * MIN_SEGMENT) return; + + const segment = values.slice(lo, hi); + const segMean = mean(segment); + const sd = stdDev(segment); + if (sd <= 0) return; + + // Cumulative sum of mean-centred deviations; track the extreme position. + let cusum = 0; + let maxAbs = 0; + let splitRel = -1; + for (let i = 0; i < n; i++) { + cusum += segment[i] - segMean; + if (Math.abs(cusum) > maxAbs) { + maxAbs = Math.abs(cusum); + // The new regime starts at the bucket AFTER the accumulation peak. + splitRel = i + 1; + } + } + if (splitRel < MIN_SEGMENT || n - splitRel < MIN_SEGMENT) return; + + const before = segment.slice(0, splitRel); + const after = segment.slice(splitRel); + const beforeMean = mean(before); + const afterMean = mean(after); + if (Math.abs(afterMean - beforeMean) < SHIFT_SD_FLOOR * sd) return; + + out.push({ index: lo + splitRel, beforeMean, afterMean }); + cusumSegment(values, lo, lo + splitRel, out); + cusumSegment(values, lo + splitRel, hi, out); +} + +/** + * Surface level shifts in a metric over the rollup tier. Reads the trailing + * window's rollup buckets (`readBestGranularityRollups` — DAY for ≤90 days, + * coarser for longer windows; the resolved granularity is reported) and runs + * the minimal CUSUM above over the bucket means. High firing bar — returns + * `{ present: false }` when too few buckets exist or no shift clears the floor. + * Re-uses the rollup tier's already-computed bucket means; adds no new + * persisted analytics. + */ +export async function detectChangepoints( + userId: string, + args: { metric: string; window?: CoachScopeWindow }, +): Promise { + const metric = resolveRichMetric(args.metric); + if (!metric) { + return { present: false, reason: "unknown_metric" }; + } + const windowDays = windowToDays(args.window ?? "last90days"); + + const read = await readBestGranularityRollups( + userId, + metric.measurementType, + windowDays, + ); + + const miss = (reason: string): ChangepointsResult => { + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "detect_changepoints", present: false }, + }); + return { + present: false, + reason, + metric: metric.label, + unit: metric.unit, + windowDays, + }; + }; + + if (!read || read.rows.length < 2 * MIN_SEGMENT) { + return miss("insufficient_data"); + } + + const values = read.rows.map((r) => r.mean); + const found: Array<{ index: number; beforeMean: number; afterMean: number }> = + []; + cusumSegment(values, 0, values.length, found); + + if (found.length === 0) { + return { + ...miss("no_changepoint"), + granularity: read.granularity, + bucketsAnalysed: values.length, + }; + } + + const changepoints: Changepoint[] = found + .sort( + (x, y) => + Math.abs(y.afterMean - y.beforeMean) - + Math.abs(x.afterMean - x.beforeMean), + ) + .slice(0, MAX_CHANGEPOINTS) + .map((cp) => ({ + at: read.rows[cp.index].bucketStart.toISOString(), + direction: + cp.afterMean >= cp.beforeMean + ? ("increase" as const) + : ("decrease" as const), + beforeMean: cp.beforeMean, + afterMean: cp.afterMean, + delta: cp.afterMean - cp.beforeMean, + })) + .sort((x, y) => (x.at < y.at ? -1 : 1)); + + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "detect_changepoints", present: true }, + }); + return { + present: true, + metric: metric.label, + unit: metric.unit, + granularity: read.granularity, + windowDays, + bucketsAnalysed: values.length, + changepoints, + }; +} diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts index f40ec07bc..92e8b6bf8 100644 --- a/src/lib/mcp/server.ts +++ b/src/lib/mcp/server.ts @@ -17,6 +17,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { McpAuthContext } from "./auth"; import { MCP_TOOLS } from "./tools"; import { MCP_RESOURCES } from "./resources"; +import { MCP_PROMPTS } from "./prompts"; export const MCP_SERVER_NAME = "healthlog"; export const MCP_SERVER_VERSION = "1.0.0"; @@ -64,6 +65,29 @@ export function createMcpServer(ctx: McpAuthContext): McpServer { ); } + for (const prompt of MCP_PROMPTS) { + server.registerPrompt( + prompt.name, + { + title: prompt.title, + description: prompt.description, + argsSchema: prompt.argsShape, + }, + async (args: Record) => { + // The prompt assembles REAL, server-retrieved data + the central + // grounding framing; the result is a set of messages the host inserts + // into the conversation (ADR-004). Read-only, like every Phase-4 + // surface — a prompt never mutates. Returned as a fresh literal so it + // conforms to the SDK's `GetPromptResult` (index-signatured) shape. + const result = await prompt.run(ctx, args ?? {}); + return { + messages: result.messages, + ...(result.description ? { description: result.description } : {}), + }; + }, + ); + } + for (const resource of MCP_RESOURCES) { server.registerResource( resource.name, diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 894dacba0..1bd741a3a 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -28,6 +28,12 @@ import { coachScopeWindowSchema, } from "@/lib/ai/coach/types"; import { resolveBaseOrigin } from "@/lib/mcp/oauth/config"; +import { + getCorrelation, + compareMetric, + getMetricBaseline, + detectChangepoints, +} from "@/lib/mcp/rich-reads"; import type { McpAuthContext } from "./auth"; /** @@ -283,6 +289,97 @@ function searchAndFetchTools(): McpToolDefinition[] { ]; } +// ── Phase 4 deep-value output schemas ──────────────────────────────── +// Every field except `present` is optional so a grounded `{ present: false }` +// miss and a full hit both conform — the SDK validates `structuredContent` +// against these (REQ: structuredContent + outputSchema on the rich reads). + +const bandShape = z + .object({ low: z.number(), high: z.number() }) + .nullable() + .optional(); + +const getCorrelationOutput: z.ZodRawShape = { + present: z.boolean(), + reason: z.string().optional(), + pair: z + .object({ + behaviour: z.string(), + outcome: z.string(), + direction: z.enum(["higher", "lower"]), + lagDays: z.number(), + n: z.number(), + r: z.number(), + note: z.string(), + }) + .optional(), + pairsTested: z.number().optional(), + windowDays: z.number().optional(), + association: z.literal("descriptive").optional(), +}; + +const metricWindowSnapshotSchema = z.object({ + label: z.string(), + unit: z.string(), + band: bandShape, + windowDays: z.number(), + granularity: z.string(), + count: z.number(), + mean: z.number().nullable(), + min: z.number().nullable(), + max: z.number().nullable(), +}); + +const compareMetricOutput: z.ZodRawShape = { + present: z.boolean(), + reason: z.string().optional(), + mode: z.enum(["metric_vs_metric", "window_vs_window"]).optional(), + a: metricWindowSnapshotSchema.optional(), + b: metricWindowSnapshotSchema.optional(), + delta: z + .object({ mean: z.number(), pct: z.number().nullable() }) + .nullable() + .optional(), +}; + +const getMetricBaselineOutput: z.ZodRawShape = { + present: z.boolean(), + reason: z.string().optional(), + metric: z.string().optional(), + unit: z.string().optional(), + baseline: z + .object({ low: z.number(), high: z.number(), sampleDays: z.number() }) + .optional(), + latest: z.number().optional(), + placement: z.enum(["within", "above", "below"]).optional(), + referenceBand: bandShape, + driver: z + .object({ note: z.string(), behaviour: z.string(), outcome: z.string() }) + .nullable() + .optional(), +}; + +const detectChangepointsOutput: z.ZodRawShape = { + present: z.boolean(), + reason: z.string().optional(), + metric: z.string().optional(), + unit: z.string().optional(), + granularity: z.string().optional(), + windowDays: z.number().optional(), + bucketsAnalysed: z.number().optional(), + changepoints: z + .array( + z.object({ + at: z.string(), + direction: z.enum(["increase", "decrease"]), + beforeMean: z.number(), + afterMean: z.number(), + delta: z.number(), + }), + ) + .optional(), +}; + export const MCP_TOOLS: McpToolDefinition[] = [ { name: "list_metrics", @@ -357,6 +454,81 @@ export const MCP_TOOLS: McpToolDefinition[] = [ return runCoachTool(ctx, "get_correlations", args); }, }, + // ── Phase 4 deep-value reads (catalogue §5 #1, #3, #4, #7) ────────── + { + name: "get_correlation", + title: "Get a correlation between two metrics", + description: + "Fetch the statistically-vetted (FDR-controlled), lag-aware association between TWO named metrics the user tracks (e.g. 'sleep' and 'resting heart rate'). Re-exports the same discovery engine the other correlation surfaces run; returns the matched pair's direction, lag, sample size, Pearson r, and a descriptive — never causal — note. Returns { present: false } when no significant pattern links the pair (sparse data or the link did not clear the engine's floors).", + inputShape: { + metricA: z.string().min(1).max(60), + metricB: z.string().min(1).max(60), + }, + annotations: READ_ONLY_ANNOTATIONS, + outputShape: getCorrelationOutput, + run(ctx, args) { + return getCorrelation(ctx.userId, { + metricA: String(args.metricA ?? ""), + metricB: String(args.metricB ?? ""), + }); + }, + }, + { + name: "compare_metric", + title: "Compare metrics or windows", + description: + "Compare one metric against another over the same trailing window, OR a single metric across two trailing windows (e.g. last 30 days vs last 90 days). Returns each side's rollup statistics (count, mean, min, max) with units and reference bands, plus a delta + percent change when both sides share a unit. Windows are trailing-to-now. Returns { present: false } when a side has no data.", + inputShape: { + metric: z.string().min(1).max(60), + metricB: z.string().min(1).max(60).optional(), + window: coachScopeWindowSchema.optional(), + windowB: coachScopeWindowSchema.optional(), + }, + annotations: READ_ONLY_ANNOTATIONS, + outputShape: compareMetricOutput, + run(ctx, args) { + return compareMetric(ctx.userId, { + metric: String(args.metric ?? ""), + metricB: typeof args.metricB === "string" ? args.metricB : undefined, + window: args.window as never, + windowB: args.windowB as never, + }); + }, + }, + { + name: "get_metric_baseline", + title: "Get a metric's personal baseline", + description: + "Fetch where the user's latest reading for ONE metric sits against their own usual range (median ± robust deviation), plus the strongest lagged driver of that metric. Re-exports the same baseline engine the metric page renders. Returns the personal band, today's value + placement (within/above/below), and the population reference band. Returns { present: false } with reason 'insufficient_history' below the 7-day learning floor — never a fabricated range.", + inputShape: { + metric: z.string().min(1).max(60), + }, + annotations: READ_ONLY_ANNOTATIONS, + outputShape: getMetricBaselineOutput, + run(ctx, args) { + return getMetricBaseline(ctx.userId, { + metric: String(args.metric ?? ""), + }); + }, + }, + { + name: "detect_changepoints", + title: "Detect level shifts in a metric", + description: + "Surface points where a metric's level shifted over a trailing window (e.g. 'when did my weight trend change?'). Runs a conservative changepoint scan over the rollup tier's bucket means and reports each shift's date, direction, and before/after means with units. High firing bar — returns { present: false } when too little data exists or no shift clears the threshold.", + inputShape: { + metric: z.string().min(1).max(60), + window: coachScopeWindowSchema.optional(), + }, + annotations: READ_ONLY_ANNOTATIONS, + outputShape: detectChangepointsOutput, + run(ctx, args) { + return detectChangepoints(ctx.userId, { + metric: String(args.metric ?? ""), + window: args.window as never, + }); + }, + }, ...searchAndFetchTools(), ]; From 90c87684078411df0b580f26725eb610b60cb788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 17:38:29 +0200 Subject: [PATCH 09/11] feat(mcp): add a dedicated MCP measurement source for write provenance Append an MCP value to MeasurementSource (migration 0196, additive enum extension) so a row logged through the remote MCP endpoint is distinguishable from MANUAL/TELEGRAM provenance and gets an isolated idempotency namespace (userId, type, MCP, externalId). The value joins the read enum so existing read/response shapes decode the rows; the REST batch and single-POST write surfaces still reject it (server-resolved only). Regenerate the OpenAPI contract for the additive enum. --- docs/api/openapi.yaml | 116 ++++++++++++++++++ .../migration.sql | 14 +++ prisma/schema.prisma | 9 ++ src/lib/validations/measurement.ts | 7 ++ 4 files changed, 146 insertions(+) create mode 100644 prisma/migrations/0196_mcp_phase5_write_surface/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 586a1f894..edd7a098f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1745,6 +1745,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP - in: query name: valueMin schema: @@ -2240,6 +2241,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP externalId: type: string maxLength: 128 @@ -3358,6 +3360,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP activeEnergy: maxItems: 8 type: array @@ -3375,6 +3378,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP walkingRunningDistance: maxItems: 8 type: array @@ -3392,6 +3396,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP flightsClimbed: maxItems: 8 type: array @@ -3409,6 +3414,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP sleep: maxItems: 8 type: array @@ -3426,6 +3432,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP weight: maxItems: 8 type: array @@ -3443,6 +3450,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP bloodPressure: maxItems: 8 type: array @@ -3460,6 +3468,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP pulse: maxItems: 8 type: array @@ -3477,6 +3486,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP bodyFat: maxItems: 8 type: array @@ -3494,6 +3504,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP bodyTemperature: maxItems: 8 type: array @@ -3511,6 +3522,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP spo2: maxItems: 8 type: array @@ -3528,6 +3540,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP hrv: maxItems: 8 type: array @@ -3545,6 +3558,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP restingHeartRate: maxItems: 8 type: array @@ -3562,6 +3576,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP vo2Max: maxItems: 8 type: array @@ -3579,6 +3594,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP skinTemperature: maxItems: 8 type: array @@ -3596,6 +3612,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP respiratoryRate: maxItems: 8 type: array @@ -3613,6 +3630,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP recovery: maxItems: 8 type: array @@ -3630,6 +3648,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP stress: maxItems: 8 type: array @@ -3647,6 +3666,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP metricPriority: type: object properties: @@ -3667,6 +3687,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP activeEnergy: maxItems: 8 type: array @@ -3684,6 +3705,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP walkingRunningDistance: maxItems: 8 type: array @@ -3701,6 +3723,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP flightsClimbed: maxItems: 8 type: array @@ -3718,6 +3741,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP sleep: maxItems: 8 type: array @@ -3735,6 +3759,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP weight: maxItems: 8 type: array @@ -3752,6 +3777,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP bloodPressure: maxItems: 8 type: array @@ -3769,6 +3795,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP pulse: maxItems: 8 type: array @@ -3786,6 +3813,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP bodyFat: maxItems: 8 type: array @@ -3803,6 +3831,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP bodyTemperature: maxItems: 8 type: array @@ -3820,6 +3849,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP spo2: maxItems: 8 type: array @@ -3837,6 +3867,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP hrv: maxItems: 8 type: array @@ -3854,6 +3885,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP restingHeartRate: maxItems: 8 type: array @@ -3871,6 +3903,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP vo2Max: maxItems: 8 type: array @@ -3888,6 +3921,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP skinTemperature: maxItems: 8 type: array @@ -3905,6 +3939,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP respiratoryRate: maxItems: 8 type: array @@ -3922,6 +3957,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP recovery: maxItems: 8 type: array @@ -3939,6 +3975,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP stress: maxItems: 8 type: array @@ -3956,6 +3993,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP deviceTypePriority: type: object properties: @@ -10743,6 +10781,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP notes: anyOf: - type: string @@ -11466,6 +11505,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP notes: anyOf: - type: string @@ -11709,6 +11749,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP - type: "null" start: type: string @@ -11836,6 +11877,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP - type: "null" start: type: string @@ -12209,6 +12251,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP externalId: anyOf: - type: string @@ -12318,6 +12361,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP externalId: anyOf: - type: string @@ -14529,6 +14573,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP activeEnergy: maxItems: 8 type: array @@ -14546,6 +14591,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP walkingRunningDistance: maxItems: 8 type: array @@ -14563,6 +14609,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP flightsClimbed: maxItems: 8 type: array @@ -14580,6 +14627,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP sleep: maxItems: 8 type: array @@ -14597,6 +14645,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP weight: maxItems: 8 type: array @@ -14614,6 +14663,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bloodPressure: maxItems: 8 type: array @@ -14631,6 +14681,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP pulse: maxItems: 8 type: array @@ -14648,6 +14699,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bodyFat: maxItems: 8 type: array @@ -14665,6 +14717,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bodyTemperature: maxItems: 8 type: array @@ -14682,6 +14735,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP spo2: maxItems: 8 type: array @@ -14699,6 +14753,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP hrv: maxItems: 8 type: array @@ -14716,6 +14771,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP restingHeartRate: maxItems: 8 type: array @@ -14733,6 +14789,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP vo2Max: maxItems: 8 type: array @@ -14750,6 +14807,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP skinTemperature: maxItems: 8 type: array @@ -14767,6 +14825,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP respiratoryRate: maxItems: 8 type: array @@ -14784,6 +14843,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP recovery: maxItems: 8 type: array @@ -14801,6 +14861,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP stress: maxItems: 8 type: array @@ -14818,6 +14879,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP metricPriority: type: object properties: @@ -14838,6 +14900,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP activeEnergy: maxItems: 8 type: array @@ -14855,6 +14918,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP walkingRunningDistance: maxItems: 8 type: array @@ -14872,6 +14936,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP flightsClimbed: maxItems: 8 type: array @@ -14889,6 +14954,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP sleep: maxItems: 8 type: array @@ -14906,6 +14972,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP weight: maxItems: 8 type: array @@ -14923,6 +14990,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bloodPressure: maxItems: 8 type: array @@ -14940,6 +15008,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP pulse: maxItems: 8 type: array @@ -14957,6 +15026,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bodyFat: maxItems: 8 type: array @@ -14974,6 +15044,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bodyTemperature: maxItems: 8 type: array @@ -14991,6 +15062,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP spo2: maxItems: 8 type: array @@ -15008,6 +15080,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP hrv: maxItems: 8 type: array @@ -15025,6 +15098,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP restingHeartRate: maxItems: 8 type: array @@ -15042,6 +15116,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP vo2Max: maxItems: 8 type: array @@ -15059,6 +15134,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP skinTemperature: maxItems: 8 type: array @@ -15076,6 +15152,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP respiratoryRate: maxItems: 8 type: array @@ -15093,6 +15170,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP recovery: maxItems: 8 type: array @@ -15110,6 +15188,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP stress: maxItems: 8 type: array @@ -15127,6 +15206,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP additionalProperties: false deviceTypePriority: type: object @@ -15193,6 +15273,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP activeEnergy: maxItems: 8 type: array @@ -15210,6 +15291,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP walkingRunningDistance: maxItems: 8 type: array @@ -15227,6 +15309,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP flightsClimbed: maxItems: 8 type: array @@ -15244,6 +15327,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP sleep: maxItems: 8 type: array @@ -15261,6 +15345,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP weight: maxItems: 8 type: array @@ -15278,6 +15363,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bloodPressure: maxItems: 8 type: array @@ -15295,6 +15381,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP pulse: maxItems: 8 type: array @@ -15312,6 +15399,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bodyFat: maxItems: 8 type: array @@ -15329,6 +15417,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bodyTemperature: maxItems: 8 type: array @@ -15346,6 +15435,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP spo2: maxItems: 8 type: array @@ -15363,6 +15453,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP hrv: maxItems: 8 type: array @@ -15380,6 +15471,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP restingHeartRate: maxItems: 8 type: array @@ -15397,6 +15489,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP vo2Max: maxItems: 8 type: array @@ -15414,6 +15507,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP skinTemperature: maxItems: 8 type: array @@ -15431,6 +15525,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP respiratoryRate: maxItems: 8 type: array @@ -15448,6 +15543,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP recovery: maxItems: 8 type: array @@ -15465,6 +15561,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP stress: maxItems: 8 type: array @@ -15482,6 +15579,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP metricPriority: type: object properties: @@ -15502,6 +15600,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP activeEnergy: maxItems: 8 type: array @@ -15519,6 +15618,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP walkingRunningDistance: maxItems: 8 type: array @@ -15536,6 +15636,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP flightsClimbed: maxItems: 8 type: array @@ -15553,6 +15654,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP sleep: maxItems: 8 type: array @@ -15570,6 +15672,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP weight: maxItems: 8 type: array @@ -15587,6 +15690,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bloodPressure: maxItems: 8 type: array @@ -15604,6 +15708,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP pulse: maxItems: 8 type: array @@ -15621,6 +15726,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bodyFat: maxItems: 8 type: array @@ -15638,6 +15744,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bodyTemperature: maxItems: 8 type: array @@ -15655,6 +15762,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP spo2: maxItems: 8 type: array @@ -15672,6 +15780,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP hrv: maxItems: 8 type: array @@ -15689,6 +15798,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP restingHeartRate: maxItems: 8 type: array @@ -15706,6 +15816,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP vo2Max: maxItems: 8 type: array @@ -15723,6 +15834,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP skinTemperature: maxItems: 8 type: array @@ -15740,6 +15852,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP respiratoryRate: maxItems: 8 type: array @@ -15757,6 +15870,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP recovery: maxItems: 8 type: array @@ -15774,6 +15888,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP stress: maxItems: 8 type: array @@ -15791,6 +15906,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP additionalProperties: false deviceTypePriority: type: object diff --git a/prisma/migrations/0196_mcp_phase5_write_surface/migration.sql b/prisma/migrations/0196_mcp_phase5_write_surface/migration.sql new file mode 100644 index 000000000..c4055770a --- /dev/null +++ b/prisma/migrations/0196_mcp_phase5_write_surface/migration.sql @@ -0,0 +1,14 @@ +-- MCP write provenance. +-- +-- The remote MCP endpoint gains a confirmed write surface (logging a +-- measurement / mood). Rows it writes carry their own `MeasurementSource` +-- value so they are distinguishable from MANUAL / TELEGRAM provenance and +-- get an isolated idempotency namespace `(userId, type, MCP, externalId)`. +-- +-- Purely-additive enum extension; no row touched. `ADD VALUE IF NOT EXISTS` +-- makes a rerun safe. The new value is NOT used elsewhere in this migration, +-- so it is safe to extend the enum in the same step (Postgres only forbids +-- USING a freshly-added value in the same transaction). Mirrors the 0189 +-- source-extension that added TELEGRAM. The enum is `@@map`-ed to the +-- snake_case type name `measurement_source`. +ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'MCP'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 172aafc9d..a24846b30 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1033,6 +1033,15 @@ enum MeasurementSource { // (server-resolved only); iOS DTO mirrors this spelling so the // /api/sync/changes delta feed decodes the rows. TELEGRAM + // v1.22.0 — measurements logged through the remote MCP write surface. A + // distinct provenance from MANUAL/TELEGRAM: the row was confirmed and + // written by an external assistant over the `/mcp` wire under a + // `health:write`-scoped token, never the cookie/Bearer client write path. + // Its own value gives the MCP writes an isolated idempotency namespace + // `(userId, type, MCP, externalId)`. The client batch + single-POST + // surfaces still reject it (server-resolved only); iOS DTO mirrors this + // spelling so the /api/sync/changes delta feed decodes the rows. + MCP @@map("measurement_source") } diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 991094226..2c11ba6f0 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -184,6 +184,13 @@ export const measurementSourceEnum = z.enum([ // decode the rows it surfaces; the client-facing write surfaces still // reject it (see `WRITABLE_MEASUREMENT_SOURCES` + the batch allowlist). "TELEGRAM", + // v1.22.0 — measurements logged through the confirmed remote MCP write + // surface under a `health:write`-scoped token. Server-resolved (the row is + // written in-process over `/mcp`, never through the cookie/Bearer client + // write path), so it is excluded from `WRITABLE_MEASUREMENT_SOURCES`; part + // of this enum so the read/response shapes (and the iOS decoder) can decode + // the rows it surfaces. + "MCP", ]); /** From 6de61de010a6d004d08f963c8e15edf35b9ad6e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 17:38:42 +0200 Subject: [PATCH 10/11] feat(mcp): add a confirmed, audience-bound write surface for logging measurements and mood MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register two write tools — log_measurement and log_mood — only for a health:write-scoped session; a read-only session never sees them advertised. Each write mirrors the proven Telegram capture path (same range guard, canonical unit, cache invalidation and rollup recompute) pinned to the MCP source, and is idempotent on a hashed externalId derived from a client key, so a retried call is a no-op rather than a duplicate. Because the remote wire is stateless (no elicitation), the tools carry a protocol-level confirm gate: without confirm:true they write nothing and return the exact record they would write plus an instruction to confirm the value with the user and re-call with the same idempotency key. Commits run under a tighter per-credential write budget on top of the per-binding request limiter. Harden the resource-server audience binding: a token whose scopes are all in {health:read, health:write} is bound to the MCP surface and refused on every REST write/delete. A health:write token therefore admits the in-process MCP write tools only and can never become a general REST write credential; admin stays unreachable (cookie-only). Refuse, by construction, any medication, lab, delete/update, or data-export tool on this surface. --- src/app/mcp/__tests__/route.test.ts | 41 ++- src/app/mcp/route.ts | 13 +- src/lib/__tests__/require-auth-bearer.test.ts | 27 +- src/lib/api-handler.ts | 34 +- src/lib/mcp/__tests__/mcp-audience.test.ts | 45 +++ src/lib/mcp/__tests__/scopes.test.ts | 25 ++ .../server-write-registration.test.ts | 62 ++++ src/lib/mcp/__tests__/write-tools.test.ts | 203 +++++++++++ src/lib/mcp/__tests__/writes.test.ts | 195 +++++++++++ src/lib/mcp/index.ts | 7 + src/lib/mcp/oauth/config.ts | 12 +- src/lib/mcp/server.ts | 34 +- src/lib/mcp/write-tools.ts | 261 +++++++++++++++ src/lib/mcp/writes.ts | 316 ++++++++++++++++++ src/lib/rate-limit.ts | 22 ++ 15 files changed, 1263 insertions(+), 34 deletions(-) create mode 100644 src/lib/mcp/__tests__/mcp-audience.test.ts create mode 100644 src/lib/mcp/__tests__/scopes.test.ts create mode 100644 src/lib/mcp/__tests__/server-write-registration.test.ts create mode 100644 src/lib/mcp/__tests__/write-tools.test.ts create mode 100644 src/lib/mcp/__tests__/writes.test.ts create mode 100644 src/lib/mcp/write-tools.ts create mode 100644 src/lib/mcp/writes.ts diff --git a/src/app/mcp/__tests__/route.test.ts b/src/app/mcp/__tests__/route.test.ts index 52784e2f9..286daab63 100644 --- a/src/app/mcp/__tests__/route.test.ts +++ b/src/app/mcp/__tests__/route.test.ts @@ -288,7 +288,7 @@ describe("/mcp — rate limit", () => { }); }); -describe("/mcp — admin unreachable over HTTP (read-only, cookie-only boundary)", () => { +describe("/mcp — admin unreachable over HTTP (cookie-only boundary)", () => { it("never consults a cookie session, even with a wildcard token", async () => { validToken(["*"]); const res = await POST(postRpc(TOOLS_LIST)); @@ -299,12 +299,16 @@ describe("/mcp — admin unreachable over HTTP (read-only, cookie-only boundary) (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); + // A `["*"]` token carries write capability (`tokenAllowsWrite`), so it + // sees the confirmed write tools alongside the read tools — but NO admin + // tool exists, and admin stays unreachable here by construction. + for (const read of READ_TOOLS) { + expect(names).toContain(read); + } + expect(names).toContain("log_measurement"); + expect(names).toContain("log_mood"); 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 @@ -314,6 +318,33 @@ describe("/mcp — admin unreachable over HTTP (read-only, cookie-only boundary) }); }); +describe("/mcp — write tools gate on health:write", () => { + it("a health:read token lists NO write tools", async () => { + validToken(["health:read"]); + 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, + ); + expect(names.sort()).toEqual(READ_TOOLS); + expect(names).not.toContain("log_measurement"); + expect(names).not.toContain("log_mood"); + }); + + it("a health:read+write token can list (and thus invoke) the write tools", async () => { + validToken(["health:read", "health:write"]); + 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, + ); + expect(names).toContain("log_measurement"); + expect(names).toContain("log_mood"); + }); +}); + describe("/mcp — GET transport", () => { it("is wired for GET (SSE stream open) behind the same gates", async () => { validToken(); diff --git a/src/app/mcp/route.ts b/src/app/mcp/route.ts index 80bf1a323..db3b25980 100644 --- a/src/app/mcp/route.ts +++ b/src/app/mcp/route.ts @@ -31,11 +31,14 @@ * (`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. + * Write surface: the factory registers the confirmed write tools + * (`log_measurement` / `log_mood`) ONLY for a `health:write`-scoped session; + * a read-only token never sees them. Writes happen in-process here and are + * audience-bound to `/mcp` — a `health:write` MCP token is refused on every + * REST write/delete by the resource-server guard in `api-handler.ts`, so it + * can never become a general REST write credential. `requireAdmin()` stays + * cookie-only, so admin is unreachable over this wire regardless of token + * scope — including `["*"]` (ADR-003 / ADR-005). * * Same-origin / no-CORS by construction: no `Access-Control-Allow-Origin` * header is emitted anywhere here, matching the app-wide posture. diff --git a/src/lib/__tests__/require-auth-bearer.test.ts b/src/lib/__tests__/require-auth-bearer.test.ts index f1688bba0..7f5d2cb2e 100644 --- a/src/lib/__tests__/require-auth-bearer.test.ts +++ b/src/lib/__tests__/require-auth-bearer.test.ts @@ -273,7 +273,7 @@ describe("requireAuth — Bearer token path", () => { describe("requireAuth — H1 MCP health:read audience binding on REST", () => { // Drive the FULL apiHandler path so the request method is in event context — // the H1 check reads it to allow safe reads but block writes. - const route = apiHandler(async (_req: Request) => { + const route = apiHandler(async () => { await requireAuth(); return Response.json({ ok: true }); }); @@ -340,7 +340,10 @@ describe("requireAuth — H1 MCP health:read audience binding on REST", () => { expect(res.status).toBe(200); }); - it("does NOT restrict a health:read+write token on POST", async () => { + it("rejects a health:read+write token on POST with 403 (writes are /mcp-only)", async () => { + // A write-scoped MCP token is STILL audience-bound: its `health:write` + // grant admits the confirmed in-process `/mcp` write tools, never a REST + // write/delete. So it gets the same 403 as a read-only MCP token here. vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ id: "rw", userId: "user-1", @@ -349,6 +352,26 @@ describe("requireAuth — H1 MCP health:read audience binding on REST", () => { expiresAt: null, } as never); const res = await call("POST"); + expect(res.status).toBe(403); + expect(auditLog).toHaveBeenCalledWith( + "auth.bearer.failure", + expect.objectContaining({ + details: expect.objectContaining({ + reason: "mcp_audience_write_blocked", + }), + }), + ); + }); + + it("accepts a health:read+write token on a GET read route", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "rw", + userId: "user-1", + permissions: ["health:read", "health:write"], + revoked: false, + expiresAt: null, + } as never); + const res = await call("GET"); expect(res.status).toBe(200); }); }); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 0e59c1c84..04db41dfb 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -10,7 +10,7 @@ import { auditLog } from "./auth/audit"; import { resolveBearerToken, BearerAuthError } from "./auth/bearer"; import { AssistantDisabledError } from "./feature-flags"; import { ConsentRequiredError } from "./ai/consent-guard"; -import { SCOPE_HEALTH_READ } from "./mcp/oauth/config"; +import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config"; /** * HTTP methods a read-only credential may use on the REST surface. A request @@ -20,15 +20,20 @@ const READ_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); /** * Whether a token is MCP-audience-bound (H1). The MCP OAuth bridge and the - * connector settings card mint tokens whose ONLY grant is `health:read`. Such a - * token is bound to the read surface: the `/mcp` resolver accepts it, and so do - * safe (read) REST methods, but it must never reach a REST write/delete. A token - * carrying any broader or legacy grant (`*`, `medication:ingest`, an explicit - * `health:write`, …) is NOT MCP-audience-bound and keeps its existing reach. + * connector settings card mint tokens whose ONLY grants are `health:read` — + * and, when the user consents to logging, `health:read health:write`. Either + * shape is bound to the MCP surface: the `/mcp` resolver accepts it and so do + * safe (read) REST methods, but it must NEVER reach a REST write/delete. The + * `health:write` grant admits writes ONLY in-process over `/mcp` (the confirmed + * write tools), never over REST — so a write-scoped MCP token is exactly as + * audience-bound on this edge as a read-only one. A token carrying any broader + * or legacy grant (`*`, `medication:ingest`, …) is NOT MCP-audience-bound and + * keeps its existing reach. */ -function isMcpAudienceToken(permissions: readonly string[]): boolean { - return ( - permissions.length > 0 && permissions.every((p) => p === SCOPE_HEALTH_READ) +export function isMcpAudienceToken(permissions: readonly string[]): boolean { + if (permissions.length === 0) return false; + return permissions.every( + (p) => p === SCOPE_HEALTH_READ || p === SCOPE_HEALTH_WRITE, ); } @@ -354,10 +359,13 @@ async function authenticateBearer( const { user, tokenId, expiresAt, permissions } = resolution; - // H1 — audience binding at the resource server. A health:read-only token is - // the MCP read credential; it may reach `/mcp` (a separate resolver that - // never runs this edge) and safe REST reads, but a write/delete over REST is - // outside its audience and is refused. Fail closed when the method is unknown + // H1 — audience binding at the resource server. An MCP-audience token + // (`health:read`, or `health:read health:write`) is bound to the `/mcp` + // surface; it may reach `/mcp` (a separate resolver that never runs this + // edge) and safe REST reads, but a write/delete over REST is outside its + // audience and is refused — INCLUDING a write-scoped token, whose writes are + // confined to the in-process `/mcp` tools and never granted over REST. Fail + // closed when the method is unknown // (no event context) since every real REST request runs inside apiHandler, // which always sets the method — an unknown method means we cannot prove a // read, so we deny. This is RFC 8707 audience binding on the credential the diff --git a/src/lib/mcp/__tests__/mcp-audience.test.ts b/src/lib/mcp/__tests__/mcp-audience.test.ts new file mode 100644 index 000000000..031623bb4 --- /dev/null +++ b/src/lib/mcp/__tests__/mcp-audience.test.ts @@ -0,0 +1,45 @@ +/** + * Guard: the resource-server audience binding (H1). An MCP-audience token + * (`health:read`, or `health:read health:write`) is bound to the `/mcp` + * surface and refused on every REST write — a `health:write` MCP token can + * never become a general REST write credential. A wildcard or any broader / + * legacy grant is NOT audience-bound and keeps its existing reach. + */ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); +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() })); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ get: () => undefined })), +})); + +import { isMcpAudienceToken } from "@/lib/api-handler"; + +describe("isMcpAudienceToken", () => { + it("read-only token IS audience-bound", () => { + expect(isMcpAudienceToken(["health:read"])).toBe(true); + }); + it("read+write token IS audience-bound (writes confined to /mcp)", () => { + expect(isMcpAudienceToken(["health:read", "health:write"])).toBe(true); + }); + it("write-only token IS audience-bound", () => { + expect(isMcpAudienceToken(["health:write"])).toBe(true); + }); + it("wildcard token is NOT audience-bound (keeps its reach)", () => { + expect(isMcpAudienceToken(["*"])).toBe(false); + }); + it("a mixed legacy grant is NOT audience-bound", () => { + expect(isMcpAudienceToken(["health:read", "medication:ingest"])).toBe( + false, + ); + }); + it("an empty scope set is NOT audience-bound", () => { + expect(isMcpAudienceToken([])).toBe(false); + }); +}); diff --git a/src/lib/mcp/__tests__/scopes.test.ts b/src/lib/mcp/__tests__/scopes.test.ts new file mode 100644 index 000000000..0a10753c0 --- /dev/null +++ b/src/lib/mcp/__tests__/scopes.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; + +import { + tokenAllowsWrite, + SCOPE_HEALTH_READ, + SCOPE_HEALTH_WRITE, + SCOPE_WILDCARD, +} from "../scopes"; + +describe("tokenAllowsWrite", () => { + it("is false for a read-only token", () => { + expect(tokenAllowsWrite([SCOPE_HEALTH_READ])).toBe(false); + }); + it("is true for a read+write token", () => { + expect(tokenAllowsWrite([SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE])).toBe( + true, + ); + }); + it("is true for the wildcard token", () => { + expect(tokenAllowsWrite([SCOPE_WILDCARD])).toBe(true); + }); + it("is false for an empty scope set", () => { + expect(tokenAllowsWrite([])).toBe(false); + }); +}); diff --git a/src/lib/mcp/__tests__/server-write-registration.test.ts b/src/lib/mcp/__tests__/server-write-registration.test.ts new file mode 100644 index 000000000..a78430431 --- /dev/null +++ b/src/lib/mcp/__tests__/server-write-registration.test.ts @@ -0,0 +1,62 @@ +/** + * Guard: the confirmed write tools are advertised ONLY to a write-scoped + * session. A read-only (`health:read`) session must see zero write tools — the + * read-only-by-default posture is structural, not a runtime flag. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { createMcpServer } from "../server"; +import { MCP_WRITE_TOOL_NAMES } from "../write-tools"; +import type { McpAuthContext } from "../auth"; + +function ctx(canWrite: boolean): McpAuthContext { + return { + userId: "u-1", + tokenId: "t-1", + scopes: canWrite ? ["health:read", "health:write"] : ["health:read"], + binding: "u-1:t-1", + canRead: true, + canWrite, + }; +} + +let registered: string[]; + +beforeEach(() => { + registered = []; + vi.spyOn(McpServer.prototype, "registerTool").mockImplementation(function ( + this: unknown, + name: string, + ) { + registered.push(name); + return {} as never; + }); + // Prompts / resources registration is irrelevant here — stub to no-ops. + vi.spyOn(McpServer.prototype, "registerPrompt").mockReturnValue({} as never); + vi.spyOn(McpServer.prototype, "registerResource").mockReturnValue( + {} as never, + ); +}); + +describe("write-tool registration gating", () => { + it("a read-only session sees NO write tools", () => { + createMcpServer(ctx(false)); + for (const name of MCP_WRITE_TOOL_NAMES) { + expect(registered).not.toContain(name); + } + // It still registers read tools. + expect(registered).toContain("list_metrics"); + }); + + it("a write-scoped session sees the write tools", () => { + createMcpServer(ctx(true)); + for (const name of MCP_WRITE_TOOL_NAMES) { + expect(registered).toContain(name); + } + expect(registered).toContain("log_measurement"); + expect(registered).toContain("log_mood"); + }); +}); diff --git a/src/lib/mcp/__tests__/write-tools.test.ts b/src/lib/mcp/__tests__/write-tools.test.ts new file mode 100644 index 000000000..a4f5f5315 --- /dev/null +++ b/src/lib/mcp/__tests__/write-tools.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const logMcpMeasurement = vi.fn(); +const logMcpMood = vi.fn(); +vi.mock("../writes", () => ({ + logMcpMeasurement: (...a: unknown[]) => logMcpMeasurement(...a), + logMcpMood: (...a: unknown[]) => logMcpMood(...a), +})); + +const checkMcpWriteRateLimit = vi.fn(); +vi.mock("@/lib/rate-limit", () => ({ + checkMcpWriteRateLimit: (...a: unknown[]) => checkMcpWriteRateLimit(...a), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { MCP_WRITE_TOOLS, MCP_WRITE_TOOL_NAMES } from "../write-tools"; +import type { McpAuthContext } from "../auth"; + +const CTX: McpAuthContext = { + userId: "u-1", + tokenId: "t-1", + scopes: ["health:read", "health:write"], + binding: "u-1:t-1", + canRead: true, + canWrite: true, +}; + +function tool(name: string) { + const t = MCP_WRITE_TOOLS.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +} + +beforeEach(() => { + vi.clearAllMocks(); + checkMcpWriteRateLimit.mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 1000, + }); +}); + +describe("write-tool surface", () => { + it("exposes exactly log_measurement and log_mood", () => { + expect([...MCP_WRITE_TOOL_NAMES].sort()).toEqual([ + "log_measurement", + "log_mood", + ]); + }); + + it("annotates writes as non-read-only, non-destructive, idempotent", () => { + for (const t of MCP_WRITE_TOOLS) { + expect(t.annotations).toEqual({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }); + } + }); +}); + +describe("confirm gate — log_measurement", () => { + it("confirm:false returns a preview and writes NOTHING", async () => { + const result = (await tool("log_measurement").run(CTX, { + type: "WEIGHT", + value: 80, + idempotencyKey: "k-1", + })) as Record; + + expect(result.requiresConfirmation).toBe(true); + expect(result.written).toBe(false); + expect(result.preview).toMatchObject({ + type: "WEIGHT", + value: 80, + source: "MCP", + }); + expect(logMcpMeasurement).not.toHaveBeenCalled(); + expect(checkMcpWriteRateLimit).not.toHaveBeenCalled(); + }); + + it("confirm:true executes the core and reports the written record", async () => { + logMcpMeasurement.mockResolvedValue({ + status: "written", + measurement: { + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: "x", + source: "MCP", + }, + }); + const result = (await tool("log_measurement").run(CTX, { + type: "WEIGHT", + value: 80, + confirm: true, + idempotencyKey: "k-1", + })) as Record; + + expect(logMcpMeasurement).toHaveBeenCalledTimes(1); + expect(logMcpMeasurement).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "u-1", + type: "WEIGHT", + value: 80, + idempotencyKey: "k-1", + }), + ); + expect(result.written).toBe(true); + }); + + it("an idempotent replay reports alreadyLogged, not a duplicate write", async () => { + logMcpMeasurement.mockResolvedValue({ + status: "already_logged", + measurement: { + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: "x", + source: "MCP", + }, + }); + const result = (await tool("log_measurement").run(CTX, { + type: "WEIGHT", + value: 80, + confirm: true, + idempotencyKey: "k-1", + })) as Record; + expect(result.written).toBe(false); + expect(result.alreadyLogged).toBe(true); + }); + + it("refuses to commit when the write budget is exhausted", async () => { + checkMcpWriteRateLimit.mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 1000, + }); + const result = (await tool("log_measurement").run(CTX, { + type: "WEIGHT", + value: 80, + confirm: true, + idempotencyKey: "k-1", + })) as Record; + expect(result.written).toBe(false); + expect(result.error).toBe("rate_limited"); + expect(logMcpMeasurement).not.toHaveBeenCalled(); + }); + + it("rejects a non-numeric value before any write", async () => { + const result = (await tool("log_measurement").run(CTX, { + type: "WEIGHT", + value: Number.NaN, + confirm: true, + idempotencyKey: "k-1", + })) as Record; + expect(result.written).toBe(false); + expect(logMcpMeasurement).not.toHaveBeenCalled(); + }); +}); + +describe("confirm gate — log_mood", () => { + it("confirm:false returns a preview and writes NOTHING", async () => { + const result = (await tool("log_mood").run(CTX, { + score: 4, + idempotencyKey: "m-1", + })) as Record; + expect(result.requiresConfirmation).toBe(true); + expect(result.written).toBe(false); + expect(logMcpMood).not.toHaveBeenCalled(); + }); + + it("confirm:true commits the mood entry", async () => { + logMcpMood.mockResolvedValue({ + status: "written", + moodEntry: { + score: 4, + mood: "GUT", + note: null, + date: "2026-06-27", + source: "MCP", + }, + }); + const result = (await tool("log_mood").run(CTX, { + score: 4, + confirm: true, + idempotencyKey: "m-1", + })) as Record; + expect(logMcpMood).toHaveBeenCalledTimes(1); + expect(result.written).toBe(true); + }); + + it("rejects an out-of-band score before any write", async () => { + const result = (await tool("log_mood").run(CTX, { + score: 9, + confirm: true, + idempotencyKey: "m-1", + })) as Record; + expect(result.written).toBe(false); + expect(logMcpMood).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/mcp/__tests__/writes.test.ts b/src/lib/mcp/__tests__/writes.test.ts new file mode 100644 index 000000000..6019d3dfd --- /dev/null +++ b/src/lib/mcp/__tests__/writes.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { measurement, moodEntry } = vi.hoisted(() => ({ + measurement: { + findUnique: vi.fn(), + create: vi.fn(), + }, + moodEntry: { + findUnique: vi.fn(), + create: vi.fn(), + }, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { measurement, moodEntry }, +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMeasurements: vi.fn(), + invalidateUserMood: vi.fn(), +})); + +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rollups/mood-rollups", () => ({ + recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/moodlog/push", () => ({ + pushMoodEntriesToMoodLog: vi.fn().mockResolvedValue(undefined), +})); + +const auditLog = vi.fn().mockResolvedValue(undefined); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: (...args: unknown[]) => auditLog(...args), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: () => ({ addMeta: vi.fn() }), +})); + +import { logMcpMeasurement, logMcpMood } from "../writes"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; + +beforeEach(() => { + vi.clearAllMocks(); + measurement.findUnique.mockResolvedValue(null); + measurement.create.mockResolvedValue({ id: "m-1" }); + moodEntry.findUnique.mockResolvedValue(null); + moodEntry.create.mockResolvedValue({ + date: "2026-06-27", + mood: "GUT", + note: null, + tags: [], + }); +}); + +describe("logMcpMeasurement", () => { + it("writes one row with source MCP for a capturable type", async () => { + const result = await logMcpMeasurement({ + userId: "u-1", + type: "WEIGHT", + value: 80, + idempotencyKey: "key-1", + }); + + expect(result.status).toBe("written"); + expect(measurement.create).toHaveBeenCalledTimes(1); + const data = measurement.create.mock.calls[0][0].data; + expect(data.source).toBe("MCP"); + expect(data.userId).toBe("u-1"); + expect(data.type).toBe("WEIGHT"); + expect(data.value).toBe(80); + expect(data.unit).toBe("kg"); + // externalId is namespaced + hashed (never the raw key). + expect(data.externalId).toMatch(/^mcp:measure:[0-9a-f]{64}$/); + expect(data.externalId).not.toContain("key-1"); + expect(invalidateUserMeasurements).toHaveBeenCalledWith("u-1", { + evict: true, + }); + expect(auditLog).toHaveBeenCalledWith( + "mcp.write.measurement", + expect.objectContaining({ userId: "u-1" }), + ); + }); + + it("refuses a non-capturable / clinical-only type without writing", async () => { + const result = await logMcpMeasurement({ + userId: "u-1", + type: "RECOVERY_SCORE" as never, + value: 50, + idempotencyKey: "key-x", + }); + expect(result.status).toBe("unsupported_type"); + expect(measurement.create).not.toHaveBeenCalled(); + }); + + it("refuses an out-of-range value without writing", async () => { + const result = await logMcpMeasurement({ + userId: "u-1", + type: "WEIGHT", + value: 99999, + idempotencyKey: "key-y", + }); + expect(result.status).toBe("out_of_range"); + expect(measurement.create).not.toHaveBeenCalled(); + }); + + it("is idempotent — a second call with the same key writes nothing", async () => { + measurement.findUnique.mockResolvedValue({ + id: "m-1", + value: 80, + unit: "kg", + measuredAt: new Date("2026-06-27T08:00:00Z"), + }); + const result = await logMcpMeasurement({ + userId: "u-1", + type: "WEIGHT", + value: 80, + idempotencyKey: "key-1", + }); + expect(result.status).toBe("already_logged"); + expect(measurement.create).not.toHaveBeenCalled(); + }); + + it("derives the same externalId for the same idempotencyKey", async () => { + await logMcpMeasurement({ + userId: "u-1", + type: "PULSE", + value: 60, + idempotencyKey: "stable", + }); + const first = measurement.create.mock.calls[0][0].data.externalId; + vi.clearAllMocks(); + measurement.findUnique.mockResolvedValue(null); + measurement.create.mockResolvedValue({ id: "m-2" }); + await logMcpMeasurement({ + userId: "u-1", + type: "PULSE", + value: 61, + idempotencyKey: "stable", + }); + const second = measurement.create.mock.calls[0][0].data.externalId; + expect(first).toBe(second); + }); +}); + +describe("logMcpMood", () => { + it("writes one mood row with source MCP", async () => { + const result = await logMcpMood({ + userId: "u-1", + score: 4, + idempotencyKey: "mood-1", + }); + expect(result.status).toBe("written"); + expect(moodEntry.create).toHaveBeenCalledTimes(1); + const data = moodEntry.create.mock.calls[0][0].data; + expect(data.source).toBe("MCP"); + expect(data.userId).toBe("u-1"); + expect(data.externalId).toMatch(/^mcp:mood:[0-9a-f]{64}$/); + expect(auditLog).toHaveBeenCalledWith( + "mcp.write.mood", + expect.objectContaining({ userId: "u-1" }), + ); + }); + + it("rejects an out-of-band score without writing", async () => { + const result = await logMcpMood({ + userId: "u-1", + score: 9, + idempotencyKey: "mood-bad", + }); + expect(result.status).toBe("invalid_score"); + expect(moodEntry.create).not.toHaveBeenCalled(); + }); + + it("is idempotent on the same key", async () => { + moodEntry.findUnique.mockResolvedValue({ + mood: "GUT", + score: 4, + note: null, + date: "2026-06-27", + }); + const result = await logMcpMood({ + userId: "u-1", + score: 4, + idempotencyKey: "mood-1", + }); + expect(result.status).toBe("already_logged"); + expect(moodEntry.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/mcp/index.ts b/src/lib/mcp/index.ts index da135b81b..1302cec27 100644 --- a/src/lib/mcp/index.ts +++ b/src/lib/mcp/index.ts @@ -10,6 +10,13 @@ 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_WRITE_TOOLS, MCP_WRITE_TOOL_NAMES } from "./write-tools"; +export { + logMcpMeasurement, + logMcpMood, + type McpMeasurementResult, + type McpMoodResult, +} from "./writes"; export { MCP_RESOURCES, MCP_RESOURCE_URIS, diff --git a/src/lib/mcp/oauth/config.ts b/src/lib/mcp/oauth/config.ts index d0597af59..2fc116c4b 100644 --- a/src/lib/mcp/oauth/config.ts +++ b/src/lib/mcp/oauth/config.ts @@ -114,9 +114,17 @@ export function audienceMatches( return want === got; } -/** The single scope the read MCP surface issues. */ +/** The read scope the MCP surface issues — the default posture. */ export const SCOPE_HEALTH_READ = "health:read"; +/** + * The write scope. Granted only on explicit request + consent; it admits the + * confirmed `/mcp` write tools. A token carrying it is STILL MCP-audience-bound + * (see `isMcpAudienceToken`) — it can never become a general REST write + * credential. + */ +export const SCOPE_HEALTH_WRITE = "health:write"; + /** * Scopes the AS advertises + will issue. `health:write` is advertised so a * future incremental-consent (SEP-835) challenge can request it, but the read @@ -125,7 +133,7 @@ export const SCOPE_HEALTH_READ = "health:read"; */ export const SUPPORTED_SCOPES = [ SCOPE_HEALTH_READ, - "health:write", + SCOPE_HEALTH_WRITE, "offline_access", ] as const; diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts index 92e8b6bf8..67b1d6872 100644 --- a/src/lib/mcp/server.ts +++ b/src/lib/mcp/server.ts @@ -8,14 +8,18 @@ * `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). + * Read tools are always registered. The write tools (`log_measurement` / + * `log_mood`) are registered ONLY for a `health:write`-scoped session + * (`ctx.canWrite`); a read-only session never has them advertised, so the + * read-only-by-default posture is structural (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_TOOLS, type McpToolDefinition } from "./tools"; +import { MCP_WRITE_TOOLS } from "./write-tools"; import { MCP_RESOURCES } from "./resources"; import { MCP_PROMPTS } from "./prompts"; @@ -33,7 +37,9 @@ export function createMcpServer(ctx: McpAuthContext): McpServer { version: MCP_SERVER_VERSION, }); - for (const tool of MCP_TOOLS) { + // Register one tool definition. Shared by the read registry and the + // write-scoped registry so the wire shape can never fork between them. + const registerTool = (tool: McpToolDefinition) => { server.registerTool( tool.name, { @@ -41,8 +47,9 @@ export function createMcpServer(ctx: McpAuthContext): McpServer { 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). + // Apps SDK treats an omitted hint as a validation error. Read tools + // are read-only/non-destructive; write tools advertise + // `readOnlyHint:false` so a host can add human-in-the-loop (ADR-003). annotations: tool.annotations, ...(tool.outputShape ? { outputSchema: tool.outputShape } : {}), }, @@ -63,6 +70,19 @@ export function createMcpServer(ctx: McpAuthContext): McpServer { return { content }; }, ); + }; + + for (const tool of MCP_TOOLS) { + registerTool(tool); + } + + // Write tools are advertised ONLY to a write-scoped session. A read-only + // (`health:read`) token never sees them in the capability list — the + // read-only posture is structural, not a runtime flag a tool could flip. + if (ctx.canWrite) { + for (const tool of MCP_WRITE_TOOLS) { + registerTool(tool); + } } for (const prompt of MCP_PROMPTS) { diff --git a/src/lib/mcp/write-tools.ts b/src/lib/mcp/write-tools.ts new file mode 100644 index 000000000..eb1497ad2 --- /dev/null +++ b/src/lib/mcp/write-tools.ts @@ -0,0 +1,261 @@ +/** + * Transport-agnostic MCP write-tool registry — the confirmed write surface. + * + * Registered ONLY for a `health:write`-scoped session (`ctx.canWrite`); a + * read-only session never sees these tools advertised. Each tool wraps an + * in-process write core (`src/lib/mcp/writes.ts`) that mirrors the proven + * Telegram capture helpers, pinned to the `MCP` provenance. + * + * PROTOCOL-LEVEL CONFIRM GATE. The remote `/mcp` wire is stateless + * (`enableJsonResponse: true`, no SSE) so server→client elicitation is + * impossible. Instead every write tool takes an explicit `confirm` flag: + * + * - `confirm:false` (default) → NOTHING is written. The tool returns the + * exact normalized record it WOULD write plus `requiresConfirmation:true` + * and an instruction telling the assistant to confirm the value with the + * user and re-call with `confirm:true` and the SAME `idempotencyKey`. + * - `confirm:true` → the write executes. The `(userId, …, externalId)` + * idempotency derived from `idempotencyKey` makes a retried call a no-op + * (`alreadyLogged:true`) rather than a duplicate. + * + * DELIBERATE SAFETY BOUNDARY — what is NOT here and must NOT be added without + * a security review: + * - No medication create / intake / edit, no schedule or dose changes. + * - No lab write, no clinical-threshold or reference-range change. + * - No delete or update of any existing row (writes are append-only, + * idempotent inserts of single self-reported readings). + * - No `export_data` / data-export tool. If one is ever added it must be + * write-tier AND never co-registered with any egress on the same surface. + * The write surface is intentionally the smallest possible: a confirmed, + * append-only log of a measurement or a mood the user reports themselves. + */ +import { z } from "zod/v4"; + +import type { MeasurementType } from "@/generated/prisma/client"; +import { annotate } from "@/lib/logging/context"; +import { checkMcpWriteRateLimit } from "@/lib/rate-limit"; +import type { McpToolDefinition } from "./tools"; +import { logMcpMeasurement, logMcpMood } from "./writes"; + +/** + * Write-tool annotations (MCP 2025-11-25). NOT read-only and NOT destructive + * (an append-only, idempotent insert of a single self-reported reading), and + * idempotent so a retried call under the same `idempotencyKey` is safe. A host + * that adds human-in-the-loop confirmation keys off `readOnlyHint:false`. + */ +const WRITE_ANNOTATIONS = { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +} as const; + +/** Shared confirm-gate inputs every write tool carries. */ +const confirmField = z + .boolean() + .default(false) + .describe( + "Set to true ONLY after confirming the value with the user. When false or absent the tool returns a preview and writes nothing.", + ); +const idempotencyKeyField = z + .string() + .min(1) + .max(120) + .describe( + "A stable client-chosen key for this logical write. Re-calling with the same key (after a preview) commits exactly one row; a further retry is a safe no-op.", + ); + +/** The instruction the preview hands back to the assistant. */ +const CONFIRM_INSTRUCTION = + "This writes to the user's own health record. Confirm the value with the user, then call this tool again with confirm:true and the SAME idempotencyKey to commit it."; + +/** + * Enforce the tighter per-credential write budget before a commit. Returns a + * structured refusal when the budget is exhausted so a runaway client cannot + * flood writes (this is ON TOP of the `/mcp` per-binding request limiter). + */ +async function writeBudgetOk(binding: string): Promise { + const rl = await checkMcpWriteRateLimit(binding); + if (!rl.allowed) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "write", status: "rate_limited" }, + }); + } + return rl.allowed; +} + +export const MCP_WRITE_TOOLS: McpToolDefinition[] = [ + { + name: "log_measurement", + title: "Log a measurement", + description: + "Log ONE single-value health measurement to the user's own record (e.g. weight, pulse, blood glucose, body temperature, oxygen saturation, a body-composition value). Requires confirmation: call once to preview the exact record, confirm the value with the user, then call again with confirm:true and the same idempotencyKey. Blood pressure (two values) and computed/clinical-only metrics are not loggable here.", + inputShape: { + type: z + .string() + .min(1) + .max(60) + .describe( + "The measurement type, e.g. WEIGHT, PULSE, BLOOD_GLUCOSE, BODY_TEMPERATURE, OXYGEN_SATURATION, BODY_FAT. Not all types are loggable; an unsupported type is refused.", + ), + value: z + .number() + .describe("The numeric reading in the type's canonical unit."), + unit: z + .string() + .min(1) + .max(20) + .optional() + .describe( + "Optional unit hint; the server uses the type's canonical unit when omitted.", + ), + measuredAt: z.iso + .datetime({ offset: true }) + .optional() + .describe( + "Optional ISO-8601 instant the reading was taken; defaults to now.", + ), + confirm: confirmField, + idempotencyKey: idempotencyKeyField, + }, + annotations: WRITE_ANNOTATIONS, + async run(ctx, args) { + const type = String(args.type ?? "") as MeasurementType; + const value = typeof args.value === "number" ? args.value : Number.NaN; + const unit = typeof args.unit === "string" ? args.unit : undefined; + const measuredAt = + typeof args.measuredAt === "string" + ? new Date(args.measuredAt) + : undefined; + const confirm = args.confirm === true; + const idempotencyKey = + typeof args.idempotencyKey === "string" ? args.idempotencyKey : ""; + + if (!Number.isFinite(value)) { + return { written: false, error: "invalid_number" }; + } + + if (!confirm) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_measurement", status: "preview" }, + }); + return { + requiresConfirmation: true, + written: false, + preview: { + type, + value, + unit: unit ?? null, + measuredAt: measuredAt ? measuredAt.toISOString() : "now", + source: "MCP", + }, + instruction: CONFIRM_INSTRUCTION, + }; + } + + if (!(await writeBudgetOk(ctx.binding))) { + return { written: false, error: "rate_limited" }; + } + + const result = await logMcpMeasurement({ + userId: ctx.userId, + type, + value, + unit, + measuredAt, + idempotencyKey, + }); + + if (result.status === "unsupported_type") { + return { written: false, error: "unsupported_type" }; + } + if (result.status === "out_of_range") { + return { written: false, error: "out_of_range", reason: result.reason }; + } + if (result.status === "already_logged") { + return { + written: false, + alreadyLogged: true, + record: result.measurement, + }; + } + return { written: true, record: result.measurement }; + }, + }, + { + name: "log_mood", + title: "Log a mood entry", + description: + "Log the user's mood for today on a 1-5 scale (1 = very low, 5 = very good) to their own record, with an optional short note. Requires confirmation: call once to preview, confirm with the user, then call again with confirm:true and the same idempotencyKey to commit.", + inputShape: { + score: z + .number() + .int() + .min(1) + .max(5) + .describe("Mood score 1-5 (1 = very low, 5 = very good)."), + note: z + .string() + .max(500) + .optional() + .describe("Optional short free-text note (max 500 chars)."), + confirm: confirmField, + idempotencyKey: idempotencyKeyField, + }, + annotations: WRITE_ANNOTATIONS, + async run(ctx, args) { + const score = typeof args.score === "number" ? args.score : Number.NaN; + const note = typeof args.note === "string" ? args.note : undefined; + const confirm = args.confirm === true; + const idempotencyKey = + typeof args.idempotencyKey === "string" ? args.idempotencyKey : ""; + + if (!Number.isInteger(score) || score < 1 || score > 5) { + return { written: false, error: "invalid_score" }; + } + + if (!confirm) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_mood", status: "preview" }, + }); + return { + requiresConfirmation: true, + written: false, + preview: { score, note: note ?? null, source: "MCP" }, + instruction: CONFIRM_INSTRUCTION, + }; + } + + if (!(await writeBudgetOk(ctx.binding))) { + return { written: false, error: "rate_limited" }; + } + + const result = await logMcpMood({ + userId: ctx.userId, + score, + note, + idempotencyKey, + }); + + if (result.status === "invalid_score") { + return { written: false, error: "invalid_score" }; + } + if (result.status === "already_logged") { + return { + written: false, + alreadyLogged: true, + record: result.moodEntry, + }; + } + return { written: true, record: result.moodEntry }; + }, + }, +]; + +/** Stable list of the registered write-tool names. */ +export const MCP_WRITE_TOOL_NAMES: readonly string[] = MCP_WRITE_TOOLS.map( + (t) => t.name, +); diff --git a/src/lib/mcp/writes.ts b/src/lib/mcp/writes.ts new file mode 100644 index 000000000..11e26bcf8 --- /dev/null +++ b/src/lib/mcp/writes.ts @@ -0,0 +1,316 @@ +/** + * MCP write data cores — in-process measurement + mood logging. + * + * These mirror the proven server-authoritative Telegram capture helpers + * (`src/lib/measurements/create-from-telegram.ts`, + * `src/lib/mood/create-from-telegram.ts`) one-for-one — same range guard, + * same canonical unit, same cache invalidation + rollup recompute — but pin + * `source` to the dedicated `MCP` provenance and use an MCP idempotency + * namespace. The two surfaces are kept as deliberate parallels rather than a + * shared core so a future change to one cannot silently alter the other's + * validation or dedup contract. + * + * `userId` is ALWAYS the resolved session id (`McpAuthContext.userId`), never + * a caller-supplied field. The write tools (`src/lib/mcp/write-tools.ts`) own + * the confirm gate; these cores only execute a confirmed write. + */ +import { createHash } from "node:crypto"; + +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { + getUnitForType, + validateMeasurementRange, +} from "@/lib/validations/measurement"; +import { isTelegramCapturableType } from "@/lib/measurements/create-from-telegram"; +import { + invalidateUserMeasurements, + invalidateUserMood, +} from "@/lib/cache/invalidate"; +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; +import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; +import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels"; +import { getScoreForMood } from "@/lib/validations/moodlog"; +import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push"; + +/** The MCP write provenance — distinct from MANUAL / TELEGRAM. */ +const MCP_SOURCE = "MCP" as const; + +/** + * Derive the stable per-write `externalId` from the caller's idempotency key. + * A SHA-256 hash keeps the column bounded and opaque regardless of how long + * the assistant's key is, and gives the MCP writes their own dedup namespace + * `(userId, type, MCP, externalId)` so a key can never collide with a + * Telegram or manual row. + */ +function mcpExternalId( + prefix: "measure" | "mood", + idempotencyKey: string, +): string { + const digest = createHash("sha256").update(idempotencyKey).digest("hex"); + return `mcp:${prefix}:${digest}`; +} + +/** Normalized record echoed back to the assistant (preview + commit result). */ +export interface McpMeasurementRecord { + type: MeasurementType; + value: number; + unit: string; + measuredAt: string; + source: typeof MCP_SOURCE; +} + +export type McpMeasurementResult = + | { status: "unsupported_type" } + | { status: "out_of_range"; reason: string } + | { status: "written"; measurement: McpMeasurementRecord } + | { status: "already_logged"; measurement: McpMeasurementRecord }; + +/** + * Log one single-value measurement on behalf of the resolved MCP session. + * + * Mirrors `logTelegramMeasurement`: gates the type on the SAME + * `isTelegramCapturableType` allowlist (the safe, user-loggable subset that + * excludes COMPUTED / WHOOP / clinical-only types), validates the plausibility + * range, and is idempotent on `(userId, type, MCP, externalId)`. + */ +export async function logMcpMeasurement(input: { + userId: string; + type: MeasurementType; + value: number; + unit?: string; + measuredAt?: Date; + idempotencyKey: string; +}): Promise { + if (!isTelegramCapturableType(input.type)) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_measurement", status: "unsupported_type" }, + }); + return { status: "unsupported_type" }; + } + + const rangeError = validateMeasurementRange(input.type, input.value); + if (rangeError !== null) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_measurement", status: "out_of_range" }, + }); + return { status: "out_of_range", reason: rangeError }; + } + + const unit = input.unit ?? getUnitForType(input.type); + const measuredAt = input.measuredAt ?? new Date(); + const externalId = mcpExternalId("measure", input.idempotencyKey); + + const record: McpMeasurementRecord = { + type: input.type, + value: input.value, + unit, + measuredAt: measuredAt.toISOString(), + source: MCP_SOURCE, + }; + + const existing = await prisma.measurement.findUnique({ + where: { + userId_type_source_externalId: { + userId: input.userId, + type: input.type, + source: MCP_SOURCE, + externalId, + }, + }, + select: { id: true, value: true, unit: true, measuredAt: true }, + }); + if (existing) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_measurement", status: "already_logged" }, + }); + return { + status: "already_logged", + measurement: { + type: input.type, + value: existing.value, + unit: existing.unit ?? unit, + measuredAt: existing.measuredAt.toISOString(), + source: MCP_SOURCE, + }, + }; + } + + // No mass assignment — every column is set explicitly from validated input. + await prisma.measurement.create({ + data: { + userId: input.userId, + type: input.type, + value: input.value, + unit, + source: MCP_SOURCE, + measuredAt, + externalId, + }, + }); + + invalidateUserMeasurements(input.userId, { evict: true }); + + // Best-effort rollup refresh — a cache tier, never a write-path invariant. + try { + await recomputeBucketsForMeasurement(input.userId, input.type, measuredAt); + } catch (rollupErr) { + getEvent()?.addMeta( + "mcp_measurement_rollup_failed", + rollupErr instanceof Error ? rollupErr.message : String(rollupErr), + ); + } + + await auditLog("mcp.write.measurement", { + userId: input.userId, + details: { + type: input.type, + source: MCP_SOURCE, + idempotencyKey: input.idempotencyKey, + }, + }); + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_measurement", status: "written" }, + }); + + return { status: "written", measurement: record }; +} + +/** Normalized mood record echoed back to the assistant. */ +export interface McpMoodRecord { + score: number; + mood: string; + note: string | null; + date: string; + source: typeof MCP_SOURCE; +} + +export type McpMoodResult = + | { status: "invalid_score" } + | { status: "written"; moodEntry: McpMoodRecord } + | { status: "already_logged"; moodEntry: McpMoodRecord }; + +/** + * Log one mood entry on behalf of the resolved MCP session. + * + * Mirrors `logTelegramMood`: maps the 1..5 score to the canonical mood enum, + * anchors the date key in the user's timezone, and is idempotent on the + * NULL-distinct `(userId, source, externalId)` unique with `source = "MCP"`. + */ +export async function logMcpMood(input: { + userId: string; + score: number; + note?: string | null; + tz?: string | null; + idempotencyKey: string; +}): Promise { + const mood = MOOD_ENUM_BY_SCORE[input.score]; + if (!mood) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_mood", status: "invalid_score" }, + }); + return { status: "invalid_score" }; + } + + const score = getScoreForMood(mood); + const note = input.note ? input.note.slice(0, 500) : null; + const moodLoggedAt = new Date(); + const tz = input.tz ?? DEFAULT_TIMEZONE; + const date = moodDateKey(moodLoggedAt, tz); + const externalId = mcpExternalId("mood", input.idempotencyKey); + + const existing = await prisma.moodEntry.findUnique({ + where: { + userId_source_externalId: { + userId: input.userId, + source: MCP_SOURCE, + externalId, + }, + }, + select: { mood: true, score: true, note: true, date: true }, + }); + if (existing) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_mood", status: "already_logged" }, + }); + return { + status: "already_logged", + moodEntry: { + score: existing.score, + mood: existing.mood, + note: existing.note ?? null, + date: existing.date, + source: MCP_SOURCE, + }, + }; + } + + // No mass assignment — every column is set explicitly from validated input. + const entry = await prisma.moodEntry.create({ + data: { + userId: input.userId, + date, + tz, + mood, + score, + note, + source: MCP_SOURCE, + externalId, + moodLoggedAt, + }, + select: { date: true, mood: true, note: true, tags: true }, + }); + + invalidateUserMood(input.userId); + + // Best-effort rollup refresh — a cache tier, never a write-path invariant. + try { + await recomputeMoodBucketsForEntry(input.userId, moodLoggedAt); + } catch (rollupErr) { + getEvent()?.addMeta( + "mcp_mood_rollup_failed", + rollupErr instanceof Error ? rollupErr.message : String(rollupErr), + ); + } + + // Reverse-sync to MoodLog (fire-and-forget; never throws). + void pushMoodEntriesToMoodLog(input.userId, [ + { + date: entry.date, + moodLoggedAt, + mood: entry.mood, + note: entry.note ?? null, + tags: entry.tags, + source: MCP_SOURCE, + }, + ]).catch(() => {}); + + await auditLog("mcp.write.mood", { + userId: input.userId, + details: { source: MCP_SOURCE, idempotencyKey: input.idempotencyKey }, + }); + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_mood", status: "written" }, + }); + + return { + status: "written", + moodEntry: { + score, + mood: entry.mood, + note: entry.note ?? null, + date: entry.date, + source: MCP_SOURCE, + }, + }; +} diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 90f1c5968..d72de6da5 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -105,6 +105,28 @@ export async function checkMcpRateLimit( return checkRateLimit(`mcp:${binding}`, MCP_LIMIT, MCP_WINDOW_MS); } +/** + * v1.22.0 — tighter per-credential ceiling for the remote MCP WRITE surface + * (the `log_measurement` / `log_mood` tools), applied ON TOP of the general + * `/mcp` per-binding request limiter. Keyed by the same `:` + * binding so a single leaked / shared write-scoped token cannot flood writes + * into the account, and the cap is scoped to the exact credential in use. + * Writes are deliberate, low-frequency events, so this is far tighter than the + * read budget — 60 confirmed writes per 5 minutes only caps a runaway loop. + */ +const MCP_WRITE_LIMIT = 60; +const MCP_WRITE_WINDOW_MS = 5 * 60 * 1000; + +export async function checkMcpWriteRateLimit( + binding: string, +): Promise { + return checkRateLimit( + `mcp-write:${binding}`, + MCP_WRITE_LIMIT, + MCP_WRITE_WINDOW_MS, + ); +} + /** * v1.16.16 — per-user ceiling for the AI-consent surfaces * (`POST /api/consent/ai`, `POST /api/consent/ai/web`, From ddfe9170bda84dcee0a33213aa04045ea3ab8ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 17:38:51 +0200 Subject: [PATCH 11/11] feat(mcp): allow minting and consenting to health:write connector tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let a user opt a connector token into logging. The settings card gains a write toggle; the mint endpoint accepts a closed scope choice (read, or read_write) and builds the permission array explicitly — it can mint only [health:read] or [health:read, health:write], never a wildcard or any other grant. The OAuth flow grants health:write only when a client requests it, and the consent screen states in plain language that the app will be able to read AND log/write health data when write is requested; the token endpoint grants exactly the approved scopes and never widens. Read-only behaviour is unchanged. Add the English and German strings for the new toggle and warning. --- 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/api/mcp/oauth/authorize/route.ts | 19 +++- src/app/api/mcp/oauth/token/route.ts | 21 +++- .../api/mcp/tokens/__tests__/route.test.ts | 95 +++++++++++++++++++ src/app/api/mcp/tokens/route.ts | 31 ++++-- src/components/settings/mcp-section.tsx | 24 ++++- 11 files changed, 195 insertions(+), 19 deletions(-) create mode 100644 src/app/api/mcp/tokens/__tests__/route.test.ts diff --git a/messages/de.json b/messages/de.json index 0e5669e2b..fadbd09da 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4907,7 +4907,9 @@ "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 Admin-Funktionen erreichen.", + "writeScopeToggleLabel": "Eintragen erlauben (health:write)", + "writeScopeNote": "Fügt den Scope health:write hinzu, damit der Assistent neue Messwerte und Stimmungseinträge in dein Konto eintragen kann. Jeder Schreibvorgang wird zuvor bestätigt. Das Token kann weiterhin keine vorhandenen Einträge löschen oder ändern, keine Medikamente bearbeiten und keine REST- oder Admin-Funktionen erreichen. Für reinen Lesezugriff ausgeschaltet lassen.", "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.", diff --git a/messages/en.json b/messages/en.json index 0b1200347..f63071e40 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4907,7 +4907,9 @@ "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 reach admin functions.", + "writeScopeToggleLabel": "Allow logging (health:write)", + "writeScopeNote": "Adds the health:write scope so the assistant can log new measurements and mood entries to your account. Every write is confirmed first. The token still can't delete or edit existing entries, change medications, or reach REST or admin functions. Leave off for read-only access.", "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.", diff --git a/messages/es.json b/messages/es.json index cac512da5..60e3b7a3d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4907,7 +4907,9 @@ "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 acceder a funciones de administración.", + "writeScopeToggleLabel": "Permitir registro (health:write)", + "writeScopeNote": "Añade el ámbito health:write para que el asistente pueda registrar nuevas mediciones y entradas de estado de ánimo en tu cuenta. Cada escritura se confirma antes. El token sigue sin poder eliminar ni editar entradas existentes, cambiar medicamentos ni acceder a funciones REST o de administración. Déjalo desactivado para acceso de solo lectura.", "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.", diff --git a/messages/fr.json b/messages/fr.json index ff0d19256..526b0f90e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4907,7 +4907,9 @@ "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 atteindre les fonctions d'administration.", + "writeScopeToggleLabel": "Autoriser l'enregistrement (health:write)", + "writeScopeNote": "Ajoute la portée health:write pour que l'assistant puisse enregistrer de nouvelles mesures et entrées d'humeur dans votre compte. Chaque écriture est d'abord confirmée. Le jeton ne peut toujours pas supprimer ni modifier des entrées existantes, changer les médicaments, ni atteindre les fonctions REST ou d'administration. Laissez désactivé pour un accès en lecture seule.", "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é.", diff --git a/messages/it.json b/messages/it.json index 40fd73e45..1f1b43722 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4907,7 +4907,9 @@ "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 raggiungere funzioni di amministrazione.", + "writeScopeToggleLabel": "Consenti la registrazione (health:write)", + "writeScopeNote": "Aggiunge l'ambito health:write così l'assistente può registrare nuove misurazioni e voci dell'umore nel tuo account. Ogni scrittura viene prima confermata. Il token non può comunque eliminare o modificare voci esistenti, cambiare i farmaci o raggiungere funzioni REST o di amministrazione. Lascia disattivato per l'accesso in sola lettura.", "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.", diff --git a/messages/pl.json b/messages/pl.json index cfbf4ef2b..3f7d37797 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4907,7 +4907,9 @@ "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ą uzyskać dostępu do funkcji administracyjnych.", + "writeScopeToggleLabel": "Zezwól na zapis (health:write)", + "writeScopeNote": "Dodaje zakres health:write, aby asystent mógł zapisywać nowe pomiary i wpisy nastroju na Twoim koncie. Każdy zapis jest najpierw potwierdzany. Token nadal nie może usuwać ani edytować istniejących wpisów, zmieniać leków ani uzyskiwać dostępu do funkcji REST lub administracyjnych. Pozostaw wyłączone, aby uzyskać dostęp tylko do odczytu.", "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.", diff --git a/src/app/api/mcp/oauth/authorize/route.ts b/src/app/api/mcp/oauth/authorize/route.ts index 70288aa07..f4c0ccfdc 100644 --- a/src/app/api/mcp/oauth/authorize/route.ts +++ b/src/app/api/mcp/oauth/authorize/route.ts @@ -38,6 +38,7 @@ import { isMcpOriginConfigured, resolveBaseOrigin, SCOPE_HEALTH_READ, + SCOPE_HEALTH_WRITE, } from "@/lib/mcp/oauth/config"; import { redirectUriAllowed, resolveClient } from "@/lib/mcp/oauth/clients"; import { isValidChallenge } from "@/lib/mcp/oauth/pkce"; @@ -86,12 +87,19 @@ function html(body: string, status = 200): Response { ); } -/** The single scope this read surface grants, plus optional refresh continuity. */ +/** + * The scopes this surface grants for a request. `health:read` is always + * granted (the floor). `health:write` is granted ONLY when explicitly + * requested (RFC 8707 / incremental consent, SEP-835) — it admits the + * confirmed `/mcp` write tools and is surfaced plainly on the consent screen. + * `offline_access` enables refresh continuity. Nothing else is ever granted. + */ 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(SCOPE_HEALTH_WRITE)) granted.push(SCOPE_HEALTH_WRITE); if (wants.has("offline_access")) granted.push("offline_access"); return granted.join(" "); } @@ -299,12 +307,19 @@ export async function GET(request: NextRequest): Promise { v.clientSource === "cimd" ? `

Verified origin: ${htmlEscape(v.clientOrigin)}

` : `

Unverified application (dynamically registered — the name above is self-reported and not verified).

`; + // Plain-language access summary. When write is granted, say so explicitly — + // the app will be able to READ and LOG/WRITE health data, not just read. + const writeGranted = v.scope.split(/\s+/).includes(SCOPE_HEALTH_WRITE); + const accessSummary = writeGranted + ? `

This grants read AND write access: the application will be able to read your own health records and log new measurements and mood entries to your account on your behalf. It cannot delete or change existing entries, edit medications, or reach admin functions.

` + : `

Scope: ${htmlEscape(v.scope)} — read-only access to your own health records.

`; 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.

+

Scope: ${htmlEscape(v.scope)}

+ ${accessSummary} ${hidden("response_type", "code")} ${hidden("client_id", v.clientId)} diff --git a/src/app/api/mcp/oauth/token/route.ts b/src/app/api/mcp/oauth/token/route.ts index 749d2f917..c61342fdd 100644 --- a/src/app/api/mcp/oauth/token/route.ts +++ b/src/app/api/mcp/oauth/token/route.ts @@ -2,8 +2,11 @@ * 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 + * `ApiToken` (the access token) minted through `issue-token.ts` with EXACTLY + * the scopes the authorization code carried — `health:read`, or + * `health:read health:write` when the user consented to logging (ADR-006). A + * `health:write` token is still audience-bound to `/mcp`. 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. @@ -42,6 +45,7 @@ import { isMcpOriginConfigured, REFRESH_TOKEN_TTL_DAYS, SCOPE_HEALTH_READ, + SCOPE_HEALTH_WRITE, } from "@/lib/mcp/oauth/config"; import { signArtifact, verifyArtifact } from "@/lib/mcp/oauth/artifacts"; import { verifyPkceS256 } from "@/lib/mcp/oauth/pkce"; @@ -124,10 +128,21 @@ async function mintTokenPair(args: { connectionId?: string; refreshJti?: string; }): Promise { + // Grant EXACTLY the scopes the authorization code carried (read, or + // read+write). `health:read` is always present; `health:write` is added + // only when the user consented to it at the authorize step. The token is + // never widened beyond the approved grant, and `offline_access` is a + // refresh-continuity marker, not an API permission. + const grantedScopes = new Set(args.scope.split(/\s+/).filter(Boolean)); + const permissions = [SCOPE_HEALTH_READ]; + if (grantedScopes.has(SCOPE_HEALTH_WRITE)) { + permissions.push(SCOPE_HEALTH_WRITE); + } + const access = await issueApiToken({ userId: args.userId, name: "MCP connector", - permissions: [SCOPE_HEALTH_READ], + permissions, expiresInMinutes: ACCESS_TOKEN_TTL_MINUTES, ...(args.connectionId ? { mcpConnectionId: args.connectionId } : {}), }); diff --git a/src/app/api/mcp/tokens/__tests__/route.test.ts b/src/app/api/mcp/tokens/__tests__/route.test.ts new file mode 100644 index 000000000..9455ccc45 --- /dev/null +++ b/src/app/api/mcp/tokens/__tests__/route.test.ts @@ -0,0 +1,95 @@ +/** + * Guard: the MCP connector mint endpoint mints ONLY the two allowed scope + * shapes — `["health:read"]` (default) or `["health:read","health:write"]` — + * and never a wildcard or any other grant. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + apiToken: { findMany: vi.fn() }, + auditLog: { create: vi.fn() }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` })); +vi.mock("@/lib/app-settings", () => ({ isApiGloballyEnabled: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/auth/issue-token", () => ({ + issueApiToken: vi.fn(), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ get: () => undefined })), +})); + +import { POST } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { issueApiToken } from "@/lib/auth/issue-token"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +function postReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/mcp/tokens", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); + vi.mocked(issueApiToken).mockResolvedValue({ + token: "hlk_abc", + tokenId: "tok-1", + name: "n", + expiresAt: new Date(), + } as never); +}); + +describe("POST /api/mcp/tokens — scope minting", () => { + it("defaults to read-only [health:read]", async () => { + const res = await POST(postReq({ name: "laptop" })); + expect(res.status).toBe(201); + expect(issueApiToken).toHaveBeenCalledWith( + expect.objectContaining({ permissions: ["health:read"] }), + ); + }); + + it("mints read+write when scope=read_write", async () => { + const res = await POST(postReq({ name: "laptop", scope: "read_write" })); + expect(res.status).toBe(201); + expect(issueApiToken).toHaveBeenCalledWith( + expect.objectContaining({ + permissions: ["health:read", "health:write"], + }), + ); + }); + + it("rejects any scope outside the closed set (no wildcard path)", async () => { + for (const bad of ["*", "admin", "health:write", "write", "read_admin"]) { + vi.mocked(issueApiToken).mockClear(); + const res = await POST(postReq({ name: "x", scope: bad })); + expect(res.status).toBe(422); + expect(issueApiToken).not.toHaveBeenCalled(); + } + }); + + it("never mints a wildcard for any input", async () => { + await POST(postReq({ name: "a" })); + await POST(postReq({ name: "b", scope: "read_write" })); + for (const call of vi.mocked(issueApiToken).mock.calls) { + expect(call[0].permissions).not.toContain("*"); + } + }); +}); diff --git a/src/app/api/mcp/tokens/route.ts b/src/app/api/mcp/tokens/route.ts index 5140a001d..dfa644806 100644 --- a/src/app/api/mcp/tokens/route.ts +++ b/src/app/api/mcp/tokens/route.ts @@ -8,9 +8,14 @@ * `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). + * `userId` is always narrowed from the session — never a body field. The + * caller may choose ONE of exactly two shapes via `scope`: `read` → + * `["health:read"]` (default) or `read_write` → `["health:read", + * "health:write"]`. The `permissions` array is built explicitly from that + * closed choice, so this endpoint can never be coerced into minting a + * wildcard or any other grant (no mass assignment). A `health:write` token is + * still audience-bound to `/mcp` (the resource-server guard refuses it on + * every REST write) — it only admits the confirmed in-process write tools. */ import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -27,11 +32,16 @@ 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"; +import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } 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(), + // The ONLY two scope shapes this endpoint will mint. `read` (default) is the + // least-privilege read token; `read_write` adds `health:write` so the + // confirmed `/mcp` write tools become available. Anything else is rejected + // by the enum — the endpoint can never mint a wildcard or arbitrary grant. + scope: z.enum(["read", "read_write"]).optional().default("read"), }); export const GET = apiHandler(async () => { @@ -84,17 +94,24 @@ export const POST = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } + // Build the permission array explicitly from the closed scope choice — + // never spread, never wildcard. `read` → read-only; `read_write` → read plus + // the `/mcp`-audience-bound write scope. + const permissions = + parsed.data.scope === "read_write" + ? [SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE] + : [SCOPE_HEALTH_READ]; + 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], + permissions, expiresInDays: parsed.data.expiresInDays ?? 90, }); await auditLog("mcp.tokens.create", { userId: user.id, - details: { tokenId: issued.tokenId, scope: SCOPE_HEALTH_READ }, + details: { tokenId: issued.tokenId, scope: permissions.join(" ") }, }); // Return the raw token ONCE — it can never be retrieved again. diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx index fdb8f58da..0b79f081a 100644 --- a/src/components/settings/mcp-section.tsx +++ b/src/components/settings/mcp-section.tsx @@ -257,6 +257,7 @@ function McpTokensCard() { const { isAuthenticated } = useAuth(); const queryClient = useQueryClient(); const [newName, setNewName] = useState(""); + const [allowWrite, setAllowWrite] = useState(false); const [newToken, setNewToken] = useState(null); const [creating, setCreating] = useState(false); const [tokenMsg, setTokenMsg] = useState(null); @@ -277,12 +278,17 @@ function McpTokensCard() { const res = await apiFetchRaw("/api/mcp/tokens", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: newName.trim() }), + body: JSON.stringify({ + name: newName.trim(), + // Closed choice — read-only, or read + the audience-bound write scope. + scope: allowWrite ? "read_write" : "read", + }), }); const json = await res.json(); if (res.ok) { setNewToken(json.data.token); setNewName(""); + setAllowWrite(false); queryClient.invalidateQueries({ queryKey: queryKeys.mcpTokens() }); } else { setTokenMsg(json.error || t("common.error")); @@ -336,6 +342,22 @@ function McpTokensCard() { {t("settings.mcp.scopeNote")}

+
+
+ + {t("settings.mcp.writeScopeToggleLabel")} + + +
+

+ {t("settings.mcp.writeScopeNote")} +

+
+