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

393 lines • 20.5 kB
// #1524 — read the harness's OWN report of which MCP servers a session came up // with, instead of discarding it. // // The reported failure: mid-session both MCP servers dropped; the `comfyui` half // came back and all 92 `panel_*` tools did not. The orchestrator kept running and // kept its ports, the panel looked healthy, and the agent spent the rest of a // six-hour session unable to touch the user's canvas. Nothing anywhere said so — // the reporter established it by hand, by noticing a tool-list diff and then // probing with `ToolSearch`. // // It did not have to be established by hand. Every Claude session opens with a // `system`/`init` message carrying `mcp_servers: {name, status}[]`, which is the // CLI's authoritative per-session statement of what actually connected. Verified // against the installed harness (agent SDK 0.3.197) with one server of each kind // configured: // // [{"name":"deadstdio","status":"failed"},{"name":"panel","status":"connected"}] // // — so it covers BOTH transports the panel uses: the in-process `createSdkMcpServer` // panel server (Claude lane) and the stdio `comfyui` child. `route()` already reads // that message for the session id and throws the rest away. // // This module is the comparison and the wording. The comparison says what was // observed and nothing more: it does not diagnose WHY a server is missing // (still open for #1524, and the two candidate causes need different fixes). // The wording carries the recovery story the same way: a reconnect attempt is // reported as attempted, its outcome as whatever the re-read status poll said — // never as a promise that a restart or reconnect would help. /** * Compare the MCP servers we handed the session against the ones it reports. * * `configured` is the set of names passed as `Options.mcpServers`. Because the * panel always sets `strictMcpConfig: true`, the session's server set is exactly * ours — no user/project config can appear in the report and no name of ours can * be legitimately replaced. * * Splits the configured servers into the ones the session does not have * (`degraded`) and the ones still connecting (`pending`), in the order they were * configured. Both empty means every server we asked for is up — which is the * case this has to get right first, since a false "your tools are gone" is worse * than the silence it replaces. * * Two deliberate silences: * * - An ABSENT or empty report yields nothing. A harness that does not populate * the field at all (or has not yet) tells us nothing about our servers, and * reading its silence as failure would fire on every healthy session. * - Any status that is not recognizably a failure is treated as fine. The set of * statuses is the CLI's to grow; a new one must not become an alarm here. * * Absence from a NON-EMPTY report does count: the report is populated, the config * is strict, so a name of ours missing from it is a server the session does not * have. That case matters — a server whose connection fails before it is * advertised is missing rather than failed. */ export function inspectMcpServers(configured, reported) { if (!Array.isArray(reported) || reported.length === 0) return { degraded: [], pending: [] }; const byName = new Map(); for (const entry of reported) { if (entry && typeof entry.name === "string") byName.set(entry.name, String(entry.status ?? "")); } const degraded = []; const pending = []; for (const name of configured) { if (!byName.has(name)) { degraded.push({ name, status: null }); continue; } const status = (byName.get(name) ?? "").trim().toLowerCase(); if (status === "pending") pending.push(name); else if (FAILED_STATUSES.has(status)) degraded.push({ name, status: byName.get(name) ?? "" }); } return { degraded, pending }; } /** * Configured servers that CONNECTED and contributed no tools (#2742). * * The #1524 comparison above asks whether a server is present and what its status * is. That is blind to the variant five reporters hit on 0.52.174 through * 0.52.178: `panel=connected` in every session, panel_* absent from both the * direct declarations and the deferred catalog, no notice, no down-episode, no * reconnect. Status `connected` was being read as proof of a usable toolset, and * it is not — a server can connect and register nothing. * * The cost of that blindness is the whole report: "every signal the product * surfaces said the panel was connected", and one reporter reinstalled twice * chasing it. * * The init message carries `tools: string[]`, and an MCP tool appears there * namespaced as `mcp__<server>__<tool>`. So a configured server with no entry * under its own prefix contributed nothing. * * THREE SILENCES, each the same "a false alarm is worse than this silence" rule * the rest of this module follows: * * - No tool list, or an empty one, says nothing about our servers. * - A tool list containing NO `mcp__` name at all is a harness that does not * list MCP tools here, not a session with none — UNLESS this process has * already seen a namespaced MCP tool, which proves the harness does list them. * Reporting every configured server without that proof would fire on healthy * sessions on any harness shaped that way. * * This started as an accepted blind spot, justified by `mcp__comfyui__*` being * present throughout ("headless mcp__comfyui__* tools remained available" in * three of the five reports). Self-review found that justification is evidence * about EARLIER reports, not a property of the failure: a sixth report * (2026-09-03, on 0.52.183) describes zero panel tools without saying whether * comfyui tools survived, so it may sit squarely inside the blind spot — the * fix would not fire on the case it was written for. The latch below closes it * without inventing a false alarm: a harness that never namespaces never * latches, so it is never reported on. * - A server that is degraded or still pending is not reported here. It has no * tools because it is not up; `inspectMcpServers` already says so, and saying * it twice in different words would read as two faults. */ /** * Latched once per process: has ANY session listed a `mcp__*` tool? Until it has, * an absent namespace is an unknown harness, not an empty server. It only ever * moves false -> true, so the detection can widen but never narrow. * * HAZARD IF TOOL SEARCH IS EVER ENABLED. The SDK defers MCP tool schemas behind * tool search by default ("tools are deferred when tool search is enabled", the * `alwaysLoad` docs on every MCP server config type), and a deferred tool is not in * the init message's `tools`. Today nothing in this repo sets `alwaysLoad` or * enables tool search, so every server is treated alike and the list is populated. * * If that changes, the failure direction depends entirely on this latch: * - never latched -> no `mcp__` names -> silence. The check goes inert, which is * the safe direction and is what the first test pins. * - already latched by an earlier non-deferred session in the SAME process -> an * all-deferred session reads as EVERY server contributing zero tools, and the * notice fires on a healthy session. That is the exact false positive this * module exists to avoid. * * So the latch must become per-session, or deferral-aware, BEFORE tool search is * turned on. Being process state is only correct while every session in the process * shares one deferral mode. */ let harnessListsNamespacedMcpTools = false; /** Test seam — the latch is process state, so suites must not leak into each other. */ export function resetMcpToolNamespacingLatchForTests() { harnessListsNamespacedMcpTools = false; } export function serversWithoutTools(configured, reported, tools) { if (!Array.isArray(tools) || tools.length === 0) return []; const namespaced = tools.filter((t) => typeof t === "string" && t.startsWith("mcp__")); // One namespaced tool, ever, is proof this harness lists them here. After that, // an EMPTY namespaced set is a real observation rather than an unknown harness. if (namespaced.length > 0) harnessListsNamespacedMcpTools = true; else if (!harnessListsNamespacedMcpTools) return []; const health = inspectMcpServers(configured, reported); // #2742 — the notice says a server CONNECTED and contributed nothing, so a server // may only appear here if the report SAYS it connected. "Not in notUp" is a // different fact: with `reported` absent or empty nothing is classified at all, // and an unrecognised status classifies as neither -- so a server with no status // evidence used to be announced as connected-but-empty on the strength of ANOTHER // server's tools populating the list. // POSITIVE evidence, read here rather than added to McpSessionHealth: every other // caller of that type asks "what is wrong", and widening it for one caller that // asks "what is proven right" would churn six expectations for no gain. // // An unrecognised status is deliberately NOT an alarm (its own test says so) -- // but it is not proof of health either, and this notice needs the second thing. const isConnected = new Set((Array.isArray(reported) ? reported : []) .filter((e) => e && String(e.status ?? "").trim().toLowerCase() === "connected") .map((e) => e.name)); const empty = []; for (const name of configured) { if (!isConnected.has(name)) continue; const prefix = `mcp__${name}__`; if (!namespaced.some((t) => t.startsWith(prefix))) empty.push(name); } return empty; } /** * The line the user sees for a connected-but-empty server (#2742). * * Deliberately NOT `degradedMcpNotice`'s wording: that one says the session * "started without" the server, which is false here and would send the reader * looking for a connection failure that did not happen. The distinction is the * entire point of the report. * * It also names the recovery that actually worked for the reporters, and the one * that did not: Disconnect → Connect keeps the same orchestrator process, so the * server is not re-registered; stopping the process is what re-runs it. Two * reporters established that by hand. */ export function emptyToolsMcpNotice(names) { if (names.length === 0) return ""; const named = names.map((n) => `\`${n}\``).join(", "); const plural = names.length > 1 ? "servers" : "server"; const their = names.length > 1 ? "their" : "its"; return (`This session's MCP ${plural} ${named} reported \`connected\` but contributed ZERO tools, ` + `so ${their} tools are not available to the agent even though every status signal says the ` + `${plural} ${names.length > 1 ? "are" : "is"} healthy. Anything that needs them will fail or ` + `be worked around silently. This is not a connection failure and reconnecting the panel tab ` + `does not clear it: Disconnect → Connect keeps the same orchestrator process, which is where ` + `the registration lives. Stopping and restarting the orchestrator process re-runs it. ` + `Why the registration comes up empty is open on #2742; this reports the observation only.`); } /** * Translate a Codex `mcpServerStatus/list` page into the report * `inspectMcpServers` already understands. * * Returns `undefined` when the listing is absent or empty — same silence as * an unpopulated Claude init report: a harness that did not answer tells us * nothing, and reading that as failure would fire on every healthy session. * * `runtimeStatus` is the connection signal. Cached `tools` never override it: * `{ name: "panel", runtimeStatus: "failed", tools: { panel_set_todo: … } }` * is a drop, which is the reporter's ALL_TOOLS gap after panel_restart_comfyui. * Empty tools is only a fallback when `runtimeStatus` is omitted (older * app-servers, or a poll that forgot `threadId`). */ export function reportedFromCodexMcpListing(listed) { if (!Array.isArray(listed) || listed.length === 0) return undefined; const out = []; for (const entry of listed) { if (!entry || typeof entry.name !== "string" || !entry.name) continue; out.push({ name: entry.name, status: statusOfCodexListing(entry) }); } return out.length > 0 ? out : undefined; } /** * Codex `McpServerConnectionStatus`: notStarted | starting | connected | * authenticationRequired | failed | cancelled | disabled. */ function statusOfCodexListing(entry) { const runtime = typeof entry.runtimeStatus === "string" ? entry.runtimeStatus.trim().toLowerCase() : ""; if (runtime) { if (runtime === "connected") return "connected"; if (runtime === "starting") return "pending"; if (runtime === "authenticationrequired") return "needs-auth"; if (runtime === "disabled") return "disabled"; // notStarted / failed / cancelled — drop shapes a reload can speak to. if (runtime === "failed" || runtime === "cancelled" || runtime === "notstarted" || runtime === "error") { return "failed"; } // Unrecognized runtimeStatus: closed list, same as inspectMcpServers — // a new value must not become an alarm. return "connected"; } const auth = typeof entry.authStatus === "string" ? entry.authStatus.trim().toLowerCase() : ""; if (auth === "notloggedin") return "needs-auth"; // Older listing with no runtimeStatus: empty inventory is a drop, a // populated (possibly cached) map is not proof of connected either, but // without a runtime field we cannot tell a healthy row from a stale cache // — silence, matching "omit threadId → runtimeStatus is null". if (toolsInventoryEmpty(entry.tools)) return "failed"; return "connected"; } /** Codex serializes `tools` as a name→schema map; older stubs used an array. */ function toolsInventoryEmpty(tools) { if (Array.isArray(tools)) return tools.length === 0; if (tools && typeof tools === "object") return Object.keys(tools).length === 0; return false; } /** * Statuses that mean the server is not usable. Taken from the SDK's own * `McpServerStatus` union (`connected | failed | needs-auth | pending | * disabled`), plus `error` for a host that words it that way. * * A closed list, deliberately: anything the CLI adds later reads as connected * rather than as an alarm, so a vocabulary change cannot start telling healthy * sessions their tools are gone. */ const FAILED_STATUSES = new Set(["failed", "needs-auth", "disabled", "error"]); /** * Whether an automatic reconnect is worth attempting for a degraded status. * * `failed` / `error` / absent-from-the-report are DROP shapes — re-running the * connection can bring the server back. `needs-auth` waits on the user * completing an auth flow and `disabled` is a deliberate off switch: an * automatic retry of the first claims effort that cannot land, and of the * second overrides intent — so neither is retried, only reported. */ export function reconnectableMcpStatus(status) { if (status === null) return true; const s = status.trim().toLowerCase(); return s === "failed" || s === "error"; } /** * The line the user sees when a session starts without a server it was given. * * Scoped the way `NO_PANEL_TOOLS_OVERRIDE` is scoped: it states the observation * and its one consequence, and then stops. It does not say why (unknown), does * not say a restart fixes it (a cause that persists survives a restart), and does * not vouch for the servers that DID come up — the reader can see those in the * same sentence. */ export function degradedMcpNotice(degraded) { if (degraded.length === 0) return ""; const named = degraded .map((d) => (d.status === null ? `\`${d.name}\` (not reported)` : `\`${d.name}\` (${d.status})`)) .join(", "); const plural = degraded.length > 1 ? "servers" : "server"; const theirTools = degraded.length > 1 ? "Their tools are" : "Its tools are"; return (`This session started without ${degraded.length} of its MCP ${plural}: ${named}. ` + `${theirTools} not available to the agent until the server connects, ` + `so anything that needs them will fail or be worked around silently. ` + `A reconnect is attempted at the next turn boundary for any of them that can be retried, ` + `and the outcome is reported; starting a new session (Disconnect → Connect) ` + `re-runs the connection from scratch. ` + `Whether either succeeds depends on why this one did not, which this message does not know.`); } /** Format a degraded-server list the way every notice in this module does. */ function namedDegraded(degraded) { return degraded .map((d) => (d.status === null ? `\`${d.name}\` (not reported)` : `\`${d.name}\` (${d.status})`)) .join(", "); } /** * The line the user sees when a server is down and STAYS down past the * recovery this session can offer (#1524). `why` names exactly which situation * the session is in — the one thing the reader must not have to guess: * * - `reconnect-failed`: an automatic reconnect was tried and the re-read still * reports the server down; * - `unverified`: an automatic reconnect was tried but the status re-read that * would judge it could not be read — so the outcome is not claimed either * way ("did not come back" would be a claim nobody checked); * - `not-retriable`: the status (`needs-auth`, `disabled`) is not one an * automatic reconnect can clear — nothing was attempted; * - `unsupported`: the harness exposes no reconnect to ask for — nothing * could be attempted. * * Like `degradedMcpNotice`, it stops at the observation: it does not diagnose * why the server is down and does not promise a new session fixes it. */ export function unrecoveredMcpNotice(degraded, why) { if (degraded.length === 0) return ""; const plural = degraded.length > 1; const reason = why === "reconnect-failed" ? `the automatic reconnect did not bring ${plural ? "them" : "it"} back` : why === "unverified" ? "an automatic reconnect was attempted, but its outcome could not be read back" : why === "not-retriable" ? "an automatic reconnect cannot clear this status, so none was attempted" : "this harness offers no reconnect to ask for"; return (`MCP ${plural ? "servers" : "server"} ${namedDegraded(degraded)} ${plural ? "are" : "is"} ` + `unavailable to the agent — ${reason}. ` + `Anything that needs ${plural ? "their" : "its"} tools will fail or be worked around ` + `silently for the rest of this session. ` + `Starting a new session (Disconnect → Connect) re-runs the connection; ` + `whether it succeeds depends on why the server is down, which this message does not know.`); } /** * The line the user sees when a down server reads CONNECTED again (#1524). * `byReconnect` distinguishes the two honest shapes: the session's own * reconnect attempt landed, or the harness's supervision brought the server * back on its own (the reporter watched `comfyui` do exactly this while * `panel` stayed down). The claim is only ever what a status poll reported — * never that the tools were exercised. */ export function recoveredMcpNotice(names, byReconnect) { if (names.length === 0) return ""; const named = names.map((n) => `\`${n}\``).join(", "); const plural = names.length > 1; const how = byReconnect ? `the automatic reconnect brought ${plural ? "them" : "it"} back` : `${plural ? "they are" : "it is"} connected again`; return (`MCP ${plural ? "servers" : "server"} ${named}: ${how} — ` + `${plural ? "their" : "its"} tools are available to the agent. ` + `Anything the agent reported as unreachable while the server was down may be worth another try.`); } //# sourceMappingURL=mcp-session-health.js.map