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.
@@ -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()