diff --git a/src/app/mcp/__tests__/route.test.ts b/src/app/mcp/__tests__/route.test.ts index 90f9ca47c..b838cd9a8 100644 --- a/src/app/mcp/__tests__/route.test.ts +++ b/src/app/mcp/__tests__/route.test.ts @@ -67,6 +67,8 @@ const READ_TOOLS = [ "get_medication_compliance", "get_metric_series", "list_metrics", + "search", + "fetch", ].sort(); /** A valid, narrow-scope (`health:read`) token resolution. */ @@ -148,6 +150,48 @@ describe("/mcp — happy path", () => { }); }); +describe("/mcp — Origin gate (DNS-rebinding defense, spec PR #1439)", () => { + it("rejects a cross-origin browser request with 403 before auth", async () => { + validToken(); + const res = await POST( + postRpc(TOOLS_LIST, { origin: "https://evil.example" }), + ); + expect(res.status).toBe(403); + // The Origin check runs first — the token table is never consulted. + expect(resolveBearerToken).not.toHaveBeenCalled(); + }); + + it("allows a same-origin request", async () => { + validToken(); + const res = await POST(postRpc(TOOLS_LIST, { origin: "http://localhost" })); + expect(res.status).toBe(200); + }); + + it("allows a request with no Origin header (non-browser MCP client)", async () => { + validToken(); + const res = await POST(postRpc(TOOLS_LIST)); + expect(res.status).toBe(200); + }); +}); + +describe("/mcp — 401 carries the RFC 9728 resource_metadata pointer", () => { + it("includes resource_metadata in the WWW-Authenticate challenge", async () => { + const res = await POST( + new Request("http://localhost/mcp", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(TOOLS_LIST), + }), + ); + expect(res.status).toBe(401); + const challenge = res.headers.get("www-authenticate") ?? ""; + expect(challenge).toMatch(/Bearer/); + expect(challenge).toMatch( + /resource_metadata="[^"]*\/\.well-known\/oauth-protected-resource"/, + ); + }); +}); + describe("/mcp — authentication", () => { it("rejects a request with no Authorization header (401 + challenge)", async () => { const res = await POST( diff --git a/src/app/mcp/route.ts b/src/app/mcp/route.ts index c75463026..792182441 100644 --- a/src/app/mcp/route.ts +++ b/src/app/mcp/route.ts @@ -47,6 +47,8 @@ import { withBackgroundEvent } from "@/lib/logging/background"; import { annotate } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; import { checkMcpRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { resolveBaseOrigin } from "@/lib/mcp/oauth/config"; +import { wwwAuthenticateChallenge } from "@/lib/mcp/oauth/metadata"; import type { ModuleKey } from "@/lib/modules/registry"; // The Bearer resolver + module gate + rate limiter all touch Prisma, so the @@ -56,19 +58,41 @@ export const dynamic = "force-dynamic"; const MCP_MODULE: ModuleKey = "mcp"; -/** One blunt 401 for every authentication failure class — no oracle. */ -function unauthorized(): Response { +/** + * One blunt 401 for every authentication failure class — no oracle. The + * `WWW-Authenticate` carries the RFC 9728 `resource_metadata` pointer so a + * remote client can discover the Authorization Server and start the OAuth flow + * (REQ-T3). An optional `scope` drives incremental consent (SEP-835). + */ +function unauthorized(request: Request, scope?: string): Response { return Response.json( { error: "unauthorized" }, { status: 401, - // Pre-OAuth bridge: a bare Bearer challenge. The RFC 9728 - // `resource_metadata` parameter is added when OAuth discovery lands. - headers: { "WWW-Authenticate": 'Bearer realm="healthlog-mcp"' }, + headers: { + "WWW-Authenticate": wwwAuthenticateChallenge(request.url, scope), + }, }, ); } +/** + * DNS-rebinding defense (MCP spec PR #1439): reject a request whose `Origin` + * header does not match this deployment's own origin with a 403. A non-browser + * MCP client (Claude.ai / ChatGPT server-side) sends no `Origin`, so the absence + * of the header is allowed; only a present, mismatched browser `Origin` is + * refused — closing the rebinding hole without breaking the real clients. + */ +function originAllowed(request: Request): boolean { + const origin = request.headers.get("origin"); + if (!origin) return true; + return origin === resolveBaseOrigin(request.url); +} + +function forbiddenOrigin(): Response { + return Response.json({ error: "forbidden_origin" }, { status: 403 }); +} + /** * Module off → the surface does not exist for this account. A 404 (not a * 403) keeps the remote endpoint invisible until the operator / user opts @@ -101,11 +125,17 @@ function readBearer(request: Request): string | null { async function handleMcp(request: Request): Promise { return withBackgroundEvent("mcp.request", async () => { + // 0. ORIGIN — refuse a cross-origin browser request before anything else. + if (!originAllowed(request)) { + annotate({ action: { name: "mcp.origin.rejected" } }); + return forbiddenOrigin(); + } + // 1. AUTH — Bearer only; a cookie is never consulted (admin unreachable). const raw = readBearer(request); if (!raw) { annotate({ action: { name: "mcp.auth.missing" } }); - return unauthorized(); + return unauthorized(request); } let ctx; @@ -114,7 +144,7 @@ async function handleMcp(request: Request): Promise { } catch { // Do not echo the token or the rejection reason — one blunt 401. annotate({ action: { name: "mcp.auth.rejected" } }); - return unauthorized(); + return unauthorized(request); } // 2. RATE LIMIT — per `:` credential binding. diff --git a/src/lib/mcp/__tests__/search-fetch.test.ts b/src/lib/mcp/__tests__/search-fetch.test.ts new file mode 100644 index 000000000..a9280e939 --- /dev/null +++ b/src/lib/mcp/__tests__/search-fetch.test.ts @@ -0,0 +1,141 @@ +/** + * `search` + `fetch` — the ChatGPT default-mode retrieval pair. + * + * Pins the EXACT wire shapes OpenAI mandates (`{id,title,url}[]` / + * `{id,title,text,url,metadata}`), real deep-link URLs, and the read-only + * annotations. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { z } from "zod/v4"; + +process.env.APP_URL = "https://health.example"; + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/ai/coach/tools/inventory", () => ({ + buildCoachDataInventory: vi.fn(), +})); +vi.mock("@/lib/ai/coach/tools/executor", () => ({ executeCoachTool: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { findMany: vi.fn(), findFirst: vi.fn() }, + labResult: { findMany: vi.fn() }, + }, +})); + +import { MCP_TOOLS } from "../tools"; +import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory"; +import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; +import { prisma } from "@/lib/db"; +import type { McpAuthContext } from "../auth"; + +const CTX: McpAuthContext = { + userId: "user-1", + tokenId: "token-1", + scopes: ["health:read"], + binding: "user-1:token-1", + canRead: true, + canWrite: false, +}; + +function tool(name: string) { + const def = MCP_TOOLS.find((t) => t.name === name); + if (!def) throw new Error(`tool ${name} not registered`); + return def; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(buildCoachDataInventory).mockResolvedValue({ + entries: [ + { + tool: "get_metric_series", + metric: "weight", + domain: "weight", + present: true, + count: 10, + }, + ], + window: "90d", + restMode: false, + cycleEnabled: false, + } as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + { id: "med-1", name: "Ramipril", dose: "5mg" }, + ] as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([ + { analyte: "LDL" }, + ] as never); +}); + +describe("search", () => { + it("returns { results: [{id,title,url}] } with real deep-link URLs", async () => { + const def = tool("search"); + const result = (await def.run(CTX, { query: "" })) as { + results: Array<{ id: string; title: string; url: string }>; + }; + + // Output conforms to the declared schema. + const schema = z.object(def.outputShape!); + expect(schema.safeParse(result).success).toBe(true); + + expect(result.results.length).toBeGreaterThan(0); + for (const r of result.results) { + expect(r.id).toMatch(/^(metric|med|lab|domain):/); + // ChatGPT only cites when `url` is a non-empty absolute URL. + expect(r.url.startsWith("https://health.example/")).toBe(true); + } + // The internal id stays separate from the citation url. + const med = result.results.find((r) => r.id === "med:med-1"); + expect(med?.url).toBe("https://health.example/medications"); + }); + + it("filters by query (case-insensitive substring)", async () => { + const def = tool("search"); + const result = (await def.run(CTX, { query: "ldl" })) as { + results: Array<{ id: string }>; + }; + expect(result.results.map((r) => r.id)).toContain("lab:LDL"); + expect(result.results.some((r) => r.id.startsWith("med:"))).toBe(false); + }); +}); + +describe("fetch", () => { + it("returns { id,title,text,url,metadata } for a metric id", async () => { + vi.mocked(executeCoachTool).mockResolvedValue({ + present: true, + data: { mean: 80 }, + } as never); + const def = tool("fetch"); + const result = (await def.run(CTX, { id: "metric:weight" })) as Record< + string, + unknown + >; + const schema = z.object(def.outputShape!); + expect(schema.safeParse(result).success).toBe(true); + expect(result.id).toBe("metric:weight"); + expect(result.url).toBe("https://health.example/insights"); + expect(typeof result.text).toBe("string"); + }); + + it("returns a not-found shape for an unknown id (still schema-valid)", async () => { + const def = tool("fetch"); + const result = (await def.run(CTX, { id: "bogus" })) as Record< + string, + unknown + >; + const schema = z.object(def.outputShape!); + expect(schema.safeParse(result).success).toBe(true); + expect(result.title).toBe("Not found"); + }); +}); + +describe("annotations", () => { + it("search + fetch are read-only, non-destructive, closed-world", () => { + for (const name of ["search", "fetch"]) { + const a = tool(name).annotations; + expect(a.readOnlyHint).toBe(true); + expect(a.destructiveHint).toBe(false); + expect(a.openWorldHint).toBe(false); + } + }); +}); diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 454290f23..468152565 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -10,6 +10,17 @@ vi.mock("@/lib/ai/coach/tools/inventory", () => ({ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), })); +// v1.22.0 — `search` reads the record directly via Prisma; stub it so the +// registry-wide loops never reach a DB. +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { + findMany: vi.fn(async () => []), + findFirst: vi.fn(async () => null), + }, + labResult: { findMany: vi.fn(async () => []) }, + }, +})); import { MCP_TOOLS, MCP_TOOL_NAMES } from "../tools"; import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; @@ -44,10 +55,21 @@ describe("MCP tool registry — surface", () => { "get_medication_compliance", "get_metric_series", "list_metrics", + // v1.22.0 — the ChatGPT default-mode retrieval pair. + "search", + "fetch", ].sort(), ); }); + it("annotates every tool read-only / closed-world (cloud-connector requirement)", () => { + for (const def of MCP_TOOLS) { + expect(def.annotations.readOnlyHint).toBe(true); + expect(def.annotations.destructiveHint).toBe(false); + expect(def.annotations.openWorldHint).toBe(false); + } + }); + it("exposes no admin / write tool", () => { for (const name of MCP_TOOL_NAMES) { expect(name).not.toMatch(/admin/i); diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts index 52905c5af..f40ec07bc 100644 --- a/src/lib/mcp/server.ts +++ b/src/lib/mcp/server.ts @@ -39,12 +39,27 @@ export function createMcpServer(ctx: McpAuthContext): McpServer { title: tool.title, description: tool.description, inputSchema: tool.inputShape, + // Annotations are MANDATORY for the cloud connectors — the ChatGPT + // Apps SDK treats an omitted hint as a validation error. Every read + // tool is read-only, non-destructive, closed-world (ADR-003). + annotations: tool.annotations, + ...(tool.outputShape ? { outputSchema: tool.outputShape } : {}), }, async (args: Record) => { const result = await tool.run(ctx, args ?? {}); - return { - content: [{ type: "text" as const, text: JSON.stringify(result) }], - }; + const content = [ + { type: "text" as const, text: JSON.stringify(result) }, + ]; + // When the tool declares an outputSchema (search / fetch), also return + // the object as `structuredContent` so ChatGPT + the model can read the + // typed shape; the JSON-in-`content` keeps backwards compatibility. + if (tool.outputShape) { + return { + content, + structuredContent: result as Record, + }; + } + return { content }; }, ); } diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index ac44f5426..894dacba0 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -19,6 +19,7 @@ */ import { z } from "zod/v4"; +import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory"; @@ -26,8 +27,24 @@ import { coachScopeSourceSchema, coachScopeWindowSchema, } from "@/lib/ai/coach/types"; +import { resolveBaseOrigin } from "@/lib/mcp/oauth/config"; import type { McpAuthContext } from "./auth"; +/** + * Tool annotations (MCP 2025-11-25). The cloud connectors REQUIRE these on every + * tool — the ChatGPT Apps SDK treats an omitted hint as a validation error, and + * the Claude directory requires a safety hint per tool. Every tool the MCP + * surface exposes is read-only over a closed personal record, so they all share + * the same shape (ADR-003): the read-only guarantee is structural, the + * annotation merely advertises it. + */ +const READ_ONLY_ANNOTATIONS = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +} as const; + /** * A registry entry. `inputShape` is a zod/v4 raw shape handed straight to the * SDK's `registerTool` for argument validation; `run` receives the validated @@ -38,6 +55,19 @@ export interface McpToolDefinition { title: string; description: string; inputShape: z.ZodRawShape; + /** Tool annotations — mandatory for the cloud connectors (see above). */ + annotations: { + readOnlyHint: boolean; + destructiveHint: boolean; + idempotentHint: boolean; + openWorldHint: boolean; + }; + /** + * When set, the tool declares a structured `outputSchema` and its result is + * returned as `structuredContent`. Used by `search` / `fetch` so ChatGPT can + * consume the exact `{id,title,url}[]` / `{id,title,text,url,metadata}` shapes. + */ + outputShape?: z.ZodRawShape; run: (ctx: McpAuthContext, args: Record) => Promise; } @@ -65,6 +95,194 @@ async function runCoachTool( return result; } +/** + * The `search` + `fetch` pair — the de-facto two-tool retrieval convention and, + * critically, the ONLY tools ChatGPT can call in its default (non-Developer) + * mode. Without them HealthLog is invisible in default ChatGPT. The exact wire + * shapes are mandated by OpenAI: + * + * - `search({ query })` → `{ results: [{ id, title, url }] }` + * - `fetch({ id })` → `{ id, title, text, url, metadata? }` + * + * Every result carries a REAL, user-openable HTTPS deep link (`url`) into the + * HealthLog web app — ChatGPT only creates citation metadata when `url` is a + * non-empty string. The internal `id` stays separate from `url`. Both tools are + * thin façades over the SAME server-authoritative read paths the other tools + * use; free-text fields (medication names, lab analytes) are returned as DATA, + * never interpreted as instructions (R-SEC-2). + */ +function searchAndFetchTools(): McpToolDefinition[] { + return [ + { + name: "search", + title: "Search your health records", + description: + "Search the user's own health record — metric domains, medications, and lab biomarkers — for items matching a free-text query. Returns a list of { id, title, url }; pass an id to the `fetch` tool to hydrate it. Each `url` deep-links into the HealthLog web app for citation. Returns an empty list when nothing matches.", + inputShape: { query: z.string().max(200) }, + annotations: READ_ONLY_ANNOTATIONS, + outputShape: { + results: z.array( + z.object({ + id: z.string(), + title: z.string(), + url: z.string(), + }), + ), + }, + async run(ctx, args) { + const query = + typeof args.query === "string" ? args.query.trim().toLowerCase() : ""; + const origin = resolveBaseOrigin(); + const results: Array<{ id: string; title: string; url: string }> = []; + + const inventory = await buildCoachDataInventory(ctx.userId, undefined); + for (const entry of inventory.entries) { + if (!entry.present) continue; + const hay = `${entry.domain} ${entry.metric ?? ""}`.toLowerCase(); + if (query && !hay.includes(query)) continue; + results.push({ + id: entry.metric + ? `metric:${entry.metric}` + : `domain:${entry.domain}`, + title: entry.domain, + url: `${origin}/insights`, + }); + } + + const meds = await prisma.medication.findMany({ + where: { userId: ctx.userId }, + select: { id: true, name: true, dose: true }, + orderBy: { createdAt: "desc" }, + take: 200, + }); + for (const med of meds) { + if (query && !med.name.toLowerCase().includes(query)) continue; + results.push({ + id: `med:${med.id}`, + title: med.dose ? `${med.name} ${med.dose}` : med.name, + url: `${origin}/medications`, + }); + } + + const labs = await prisma.labResult.findMany({ + where: { userId: ctx.userId, deletedAt: null }, + select: { analyte: true }, + distinct: ["analyte"], + take: 200, + }); + for (const lab of labs) { + if (query && !lab.analyte.toLowerCase().includes(query)) continue; + results.push({ + id: `lab:${lab.analyte}`, + title: lab.analyte, + url: `${origin}/labs`, + }); + } + + const capped = results.slice(0, 50); + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "search", present: capped.length > 0 }, + }); + return { results: capped }; + }, + }, + { + name: "fetch", + title: "Fetch one health record", + description: + "Hydrate a single record returned by `search`, by its id (e.g. `metric:weight`, `med:`, `lab:LDL`). Returns { id, title, text, url, metadata } where `text` is a server-authoritative, plain-text summary suitable for citation and `url` deep-links into HealthLog. Returns a not-found message when the id does not resolve.", + inputShape: { id: z.string().min(1).max(200) }, + annotations: READ_ONLY_ANNOTATIONS, + outputShape: { + id: z.string(), + title: z.string(), + text: z.string(), + url: z.string(), + metadata: z.record(z.string(), z.unknown()).optional(), + }, + async run(ctx, args) { + const id = typeof args.id === "string" ? args.id : ""; + const origin = resolveBaseOrigin(); + const sep = id.indexOf(":"); + const kind = sep > 0 ? id.slice(0, sep) : ""; + const rid = sep > 0 ? id.slice(sep + 1) : ""; + + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "fetch", kind: kind || "unknown" }, + }); + + if (kind === "metric" && rid) { + const result = await executeCoachTool({ + userId: ctx.userId, + name: "get_metric_series", + rawArguments: JSON.stringify({ metric: rid }), + }); + return { + id, + title: rid, + text: JSON.stringify(result), + url: `${origin}/insights`, + metadata: { type: "metric", metric: rid }, + }; + } + + if (kind === "lab" && rid) { + const result = await executeCoachTool({ + userId: ctx.userId, + name: "get_labs", + rawArguments: JSON.stringify({ analyte: rid }), + }); + return { + id, + title: rid, + text: JSON.stringify(result), + url: `${origin}/labs`, + metadata: { type: "lab", analyte: rid }, + }; + } + + if (kind === "med" && rid) { + const med = await prisma.medication.findFirst({ + where: { id: rid, userId: ctx.userId }, + select: { + name: true, + dose: true, + treatmentClass: true, + asNeeded: true, + }, + }); + if (!med) { + return { + id, + title: "Not found", + text: "No medication matches this id.", + url: `${origin}/medications`, + metadata: { type: "medication" }, + }; + } + return { + id, + title: med.name, + text: JSON.stringify(med), + url: `${origin}/medications`, + metadata: { type: "medication" }, + }; + } + + return { + id, + title: "Not found", + text: `No record matches the id "${id}".`, + url: `${origin}/insights`, + metadata: { type: "unknown" }, + }; + }, + }, + ]; +} + export const MCP_TOOLS: McpToolDefinition[] = [ { name: "list_metrics", @@ -72,6 +290,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [ description: "Enumerate which of the user's health data exists and how to fetch it: one row per domain with whether data is present, an approximate sample count, and the tool that retrieves it. Call this first to discover what is available before fetching figures.", inputShape: {}, + annotations: READ_ONLY_ANNOTATIONS, async run(ctx) { const inventory = await buildCoachDataInventory(ctx.userId, undefined); annotate({ @@ -96,6 +315,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [ metric: coachScopeSourceSchema, window: coachScopeWindowSchema.optional(), }, + annotations: READ_ONLY_ANNOTATIONS, run(ctx, args) { return runCoachTool(ctx, "get_metric_series", args); }, @@ -108,6 +328,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [ inputShape: { window: coachScopeWindowSchema.optional(), }, + annotations: READ_ONLY_ANNOTATIONS, run(ctx, args) { return runCoachTool(ctx, "get_medication_compliance", args); }, @@ -120,6 +341,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [ inputShape: { analyte: z.string().min(1).max(80).optional(), }, + annotations: READ_ONLY_ANNOTATIONS, run(ctx, args) { return runCoachTool(ctx, "get_labs", args); }, @@ -130,10 +352,12 @@ export const MCP_TOOLS: McpToolDefinition[] = [ description: "Fetch the user's statistically-vetted (FDR-controlled) day-to-next-day driver pairs between behaviours (daylight, mood, glucose, blood pressure, steps) and outcomes (sleep, HRV, resting HR, weight), each with direction, lag, sample size, and a descriptive — never causal — note over a fixed trailing window. Returns { present: false } when too little paired data exists.", inputShape: {}, + annotations: READ_ONLY_ANNOTATIONS, run(ctx, args) { return runCoachTool(ctx, "get_correlations", args); }, }, + ...searchAndFetchTools(), ]; /** Stable list of the registered tool names. */