"""The way from an MCP tool to the open ComfyUI page, for what only the page can do (the canvas lives in the browser). `ask_page` sends {id, action, args} as the "anomalous.mcp" event over ComfyUI's websocket to every open ComfyUI page and waits. One page claims it (the first claim wins, visible pages claim first: web/modules/mcp_bridge.js; while the page that did the last action is open, it gets the next too: it holds the task's tabs, and with ComfyUI open in two browser tabs one must not open a task's tab and the other then not find it), does the action with Anomalous's own canvas code and posts the outcome back; `claim` and `resolve` take those posts (routes in mcp_server.py). With no page open, or none answering in time, the tool fails with words the AI can pass on. Actions on the canvas carry the task's own tab (ai_task_canvas.py), and the tab the page made or opened for it is kept. """ import threading import time import uuid TIMEOUT_SECONDS = 30 MAX_WAITING = 20 HOLD_SECONDS = 3 # how long the page that did the last action has to claim the next before another page may _lock = threading.Lock() _waiting = {} # id -> {"event": Event, "claimed": bool, "outcome": dict | None} _last = {"page": None} # the page that did the last action (its own id, web/modules/mcp_bridge.js pageId) class PageUnavailable(Exception): """No ComfyUI page took or finished the action.""" class PageRefused(Exception): """The page tried and said why it could not (missing node, wrong model type…).""" def _server(): from server import PromptServer # ComfyUI's; imported late so tests can run without it return PromptServer.instance def ask_page(action, args=None, timeout=TIMEOUT_SECONDS, required_capability=None): """Runs `action` in the open ComfyUI page; its result value. Call from a worker thread.""" server = _server() if not getattr(server, "sockets", None): raise PageUnavailable("ComfyUI is not open in a browser on this computer. Open it, then try again.") from . import ai_task_canvas task_canvas = ai_task_canvas.spec(action) if task_canvas: args = {**(args or {}), "_task_canvas": task_canvas} request_id = uuid.uuid4().hex entry = {"event": threading.Event(), "claimed": False, "outcome": None, "required": required_capability, "sent": time.monotonic()} with _lock: if len(_waiting) >= MAX_WAITING: raise PageUnavailable("Too many canvas actions are waiting; try again in a moment.") _waiting[request_id] = entry try: server.send_sync("anomalous.mcp", {"id": request_id, "action": action, "args": args or {}}) if not entry["event"].wait(timeout): raise PageUnavailable("The ComfyUI page did not answer in time. Is it open and not frozen? " "If the action was taken late, check the canvas before repeating it.") finally: with _lock: _waiting.pop(request_id, None) outcome = entry["outcome"] or {} if task_canvas and outcome.get("tab"): ai_task_canvas.remember(outcome["tab"]) if not outcome.get("ok"): raise PageRefused(str(outcome.get("error") or "The page could not do that.")) return outcome.get("value") def claim(request_id, capabilities=None, page=None): """True for the page that may do request `request_id`: the first that asks, but for HOLD_SECONDS only the page that did the last action (`page`, its id), when it is another: "later" tells another page to ask again then (it is the one left when that page was closed).""" with _lock: entry = _waiting.get(request_id) if entry is None or entry["claimed"] or entry["outcome"] is not None: return False if entry.get("required") and (not isinstance(capabilities, list) or entry["required"] not in capabilities): entry["outcome"] = {"ok": False, "error": "Refresh the ComfyUI browser page before running from AI. Nothing was queued."} entry["event"].set() return False if page and _last["page"] and page != _last["page"] and time.monotonic() - entry.get("sent", 0) < HOLD_SECONDS: return "later" entry["claimed"] = True if page: _last["page"] = page return True def resolve(request_id, outcome): """The claiming page's outcome: {ok, value} or {ok: false, error}. False when nobody waits.""" with _lock: entry = _waiting.get(request_id) if entry is None or not entry["claimed"] or entry["outcome"] is not None: return False entry["outcome"] = outcome if isinstance(outcome, dict) else {"ok": False, "error": "Malformed answer"} entry["event"].set() return True