feat(mcp): AI apps can act on the open workflow, scan and speak

The second batch of MCP tools: they change things, through the same code the UI uses.

- Canvas, done by the open ComfyUI page: describe_canvas, set_prompt, set_model,
  add_lora, place_combo, check_workflow_models, fix_workflow_models, run_workflow,
  open_in_anomalous. api/mcp_bridge.py sends the action over ComfyUI's websocket and
  waits; web/modules/mcp_bridge.js claims it (visible tab first), does it with
  Anomalous's canvas code and answers. Each change is one Ctrl+Z step, shows a toast and
  is logged as the AI's (activity entries carry via: "mcp", with their undo).
- This server: scan_models (new / look up again / picked models, via /anomalous/scan_all),
  list_voices and speak (Anomalous TTS through /prompt and /history).
- graph_splice.js takes the chain's slot types, so a LoRA can go into a model-only (UNet,
  Flux) chain as LoraLoaderModelOnly; the default (MODEL and CLIP) is unchanged.
- Nothing deletes or renames files; acting tools are not marked read-only, so AI apps
  ask before calling them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-05 22:13:28 +08:00
co-authored by Claude Opus 5.5
parent 358c2bc4d3
commit 80f41f98e9
15 changed files with 770 additions and 34 deletions
+8 -3
View File
@@ -103,9 +103,14 @@ covered by the `styles.css` manifest.
- `api/mcp_server.py` serves `/anomalous/mcp`, the Model Context Protocol endpoint AI apps
connect to (Streamable HTTP; the 2026-07-28 stateless protocol and the earlier
`initialize`-based ones; this computer only, Host and Origin checked against DNS
rebinding). `api/mcp_tools.py` holds its tools: read-only, summarised views over the same
stores the pages use (models, scans, output images, combos, recipes, saved prompts,
generated audio, activity log). User guide: `docs/guides/mcp.md`.
rebinding). `api/mcp_tools.py` holds the reading tools (summarised views over the same
stores the pages use: models, scans, output images, combos, recipes, saved prompts,
generated audio, activity log) and `call_tool`; `api/mcp_actions.py` the acting ones:
canvas actions done by the open page, scans and Anomalous TTS speech through this
server's own routes. `api/mcp_bridge.py` hands a canvas action to the page over
ComfyUI's websocket and waits for its answer; `web/modules/mcp_bridge.js` is the page
side, doing it with Anomalous's canvas code (one Ctrl+Z step each, logged as the AI's).
User guide: `docs/guides/mcp.md`.
- `api/workflow_schema.py`, `api/recipe_schema.py`, `api/recipe_images.py`, and
`api/recipe_store.py` own recipe validation/shaping, images, CRUD, history, and
integrity receipts; `api/recipes.py` is the HTTP facade. `api/recipe_constants.py`
+3 -2
View File
@@ -52,9 +52,10 @@
- **Model folders, readable (模型文件夹设置更好懂)**: folder management is a view of the settings page instead of a pop-up. Grouping is two plain choices, **By model type** or **By folders on disk** (it was "Category mode / Physical mode"); the types shown on the Models page come first, each with its usual name (Checkpoint, Diffusion…) and folder name, a switch and ↑↓ to order them; the ones that are off follow, and the many folders ComfyUI registers that few people use (configs, datasets…) fold away. Each change applies at once; there is no Save and reload button any more.
- The output gallery shows 512 px thumbnails instead of full images (cached like model cards), so scrolling through hundreds of outputs no longer fills the browser's memory and graphics memory with full-size pictures. Dragging an image to the canvas and the large viewer still use the original. When the browser has been closed for 90 seconds, its gallery images are released too, like the model grid.
### 🤖 AI apps can read your library (MCP,让 AI 读你的库)
### 🤖 AI apps can use your library (MCP,让 AI 用你的库)
- While ComfyUI runs, AI apps that speak MCP (Claude Code, Claude Desktop, Cursor, Cherry Studio, VS Code…) can connect to `http://127.0.0.1:8188/anomalous/mcp` and ask about your library: models with their Civitai information, trigger words and your notes, which models are unmatched and why, output images and the settings that made them, combos, workflow recipes, saved prompts, generated speech and the activity log. A model's cover or an image's thumbnail can be attached for apps that look at pictures.
- It only reads; nothing is changed. Only this computer can connect, and requests from web pages, other hosts or through a proxy are refused. It costs no memory while nobody is connected. Setup for each app: `docs/guides/mcp.md`.
- It can also act on the workflow open in ComfyUI: rewrite or extend the prompts, swap a node's model, add a LoRA into the model chain (UNet and Flux models get a model-only LoRA loader), place one of your combos, check and put back missing models, and run the workflow; and start scans, or have an Anomalous TTS voice read a line. Every canvas change is one Ctrl+Z step, shows a note on the page and is in the activity log marked "AI". Nothing deletes or renames files; AI apps ask before calling a tool that changes something.
- Only this computer can connect, and requests from web pages, other hosts or through a proxy are refused. It costs no memory while nobody is connected. Setup for each app: `docs/guides/mcp.md`.
### 🕘 Activity log (操作记录)
- **See what Anomalous changed (看 Anomalous 改了什么)**: the new **Activity** page on the rail lists, by day, every change the plugin made: on the canvas, which node settings changed (from → to), which nodes were added or removed, and which workflow was opened; in files, models, covers, recipes, materials, notes, presets, images, audio and imported characters. Filter by canvas or files; open a canvas entry for its details and **Find on canvas** to jump to the node. Your own edits on the canvas are not recorded as the plugin's. The home page shows the latest few.
+2 -2
View File
@@ -52,7 +52,7 @@ Tools on the rail:
* **Each tool on its page**: workflow share codes (import / export) on Workflows, Model Sources (where each model can be downloaded) on Models and in Model Check, translation on the prompt boxes in Current node.
* **Settings** (at the bottom): language, font size, thumbnails, video covers, which model folders to show, and feedback. Home and Settings have **Report a problem**, which opens a GitHub issue with your versions and GPU already filled in.
**AI apps (MCP)**: Claude, Cursor, Cherry Studio and other apps that support MCP can read your library through `http://127.0.0.1:8188/anomalous/mcp`, to answer questions about your models, outputs and saved prompts. Read-only and local; setup in [docs/guides/mcp.md](docs/guides/mcp.md).
**AI apps (MCP)**: Claude, Cursor, Cherry Studio and other apps that support MCP can use your library through `http://127.0.0.1:8188/anomalous/mcp`: answer questions about your models, outputs and saved prompts, and change the open workflow for you (prompts, models, LoRAs, combos, missing models), each change one Ctrl+Z step. Local only; setup in [docs/guides/mcp.md](docs/guides/mcp.md).
**Light on your computer**: the plugin itself loads no models, so it takes no graphics memory from image generation; the browser shows small thumbnails instead of full images, and its pictures are released a minute and a half after you close it. The voice models of Anomalous_TTS give their graphics memory back whenever ComfyUI needs it for image generation, and all of it on **Unload models**.
@@ -114,7 +114,7 @@ The first time you open it, start on **Home**: **Scan model folders** fills in c
* **工具回到各自的页面**:工作流分享码(导入导出)在“工作流”页,模型来源(每个模型去哪下载)在“模型”页和模型检查里,翻译在“当前节点”的提示词框上。
* **设置**(最下面):语言、字号、缩略图、视频封面、显示哪些模型文件夹,以及提交反馈。主页和设置里的 **报告问题** 会打开一个已经填好版本和显卡信息的 GitHub 页面。
**接入 AI 软件(MCP)**:Claude、Cursor、Cherry Studio 等支持 MCP 的 AI 软件,可以通过 `http://127.0.0.1:8188/anomalous/mcp` 读取你的模型、出图和存下的提示词,回答相关问题。只读,只限本机。设置方法见 [docs/guides/mcp.md](docs/guides/mcp.md)。
**接入 AI 软件(MCP)**:Claude、Cursor、Cherry Studio 等支持 MCP 的 AI 软件,可以通过 `http://127.0.0.1:8188/anomalous/mcp` 查你的模型、出图和存下的提示词,回答相关问题;也能帮你改当前工作流(提示词、模型、LoRA、搭配、缺失的模型),每次改动都能用 Ctrl+Z 撤销。只限本机。设置方法见 [docs/guides/mcp.md](docs/guides/mcp.md)。
**不占你的资源**:
- 插件本身不加载任何模型,不和生图抢显存。
+4 -1
View File
@@ -225,7 +225,8 @@ async def api_get_activity(request):
async def api_post_activity(request):
"""Canvas changes from the browser: {changes: [...], total, workflow}."""
"""Canvas changes from the browser: {changes: [...], total, workflow, via?}; via "mcp" marks
changes an AI app made through the MCP endpoint."""
try:
body = await request.json()
except ValueError:
@@ -237,6 +238,8 @@ async def api_post_activity(request):
return web.json_response({'error': 'No recognizable changes'}, status=400)
total = body.get('total') if isinstance(body.get('total'), int) else len(changes)
detail = {'changes': changes, 'total': max(total, len(changes))}
if body.get('via') == 'mcp':
detail['via'] = 'mcp'
entry = await asyncio.get_running_loop().run_in_executor(
None, add_entry, 'canvas', 'canvas', body.get('workflow') if isinstance(body.get('workflow'), str) else '', detail)
return web.json_response({'entry': entry})
+258
View File
@@ -0,0 +1,258 @@
"""The MCP tools that act: on the open canvas, through the page (mcp_bridge.py and
web/modules/mcp_bridge.js, which use Anomalous's own canvas code, so every change is one
Ctrl+Z step and lands in the activity log marked as the AI's), and on this ComfyUI server
through its own routes (scans, Anomalous TTS speech), so they behave exactly as from the UI.
Nothing here deletes or renames files. Tools that overwrite something on the canvas say so
(destructiveHint); AI apps ask the user before calling tools that are not read-only.
"""
import json
import os
import re
import ssl
import time
import urllib.error
import urllib.request
from .mcp_bridge import PageRefused, PageUnavailable, ask_page
from .mcp_tools import MAX_LIMIT, ToolError, _resolve_model, _schema
SPEAK_WAIT_SECONDS = 120
HISTORY_POLL_SECONDS = 1.5
def _page(action, args=None, timeout=30):
try:
return ask_page(action, args, timeout)
except (PageUnavailable, PageRefused) as error:
raise ToolError(str(error))
def _rel(base_dir, file_path):
return os.path.relpath(file_path, base_dir).replace(os.sep, "/")
def _model_ref(model_id):
"""{type, path} of a model, as a node's drop-down names it (relative to its folder)."""
folder_type, _idx, base_dir, file_path = _resolve_model(model_id)
return {"type": folder_type, "path": _rel(base_dir, file_path)}
def _local(ctx, method, path, body=None, timeout=20):
"""(status, JSON) of one request to this ComfyUI server's own routes."""
data = json.dumps(body).encode("utf-8") if body is not None else None
request = urllib.request.Request(ctx["base_url"] + path, data=data, method=method,
headers={"Content-Type": "application/json"})
# Loopback only (mcp_server checked the host); a self-signed certificate is the user's own.
context = ssl._create_unverified_context() if ctx["base_url"].startswith("https:") else None
try:
with urllib.request.urlopen(request, timeout=timeout, context=context) as response:
return response.status, json.loads(response.read() or b"null")
except urllib.error.HTTPError as error:
try:
return error.code, json.loads(error.read() or b"null")
except ValueError:
return error.code, None
except (urllib.error.URLError, OSError) as error:
raise ToolError(f"Could not reach ComfyUI's own server: {error}")
# ---------------------------------------------------------------- canvas (through the page)
def describe_canvas(ctx):
return _page("describe_canvas")
def set_prompt(ctx, text, role="positive", node_id=None, mode="replace"):
if role not in ("positive", "negative") or mode not in ("replace", "append"):
raise ToolError("role is positive or negative; mode is replace or append.")
return _page("set_prompt", {"text": str(text), "role": role, "node_id": node_id, "mode": mode})
def set_model(ctx, node_id, model_id):
return _page("set_model", {"node_id": node_id, "model": _model_ref(model_id)})
def add_lora(ctx, model_id, after_node_id=None, strength_model=1.0, strength_clip=1.0):
ref = _model_ref(model_id)
if ref["type"] != "loras":
raise ToolError("add_lora takes a model from the loras folder.")
return _page("add_lora", {"model": ref, "after_node_id": after_node_id,
"strength_model": float(strength_model), "strength_clip": float(strength_clip)})
def place_combo(ctx, name):
return _page("place_combo", {"name": str(name)})
def check_workflow_models(ctx):
return _page("check_models", timeout=90)
def fix_workflow_models(ctx):
return _page("fix_models", timeout=90)
def run_workflow(ctx, batch=1):
batch = max(1, min(8, int(batch)))
return _page("run_workflow", {"batch": batch})
def open_in_anomalous(ctx, model_id):
folder_type, path_idx, base_dir, file_path = _resolve_model(model_id)
return _page("open_model", {"type": folder_type, "path_idx": path_idx, "rel": _rel(base_dir, file_path)})
# ---------------------------------------------------------------- this server
def scan_models(ctx, what="new", model_ids=None):
body = {"offline_only": False, "skip_rename": False, "virtual_rename": True, "physical_rename": False,
"force_overwrite": False, "retry_unmatched": what == "retry_unmatched"}
if what == "models":
folders = {}
for model_id in model_ids or []:
folder_type, path_idx, base_dir, file_path = _resolve_model(model_id)
subfolder, _, filename = _rel(base_dir, file_path).rpartition("/")
folders.setdefault((folder_type, path_idx, "/" + subfolder), []).append(filename)
if not folders:
raise ToolError("what=models needs model_ids from search_models.")
body["targets"] = [{"type": t, "path_idx": i, "subfolder": s, "files": files} for (t, i, s), files in folders.items()]
elif what not in ("new", "retry_unmatched"):
raise ToolError("what is new, retry_unmatched or models.")
status, data = _local(ctx, "POST", "/anomalous/scan_all", body)
if status == 409:
raise ToolError("A scan is already running; call scan_report later to see it.")
if status != 200 or (data or {}).get("status") != "ok":
raise ToolError(f"The scan did not start: {(data or {}).get('message') or status}")
return {"started": True, "note": "Scans run in the background and only read .safetensors files. "
"Call scan_report in a minute or two for what it found."}
def _tts_characters(ctx):
status, data = _local(ctx, "GET", "/anomalous_tts/characters")
if status == 404:
raise ToolError("Anomalous TTS is not installed, so there are no voices.")
if status != 200 or not isinstance(data, dict):
raise ToolError(f"Could not read the voices ({status}).")
return data.get("characters") or []
def list_voices(ctx):
return {"voices": [{"character": item.get("name"), "language": item.get("language") or "",
"emotions": sorted((item.get("emotions") or {}).keys()),
**({"problem": item.get("error") or item.get("settings_error")}
if item.get("error") or item.get("settings_error") else {})}
for item in _tts_characters(ctx)]}
def _output_prefix(character):
"""Where the audio page saves a character's lines (audio_script.js ttsOutputPrefix)."""
parts = [re.sub(r'[<>:"\\|?*\x00-\x1f]', "_", part).strip() or "_" for part in str(character).split("/")]
parts = ["_" if re.fullmatch(r"\.+", part) else part for part in parts]
return f"audio/{'/'.join(parts)}/{parts[-1]}"
def speak(ctx, character, text, seed=None, language="auto"):
names = {item.get("name") for item in _tts_characters(ctx)}
if character not in names:
raise ToolError(f"No voice called {character!r}; list_voices names them.")
if not str(text).strip():
raise ToolError("text is empty.")
seed = int(seed) if seed is not None else int(time.time() * 1000) % 2**31
prompt = {"1": {"class_type": "AnomalousTTS_CharacterSpeech",
"inputs": {"character": character, "text": str(text), "seed": seed, "language": language}},
"2": {"class_type": "SaveAudio", "inputs": {"audio": ["1", 0], "filename_prefix": _output_prefix(character)}}}
status, data = _local(ctx, "POST", "/prompt", {"prompt": prompt, "client_id": "anomalous-mcp"})
prompt_id = (data or {}).get("prompt_id")
if status != 200 or not prompt_id:
errors = (data or {}).get("node_errors") or (data or {}).get("error") or status
raise ToolError(f"ComfyUI refused the speech job: {json.dumps(errors, ensure_ascii=False)[:500]}")
deadline = time.monotonic() + SPEAK_WAIT_SECONDS
while time.monotonic() < deadline:
time.sleep(HISTORY_POLL_SECONDS)
_status, history = _local(ctx, "GET", f"/history/{prompt_id}")
record = (history or {}).get(prompt_id) or {}
state = record.get("status") or {}
if state.get("status_str") == "error":
messages = [m[1].get("exception_message") for m in state.get("messages") or [] if m and m[0] == "execution_error"]
raise ToolError(f"Speech failed: {messages[0] if messages else 'see the ComfyUI console'}")
if state.get("completed"):
files = [f"{item.get('subfolder')}/{item.get('filename')}".replace("\\", "/").strip("/")
for output in (record.get("outputs") or {}).values() for item in output.get("audio") or []]
return {"done": True, "seed": seed, "files": files,
"note": "Saved under ComfyUI's output folder; it shows in Anomalous's audio gallery."}
return {"done": False, "prompt_id": prompt_id, "seed": seed,
"note": "Still generating (ComfyUI may be busy with other jobs); search_audio will list it when done."}
# ---------------------------------------------------------------- registry
_NODE = {"type": "integer", "description": "A node id from describe_canvas."}
_MODEL = {"type": "string", "description": "model_id from search_models."}
def _action(name, title, description, run, properties=None, required=(), destructive=False, open_world=False,
read_only=False):
return name, {"run": run, "context": True, "spec": {
"name": name, "title": title, "description": description,
"inputSchema": _schema(properties, required),
"annotations": {"title": title, "readOnlyHint": read_only, "destructiveHint": destructive,
"idempotentHint": read_only, "openWorldHint": open_world},
}}
ACTIONS = dict([
_action("describe_canvas", "Read the canvas",
"The workflow open in ComfyUI now: each node's id, type, title, settings, which prompt box is "
"positive or negative, and where its inputs come from. Call it before changing the canvas.",
describe_canvas, read_only=True),
_action("set_prompt", "Write a prompt",
"Writes text into the workflow's positive or negative prompt box (or the one on node_id). "
"mode=append adds to what is there. One Ctrl+Z step.",
set_prompt, {"text": {"type": "string"}, "role": {"type": "string", "enum": ["positive", "negative"], "default": "positive"},
"node_id": _NODE, "mode": {"type": "string", "enum": ["replace", "append"], "default": "replace"}},
["text"], destructive=True),
_action("set_model", "Swap a node's model",
"Puts a model into a loader node (checkpoint, LoRA, VAE…) on the canvas, replacing the one it had.",
set_model, {"node_id": _NODE, "model_id": _MODEL}, ["node_id", "model_id"], destructive=True),
_action("add_lora", "Add a LoRA",
"Inserts a LoRA loader into the model chain after node after_node_id (default: the workflow's main "
"model loader), wired in, with the given strengths.",
add_lora, {"model_id": _MODEL, "after_node_id": _NODE,
"strength_model": {"type": "number", "default": 1.0}, "strength_clip": {"type": "number", "default": 1.0}},
["model_id"]),
_action("place_combo", "Place a combo",
"Adds one of the user's combos (list_combos) to the canvas as a new wired group: model loader, LoRAs, "
"positive and negative prompt. Nothing already there changes.",
place_combo, {"name": {"type": "string", "description": "Combo name from list_combos."}}, ["name"]),
_action("check_workflow_models", "Check missing models",
"Which models the open workflow needs that this computer does not have under that name, and which "
"of them Anomalous can put back (same file found by hash, or a likely candidate).",
check_workflow_models, read_only=True),
_action("fix_workflow_models", "Fix missing models",
"Puts back every missing model that Anomalous found with certainty (same file by hash). "
"Likely candidates are left for the user. One Ctrl+Z step.",
fix_workflow_models, destructive=True),
_action("run_workflow", "Run the workflow",
"Queues the open workflow in ComfyUI, like pressing Run. Uses the GPU; results appear in the output folder.",
run_workflow, {"batch": {"type": "integer", "minimum": 1, "maximum": 8, "default": 1}}),
_action("open_in_anomalous", "Show a model",
"Opens Anomalous Model Browser on this model's page, so the user sees its cover and details.",
open_in_anomalous, {"model_id": _MODEL}, ["model_id"]),
_action("scan_models", "Scan models",
"Starts a scan that fetches covers, trigger words and base models from Civitai (by file hash) for "
"models not scanned yet (new), looks unmatched ones up again (retry_unmatched), or scans model_ids.",
scan_models, {"what": {"type": "string", "enum": ["new", "retry_unmatched", "models"], "default": "new"},
"model_ids": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_LIMIT}},
open_world=True),
_action("list_voices", "Voices",
"The Anomalous TTS characters (GPT-SoVITS voices) with their language and emotions.", list_voices,
read_only=True),
_action("speak", "Speak a line",
"Has an Anomalous TTS character read text and saves the audio. Emotion tags like {happy} switch "
"emotion mid-text; waits up to two minutes.",
speak, {"character": {"type": "string", "description": "From list_voices."}, "text": {"type": "string"},
"seed": {"type": "integer"}, "language": {"type": "string", "default": "auto"}},
["character", "text"]),
])
+78
View File
@@ -0,0 +1,78 @@
"""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), 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.
"""
import threading
import uuid
TIMEOUT_SECONDS = 30
MAX_WAITING = 20
_lock = threading.Lock()
_waiting = {} # id -> {"event": Event, "claimed": bool, "outcome": dict | None}
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):
"""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.")
request_id = uuid.uuid4().hex
entry = {"event": threading.Event(), "claimed": False, "outcome": None}
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 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):
"""True for the first page that asks to do request `request_id`."""
with _lock:
entry = _waiting.get(request_id)
if entry is None or entry["claimed"]:
return False
entry["claimed"] = True
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
+35 -9
View File
@@ -7,8 +7,9 @@ POST /anomalous/mcp speaks the Model Context Protocol's Streamable HTTP transpor
(sessions are optional there), so both eras are served the same stateless way.
Only this computer may connect: the peer must be loopback, no proxy header may be present,
and Host and Origin (when sent) must name a loopback host, which stops DNS rebinding.
Answers are always one JSON object; there are no server-initiated messages. The tools
themselves are in mcp_tools.py.
Answers are always one JSON object; there are no server-initiated messages. The tools are
in mcp_tools.py (reading) and mcp_actions.py (acting); POST /anomalous/mcp/bridge is where
the open ComfyUI page claims and answers canvas actions (mcp_bridge.py).
"""
import asyncio
@@ -18,7 +19,11 @@ import urllib.parse
from aiohttp import web
from .mcp_tools import TOOLS, call_tool
from . import mcp_bridge
from .mcp_actions import ACTIONS
from .mcp_tools import TOOLS as READ_TOOLS, call_tool
TOOLS = {**READ_TOOLS, **ACTIONS}
MODERN_VERSIONS = ("2026-07-28",)
LEGACY_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26")
@@ -27,9 +32,12 @@ INSTRUCTIONS = (
"Tools over the user's ComfyUI library as Anomalous Model Browser keeps it: model files with "
"their Civitai information, output images with their generation settings, combos (a model, "
"LoRAs and a prompt), workflow recipes, saved prompts, generated audio, the scan status and "
"the activity log. All tools only read. To change something, tell the user where to do it "
"in Anomalous (the rail on the left: Models, Gallery, Workflows, Combos, Prompts, Voices). "
"Ids returned by one tool (model_id, image) are what the detail tools take."
"the activity log; and tools that act on the workflow open in ComfyUI (read it with "
"describe_canvas first), scan models, or have an Anomalous TTS voice speak. Canvas changes "
"are single Ctrl+Z steps and are logged as the AI's; nothing deletes files. Ids returned by "
"one tool (model_id, image, node ids) are what the others take. For what no tool does, tell "
"the user where in Anomalous to do it (the rail on the left: Models, Gallery, Workflows, "
"Combos, Prompts, Voices)."
)
LIST_TTL_MS = 300000
# Requests run tools in threads; a few at a time is plenty for one person's AI app.
@@ -135,13 +143,14 @@ def _modern_header_problem(request, method, params, version):
return None
async def _call(params):
async def _call(params, request):
name = params.get("name")
arguments = params.get("arguments") or {}
if name not in TOOLS or not isinstance(arguments, dict):
return None
ctx = {"base_url": f"{request.scheme}://{request.host}"} # loopback, checked in api_mcp
async with _slots:
result = await asyncio.to_thread(call_tool, name, arguments)
result = await asyncio.to_thread(call_tool, TOOLS, name, arguments, ctx)
return result
@@ -187,12 +196,29 @@ async def api_mcp(request):
if method == "tools/list":
return _result(id_, _tool_list(modern), modern)
if method == "tools/call":
result = await _call(params)
result = await _call(params, request)
if result is None:
return _error(id_, INVALID_PARAMS, f"Unknown tool or bad arguments: {params.get('name')}")
return _result(id_, result, modern)
return _error(id_, METHOD_NOT_FOUND, f"Method not found: {method}", status=404 if modern else 200)
async def api_mcp_bridge(request):
"""POST /anomalous/mcp/bridge - the open page claims a canvas action ({id, claim: true} ->
{granted}) and then answers it ({id, outcome: {ok, value} | {ok: false, error}})."""
if not _is_local(request) or not _origin_ok(request):
return web.json_response({"error": "This computer only"}, status=403)
try:
body = await request.json()
except (ValueError, UnicodeDecodeError):
body = None
if not isinstance(body, dict) or not isinstance(body.get("id"), str):
return web.json_response({"error": "Expected {id, claim} or {id, outcome}"}, status=400)
if body.get("claim") is True:
return web.json_response({"granted": mcp_bridge.claim(body["id"])})
return web.json_response({"accepted": mcp_bridge.resolve(body["id"], body.get("outcome"))})
def register_routes(app):
app.router.add_route("*", "/anomalous/mcp", api_mcp)
app.router.add_post("/anomalous/mcp/bridge", api_mcp_bridge)
+9 -5
View File
@@ -1,4 +1,6 @@
"""The tools the MCP endpoint (mcp_server.py) offers: read-only views of the library.
"""The MCP endpoint's (mcp_server.py) reading tools: views of the library (mcp_actions.py acts).
`call_tool` runs any tool of a registry, these and mcp_actions.py's.
Each tool is a plain function over the same stores the browser's pages use (model catalog
and sidecars, output gallery, combos, workflow recipes, saved prompts, generated audio, scan
@@ -450,15 +452,17 @@ TOOLS = dict([
])
def call_tool(name, arguments):
"""An MCP tools/call result for one tool (run in a worker thread)."""
spec = TOOLS[name]["spec"]["inputSchema"]
def call_tool(tools, name, arguments, ctx=None):
"""An MCP tools/call result for tool `name` of `tools` (run in a worker thread). Tools marked
`context` get `ctx` ({base_url} of this server) first."""
tool = tools[name]
spec = tool["spec"]["inputSchema"]
unknown = set(arguments) - set(spec["properties"])
missing = [key for key in spec.get("required", []) if key not in arguments]
if unknown or missing:
return _failure(f"Unknown arguments {sorted(unknown)}" if unknown else f"Missing arguments {missing}")
try:
value = TOOLS[name]["run"](**arguments)
value = tool["run"](ctx, **arguments) if tool.get("context") else tool["run"](**arguments)
except ToolError as error:
return _failure(str(error))
except Exception as error: # noqa: BLE001 - an AI app gets a readable failure, never a dropped request
+10
View File
@@ -167,6 +167,16 @@ A tool's own failure (bad id, missing file, unexpected error) is a result with
`isError: true`, not a protocol error. Pictures attached on request are the gallery's
512 px thumbnails (at most 1.5 MB).
Acting tools (`api/mcp_actions.py`) never delete or rename files. Canvas actions go
through `mcp_bridge.ask_page`: it sends `{id, action, args}` as the `anomalous.mcp`
websocket event to every open page and waits (30 s, 90 s for model checks; at most 20
waiting). The first page to POST `{id, claim: true}` to `/anomalous/mcp/bridge` gets it
(hidden tabs wait 1.5 s so a visible one wins) and posts `{id, outcome}` back. No page,
no answer in time, or the page's refusal becomes a tool error. Scans and speech call this
server's own `/anomalous/scan_all`, `/anomalous_tts/characters`, `/prompt` and
`/history` over loopback, so they run exactly as from the UI (speech waits up to two
minutes for the file). Canvas changes are posted to the activity log with `via: "mcp"`.
## Metadata and cache behavior
The output gallery keeps one ordered directory snapshot for at most ten seconds
+30 -4
View File
@@ -2,7 +2,13 @@
支持 MCP 的 AI 软件(Claude、Cursor、Cherry Studio、VS Code 等)连上以后,就能直接查你的 ComfyUI 资料:有哪些模型和触发词、某张图当时用的什么参数、存过哪些搭配和提示词,然后据此回答你的问题或者帮你写提示词。
**它只读,不改任何东西。** 想改什么,AI 会告诉你去插件的哪个页面改。
它还能动手:改当前工作流的提示词、换模型、加 LoRA、摆上你的搭配、找回缺失的模型、运行工作流、扫描模型,以及让 Anomalous TTS 的角色读台词。
- **每一步都能撤销**:画布上的改动都是一步,按 Ctrl+Z 就回去;
- **都有记录**:改动会写进“操作记录”,标着“AI”,页面右上角也会提示一句;
- **不删文件**:不会删除或改名任何文件。
AI 软件在调用会改东西的工具前,一般会先问你同不同意。
English summary at the end.
@@ -60,8 +66,13 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
- “我有哪些 Flux 的 LoRA?各自的触发词是什么?”
- “找找用过『某某』这个 LoRA 的出图,看看当时的种子和采样器。”
- “哪些模型还没在 C 站匹配上?为什么?”
- “用我库里合适的模型和 LoRA,帮我写一段赛博朋克夜景的提示词。”
- “我上周存的那个搭配用的是哪个底模?”
- “给当前工作流加上那个赛博朋克 LoRA,强度 0.7,再把触发词写进正向提示词。”
- “把我的搭配『夜景』摆到画布上。”
- “这个工作流缺哪些模型?能找回的都找回来。”
- “用阿罗娜读一下:老师,欢迎回来!”
改画布需要 ComfyUI 在这台电脑的浏览器里开着(就是你平时用的那个页面)。开了好几个标签页的话,由当前正在看的那个来执行。
## 4. 能查什么
@@ -77,11 +88,26 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
| search_audio | 用 Anomalous TTS 生成过的语音,带台词和角色 |
| recent_activity | 插件最近改过什么(操作记录) |
能动手的:
| 工具 | 做什么 |
|---|---|
| describe_canvas | 读当前工作流:每个节点的编号、类型、设置,哪个是正向/反向提示词框 |
| set_prompt | 改写或追加正向/反向提示词 |
| set_model | 换某个加载器节点里的模型 |
| add_lora | 在模型链里插入一个 LoRA(默认接在主模型加载器和已有 LoRA 后面),UNet / Flux 类模型会用仅模型的 LoRA 节点 |
| place_combo | 把一个搭配作为一组新节点摆到画布上,原有节点不动 |
| check_workflow_models / fix_workflow_models | 检查缺失的模型;把能确定找回的(同一个文件)放回去,“可能是”的留给你在模型检查里确认 |
| run_workflow | 像按“运行”一样把工作流加入队列 |
| open_in_anomalous | 在插件里打开某个模型的详情页给你看 |
| scan_models | 扫描新模型、重新查找没匹配上的,或扫描指定的模型(会联网访问 Civitai) |
| list_voices / speak | 列出 Anomalous TTS 的角色;让某个角色读一段台词并保存(最多等两分钟) |
## 5. 注意
- **隐私**:如果 AI 软件用的是云端模型,查询结果(模型名、提示词、图片的生成参数)会发给那家 AI 服务商。附上封面或缩略图时,图片也会一起发过去。介意的话可以用本地模型,或者不让 AI 附图。
- 附图时会用到插件自己的缩略图缓存,和浏览图库时一样,不动你的原图。
- 改模型名、扫描、往工作流里加 LoRA 这类操作,目前还得在插件里自己点。
- 改模型的名字和备注、删除东西、物理改文件名,这些 AI 做不了,得在插件里自己点。
---
@@ -89,4 +115,4 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
While ComfyUI runs, Anomalous serves an MCP endpoint at `http://127.0.0.1:8188/anomalous/mcp` (use your ComfyUI port). Add it as a Streamable HTTP server in your AI app (Claude Code: `claude mcp add --transport http anomalous <url>`; Cursor and VS Code: a `url` / `"type": "http"` entry; Claude Desktop: through `npx mcp-remote <url> --transport http-only`). Both the 2026-07-28 protocol and the earlier `initialize`-based versions are served.
The tools only read: library overview, model search and details (optionally with the cover), scan status, output-image search and generation settings (optionally with a thumbnail), combos, workflow recipes, saved prompts, generated audio and the activity log. Only this computer may connect; requests through a proxy, from other hosts or from web pages are refused. When your AI app uses a cloud model, what the tools return (and any attached picture) is sent to that provider.
Reading tools: library overview, model search and details (optionally with the cover), scan status, output-image search and generation settings (optionally with a thumbnail), combos, workflow recipes, saved prompts, generated audio and the activity log. Acting tools: read the open workflow, write prompts, swap a node's model, add a LoRA, place a combo, check and fix missing models, run the workflow, open a model in Anomalous, start scans, list Anomalous TTS voices and have one speak. Canvas actions need ComfyUI open in a browser on this computer; each is one Ctrl+Z step and is logged as the AI's. Nothing deletes or renames files. Only this computer may connect; requests through a proxy, from other hosts or from web pages are refused. When your AI app uses a cloud model, what the tools return (and any attached picture) is sent to that provider.
+2 -1
View File
@@ -69,7 +69,8 @@ export function entrySummary(entry) {
const changes = entry.detail?.changes || [];
const opened = changes.find(change => change.kind === 'opened');
if (opened) return t('activityCanvasOpened', { name: opened.node || '—', count: opened.after || '?' });
return t('activityCanvasChanged', { count: entry.detail?.total || changes.length });
const changed = t('activityCanvasChanged', { count: entry.detail?.total || changes.length });
return entry.detail?.via === 'mcp' ? t('activityByAi', { summary: changed }) : changed;
}
if (entry.action === 'scan_done') {
const scan = entry.detail?.scan || {};
+3
View File
@@ -1,5 +1,6 @@
import { app } from '../../../scripts/app.js';
import { AnomalousBrowser } from './browser.js';
import { watchMcpActions } from './mcp_bridge.js';
import {
clampFloatingTriggerPosition,
clearSavedTriggerPosition,
@@ -436,6 +437,8 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) {
ensureBrowser();
installMaterialsShortcutFallback();
// For the Anomalous_TTS node's character menu (its docs/INTERFACE.md §6).
// Canvas actions asked for by AI apps through the MCP endpoint (api/mcp_bridge.py).
watchMcpActions(ensureBrowser);
window.anomalous_open_voice = (character) => {
const browser = ensureBrowser();
if (!browser) return false;
+12 -7
View File
@@ -27,7 +27,12 @@ function unsupported(direction, code, details = {}) {
return { supported: false, direction, code, channels: [], ...details };
}
export function analyzeModelChainInsertion(graph, anchorNode, direction) {
/**
* Whether a node can go into the model chain before / after `anchorNode`, and the links it
* would take over. `types`: the chain's slot types, MODEL and CLIP (a LoRA loader), or MODEL
* alone (a model-only LoRA loader, for UNet models that carry no text encoder).
*/
export function analyzeModelChainInsertion(graph, anchorNode, direction, types = MODEL_CHAIN_TYPES) {
if (!graph || !anchorNode) {
return unsupported(direction, 'missing_graph_or_node');
}
@@ -36,7 +41,7 @@ export function analyzeModelChainInsertion(graph, anchorNode, direction) {
}
const channels = [];
for (const type of MODEL_CHAIN_TYPES) {
for (const type of types) {
const anchorSlot = direction === 'before'
? findSlotIndex(anchorNode.inputs, type)
: findSlotIndex(anchorNode.outputs, type);
@@ -98,9 +103,9 @@ export function getModelChainInsertionCapabilities(graph, anchorNode) {
};
}
function assertInsertedNodeSlots(insertedNode) {
function assertInsertedNodeSlots(insertedNode, types) {
const slots = {};
for (const type of MODEL_CHAIN_TYPES) {
for (const type of types) {
const input = findSlotIndex(insertedNode?.inputs, type);
const output = findSlotIndex(insertedNode?.outputs, type);
if (input < 0 || output < 0) {
@@ -166,8 +171,8 @@ function placeInsertedNode(graph, anchorNode, insertedNode, direction) {
insertedNode.pos = [x, y];
}
export function spliceModelChainNode({ graph, anchorNode, insertedNode, direction }) {
const analysis = analyzeModelChainInsertion(graph, anchorNode, direction);
export function spliceModelChainNode({ graph, anchorNode, insertedNode, direction, types = MODEL_CHAIN_TYPES }) {
const analysis = analyzeModelChainInsertion(graph, anchorNode, direction, types);
if (!analysis.supported) {
const error = new Error(`Model-chain insertion is not available: ${analysis.code}`);
error.code = analysis.code;
@@ -175,7 +180,7 @@ export function spliceModelChainNode({ graph, anchorNode, insertedNode, directio
throw error;
}
const insertedSlots = assertInsertedNodeSlots(insertedNode);
const insertedSlots = assertInsertedNodeSlots(insertedNode, types);
const originalConnections = analysis.channels.flatMap(channel => channel.originalLinks);
let added = false;
graph.beforeChange?.(anchorNode);
+16
View File
@@ -884,6 +884,14 @@ export const i18n = {
activityToday: "今天",
activityYesterday: "昨天",
activityCanvasChanged: "改了画布上 {count} 处",
activityByAi: "AI:{summary}",
mcpDidPositive: "AI 改写了正向提示词(Ctrl+Z 撤销)",
mcpDidNegative: "AI 改写了反向提示词(Ctrl+Z 撤销)",
mcpDidModel: "AI 把模型换成了 {name}(Ctrl+Z 撤销)",
mcpDidLora: "AI 加入了 LoRA:{name}(Ctrl+Z 撤销)",
mcpDidCombo: "AI 摆上了搭配“{name}”(Ctrl+Z 撤销)",
mcpDidFix: "AI 找回了 {count} 个缺失的模型(Ctrl+Z 撤销)",
mcpDidRun: "AI 让工作流开始运行",
activityCanvasOpened: "打开工作流 {name}({count} 个节点)",
activityChangedValue: "{node} · {widget}:{before} → {after}",
activityAdded: "新增节点 {node}",
@@ -2814,6 +2822,14 @@ export const i18n = {
activityToday: "Today",
activityYesterday: "Yesterday",
activityCanvasChanged: "Changed {count} things on the canvas",
activityByAi: "AI: {summary}",
mcpDidPositive: "The AI rewrote the positive prompt (Ctrl+Z undoes it)",
mcpDidNegative: "The AI rewrote the negative prompt (Ctrl+Z undoes it)",
mcpDidModel: "The AI switched the model to {name} (Ctrl+Z undoes it)",
mcpDidLora: "The AI added the LoRA {name} (Ctrl+Z undoes it)",
mcpDidCombo: "The AI placed the combo “{name}” (Ctrl+Z undoes it)",
mcpDidFix: "The AI put back {count} missing models (Ctrl+Z undoes it)",
mcpDidRun: "The AI started the workflow",
activityCanvasOpened: "Opened workflow {name} ({count} nodes)",
activityChangedValue: "{node} · {widget}: {before} → {after}",
activityAdded: "Added node {node}",
+300
View File
@@ -0,0 +1,300 @@
/**
* The page side of the MCP canvas actions (api/mcp_bridge.py). The server sends
* {id, action, args} as the "anomalous.mcp" event; one open page claims it (a hidden tab
* waits a moment so a visible one goes first), does it with Anomalous's own canvas code and
* posts the outcome back. Every change is one Ctrl+Z step, lands in the activity log marked
* as the AI's (with its undo), and shows a toast. Errors are written for the AI to read.
*/
import { app } from '../../../scripts/app.js';
import { api } from '../../../scripts/api.js';
import { translate as t } from './locales.js';
import { diffSnapshots, snapshotGraph } from './activity_diff.js';
import { postCanvasActivity } from './activity_log.js';
import { keepUndo, workflowKey } from './canvas_undo.js';
import { recordCanvasStep } from './canvas_history.js';
import { analyzeModelChainInsertion, spliceModelChainNode } from './graph_splice.js';
import { checkWorkflowModels, fixWorkflowModels, isProblem } from './model_check.js';
import { promptBoxes } from './prompt_boxes.js';
import { findModelComboWidget, getNativeWidgetValues, setWidgetValue } from './ui_node_model_picker.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
import { openListedModel } from './ui_scan_lists.js';
const HIDDEN_PAGE_DELAY_MS = 1500;
const MAX_NODES = 150;
const MAX_VALUE = 400;
const MAX_LOGGED = 60;
const MAIN_LOADER = /checkpointloader|unetloader|unetloadergguf|diffusionmodelloader/i;
const graph = () => app.canvas?.graph || app.graph || null;
const workflowName = () => {
const workflow = app.extensionManager?.workflow?.activeWorkflow;
return String(workflow?.filename || workflow?.path || '');
};
const short = (text) => (String(text).length > MAX_VALUE ? `${String(text).slice(0, MAX_VALUE)}…` : String(text));
const samePath = (a, b) => String(a).replace(/\\/g, '/') === String(b).replace(/\\/g, '/');
function currentGraph() {
const g = graph();
if (!g) throw new Error('No workflow is open in ComfyUI.');
return g;
}
function nodeById(id) {
const node = currentGraph().getNodeById?.(Number(id)) ?? currentGraph().getNodeById?.(id);
if (!node) throw new Error(`There is no node ${id} on the canvas; describe_canvas lists them.`);
return node;
}
/** The drop-down value of `node`'s model widget naming `path` (relative to its models folder). */
function modelChoice(node, widget, path) {
return getNativeWidgetValues(node, widget).find(value => samePath(value, path)) || null;
}
/** Sets a widget the way the user would, so ComfyUI and other extensions notice. */
function writeWidget(node, widget, value) {
const old = widget.value;
app.graph?.beforeChange?.(node);
setWidgetValue(node, widget, value);
widget.callback?.(value, app.canvas, node, app.canvas?.graph_mouse, null);
node.onWidgetChanged?.(widget.name, value, old, widget); // clears ComfyUI's missing-model mark
app.graph?.afterChange?.(node);
if (app.lastNodeErrors?.[node.id]) delete app.lastNodeErrors[node.id];
app.graph?.setDirtyCanvas?.(true, true);
recordCanvasStep(app);
return old;
}
/** Runs a canvas change and logs what it changed as the AI's, with its undo. */
async function recorded(getOwner, toastKey, change) {
getOwner()?.flushCanvasActivity?.(); // what was pending before is the user's own
const g = currentGraph();
const before = snapshotGraph(g);
const workflow = workflowKey(app);
const value = await change(g);
const after = snapshotGraph(g);
const changes = diffSnapshots(before, after);
if (changes.length) {
postCanvasActivity({ changes: changes.slice(0, MAX_LOGGED), total: changes.length, workflow: workflowName(), via: 'mcp' })
.then((data) => {
keepUndo(data?.entry?.id, workflow, before, after);
getOwner()?.onActivityRecorded?.();
})
.catch(error => console.warn('[AMB] Activity log: the AI change was not recorded.', error));
}
showWorkbenchToast(t(toastKey, value?.toast || {}));
delete value?.toast;
return value;
}
function describeNode(g, node) {
const settings = {};
for (const widget of node.widgets || []) {
const value = widget.value;
if (!widget.name || widget.type === 'button') continue;
if (typeof value === 'string') settings[widget.name] = short(value);
else if (typeof value === 'number' || typeof value === 'boolean') settings[widget.name] = value;
}
const inputs = {};
for (const input of node.inputs || []) {
if (input.link == null) continue;
const link = g.getLink?.(input.link) ?? g.links?.[input.link];
if (link) inputs[input.name] = link.origin_id;
}
const boxes = promptBoxes(node);
return {
id: node.id,
type: node.type,
title: node.title && node.title !== node.type ? node.title : undefined,
bypassed: node.mode === 4 || undefined,
muted: node.mode === 2 || undefined,
settings,
inputs_from_nodes: Object.keys(inputs).length ? inputs : undefined,
prompt_boxes: boxes.length ? boxes.map(box => ({ setting: box.name, role: box.role || 'unknown' })) : undefined,
};
}
function describeCanvas() {
const g = currentGraph();
const nodes = g._nodes || [];
return {
workflow: workflowName(),
node_count: nodes.length,
selected_node_ids: Object.keys(app.canvas?.selected_nodes || {}).map(Number),
nodes: nodes.slice(0, MAX_NODES).map(node => describeNode(g, node)),
};
}
function findPromptBox(role, nodeId) {
if (nodeId != null) {
const node = nodeById(nodeId);
const boxes = promptBoxes(node);
const box = boxes.find(item => item.role === role) || (boxes.length === 1 ? boxes[0] : null);
if (!box) throw new Error(`Node ${nodeId} (${node.type}) has no ${role} prompt box.`);
return { node, box };
}
const found = (currentGraph()._nodes || []).flatMap(node => promptBoxes(node)
.filter(box => box.role === role).map(box => ({ node, box })));
if (!found.length) throw new Error(`No ${role} prompt box is wired on the canvas; give node_id.`);
if (found.length > 1) throw new Error(`Several ${role} prompt boxes (nodes ${found.map(item => item.node.id).join(', ')}); give node_id.`);
return found[0];
}
function setPrompt({ text, role, node_id: nodeId, mode }, getOwner) {
return recorded(getOwner, role === 'negative' ? 'mcpDidNegative' : 'mcpDidPositive', () => {
const { node, box } = findPromptBox(role, nodeId);
const old = String(box.widget.value || '');
const value = mode === 'append' && old.trim() ? `${old.replace(/[\s,]+$/, '')}, ${text}` : text;
writeWidget(node, box.widget, value);
return { node_id: node.id, role: box.role || role, before: short(old), after: short(value) };
});
}
function setModel({ node_id: nodeId, model }, getOwner) {
return recorded(getOwner, 'mcpDidModel', () => {
const node = nodeById(nodeId);
const widget = findModelComboWidget(node);
if (!widget) throw new Error(`Node ${nodeId} (${node.type}) has no model drop-down.`);
const value = modelChoice(node, widget, model.path);
if (!value) throw new Error(`${node.type} cannot load ${model.path} (a ${model.type} model), or ComfyUI has not listed it yet.`);
const old = writeWidget(node, widget, value);
delete node.color;
delete node.bgcolor;
return { node_id: node.id, before: old, after: value, toast: { name: value.split(/[\\/]/).pop() } };
});
}
/** The loader `add_lora` goes after by default: the one main model loader, past the LoRAs already chained to it. */
function defaultLoraAnchor(g) {
const loaders = (g._nodes || []).filter(node => MAIN_LOADER.test(node.type) && node.mode !== 4);
if (loaders.length !== 1) throw new Error(loaders.length ? 'Several model loaders; give after_node_id.' : 'No model loader on the canvas; give after_node_id.');
let anchor = loaders[0];
for (let hop = 0; hop < 20; hop++) {
const output = (anchor.outputs || []).find(item => String(item.type).toUpperCase() === 'MODEL');
const targets = (output?.links || []).map(id => g.getLink?.(id) ?? g.links?.[id]).filter(Boolean)
.map(link => g.getNodeById(link.target_id));
if (targets.length !== 1 || !/lora/i.test(targets[0]?.type || '')) break;
anchor = targets[0];
}
return anchor;
}
function addLora({ model, after_node_id: afterId, strength_model: strengthModel, strength_clip: strengthClip }, getOwner) {
return recorded(getOwner, 'mcpDidLora', (g) => {
const anchor = afterId != null ? nodeById(afterId) : defaultLoraAnchor(g);
// A loader with a CLIP output takes a full LoRA loader; a UNet one (model only) a model-only one.
const withClip = (anchor.outputs || []).some(output => String(output.type).toUpperCase() === 'CLIP');
const types = withClip ? ['MODEL', 'CLIP'] : ['MODEL'];
const analysis = analyzeModelChainInsertion(g, anchor, 'after', types);
if (!analysis.supported) throw new Error(`A LoRA cannot go after node ${anchor.id} (${anchor.type}): ${analysis.code}.`);
const node = LiteGraph.createNode(withClip ? 'LoraLoader' : 'LoraLoaderModelOnly');
const widget = node && findModelComboWidget(node);
const value = widget && modelChoice(node, widget, model.path);
if (!value) throw new Error(`ComfyUI does not list ${model.path} as a LoRA yet; refresh ComfyUI's model lists (R).`);
setWidgetValue(node, widget, value);
for (const [name, strength] of [['strength_model', strengthModel], ['strength_clip', strengthClip]]) {
const item = node.widgets?.find(w => w.name === name);
if (item && Number.isFinite(strength)) setWidgetValue(node, item, strength);
}
spliceModelChainNode({ graph: g, anchorNode: anchor, insertedNode: node, direction: 'after', types });
recordCanvasStep(app);
return { node_id: node.id, after_node_id: anchor.id, node_type: node.type, lora: value, toast: { name: value.split(/[\\/]/).pop() } };
});
}
async function placeCombo({ name }, getOwner) {
const data = await (await fetch('/anomalous/notebooks')).json();
const combos = data?.notebooks || [];
const combo = combos.find(item => item.name === name)
|| combos.find(item => String(item.name).toLowerCase() === String(name).toLowerCase());
if (!combo) throw new Error(`No combo called ${name}; list_combos names them.`);
if (!combo.data?.mainModel) throw new Error(`The combo ${combo.name} has no main model yet.`);
const owner = getOwner();
if (!owner) throw new Error('Anomalous is not ready on this page yet.');
return recorded(getOwner, 'mcpDidCombo', (g) => {
const nodes = g._nodes || [];
const right = nodes.reduce((max, node) => Math.max(max, (node.pos?.[0] || 0) + (node.size?.[0] || 0)), 0);
const top = nodes.length ? Math.min(...nodes.map(node => node.pos?.[1] || 0)) : 100;
const count = nodes.length;
owner.currentNotebook = combo;
owner.sendNotebookToCanvas([right + 120, top]);
return { placed: combo.name, new_nodes: (g._nodes || []).length - count, toast: { name: combo.name } };
});
}
function entryView(entry) {
return {
node_id: entry.node.id, node_type: entry.node.type, setting: entry.widget.name, model: entry.value,
state: entry.state, ...(entry.target ? { can_put_back: entry.target, found_by: entry.via } : {}),
};
}
const CHECK_MEANING = 'ready: present; changed: present but a different file than when saved; fixable: found for sure (fix_workflow_models puts it back); '
+ 'candidate: a likely file, for the user to confirm in Model Check; ambiguous / conflict / missing: not found.';
async function checkModels() {
const entries = await checkWorkflowModels({ refresh: false });
return { models: entries.map(entryView), problems: entries.filter(isProblem).length, meaning: CHECK_MEANING };
}
async function fixModels(_args, getOwner) {
const entries = await checkWorkflowModels({ refresh: false });
const fixable = entries.filter(entry => entry.state === 'fixable').map(entryView);
if (!fixable.length) return { fixed: 0, still_missing: entries.filter(isProblem).map(entryView) };
return recorded(getOwner, 'mcpDidFix', () => {
const fixed = fixWorkflowModels(entries);
return { fixed, put_back: fixable, still_missing: entries.filter(entry => isProblem(entry) && entry.state !== 'fixable').map(entryView), toast: { count: fixed } };
});
}
async function runWorkflow({ batch }) {
currentGraph();
await app.queuePrompt(0, batch || 1);
showWorkbenchToast(t('mcpDidRun'));
return { queued: batch || 1, note: 'Results land in the output folder; search_images finds them when done.' };
}
async function openModel({ type, path_idx: pathIdx, rel }, getOwner) {
const owner = getOwner();
if (!owner) throw new Error('Anomalous is not ready on this page yet.');
owner.show();
await openListedModel(owner, { type, path_idx: pathIdx, rel, filename: rel.split('/').pop() }, () => owner.goTo('models'));
return { opened: rel };
}
const ACTIONS = {
describe_canvas: describeCanvas,
set_prompt: setPrompt,
set_model: setModel,
add_lora: addLora,
place_combo: placeCombo,
check_models: checkModels,
fix_models: fixModels,
run_workflow: runWorkflow,
open_model: openModel,
};
const post = body => fetch('/anomalous/mcp/bridge', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
}).then(response => response.json());
async function handle(detail, getOwner) {
const { id, action, args } = detail || {};
const run = ACTIONS[action];
if (typeof id !== 'string' || !run) return;
if (document.visibilityState !== 'visible') await new Promise(resolve => setTimeout(resolve, HIDDEN_PAGE_DELAY_MS));
const claim = await post({ id, claim: true }).catch(() => null);
if (!claim?.granted) return; // another tab took it
let outcome;
try {
outcome = { ok: true, value: (await run(args || {}, getOwner)) ?? null };
} catch (error) {
outcome = { ok: false, error: String(error?.message || error) };
}
await post({ id, outcome }).catch(error => console.warn('[AMB] MCP: could not send the result back.', error));
}
/** Listens for MCP canvas actions; `getOwner()` gives the browser (created on demand). */
export function watchMcpActions(getOwner) {
api.addEventListener('anomalous.mcp', event => void handle(event.detail, getOwner));
}