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),
210 lines • 8.25 kB
JavaScript
import { randomBytes, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { logger } from "../utils/logger.js";
import { handleChatRequest } from "./chat-handler.js";
import { startQuickTunnel } from "../services/tunnel.js";
const DEFAULT_PORT = 8765;
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_MAX_BODY_BYTES = 1024 * 1024; // 1 MiB
class PayloadTooLargeError extends Error {
limit;
constructor(limit) {
super(`Request body exceeds the ${limit}-byte limit`);
this.limit = limit;
this.name = "PayloadTooLargeError";
}
}
/** Convert a Node IncomingMessage into a Fetch-style Request (body size-capped). */
async function toFetchRequest(req, ctx) {
const url = `http://${ctx.host}:${ctx.port}${req.url ?? "/"}`;
const method = req.method ?? "GET";
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value === undefined)
continue;
if (Array.isArray(value)) {
for (const v of value)
headers.append(key, v);
}
else {
headers.set(key, value);
}
}
const hasBody = method !== "GET" && method !== "HEAD";
let body;
if (hasBody) {
const chunks = [];
let total = 0;
for await (const chunk of req) {
const buf = chunk;
total += buf.length;
// Cap the body while reading so a huge/chunked POST can't exhaust memory
// before JSON parsing (the server may be exposed via the tunnel).
if (total > ctx.maxBodyBytes) {
throw new PayloadTooLargeError(ctx.maxBodyBytes);
}
chunks.push(buf);
}
body = Buffer.concat(chunks).toString("utf-8");
}
return new Request(url, { method, headers, body });
}
/** Pipe a Fetch-style Response into a Node ServerResponse. */
async function sendFetchResponse(res, response) {
const headers = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
res.writeHead(response.status, headers);
if (response.body) {
// response.body is a web ReadableStream; bridge it to the Node response.
// Await pipeline so downstream/stream errors (e.g. client disconnect mid-
// stream) reject here instead of surfacing as an unhandled 'error' event.
const source = Readable.fromWeb(response.body);
await pipeline(source, res);
}
else {
res.end(await response.text());
}
}
/** Constant-time `Authorization: Bearer <token>` check. */
function isAuthorized(req, token) {
const header = req.headers["authorization"];
const value = Array.isArray(header) ? header[0] : header;
if (!value)
return false;
const m = /^Bearer\s+(.+)$/i.exec(value.trim());
if (!m)
return false;
const provided = Buffer.from(m[1].trim());
const expected = Buffer.from(token);
return (provided.length === expected.length && timingSafeEqual(provided, expected));
}
/**
* Start the experimental agent POC HTTP server (and optional tunnel).
* Returns a handle (incl. the bearer token). Callers gate this behind the env flag.
*/
export async function startAgentPoc(options = {}) {
const envPort = process.env.COMFYUI_MCP_AGENT_PORT
? Number(process.env.COMFYUI_MCP_AGENT_PORT)
: undefined;
const requestedPort = options.port ?? (envPort && !Number.isNaN(envPort) ? envPort : DEFAULT_PORT);
const host = options.host ?? DEFAULT_HOST;
const token = options.token ??
process.env.COMFYUI_MCP_AGENT_TOKEN ??
randomBytes(24).toString("hex");
const ctx = {
host,
port: requestedPort,
token,
maxBodyBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
};
const server = createServer((req, res) => {
void handleRequest(req, res, ctx);
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(requestedPort, host, () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
const port = typeof address === "object" && address ? address.port : requestedPort;
ctx.port = port;
const localUrl = `http://${host}:${port}`;
logger.info(`[agent-poc] chat server listening on ${localUrl}/api/chat`);
logger.info(`[agent-poc] session token (send as 'Authorization: Bearer <token>'): ${token}`);
let tunnel = null;
if (options.tunnel) {
try {
tunnel = await startQuickTunnel(port);
logger.info(`[agent-poc] public URL: ${tunnel.url}`);
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error(`[agent-poc] failed to open tunnel: ${message}`);
}
}
return {
localUrl,
publicUrl: tunnel?.url ?? null,
token,
stop: async () => {
tunnel?.stop();
await new Promise((resolve) => server.close(() => resolve()));
},
};
}
async function handleRequest(req, res, ctx) {
// CORS: the tunnel + bearer token are the perimeter. `*` is safe here because
// requests are non-credentialed and must carry the Authorization header below.
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "authorization, content-type");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
// Health probe stays open (no secret, no provider spend).
if (req.method === "GET" && req.url === "/health") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
// Everything past here requires the bearer token.
if (!isAuthorized(req, ctx.token)) {
res.writeHead(401, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "Unauthorized" }));
return;
}
if (req.method === "POST" && req.url === "/api/chat") {
const contentType = req.headers["content-type"] ?? "";
if (!contentType.includes("application/json")) {
res.writeHead(415, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "Expected content-type: application/json" }));
return;
}
try {
const request = await toFetchRequest(req, ctx);
const response = await handleChatRequest(request);
await sendFetchResponse(res, response);
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error(`[agent-poc] /api/chat error: ${message}`);
if (res.headersSent) {
// Streaming already started — destroy the socket rather than corrupt
// the stream protocol by appending a JSON error body to it.
res.destroy(err instanceof Error ? err : new Error(message));
}
else if (err instanceof PayloadTooLargeError) {
res.writeHead(413, { "content-type": "application/json" });
res.end(JSON.stringify({ error: message }));
}
else {
res.writeHead(500, { "content-type": "application/json" });
res.end(JSON.stringify({ error: message }));
}
}
return;
}
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "Not found" }));
}
/**
* Gated bootstrap. Only starts the POC when COMFYUI_MCP_AGENT_POC is truthy.
* Safe to call unconditionally from a side entry; a no-op otherwise.
*/
export async function maybeStartAgentPoc() {
if (!process.env.COMFYUI_MCP_AGENT_POC) {
return null;
}
return startAgentPoc({
tunnel: process.env.COMFYUI_MCP_AGENT_TUNNEL === "1",
});
}
//# sourceMappingURL=agent-poc.js.map