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),
520 lines • 21.2 kB
JavaScript
import { config } from "../config.js";
import { getObjectInfo } from "../comfyui/client.js";
import { ComfyUIError } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
// ---------------------------------------------------------------------------
// GitHub helpers
// ---------------------------------------------------------------------------
function githubHeaders() {
const headers = {
Accept: "application/vnd.github.v3+json",
};
if (config.githubToken) {
headers["Authorization"] = `Bearer ${config.githubToken}`;
}
return headers;
}
/**
* #1026 — an unbounded network wait can wedge a whole agent turn.
*
* `list_packs action:"generate_skill"` reaches GitHub and api.comfy.org, and
* every one of those calls was a bare `fetch` with no AbortSignal. A host that
* accepts the connection and then never answers — a captive portal, a stalled
* proxy, a black-holed route — leaves the request pending forever: no error, no
* result, nothing for the caller to act on. A reporter's `list_packs` call sat
* in flight past 180s and had to be killed client-side, which is exactly this
* shape: not a failure that was mis-described, but no answer at all.
*
* Every other network path on that tool is already bounded (the live-server
* template listing goes through comfyuiFetch, which applies
* COMFYUI_MCP_HTTP_TIMEOUT_S; other comfyuiFetch callers pass their own). These
* three were the gap.
*
* Generous by default — a cold GitHub API call over a slow link is legitimately
* slow, and a timeout that fires on a working connection would be its own bug.
* The point is a CEILING, not a tight bound.
*/
const DEFAULT_SKILL_FETCH_TIMEOUT_S = 30;
function skillFetchTimeoutSeconds() {
const raw = Number(process.env.COMFYUI_MCP_SKILL_FETCH_TIMEOUT_S);
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_SKILL_FETCH_TIMEOUT_S;
}
function skillFetchSignal() {
return AbortSignal.timeout(Math.round(skillFetchTimeoutSeconds() * 1000));
}
/**
* Turn an abort into a statement of what we DID and did NOT learn. A timeout is
* not a 404 and not a refusal: the request may well have been received, so this
* says the wait ended, never that the resource is absent.
*/
function describeSkillFetchTimeout(err, what, url) {
const aborted = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
if (!aborted)
return err instanceof Error ? err : new Error(String(err));
return new ComfyUIError(`Timed out after ${skillFetchTimeoutSeconds()}s ${what} (${url}). The host accepted the ` +
`request but did not answer in time, so whether it exists could NOT be determined — this ` +
`is not a "not found". Check network/proxy reachability to that host, or raise ` +
`COMFYUI_MCP_SKILL_FETCH_TIMEOUT_S if the link is simply slow.`, "NETWORK_TIMEOUT");
}
export async function fetchGitHubFile(owner, repo, path) {
const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}`;
const res = await fetch(url, {
headers: githubHeaders(),
signal: skillFetchSignal(),
}).catch((err) => {
throw describeSkillFetchTimeout(err, `fetching ${path} from GitHub`, url);
});
if (!res.ok) {
throw new ComfyUIError(`GitHub API error ${res.status} fetching ${path}`, "GITHUB_ERROR");
}
const data = (await res.json());
if (data.encoding === "base64") {
return Buffer.from(data.content, "base64").toString("utf-8");
}
return data.content;
}
async function listGitHubDir(owner, repo, path = "") {
const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}`;
const res = await fetch(url, {
headers: githubHeaders(),
signal: skillFetchSignal(),
}).catch((err) => {
throw describeSkillFetchTimeout(err, `listing ${path || "/"} on GitHub`, url);
});
if (!res.ok) {
throw new ComfyUIError(`GitHub API error ${res.status} listing ${path || "/"}`, "GITHUB_ERROR");
}
const data = await res.json();
if (!Array.isArray(data))
return [];
return data;
}
// ---------------------------------------------------------------------------
// Registry helper
// ---------------------------------------------------------------------------
async function resolveRegistryRepo(registryId) {
const url = `https://api.comfy.org/nodes/${encodeURIComponent(registryId)}`;
const res = await fetch(url, {
headers: { Accept: "application/json" },
signal: skillFetchSignal(),
}).catch((err) => {
throw describeSkillFetchTimeout(err, `resolving "${registryId}" on the ComfyUI Registry`, url);
});
if (!res.ok) {
throw new ComfyUIError(`ComfyUI Registry error ${res.status} for "${registryId}"`, "REGISTRY_ERROR");
}
const data = (await res.json());
if (!data.repository) {
throw new ComfyUIError(`No repository URL found for registry ID "${registryId}"`, "REGISTRY_ERROR");
}
return data.repository;
}
// ---------------------------------------------------------------------------
// Parse owner/repo from a GitHub URL
// ---------------------------------------------------------------------------
function parseGitHubUrl(url) {
// Handles: https://github.com/owner/repo, https://github.com/owner/repo.git
const match = url.match(/github\.com\/([^/]+)\/([^/?.#]+)/);
if (!match) {
throw new ComfyUIError(`Cannot parse GitHub owner/repo from "${url}"`, "VALIDATION_ERROR");
}
return { owner: match[1], repo: match[2].replace(/\.git$/, "") };
}
// ---------------------------------------------------------------------------
// NODE_CLASS_MAPPINGS parser
// ---------------------------------------------------------------------------
export function parseNodeClassMappings(pythonSource) {
const mappings = {};
// Pattern 1: NODE_CLASS_MAPPINGS = { "DisplayName": ClassName, ... }
// Handles multiline dicts with various quoting
const dictMatch = pythonSource.match(/NODE_CLASS_MAPPINGS\s*=\s*\{([^}]*)\}/s);
if (dictMatch) {
const body = dictMatch[1];
// Match entries like "DisplayName": ClassName or 'DisplayName': ClassName
const entryPattern = /["']([^"']+)["']\s*:\s*([A-Za-z_]\w*)/g;
let m;
while ((m = entryPattern.exec(body)) !== null) {
mappings[m[1]] = m[2];
}
}
// Pattern 2: NODE_CLASS_MAPPINGS["DisplayName"] = ClassName
const assignPattern = /NODE_CLASS_MAPPINGS\[["']([^"']+)["']\]\s*=\s*([A-Za-z_]\w*)/g;
let m;
while ((m = assignPattern.exec(pythonSource)) !== null) {
mappings[m[1]] = m[2];
}
// Pattern 3: NODE_CLASS_MAPPINGS.update({ ... })
const updateMatch = pythonSource.match(/NODE_CLASS_MAPPINGS\.update\(\s*\{([^}]*)\}\s*\)/s);
if (updateMatch) {
const body = updateMatch[1];
const entryPattern = /["']([^"']+)["']\s*:\s*([A-Za-z_]\w*)/g;
let m2;
while ((m2 = entryPattern.exec(body)) !== null) {
mappings[m2[1]] = m2[2];
}
}
return mappings;
}
// ---------------------------------------------------------------------------
// Collect Python files with NODE_CLASS_MAPPINGS from a repo
// ---------------------------------------------------------------------------
async function findNodeMappings(owner, repo) {
const allMappings = {};
// List root directory
const rootEntries = await listGitHubDir(owner, repo);
// Gather Python files at root level + common sub-directories
const pyFiles = [];
const subDirs = [];
for (const entry of rootEntries) {
if (entry.type === "file" && entry.name.endsWith(".py")) {
pyFiles.push(entry.path);
}
else if (entry.type === "dir") {
subDirs.push(entry.path);
}
}
// Check common node directories (first level only to avoid excessive API calls)
for (const dir of subDirs.slice(0, 10)) {
try {
const dirEntries = await listGitHubDir(owner, repo, dir);
for (const entry of dirEntries) {
if (entry.type === "file" && entry.name.endsWith(".py")) {
pyFiles.push(entry.path);
}
}
}
catch {
// Skip directories we can't read
}
}
// Fetch Python files and parse NODE_CLASS_MAPPINGS (limit to avoid rate limits)
const filesToCheck = pyFiles.slice(0, 30);
const fetches = filesToCheck.map(async (path) => {
try {
const source = await fetchGitHubFile(owner, repo, path);
if (source.includes("NODE_CLASS_MAPPINGS")) {
const parsed = parseNodeClassMappings(source);
Object.assign(allMappings, parsed);
}
}
catch {
// Skip files we can't fetch
}
});
await Promise.all(fetches);
return allMappings;
}
// ---------------------------------------------------------------------------
// Find example workflows in the repo
// ---------------------------------------------------------------------------
async function findExampleWorkflows(owner, repo) {
const examples = [];
const jsonPaths = [];
// Check root and common example dirs
const dirsToCheck = ["", "examples", "workflows", "example_workflows"];
for (const dir of dirsToCheck) {
try {
const entries = await listGitHubDir(owner, repo, dir);
for (const entry of entries) {
if (entry.type === "file" && entry.name.endsWith(".json")) {
jsonPaths.push(entry.path);
}
}
}
catch {
// Directory may not exist
}
}
// Fetch up to 5 example workflows
for (const path of jsonPaths.slice(0, 5)) {
try {
const content = await fetchGitHubFile(owner, repo, path);
const workflow = JSON.parse(content);
// Quick heuristic: ComfyUI workflows have nodes with class_type
const isWorkflow = typeof workflow === "object" &&
workflow !== null &&
Object.values(workflow).some((v) => typeof v === "object" &&
v !== null &&
"class_type" in v);
if (isWorkflow) {
const name = path
.split("/")
.pop()
.replace(/\.json$/, "")
.replace(/[_-]/g, " ");
examples.push({
name,
description: `Example workflow from ${path}`,
workflow,
});
}
}
catch {
// Skip unparseable files
}
}
return examples;
}
// ---------------------------------------------------------------------------
// Fetch README for description
// ---------------------------------------------------------------------------
async function fetchReadme(owner, repo) {
const candidates = ["README.md", "readme.md", "README.rst", "README"];
for (const name of candidates) {
try {
return await fetchGitHubFile(owner, repo, name);
}
catch {
// Try next
}
}
return null;
}
function extractFirstParagraph(markdown) {
// Skip leading headings/badges, grab first non-empty text block
const lines = markdown.split("\n");
const paragraphLines = [];
let started = false;
for (const line of lines) {
const trimmed = line.trim();
if (!started) {
// Skip headings, blank lines, badge images, HTML
if (trimmed === "" ||
trimmed.startsWith("#") ||
trimmed.startsWith("![") ||
trimmed.startsWith("<") ||
trimmed.startsWith("[![")) {
continue;
}
started = true;
}
if (started) {
if (trimmed === "" || trimmed.startsWith("#"))
break;
paragraphLines.push(trimmed);
}
}
return paragraphLines.join(" ").slice(0, 500);
}
// ---------------------------------------------------------------------------
// Match nodes against ComfyUI object_info
// ---------------------------------------------------------------------------
function buildNodeInfo(classNameToDisplay, objectInfo) {
const nodes = [];
for (const [displayName, className] of Object.entries(classNameToDisplay)) {
// object_info is keyed by the internal class name
const nodeDef = objectInfo[className];
const inputs = [];
const outputs = [];
if (nodeDef) {
// Required inputs
if (nodeDef.input.required) {
for (const [name, spec] of Object.entries(nodeDef.input.required)) {
const type = Array.isArray(spec[0]) ? spec[0].join(" | ") : spec[0];
const defaultVal = spec[1]?.default;
inputs.push({ name, type, required: true, default: defaultVal });
}
}
// Optional inputs
if (nodeDef.input.optional) {
for (const [name, spec] of Object.entries(nodeDef.input.optional)) {
const type = Array.isArray(spec[0]) ? spec[0].join(" | ") : spec[0];
const defaultVal = spec[1]?.default;
inputs.push({ name, type, required: false, default: defaultVal });
}
}
// Outputs
for (let i = 0; i < nodeDef.output.length; i++) {
outputs.push({
name: nodeDef.output_name[i] || nodeDef.output[i],
type: nodeDef.output[i],
});
}
}
nodes.push({
className,
displayName: nodeDef?.display_name || displayName,
category: nodeDef?.category || "unknown",
inputs,
outputs,
});
}
// Sort by category then display name for consistent output
nodes.sort((a, b) => a.category === b.category
? a.displayName.localeCompare(b.displayName)
: a.category.localeCompare(b.category));
return nodes;
}
// ---------------------------------------------------------------------------
// Markdown renderer
// ---------------------------------------------------------------------------
export function renderSkillMarkdown(data) {
const tags = new Set(["comfyui"]);
for (const node of data.nodes) {
if (node.category && node.category !== "unknown") {
// Take the top-level category
const top = node.category.split("/")[0].trim().toLowerCase();
if (top)
tags.add(top);
}
}
const lines = [];
// Frontmatter
lines.push("---");
lines.push(`name: ${data.name}`);
lines.push(`version: ${data.version}`);
lines.push(`description: ${data.description}`);
lines.push(`tags: [${[...tags].join(", ")}]`);
lines.push("---");
lines.push("");
// Title
lines.push(`# ${data.name}`);
lines.push("");
if (data.description) {
lines.push(data.description);
lines.push("");
}
if (data.repository) {
lines.push(`Repository: ${data.repository}`);
lines.push("");
}
// Nodes
lines.push("## Nodes");
lines.push("");
for (const node of data.nodes) {
lines.push(`### ${node.displayName}`);
lines.push(`**Class**: \`${node.className}\``);
lines.push(`**Category**: ${node.category}`);
lines.push("");
if (node.inputs.length > 0) {
lines.push("**Inputs**:");
lines.push("| Name | Type | Required | Default |");
lines.push("|------|------|----------|---------|");
for (const inp of node.inputs) {
const def = inp.default !== undefined ? String(inp.default) : "";
lines.push(`| ${inp.name} | ${inp.type} | ${inp.required ? "Yes" : "No"} | ${def} |`);
}
lines.push("");
}
if (node.outputs.length > 0) {
lines.push("**Outputs**:");
lines.push("| Name | Type |");
lines.push("|------|------|");
for (const out of node.outputs) {
lines.push(`| ${out.name} | ${out.type} |`);
}
lines.push("");
}
}
// Examples
if (data.examples.length > 0) {
lines.push("## Usage Examples");
lines.push("");
for (const ex of data.examples) {
lines.push(`### ${ex.name}`);
lines.push(ex.description);
lines.push("");
if (ex.workflow && typeof ex.workflow === "object") {
const wf = ex.workflow;
const nodeTypes = [
...new Set(Object.values(wf)
.filter((v) => v.class_type)
.map((v) => v.class_type)),
];
if (nodeTypes.length > 0) {
lines.push(`Key nodes used: ${nodeTypes.join(", ")}`);
lines.push("");
}
}
}
}
// Composition Patterns
lines.push("## Composition Patterns");
lines.push("");
lines.push("- To use with LoRA: Insert LoraLoader between CheckpointLoader and the first node that uses MODEL/CLIP");
lines.push("- To chain with ControlNet: Add ControlNetLoader and ControlNetApply before the sampler");
lines.push("");
// #1155 — cited vs uncited. A generated skill that omits this leaves the
// reader unable to tell README-derived node I/O from the LoRA/ControlNet
// boilerplate below, which is empirically tuned and not vendor documentation.
const official = data.repository
? `repository README and node definitions at ${data.repository}`
: "none found.";
const empirical = "composition patterns (LoRA / ControlNet boilerplate) are empirically tuned, not vendor documentation. " +
"Node I/O types come from `/object_info` when a ComfyUI server was reachable, otherwise from the pack's Python `NODE_CLASS_MAPPINGS`.";
lines.push(renderSkillSourcesSection({ official, empirical }));
return lines.join("\n");
}
/**
* #1155 — the citation block every skill ends with.
*
* The distinction is *cited* vs *uncited*, not right vs wrong: a skill that
* says "this is the vendor README" and one that says "this was inferred from
* a working graph" are both useful; one that says nothing is not.
*/
export const SKILL_SOURCES_HEADING = "## Sources";
export function renderSkillSourcesSection(opts) {
const official = opts.official.trim() || "none found.";
const empirical = opts.empirical.trim() || "none stated.";
return [
SKILL_SOURCES_HEADING,
"",
`- **Official:** ${official}`,
`- **Empirical:** ${empirical}`,
].join("\n");
}
// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------
export async function generateSkill(source) {
logger.info("Generating skill", { source });
// Step 1: Resolve repository URL
let repoUrl;
if (source.includes("github.com")) {
repoUrl = source;
}
else {
// Assume it's a registry ID
logger.info("Looking up registry ID", { registryId: source });
repoUrl = await resolveRegistryRepo(source);
}
const { owner, repo } = parseGitHubUrl(repoUrl);
logger.info("Resolved repository", { owner, repo });
// Step 2: Fetch README for description
const readme = await fetchReadme(owner, repo);
const description = readme
? extractFirstParagraph(readme)
: "ComfyUI custom node pack";
// Step 3: Find NODE_CLASS_MAPPINGS in Python files
const classNameToDisplay = await findNodeMappings(owner, repo);
const nodeCount = Object.keys(classNameToDisplay).length;
logger.info("Found node class mappings", { count: nodeCount });
if (nodeCount === 0) {
logger.warn("No NODE_CLASS_MAPPINGS found in repository");
}
// Step 4: Match against ComfyUI object_info (best-effort)
let objectInfo = {};
try {
objectInfo = await getObjectInfo();
}
catch (err) {
logger.warn("Could not fetch object_info from ComfyUI (server may be offline)", {
error: err instanceof Error ? err.message : String(err),
});
}
const nodes = buildNodeInfo(classNameToDisplay, objectInfo);
// Step 5: Find example workflows
const examples = await findExampleWorkflows(owner, repo);
logger.info("Found example workflows", { count: examples.length });
// Step 6: Render SKILL.md
const data = {
name: repo,
version: "1.0.0",
description,
repository: repoUrl,
nodes,
examples,
};
return renderSkillMarkdown(data);
}
//# sourceMappingURL=skill-generator.js.map