The descriptions of the daily set are rewritten without the repeats and roundabout phrasing. Their rules and don'ts stay: no cropping or editing pictures with code, no reading ComfyUI's folders, ask the user in the workbench. So do the shared property texts: the query words, node ids, the task id. tools/list for the daily set drops from about 27,300 to 22,200 characters, and the full one from 46,000 to 40,700. begin_task's note, read at the start of every task, drops from about 1,900 to 900 characters. The replacements were made with ast, so each description's adjacent literals changed as one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
341 lines
20 KiB
Python
341 lines
20 KiB
Python
"""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 . import ai_gates, mcp_fill
|
|
from .lora_info import trains_text_encoder
|
|
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):
|
|
folder_type, _idx, base_dir, file_path = _resolve_model(model_id)
|
|
if folder_type != "loras":
|
|
raise ToolError("add_lora takes a model from the loras folder.")
|
|
return _page("add_lora", {"model": {"type": folder_type, "path": _rel(base_dir, file_path)},
|
|
"text_encoder": trains_text_encoder(file_path), "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 download_missing_models(ctx, names=None):
|
|
return _page("download_missing", {"names": [str(name) for name in names or []]}, timeout=120)
|
|
|
|
|
|
def download_model(ctx, type, url="", hash="", folder=None, name=""):
|
|
if not (url or hash):
|
|
raise ToolError("Give url (a Civitai, Hugging Face or GitHub link to the file or model version) or hash.")
|
|
return _page("download_model", {"url": str(url), "hash": str(hash), "type": str(type),
|
|
"folder": None if folder is None else str(folder), "name": str(name or "")}, timeout=90)
|
|
|
|
|
|
def download_status(ctx):
|
|
status, data = _local(ctx, "GET", "/anomalous/download/status")
|
|
if status != 200:
|
|
raise ToolError(f"Download status unavailable ({status}).")
|
|
jobs = []
|
|
for job in (data or {}).get("jobs", []):
|
|
total = job.get("total") or 0
|
|
jobs.append({"file": job.get("rel"), "type": job.get("type"), "state": job.get("state"), "error": job.get("error") or None,
|
|
"percent": round(job["received"] * 100 / total) if total else None, "size": total or None,
|
|
"checked": job.get("verified"), "for_model": job.get("value") or None})
|
|
return {"downloads": jobs, "meaning": "state: queued, running, verifying, done (checked: its fingerprint matched), "
|
|
"failed (error says why; starting it again continues it), cancelled."}
|
|
|
|
|
|
def run_workflow(ctx, batch=1, wait_seconds=15, workflow=None, **slots):
|
|
from .ai_run_tracking import checked_wait
|
|
checked_wait(wait_seconds) # Reject malformed waits before anything can be submitted.
|
|
batch = max(1, min(8, int(batch)))
|
|
filled = mcp_fill.fill(ctx, workflow, **slots) # a saved workflow and its blanks first (mcp_fill.py)
|
|
try:
|
|
answer = ask_page("run_workflow", {"batch": batch}, required_capability="prompt_ids")
|
|
except (PageUnavailable, PageRefused) as error:
|
|
raise ToolError(str(error))
|
|
ids = answer.get("prompt_ids") if isinstance(answer, dict) else None
|
|
if (not isinstance(ids, list) or not ids or len(ids) > batch
|
|
or any(not isinstance(pid, str) or not pid or len(pid) > 128 for pid in ids) or len(set(ids)) != len(ids)):
|
|
raise ToolError((answer.get("submission_error") if isinstance(answer, dict) else None)
|
|
or "No valid submission receipts. Check ComfyUI's queue before retrying; do not repeat automatically.")
|
|
answer = {"prompt_ids": ids, "queued": len(ids), "requested": batch,
|
|
"note": ("If this answer does not say it is done, the user tells you when it is: say it is running and end "
|
|
"your turn; run_status then brings its pictures." if ai_gates.run_wait() == "call" else
|
|
"Follow these runs with run_status (wait_seconds up to 55).")
|
|
+ " Do not repeat the batch; accepted prompts are already queued."}
|
|
if filled:
|
|
answer["filled"] = filled
|
|
# Some prompts may have been accepted before validation or a transport failure stopped the batch.
|
|
if len(ids) < batch:
|
|
answer["submission_error"] = f"Only {len(ids)} of {batch} prompts were accepted."
|
|
return answer
|
|
|
|
|
|
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."}
|
|
_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 settings; "
|
|
"get_situation gives the canvas in brief (a line a node, what is still unwired and what fits it).",
|
|
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 after after_node_id (default: the main model loader, past its LoRAs) and moves "
|
|
"what that node fed behind it; the CLIP line too when the LoRA trains the text encoder.",
|
|
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, or a node-structure combo's own nodes and links. Nothing already there "
|
|
"changes; ports to nodes outside the combo (sampler…) stay open.",
|
|
place_combo, {"name": {"type": "string", "description": "Combo name from list_combos."}}, ["name"]),
|
|
_action("check_workflow_models", "Check missing models",
|
|
"Models the open workflow names that this computer lacks, and which Anomalous can put back (the same "
|
|
"file by hash, or a likely one).",
|
|
check_workflow_models, read_only=True),
|
|
_action("fix_workflow_models", "Fix missing models",
|
|
"Puts back every missing model found by hash; likely ones are left to the user. One Ctrl+Z step.",
|
|
fix_workflow_models, destructive=True),
|
|
_action("download_missing_models", "Download missing models",
|
|
"Downloads the models the open workflow is missing whose source is certain: Civitai found by the "
|
|
"workflow's fingerprint, or a file link the workflow carries. A file name alone is never used. Files go "
|
|
"where the user's download settings say and are checked against the fingerprint before use; finished "
|
|
"ones are put into their nodes. names limits it to those files. Large files take a while.",
|
|
download_missing_models, {"names": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_LIMIT,
|
|
"description": "Only these model file names (default: all missing)."}},
|
|
open_world=True),
|
|
_action("download_model", "Download a model",
|
|
"Downloads one model into this computer's models folder of `type` (loras, checkpoints, vae, "
|
|
"diffusion_models, text_encoders, upscale_models…): from url (a Civitai model-version link, or a Hugging "
|
|
"Face / GitHub link to the file) or by its Civitai hash. folder is a subfolder inside that type's folder "
|
|
"(default: the user's download settings); name the file name (default: the site's). Never replaces a file.",
|
|
download_model, {"type": {"type": "string"}, "url": {"type": "string"}, "hash": {"type": "string"},
|
|
"folder": {"type": "string"}, "name": {"type": "string"}}, ["type"], open_world=True),
|
|
_action("download_status", "Downloads", "How the downloads of this ComfyUI run are going.", download_status,
|
|
read_only=True),
|
|
_action("run_workflow", "Run the workflow",
|
|
"Queues the workflow (uses the GPU). In the same call: workflow (a saved one) opens in the task's tab "
|
|
"once and is reused; prompt, negative, seed (every sampler), width/height (the start latent), image "
|
|
"(its LoadImage) and values (any setting by name: {\"steps\": 30}, or {\"12\": {\"denoise\": 0.5}} when "
|
|
"several nodes have it) fill it in; the rest stays. Without workflow they fill the open canvas. Then "
|
|
"run_status, never the output folder.",
|
|
run_workflow, {"batch": {"type": "integer", "minimum": 1, "maximum": 8, "default": 1},
|
|
"wait_seconds": {"type": "number", "minimum": 0, "maximum": 55, "default": 15,
|
|
"description": "Seconds to wait for it to end."},
|
|
"workflow": {"type": "string", "description": "A name from list_workflows."},
|
|
"prompt": {"type": "string"}, "negative": {"type": "string"},
|
|
"seed": {"type": "integer", "minimum": 0},
|
|
"width": {"type": "integer", "minimum": 16, "maximum": 16384},
|
|
"height": {"type": "integer", "minimum": 16, "maximum": 16384},
|
|
"image": {"type": "string", "description": "subfolder/name.png, output or input folder."},
|
|
"image_node": {"type": "integer", "description": "Which LoadImage, when there are several."},
|
|
"values": {"type": "object", "description": "Other settings by name, any node's."}}),
|
|
_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"]),
|
|
])
|