iflow-mcp_orion4d-comfyui_mcp 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iflow_mcp_orion4d_comfyui_mcp/__init__.py +9 -0
- iflow_mcp_orion4d_comfyui_mcp/__main__.py +8 -0
- iflow_mcp_orion4d_comfyui_mcp/browser_controller.py +123 -0
- iflow_mcp_orion4d_comfyui_mcp/comfyui_client.py +301 -0
- iflow_mcp_orion4d_comfyui_mcp/generate_key.py +158 -0
- iflow_mcp_orion4d_comfyui_mcp/server.py +1027 -0
- iflow_mcp_orion4d_comfyui_mcp-1.0.0.dist-info/METADATA +239 -0
- iflow_mcp_orion4d_comfyui_mcp-1.0.0.dist-info/RECORD +11 -0
- iflow_mcp_orion4d_comfyui_mcp-1.0.0.dist-info/WHEEL +4 -0
- iflow_mcp_orion4d_comfyui_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_orion4d_comfyui_mcp-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1027 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ComfyUI MCP Server - Version propre et stable (avec outils admin)
|
|
3
|
+
- Outils ComfyUI (HTTP) via client synchrone
|
|
4
|
+
- Gestion workflows locaux
|
|
5
|
+
- Contrôle UI via WebSocket (optionnel)
|
|
6
|
+
- Middleware API Key
|
|
7
|
+
- Health check intégré
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import warnings
|
|
11
|
+
warnings.filterwarnings('ignore', category=DeprecationWarning)
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import json
|
|
17
|
+
import uuid
|
|
18
|
+
import logging
|
|
19
|
+
import atexit
|
|
20
|
+
import signal
|
|
21
|
+
from typing import Any, Dict
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from datetime import datetime, timedelta
|
|
24
|
+
from dotenv import load_dotenv
|
|
25
|
+
from urllib.parse import quote
|
|
26
|
+
from base64 import b64decode, b64encode
|
|
27
|
+
|
|
28
|
+
def _sha256_of_file(path):
|
|
29
|
+
import hashlib
|
|
30
|
+
h = hashlib.sha256()
|
|
31
|
+
with open(path, "rb") as f:
|
|
32
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
33
|
+
h.update(chunk)
|
|
34
|
+
return h.hexdigest()
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------
|
|
37
|
+
# Configuration AVANT FastMCP
|
|
38
|
+
# ---------------------------------------------------------------------
|
|
39
|
+
load_dotenv()
|
|
40
|
+
|
|
41
|
+
COMFYUI_BASE_URL = os.getenv("COMFYUI_BASE_URL", "http://127.0.0.1:8188")
|
|
42
|
+
API_KEY = os.getenv("MCP_API_KEY")
|
|
43
|
+
WORKFLOWS_DIR = Path(__file__).parent / "workflows"
|
|
44
|
+
WORKFLOWS_DIR.mkdir(exist_ok=True)
|
|
45
|
+
ENABLE_BROWSER_CONTROL = os.getenv("ENABLE_BROWSER_CONTROL", "true").lower() == "true"
|
|
46
|
+
WEBSOCKET_TOKEN = os.getenv("WEBSOCKET_TOKEN")
|
|
47
|
+
|
|
48
|
+
# Chemins ComfyUI
|
|
49
|
+
COMFYUI_ROOT = Path(os.getenv("COMFYUI_ROOT", "")).resolve() if os.getenv("COMFYUI_ROOT") else None
|
|
50
|
+
CUSTOM_NODES_DIR = (COMFYUI_ROOT / "custom_nodes") if COMFYUI_ROOT else None
|
|
51
|
+
MCP_DROP_DIR = (CUSTOM_NODES_DIR / "mcp_drop") if CUSTOM_NODES_DIR else None
|
|
52
|
+
MODELS_DIR = (COMFYUI_ROOT / "models") if COMFYUI_ROOT else None
|
|
53
|
+
|
|
54
|
+
# LIGNES DE DEBUG
|
|
55
|
+
print("COMFYUI_ROOT =", COMFYUI_ROOT)
|
|
56
|
+
print("CUSTOM_NODES_DIR =", CUSTOM_NODES_DIR)
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------
|
|
59
|
+
# Logging
|
|
60
|
+
# ---------------------------------------------------------------------
|
|
61
|
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
62
|
+
logger = logging.getLogger("ComfyUIMCP")
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------
|
|
65
|
+
# Imports FastMCP APRÈS configuration
|
|
66
|
+
# ---------------------------------------------------------------------
|
|
67
|
+
from fastmcp import FastMCP
|
|
68
|
+
from fastapi import Request, HTTPException, WebSocket, WebSocketDisconnect
|
|
69
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
70
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
71
|
+
from starlette.responses import JSONResponse
|
|
72
|
+
from starlette.routing import WebSocketRoute
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------
|
|
75
|
+
# Helpers (AVANT les outils)
|
|
76
|
+
# ---------------------------------------------------------------------
|
|
77
|
+
def _require_root(root: Path, label: str):
|
|
78
|
+
if not root or not root.exists():
|
|
79
|
+
raise FileNotFoundError(f"{label} introuvable. Définis COMFYUI_ROOT dans .env (ex: D:\\ComfyUI).")
|
|
80
|
+
|
|
81
|
+
def _sanitize_filename(name: str, ext: str = ".py") -> str:
|
|
82
|
+
base = name.strip()
|
|
83
|
+
if re.search(r"[\\/]|\.\.", base):
|
|
84
|
+
raise ValueError("Nom de fichier invalide.")
|
|
85
|
+
if not base.endswith(ext):
|
|
86
|
+
base += ext
|
|
87
|
+
if not re.fullmatch(r"[A-Za-z0-9_\-\.]{1,128}", base):
|
|
88
|
+
raise ValueError("Nom de fichier non autorisé (A-Za-z0-9_-. uniquement, 128 chars max).")
|
|
89
|
+
return base
|
|
90
|
+
|
|
91
|
+
def _safe_join(root: Path, *parts: str) -> Path:
|
|
92
|
+
# Autorise les chemins enfants même si 'output' est un symlink/junction
|
|
93
|
+
p_text = root.joinpath(*parts) # chemin "logique" (sans resolve)
|
|
94
|
+
p_real = p_text.resolve() # chemin résolu (peut pointer hors du root si symlink)
|
|
95
|
+
root_text = root
|
|
96
|
+
root_real = root.resolve()
|
|
97
|
+
|
|
98
|
+
# OK si:
|
|
99
|
+
# 1) le chemin résolu est sous le root résolu (cas sans symlink), OU
|
|
100
|
+
# 2) le chemin textuel est sous le root textuel (on accepte les junction/symlink enfants)
|
|
101
|
+
p_text_str = str(p_text)
|
|
102
|
+
root_text_str = str(root_text)
|
|
103
|
+
p_real_str = str(p_real)
|
|
104
|
+
root_real_str = str(root_real)
|
|
105
|
+
|
|
106
|
+
if p_real_str.startswith(root_real_str) or p_text_str.startswith(root_text_str):
|
|
107
|
+
return p_real
|
|
108
|
+
|
|
109
|
+
raise PermissionError("Chemin hors de la zone autorisée.")
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------
|
|
112
|
+
# Rate Limiter
|
|
113
|
+
# ---------------------------------------------------------------------
|
|
114
|
+
class RateLimiter:
|
|
115
|
+
def __init__(self, max_requests: int = 30, window_seconds: int = 60):
|
|
116
|
+
self.max_requests = max_requests
|
|
117
|
+
self.window = timedelta(seconds=window_seconds)
|
|
118
|
+
self.requests = {}
|
|
119
|
+
|
|
120
|
+
def is_allowed(self, client_id: str) -> bool:
|
|
121
|
+
now = datetime.now()
|
|
122
|
+
entries = [t for t in self.requests.get(client_id, []) if now - t < self.window]
|
|
123
|
+
if len(entries) >= self.max_requests:
|
|
124
|
+
self.requests[client_id] = entries
|
|
125
|
+
return False
|
|
126
|
+
entries.append(now)
|
|
127
|
+
self.requests[client_id] = entries
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
def reset(self, client_id: str):
|
|
131
|
+
self.requests.pop(client_id, None)
|
|
132
|
+
|
|
133
|
+
rate_limiter = RateLimiter()
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------
|
|
136
|
+
# WebSocket Manager
|
|
137
|
+
# ---------------------------------------------------------------------
|
|
138
|
+
class ConnectionManager:
|
|
139
|
+
def __init__(self):
|
|
140
|
+
self.active_connections: list[WebSocket] = []
|
|
141
|
+
self.authenticated_connections: dict[WebSocket, dict] = {}
|
|
142
|
+
|
|
143
|
+
async def connect(self, websocket: WebSocket, client_info: dict):
|
|
144
|
+
await websocket.accept()
|
|
145
|
+
self.active_connections.append(websocket)
|
|
146
|
+
self.authenticated_connections[websocket] = client_info
|
|
147
|
+
|
|
148
|
+
def disconnect(self, websocket: WebSocket):
|
|
149
|
+
if websocket in self.active_connections:
|
|
150
|
+
self.active_connections.remove(websocket)
|
|
151
|
+
if websocket in self.authenticated_connections:
|
|
152
|
+
del self.authenticated_connections[websocket]
|
|
153
|
+
|
|
154
|
+
async def send_command(self, command: dict):
|
|
155
|
+
if not ENABLE_BROWSER_CONTROL:
|
|
156
|
+
return {"status": "disabled", "message": "Browser control is disabled"}
|
|
157
|
+
if not self.active_connections:
|
|
158
|
+
return {"status": "error", "message": "No browser extension connected"}
|
|
159
|
+
|
|
160
|
+
disconnected = []
|
|
161
|
+
for connection in self.active_connections:
|
|
162
|
+
try:
|
|
163
|
+
await connection.send_json(command)
|
|
164
|
+
except Exception:
|
|
165
|
+
disconnected.append(connection)
|
|
166
|
+
|
|
167
|
+
for conn in disconnected:
|
|
168
|
+
self.disconnect(conn)
|
|
169
|
+
|
|
170
|
+
return {"status": "sent", "connections": len(self.active_connections)}
|
|
171
|
+
|
|
172
|
+
manager = ConnectionManager()
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------
|
|
175
|
+
# Clients
|
|
176
|
+
# ---------------------------------------------------------------------
|
|
177
|
+
from iflow_mcp_orion4d_comfyui_mcp.comfyui_client import ComfyUIClient
|
|
178
|
+
client = ComfyUIClient(base_url=COMFYUI_BASE_URL)
|
|
179
|
+
|
|
180
|
+
from iflow_mcp_orion4d_comfyui_mcp.browser_controller import BrowserController
|
|
181
|
+
browser = BrowserController(manager)
|
|
182
|
+
|
|
183
|
+
# =====================================================================
|
|
184
|
+
# FastMCP instance (UNE SEULE LIGNE)
|
|
185
|
+
# =====================================================================
|
|
186
|
+
mcp = FastMCP("ComfyUI MCP Server")
|
|
187
|
+
|
|
188
|
+
# ===========================================
|
|
189
|
+
# Zone d'échange fichiers (output/MCP_exchange)
|
|
190
|
+
# ===========================================
|
|
191
|
+
EXCHANGE_DIR = (COMFYUI_ROOT / "output" / "MCP_exchange") if COMFYUI_ROOT else None
|
|
192
|
+
TEXT_EXTS = {".txt", ".md", ".markdown", ".html", ".htm", ".json", ".js", ".py", ".css"}
|
|
193
|
+
IMG_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"}
|
|
194
|
+
ALL_EXTS = TEXT_EXTS | IMG_EXTS
|
|
195
|
+
MAX_WRITE_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
196
|
+
|
|
197
|
+
def _sanitize_name_for_any(name: str, allowed_exts=ALL_EXTS) -> str:
|
|
198
|
+
"""Autorise un nom simple + extension white-listée. Pas de sous-dossiers."""
|
|
199
|
+
base = name.strip()
|
|
200
|
+
if re.search(r"[\\/]|\.\.", base):
|
|
201
|
+
raise ValueError("Nom de fichier invalide.")
|
|
202
|
+
ext = Path(base).suffix.lower()
|
|
203
|
+
if not ext or ext not in allowed_exts:
|
|
204
|
+
raise ValueError(f"Extension non autorisée ({ext}). Autorisées: {sorted(allowed_exts)}")
|
|
205
|
+
if not re.fullmatch(r"[A-Za-z0-9_\-\.]{1,128}", base):
|
|
206
|
+
raise ValueError("Nom non autorisé (A-Za-z0-9_-. uniquement, 128 chars max).")
|
|
207
|
+
return base
|
|
208
|
+
|
|
209
|
+
def _ensure_exchange_dir() -> Path:
|
|
210
|
+
_require_root(COMFYUI_ROOT, "COMFYUI_ROOT")
|
|
211
|
+
root = _safe_join(COMFYUI_ROOT, "output")
|
|
212
|
+
exch = _safe_join(root, "MCP_exchange")
|
|
213
|
+
exch.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
return exch
|
|
215
|
+
|
|
216
|
+
# =====================================================================
|
|
217
|
+
# OUTILS MCP (@mcp.tool())
|
|
218
|
+
# =====================================================================
|
|
219
|
+
|
|
220
|
+
# Tools ComfyUI (client synchrone)
|
|
221
|
+
@mcp.tool()
|
|
222
|
+
def queue_prompt(workflow: dict) -> dict:
|
|
223
|
+
"""Envoie un workflow à ComfyUI pour exécution"""
|
|
224
|
+
return client.queue_prompt(workflow)
|
|
225
|
+
|
|
226
|
+
@mcp.tool()
|
|
227
|
+
def get_queue_status() -> dict:
|
|
228
|
+
"""Récupère l'état de la file d'attente ComfyUI"""
|
|
229
|
+
return client.get_queue_info()
|
|
230
|
+
|
|
231
|
+
@mcp.tool()
|
|
232
|
+
def get_history(prompt_id: str) -> dict:
|
|
233
|
+
"""Récupère l'historique d'un prompt spécifique"""
|
|
234
|
+
return client.get_history(prompt_id)
|
|
235
|
+
|
|
236
|
+
@mcp.tool()
|
|
237
|
+
def cancel_prompt(prompt_id: str = "") -> dict: # Le prompt_id n'est pas utilisé par l'API interrupt
|
|
238
|
+
"""Annule un prompt en cours d'exécution"""
|
|
239
|
+
return client.interrupt() # Appelle la nouvelle méthode
|
|
240
|
+
|
|
241
|
+
@mcp.tool()
|
|
242
|
+
def get_system_stats() -> dict:
|
|
243
|
+
"""Récupère les statistiques système de ComfyUI"""
|
|
244
|
+
return client.get_system_stats()
|
|
245
|
+
|
|
246
|
+
@mcp.tool()
|
|
247
|
+
def list_models(model_type: str = "checkpoints") -> dict:
|
|
248
|
+
"""Liste les modèles disponibles dans ComfyUI"""
|
|
249
|
+
if hasattr(client, "list_models"):
|
|
250
|
+
return client.list_models(model_type)
|
|
251
|
+
try:
|
|
252
|
+
info = client.get_object_info("CheckpointLoaderSimple")
|
|
253
|
+
models = info.get("CheckpointLoaderSimple", {}).get("input", {}).get("required", {}).get("ckpt_name", [[]])[0]
|
|
254
|
+
return {"model_type": "checkpoints", "models": models}
|
|
255
|
+
except Exception as e:
|
|
256
|
+
return {"status": "error", "message": str(e)}
|
|
257
|
+
|
|
258
|
+
@mcp.tool()
|
|
259
|
+
def upload_image(image_path: str) -> dict:
|
|
260
|
+
"""Upload une image vers ComfyUI"""
|
|
261
|
+
if hasattr(client, "upload_image"):
|
|
262
|
+
return client.upload_image(image_path)
|
|
263
|
+
return {"status": "error", "message": "upload_image non implémenté"}
|
|
264
|
+
|
|
265
|
+
@mcp.tool()
|
|
266
|
+
def get_image(filename: str, subfolder: str = "", folder_type: str = "output") -> bytes:
|
|
267
|
+
"""Récupère une image depuis ComfyUI"""
|
|
268
|
+
if hasattr(client, "get_image"):
|
|
269
|
+
return client.get_image(filename, subfolder, folder_type)
|
|
270
|
+
return b""
|
|
271
|
+
|
|
272
|
+
@mcp.tool()
|
|
273
|
+
def list_node_types() -> dict:
|
|
274
|
+
"""Liste tous les types de nœuds disponibles"""
|
|
275
|
+
return client.get_object_info()
|
|
276
|
+
|
|
277
|
+
@mcp.tool()
|
|
278
|
+
def interrupt_execution() -> dict:
|
|
279
|
+
"""Interrompt l'exécution en cours"""
|
|
280
|
+
if hasattr(client, "interrupt"):
|
|
281
|
+
return client.interrupt()
|
|
282
|
+
return {"status": "error", "message": "interrupt non implémenté"}
|
|
283
|
+
|
|
284
|
+
# Gestion des workflows
|
|
285
|
+
@mcp.tool()
|
|
286
|
+
def save_workflow(name: str, workflow: dict) -> dict:
|
|
287
|
+
"""Sauvegarde un workflow (supporte les sous-dossiers)"""
|
|
288
|
+
if not isinstance(workflow, dict):
|
|
289
|
+
return {"status": "error", "message": "Payload 'workflow' invalide"}
|
|
290
|
+
# Interdit absolus et traversées
|
|
291
|
+
if ".." in name or name.strip().startswith(("/", "\\")):
|
|
292
|
+
return {"status": "error", "message": "Nom de workflow invalide"}
|
|
293
|
+
try:
|
|
294
|
+
# Construit un chemin sûr sous WORKFLOWS_DIR
|
|
295
|
+
rel_path = Path(*[p for p in Path(name).parts if p not in ("", ".", "..")]).with_suffix(".json")
|
|
296
|
+
filepath = _safe_join(WORKFLOWS_DIR, str(rel_path))
|
|
297
|
+
Path(filepath).parent.mkdir(parents=True, exist_ok=True)
|
|
298
|
+
except Exception as e:
|
|
299
|
+
return {"status": "error", "message": f"Chemin non autorisé: {e}"}
|
|
300
|
+
|
|
301
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
302
|
+
json.dump(workflow, f, indent=2, ensure_ascii=False)
|
|
303
|
+
|
|
304
|
+
stat = Path(filepath).stat()
|
|
305
|
+
return {
|
|
306
|
+
"status": "success",
|
|
307
|
+
"path": str(filepath),
|
|
308
|
+
"size": stat.st_size,
|
|
309
|
+
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
310
|
+
"name": str(rel_path.with_suffix("")).replace("\\", "/")
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@mcp.tool()
|
|
315
|
+
def load_workflow(name: str) -> dict:
|
|
316
|
+
"""Charge un workflow sauvegardé (supporte les sous-dossiers)"""
|
|
317
|
+
# Interdit les patterns d'évasion
|
|
318
|
+
if ".." in name or name.strip().startswith(("/", "\\")):
|
|
319
|
+
return {"status": "error", "message": "Nom de workflow invalide"}
|
|
320
|
+
try:
|
|
321
|
+
# Construit un chemin sûr sous WORKFLOWS_DIR
|
|
322
|
+
rel_path = Path(*[p for p in Path(name).parts if p not in ("", ".", "..")])
|
|
323
|
+
filepath = _safe_join(WORKFLOWS_DIR, str(rel_path.with_suffix(".json")))
|
|
324
|
+
except Exception as e:
|
|
325
|
+
return {"status": "error", "message": f"Chemin non autorisé: {e}"}
|
|
326
|
+
if not Path(filepath).exists():
|
|
327
|
+
return {"status": "error", "message": f"Workflow '{name}' introuvable"}
|
|
328
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
329
|
+
workflow = json.load(f)
|
|
330
|
+
return {"status": "success", "workflow": workflow}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@mcp.tool()
|
|
334
|
+
def list_workflows() -> dict:
|
|
335
|
+
"""Liste tous les workflows sauvegardés (récursif, avec sous-dossiers)"""
|
|
336
|
+
workflows = []
|
|
337
|
+
for filepath in WORKFLOWS_DIR.rglob("*.json"):
|
|
338
|
+
stat = filepath.stat()
|
|
339
|
+
rel = filepath.relative_to(WORKFLOWS_DIR).with_suffix("") # garde les sous-dossiers
|
|
340
|
+
workflows.append({
|
|
341
|
+
"name": str(rel).replace("\\", "/"),
|
|
342
|
+
"size": stat.st_size,
|
|
343
|
+
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat()
|
|
344
|
+
})
|
|
345
|
+
workflows.sort(key=lambda w: w["name"])
|
|
346
|
+
return {"status": "success", "workflows": workflows}
|
|
347
|
+
|
|
348
|
+
@mcp.tool()
|
|
349
|
+
def inspect_workflow(name: str) -> dict:
|
|
350
|
+
"""
|
|
351
|
+
Inspecte un workflow pour analyser sa structure.
|
|
352
|
+
Supporte les sous-dossiers (ex: 'production/upscale').
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
name: Nom du workflow avec ou sans .json (peut contenir des /)
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
Dict avec status, format (UI/API), nombre de nodes, etc.
|
|
359
|
+
"""
|
|
360
|
+
# Protection contre path traversal
|
|
361
|
+
if ".." in name or name.strip().startswith(("/", "\\")):
|
|
362
|
+
return {"status": "error", "message": "Nom de workflow invalide"}
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
# Nettoyer et construire le chemin sécurisé
|
|
366
|
+
rel_path = Path(*[p for p in Path(name).parts if p not in ("", ".", "..")])
|
|
367
|
+
wf_path = _safe_join(WORKFLOWS_DIR, str(rel_path.with_suffix(".json")))
|
|
368
|
+
except Exception as e:
|
|
369
|
+
return {"status": "error", "message": f"Chemin non autorisé: {e}"}
|
|
370
|
+
|
|
371
|
+
if not Path(wf_path).exists():
|
|
372
|
+
return {"status": "error", "message": f"Workflow '{name}' introuvable"}
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
with open(wf_path, "r", encoding="utf-8") as f:
|
|
376
|
+
payload = json.load(f)
|
|
377
|
+
except Exception as e:
|
|
378
|
+
return {"status": "error", "message": f"Lecture invalide: {e}"}
|
|
379
|
+
|
|
380
|
+
# Analyse du format (UI ou API)
|
|
381
|
+
if isinstance(payload, dict) and "nodes" in payload and "links" in payload:
|
|
382
|
+
info = {
|
|
383
|
+
"format": "UI",
|
|
384
|
+
"nodes": len(payload.get("nodes", [])),
|
|
385
|
+
"links": len(payload.get("links", [])),
|
|
386
|
+
"path": str(wf_path)
|
|
387
|
+
}
|
|
388
|
+
else:
|
|
389
|
+
class_types = []
|
|
390
|
+
if isinstance(payload, dict):
|
|
391
|
+
graph = payload.get("prompt", payload)
|
|
392
|
+
if isinstance(graph, dict):
|
|
393
|
+
for node_id, node in graph.items():
|
|
394
|
+
ct = node.get("class_type")
|
|
395
|
+
if ct:
|
|
396
|
+
class_types.append(ct)
|
|
397
|
+
info = {
|
|
398
|
+
"format": "API",
|
|
399
|
+
"nodes": len(class_types),
|
|
400
|
+
"class_types": class_types[:100],
|
|
401
|
+
"path": str(wf_path)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return {"status": "success", "workflow": info}
|
|
405
|
+
|
|
406
|
+
# Contrôle UI Chrome
|
|
407
|
+
@mcp.tool()
|
|
408
|
+
async def ui_click_element(selector: str) -> dict:
|
|
409
|
+
"""Clique sur un élément dans l'interface Chrome de ComfyUI"""
|
|
410
|
+
if not ENABLE_BROWSER_CONTROL:
|
|
411
|
+
return {"status": "disabled", "message": "Browser control disabled"}
|
|
412
|
+
return await browser.click_element(selector)
|
|
413
|
+
|
|
414
|
+
@mcp.tool()
|
|
415
|
+
async def ui_fill_input(selector: str, text: str) -> dict:
|
|
416
|
+
"""Remplit un champ de saisie dans l'interface Chrome"""
|
|
417
|
+
if not ENABLE_BROWSER_CONTROL:
|
|
418
|
+
return {"status": "disabled", "message": "Browser control disabled"}
|
|
419
|
+
return await browser.fill_input(selector, text)
|
|
420
|
+
|
|
421
|
+
@mcp.tool()
|
|
422
|
+
async def ui_get_current_workflow() -> dict:
|
|
423
|
+
"""Récupère le workflow actuel depuis l'interface Chrome"""
|
|
424
|
+
if not ENABLE_BROWSER_CONTROL:
|
|
425
|
+
return {"status": "disabled", "message": "Browser control disabled"}
|
|
426
|
+
return await browser.get_workflow()
|
|
427
|
+
|
|
428
|
+
# OUTILS ADMIN
|
|
429
|
+
@mcp.tool()
|
|
430
|
+
def write_custom_node(name: str, content: str, subdir: str = "mcp_drop", overwrite: bool = False) -> dict:
|
|
431
|
+
"""Écrit un fichier de custom node dans ComfyUI"""
|
|
432
|
+
_require_root(CUSTOM_NODES_DIR, "custom_nodes")
|
|
433
|
+
|
|
434
|
+
if len(content.encode("utf-8")) > 200_000:
|
|
435
|
+
return {"status": "error", "message": "Contenu trop volumineux (>200 KB)"}
|
|
436
|
+
|
|
437
|
+
safe_name = _sanitize_filename(name, ".py")
|
|
438
|
+
target_dir = _safe_join(CUSTOM_NODES_DIR, subdir)
|
|
439
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
440
|
+
target_path = _safe_join(target_dir, safe_name)
|
|
441
|
+
|
|
442
|
+
if target_path.exists() and not overwrite:
|
|
443
|
+
return {"status": "error", "message": "Fichier existe déjà (overwrite=false)"}
|
|
444
|
+
|
|
445
|
+
with open(target_path, "w", encoding="utf-8") as f:
|
|
446
|
+
f.write(content)
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
"status": "success",
|
|
450
|
+
"path": str(target_path),
|
|
451
|
+
"size": target_path.stat().st_size,
|
|
452
|
+
"sha256": _sha256_of_file(target_path),
|
|
453
|
+
"tip": "Relance ComfyUI ou 'Reload custom nodes' pour prendre en compte."
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
@mcp.tool()
|
|
457
|
+
def create_custom_node_template(folder_name: str, node_name: str, description: str = "") -> dict:
|
|
458
|
+
"""
|
|
459
|
+
Crée un squelette complet de Custom Node :
|
|
460
|
+
- Dossier sous custom_nodes/
|
|
461
|
+
- __init__.py
|
|
462
|
+
- nodes.py avec une classe simple
|
|
463
|
+
- README.md descriptif
|
|
464
|
+
"""
|
|
465
|
+
try:
|
|
466
|
+
if not folder_name or not node_name:
|
|
467
|
+
return {"status": "error", "message": "folder_name et node_name sont requis."}
|
|
468
|
+
|
|
469
|
+
# Vérifie le dossier cible
|
|
470
|
+
target_dir = _safe_join(CUSTOM_NODES_DIR, folder_name)
|
|
471
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
472
|
+
|
|
473
|
+
# Fichier __init__.py
|
|
474
|
+
init_path = target_dir / "__init__.py"
|
|
475
|
+
if not init_path.exists():
|
|
476
|
+
init_path.write_text(
|
|
477
|
+
f'"""Init for {folder_name} custom nodes"""\n\n'
|
|
478
|
+
f"from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS\n\n"
|
|
479
|
+
"__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']\n",
|
|
480
|
+
encoding="utf-8"
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
# Fichier nodes.py
|
|
484
|
+
nodes_path = target_dir / "nodes.py"
|
|
485
|
+
if not nodes_path.exists():
|
|
486
|
+
nodes_path.write_text(
|
|
487
|
+
f"class {node_name}:\n"
|
|
488
|
+
f" @classmethod\n"
|
|
489
|
+
f" def INPUT_TYPES(cls):\n"
|
|
490
|
+
f" return {{'required': {{'text': ('STRING', {{'default': 'Hello World'}})}}}}\n\n"
|
|
491
|
+
f" RETURN_TYPES = ('STRING',)\n"
|
|
492
|
+
f" FUNCTION = 'say_hello'\n"
|
|
493
|
+
f" CATEGORY = 'custom/{folder_name}'\n\n"
|
|
494
|
+
f" def say_hello(self, text):\n"
|
|
495
|
+
f" return (f'{node_name} dit: {{text}}',)\n\n"
|
|
496
|
+
f"NODE_CLASS_MAPPINGS = {{'{node_name}': {node_name}}}\n"
|
|
497
|
+
f"NODE_DISPLAY_NAME_MAPPINGS = {{'{node_name}': '{node_name}'}}\n",
|
|
498
|
+
encoding="utf-8"
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
# Fichier README.md
|
|
502
|
+
readme_path = target_dir / "README.md"
|
|
503
|
+
if not readme_path.exists():
|
|
504
|
+
readme_path.write_text(
|
|
505
|
+
f"# {node_name}\n\n"
|
|
506
|
+
f"Custom node généré automatiquement.\n\n"
|
|
507
|
+
f"{description}\n",
|
|
508
|
+
encoding="utf-8"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
return {
|
|
512
|
+
"status": "success",
|
|
513
|
+
"message": f"Custom node '{node_name}' créé dans '{target_dir}'.",
|
|
514
|
+
"files": [str(init_path), str(nodes_path), str(readme_path)]
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
except Exception as e:
|
|
518
|
+
return {"status": "error", "message": str(e)}
|
|
519
|
+
|
|
520
|
+
@mcp.tool()
|
|
521
|
+
def read_custom_node(name: str, subdir: str = "mcp_drop") -> dict:
|
|
522
|
+
"""Lit le contenu d'un fichier custom node depuis ComfyUI"""
|
|
523
|
+
_require_root(CUSTOM_NODES_DIR, "custom_nodes")
|
|
524
|
+
|
|
525
|
+
safe_name = _sanitize_filename(name, ".py")
|
|
526
|
+
target_dir = _safe_join(CUSTOM_NODES_DIR, subdir)
|
|
527
|
+
target_path = _safe_join(target_dir, safe_name)
|
|
528
|
+
|
|
529
|
+
if not target_path.exists():
|
|
530
|
+
return {"status": "error", "message": f"Fichier '{name}' introuvable dans {subdir}"}
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
content = target_path.read_text(encoding="utf-8")
|
|
534
|
+
return {
|
|
535
|
+
"status": "success",
|
|
536
|
+
"name": safe_name,
|
|
537
|
+
"path": str(target_path),
|
|
538
|
+
"content": content,
|
|
539
|
+
"size": len(content),
|
|
540
|
+
"lines": len(content.splitlines())
|
|
541
|
+
}
|
|
542
|
+
except Exception as e:
|
|
543
|
+
return {"status": "error", "message": f"Erreur de lecture: {e}"}
|
|
544
|
+
|
|
545
|
+
@mcp.tool()
|
|
546
|
+
def list_custom_subdir(folder: str) -> dict:
|
|
547
|
+
"""
|
|
548
|
+
Liste les fichiers .py d'un sous-dossier direct de custom_nodes (sans descendre).
|
|
549
|
+
Exemple: folder="Orion4D_external_mcp"
|
|
550
|
+
"""
|
|
551
|
+
try:
|
|
552
|
+
# sécurité nom de dossier (pas de traversée, pas de séparateurs)
|
|
553
|
+
if not folder or ".." in folder or "/" in folder or "\\" in folder:
|
|
554
|
+
return {"status": "error", "message": "Nom de dossier invalide"}
|
|
555
|
+
|
|
556
|
+
# Vérifie la racine custom_nodes
|
|
557
|
+
_require_root(CUSTOM_NODES_DIR, "custom_nodes")
|
|
558
|
+
|
|
559
|
+
target = _safe_join(CUSTOM_NODES_DIR, folder)
|
|
560
|
+
if not target.exists() or not target.is_dir():
|
|
561
|
+
return {"status": "error", "message": f"Dossier introuvable: {folder}"}
|
|
562
|
+
|
|
563
|
+
from datetime import datetime
|
|
564
|
+
items = []
|
|
565
|
+
for p in target.glob("*.py"):
|
|
566
|
+
try:
|
|
567
|
+
st = p.stat()
|
|
568
|
+
items.append({
|
|
569
|
+
"name": p.name,
|
|
570
|
+
"size": st.st_size,
|
|
571
|
+
"modified": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
|
572
|
+
})
|
|
573
|
+
except Exception as e:
|
|
574
|
+
items.append({"name": p.name, "error": str(e)})
|
|
575
|
+
|
|
576
|
+
items.sort(key=lambda x: x.get("name", "").lower())
|
|
577
|
+
return {"status": "success", "folder": str(target), "count": len(items), "files": items}
|
|
578
|
+
|
|
579
|
+
except Exception as e:
|
|
580
|
+
return {"status": "error", "message": str(e)}
|
|
581
|
+
|
|
582
|
+
@mcp.tool()
|
|
583
|
+
def autodoc_nodes() -> dict:
|
|
584
|
+
"""Génère automatiquement la documentation des custom nodes"""
|
|
585
|
+
_require_root(CUSTOM_NODES_DIR, "custom_nodes")
|
|
586
|
+
|
|
587
|
+
data = []
|
|
588
|
+
class_rx = re.compile(r"^class\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(", flags=re.MULTILINE)
|
|
589
|
+
mapping_rx = re.compile(r"NODE_CLASS_MAPPINGS\s*=\s*\{([^}]*)\}", flags=re.DOTALL)
|
|
590
|
+
|
|
591
|
+
for folder in sorted(CUSTOM_NODES_DIR.iterdir()):
|
|
592
|
+
if not folder.is_dir():
|
|
593
|
+
continue
|
|
594
|
+
|
|
595
|
+
entry = {"folder": folder.name, "files": []}
|
|
596
|
+
for py in sorted(folder.glob("*.py")):
|
|
597
|
+
item = {"file": py.name, "classes": []}
|
|
598
|
+
try:
|
|
599
|
+
text = py.read_text(encoding="utf-8", errors="ignore")
|
|
600
|
+
item["classes"] = class_rx.findall(text)[:50]
|
|
601
|
+
|
|
602
|
+
m = mapping_rx.search(text)
|
|
603
|
+
if m:
|
|
604
|
+
keys = []
|
|
605
|
+
for line in m.group(1).splitlines():
|
|
606
|
+
line = line.strip()
|
|
607
|
+
if line.startswith("#") or ":" not in line:
|
|
608
|
+
continue
|
|
609
|
+
key = line.split(":", 1)[0].strip().strip("'\"")
|
|
610
|
+
if key and len(keys) < 50:
|
|
611
|
+
keys.append(key)
|
|
612
|
+
if keys:
|
|
613
|
+
item["node_keys"] = keys
|
|
614
|
+
except Exception as e:
|
|
615
|
+
item["error"] = str(e)
|
|
616
|
+
|
|
617
|
+
entry["files"].append(item)
|
|
618
|
+
|
|
619
|
+
if entry["files"]:
|
|
620
|
+
data.append(entry)
|
|
621
|
+
|
|
622
|
+
return {"status": "success", "custom_nodes": data}
|
|
623
|
+
|
|
624
|
+
# Liste les images de COMFYUI_ROOT/output (hors MCP_exchange si tu veux tout)
|
|
625
|
+
@mcp.tool()
|
|
626
|
+
def list_output_images(limit: int = 100, exts: str = "png,jpg,jpeg,webp") -> dict:
|
|
627
|
+
"""
|
|
628
|
+
Liste les images du dossier ComfyUI/output (triées du plus récent au plus ancien).
|
|
629
|
+
exts: extensions autorisées, séparées par virgules.
|
|
630
|
+
"""
|
|
631
|
+
_require_root(COMFYUI_ROOT, "COMFYUI_ROOT")
|
|
632
|
+
output_dir = _safe_join(COMFYUI_ROOT, "output")
|
|
633
|
+
if not output_dir.exists():
|
|
634
|
+
return {"status": "success", "files": []}
|
|
635
|
+
|
|
636
|
+
allowed = {e.strip().lower().lstrip(".") for e in exts.split(",") if e.strip()}
|
|
637
|
+
items = []
|
|
638
|
+
for p in output_dir.rglob("*"):
|
|
639
|
+
if p.is_file() and p.suffix.lower().lstrip(".") in allowed:
|
|
640
|
+
rel = p.relative_to(output_dir)
|
|
641
|
+
stat = p.stat()
|
|
642
|
+
filename = p.name
|
|
643
|
+
subfolder = str(rel.parent).replace("\\", "/") if rel.parent != Path(".") else ""
|
|
644
|
+
view = f"/view?filename={quote(filename)}&type=output"
|
|
645
|
+
if subfolder:
|
|
646
|
+
view += f"&subfolder={quote(subfolder)}"
|
|
647
|
+
items.append({
|
|
648
|
+
"filename": filename,
|
|
649
|
+
"subfolder": subfolder,
|
|
650
|
+
"size_bytes": stat.st_size,
|
|
651
|
+
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
652
|
+
"view_path": view,
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
items.sort(key=lambda x: x["modified"], reverse=True)
|
|
656
|
+
return {"status": "success", "count": len(items[:limit]), "files": items[:limit]}
|
|
657
|
+
@mcp.tool()
|
|
658
|
+
def list_exchange(limit: int = 200, exts: str = "png,jpg,jpeg,webp,bmp,tif,tiff,txt,md,html,htm,json,js,py,css") -> dict:
|
|
659
|
+
"""Liste les fichiers dans output/MCP_exchange (du + récent au + ancien)."""
|
|
660
|
+
root = _ensure_exchange_dir()
|
|
661
|
+
allowed = {"." + e.strip().lower().lstrip(".") for e in exts.split(",") if e.strip()}
|
|
662
|
+
files = []
|
|
663
|
+
for p in root.glob("*"):
|
|
664
|
+
if p.is_file() and p.suffix.lower() in allowed:
|
|
665
|
+
stat = p.stat()
|
|
666
|
+
files.append({
|
|
667
|
+
"name": p.name,
|
|
668
|
+
"size_bytes": stat.st_size,
|
|
669
|
+
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
670
|
+
"ext": p.suffix.lower(),
|
|
671
|
+
"view_path": f"/view?filename={quote(p.name)}&subfolder=MCP_exchange&type=output"
|
|
672
|
+
if p.suffix.lower() in IMG_EXTS else None,
|
|
673
|
+
})
|
|
674
|
+
files.sort(key=lambda x: x["modified"], reverse=True)
|
|
675
|
+
return {"status":"success","count":len(files[:limit]),"files":files[:limit]}
|
|
676
|
+
|
|
677
|
+
@mcp.tool()
|
|
678
|
+
def read_exchange(name: str, as_data_url: bool = True) -> dict:
|
|
679
|
+
"""
|
|
680
|
+
Lit un fichier dans output/MCP_exchange.
|
|
681
|
+
- Images: si as_data_url=True -> data:image/...;base64, sinon base64 brut.
|
|
682
|
+
- Textes: renvoie content (str).
|
|
683
|
+
"""
|
|
684
|
+
root = _ensure_exchange_dir()
|
|
685
|
+
safe = _sanitize_name_for_any(name)
|
|
686
|
+
path = _safe_join(root, safe)
|
|
687
|
+
|
|
688
|
+
if not path.exists() or not path.is_file():
|
|
689
|
+
return {"status":"error","message":f"Fichier introuvable: {safe}"}
|
|
690
|
+
|
|
691
|
+
ext = path.suffix.lower()
|
|
692
|
+
try:
|
|
693
|
+
if ext in TEXT_EXTS:
|
|
694
|
+
txt = path.read_text(encoding="utf-8", errors="replace")
|
|
695
|
+
return {"status":"success","name":safe,"ext":ext,"mode":"text","content":txt}
|
|
696
|
+
else:
|
|
697
|
+
raw = path.read_bytes()
|
|
698
|
+
b64 = b64encode(raw).decode("ascii")
|
|
699
|
+
if as_data_url:
|
|
700
|
+
mime = {
|
|
701
|
+
".png":"image/png",".jpg":"image/jpeg",".jpeg":"image/jpeg",
|
|
702
|
+
".webp":"image/webp",".bmp":"image/bmp",".tif":"image/tiff",".tiff":"image/tiff"
|
|
703
|
+
}.get(ext, "application/octet-stream")
|
|
704
|
+
return {"status":"success","name":safe,"ext":ext,"mode":"data_url","data_url":f"data:{mime};base64,{b64}"}
|
|
705
|
+
else:
|
|
706
|
+
return {"status":"success","name":safe,"ext":ext,"mode":"base64","base64":b64}
|
|
707
|
+
except Exception as e:
|
|
708
|
+
return {"status":"error","message":str(e)}
|
|
709
|
+
|
|
710
|
+
@mcp.tool()
|
|
711
|
+
def write_exchange(name: str, content: str, mode: str = "text", overwrite: bool = False) -> dict:
|
|
712
|
+
"""
|
|
713
|
+
Écrit un fichier dans output/MCP_exchange.
|
|
714
|
+
mode:
|
|
715
|
+
- 'text' : content = texte UTF-8 (extensions texte uniquement)
|
|
716
|
+
- 'base64' : content = base64 de données binaires
|
|
717
|
+
- 'data_url': content = data:<mime>;base64,...
|
|
718
|
+
"""
|
|
719
|
+
root = _ensure_exchange_dir()
|
|
720
|
+
safe = _sanitize_name_for_any(name)
|
|
721
|
+
path = _safe_join(root, safe)
|
|
722
|
+
|
|
723
|
+
if path.exists() and not overwrite:
|
|
724
|
+
return {"status":"error","message":"Fichier existe déjà (overwrite=false)"}
|
|
725
|
+
|
|
726
|
+
ext = path.suffix.lower()
|
|
727
|
+
try:
|
|
728
|
+
if mode == "text":
|
|
729
|
+
if ext not in TEXT_EXTS:
|
|
730
|
+
return {"status":"error","message":f"Extension {ext} non texte"}
|
|
731
|
+
data = content.encode("utf-8")
|
|
732
|
+
elif mode == "base64":
|
|
733
|
+
data = b64decode(content)
|
|
734
|
+
elif mode == "data_url":
|
|
735
|
+
if "," not in content:
|
|
736
|
+
return {"status":"error","message":"data_url invalide"}
|
|
737
|
+
data = b64decode(content.split(",",1)[1])
|
|
738
|
+
else:
|
|
739
|
+
return {"status":"error","message":"mode invalide (text|base64|data_url)"}
|
|
740
|
+
|
|
741
|
+
if len(data) > MAX_WRITE_BYTES:
|
|
742
|
+
return {"status":"error","message":"Fichier trop volumineux (>10MB)"}
|
|
743
|
+
|
|
744
|
+
path.write_bytes(data)
|
|
745
|
+
return {"status":"success","name":safe,"size_bytes":len(data),"path":str(path)}
|
|
746
|
+
except Exception as e:
|
|
747
|
+
return {"status":"error","message":str(e)}
|
|
748
|
+
|
|
749
|
+
@mcp.tool()
|
|
750
|
+
def delete_exchange(name: str) -> dict:
|
|
751
|
+
"""Supprime un fichier dans output/MCP_exchange."""
|
|
752
|
+
root = _ensure_exchange_dir()
|
|
753
|
+
safe = _sanitize_name_for_any(name)
|
|
754
|
+
path = _safe_join(root, safe)
|
|
755
|
+
if not path.exists():
|
|
756
|
+
return {"status":"error","message":"Fichier introuvable"}
|
|
757
|
+
try:
|
|
758
|
+
path.unlink()
|
|
759
|
+
return {"status":"success","deleted":safe}
|
|
760
|
+
except Exception as e:
|
|
761
|
+
return {"status":"error","message":str(e)}
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
@mcp.tool()
|
|
765
|
+
def model_info(name: str) -> dict:
|
|
766
|
+
"""Récupère les informations détaillées d'un modèle"""
|
|
767
|
+
_require_root(MODELS_DIR, "models")
|
|
768
|
+
|
|
769
|
+
rel = name.strip().lstrip("\\/")
|
|
770
|
+
target = _safe_join(MODELS_DIR, rel)
|
|
771
|
+
|
|
772
|
+
if not target.exists() or not target.is_file():
|
|
773
|
+
return {"status": "error", "message": f"Modèle introuvable: {rel}"}
|
|
774
|
+
|
|
775
|
+
stat = target.stat()
|
|
776
|
+
info = {
|
|
777
|
+
"status": "success",
|
|
778
|
+
"path": str(target),
|
|
779
|
+
"size_bytes": stat.st_size,
|
|
780
|
+
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
781
|
+
"sha256": _sha256_of_file(target),
|
|
782
|
+
"extension": target.suffix.lower()
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if target.suffix.lower() == ".safetensors":
|
|
786
|
+
try:
|
|
787
|
+
from safetensors import safe_open
|
|
788
|
+
md = {}
|
|
789
|
+
with safe_open(str(target), framework="pt") as f:
|
|
790
|
+
md = f.metadata() or {}
|
|
791
|
+
info["safetensors_metadata"] = md
|
|
792
|
+
except Exception as e:
|
|
793
|
+
info["safetensors_metadata"] = {"warning": f"Non disponible ({e})"}
|
|
794
|
+
|
|
795
|
+
return info
|
|
796
|
+
|
|
797
|
+
# =====================================================================
|
|
798
|
+
# ROUTES HTTP personnalisées (@mcp.custom_route)
|
|
799
|
+
# =====================================================================
|
|
800
|
+
|
|
801
|
+
@mcp.custom_route("/health", methods=["GET"])
|
|
802
|
+
async def health_check(request: Request) -> JSONResponse:
|
|
803
|
+
"""Health check endpoint"""
|
|
804
|
+
try:
|
|
805
|
+
status = client.get_queue_info()
|
|
806
|
+
comfyui_status = "connected" if status else "unknown"
|
|
807
|
+
except Exception:
|
|
808
|
+
comfyui_status = "disconnected"
|
|
809
|
+
|
|
810
|
+
return JSONResponse({
|
|
811
|
+
"status": "healthy",
|
|
812
|
+
"timestamp": datetime.now().isoformat(),
|
|
813
|
+
"comfyui": comfyui_status,
|
|
814
|
+
"browser_control_enabled": ENABLE_BROWSER_CONTROL,
|
|
815
|
+
"chrome_connections": len(manager.active_connections) if ENABLE_BROWSER_CONTROL else 0,
|
|
816
|
+
"api_key_enabled": bool(API_KEY)
|
|
817
|
+
})
|
|
818
|
+
|
|
819
|
+
@mcp.custom_route("/debug/tools", methods=["GET"])
|
|
820
|
+
async def debug_tools(request: Request):
|
|
821
|
+
try:
|
|
822
|
+
names = set()
|
|
823
|
+
|
|
824
|
+
# Registres possibles selon versions de FastMCP
|
|
825
|
+
for attr in ("tools", "_tools"):
|
|
826
|
+
reg = getattr(mcp, attr, None)
|
|
827
|
+
if isinstance(reg, dict):
|
|
828
|
+
names |= set(reg.keys())
|
|
829
|
+
|
|
830
|
+
tm = getattr(mcp, "_tool_manager", None)
|
|
831
|
+
if tm:
|
|
832
|
+
for attr in ("tools", "_tools"):
|
|
833
|
+
reg = getattr(tm, attr, None)
|
|
834
|
+
if isinstance(reg, dict):
|
|
835
|
+
names |= set(reg.keys())
|
|
836
|
+
|
|
837
|
+
return JSONResponse({"status": "success", "count": len(names), "tools": sorted(names)})
|
|
838
|
+
except Exception as e:
|
|
839
|
+
return JSONResponse({"status": "error", "message": str(e)})
|
|
840
|
+
|
|
841
|
+
# WebSocket endpoint
|
|
842
|
+
async def websocket_endpoint(websocket: WebSocket):
|
|
843
|
+
"""Endpoint WebSocket pour le contrôle du navigateur"""
|
|
844
|
+
if not ENABLE_BROWSER_CONTROL:
|
|
845
|
+
await websocket.close(code=1008, reason="Browser control disabled")
|
|
846
|
+
return
|
|
847
|
+
|
|
848
|
+
token = websocket.query_params.get("token")
|
|
849
|
+
if not token or token != WEBSOCKET_TOKEN:
|
|
850
|
+
await websocket.close(code=1008, reason="Unauthorized - Invalid token")
|
|
851
|
+
return
|
|
852
|
+
|
|
853
|
+
origin = websocket.headers.get("origin", "")
|
|
854
|
+
if not origin.startswith("chrome-extension://"):
|
|
855
|
+
await websocket.close(code=1008, reason="Invalid origin")
|
|
856
|
+
return
|
|
857
|
+
|
|
858
|
+
client_id = f"{websocket.client.host}:{websocket.client.port}"
|
|
859
|
+
client_info = {
|
|
860
|
+
"origin": origin,
|
|
861
|
+
"connected_at": datetime.now().isoformat(),
|
|
862
|
+
"client_id": client_id
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
await manager.connect(websocket, client_info)
|
|
866
|
+
|
|
867
|
+
try:
|
|
868
|
+
while True:
|
|
869
|
+
data = await websocket.receive_text()
|
|
870
|
+
|
|
871
|
+
if not rate_limiter.is_allowed(client_id):
|
|
872
|
+
await websocket.send_json({"error": "Rate limit exceeded"})
|
|
873
|
+
continue
|
|
874
|
+
|
|
875
|
+
if data:
|
|
876
|
+
try:
|
|
877
|
+
msg = json.loads(data)
|
|
878
|
+
if msg.get("type") == "ping":
|
|
879
|
+
await websocket.send_json({
|
|
880
|
+
"type": "pong",
|
|
881
|
+
"timestamp": datetime.now().isoformat()
|
|
882
|
+
})
|
|
883
|
+
except Exception as e:
|
|
884
|
+
logger.error(f"Erreur traitement message: {e}")
|
|
885
|
+
|
|
886
|
+
except WebSocketDisconnect:
|
|
887
|
+
manager.disconnect(websocket)
|
|
888
|
+
rate_limiter.reset(client_id)
|
|
889
|
+
except Exception as e:
|
|
890
|
+
logger.error(f"Erreur WebSocket: {e}")
|
|
891
|
+
manager.disconnect(websocket)
|
|
892
|
+
rate_limiter.reset(client_id)
|
|
893
|
+
|
|
894
|
+
# --- ROUTE DEBUG HEALTH ---
|
|
895
|
+
from fastapi import Request
|
|
896
|
+
from fastapi.responses import JSONResponse
|
|
897
|
+
import platform
|
|
898
|
+
|
|
899
|
+
@mcp.custom_route("/debug/health", methods=["GET"])
|
|
900
|
+
async def debug_health(request: Request):
|
|
901
|
+
names = set()
|
|
902
|
+
for attr in ("tools", "_tools"):
|
|
903
|
+
reg = getattr(mcp, attr, None)
|
|
904
|
+
if isinstance(reg, dict):
|
|
905
|
+
names |= set(reg.keys())
|
|
906
|
+
tm = getattr(mcp, "_tool_manager", None)
|
|
907
|
+
if tm:
|
|
908
|
+
for attr in ("tools", "_tools"):
|
|
909
|
+
reg = getattr(tm, attr, None)
|
|
910
|
+
if isinstance(reg, dict):
|
|
911
|
+
names |= set(reg.keys())
|
|
912
|
+
|
|
913
|
+
return JSONResponse({
|
|
914
|
+
"status": "success",
|
|
915
|
+
"health": {
|
|
916
|
+
"COMFYUI_ROOT": str(COMFYUI_ROOT),
|
|
917
|
+
"CUSTOM_NODES_DIR": str(CUSTOM_NODES_DIR),
|
|
918
|
+
"WORKFLOWS_DIR": str(WORKFLOWS_DIR),
|
|
919
|
+
"python": platform.python_version(),
|
|
920
|
+
"tool_count": len(names),
|
|
921
|
+
"tools_sample": sorted(list(names))[:50],
|
|
922
|
+
}
|
|
923
|
+
})
|
|
924
|
+
# --- FIN ROUTE DEBUG HEALTH ---
|
|
925
|
+
|
|
926
|
+
# =====================================================================
|
|
927
|
+
# APP SETUP (APRÈS tous les outils)
|
|
928
|
+
# =====================================================================
|
|
929
|
+
app = mcp.http_app()
|
|
930
|
+
|
|
931
|
+
# Ajout WebSocket route
|
|
932
|
+
if ENABLE_BROWSER_CONTROL:
|
|
933
|
+
app.routes.append(WebSocketRoute("/ws", websocket_endpoint))
|
|
934
|
+
|
|
935
|
+
# CORS middleware
|
|
936
|
+
app.add_middleware(
|
|
937
|
+
CORSMiddleware,
|
|
938
|
+
allow_origin_regex=r"chrome-extension://.*",
|
|
939
|
+
allow_credentials=True,
|
|
940
|
+
allow_methods=["*"],
|
|
941
|
+
allow_headers=["*"],
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
# API Key middleware
|
|
945
|
+
class APIKeyMiddleware(BaseHTTPMiddleware):
|
|
946
|
+
async def dispatch(self, request: Request, call_next):
|
|
947
|
+
# Skip auth for health and WebSocket
|
|
948
|
+
if request.url.path in ["/health", "/ws"] or request.headers.get("upgrade") == "websocket":
|
|
949
|
+
return await call_next(request)
|
|
950
|
+
|
|
951
|
+
# Si pas d'API key configurée, passer
|
|
952
|
+
if not API_KEY:
|
|
953
|
+
return await call_next(request)
|
|
954
|
+
|
|
955
|
+
# Vérifier l'API key
|
|
956
|
+
api_key = request.headers.get("X-API-Key") or request.headers.get("x-api-key")
|
|
957
|
+
if api_key != API_KEY:
|
|
958
|
+
raise HTTPException(status_code=401, detail="Invalid or missing API Key")
|
|
959
|
+
|
|
960
|
+
return await call_next(request)
|
|
961
|
+
|
|
962
|
+
app.add_middleware(APIKeyMiddleware)
|
|
963
|
+
|
|
964
|
+
# ---------------------------------------------------------------------
|
|
965
|
+
# Cleanup
|
|
966
|
+
# ---------------------------------------------------------------------
|
|
967
|
+
def cleanup():
|
|
968
|
+
logger.info("🛑 Arrêt du serveur MCP ComfyUI")
|
|
969
|
+
|
|
970
|
+
atexit.register(cleanup)
|
|
971
|
+
signal.signal(signal.SIGINT, lambda s, f: sys.exit(0))
|
|
972
|
+
signal.signal(signal.SIGTERM, lambda s, f: sys.exit(0))
|
|
973
|
+
|
|
974
|
+
# ---------------------------------------------------------------------
|
|
975
|
+
# ROUTE DE DÉBOGAGE (VERSION UNIQUE ET AMÉLIORÉE)
|
|
976
|
+
# ---------------------------------------------------------------------
|
|
977
|
+
@mcp.custom_route("/debug/health", methods=["GET"])
|
|
978
|
+
async def debug_health(request: Request):
|
|
979
|
+
import platform
|
|
980
|
+
info = {}
|
|
981
|
+
|
|
982
|
+
# Chemins utiles
|
|
983
|
+
info["COMFYUI_ROOT"] = str(COMFYUI_ROOT) if COMFYUI_ROOT else None
|
|
984
|
+
info["CUSTOM_NODES_DIR"] = str(CUSTOM_NODES_DIR) if CUSTOM_NODES_DIR else None
|
|
985
|
+
info["WORKFLOWS_DIR"] = str(WORKFLOWS_DIR) if WORKFLOWS_DIR else None
|
|
986
|
+
|
|
987
|
+
# Versions
|
|
988
|
+
info["python"] = platform.python_version()
|
|
989
|
+
try:
|
|
990
|
+
import fastmcp
|
|
991
|
+
info["fastmcp"] = getattr(fastmcp, "__version__", "unknown")
|
|
992
|
+
except Exception:
|
|
993
|
+
info["fastmcp"] = "unknown"
|
|
994
|
+
|
|
995
|
+
# Compte d'outils enregistrés
|
|
996
|
+
names = set()
|
|
997
|
+
try:
|
|
998
|
+
tm = getattr(mcp, "_tool_manager", mcp)
|
|
999
|
+
names.update(getattr(tm, "tools", {}).keys())
|
|
1000
|
+
names.update(getattr(tm, "_tools", {}).keys())
|
|
1001
|
+
except Exception:
|
|
1002
|
+
pass
|
|
1003
|
+
|
|
1004
|
+
info["tool_count"] = len(names)
|
|
1005
|
+
info["tools_sample"] = sorted(list(names))[:50]
|
|
1006
|
+
|
|
1007
|
+
return JSONResponse({"status": "success", "health": info})
|
|
1008
|
+
|
|
1009
|
+
# ---------------------------------------------------------------------
|
|
1010
|
+
|
|
1011
|
+
# ---------------------------------------------------------------------
|
|
1012
|
+
# Main entry point for MCP server
|
|
1013
|
+
# ---------------------------------------------------------------------
|
|
1014
|
+
def main():
|
|
1015
|
+
"""Main entry point for MCP server"""
|
|
1016
|
+
import sys
|
|
1017
|
+
# Check if running in stdio mode (MCP protocol)
|
|
1018
|
+
if "--transport" in sys.argv and "stdio" in sys.argv:
|
|
1019
|
+
# Run in stdio mode
|
|
1020
|
+
mcp.run()
|
|
1021
|
+
else:
|
|
1022
|
+
# Run in HTTP mode (default)
|
|
1023
|
+
import uvicorn
|
|
1024
|
+
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")
|
|
1025
|
+
|
|
1026
|
+
if __name__ == "__main__":
|
|
1027
|
+
main()
|