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),
180 lines • 8.4 kB
JavaScript
// Read and write the user's Claude Code MCP-server config (~/.claude.json), so
// the panel orchestrator's background agent uses the SAME MCP servers the user's
// normal `claude` session does — and can ADD new ones programmatically (e.g.
// connect the CivitAI MCP on demand), persisting them to the user's real config.
//
// MCP servers live at the top level of ~/.claude.json under `mcpServers`, keyed
// by name; each value is a Claude Agent SDK server config (stdio | sse | http).
// We deliberately EXCLUDE any entry that looks like a comfyui-mcp instance — the
// orchestrator injects its own bridge-safe comfyui server, and a second one
// would fight for the panel bridge port.
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { logger } from "../utils/logger.js";
/** Path to the user's Claude config. Overridable for tests. */
export function claudeJsonPath() {
return process.env.COMFYUI_MCP_CLAUDE_JSON || join(homedir(), ".claude.json");
}
/**
* Load the user's Claude config, distinguishing ABSENT from UNREADABLE (#796).
*
* `{}` is the right answer for a file that is not there. It is the WRONG answer
* for one that exists and could not be read — and here that difference destroys
* data, because every mutation below is a read-modify-WRITE:
*
* const cfg = readClaudeJson(); // {} after a failed parse — the OLD shape
* cfg.mcpServers = servers;
* writeFileSync(claudeJsonPath(), JSON.stringify(cfg, null, 2));
*
* `~/.claude.json` is the user's entire Claude Code configuration — every MCP
* server, plus whatever secrets other tools have written into `headers`/`env`
* (this file writes them there itself). One hand-edit with a trailing comma, or
* one transient read error, and adding a single MCP server replaced all of it.
*
* It is also NOT OUR FILE, which decides the remedy: unlike our own configs we do
* not move it aside, because another program expects it at that exact path.
* Refusing to write is the only safe move.
*/
function loadClaudeJson() {
const p = claudeJsonPath();
if (!existsSync(p))
return { cfg: {} };
try {
const parsed = JSON.parse(readFileSync(p, "utf-8"));
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return { cfg: parsed };
}
return { cfg: {}, unreadable: "it is valid JSON but not an object" };
}
catch (err) {
logger.warn(`[user-mcp] could not parse ${p}: ${err instanceof Error ? err.message : String(err)}`);
return { cfg: {}, unreadable: err instanceof Error ? err.message : String(err) };
}
}
/** The lenient read, for QUERIES. An unreadable config reads as "no servers",
* which is a wrong answer but not a destructive one — see readUserMcpServers. */
function readClaudeJson() {
return loadClaudeJson().cfg;
}
/**
* The read that precedes a WRITE. Refuses rather than handing back an empty
* object that the caller would then persist over the user's real config.
*/
function readClaudeJsonForMutation() {
const { cfg, unreadable } = loadClaudeJson();
if (unreadable !== undefined) {
throw new Error(`Refusing to modify ${claudeJsonPath()}: it exists but could not be read (${unreadable}). ` +
`Writing would replace your whole Claude configuration — every MCP server, and any ` +
`credentials stored in a server's headers/env — with just this change. Fix that file ` +
`(a trailing comma is the usual cause) and try again; nothing has been changed.`);
}
return cfg;
}
function serversObject(cfg) {
const m = cfg.mcpServers;
return m && typeof m === "object" ? m : {};
}
/**
* Would this server fight our own comfyui server for the bridge port? True for
* anything named "comfyui" or whose config invokes comfyui-mcp.
*/
export function isConflictingServer(name, cfg) {
if (name.toLowerCase() === "comfyui")
return true;
const s = JSON.stringify(cfg ?? "").toLowerCase();
return s.includes("comfyui-mcp");
}
/**
* The user's configured MCP servers (user scope), minus anything that conflicts
* with our injected comfyui server. This is what the panel agent inherits.
*/
export function readUserMcpServers() {
const servers = serversObject(readClaudeJson());
const out = {};
for (const [name, cfg] of Object.entries(servers)) {
if (isConflictingServer(name, cfg))
continue;
out[name] = cfg;
}
return out;
}
/**
* Does an agent running on `backend` actually RECEIVE the servers
* {@link readUserMcpServers} returns? (#2311)
*
* Only the CLAUDE lane does, and that is a property of our own wiring, not a
* guess: `startPanelOrchestrator`'s `buildMcpServers()` spreads
* `readUserMcpServers()` into the set handed to `PanelAgent`, and `PanelAgent`'s
* constructor forwards `deps.mcpServers` to the default `ClaudeBackend` ONLY —
* an injected backend (codex, gemini, grok, antigravity, qwen, …) ignores that
* field entirely and is wired from `makeHttpBackendMcpServers()`, which declares
* exactly two servers: the stdio `comfyui` child and the loopback `panel` HTTP MCP.
*
* So on every non-Claude lane the user's `~/.claude.json` entries are configured
* state, never session state. `panel_list_mcp` used to print them under
* `inherited` on all lanes, which is how a Codex session came to be told it had a
* server whose every call then failed with `unknown MCP server`.
*
* What this deliberately does NOT say: that a non-Claude backend has no other MCP
* servers. Codex reads `~/.codex/config.toml`, Gemini/Qwen read their own configs,
* and those are none of our business — we only know what WE declared. And on the
* Claude lane it says DECLARED, not connected: a declared server can still come up
* `failed` (see mcp-session-health.ts), so no caller may upgrade a `true` here into
* "the tools are there".
*/
export function backendInheritsUserMcpServers(backend) {
return typeof backend === "string" && backend.toLowerCase() === "claude";
}
const NAME_RE = /^[\w.-]+$/;
/**
* Add (or replace) an MCP server in the user's Claude config, persisting it so
* both the panel agent (after a reload) and the user's normal session see it.
* Refuses conflicting names so we can't accidentally clobber the bridge.
*/
export function addUserMcpServer(name, config) {
if (!NAME_RE.test(name)) {
throw new Error(`Invalid MCP server name "${name}" (use letters, digits, dot, dash, underscore).`);
}
if (isConflictingServer(name, config)) {
throw new Error(`Refusing to add "${name}" — it conflicts with the panel's own comfyui server.`);
}
const cfg = readClaudeJsonForMutation();
const servers = serversObject(cfg);
servers[name] = config;
cfg.mcpServers = servers;
writeFileSync(claudeJsonPath(), JSON.stringify(cfg, null, 2));
}
/**
* Write a secret onto an existing MCP server (a header for http/sse, or an env
* var for stdio), persisting to ~/.claude.json. The caller passes the raw secret
* straight from the secure input; it is never logged or returned anywhere.
*/
export function setUserMcpServerSecret(target, value) {
const cfg = readClaudeJsonForMutation();
const servers = serversObject(cfg);
const server = servers[target.server];
if (!server || typeof server !== "object") {
throw new Error(`MCP server "${target.server}" is not configured — add it first with panel_add_mcp.`);
}
const bag = target.kind === "header" ? "headers" : "env";
const s = server;
const existing = s[bag] && typeof s[bag] === "object" ? s[bag] : {};
existing[target.key] = `${target.prefix ?? ""}${value}`;
s[bag] = existing;
servers[target.server] = s;
cfg.mcpServers = servers;
writeFileSync(claudeJsonPath(), JSON.stringify(cfg, null, 2));
}
/** Remove an MCP server from the user's config. Returns false if absent. */
export function removeUserMcpServer(name) {
const cfg = readClaudeJsonForMutation();
const servers = serversObject(cfg);
if (!(name in servers))
return false;
delete servers[name];
cfg.mcpServers = servers;
writeFileSync(claudeJsonPath(), JSON.stringify(cfg, null, 2));
return true;
}
//# sourceMappingURL=user-mcp-config.js.map