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

520 lines • 21.2 kB
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