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),

503 lines (493 loc) • 21.6 kB
import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from "node:fs"; import { platform } from "node:os"; import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { freshSecretValue } from "../env-file.js"; import { resolveEffectiveComfyUIBase } from "./workspace-env.js"; import { ComfyUIError, ValidationError } from "../utils/errors.js"; import { logger } from "../utils/logger.js"; // --------------------------------------------------------------------------- // Custom-node authoring lifecycle — scaffold a Python node pack from a // deterministic template, then publish it to the Comfy Registry via // `comfy node publish`. // // Template shape mirrors the Comfy Registry publishing guide // (https://docs.comfy.org/registry/publishing) and the custom-node walkthrough // (https://docs.comfy.org/custom-nodes/walkthrough): // - pyproject.toml carries [project] metadata plus a [tool.comfy] table with // PublisherId / DisplayName / Icon. The registry reads PublisherId to route // the pack to your account and the [project] name/version for the listing. // - __init__.py exports NODE_CLASS_MAPPINGS / NODE_DISPLAY_NAME_MAPPINGS (and // WEB_DIRECTORY when a frontend extension ships). // - src/nodes.py holds a sample node class with the canonical INPUT_TYPES / // RETURN_TYPES / FUNCTION / CATEGORY contract. // // Both operations are LOCAL-ONLY: they write to / run inside // <COMFYUI_PATH>/custom_nodes and have no meaning against a remote // --comfyui-url instance. // --------------------------------------------------------------------------- export class NodeAuthoringError extends ComfyUIError { constructor(message, details) { super(message, "NODE_AUTHORING_ERROR", details); this.name = "NodeAuthoringError"; } } const IS_WIN = platform() === "win32"; function defaultIsNonEmptyDir(p) { try { if (!existsSync(p)) return false; return readdirSync(p).length > 0; } catch { return false; } } function defaultHasCommand(cmd) { try { const probe = IS_WIN ? ["where", cmd] : ["command", "-v", cmd]; const res = spawnSync(probe[0], probe.slice(1), { stdio: "ignore", timeout: 5000, shell: IS_WIN ? false : true, }); return res.status === 0; } catch { return false; } } function defaultRun(cmd, args, opts) { const result = spawnSync(cmd, args, { cwd: opts.cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], shell: false, timeout: 600_000, // Token is injected via env, never via args (which we log). env: { ...process.env, ...(opts.env ?? {}) }, }); if (result.error) { const e = result.error; if (e.code === "ENOENT") { throw new NodeAuthoringError(`"${cmd}" was not found on PATH. Install comfy-cli (\`pip install comfy-cli\`) to publish.`); } throw new NodeAuthoringError(`Failed to execute ${cmd}: ${e.message}`); } const stdout = result.stdout ?? ""; const stderr = result.stderr ?? ""; const combined = [stdout, stderr].filter(Boolean).join("\n").trim(); if (result.status !== 0) { throw new NodeAuthoringError(`Command failed (exit ${result.status}): ${cmd} ${args.join(" ")}\n${combined}`); } return combined; } const defaultDeps = { existsSync, mkdirp: (p) => { mkdirSync(p, { recursive: true }); }, writeFile: (p, contents) => { writeFileSync(p, contents, "utf-8"); }, readFile: (p) => readFileSync(p, "utf-8"), isNonEmptyDir: defaultIsNonEmptyDir, hasCommand: defaultHasCommand, run: defaultRun, }; // --------------------------------------------------------------------------- // Validation helpers — pure, so tests can assert on them directly. // --------------------------------------------------------------------------- const SLUG_RE = /^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/; /** * Validate a pack name as a safe slug AND reject anything that could escape * custom_nodes/ (path separators, "..", absolute paths, drive letters). The * SLUG_RE already forbids "/", "\\", ":" and "." so traversal is impossible, * but we keep an explicit check for a clear, security-focused error message. */ export function validatePackName(name) { const trimmed = (name ?? "").trim(); if (!trimmed) { throw new ValidationError("name is required and cannot be empty."); } if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("..") || isAbsolute(trimmed) || /^[a-zA-Z]:/.test(trimmed)) { throw new ValidationError(`Unsafe pack name "${name}": names may not contain path separators, "..", ` + `or be absolute — they must stay inside custom_nodes/.`); } if (!SLUG_RE.test(trimmed)) { throw new ValidationError(`Invalid pack name "${name}". Use a lowercase slug: letters, digits, ` + `hyphens, and underscores only (e.g. "my-cool-nodes"). Must start and ` + `end with a letter or digit.`); } if (trimmed.length > 64) { throw new ValidationError(`Pack name "${name}" is too long (max 64 chars).`); } return trimmed; } /** A python-identifier-safe variant of the slug for the sample class name. */ function toClassName(slug) { const parts = slug.split(/[-_]+/).filter(Boolean); const pascal = parts .map((p) => p.charAt(0).toUpperCase() + p.slice(1)) .join(""); // Ensure it doesn't start with a digit (python identifier rule). return /^[0-9]/.test(pascal) ? `Node${pascal}` : `${pascal}Node`; } /** * Resolve the custom_nodes root, throwing a clear error when there is no local * install path to write into. Resolves the effective LOCAL ComfyUI base the same * way every other filesystem-backed tool does (node-dev, node-verify): COMFYUI_PATH * first, then the saved default workspace (set via workspace action:"set_default") when * COMFYUI_PATH is unset and we are not targeting a remote ComfyUI. This is what * install_comfyui (action:"environment") / workspace action:"get" already report, so scaffold/publish no longer * reject a loopback session that has a saved default workspace as if it were remote * (#506). Returns undefined only in remote mode or when no local install is known — * then we refuse with a clear, actionable error. * * `resolvedBase` is the caller's ASYNC, live-aware scan-root resolution * (`resolveCustomNodesScanBaseLive`, #1653/#1715/#2031) and is AUTHORITATIVE * when given: that resolver already encodes the full precedence — the live * server's `--base-directory` (where folder_paths scans custom_nodes/ from * when the flag is set, #1715), else the live main.py checkout on a split * install that has no `--base-directory` (the data workspace is not scanned * unless the flag said so, #2031), then configuration. Re-preferring * configuration here would resurrect either the Desktop split-root bug * (scaffold under the code install root while the runtime loaded from its * base directory) or the portable split-root bug (scaffold under the data * workspace while the runtime loaded from the checkout). The sync * configured-only resolution remains the fallback for callers that never * resolved one. */ function customNodesRoot(resolvedBase) { const base = resolvedBase ?? resolveEffectiveComfyUIBase(); if (!base) { throw new ValidationError("This operation needs a local ComfyUI install, but none could be resolved: " + "COMFYUI_PATH is unset, no default workspace is saved, and no running " + "LOCAL ComfyUI could be adopted from the connected server (or this " + "session targets a remote --comfyui-url instance). Set COMFYUI_PATH, " + "save a default workspace (workspace action:\"set_default\"), or connect " + "to a running local ComfyUI to scaffold or publish custom nodes."); } return join(base, "custom_nodes"); } /** * Resolve and confirm a target pack directory is strictly inside custom_nodes/. * Defends against traversal even if validation upstream were bypassed. */ function resolvePackDir(name, resolvedBase) { const root = customNodesRoot(resolvedBase); const dir = resolve(root, name); const rel = relative(root, dir); if (rel === "" || rel.startsWith("..") || isAbsolute(rel) || rel.includes(`..${sep}`)) { throw new ValidationError(`Refusing to operate outside custom_nodes/: resolved "${name}" to "${dir}".`); } return dir; } // --------------------------------------------------------------------------- // Template builders — pure functions returning file contents. // --------------------------------------------------------------------------- function escapeToml(s) { // TOML basic strings: escape backslash and double-quote. return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } export function buildPyprojectToml(opts) { const repoUrl = `https://github.com/${opts.publisherId}/${opts.name}`; return `[project] name = "${escapeToml(opts.name)}" description = "${escapeToml(opts.description)}" version = "0.0.1" license = { text = "MIT" } dependencies = [] [project.urls] Repository = "${escapeToml(repoUrl)}" [tool.comfy] PublisherId = "${escapeToml(opts.publisherId)}" DisplayName = "${escapeToml(opts.displayName)}" Icon = "" `; } export function buildInitPy(opts) { const lines = [ `"""${opts.displayName} — ComfyUI custom node pack."""`, "", `from .src.nodes import ${opts.className}`, "", "NODE_CLASS_MAPPINGS = {", ` "${opts.className}": ${opts.className},`, "}", "", "NODE_DISPLAY_NAME_MAPPINGS = {", ` "${opts.className}": "${opts.displayName}",`, "}", "", ]; if (opts.withFrontend) { lines.push("# Serve the bundled JS extension(s) from ./web to the ComfyUI frontend.", 'WEB_DIRECTORY = "./web"', "", '__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]', ""); } else { lines.push('__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]', ""); } return lines.join("\n"); } export function buildNodesPy(opts) { return `"""Sample node for ${escapeToml(opts.displayName)}.""" class ${opts.className}: """A minimal example node. Replace with your own logic.""" @classmethod def INPUT_TYPES(cls): return { "required": { "text": ("STRING", {"default": "hello", "multiline": False}), }, } RETURN_TYPES = ("STRING",) RETURN_NAMES = ("text",) FUNCTION = "run" CATEGORY = "${escapeToml(opts.category)}" def run(self, text): # TODO: implement your node. This sample echoes its input. return (text,) `; } export function buildFrontendJs(opts) { return `// Frontend extension stub for ${opts.displayName}. // Registered via WEB_DIRECTORY in __init__.py. See: // https://docs.comfy.org/custom-nodes/js/javascript_overview import { app } from "../../scripts/app.js"; app.registerExtension({ name: "${opts.name}", async setup() { console.log("[${opts.name}] frontend extension loaded"); }, }); `; } export function buildComfyignore() { // Layers on top of .gitignore; excludes dev cruft from the published package. return `.git/ .github/ __pycache__/ *.py[cod] .venv/ venv/ node_modules/ *.log .DS_Store `; } export function buildGitignore() { return `__pycache__/ *.py[cod] .venv/ venv/ node_modules/ *.log .DS_Store `; } export function buildPublishWorkflowYml() { // Publishes a new version on push to main when pyproject.toml changes // (i.e. when the author bumps `version`). Needs the REGISTRY_ACCESS_TOKEN // repo secret. See https://docs.comfy.org/registry/publishing. return `name: Publish to Comfy Registry on: workflow_dispatch: push: branches: - main paths: - "pyproject.toml" jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Publish custom node uses: Comfy-Org/publish-node-action@main with: personal_access_token: \${{ secrets.REGISTRY_ACCESS_TOKEN }} `; } // --------------------------------------------------------------------------- // node_pack action:"scaffold" // --------------------------------------------------------------------------- export function scaffoldCustomNode(options, deps = defaultDeps, resolvedBase) { const name = validatePackName(options.name); const displayName = (options.displayName ?? "").trim() || name; const category = (options.category ?? "custom").trim() || "custom"; const description = (options.description ?? "").trim() || `ComfyUI custom nodes: ${displayName}`; // PublisherId is required for publishing later; default to a clear placeholder // the author must replace (rather than guessing). const publisherId = (options.publisherId ?? "").trim() || "your-publisher-id"; const withFrontend = options.withFrontend ?? false; const className = toClassName(name); const packDir = resolvePackDir(name, resolvedBase); // Refuse to clobber an existing pack unless explicitly allowed. if (deps.isNonEmptyDir(packDir) && !options.overwrite) { throw new ValidationError(`A non-empty directory already exists at ${packDir}. ` + `Pass overwrite:true to replace its files, or choose a different name.`); } deps.mkdirp(packDir); deps.mkdirp(join(packDir, "src")); const written = []; const write = (relPath, contents) => { deps.writeFile(join(packDir, relPath), contents); written.push(relPath); }; write("pyproject.toml", buildPyprojectToml({ name, description, publisherId, displayName })); write("__init__.py", buildInitPy({ className, displayName, withFrontend })); // src is a package; an empty __init__ makes the relative import work. write(join("src", "__init__.py"), ""); write(join("src", "nodes.py"), buildNodesPy({ className, displayName, category })); if (withFrontend) { deps.mkdirp(join(packDir, "web", "js")); write(join("web", "js", `${name}.js`), buildFrontendJs({ name, displayName })); } // Always emit ignore files so the published package and repo stay clean. write(".comfyignore", buildComfyignore()); write(".gitignore", buildGitignore()); const withCi = options.withCi ?? false; if (withCi) { deps.mkdirp(join(packDir, ".github", "workflows")); write(join(".github", "workflows", "publish_action.yml"), buildPublishWorkflowYml()); } logger.info(`Scaffolded custom node pack "${name}"`, { path: packDir }); return { name, path: packDir, files: written, withFrontend, message: `Scaffolded custom node pack "${name}" at ${packDir}. ` + (publisherId === "your-publisher-id" ? `Set [tool.comfy].PublisherId in pyproject.toml before publishing. ` : ``) + `Restart ComfyUI (restart_comfyui) to load it, then publish with node_pack (action:"publish").`, }; } /** * Extract just the fields publish needs from a pyproject.toml. Intentionally * minimal — we read [project].name/version and [tool.comfy].PublisherId without * a full TOML parser (no dep), tolerating common formatting. */ export function parsePyproject(toml) { const result = {}; // Slice a [header] table: from its header line up to (but not including) the // next table header — a line that STARTS with "[" at column 0. We must not // stop at a "[" mid-line (e.g. an inline array like `dependencies = ["x"]`), // which would truncate the section and drop later keys such as `version`. const escapedHeader = (header) => header.replace(/[.[\]]/g, "\\$&"); const sectionOf = (header) => { const re = new RegExp(`^\\[${escapedHeader(header)}\\][^\\n]*\\n(?:(?!\\[)[^\\n]*\\n?)*`, "m"); const m = toml.match(re); return m ? m[0] : ""; }; const readKey = (section, key) => { // Accept single- OR double-quoted TOML strings (backreference the opening // quote so mismatched quotes don't match). Not a full TOML parser, but it // covers the basic + multi-line table forms Python packaging tools emit. const re = new RegExp(`^\\s*${key}\\s*=\\s*(["'])(.*?)\\1`, "m"); const m = section.match(re); return m ? m[2] : undefined; }; const project = sectionOf("project"); result.projectName = readKey(project, "name"); result.version = readKey(project, "version"); const toolComfy = sectionOf("tool.comfy"); result.publisherId = readKey(toolComfy, "PublisherId"); return result; } // --------------------------------------------------------------------------- // node_pack action:"publish" // --------------------------------------------------------------------------- /** Parse a registry URL out of comfy-cli publish output, if present. */ export function extractRegistryUrl(output) { const m = output.match(/https?:\/\/[^\s"')]*registry\.comfy\.org[^\s"')]*/i); return m ? m[0] : undefined; } export function publishCustomNode(options, deps = defaultDeps, resolvedBase) { // Resolve the pack directory from either an explicit path or a name. let packDir; if (options.path && options.path.trim()) { // Publishing an explicit pack directory is a local filesystem + comfy-cli // operation, independent of which ComfyUI instance is targeted — so it is // intentionally allowed even in remote (--comfyui-url) mode. Resolving by // `name` (below) lives under <COMFYUI_PATH>/custom_nodes and is guarded by // resolvePackDir, which fails clearly when there is no local install. packDir = resolve(options.path.trim()); } else if (options.name && options.name.trim()) { packDir = resolvePackDir(validatePackName(options.name), resolvedBase); } else { throw new ValidationError("Provide either `name` (a pack under custom_nodes/) or an explicit `path`."); } if (!deps.existsSync(packDir)) { throw new NodeAuthoringError(`Pack directory does not exist: ${packDir}`); } const pyprojectPath = join(packDir, "pyproject.toml"); if (!deps.existsSync(pyprojectPath)) { throw new NodeAuthoringError(`No pyproject.toml found in ${packDir}. A Comfy Registry pack must have one ` + `(see https://docs.comfy.org/registry/publishing).`); } const parsed = parsePyproject(deps.readFile(pyprojectPath)); const missing = []; if (!parsed.projectName) missing.push("[project].name"); if (!parsed.version) missing.push("[project].version"); if (!parsed.publisherId) missing.push("[tool.comfy].PublisherId"); if (parsed.publisherId === "your-publisher-id") { throw new ValidationError(`[tool.comfy].PublisherId is still the placeholder "your-publisher-id" in ` + `${pyprojectPath}. Set it to your real Comfy Registry publisher id before publishing.`); } if (missing.length > 0) { throw new ValidationError(`pyproject.toml at ${pyprojectPath} is missing required field(s): ` + `${missing.join(", ")}. See https://docs.comfy.org/registry/publishing.`); } // Require the registry token. comfy-cli reads --token; we pass it via env so // it never lands in argv (which we log) — comfy-cli falls back to the // REGISTRY_ACCESS_TOKEN env var when --token is absent. // Access-time resolution against the canonical ~/.comfyui-mcp/.env (#826) so a // token the panel saved after this process started is actually seen, instead of // reporting "not set" forever against a token that is on disk. const token = freshSecretValue("REGISTRY_ACCESS_TOKEN"); if (!token || !token.trim()) { throw new ValidationError("REGISTRY_ACCESS_TOKEN is not set. Create an API key at " + "https://registry.comfy.org and export it as REGISTRY_ACCESS_TOKEN before publishing."); } if (!deps.hasCommand("comfy")) { throw new NodeAuthoringError("comfy-cli is not installed (the `comfy` command was not found on PATH). " + "Install it with `pip install comfy-cli` to publish to the registry."); } // NOTE: token is passed ONLY through env, never in args. We log args, so a // token in args would leak into logs. logger.info("Publishing custom node to Comfy Registry", { pack: parsed.projectName, version: parsed.version, cwd: packDir, args: ["node", "publish"].join(" "), }); const output = deps.run("comfy", ["node", "publish"], { cwd: packDir, env: { REGISTRY_ACCESS_TOKEN: token }, }); const registryUrl = extractRegistryUrl(output); return { published: true, packName: parsed.projectName, version: parsed.version, publisherId: parsed.publisherId, path: packDir, registryUrl, message: `Published "${parsed.projectName}" v${parsed.version} to the Comfy Registry` + (registryUrl ? ` (${registryUrl}).` : ".") + ` This is a public, external action and cannot be undone here.`, output, }; } //# sourceMappingURL=node-authoring.js.map