mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
merge: bring the MCP server onto the v1.23 base
Combine the remote MCP server (stdio + remote /mcp + OAuth 2.1 + read tools + deep-value reads + a confirmed, audience-bound write surface) with the v1.23 account-security line (MFA, known/trusted devices, free-text note encryption). - schema: keep every v1.23 User relation (MFA, known/trusted devices) alongside the McpOAuthConnection relation; the MeasurementSource enum carries the MCP value. - api-handler: the MCP audience guard (a token scoped to health:read/health:write is bound to /mcp and refused a REST write) and the step-up MFA helpers coexist. - maintenance queues: register the MCP token-cleanup sweep next to the note-encryption backfill and key-rotation queues; shift it to 04:00 so the two daily discovery ticks do not collide. - migrations: renumber the MCP migrations to 0206/0207 so they follow the v1.23 chain (0199-0205). - MCP mood writes route the note through the v1.23 at-rest cipher.
This commit is contained in:
commit
fb986bc09d
89 files changed
+10276
-109
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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";
|
||||
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), {
|
||||
// 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" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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";
|
||||
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), {
|
||||
// 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" },
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 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(() => ({})),
|
||||
}));
|
||||
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";
|
||||
|
||||
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, string | undefined> = {},
|
||||
): string {
|
||||
const params: Record<string, string | undefined> = {
|
||||
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<string, string>): 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);
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
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("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(
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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(() => ({})),
|
||||
}));
|
||||
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", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* 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(() => ({})),
|
||||
}));
|
||||
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";
|
||||
|
||||
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, unknown> = {}): 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<string, string>): 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,
|
||||
});
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
vi.mocked(createConnection).mockResolvedValue("conn-1");
|
||||
vi.mocked(rotateConnection).mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
function refreshToken(overrides: Record<string, unknown> = {}): 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(
|
||||
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("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({
|
||||
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 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(),
|
||||
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: legacy,
|
||||
client_id: CLIENT_ID,
|
||||
}) as never,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* 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 { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import {
|
||||
audienceMatches,
|
||||
AUTH_CODE_TTL_SECONDS,
|
||||
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";
|
||||
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, """)
|
||||
.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(
|
||||
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>HealthLog — Authorize access</title></head><body>${body}</body></html>`,
|
||||
{
|
||||
status,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(" ");
|
||||
}
|
||||
|
||||
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;
|
||||
state?: string;
|
||||
resource: string;
|
||||
}
|
||||
|
||||
/** Shared validation for GET + POST. Returns a ready error Response on failure. */
|
||||
async function validate(
|
||||
params: Record<string, string | undefined>,
|
||||
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",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// 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),
|
||||
state: p.state,
|
||||
resource: p.resource as string,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, string>,
|
||||
): 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);
|
||||
}
|
||||
|
||||
/** Whether the surface is available (M1 origin pinned + M4 kill-switch off). */
|
||||
async function surfaceAvailable(): Promise<boolean> {
|
||||
return isMcpOriginConfigured() && (await isApiGloballyEnabled());
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest): Promise<Response> {
|
||||
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",
|
||||
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(
|
||||
`<main><h1>Sign in to HealthLog</h1><p>To connect <strong>${htmlEscape(v.clientName)}</strong> you must sign in first.</p><p><a href="${htmlEscape(loginHref)}">Sign in and continue</a></p></main>`,
|
||||
);
|
||||
}
|
||||
|
||||
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) =>
|
||||
`<input type="hidden" name="${name}" value="${htmlEscape(value)}">`;
|
||||
// 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"
|
||||
? `<p>Verified origin: <code>${htmlEscape(v.clientOrigin)}</code></p>`
|
||||
: `<p><strong>Unverified application</strong> (dynamically registered — the name above is self-reported and not verified).</p>`;
|
||||
// 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
|
||||
? `<p><strong>This grants read AND write access:</strong> the application will be able to read your own health records <strong>and log new measurements and mood entries</strong> to your account on your behalf. It cannot delete or change existing entries, edit medications, or reach admin functions.</p>`
|
||||
: `<p>Scope: <code>${htmlEscape(v.scope)}</code> — read-only access to your own health records.</p>`;
|
||||
return html(
|
||||
`<main>
|
||||
<h1>Authorize access</h1>
|
||||
<p><strong>${htmlEscape(v.clientName)}</strong> is requesting access to your HealthLog data.</p>
|
||||
${provenance}
|
||||
<p>Scope: <code>${htmlEscape(v.scope)}</code></p>
|
||||
${accessSummary}
|
||||
<form method="POST" action="/api/mcp/oauth/authorize">
|
||||
${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) : ""}
|
||||
<button type="submit" name="decision" value="allow">Allow</button>
|
||||
<button type="submit" name="decision" value="deny">Deny</button>
|
||||
</form>
|
||||
</main>`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<Response> {
|
||||
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",
|
||||
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<string, string | undefined> = {};
|
||||
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,
|
||||
// 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,
|
||||
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 } : {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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 { 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";
|
||||
|
||||
const REGISTER_LIMIT = 20;
|
||||
const REGISTER_WINDOW_MS = 60 * 60 * 1000;
|
||||
const MAX_BODY_BYTES = 16 * 1024;
|
||||
|
||||
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<string, string> = {},
|
||||
): Response {
|
||||
return Response.json(
|
||||
{ error, error_description: description },
|
||||
{ status, headers: extraHeaders },
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<Response> {
|
||||
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,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 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(read.text);
|
||||
} 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 },
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* 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 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 `<userId>:<tokenId>`
|
||||
* (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 { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkAuthSurfaceRateLimit,
|
||||
rateLimitHeaders,
|
||||
} from "@/lib/rate-limit";
|
||||
import {
|
||||
ACCESS_TOKEN_TTL_MINUTES,
|
||||
audienceMatches,
|
||||
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";
|
||||
import {
|
||||
createConnection,
|
||||
rotateConnection,
|
||||
} from "@/lib/mcp/oauth/connections";
|
||||
|
||||
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<string, unknown>): 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<boolean> {
|
||||
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;
|
||||
client_name?: string;
|
||||
redirect_uri: string;
|
||||
code_challenge: string;
|
||||
scope: string;
|
||||
resource: string;
|
||||
}
|
||||
|
||||
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<Response> {
|
||||
// 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,
|
||||
expiresInMinutes: ACCESS_TOKEN_TTL_MINUTES,
|
||||
...(args.connectionId ? { mcpConnectionId: args.connectionId } : {}),
|
||||
});
|
||||
|
||||
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,
|
||||
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<Response> {
|
||||
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",
|
||||
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<AuthCodeClaims>("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",
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
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<RefreshClaims>(
|
||||
"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",
|
||||
);
|
||||
}
|
||||
|
||||
// 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 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",
|
||||
);
|
||||
}
|
||||
|
||||
return mintTokenPair({
|
||||
userId: claims.sub,
|
||||
clientId: claims.client_id,
|
||||
scope: claims.scope,
|
||||
resource: claims.resource,
|
||||
grant: "refresh_token",
|
||||
connectionId: claims.cid,
|
||||
refreshJti: newJti,
|
||||
});
|
||||
}
|
||||
|
||||
return oauthError(
|
||||
"unsupported_grant_type",
|
||||
"Only authorization_code and refresh_token are supported",
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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("*");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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. 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";
|
||||
|
||||
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, 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 () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "mcp.tokens.list" } });
|
||||
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
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 },
|
||||
mcpConnectionId: null,
|
||||
},
|
||||
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);
|
||||
}
|
||||
|
||||
// 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,
|
||||
permissions,
|
||||
expiresInDays: parsed.data.expiresInDays ?? 90,
|
||||
});
|
||||
|
||||
await auditLog("mcp.tokens.create", {
|
||||
userId: user.id,
|
||||
details: { tokenId: issued.tokenId, scope: permissions.join(" ") },
|
||||
});
|
||||
|
||||
// Return the raw token ONCE — it can never be retrieved again.
|
||||
return apiSuccess(
|
||||
{ token: issued.token, name: issued.name, expiresAt: issued.expiresAt },
|
||||
201,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
// 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 {},
|
||||
}));
|
||||
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";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
|
||||
const READ_TOOLS = [
|
||||
"get_correlations",
|
||||
"get_labs",
|
||||
"get_medication_compliance",
|
||||
"get_metric_series",
|
||||
"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. */
|
||||
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<string, string> = {}): 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(isApiGloballyEnabled).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 — 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 — 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(
|
||||
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(
|
||||
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 `<userId>:<tokenId>` 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 (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 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);
|
||||
}
|
||||
|
||||
// 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 — 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();
|
||||
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(() => {});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 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_<hex>` 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 `<userId>:<tokenId>`
|
||||
* binding (`mcp:<binding>`), 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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 { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { checkMcpRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
isMcpOriginConfigured,
|
||||
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
|
||||
// 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. 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,
|
||||
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
|
||||
* 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 <token>` 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<Response> {
|
||||
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();
|
||||
}
|
||||
|
||||
// 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) {
|
||||
annotate({ action: { name: "mcp.auth.missing" } });
|
||||
return unauthorized(request);
|
||||
}
|
||||
|
||||
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(request);
|
||||
}
|
||||
|
||||
// 2. RATE LIMIT — per `<userId>:<tokenId>` 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;
|
||||
@@ -17,6 +17,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";
|
||||
@@ -76,6 +77,7 @@ const SECTION_COMPONENTS: Record<
|
||||
vorsorge: VorsorgeSection,
|
||||
thresholds: ThresholdsSection,
|
||||
api: ApiSection,
|
||||
mcp: McpSection,
|
||||
gesundheitsakte: GesundheitsakteSection,
|
||||
sharing: SharingSection,
|
||||
export: ExportSection,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Local stdio entrypoint for the HealthLog MCP server.
|
||||
*
|
||||
* A power-user runs this in a plain Node process and points an MCP client
|
||||
* (Claude Desktop, etc.) at it. Authentication is a pasted, scoped `hlk_<hex>`
|
||||
* Bearer token — no OAuth infrastructure (REQ-T1). The token is read from the
|
||||
* `HEALTHLOG_MCP_TOKEN` environment variable (preferred — keeps the secret out
|
||||
* of the process argument list) or, as a fallback, the first CLI argument.
|
||||
*
|
||||
* Off by default: with no token the server refuses to start. Running it with a
|
||||
* minted token IS the opt-in.
|
||||
*
|
||||
* Run it with tsx (the standalone production image strips tsx, so this is a
|
||||
* local-only tool):
|
||||
*
|
||||
* HEALTHLOG_MCP_TOKEN=hlk_... \
|
||||
* API_TOKEN_HMAC_KEY=... DATABASE_URL=... ENCRYPTION_KEYS=... \
|
||||
* pnpm dlx tsx src/cli/mcp-stdio.ts
|
||||
*
|
||||
* stdout is the JSON-RPC channel — every diagnostic goes to stderr so it can
|
||||
* never corrupt the protocol stream.
|
||||
*/
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
|
||||
import { createMcpServer } from "@/lib/mcp/server";
|
||||
import { resolveMcpAuthContext } from "@/lib/mcp/auth";
|
||||
|
||||
function readToken(): string | null {
|
||||
const fromEnv = process.env.HEALTHLOG_MCP_TOKEN?.trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
const fromArg = process.argv[2]?.trim();
|
||||
if (fromArg) return fromArg;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
console.error(
|
||||
"HealthLog MCP: no token. Set HEALTHLOG_MCP_TOKEN (a scoped hlk_ Bearer token) or pass it as the first argument.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await resolveMcpAuthContext(token);
|
||||
} catch {
|
||||
// Do not echo the token or the underlying reason to stderr.
|
||||
console.error("HealthLog MCP: token rejected.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = createMcpServer(ctx);
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error(
|
||||
`HealthLog MCP server running on stdio (session ${ctx.binding}).`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("HealthLog MCP: fatal error during startup.", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -117,6 +117,7 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
"ai",
|
||||
"coach",
|
||||
"api",
|
||||
"mcp",
|
||||
"gesundheitsakte",
|
||||
"sharing",
|
||||
"export",
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<McpSection>` — 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, Link2, 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;
|
||||
}
|
||||
|
||||
interface McpConnectionInfo {
|
||||
id: string;
|
||||
clientName: string;
|
||||
scope: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
export function McpSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<McpEnableCard />
|
||||
<McpConnectionsCard />
|
||||
<McpTokensCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function McpConnectionsCard() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: connections } = useQuery({
|
||||
queryKey: queryKeys.mcpConnections(),
|
||||
queryFn: async () => apiGet<McpConnectionInfo[]>("/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 (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Link2}
|
||||
title={t("settings.mcp.connectionsTitle")}
|
||||
description={t("settings.mcp.connectionsDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
{list.length === 0 ? (
|
||||
<p className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm">
|
||||
{t("settings.mcp.noConnections")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{list.map((conn) => (
|
||||
<li
|
||||
key={conn.id}
|
||||
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 text-sm font-medium break-all">
|
||||
{conn.clientName}
|
||||
</p>
|
||||
<Badge className="bg-success/15 text-success text-[10px]">
|
||||
{t("settings.mcp.connectionConnected")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTablePermissions")}:
|
||||
</span>{" "}
|
||||
{conn.scope}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.mcp.connectionLastUsed")}:
|
||||
</span>{" "}
|
||||
{conn.lastUsedAt
|
||||
? formatDateTime(conn.lastUsedAt)
|
||||
: t("settings.mcp.connectionNeverUsed")}
|
||||
</p>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive border-destructive/30 min-h-11 w-full"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("settings.mcp.connectionRevoke")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.mcp.connectionRevoke")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.mcp.connectionRevokeDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => handleRevoke(conn.id)}
|
||||
>
|
||||
{t("settings.mcp.connectionRevoke")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Plug}
|
||||
title={t("settings.mcp.enableTitle")}
|
||||
description={t("settings.mcp.enableDescription")}
|
||||
status={
|
||||
enabled ? (
|
||||
<Badge className="bg-success/15 text-success">
|
||||
{t("settings.mcp.enabled")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{t("settings.mcp.disabled")}</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.mcp.enableToggleLabel")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={toggle.isPending}
|
||||
onCheckedChange={(next) => toggle.mutate(next)}
|
||||
aria-label={t("settings.mcp.enableToggleLabel")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs tracking-wide uppercase">
|
||||
{t("settings.mcp.endpointLabel")}
|
||||
</p>
|
||||
<code className="bg-muted block rounded p-2 font-mono text-xs break-all">
|
||||
{endpoint}
|
||||
</code>
|
||||
<p className="text-muted-foreground mt-2 text-xs leading-relaxed">
|
||||
{t("settings.mcp.connectDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function McpTokensCard() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [newName, setNewName] = useState("");
|
||||
const [allowWrite, setAllowWrite] = useState(false);
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [tokenMsg, setTokenMsg] = useState<string | null>(null);
|
||||
const [tokenCopied, setTokenCopied] = useState(false);
|
||||
|
||||
const { data: tokens } = useQuery({
|
||||
queryKey: queryKeys.mcpTokens(),
|
||||
queryFn: async () => apiGet<McpTokenInfo[]>("/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(),
|
||||
// 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"));
|
||||
}
|
||||
} 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 (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Key}
|
||||
title={t("settings.mcp.tokensTitle")}
|
||||
description={t("settings.mcp.tokensDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{t("settings.mcp.scopeNote")}
|
||||
</p>
|
||||
|
||||
<div className="border-border bg-muted/30 space-y-2 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.mcp.writeScopeToggleLabel")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={allowWrite}
|
||||
onCheckedChange={setAllowWrite}
|
||||
aria-label={t("settings.mcp.writeScopeToggleLabel")}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[11px] leading-relaxed">
|
||||
{t("settings.mcp.writeScopeNote")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreate} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t("settings.tokenNamePlaceholder")}
|
||||
maxLength={100}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="h-11 sm:h-10"
|
||||
disabled={creating || !newName.trim()}
|
||||
>
|
||||
{creating && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{newToken && (
|
||||
<div
|
||||
className="bg-success/10 rounded-lg p-3 text-sm"
|
||||
data-slot="settings-mcp-token-created"
|
||||
>
|
||||
<p className="text-success mb-1 font-medium">
|
||||
{t("settings.tokenCreated")}
|
||||
</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
|
||||
{newToken}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
|
||||
onClick={() => void handleCopyToken()}
|
||||
aria-label={t("settings.tokenCopy")}
|
||||
>
|
||||
{tokenCopied ? (
|
||||
<Check className="text-success h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tokenMsg && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{tokenMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">
|
||||
{t("settings.activeTokensTitle")}
|
||||
</p>
|
||||
{activeTokens.length === 0 ? (
|
||||
<p className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm">
|
||||
{t("settings.noActiveTokens")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{activeTokens.map((tok) => {
|
||||
const isExpired =
|
||||
tok.expiresAt && new Date(tok.expiresAt) < new Date();
|
||||
return (
|
||||
<li
|
||||
key={tok.id}
|
||||
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 text-sm font-medium break-all">
|
||||
{tok.name}
|
||||
</p>
|
||||
{isExpired ? (
|
||||
<Badge variant="destructive" className="text-[10px]">
|
||||
{t("settings.tokenExpired")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-success/15 text-success text-[10px]">
|
||||
{t("settings.tokenActive")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTablePermissions")}:
|
||||
</span>{" "}
|
||||
{tok.permissions.join(", ")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableCreated")}:
|
||||
</span>{" "}
|
||||
{formatDate(tok.createdAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableLastUsed")}:
|
||||
</span>{" "}
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</p>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive border-destructive/30 min-h-11 w-full"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("settings.tokenRevoke")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.tokenRevoke")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.tokenRevokeDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => handleRevoke(tok.id)}
|
||||
>
|
||||
{t("settings.tokenRevoked")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
FileText,
|
||||
MessageCircleHeart,
|
||||
Moon,
|
||||
Plug,
|
||||
Pill,
|
||||
Smile,
|
||||
Sparkles,
|
||||
@@ -71,6 +72,8 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
|
||||
// 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() {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FlaskConical,
|
||||
Info,
|
||||
KeyRound,
|
||||
Plug,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
@@ -63,6 +64,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
ai: Sparkles,
|
||||
coach: Bot,
|
||||
api: KeyRound,
|
||||
mcp: Plug,
|
||||
gesundheitsakte: FileHeart,
|
||||
sharing: Share2,
|
||||
export: Download,
|
||||
|
||||
@@ -77,6 +77,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
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
FlaskConical,
|
||||
Info,
|
||||
KeyRound,
|
||||
Plug,
|
||||
LayoutDashboard,
|
||||
Layers,
|
||||
Link2,
|
||||
@@ -216,6 +217,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.
|
||||
//
|
||||
|
||||
@@ -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,112 @@ 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, method: req.method });
|
||||
});
|
||||
|
||||
function call(method: string): Promise<Response> {
|
||||
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("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",
|
||||
permissions: ["health:read", "health:write"],
|
||||
revoked: false,
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireAuth — cookie path remains intact", () => {
|
||||
it("returns the session payload without consulting the Bearer header", async () => {
|
||||
const cookieSession = {
|
||||
|
||||
+90
-101
@@ -1,16 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { WideEventBuilder } from "./logging/event-builder";
|
||||
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";
|
||||
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
|
||||
* 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 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.
|
||||
*/
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error class for HTTP errors with status codes.
|
||||
@@ -306,110 +332,78 @@ 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<AuthContext> {
|
||||
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");
|
||||
const { user, tokenId, expiresAt, permissions } = resolution;
|
||||
|
||||
// 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
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
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(() => {});
|
||||
|
||||
auditLog("auth.bearer.success", {
|
||||
userId: user.id,
|
||||
details: { tokenId: apiToken.id },
|
||||
details: { tokenId },
|
||||
}).catch(() => {});
|
||||
|
||||
const evt = getEvent();
|
||||
@@ -421,13 +415,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<BearerAuthError>);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Canonical Bearer-token validation — the single source of truth for resolving
|
||||
* a raw `hlk_<hex>` 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 `<user>:<token>` 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_<hex>` 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<BearerResolution> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,18 @@ 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;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,9 +47,10 @@ export async function issueApiToken(
|
||||
): Promise<IssuedToken> {
|
||||
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: {
|
||||
@@ -46,6 +59,9 @@ export async function issueApiToken(
|
||||
tokenHash: tokenHashValue,
|
||||
permissions: opts.permissions ?? ["*"],
|
||||
expiresAt,
|
||||
...(opts.mcpConnectionId
|
||||
? { mcpConnectionId: opts.mcpConnectionId }
|
||||
: {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
@@ -83,6 +83,7 @@ function moduleMap(
|
||||
insights: true,
|
||||
medications: true,
|
||||
doctorReport: true,
|
||||
mcp: true,
|
||||
};
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
@@ -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<Uint8Array> {
|
||||
let i = 0;
|
||||
return new ReadableStream<Uint8Array>({
|
||||
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<Uint8Array>({
|
||||
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: "" });
|
||||
});
|
||||
});
|
||||
@@ -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<Uint8Array> | null },
|
||||
maxBytes: number,
|
||||
): Promise<CappedReadResult> {
|
||||
// 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) };
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<McpTokenCleanupResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<McpTokenCleanupPayload>[],
|
||||
) {
|
||||
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<WithingsOAuthStateCleanupPayload>[],
|
||||
) {
|
||||
|
||||
@@ -92,6 +92,8 @@ import {
|
||||
handleAuditLogCleanup,
|
||||
CoachMessageCleanupPayload,
|
||||
handleCoachMessageCleanup,
|
||||
McpTokenCleanupPayload,
|
||||
handleMcpTokenCleanup,
|
||||
} from "./cleanup-handlers";
|
||||
import {
|
||||
HostMetricSamplePayload,
|
||||
@@ -188,6 +190,14 @@ const COACH_MESSAGE_CLEANUP_CRON = "50 3 * * *";
|
||||
// migration converges without a restart. Idempotent + fail-closed per row.
|
||||
|
||||
const NOTE_ENCRYPTION_BACKFILL_CRON = "55 3 * * *";
|
||||
// v1.24 — 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 04:00 Europe/Berlin after the note-encryption
|
||||
// backfill discovery (03:55) so the two don't pile up on the same boss poll.
|
||||
|
||||
const MCP_TOKEN_CLEANUP_QUEUE = "mcp-token-cleanup";
|
||||
|
||||
const MCP_TOKEN_CLEANUP_CRON = "0 4 * * *";
|
||||
|
||||
const allQueues = [
|
||||
DATA_BACKUP_QUEUE,
|
||||
@@ -239,6 +249,10 @@ const allQueues = [
|
||||
// corpus to the active key. Without this entry pg-boss never provisions the
|
||||
// queue and the admin trigger silently no-ops.
|
||||
ENCRYPTION_KEY_ROTATE_QUEUE,
|
||||
// v1.24 — 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[] = [
|
||||
@@ -294,6 +308,8 @@ const schedules: ScheduleEntry[] = [
|
||||
// The empty cron payload (no `userId`) is the handler's signal to fan out
|
||||
// one per-user job per account still holding a plaintext note.
|
||||
[NOTE_ENCRYPTION_BACKFILL_QUEUE, NOTE_ENCRYPTION_BACKFILL_CRON],
|
||||
// v1.24 — daily 04:00 Europe/Berlin prune for dead MCP connector tokens.
|
||||
[MCP_TOKEN_CLEANUP_QUEUE, MCP_TOKEN_CLEANUP_CRON],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -383,6 +399,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<McpTokenCleanupPayload>(
|
||||
MCP_TOKEN_CLEANUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleMcpTokenCleanup,
|
||||
);
|
||||
await boss.work<PrDetectionPayload>(
|
||||
PR_DETECTION_QUEUE,
|
||||
{ localConcurrency: PR_DETECTION_CONCURRENCY },
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Guard: the MCP / Bearer wire can never reach an admin surface (REQ-SEC-7,
|
||||
* ADR-005).
|
||||
*
|
||||
* `requireAdmin()` authorises off a cookie session ONLY. The MCP transports
|
||||
* authenticate by Bearer token and never mint a cookie session, so in an MCP
|
||||
* process `getSession()` is always null and `requireAdmin()` always rejects —
|
||||
* regardless of the token's scope (even `["*"]`). This test pins that structural
|
||||
* boundary so a future change that softens it fails loudly.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
apiToken: { findUnique: vi.fn(), update: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
const headersGet = vi.fn<(name: string) => string | null>();
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: headersGet })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { requireAdmin } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("requireAdmin is cookie-only — MCP/Bearer cannot elevate", () => {
|
||||
it("rejects with 401 when there is no cookie session (the MCP reality)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
// Even with a Bearer header present, requireAdmin must not consult it.
|
||||
headersGet.mockImplementation((name) =>
|
||||
name.toLowerCase() === "authorization"
|
||||
? `Bearer hlk_${"a".repeat(64)}`
|
||||
: null,
|
||||
);
|
||||
|
||||
await expect(requireAdmin()).rejects.toMatchObject({ statusCode: 401 });
|
||||
// The token table is never touched — admin auth ignores Bearer entirely.
|
||||
expect(prisma.apiToken.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a non-admin cookie session with 403", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) },
|
||||
user: { id: "user-1", role: "USER" },
|
||||
} as never);
|
||||
|
||||
await expect(requireAdmin()).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/auth/bearer", () => ({
|
||||
resolveBearerToken: vi.fn(),
|
||||
}));
|
||||
|
||||
import { resolveMcpAuthContext } from "../auth";
|
||||
import { resolveBearerToken } from "@/lib/auth/bearer";
|
||||
|
||||
const FAKE_USER = { id: "user-1", role: "USER" } as never;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("resolveMcpAuthContext", () => {
|
||||
it("binds the session to <userId>:<tokenId> (REQ-SEC-11) via the canonical Bearer path", async () => {
|
||||
vi.mocked(resolveBearerToken).mockResolvedValue({
|
||||
user: FAKE_USER,
|
||||
tokenId: "token-9",
|
||||
permissions: ["health:read"],
|
||||
expiresAt: new Date(),
|
||||
});
|
||||
|
||||
const ctx = await resolveMcpAuthContext(" hlk_abc ");
|
||||
|
||||
// Trimmed token resolved through the shared validator (no requiredPermission for reads).
|
||||
expect(resolveBearerToken).toHaveBeenCalledWith("hlk_abc");
|
||||
expect(ctx.userId).toBe("user-1");
|
||||
expect(ctx.tokenId).toBe("token-9");
|
||||
expect(ctx.binding).toBe("user-1:token-9");
|
||||
expect(ctx.canRead).toBe(true);
|
||||
expect(ctx.canWrite).toBe(false);
|
||||
});
|
||||
|
||||
it("grants write capability only when the token carries health:write", async () => {
|
||||
vi.mocked(resolveBearerToken).mockResolvedValue({
|
||||
user: FAKE_USER,
|
||||
tokenId: "token-w",
|
||||
permissions: ["health:write"],
|
||||
expiresAt: new Date(),
|
||||
});
|
||||
const ctx = await resolveMcpAuthContext("hlk_xyz");
|
||||
expect(ctx.canWrite).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an empty token without calling the validator", async () => {
|
||||
await expect(resolveMcpAuthContext(" ")).rejects.toThrow();
|
||||
expect(resolveBearerToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates a rejection from the Bearer validator (invalid / revoked / expired)", async () => {
|
||||
vi.mocked(resolveBearerToken).mockRejectedValue(new Error("revoked"));
|
||||
await expect(resolveMcpAuthContext("hlk_bad")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
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<Record<string, unknown>>;
|
||||
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<Record<string, unknown>>;
|
||||
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<Record<string, unknown>>;
|
||||
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<string, unknown>;
|
||||
expect(patient).not.toHaveProperty("fullName");
|
||||
expect(patient).not.toHaveProperty("username");
|
||||
expect(patient.dateOfBirth).toBe("1990-01-01");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
import { MCP_RESOURCES, MCP_RESOURCE_URIS } from "../resources";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { McpAuthContext } from "../auth";
|
||||
|
||||
const CTX: McpAuthContext = {
|
||||
userId: "user-1",
|
||||
tokenId: "token-1",
|
||||
scopes: ["health:read"],
|
||||
binding: "user-1:token-1",
|
||||
canRead: true,
|
||||
canWrite: false,
|
||||
};
|
||||
|
||||
function resource(uri: string) {
|
||||
const def = MCP_RESOURCES.find((r) => r.uri === uri);
|
||||
if (!def) throw new Error(`resource ${uri} not registered`);
|
||||
return def;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("MCP resource surface", () => {
|
||||
it("registers exactly the Phase-1 resources, none admin", () => {
|
||||
expect([...MCP_RESOURCE_URIS].sort()).toEqual([
|
||||
"healthlog://medications",
|
||||
"healthlog://profile",
|
||||
]);
|
||||
for (const uri of MCP_RESOURCE_URIS) {
|
||||
expect(uri).not.toMatch(/admin/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("healthlog://profile", () => {
|
||||
it("returns minimised, health-relevant profile data and omits identifiers", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1990-01-01T00:00:00Z"),
|
||||
gender: "MALE",
|
||||
timezone: "Europe/Berlin",
|
||||
unitPreference: "metric",
|
||||
glucoseUnit: "mmol/L",
|
||||
} as never);
|
||||
|
||||
const result = (await resource("healthlog://profile").read(CTX)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
// userId is narrowed from the session, never a caller argument.
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: "user-1" } }),
|
||||
);
|
||||
expect(result.present).toBe(true);
|
||||
expect(result.heightCm).toBe(180);
|
||||
expect(typeof result.ageYears).toBe("number");
|
||||
expect(result.glucoseUnit).toBe("mmol/L");
|
||||
// No direct identifiers leak to the assistant.
|
||||
expect(Object.keys(result)).not.toContain("email");
|
||||
expect(Object.keys(result)).not.toContain("username");
|
||||
expect(Object.keys(result)).not.toContain("role");
|
||||
});
|
||||
|
||||
it("returns { present: false } when the user row is missing", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
const result = (await resource("healthlog://profile").read(CTX)) as {
|
||||
present: boolean;
|
||||
};
|
||||
expect(result.present).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("healthlog://medications", () => {
|
||||
it("lists medications + schedules scoped to the session user", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{
|
||||
name: "Med A",
|
||||
dose: "10mg",
|
||||
treatmentClass: "GENERIC",
|
||||
asNeeded: false,
|
||||
pausedAt: null,
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
schedules: [
|
||||
{
|
||||
label: "Morning",
|
||||
dose: null,
|
||||
windowStart: "08:00",
|
||||
windowEnd: "10:00",
|
||||
timesOfDay: ["08:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
},
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
|
||||
const result = (await resource("healthlog://medications").read(
|
||||
CTX,
|
||||
)) as Record<string, unknown>;
|
||||
|
||||
expect(prisma.medication.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { userId: "user-1" } }),
|
||||
);
|
||||
expect(result.present).toBe(true);
|
||||
expect(result.count).toBe(1);
|
||||
expect(Array.isArray(result.medications)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns { present: false } when no medications are tracked", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
const result = (await resource("healthlog://medications").read(CTX)) as {
|
||||
present: boolean;
|
||||
count: number;
|
||||
};
|
||||
expect(result.present).toBe(false);
|
||||
expect(result.count).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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<typeof import("@/lib/rollups/measurement-read-wmy")>();
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
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(),
|
||||
}));
|
||||
// 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 () => []) },
|
||||
},
|
||||
}));
|
||||
// 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";
|
||||
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 read tools (Phase 1 + Phase 4)", () => {
|
||||
expect([...MCP_TOOL_NAMES].sort()).toEqual(
|
||||
[
|
||||
"get_correlations",
|
||||
"get_labs",
|
||||
"get_medication_compliance",
|
||||
"get_metric_series",
|
||||
"list_metrics",
|
||||
// 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(),
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
expect(name).not.toMatch(
|
||||
/^(log_|create_|update_|delete_|export_|write_)/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("list_metrics", () => {
|
||||
it("enumerates available metrics with coverage from the inventory", async () => {
|
||||
vi.mocked(buildCoachDataInventory).mockResolvedValue({
|
||||
entries: [
|
||||
{
|
||||
tool: "get_metric_series",
|
||||
metric: "bp",
|
||||
domain: "blood pressure",
|
||||
present: true,
|
||||
count: 42,
|
||||
},
|
||||
{
|
||||
tool: "get_metric_series",
|
||||
metric: "weight",
|
||||
domain: "weight",
|
||||
present: false,
|
||||
},
|
||||
],
|
||||
restMode: false,
|
||||
cycleEnabled: false,
|
||||
window: "last30days",
|
||||
probeScope: { sources: [], window: "last30days" },
|
||||
} as never);
|
||||
|
||||
const result = (await tool("list_metrics").run(CTX, {})) as {
|
||||
present: boolean;
|
||||
window: string;
|
||||
metrics: unknown[];
|
||||
};
|
||||
|
||||
expect(buildCoachDataInventory).toHaveBeenCalledWith("user-1", undefined);
|
||||
expect(result.present).toBe(true);
|
||||
expect(result.window).toBe("last30days");
|
||||
expect(result.metrics).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get_metric_series", () => {
|
||||
it("forwards validated args + session userId to the F1 executor and returns the grounded result", async () => {
|
||||
vi.mocked(executeCoachTool).mockResolvedValue({
|
||||
present: true,
|
||||
data: { metric: "bp", section: { unit: "mmHg" } },
|
||||
grounding: "Population band 120/80 mmHg.",
|
||||
});
|
||||
|
||||
const result = (await tool("get_metric_series").run(CTX, {
|
||||
metric: "bp",
|
||||
window: "last90days",
|
||||
})) as { present: boolean; grounding?: string };
|
||||
|
||||
expect(executeCoachTool).toHaveBeenCalledWith({
|
||||
userId: "user-1",
|
||||
name: "get_metric_series",
|
||||
rawArguments: JSON.stringify({ metric: "bp", window: "last90days" }),
|
||||
});
|
||||
expect(result.present).toBe(true);
|
||||
// Units / reference bands ride the result (ADR-004).
|
||||
expect(result.grounding).toContain("mmHg");
|
||||
});
|
||||
|
||||
it("returns { present: false } for an absent metric (never a silent zero)", async () => {
|
||||
vi.mocked(executeCoachTool).mockResolvedValue({
|
||||
present: false,
|
||||
reason: "no_data",
|
||||
});
|
||||
const result = (await tool("get_metric_series").run(CTX, {
|
||||
metric: "weight",
|
||||
})) as { present: boolean; reason?: string };
|
||||
expect(result.present).toBe(false);
|
||||
expect(result.reason).toBe("no_data");
|
||||
});
|
||||
});
|
||||
|
||||
describe("get_medication_compliance", () => {
|
||||
it("forwards to the F1 executor under its name", async () => {
|
||||
vi.mocked(executeCoachTool).mockResolvedValue({
|
||||
present: true,
|
||||
data: { compliance: { short: { rate: 90 } } },
|
||||
});
|
||||
const result = (await tool("get_medication_compliance").run(CTX, {})) as {
|
||||
present: boolean;
|
||||
};
|
||||
expect(executeCoachTool).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
name: "get_medication_compliance",
|
||||
}),
|
||||
);
|
||||
expect(result.present).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get_labs", () => {
|
||||
it("forwards the optional analyte filter and returns readings with units + bands", async () => {
|
||||
vi.mocked(executeCoachTool).mockResolvedValue({
|
||||
present: true,
|
||||
data: {
|
||||
recent: [
|
||||
{
|
||||
analyte: "LDL",
|
||||
value: 120,
|
||||
unit: "mg/dL",
|
||||
referenceHigh: 116,
|
||||
rangeStatus: "above",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const result = (await tool("get_labs").run(CTX, { analyte: "LDL" })) as {
|
||||
present: boolean;
|
||||
};
|
||||
expect(executeCoachTool).toHaveBeenCalledWith({
|
||||
userId: "user-1",
|
||||
name: "get_labs",
|
||||
rawArguments: JSON.stringify({ analyte: "LDL" }),
|
||||
});
|
||||
expect(result.present).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get_correlations", () => {
|
||||
it("forwards to the F1 executor and returns FDR-controlled drivers", async () => {
|
||||
vi.mocked(executeCoachTool).mockResolvedValue({
|
||||
present: true,
|
||||
data: { drivers: [], pairsTested: 20, windowDays: 180 },
|
||||
});
|
||||
const result = (await tool("get_correlations").run(CTX, {})) as {
|
||||
present: boolean;
|
||||
};
|
||||
expect(executeCoachTool).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "user-1", name: "get_correlations" }),
|
||||
);
|
||||
expect(result.present).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no prose verdict (ADR-004 / REQ-SEC-2)", () => {
|
||||
it("every tool result is a structured object without verdict/diagnosis fields", async () => {
|
||||
vi.mocked(executeCoachTool).mockResolvedValue({ present: true, data: {} });
|
||||
vi.mocked(buildCoachDataInventory).mockResolvedValue({
|
||||
entries: [],
|
||||
restMode: false,
|
||||
cycleEnabled: false,
|
||||
window: "last30days",
|
||||
probeScope: { sources: [] },
|
||||
} as never);
|
||||
|
||||
for (const def of MCP_TOOLS) {
|
||||
const args = def.name === "get_metric_series" ? { metric: "bp" } : {};
|
||||
const result = await def.run(CTX, args);
|
||||
expect(typeof result).toBe("object");
|
||||
const keys = Object.keys(result as object);
|
||||
expect(keys).not.toContain("verdict");
|
||||
expect(keys).not.toContain("diagnosis");
|
||||
expect(keys).not.toContain("advice");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
expect(result.written).toBe(false);
|
||||
expect(logMcpMood).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* MCP auth context.
|
||||
*
|
||||
* Resolves a pasted `hlk_<hex>` Bearer token to a session-narrowed identity for
|
||||
* the MCP transports. Authentication is delegated to the canonical
|
||||
* `resolveBearerToken` (`@/lib/auth/bearer`) — the SAME validation the HTTP edge
|
||||
* runs — so revoked / expired / scope semantics are identical across both wires
|
||||
* (REQ-T1, REQ-SEC-5).
|
||||
*
|
||||
* Security properties:
|
||||
* - The id is narrowed to one `userId`; tools and resources feed it straight
|
||||
* into the Prisma `where`, never accepting a caller-supplied user id.
|
||||
* - The session is bound to `<user_id>:<token_id>` (REQ-SEC-11), never to a
|
||||
* cookie session — the MCP wire carries no cookie.
|
||||
* - Admin is unreachable by construction: `requireAdmin()` is cookie-only and
|
||||
* this path never mints a cookie session, so no MCP context can elevate
|
||||
* (REQ-SEC-7 / ADR-005). The guard test pins it.
|
||||
*/
|
||||
import { resolveBearerToken } from "@/lib/auth/bearer";
|
||||
import { sessionBinding, tokenAllowsRead, tokenAllowsWrite } from "./scopes";
|
||||
|
||||
export interface McpAuthContext {
|
||||
/** The single user this session acts as. Feeds every Prisma `where`. */
|
||||
userId: string;
|
||||
/** The presented `ApiToken` id (the token half of the binding). */
|
||||
tokenId: string;
|
||||
/** The token's granted scopes. */
|
||||
scopes: string[];
|
||||
/** `<user_id>:<token_id>` — the session binding (REQ-SEC-11). */
|
||||
binding: string;
|
||||
/** Whether this session may read (always true for a valid token). */
|
||||
canRead: boolean;
|
||||
/** Whether this session may write (requires `health:write`; no write tools yet). */
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a raw Bearer token into an `McpAuthContext`, or throw on an invalid /
|
||||
* revoked / expired token. Reads require no special scope (the underlying reads
|
||||
* are unscoped), so no `requiredPermission` is passed.
|
||||
*/
|
||||
export async function resolveMcpAuthContext(
|
||||
rawToken: string,
|
||||
): Promise<McpAuthContext> {
|
||||
const trimmed = rawToken.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Missing MCP Bearer token");
|
||||
}
|
||||
|
||||
const { user, tokenId, permissions } = await resolveBearerToken(trimmed);
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
tokenId,
|
||||
scopes: permissions,
|
||||
binding: sessionBinding(user.id, tokenId),
|
||||
canRead: tokenAllowsRead(),
|
||||
canWrite: tokenAllowsWrite(permissions),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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_WRITE_TOOLS, MCP_WRITE_TOOL_NAMES } from "./write-tools";
|
||||
export {
|
||||
logMcpMeasurement,
|
||||
logMcpMood,
|
||||
type McpMeasurementResult,
|
||||
type McpMoodResult,
|
||||
} from "./writes";
|
||||
export {
|
||||
MCP_RESOURCES,
|
||||
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,
|
||||
SCOPE_WILDCARD,
|
||||
tokenAllowsRead,
|
||||
tokenAllowsWrite,
|
||||
sessionBinding,
|
||||
} from "./scopes";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 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" });
|
||||
});
|
||||
|
||||
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", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 `<userId>:<tokenId>` 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 `<prefix>.<base64url(payload)>.<base64url(mac)>`. 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<string, unknown>,
|
||||
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<T> =
|
||||
| { 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<T = Record<string, unknown>>(
|
||||
kind: ArtifactKind,
|
||||
value: string,
|
||||
): VerifyResult<T> {
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* 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 { readBodyCapped } from "@/lib/http/read-capped";
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
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<ClientResolution> {
|
||||
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" };
|
||||
|
||||
// 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<string, unknown>;
|
||||
try {
|
||||
doc = JSON.parse(read.text);
|
||||
} 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" };
|
||||
// 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,
|
||||
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<ClientResolution> {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
|
||||
/** 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 (`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 = [
|
||||
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 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
|
||||
* 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,
|
||||
SCOPE_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;
|
||||
@@ -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<string> {
|
||||
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<RotationOutcome> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<ConnectionSummary[]> {
|
||||
return prisma.mcpOAuthConnection.findMany({
|
||||
where: { userId, revokedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
clientName: true,
|
||||
scope: true,
|
||||
createdAt: true,
|
||||
lastUsedAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
@@ -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(", ");
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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<CoachScopeWindow, number> = {
|
||||
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<string, unknown>,
|
||||
) => Promise<McpPromptResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, string> = {
|
||||
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<string, unknown> {
|
||||
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,
|
||||
);
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Transport-agnostic MCP resource registry.
|
||||
*
|
||||
* Resources expose read-only context the assistant can surface alongside tool
|
||||
* results (REQ-R10). Like the tools, they are server-authoritative reads of the
|
||||
* canonical tables scoped to the session `userId` — no new analytics, no
|
||||
* caller-supplied id. Absence is explicit via `{ present: false }` (REQ-SEC-4).
|
||||
*
|
||||
* The profile resource is deliberately data-minimised: it ships only the
|
||||
* health-relevant context an assistant needs (age in whole years, gender,
|
||||
* height, timezone, unit preferences) and omits direct identifiers (email,
|
||||
* username, role, insurance number) — narrowing the surface on the
|
||||
* external-assistant boundary.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { McpAuthContext } from "./auth";
|
||||
|
||||
export interface McpResourceDefinition {
|
||||
name: string;
|
||||
/** Fixed resource URI (e.g. `healthlog://profile`). */
|
||||
uri: string;
|
||||
title: string;
|
||||
description: string;
|
||||
mimeType: string;
|
||||
read: (ctx: McpAuthContext) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/** Whole-year age from a date of birth, or null when unknown. */
|
||||
function ageYears(dateOfBirth: Date | null): number | null {
|
||||
if (!dateOfBirth) return null;
|
||||
const now = new Date();
|
||||
let age = now.getUTCFullYear() - dateOfBirth.getUTCFullYear();
|
||||
const monthDelta = now.getUTCMonth() - dateOfBirth.getUTCMonth();
|
||||
if (
|
||||
monthDelta < 0 ||
|
||||
(monthDelta === 0 && now.getUTCDate() < dateOfBirth.getUTCDate())
|
||||
) {
|
||||
age -= 1;
|
||||
}
|
||||
return age >= 0 && age < 150 ? age : null;
|
||||
}
|
||||
|
||||
export const MCP_RESOURCES: McpResourceDefinition[] = [
|
||||
{
|
||||
name: "profile",
|
||||
uri: "healthlog://profile",
|
||||
title: "User profile",
|
||||
description:
|
||||
"The user's health-relevant profile context: age, gender, height, timezone, and unit preferences. Identifiers are omitted.",
|
||||
mimeType: "application/json",
|
||||
async read(ctx) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: ctx.userId },
|
||||
select: {
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
timezone: true,
|
||||
unitPreference: true,
|
||||
glucoseUnit: true,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "mcp.resource.read" },
|
||||
meta: { resource: "profile", present: user !== null },
|
||||
});
|
||||
if (!user) return { present: false };
|
||||
return {
|
||||
present: true,
|
||||
ageYears: ageYears(user.dateOfBirth),
|
||||
gender: user.gender ?? null,
|
||||
heightCm: user.heightCm ?? null,
|
||||
timezone: user.timezone,
|
||||
unitPreference:
|
||||
user.unitPreference === "imperial" ? "imperial" : "metric",
|
||||
glucoseUnit: user.glucoseUnit ?? "mg/dL",
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "medications",
|
||||
uri: "healthlog://medications",
|
||||
title: "Medications",
|
||||
description:
|
||||
"The user's tracked medications with their schedules (dose, cadence, time windows). Read-only list; use get_medication_compliance for adherence figures.",
|
||||
mimeType: "application/json",
|
||||
async read(ctx) {
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId: ctx.userId },
|
||||
include: { schedules: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "mcp.resource.read" },
|
||||
meta: { resource: "medications", present: medications.length > 0 },
|
||||
});
|
||||
if (medications.length === 0) return { present: false, count: 0 };
|
||||
return {
|
||||
present: true,
|
||||
count: medications.length,
|
||||
medications: medications.map((med) => ({
|
||||
name: med.name,
|
||||
dose: med.dose,
|
||||
treatmentClass: med.treatmentClass,
|
||||
asNeeded: med.asNeeded,
|
||||
paused: med.pausedAt !== null,
|
||||
startsOn: med.startsOn ? med.startsOn.toISOString() : null,
|
||||
endsOn: med.endsOn ? med.endsOn.toISOString() : null,
|
||||
schedules: med.schedules.map((s) => ({
|
||||
label: s.label ?? null,
|
||||
dose: s.dose ?? null,
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
timesOfDay: s.timesOfDay,
|
||||
daysOfWeek: s.daysOfWeek ?? null,
|
||||
rrule: s.rrule ?? null,
|
||||
rollingIntervalDays: s.rollingIntervalDays ?? null,
|
||||
scheduleType: s.scheduleType,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Stable list of the registered resource URIs. */
|
||||
export const MCP_RESOURCE_URIS: readonly string[] = MCP_RESOURCES.map(
|
||||
(r) => r.uri,
|
||||
);
|
||||
@@ -0,0 +1,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<CoachScopeWindow, number> = {
|
||||
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<string, RichMetric> = {
|
||||
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<string, string> = {
|
||||
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<CorrelationResult> {
|
||||
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<MetricWindowSnapshot | null> {
|
||||
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<CompareMetricResult> {
|
||||
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<MetricBaselineResult> {
|
||||
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<ChangepointsResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* MCP scope model.
|
||||
*
|
||||
* The MCP wire reuses the existing `hlk_<hex>` Bearer tokens as its auth
|
||||
* substrate (no parallel identity system). Two scope handles frame the
|
||||
* least-privilege contract:
|
||||
*
|
||||
* - `health:read` — read the user's own health data. The default MCP posture.
|
||||
* - `health:write` — mutate (log a measurement / intake / mood). Reserved for
|
||||
* the write tools, which are NOT registered in this milestone phase.
|
||||
*
|
||||
* Read tools impose no scope requirement of their own: every underlying read
|
||||
* path is already an unscoped authenticated route, so a narrow-scope token works
|
||||
* and a wildcard token is never required (REQ-SEC-5). The strong "read-only by
|
||||
* default" guarantee (REQ-SEC-1 / ADR-003) is structural — only read tools are
|
||||
* registered — not a runtime flag a future token could flip. Write registration
|
||||
* (a later phase) gates on `health:write` via `tokenAllowsWrite`.
|
||||
*/
|
||||
|
||||
/** Wildcard scope — grants every capability the token model can express. */
|
||||
export const SCOPE_WILDCARD = "*";
|
||||
|
||||
/** Read the authenticated user's own health data over MCP. */
|
||||
export const SCOPE_HEALTH_READ = "health:read";
|
||||
|
||||
/** Mutate the authenticated user's data over MCP (reserved; no write tools yet). */
|
||||
export const SCOPE_HEALTH_WRITE = "health:write";
|
||||
|
||||
/**
|
||||
* Whether a token's scopes admit the read surface. Any valid token qualifies —
|
||||
* the underlying reads are unscoped — so this is permissive by design and exists
|
||||
* to document the contract and to give a single place to tighten later.
|
||||
*/
|
||||
export function tokenAllowsRead(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a token's scopes admit the write surface. A token must carry the
|
||||
* explicit `health:write` scope (or the wildcard). Used to decide whether write
|
||||
* tools are registered for a session.
|
||||
*/
|
||||
export function tokenAllowsWrite(permissions: readonly string[]): boolean {
|
||||
return (
|
||||
permissions.includes(SCOPE_WILDCARD) ||
|
||||
permissions.includes(SCOPE_HEALTH_WRITE)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `<user_id>:<token_id>` binding (REQ-SEC-11). Identifies a session by the
|
||||
* user AND the specific token presented — never the user alone — so audit,
|
||||
* annotation, and (later phases) rate-limit buckets are keyed to the credential
|
||||
* actually in use.
|
||||
*/
|
||||
export function sessionBinding(userId: string, tokenId: string): string {
|
||||
return `${userId}:${tokenId}`;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* 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, type McpToolDefinition } from "./tools";
|
||||
import { MCP_WRITE_TOOLS } from "./write-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";
|
||||
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
|
||||
// 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,
|
||||
{
|
||||
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. 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 } : {}),
|
||||
},
|
||||
async (args: Record<string, unknown>) => {
|
||||
const result = await tool.run(ctx, args ?? {});
|
||||
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<string, unknown>,
|
||||
};
|
||||
}
|
||||
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) {
|
||||
server.registerPrompt(
|
||||
prompt.name,
|
||||
{
|
||||
title: prompt.title,
|
||||
description: prompt.description,
|
||||
argsSchema: prompt.argsShape,
|
||||
},
|
||||
async (args: Record<string, unknown>) => {
|
||||
// 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,
|
||||
resource.uri,
|
||||
{
|
||||
title: resource.title,
|
||||
description: resource.description,
|
||||
mimeType: resource.mimeType,
|
||||
},
|
||||
async (uri: URL) => {
|
||||
const data = await resource.read(ctx);
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: uri.href,
|
||||
mimeType: resource.mimeType,
|
||||
text: JSON.stringify(data),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
/**
|
||||
* 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 { 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";
|
||||
import {
|
||||
coachScopeSourceSchema,
|
||||
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";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* arguments and the session context and returns a plain structured object.
|
||||
*/
|
||||
export interface McpToolDefinition {
|
||||
name: string;
|
||||
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<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one Coach F1 retrieval tool through its executor. The executor re-parses
|
||||
* and re-validates the arguments against its own Zod schema, runs the
|
||||
* server-authoritative snapshot builder scoped to the requested domain, and
|
||||
* returns the grounded `{ present, reason?, data?, grounding? }` result. It
|
||||
* never throws and never widens scope.
|
||||
*/
|
||||
async function runCoachTool(
|
||||
ctx: McpAuthContext,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const result = await executeCoachTool({
|
||||
userId: ctx.userId,
|
||||
name,
|
||||
rawArguments: JSON.stringify(args ?? {}),
|
||||
});
|
||||
annotate({
|
||||
action: { name: "mcp.tool.invoked" },
|
||||
meta: { tool: name, present: result.present },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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:<id>`, `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" },
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── 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",
|
||||
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: {},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
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(),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
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(),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
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(),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
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: {},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
run(ctx, args) {
|
||||
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(),
|
||||
];
|
||||
|
||||
/** Stable list of the registered tool names. */
|
||||
export const MCP_TOOL_NAMES: readonly string[] = MCP_TOOLS.map((t) => t.name);
|
||||
@@ -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<boolean> {
|
||||
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,
|
||||
);
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* 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 { encryptNote, readNote } from "@/lib/crypto/note-cipher";
|
||||
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<McpMeasurementResult> {
|
||||
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<McpMoodResult> {
|
||||
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,
|
||||
noteEncrypted: 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: readNote(existing.noteEncrypted, existing.note),
|
||||
date: existing.date,
|
||||
source: MCP_SOURCE,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// No mass assignment — every column is set explicitly from validated input.
|
||||
// v1.23 — encrypt the note at rest; legacy plaintext column nulled.
|
||||
const entry = await prisma.moodEntry.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
date,
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note),
|
||||
source: MCP_SOURCE,
|
||||
externalId,
|
||||
moodLoggedAt,
|
||||
},
|
||||
select: {
|
||||
date: true,
|
||||
mood: true,
|
||||
note: true,
|
||||
noteEncrypted: true,
|
||||
tags: true,
|
||||
},
|
||||
});
|
||||
|
||||
const savedNote = readNote(entry.noteEncrypted, entry.note);
|
||||
|
||||
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: savedNote,
|
||||
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: savedNote,
|
||||
date: entry.date,
|
||||
source: MCP_SOURCE,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"]]) {
|
||||
|
||||
+13
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Record<ModuleKey, ModuleDefinition>> =
|
||||
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<string> = 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;
|
||||
}
|
||||
@@ -91,6 +91,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]";
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
*/
|
||||
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,
|
||||
// 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,
|
||||
|
||||
@@ -87,6 +87,46 @@ export async function checkAnalyticsReadRateLimit(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22.0 — per-credential ceiling for the remote MCP endpoint (`/mcp`).
|
||||
* The bucket is keyed by the `<userId>:<tokenId>` 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<RateLimitResult> {
|
||||
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 `<userId>:<tokenId>`
|
||||
* 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<RateLimitResult> {
|
||||
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`,
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -71,6 +71,13 @@ const PUBLIC_PATHS = [
|
||||
*/
|
||||
const WELL_KNOWN_PUBLIC_PATHS = new Set<string>([
|
||||
"/.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",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -86,6 +93,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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user