diff --git a/mcp/src/lab/mount.ts b/mcp/src/lab/mount.ts index 7af4a2202..ef6b15da6 100644 --- a/mcp/src/lab/mount.ts +++ b/mcp/src/lab/mount.ts @@ -35,6 +35,30 @@ function bridge(factory: () => Promise<{ app: { fetch: any } }>) { * Registration MUST happen before `express.json()` so vein receives the * raw request stream (same constraint as the graph SSE routes). */ +/** + * Gate every /lab route behind the mcp-wide API_TOKEN (unset = dev mode = + * open, the same posture as the /events route). Two accepted credentials: + * HTTP Basic `admin:` — the browser prompts once for the UI and + * then attaches it to every request including EventSource streams, which + * cannot carry custom headers — and the `x-api-token` header, matching the + * rest of mcp for server-to-server callers. + */ +function labAuth(req: Request, res: Response, next: NextFunction): void { + const apiToken = process.env.API_TOKEN; + if (!apiToken) return next(); + if (req.header("x-api-token") === apiToken) return next(); + const header = req.header("authorization") ?? ""; + if (header.startsWith("Basic ")) { + const decoded = Buffer.from(header.slice(6), "base64").toString(); + const sep = decoded.indexOf(":"); + const user = decoded.slice(0, sep); + const pass = decoded.slice(sep + 1); + if (sep > 0 && user === "admin" && pass === apiToken) return next(); + } + res.set("WWW-Authenticate", 'Basic realm="stakgraph-lab"'); + res.status(401).json({ error: "Unauthorized" }); +} + export function mountLab(app: Express): void { // Trailing slash so the SPA's relative asset URLs resolve under /lab/. // Express routing is non-strict, so `/lab` also matches `/lab/`; guard @@ -45,5 +69,5 @@ export function mountLab(app: Express): void { if (req.path === "/lab/") return next(); res.redirect(308, "/lab/"); }); - app.use("/lab", bridge(() => createLabVein({ serveUi: true }))); + app.use("/lab", labAuth, bridge(() => createLabVein({ serveUi: true }))); }