import http from "node:http"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { loadConfig, validateConfig, type Config } from "./config.js"; import { SessionManager } from "./runtime/SessionManager.js"; import { createServer, SERVER_NAME, SERVER_VERSION } from "./mcp/createServer.js"; import { extractBearer, safeTokenEqual, shortHash } from "./security/auth.js"; import { hostAllowed, originAllowed } from "./security/origin.js"; import { RateLimiter } from "./security/rateLimit.js"; import { createLogger } from "./observability/logger.js"; import { MetricsRegistry } from "./observability/metrics.js"; export interface ServerHandle { httpServer: http.Server; sessionManager: SessionManager; config: Config; close: () => Promise; startedAt: number; } const STARTED_AT = Date.now(); export async function startServer(overrides?: Partial): Promise { const cfg: Config = { ...loadConfig(), ...(overrides ?? {}) }; const issues = validateConfig(cfg); const log = createLogger(); const metrics = new MetricsRegistry(); for (const i of issues) { if (i.level === "error") { log.error(`startup config error: ${i.message}`); } else { log.warn(`startup config warning: ${i.message}`); } } const errors = issues.filter((i) => i.level === "error"); if (errors.length > 0) { throw new Error( `Refusing to start; ${errors.length} config error(s): ${errors.map((e) => e.message).join("; ")}`, ); } const sm = new SessionManager(cfg); sm.startSweeper(); const rateLimiter = new RateLimiter(cfg.maxToolCallsPerMinute); const authFailRateLimiter = new RateLimiter(cfg.maxAuthFailuresPerMinute); const httpServer = http.createServer(async (req, res) => { try { metrics.requests.inc(); const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); const host = req.headers.host; if (url.pathname === "/healthz" && req.method === "GET") { return sendJson(res, 200, { ok: !sm.isDraining(), draining: sm.isDraining(), name: SERVER_NAME, version: SERVER_VERSION, build_sha: cfg.buildSha, started_at: new Date(STARTED_AT).toISOString(), uptime_seconds: Math.round((Date.now() - STARTED_AT) / 1000), }); } if (url.pathname === "/metrics" && req.method === "GET") { // require auth const authToken = extractBearer(req.headers["authorization"]); const expected = cfg.metricsToken ?? cfg.authToken; if (!expected) { return sendText(res, 503, "metrics token not configured"); } if (!authToken || !safeTokenEqual(authToken, expected)) { metrics.authFailures.inc({ scope: "metrics" }); return sendText(res, 401, "unauthorized"); } metrics.sessionsActive.set(sm.count()); metrics.resourceBytesActive.set(sm.resourceStore.totalBytesUsed()); const text = metrics.format(); res.writeHead(200, { "content-type": "text/plain; version=0.0.4" }); return res.end(text); } if (url.pathname !== "/mcp") { return sendText(res, 404, "not found"); } if (req.method === "GET") { return sendText(res, 405, "method not allowed"); } if (req.method !== "POST") { return sendText(res, 405, "method not allowed"); } // Host validation (always) if (!hostAllowed(host, cfg.allowedHosts)) { return sendJson(res, 403, { error: "host not allowed" }); } // Origin validation (only when Origin is present) const origin = (req.headers["origin"] as string | undefined) ?? undefined; if (origin && !originAllowed(origin, cfg.allowedOrigins)) { return sendJson(res, 403, { error: "origin not allowed" }); } // Auth if (cfg.authToken) { const provided = extractBearer(req.headers["authorization"]); if (!provided || !safeTokenEqual(provided, cfg.authToken)) { const clientHash = shortHash((req.socket.remoteAddress ?? "0.0.0.0") + ":authfail"); if (!authFailRateLimiter.check(clientHash)) { return sendJson(res, 429, { error: "too many auth failures" }); } metrics.authFailures.inc({ scope: "mcp" }); res.setHeader("www-authenticate", 'Bearer realm="just-bash-mcp"'); return sendJson(res, 401, { error: "unauthorized" }); } } // Rate limit per token (or IP if no token) const provided = cfg.authToken ? extractBearer(req.headers["authorization"]) ?? "" : (req.socket.remoteAddress ?? "0.0.0.0"); const rateKey = shortHash(provided); if (!rateLimiter.check(rateKey)) { metrics.rateLimited.inc(); return sendJson(res, 429, { error: "rate limit exceeded" }); } // Read body const body = await readJson(req); // Build a per-request MCP server + transport (stateless model) const mcp = createServer(sm); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, }); res.on("close", () => { transport.close().catch(() => undefined); mcp.close().catch(() => undefined); }); await mcp.connect(transport); await transport.handleRequest(req, res, body); // Best-effort metrics: track tool calls. if (body && typeof body === "object" && "method" in (body as any)) { const method = (body as any).method as string; if (method === "tools/call") { const toolName = (body as any).params?.name ?? "unknown"; metrics.toolCalls.inc({ tool: toolName }); } } } catch (err: any) { log.error("request error", { error_type: err?.name, msg: String(err?.message ?? err) }); if (!res.headersSent) { sendJson(res, 500, { error: "internal" }); } else { try { res.end(); } catch {} } } }); await new Promise((resolve, reject) => { httpServer.once("error", reject); httpServer.listen(cfg.port, "0.0.0.0", () => { httpServer.off("error", reject); log.info("listening", { port: cfg.port }); resolve(); }); }); metrics.startupHealthMs.set(Date.now() - STARTED_AT); let closed = false; const close = async () => { if (closed) return; closed = true; sm.setDraining(true); await new Promise((resolve) => httpServer.close(() => resolve())); await sm.shutdown(cfg.shutdownGraceMs); }; if (process.env.JBMCP_INSTALL_SIGTERM !== "0") { process.once("SIGTERM", () => { log.info("SIGTERM received; draining"); void close().then(() => process.exit(0)); }); process.once("SIGINT", () => { log.info("SIGINT received; draining"); void close().then(() => process.exit(0)); }); } return { httpServer, sessionManager: sm, config: cfg, close, startedAt: STARTED_AT }; } function sendJson(res: http.ServerResponse, status: number, obj: unknown) { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); } function sendText(res: http.ServerResponse, status: number, msg: string) { res.writeHead(status, { "content-type": "text/plain" }); res.end(msg); } async function readJson(req: http.IncomingMessage): Promise { const chunks: Buffer[] = []; for await (const c of req) chunks.push(c as Buffer); if (chunks.length === 0) return null; const text = Buffer.concat(chunks).toString("utf8"); if (!text.trim()) return null; try { return JSON.parse(text); } catch { return null; } } // Auto-run when invoked directly (not when imported by tests). const invokedDirectly = process.argv[1] && (import.meta.url === `file://${process.argv[1]}` || process.argv[1].endsWith("server.ts") || process.argv[1].endsWith("server.js")); if (invokedDirectly) { startServer().catch((err) => { // eslint-disable-next-line no-console console.error("startup failed:", err); process.exit(1); }); }