UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

149 lines • 6.73 kB
import http from "node:http"; import { randomUUID, timingSafeEqual } from "node:crypto"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { logger } from "../utils/logger.js"; // Hostnames that keep traffic on the local machine. Anything else (including // 0.0.0.0, which binds all interfaces) is reachable off-box, so an unset token // there is a hard failure unless explicitly allowed. const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost", "::ffff:127.0.0.1"]); /** * Constant-time comparison of the presented credential against the configured * token. Reads `Authorization: Bearer <token>` first, then `X-API-Key`. Returns * true iff exactly one matches; the length-guard keeps timingSafeEqual from * throwing on mismatched lengths while staying constant-time per-comparison. */ function isAuthorized(req, token) { const expected = Buffer.from(token); const candidates = []; const authHeader = req.headers["authorization"]; const auth = Array.isArray(authHeader) ? authHeader[0] : authHeader; if (auth) { const m = /^Bearer\s+(.+)$/i.exec(auth.trim()); if (m) candidates.push(m[1].trim()); } const apiKeyHeader = req.headers["x-api-key"]; const apiKey = Array.isArray(apiKeyHeader) ? apiKeyHeader[0] : apiKeyHeader; if (apiKey) candidates.push(apiKey.trim()); for (const candidate of candidates) { const provided = Buffer.from(candidate); if (provided.length === expected.length && timingSafeEqual(provided, expected)) { return true; } } return false; } function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => { const raw = Buffer.concat(chunks).toString("utf-8"); if (raw.length === 0) return resolve(undefined); try { resolve(JSON.parse(raw)); } catch (err) { reject(err); } }); req.on("error", reject); }); } function sendJsonError(res, status, message) { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message }, id: null, })); } /** * Start a streamable-HTTP MCP server using the canonical SDK session pattern: * a new transport + McpServer is created on the initialize request and reused * for subsequent requests carrying the mcp-session-id header. */ export async function startHttpServer(opts) { const path = opts.path ?? "/mcp"; const token = opts.token?.trim() || undefined; const transports = new Map(); if (!token && !LOOPBACK_HOSTS.has(opts.host.toLowerCase())) { if (!opts.allowUnauthenticated) { throw new Error(`Refusing to start: HTTP MCP transport bound on non-loopback host ${opts.host} ` + `WITHOUT an auth token — the /mcp endpoint would be OPEN to anyone who can reach ` + `this host. Fix one of:\n` + ` • set COMFYUI_MCP_HTTP_TOKEN=<secret> (recommended), or\n` + ` • use --tunnel (auto-generates a token), or\n` + ` • bind a loopback host (--host 127.0.0.1), or\n` + ` • explicitly opt into an open endpoint with ` + `--allow-unauthenticated-non-loopback (env COMFYUI_MCP_ALLOW_UNAUTH=1).`); } logger.warn(`HTTP MCP transport bound on non-loopback host ${opts.host} WITHOUT an auth token — ` + `the /mcp endpoint is OPEN to anyone who can reach this host ` + `(allowed via --allow-unauthenticated-non-loopback / COMFYUI_MCP_ALLOW_UNAUTH).`); } const httpServer = http.createServer(async (req, res) => { try { const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); if (url.pathname !== path) { sendJsonError(res, 404, `Not found. MCP endpoint is ${path}`); return; } // Auth gate: only enforced when a token is configured. Unset → open // (unchanged legacy behavior). Applies to every method on the endpoint. if (token && !isAuthorized(req, token)) { sendJsonError(res, 401, "Unauthorized: missing or invalid token"); return; } const sessionId = req.headers["mcp-session-id"]; let transport = sessionId ? transports.get(sessionId) : undefined; if (req.method === "POST") { const body = await readBody(req); if (!transport) { if (!isInitializeRequest(body)) { sendJsonError(res, 400, "No valid session: first request must be an initialize request"); return; } transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sid) => { transports.set(sid, transport); logger.info("HTTP MCP session initialized", { session: sid }); }, }); transport.onclose = () => { const sid = transport.sessionId; if (sid) transports.delete(sid); }; const server = await opts.createServer(); await server.connect(transport); } await transport.handleRequest(req, res, body); } else if (req.method === "GET" || req.method === "DELETE") { if (!transport) { sendJsonError(res, 400, "Missing or unknown mcp-session-id"); return; } await transport.handleRequest(req, res); } else { sendJsonError(res, 405, "Method not allowed"); } } catch (err) { logger.error("HTTP MCP request error", { error: err instanceof Error ? err.message : err, }); if (!res.headersSent) sendJsonError(res, 500, "Internal server error"); } }); await new Promise((resolve) => httpServer.listen(opts.port, opts.host, resolve)); return httpServer; } //# sourceMappingURL=http.js.map