diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 01b4e4c..8d8806f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -100,6 +100,12 @@ covered by the `styles.css` manifest. for the scan page and lists the last two, by the same sidecar rules the scraper uses (`sidecar_info`, `is_unmatched`, `unmatched_reason` in `model_identity.py`); it also lists the model files in other formats, which scans never read, so the page can say so. +- `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`. - `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` diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c85b36..64d9aba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +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 读你的库) +- 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`. + ### 🕘 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. - **More detail (记录更详细)**: a scan is recorded when it ends, with what it found and each model it changed (open one from the entry), instead of a bare "scanned models" when it started. Editing a model records each field from → to, and deleting one lists the files moved to the Recycle Bin. diff --git a/README.md b/README.md index a2cb46d..4632744 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ 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). + **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**. ### 📦 Quick Installation @@ -112,6 +114,8 @@ 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)。 + **不占你的资源**: - 插件本身不加载任何模型,不和生图抢显存。 - 浏览时显示的是小缩略图,不加载原图;关闭窗口一分半后,图片也会释放掉。 diff --git a/api/__init__.py b/api/__init__.py index 5cea44a..5c69b9b 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,7 +1,7 @@ from . import ( activity_log, audio_catalog, folder_types, gallery_routes, materials, media_routes, model_catalog, model_media, model_metadata, model_resolution, model_type_listing, kept_images, node_material, recipe_packages, recipes, scan_report, scan_summary, - translation_routes, version_manager, + mcp_server, translation_routes, version_manager, ) from .scanner import * from .config import * @@ -117,4 +117,5 @@ def setup_routes(app): app.router.add_get('/anomalous/audio_previews', audio_catalog.api_get_audio_previews) app.router.add_post('/anomalous/save_audio_preview', audio_catalog.api_save_audio_preview) version_manager.register_routes(app) - activity_log.register_routes(app) + activity_log.register_routes(app) + mcp_server.register_routes(app) diff --git a/api/mcp_server.py b/api/mcp_server.py new file mode 100644 index 0000000..963e801 --- /dev/null +++ b/api/mcp_server.py @@ -0,0 +1,198 @@ +"""MCP endpoint: AI apps (Claude, Cursor, Cherry Studio…) use Anomalous's library as tools. + +POST /anomalous/mcp speaks the Model Context Protocol's Streamable HTTP transport, both eras: +- modern (2026-07-28): stateless; every request carries its protocol version in `_meta` and + the MCP-Protocol-Version / Mcp-Method / Mcp-Name headers, which must match the body; +- legacy (2025-03-26 to 2025-11-25): an `initialize` handshake first. No session is kept + (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. +""" + +import asyncio +import base64 +import ipaddress +import urllib.parse + +from aiohttp import web + +from .mcp_tools import TOOLS, call_tool + +MODERN_VERSIONS = ("2026-07-28",) +LEGACY_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26") +SERVER_INFO = {"name": "anomalous-model-browser", "title": "Anomalous Model Browser", "version": "1.0.0"} +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." +) +LIST_TTL_MS = 300000 +# Requests run tools in threads; a few at a time is plenty for one person's AI app. +_slots = asyncio.Semaphore(4) + +PROXY_HEADERS = ("Forwarded", "X-Forwarded-For", "X-Real-IP", "X-Forwarded-Host") +LOCAL_NAMES = ("localhost",) + +PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS = -32700, -32600, -32601, -32602 +HEADER_MISMATCH, UNSUPPORTED_VERSION = -32020, -32022 + + +def _loopback_host(name): + if not name: + return False + name = name.strip("[]").lower() + if name in LOCAL_NAMES: + return True + try: + return ipaddress.ip_address(name).is_loopback + except ValueError: + return False + + +def _is_local(request): + """The request comes from this computer, directly, and names a loopback host.""" + if not _loopback_host(request.remote or ""): + return False + if any(header in request.headers for header in PROXY_HEADERS): + return False + return _loopback_host(urllib.parse.urlsplit("//" + request.headers.get("Host", "")).hostname) + + +def _origin_ok(request): + origin = request.headers.get("Origin") + return origin is None or _loopback_host(urllib.parse.urlsplit(origin).hostname) + + +def _decode_header(value): + """Mcp-Name values outside plain ASCII come as =?base64?…?=.""" + if value and value.startswith("=?base64?") and value.endswith("?="): + try: + return base64.b64decode(value[9:-2], validate=True).decode("utf-8") + except (ValueError, UnicodeDecodeError): + return None + return value + + +def _error(id_, code, message, status=200, data=None): + error = {"code": code, "message": message} + if data is not None: + error["data"] = data + body = {"jsonrpc": "2.0", "error": error} + if id_ is not None: + body["id"] = id_ + return web.json_response(body, status=status) + + +def _result(id_, result, modern): + if modern: + result = {"resultType": "complete", **result} + return web.json_response({"jsonrpc": "2.0", "id": id_, "result": result}) + + +def _tool_list(modern): + result = {"tools": [tool["spec"] for tool in TOOLS.values()]} + if modern: + result.update(ttlMs=LIST_TTL_MS, cacheScope="public") + return result + + +def _initialize(params): + requested = params.get("protocolVersion") + return { + "protocolVersion": requested if requested in LEGACY_VERSIONS else LEGACY_VERSIONS[0], + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": SERVER_INFO, + "instructions": INSTRUCTIONS, + } + + +def _discover(): + return { + "supportedVersions": list(MODERN_VERSIONS + LEGACY_VERSIONS), + "capabilities": {"tools": {"listChanged": False}}, + "_meta": {"io.modelcontextprotocol/serverInfo": SERVER_INFO}, + "instructions": INSTRUCTIONS, + "ttlMs": LIST_TTL_MS, + "cacheScope": "public", + } + + +def _modern_header_problem(request, method, params, version): + """Why the mirrored headers do not match the body (2026-07-28), or None.""" + if request.headers.get("MCP-Protocol-Version") != version: + return "MCP-Protocol-Version header missing or not the body's protocol version" + if request.headers.get("Mcp-Method") != method: + return "Mcp-Method header missing or not the body's method" + if method in ("tools/call", "prompts/get", "resources/read"): + name = params.get("uri") if method == "resources/read" else params.get("name") + if _decode_header(request.headers.get("Mcp-Name")) != name: + return "Mcp-Name header missing or not the body's name" + return None + + +async def _call(params): + name = params.get("name") + arguments = params.get("arguments") or {} + if name not in TOOLS or not isinstance(arguments, dict): + return None + async with _slots: + result = await asyncio.to_thread(call_tool, name, arguments) + return result + + +async def api_mcp(request): + """POST /anomalous/mcp - one JSON-RPC request or notification per POST.""" + if request.method != "POST": + return web.Response(status=405, headers={"Allow": "POST"}) + if not _is_local(request) or not _origin_ok(request): + return _error(None, INVALID_REQUEST, "Only this computer may use the Anomalous MCP endpoint.", status=403) + try: + body = await request.json() + except (ValueError, UnicodeDecodeError): + return _error(None, PARSE_ERROR, "Body is not JSON", status=400) + if not isinstance(body, dict) or body.get("jsonrpc") != "2.0" or not isinstance(body.get("method"), str): + return _error(None, INVALID_REQUEST, "Expected one JSON-RPC 2.0 request (batches are not supported)", status=400) + if "id" not in body: + return web.Response(status=202) # notifications (initialized, cancelled) need no answer + id_, method = body["id"], body["method"] + params = body.get("params") if isinstance(body.get("params"), dict) else {} + + if method == "initialize": + return _result(id_, _initialize(params), modern=False) + meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {} + version = meta.get("io.modelcontextprotocol/protocolVersion") + modern = version is not None + if modern: + if version not in MODERN_VERSIONS: + return _error(id_, UNSUPPORTED_VERSION, "Unsupported protocol version", status=400, + data={"supported": list(MODERN_VERSIONS + LEGACY_VERSIONS), "requested": version}) + problem = _modern_header_problem(request, method, params, version) + if problem: + return _error(id_, HEADER_MISMATCH, f"Header mismatch: {problem}", status=400) + else: + header = request.headers.get("MCP-Protocol-Version") + if header is not None and header not in LEGACY_VERSIONS: + return _error(id_, INVALID_REQUEST, f"Unsupported protocol version {header}", status=400, + data={"supported": list(MODERN_VERSIONS + LEGACY_VERSIONS), "requested": header}) + + if method == "server/discover" and modern: + return _result(id_, _discover(), modern) + if method == "ping": + return _result(id_, {}, modern) + if method == "tools/list": + return _result(id_, _tool_list(modern), modern) + if method == "tools/call": + result = await _call(params) + 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) + + +def register_routes(app): + app.router.add_route("*", "/anomalous/mcp", api_mcp) diff --git a/api/mcp_tools.py b/api/mcp_tools.py new file mode 100644 index 0000000..e4706d6 --- /dev/null +++ b/api/mcp_tools.py @@ -0,0 +1,473 @@ +"""The tools the MCP endpoint (mcp_server.py) offers: read-only views of the library. + +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 +results, activity log). They never write. Results are small, already-summarised JSON (an AI +reads them), with ids the detail tools take back: model_id "type:path_idx:relative/path" and +image "subfolder/file.png" under ComfyUI's output folder. A tool's mistake (bad id, missing +file) comes back as a tool error the AI can read and correct, never as an exception. +""" + +import base64 +import datetime +import html +import json +import logging +import os +import re + +import folder_paths + +from .activity_log import list_entries +from .audio_catalog import _gallery_page as _audio_page +from .gallery_routes import _gallery_listing +from .image_search import read_png_text +from .kept_images import _kept_images +from .material_store import _list_materials, _read_material, get_materials_dir +from .media_routes import _build_card_thumbnail +from .metadata import get_metadata +from .model_catalog import _iter_search_models +from .model_constants import MEDIA_EXTENSIONS, MODEL_EXTENSIONS, PREVIEW_SUFFIXES +from .notebooks import _list_notebooks +from .path_utils import resolve_folder_subdir, resolve_within +from .recipe_store import _list_recipes, get_recipes_dir +from .scan_report import last_scan_path +from .scan_summary import _summarize + +MAX_LIMIT = 50 +MAX_TEXT = 1500 +MAX_IMAGE_BYTES = 1_500_000 +IMAGE_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"} +SOURCES = {"civitai": "civitai", "local": "inferred_from_file", "": "not_scanned"} + + +class ToolError(Exception): + """A problem the AI can fix (bad id, nothing there); shown to it as the tool's result.""" + + +# ---------------------------------------------------------------- helpers + +def _text(value, limit=MAX_TEXT): + """Plain text from HTML-ish Civitai descriptions, shortened.""" + value = html.unescape(re.sub(r"<[^>]+>", " ", str(value or ""))) + value = re.sub(r"\s+", " ", value).strip() + return value if len(value) <= limit else value[:limit] + "…" + + +def _when(seconds): + try: + return datetime.datetime.fromtimestamp(float(seconds)).isoformat(timespec="seconds") + except (TypeError, ValueError, OverflowError, OSError): + return None + + +def _limit(value, default=20): + try: + return max(1, min(MAX_LIMIT, int(value))) + except (TypeError, ValueError): + return default + + +def _terms(query): + return [term for term in str(query or "").lower().split() if term] + + +def _matches(terms, *texts): + haystack = " ".join(str(text or "") for text in texts).lower() + return all(term in haystack for term in terms) + + +def _model_id(folder_type, path_idx, base_dir, file_path): + return f"{folder_type}:{path_idx}:{os.path.relpath(file_path, base_dir).replace(os.sep, '/')}" + + +def _resolve_model(model_id): + """(type, path_idx, base_dir, file_path) for a model_id from search_models.""" + parts = str(model_id or "").split(":", 2) + if len(parts) != 3: + raise ToolError("model_id looks like 'loras:0:folder/file.safetensors'; take it from search_models.") + folder_type, path_idx, rel = parts + rel = rel.replace("\\", "/").strip("/") + subfolder, filename = os.path.split(rel) + try: + path_idx = int(path_idx) + base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, "/" + subfolder) + file_path = resolve_within(target_dir, filename) + except (ValueError, KeyError, TypeError): + raise ToolError(f"No models folder for {model_id}.") + if not filename.lower().endswith(MODEL_EXTENSIONS) or not os.path.isfile(file_path): + raise ToolError(f"No model file at {model_id}; it may have been moved or renamed.") + return folder_type, path_idx, base_dir, file_path + + +def _resolve_image(image): + """The output image at `image` ('subfolder/file.png'), checked to stay inside the output folder.""" + rel = str(image or "").replace("\\", "/").strip("/") + try: + path = resolve_within(folder_paths.get_output_directory(), rel) + except ValueError: + raise ToolError("image is a path under ComfyUI's output folder, as search_images returns it.") + if os.path.splitext(path)[1].lower() not in IMAGE_TYPES or not os.path.isfile(path): + raise ToolError(f"No output image at {image}.") + return rel, path + + +def _image_content(path): + """An MCP image item: the 512 px thumbnail the gallery uses, or None when too big or unreadable.""" + try: + thumb = _build_card_thumbnail(path) + with open(thumb, "rb") as source: + data = source.read(MAX_IMAGE_BYTES + 1) + except OSError: + return None + if len(data) > MAX_IMAGE_BYTES: + return None + mime = IMAGE_TYPES.get(os.path.splitext(thumb)[1].lower(), "image/webp") + return {"type": "image", "data": base64.b64encode(data).decode("ascii"), "mimeType": mime} + + +def _kept_lookup(): + return {(item["subfolder"], item["filename"]): {kind: (value or {}).get("name") for kind, value in item.items() + if kind in ("recipe", "combo", "prompt") and value} + for item in _kept_images()} + + +def _model_brief(folder_type, path_idx, base_dir, file_path, meta): + rel_dir = os.path.relpath(os.path.dirname(file_path), base_dir).replace(os.sep, "/") + try: + size_mb = round(os.path.getsize(file_path) / 1048576, 1) + except OSError: + size_mb = None + return { + "model_id": _model_id(folder_type, path_idx, base_dir, file_path), + "name": meta.get("custom_name") or meta.get("name") or os.path.basename(file_path), + "file": os.path.basename(file_path), + "type": folder_type, + "folder": "" if rel_dir == "." else rel_dir, + "base_model": meta.get("baseModel") or "", + "source": SOURCES.get(meta.get("info_source", ""), "not_scanned"), + "trigger_words": list(meta.get("trainedWords") or [])[:8], + "size_mb": size_mb, + } + + +def _each_model(): + """Every model file once (folders registered twice, like unet and diffusion_models, count once). + ComfyUI also registers custom_nodes, whose node packs carry files of their own: not the library.""" + seen = set() + for folder_type, path_idx, base_dir, file_path in _iter_search_models(): + if folder_type == "custom_nodes" or not file_path.lower().endswith(MODEL_EXTENSIONS): + continue + real = os.path.normcase(os.path.realpath(file_path)) + if real in seen: + continue + seen.add(real) + yield folder_type, path_idx, base_dir, file_path + + +# ---------------------------------------------------------------- models and scans + +def search_models(query="", type="", base_model="", source="", limit=20): + terms, wanted_base, found = _terms(query), str(base_model or "").lower(), [] + total = 0 + for folder_type, path_idx, base_dir, file_path in _each_model(): + if type and folder_type != type: + continue + meta = get_metadata(file_path) + if wanted_base and wanted_base not in str(meta.get("baseModel") or "").lower(): + continue + if source and SOURCES.get(meta.get("info_source", ""), "not_scanned") != source: + continue + if not _matches(terms, os.path.relpath(file_path, base_dir), meta.get("name"), meta.get("custom_name"), + meta.get("baseModel"), " ".join(meta.get("trainedWords") or [])): + continue + total += 1 + if len(found) < _limit(limit): + found.append(_model_brief(folder_type, path_idx, base_dir, file_path, meta)) + return {"total_matches": total, "models": found} + + +def get_model(model_id, include_cover=False): + folder_type, path_idx, base_dir, file_path = _resolve_model(model_id) + meta = get_metadata(file_path) + detail = _model_brief(folder_type, path_idx, base_dir, file_path, meta) + detail.update({ + "trigger_words": list(meta.get("trainedWords") or []), + "civitai_name": meta.get("name") or "", + "civitai_url": meta.get("civitai_url") or "", + "download_link": meta.get("source_url") or "", + "user_notes": meta.get("custom_notes") or "", + "fields_set_by_user": list(meta.get("user_fields") or []), + "description": _text(meta.get("description")), + "sha256": meta.get("hash") or "", + }) + if meta.get("info_source") == "local": + detail["why_not_on_civitai"] = meta.get("unmatched_reason") or "unknown" + extra = [] + if include_cover: + base = os.path.splitext(file_path)[0] + cover = next((base + suffix for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS + if os.path.splitext(suffix)[1].lower() in IMAGE_TYPES and os.path.isfile(base + suffix)), None) + item = _image_content(cover) if cover else None + detail["cover"] = "attached" if item else "none" + extra = [item] if item else [] + return detail, extra + + +def library_overview(): + by_type, by_base = {}, {} + for folder_type, _idx, _base, file_path in _each_model(): + by_type[folder_type] = by_type.get(folder_type, 0) + 1 + base = get_metadata(file_path).get("baseModel") or "unknown" + by_base[base] = by_base.get(base, 0) + 1 + summary = _summarize() + return { + "models_by_type": dict(sorted(by_type.items(), key=lambda item: -item[1])), + "models_by_base_model": dict(sorted(by_base.items(), key=lambda item: -item[1])[:20]), + "scan": {key: summary[key] for key in ("total", "matched", "unmatched", "new", "pending")}, + "combos": len(_list_notebooks()), + "workflow_recipes": len(_list_recipes(get_recipes_dir())), + "saved_prompts": sum(1 for item in _list_materials(get_materials_dir()) if item.get("kind") == "prompt_plan"), + } + + +def scan_report(): + summary = _summarize() + try: + with open(last_scan_path(), encoding="utf-8") as source: + last = json.load(source) + except (OSError, ValueError): + last = None + report = { + "counts": {key: summary[key] for key in ("total", "matched", "unmatched", "new", "pending")}, + "meaning": "matched: found on Civitai; unmatched: base model inferred from the file (reason says why); " + "new: never scanned; pending: unmatched only because Civitai was not asked (offline or no connection).", + "unmatched_models": summary["unmatched_models"][:MAX_LIMIT], + "not_scanned_models": summary["new_models"][:MAX_LIMIT], + } + if isinstance(last, dict) and last.get("counts"): + report["last_scan"] = {"finished": _when(last.get("finished")), "kind": last.get("kind"), + "counts": last.get("counts"), "civitai_unreachable": bool(last.get("civitai_down")), + "models": (last.get("files") or [])[:30]} + return report + + +# ---------------------------------------------------------------- outputs + +def search_images(query="", limit=20, page=1): + images = _gallery_listing(folder_paths.get_output_directory(), False, str(query or "").strip()) + limit = _limit(limit) + try: + page = max(1, int(page)) + except (TypeError, ValueError): + page = 1 + kept = _kept_lookup() + chosen = images[(page - 1) * limit:page * limit] + return { + "total_matches": len(images), "page": page, + "images": [{"image": "/".join(part for part in (item["subfolder"], item["filename"]) if part), + "created": _when(item.get("mtime")), + **({"kept_as": kept[(item["subfolder"], item["filename"])]} + if (item["subfolder"], item["filename"]) in kept else {})} + for item in chosen], + } + + +def get_image_info(image, include_image=False): + rel, path = _resolve_image(image) + info = {"image": rel, "created": _when(os.path.getmtime(path))} + chunks = read_png_text(path) if path.lower().endswith(".png") else {} + try: + prompt = json.loads(chunks.get("prompt") or "null") + except ValueError: + prompt = None + if isinstance(prompt, dict): + nodes, models = [], set() + for node_id, node in list(prompt.items())[:80]: + if not isinstance(node, dict): + continue + inputs = {} + for key, value in (node.get("inputs") or {}).items(): + if isinstance(value, str): + inputs[key] = _text(value, 2000) if len(value) > 2000 else value + if value.lower().endswith(MODEL_EXTENSIONS): + models.add(value.replace("\\", "/")) + elif isinstance(value, (int, float, bool)): + inputs[key] = value + nodes.append({"id": node_id, "type": node.get("class_type"), "inputs": inputs}) + info.update(models_used=sorted(models), nodes=nodes) + elif chunks.get("parameters"): + info["parameters"] = _text(chunks["parameters"], 4000) + else: + info["note"] = "No generation settings are stored in this image." + kept = _kept_lookup().get(tuple(rel.rsplit("/", 1)) if "/" in rel else ("", rel)) + if kept: + info["kept_as"] = kept + extra = [] + if include_image: + item = _image_content(path) + info["image_attached"] = bool(item) + extra = [item] if item else [] + return info, extra + + +def search_audio(query="", limit=20): + items, total, _more = _audio_page(folder_paths.get_output_directory(), 1, _limit(limit), str(query or "").lower().strip()) + return {"total_matches": total, "audio": [{ + "file": "/".join(part for part in (item["subfolder"], item["filename"]) if part), + "created": _when(item.get("mtime")), + "speech": (item.get("generation") or {}).get("speech") or "", + "voice_sample": (item.get("generation") or {}).get("sample") or "", + "seed": (item.get("generation") or {}).get("seed"), + } for item in items]} + + +# ---------------------------------------------------------------- what the user kept + +def _model_name(model): + if isinstance(model, dict): + return model.get("filename") or model.get("name") or "" + return str(model or "") + + +def list_combos(query="", limit=20): + terms, found = _terms(query), [] + for note in _list_notebooks(): + data = note.get("data") or {} + loras = [_model_name(lora) for lora in data.get("loras") or []] + combo = {"name": note.get("name"), "base_model": data.get("baseModel") or "", + "main_model": _model_name(data.get("mainModel")), "loras": loras, + "prompt": _text(data.get("promptEn"), 800)} + if not _matches(terms, combo["name"], combo["main_model"], combo["prompt"], " ".join(loras)): + continue + found.append(combo) + return {"total_matches": len(found), "combos": found[:_limit(limit)]} + + +def list_workflows(query="", limit=20): + terms, found = _terms(query), [] + for recipe in _list_recipes(get_recipes_dir()): + data = recipe.get("data") or {} + params = json.dumps(data.get("params") or {}, ensure_ascii=False) + if not _matches(terms, data.get("name"), " ".join(data.get("tags") or []), data.get("notes"), params): + continue + found.append({"name": data.get("name") or recipe["filename"], "tags": data.get("tags") or [], + "notes": _text(data.get("notes"), 500), "saved": _when((data.get("timestamp") or 0) / 1000), + "settings": params if len(params) <= MAX_TEXT else params[:MAX_TEXT] + "…"}) + return {"total_matches": len(found), "workflows": found[:_limit(limit)]} + + +def list_saved_prompts(query="", limit=20): + terms, found = _terms(query), [] + directory = get_materials_dir() + for summary in _list_materials(directory): + if summary.get("kind") != "prompt_plan" or summary.get("moved_to_recipe"): + continue + try: + plan = _read_material(os.path.join(directory, summary["filename"])).get("plan") or {} + except (OSError, ValueError, KeyError): + continue + prompt = {"name": summary.get("name"), "tags": summary.get("tags") or [], + "positive": _text(plan.get("positive"), 1200), "negative": _text(plan.get("negative"), 600)} + if _matches(terms, prompt["name"], " ".join(prompt["tags"]), prompt["positive"], prompt["negative"]): + found.append(prompt) + return {"total_matches": len(found), "prompts": found[:_limit(limit)]} + + +def recent_activity(limit=20, source=""): + entries = list_entries(_limit(limit), source if source in ("canvas", "file") else None) + return {"entries": [{"time": _when(entry.get("time")), "source": entry.get("source"), "action": entry.get("action"), + "target": entry.get("target") or "", "detail": entry.get("detail") or {}} for entry in entries]} + + +# ---------------------------------------------------------------- registry + +def _schema(properties=None, required=()): + schema = {"type": "object", "properties": properties or {}, "additionalProperties": False} + if required: + schema["required"] = list(required) + return schema + + +_QUERY = {"type": "string", "description": "Words that must all appear (case-insensitive); empty lists everything."} +_LIMIT = {"type": "integer", "minimum": 1, "maximum": MAX_LIMIT, "default": 20} + + +def _tool(name, title, description, run, properties=None, required=()): + return name, {"run": run, "spec": { + "name": name, "title": title, "description": description, + "inputSchema": _schema(properties, required), + "annotations": {"title": title, "readOnlyHint": True, "destructiveHint": False, + "idempotentHint": True, "openWorldHint": False}, + }} + + +TOOLS = dict([ + _tool("library_overview", "Library overview", + "How many models of each type and base model the user has, how many are scanned, and how many " + "combos, workflow recipes and saved prompts they kept. A good first call.", library_overview), + _tool("search_models", "Search models", + "Find model files (checkpoints, LoRAs, VAEs…) by name, trigger word or base model. Returns model_id for get_model.", + search_models, { + "query": _QUERY, + "type": {"type": "string", "description": "ComfyUI models folder, e.g. checkpoints, loras, vae, diffusion_models."}, + "base_model": {"type": "string", "description": "Part of the base model name, e.g. SDXL, Pony, Flux, SD 1.5."}, + "source": {"type": "string", "enum": ["civitai", "inferred_from_file", "not_scanned"], + "description": "Where the model's information came from."}, + "limit": _LIMIT}), + _tool("get_model", "Model details", + "Everything known about one model: Civitai name and link, trigger words, base model, description, " + "the user's own name and notes. include_cover attaches its cover picture.", + get_model, {"model_id": {"type": "string", "description": "From search_models."}, + "include_cover": {"type": "boolean", "default": False}}, ["model_id"]), + _tool("scan_report", "Scan status", + "Which models are matched on Civitai, which are not and why, which were never scanned, and what the last scan did.", + scan_report), + _tool("search_images", "Search output images", + "Find images ComfyUI generated, newest first, by words in their prompt, model or settings (e.g. a LoRA " + "name or 'seed:123'). Returns image paths for get_image_info.", + search_images, {"query": _QUERY, "limit": _LIMIT, "page": {"type": "integer", "minimum": 1, "default": 1}}), + _tool("get_image_info", "Image generation settings", + "The nodes and settings that produced an output image (prompts, models, seed, sampler…) and whether the " + "user kept it as a recipe, combo or prompt. include_image attaches a 512 px copy.", + get_image_info, {"image": {"type": "string", "description": "From search_images, e.g. 'ComfyUI_00012_.png'."}, + "include_image": {"type": "boolean", "default": False}}, ["image"]), + _tool("list_combos", "Combos", + "The user's combos (搭配): a main model, LoRAs and a prompt kept together.", list_combos, + {"query": _QUERY, "limit": _LIMIT}), + _tool("list_workflows", "Workflow recipes", + "The user's saved workflow recipes with tags, notes and their main settings.", list_workflows, + {"query": _QUERY, "limit": _LIMIT}), + _tool("list_saved_prompts", "Saved prompts", + "Prompts the user saved in the Prompt Studio, with positive and negative text.", list_saved_prompts, + {"query": _QUERY, "limit": _LIMIT}), + _tool("search_audio", "Generated audio", + "Speech the user generated (Anomalous TTS), newest first, with its text, voice and seed.", search_audio, + {"query": _QUERY, "limit": _LIMIT}), + _tool("recent_activity", "Activity log", + "What Anomalous changed recently, newest first: canvas edits it made and files it saved, scanned or deleted.", + recent_activity, {"limit": _LIMIT, "source": {"type": "string", "enum": ["canvas", "file"]}}), +]) + + +def call_tool(name, arguments): + """An MCP tools/call result for one tool (run in a worker thread).""" + spec = TOOLS[name]["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) + 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 + logging.getLogger(__name__).warning("Anomalous MCP tool %s failed", name, exc_info=True) + return _failure(f"{name} failed: {error}") + data, extra = value if isinstance(value, tuple) else (value, []) + return {"content": [{"type": "text", "text": json.dumps(data, ensure_ascii=False)}, *extra], + "structuredContent": data, "isError": False} + + +def _failure(message): + return {"content": [{"type": "text", "text": message}], "isError": True} diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index d5ea217..082154e 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -145,6 +145,28 @@ wait for "look up again". After `CIVITAI_DOWN_AFTER` models in a row without an a scan stops asking (`--civitai-down` carries this to the next folders) and infers the rest from the files, instead of waiting for every timeout. +## MCP endpoint + +`POST /anomalous/mcp` (`api/mcp_server.py`) answers one JSON-RPC message per POST with +one JSON object; GET and DELETE get 405, and nothing is sent unasked. A request whose +`params._meta` carries `io.modelcontextprotocol/protocolVersion` is served as 2026-07-28: +the `MCP-Protocol-Version`, `Mcp-Method` and (for `tools/call`) `Mcp-Name` headers must +match the body, or 400 with -32020; an unknown version gets 400 with -32022 and the +supported list. `initialize` selects the legacy shape (2025-03-26 to 2025-11-25); no +session id is minted, so both eras are stateless. Unknown methods are -32601 (404 for +modern requests). + +The peer must be loopback with no proxy header (`Forwarded`, `X-Forwarded-*`, +`X-Real-IP`), and the Host and any Origin must name a loopback host; anything else is +403. ComfyUI's own origin middleware also applies. + +Tools (`api/mcp_tools.py`) only read and run in worker threads, four at a time. Model ids +are `type:path_idx:relative/path` and images are paths under the output folder; both are +resolved with `resolve_folder_subdir` / `resolve_within`, so an id cannot leave its root. +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). + ## Metadata and cache behavior The output gallery keeps one ordered directory snapshot for at most ten seconds diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md new file mode 100644 index 0000000..ec99f1f --- /dev/null +++ b/docs/guides/mcp.md @@ -0,0 +1,92 @@ +# 让 AI 读你的模型库(MCP) + +支持 MCP 的 AI 软件(Claude、Cursor、Cherry Studio、VS Code 等)连上以后,就能直接查你的 ComfyUI 资料:有哪些模型和触发词、某张图当时用的什么参数、存过哪些搭配和提示词,然后据此回答你的问题或者帮你写提示词。 + +**它只读,不改任何东西。** 想改什么,AI 会告诉你去插件的哪个页面改。 + +English summary at the end. + +--- + +## 1. 地址 + +ComfyUI 开着的时候,插件自动提供: + +``` +http://127.0.0.1:8188/anomalous/mcp +``` + +`8188` 换成你 ComfyUI 实际的端口。不需要装别的东西,也不占内存和显存:没人连接的时候它什么也不做。 + +**只有运行 ComfyUI 的这台电脑能连。** 别的电脑、经过反向代理的请求、网页里的脚本都会被拒绝。 + +## 2. 在 AI 软件里添加 + +**Cherry Studio**:设置 → MCP → 添加 → 快速创建,类型选 **可流式传输的 HTTP(streamableHttp)**,URL 填上面的地址。需要较新的版本。 + +**Claude Code**: + +```bash +claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp +``` + +**Cursor**:在 `~/.cursor/mcp.json` 里加: + +```json +{ "mcpServers": { "anomalous": { "url": "http://127.0.0.1:8188/anomalous/mcp" } } } +``` + +**VS Code(Copilot)**:在项目的 `.vscode/mcp.json` 里加: + +```json +{ "servers": { "anomalous": { "type": "http", "url": "http://127.0.0.1:8188/anomalous/mcp" } } } +``` + +**Claude 桌面版**:它的配置文件只能启动本地程序,要借 `mcp-remote` 转一下(需要装 Node.js)。在 `claude_desktop_config.json` 里加: + +```json +{ + "mcpServers": { + "anomalous": { + "command": "npx", + "args": ["mcp-remote", "http://127.0.0.1:8188/anomalous/mcp", "--transport", "http-only"] + } + } +} +``` + +## 3. 可以这样问 + +- “我有哪些 Flux 的 LoRA?各自的触发词是什么?” +- “找找用过『某某』这个 LoRA 的出图,看看当时的种子和采样器。” +- “哪些模型还没在 C 站匹配上?为什么?” +- “用我库里合适的模型和 LoRA,帮我写一段赛博朋克夜景的提示词。” +- “我上周存的那个搭配用的是哪个底模?” + +## 4. 能查什么 + +| 工具 | 查什么 | +|---|---| +| library_overview | 各类模型的数量、底模分布、扫描情况,存了多少搭配、工作流和提示词 | +| search_models / get_model | 按名字、触发词、底模、来源找模型;看某个模型的 C 站信息、描述、你写的备注,可以附上封面 | +| scan_report | 哪些模型匹配上了 C 站,哪些没有、原因是什么,哪些还没扫,上次扫描做了什么 | +| search_images / get_image_info | 按提示词、模型、参数找出图;看某张图的全部生成参数,可以附上缩略图 | +| list_combos | 你的搭配(主模型 + LoRA + 提示词) | +| list_workflows | 你的工作流配方,带标签、备注和主要设置 | +| list_saved_prompts | 提示词工坊里存的提示词 | +| search_audio | 用 Anomalous TTS 生成过的语音,带台词和角色 | +| recent_activity | 插件最近改过什么(操作记录) | + +## 5. 注意 + +- **隐私**:如果 AI 软件用的是云端模型,查询结果(模型名、提示词、图片的生成参数)会发给那家 AI 服务商。附上封面或缩略图时,图片也会一起发过去。介意的话可以用本地模型,或者不让 AI 附图。 +- 附图时会用到插件自己的缩略图缓存,和浏览图库时一样,不动你的原图。 +- 改模型名、扫描、往工作流里加 LoRA 这类操作,目前还得在插件里自己点。 + +--- + +## English + +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 `; Cursor and VS Code: a `url` / `"type": "http"` entry; Claude Desktop: through `npx mcp-remote --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.