Files
HealthLog/src/app/mcp/route.ts
T
Marc-André Bombeck 6de61de010 feat(mcp): add a confirmed, audience-bound write surface for logging measurements and mood
Register two write tools — log_measurement and log_mood — only for a
health:write-scoped session; a read-only session never sees them advertised.
Each write mirrors the proven Telegram capture path (same range guard,
canonical unit, cache invalidation and rollup recompute) pinned to the MCP
source, and is idempotent on a hashed externalId derived from a client key, so
a retried call is a no-op rather than a duplicate.

Because the remote wire is stateless (no elicitation), the tools carry a
protocol-level confirm gate: without confirm:true they write nothing and
return the exact record they would write plus an instruction to confirm the
value with the user and re-call with the same idempotency key. Commits run
under a tighter per-credential write budget on top of the per-binding request
limiter.

Harden the resource-server audience binding: a token whose scopes are all in
{health:read, health:write} is bound to the MCP surface and refused on every
REST write/delete. A health:write token therefore admits the in-process MCP
write tools only and can never become a general REST write credential; admin
stays unreachable (cookie-only). Refuse, by construction, any medication, lab,
delete/update, or data-export tool on this surface.
2026-06-27 17:38:42 +02:00

210 lines
8.5 KiB
TypeScript

/**
* 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;