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),
446 lines • 23.6 kB
JavaScript
// Per-provider readiness, computed on the machine that actually RUNS the agents
// (this orchestrator = the user's laptop) — NOT on the ComfyUI host.
//
// The panel's ComfyUI-side Python (comfyui-mcp-panel/__init__.py) also probes
// readiness, but it runs wherever ComfyUI runs. In the "remote ComfyUI, local
// agent" model that's the POD, which has no provider CLIs and no logins, so it
// always reports "CLI not installed" even though the agent is happily running on
// the laptop. This module lets the orchestrator report the TRUTH over the bridge,
// which the panel then prefers (see comfyui-mcp-panel: applyReadiness on a
// {type:"backends"} bridge frame).
import { existsSync, readFileSync } from "node:fs";
import { kimiCodeAuthCandidates } from "../services/code-provider-auth.js";
import { homedir } from "node:os";
import { join } from "node:path";
import { readOAuthStatus } from "../services/code-provider-auth.js";
import { resolveAgyBin } from "./antigravity-backend.js";
import { resolvePiLaunch } from "./pi-backend.js";
// pi's credential detection is large enough (pi's whole env map + auth.json /
// models.json / Vertex-ADC parsing) to live in its own module; re-exported below
// so existing importers of `piCredentialPresent` are unaffected.
import { piCredentialPresent } from "./pi-credentials.js";
import { simpleKeyProvider } from "../services/openai-provider-registry.js";
export { piCredentialPresent } from "./pi-credentials.js";
// CLI binary names per provider (Windows resolves .cmd/.exe via PATHEXT, but we
// probe the common variants explicitly to match the panel's Python).
const CLI_NAMES = {
codex: ["codex", "codex.cmd", "codex.exe"],
gemini: ["gemini", "gemini.cmd", "gemini.exe"],
grok: ["grok", "grok.cmd", "grok.exe"],
qwen: ["qwen", "qwen.cmd", "qwen.exe"],
pi: ["pi", "pi.exe"], // presence only; the pi READINESS path uses resolvePiLaunch (#2835)
ollama: ["ollama", "ollama.exe"],
lmstudio: ["lms", "lms.exe"],
llamacpp: ["llama-server", "llama-server.exe"],
};
/** Well-known Ollama install locations probed in addition to PATH (the Windows
* installer adds PATH for NEW shells only — an orchestrator started from an
* older shell would false-flag "not installed"). */
function ollamaInstalled(home) {
if (onPath(CLI_NAMES.ollama))
return true;
if (process.platform === "win32") {
const localAppData = process.env.LOCALAPPDATA || join(home, "AppData", "Local");
return fileExists(localAppData, "Programs", "Ollama", "ollama.exe");
}
return fileExists("/usr/local/bin/ollama") || fileExists("/opt/homebrew/bin/ollama");
}
/** LM Studio install signals: the app drops its `lms` CLI at ~/.lmstudio/bin on
* every platform (best cross-OS marker), plus the per-OS app locations. */
function lmstudioInstalled(home) {
if (lmstudioCliPath(home))
return true;
if (process.platform === "win32") {
const localAppData = process.env.LOCALAPPDATA || join(home, "AppData", "Local");
return fileExists(localAppData, "Programs", "LM Studio", "LM Studio.exe");
}
if (process.platform === "darwin")
return fileExists("/Applications", "LM Studio.app");
return fileExists(home, ".lmstudio");
}
/** Absolute path (or bare name if on PATH) of the `lms` CLI — the supported
* programmatic surface for load/unload/server lifecycle. Used by
* services/lmstudio-lifecycle to shell out even when PATH lacks it (the app
* adds ~/.lmstudio/bin to PATH for NEW shells only). Null when not found. */
export function lmstudioCliPath(home = homedir()) {
if (onPath(CLI_NAMES.lmstudio))
return "lms";
const binName = process.platform === "win32" ? "lms.exe" : "lms";
const inHome = join(home, ".lmstudio", "bin", binName);
if (fileExists(inHome))
return inHome;
return null;
}
/** True if any of `names` resolves on the local PATH. */
function onPath(names) {
const sep = process.platform === "win32" ? ";" : ":";
const dirs = (process.env.PATH || "").split(sep).filter(Boolean);
for (const dir of dirs) {
for (const name of names) {
try {
if (existsSync(join(dir, name)))
return true;
}
catch {
// unreadable PATH entry — skip
}
}
}
return false;
}
/** True if <geminiHome>/.gemini/settings.json selects API-key auth
* (security.auth.selectedType === "gemini-api-key"). This is an
* env-independent "configured for API key" signal (issue #456): the gemini CLI
* writes it on `/auth` → "Gemini API key" and it persists across processes,
* unlike GEMINI_API_KEY which the orchestrator only inherits if set before
* launch. Never throws — a missing/corrupt file just means "no signal". */
function geminiSettingsUsesApiKey(geminiHome) {
try {
const raw = readFileSync(join(geminiHome, ".gemini", "settings.json"), "utf8");
const parsed = JSON.parse(raw);
return parsed?.security?.auth?.selectedType === "gemini-api-key";
}
catch {
return false;
}
}
/** True if <home>/.qwen/settings.json selects an auth type
* (security.auth.selectedType is a non-empty string) — the env-independent
* "configured" signal for Qwen Code, mirroring geminiSettingsUsesApiKey. `/auth`
* writes it and it persists across processes, unlike DASHSCOPE_API_KEY which
* the orchestrator only inherits if set before launch. A stale or keyless
* selection still surfaces via the connect ack's model probe (degraded), same
* as every other provider. Never throws — a missing/corrupt file just means
* "no signal". */
function qwenSettingsConfigured(home) {
try {
const raw = readFileSync(join(home, ".qwen", "settings.json"), "utf8");
const parsed = JSON.parse(raw);
const t = parsed?.security?.auth?.selectedType;
return typeof t === "string" && t.trim().length > 0;
}
catch {
return false;
}
}
function fileExists(...parts) {
try {
return existsSync(join(...parts));
}
catch {
return false;
}
}
function claudeCredentialPresent(home) {
try {
const raw = JSON.parse(readFileSync(join(home, ".claude", ".credentials.json"), "utf8"));
const oauth = raw?.claudeAiOauth;
return [oauth?.accessToken, oauth?.refreshToken].some((value) => typeof value === "string" && value.trim().length > 0);
}
catch {
return false;
}
}
function staticAvailability(readiness, home) {
if (["ollama", "lmstudio", "llamacpp"].includes(readiness.backend))
return false;
if (readiness.backend === "claude")
return claudeCredentialPresent(home);
return readiness.ready;
}
function modelsEndpoint(baseUrl) {
const base = baseUrl.replace(/\/+$/, "");
return /\/v1$/i.test(base) ? `${base}/models` : `${base}/v1/models`;
}
async function probeJsonShape(url, valid, options) {
try {
const response = await options.fetchImpl(url, {
signal: AbortSignal.timeout(options.timeoutMs),
headers: { Accept: "application/json" },
});
if (!response.ok)
return false;
return valid(await response.json());
}
catch {
return false;
}
}
/** Overlay live local-service availability onto the static snapshot. Raw open
* ports do not count: every endpoint must return its provider's JSON shape. */
export async function discoverBackendAvailability(snapshot, options = {}) {
const fetchImpl = options.fetchImpl ?? fetch;
const timeoutMs = options.timeoutMs ?? 1500;
const ollamaBase = (options.ollamaBaseUrl ?? "http://127.0.0.1:11434").replace(/\/+$/, "");
const lmstudioBase = options.lmstudioBaseUrl ?? "http://127.0.0.1:1234/v1";
const llamacppBase = options.llamacppBaseUrl ?? "http://127.0.0.1:8080/v1";
const json = { fetchImpl, timeoutMs };
const checks = new Map([
[
"ollama",
options.ollamaApi === "openai"
? probeJsonShape(modelsEndpoint(ollamaBase), (v) => Array.isArray(v?.data), json)
: probeJsonShape(`${ollamaBase.replace(/\/v1$/i, "")}/api/tags`, (v) => Array.isArray(v?.models), json),
],
[
"lmstudio",
probeJsonShape(modelsEndpoint(lmstudioBase), (v) => Array.isArray(v?.data), json),
],
[
"llamacpp",
probeJsonShape(modelsEndpoint(llamacppBase), (v) => Array.isArray(v?.data), json),
],
]);
const live = new Map();
await Promise.all([...checks].map(async ([backend, check]) => live.set(backend, await check)));
return snapshot.map((entry) => {
if (!live.has(entry.backend))
return entry;
const available = live.get(entry.backend) === true;
return {
...entry,
available,
// A reachable local service is usable even if its binary/app lives outside
// our install probes. Preserve cli=false when stopped so the UI can still
// distinguish "not installed" from "installed but not running".
...(available ? { cli: true, auth: true, ready: true } : {}),
};
});
}
/** The panel's in-panel-OAuth status records — injectable for tests (so a test
* never has to touch the real ~/.comfyui-mcp/panel-secrets.json), defaulting
* to the real store. Never throws — a corrupt/missing store just means "no
* panel sign-ins known", not a readiness crash. */
function panelOAuthRecords(opts) {
if (opts?.oauthStatus)
return opts.oauthStatus;
try {
// Thread the (test-injectable) home through — readOAuthStatus also detects
// native CLI auth files under it, and reading the REAL home from inside a
// test-scoped readiness probe would leak the developer's own logins.
return readOAuthStatus(opts?.home);
}
catch {
return [];
}
}
/** True if `providerId` has an in-panel OAuth status entry that isn't expired.
* A record with no `expires_at` (copilot's device-code tokens, e.g.) is
* treated as "no known expiry" — mirrors the resolvers' own tokenExpiring()
* semantics in code-provider-auth.ts, which likewise never expires a token
* it has no expiry info for. */
function hasValidPanelOAuth(providerId, records, nowMs) {
const rec = records.find((r) => r.provider === providerId);
if (!rec)
return false;
if (typeof rec.expires_at !== "number")
return true;
return rec.expires_at * 1000 > nowMs;
}
/**
* Readiness for one backend, evaluated locally.
*
* - claude: the orchestrator IS the Claude Agent SDK host — no separate CLI. If
* this process is running we can attempt Claude; a genuinely dead/unsigned
* Claude still surfaces via the connect ack's model probe (degraded). So we
* report it usable here rather than false-flagging "CLI not installed".
* - codex/gemini: the CLI must be on PATH AND a login cached on disk.
*/
export function backendReadiness(backend, opts) {
const b = (backend || "").toLowerCase();
const home = opts?.home ?? homedir();
const nowMs = opts?.now ?? Date.now();
if (b === "claude") {
return { backend: "claude", cli: true, auth: true, ready: true };
}
if (b === "codex") {
const cli = onPath(CLI_NAMES.codex);
// The panel's in-panel OAuth for codex writes the SAME ~/.codex/auth.json
// an external `codex login` would (see persistOAuthResult), so the native
// file check below already picks it up in practice — the OAuth-status
// check is kept as an explicit, independent signal (belt-and-suspenders,
// and the only signal at all for a provider with no dedicated file check).
const auth = fileExists(home, ".codex", "auth.json") ||
hasValidPanelOAuth("codex", panelOAuthRecords(opts), nowMs);
return { backend: "codex", cli, auth, ready: cli && auth };
}
if (b === "chatgpt") {
// Direct Codex OAuth — no CLI; ~/.codex/auth.json from `codex login`.
const auth = fileExists(home, ".codex", "auth.json");
return { backend: "chatgpt", cli: true, auth, ready: !!auth };
}
const simpleKeyReg = simpleKeyProvider(b);
if (simpleKeyReg) {
// Simple OpenAI-compatible api-key providers (glm, moonshot, …) — hosted, no
// CLI. Readiness = one of the provider's env keys is set (a bad key still
// surfaces via the connect ack's model probe → degraded). Derived from the
// openai-provider-registry so a new such provider needs no branch here.
// `kimi` is deliberately EXCLUDED (simpleKeyAuth=false): its dual OAuth-or-
// KIMI_API_KEY readiness is kept bespoke just below.
const auth = simpleKeyReg.envKeys.some((k) => !!process.env[k]?.trim());
return { backend: b, cli: true, auth, ready: auth };
}
if (b === "kimi") {
const apiKey = process.env.KIMI_API_KEY?.trim();
// Asks the SAME resolver the auth path uses. This used to re-derive the location
// and looked only in the legacy ~/.kimi, so a user signed in with the current
// kimi-code CLI (~/.kimi-code) was reported {auth:false, ready:false} while their
// credentials resolved fine everywhere else — and the panel told them they were not
// signed in.
const oauth = kimiCodeAuthCandidates(home).some((p) => existsSync(p));
const auth = !!apiKey || oauth;
return { backend: "kimi", cli: true, auth, ready: auth };
}
if (b === "gemini") {
const cli = onPath(CLI_NAMES.gemini);
// API-key auth is a first-class gemini CLI mode and writes NO
// oauth_creds.json (issue #456). Mirror the kimi branch and accept ANY of:
// 1. GEMINI_API_KEY / GOOGLE_API_KEY in the orchestrator's env, or
// 2. Google OAuth cached at <home>/.gemini/oauth_creds.json (or
// GEMINI_CLI_HOME when set), or
// 3. an env-independent "configured for API key" signal —
// <home>/.gemini/settings.json with
// security.auth.selectedType === "gemini-api-key". This lets a user
// who authed with a key before ComfyUI launched (so the key isn't in
// this process's env yet) still read as ready rather than "not signed
// in"; a genuinely-bad/absent key surfaces via the connect ack's model
// probe (degraded), same as every other key provider.
const geminiHome = process.env.GEMINI_CLI_HOME || home;
const apiKey = !!(process.env.GEMINI_API_KEY?.trim() || process.env.GOOGLE_API_KEY?.trim());
const oauth = fileExists(geminiHome, ".gemini", "oauth_creds.json");
const apiKeyConfigured = geminiSettingsUsesApiKey(geminiHome);
const auth = apiKey || oauth || apiKeyConfigured;
return { backend: "gemini", cli, auth, ready: cli && auth };
}
if (b === "antigravity") {
// Antigravity CLI (`agy`, issue #262) — Google subscription (AI Pro/Ultra).
// Auth lives in the system keyring, which we deliberately do NOT read
// (per #262: no token extraction) — so auth is UNKNOWN (null, don't nag)
// when the CLI is present; the real auth signal is the connect ack's
// `agy models` probe, which degrades with sign-in guidance when it fails.
// resolveAgyBin covers COMFYUI_MCP_ANTIGRAVITY_PATH, PATH, and the official
// installers' well-known locations (%LOCALAPPDATA%\agy\bin, ~/.local/bin).
const cli = !!resolveAgyBin(home);
return { backend: "antigravity", cli, auth: cli ? null : false, ready: cli };
}
if (b === "pi") {
// pi.dev CLI (`pi`, issue #491) — a multi-provider coding agent. Auth lives
// in ~/.pi/agent/auth.json (API keys + `/login` subscriptions) or provider
// env vars. UNLIKE agy, `pi --list-models` is NOT an auth probe (it prints
// the built-in catalog with no key), so readiness must NOT key off the CLI
// alone — that would greet a keyless pi green-ready and then fail its first
// turn (issue #491 codex P1a). We report ready ONLY when a WELL-FORMED,
// PRESENT credential is verifiable — see pi-credentials.ts, which mirrors
// pi's own resolution (its full provider env map minus the keys our spawn
// env strips, a parsed auth.json record that actually carries a key/token, a
// JSONC models.json provider with its own apiKey/oauth, or Google Vertex ADC
// with an existing credentials file plus project+location). When the CLI is
// present but no credential is verifiable, auth is UNKNOWN (null) and ready
// is FALSE — the panel then shows "configure a provider", not green.
// Residual (accepted, and the UI wording must not over-promise): a present
// but revoked/quota-exhausted key is indistinguishable from a good one here
// and still fails on the first turn.
// #2835 — the SAME resolver prepare() launches with. Readiness used to call
// resolvePiBin, whose candidate list is ["pi", "pi.exe"] with .cmd excluded on
// the premise that Node cannot spawn one. That premise no longer holds: an npm
// .cmd resolves to the script it runs. Keeping the two apart reported a
// .cmd-only install as ABSENT though it runs, and an unresolvable extensionless
// shim as PRESENT though it spawns ENOENT -- which is #2835's own symptom.
const cli = !!resolvePiLaunch(home);
const authKnown = piCredentialPresent(home);
const auth = authKnown ? true : cli ? null : false;
return { backend: "pi", cli, auth, ready: cli && authKnown };
}
if (b === "grok") {
const cli = onPath(CLI_NAMES.grok);
// Same reasoning as codex above: the panel's in-panel OAuth writes the
// same ~/.grok/auth.json an external `grok` login would.
const auth = fileExists(home, ".grok", "auth.json") ||
hasValidPanelOAuth("grok", panelOAuthRecords(opts), nowMs);
return { backend: "grok", cli, auth, ready: cli && auth };
}
if (b === "qwen") {
// Qwen Code CLI (`qwen --acp`, issue #1417). `.cmd` IS probed — unlike pi,
// we spawn through the shell on Windows (see qwen-backend resolveSpawn), so
// a `qwen.cmd` shim is genuinely runnable. Auth (the CLI owns it) reads as
// present when ANY of: a Qwen-side key is in the orchestrator's env
// (DASHSCOPE_API_KEY / BAILIAN_CODING_PLAN_API_KEY / OPENAI_API_KEY), cached
// OAuth creds exist at <home>/.qwen/oauth_creds.json (the free tier ended
// 2026-04-15, but a Coding Plan key configured via /auth lands in
// settings.json — covered by the next signal), or ~/.qwen/settings.json
// selects an auth type (the env-independent "configured" signal, mirroring
// the gemini branch's settings check — this also covers the generic
// OPENAI_API_KEY + selectedType:"openai" setup, which a bare env probe
// can't distinguish from an unrelated OPENAI_API_KEY the user set for
// something else). A stale/bad credential still surfaces via the connect
// ack's model probe → degraded, same as every other provider.
const cli = onPath(CLI_NAMES.qwen);
const envKey = !!(process.env.DASHSCOPE_API_KEY?.trim() ||
process.env.BAILIAN_CODING_PLAN_API_KEY?.trim());
const auth = envKey ||
fileExists(home, ".qwen", "oauth_creds.json") ||
qwenSettingsConfigured(home);
return { backend: "qwen", cli, auth, ready: cli && auth };
}
if (b === "copilot") {
// No external CLI/native-file concept for Copilot — the in-panel device-
// code OAuth (experimental, gated behind allow_experimental in
// oauth-bridge.ts) is the ONLY sign-in path, so it's the only signal here.
// `experimental: true` lets the panel (Task 7) label/gate this row even
// once signed in — this backend is opt-in ToS-risk territory, never a
// default or auto-pick.
const auth = hasValidPanelOAuth("copilot", panelOAuthRecords(opts), nowMs);
return { backend: "copilot", cli: true, auth, ready: auth, experimental: true };
}
if (b === "ollama") {
// No login concept — a local daemon. Binary presence is the readiness
// signal here (mirrors claude's posture); a stopped daemon still surfaces
// via the connect ack's model probe (GET /api/tags fails → degraded ack).
const cli = ollamaInstalled(home);
return { backend: "ollama", cli, auth: cli ? true : null, ready: cli };
}
if (b === "lmstudio") {
// Same posture as ollama: a local server, no login concept. App/CLI
// presence is the readiness signal; a stopped server still surfaces via
// the connect ack's model probe (GET /v1/models fails → degraded ack).
const cli = lmstudioInstalled(home);
return { backend: "lmstudio", cli, auth: cli ? true : null, ready: cli };
}
if (b === "llamacpp") {
// llama.cpp's llama-server: PATH presence is the install signal (no app
// dirs — people build it or unzip a release anywhere). A stopped server
// surfaces via the connect ack's probe; when the binary isn't findable we
// still allow a REACHABLE server to count (checked at connect), so `cli`
// false + a live endpoint is fine — mirrors the "installed elsewhere"
// reality of this tool.
const cli = onPath(CLI_NAMES.llamacpp);
return { backend: "llamacpp", cli, auth: cli ? true : null, ready: cli };
}
if (b === "custom") {
// A user-defined OpenAI-compatible endpoint (issue #162) — nothing to
// install, no login flow. Readiness = a base URL is configured (panel
// Settings or COMFYUI_MCP_CUSTOM_BASE_URL; the caller passes the resolved
// truth via opts). A wrong URL or key still surfaces via the connect ack's
// model probe (degraded), same as every other endpoint provider.
const configured = opts?.customEndpointConfigured ?? !!process.env.COMFYUI_MCP_CUSTOM_BASE_URL;
return { backend: "custom", cli: configured, auth: configured ? true : null, ready: configured };
}
if (b === "openrouter") {
// Hosted — no CLI. Readiness = an OpenRouter API key in the orchestrator's
// env (OPENROUTER_API_KEY, or the shared COMFYUI_MCP_OLLAMA_API_KEY). A bad
// key still surfaces via the connect ack's model probe (degraded).
const key = !!(process.env.OPENROUTER_API_KEY || process.env.COMFYUI_MCP_OLLAMA_API_KEY);
return { backend: "openrouter", cli: key, auth: key ? true : false, ready: key };
}
return { backend: b, cli: false, auth: false, ready: false };
}
/** Readiness for every known backend, plus a rolled-up any_ready. */
export function allBackendReadiness(backends, opts) {
const home = opts?.home ?? homedir();
const list = [...backends].map((b) => {
const readiness = backendReadiness(b, opts);
return { ...readiness, available: staticAvailability(readiness, home) };
});
return { backends: list, any_ready: list.some((r) => r.ready) };
}
//# sourceMappingURL=backend-readiness.js.map