feat(mcp-image-gen): add ComfyUI auto-start health check + systemd service
Option A: Add lifespan context manager to server.py - _ping_comfyui(): async health check against /system_stats - check_and_start_comfyui(): ping on startup; if down, launches ComfyUI via subprocess.Popen from COMFYUI_DIR (.venv/bin/python main.py) with HSA_OVERRIDE_GFX_VERSION=11.0.0 injected for AMD ROCm - Polls up to 30s for readiness after auto-start - New env var: COMFYUI_DIR (default ~/ComfyUI) - FastMCP lifespan= wired in; 34/34 tests still passing Option B: Add comfyui.service systemd user service file - Install: cp mcp/mcp-image-gen/comfyui.service ~/.config/systemd/user/ - Enable: systemctl --user enable --now comfyui - Sets HSA_OVERRIDE_GFX_VERSION=11.0.0, WorkingDirectory=%h/ComfyUI - Restart=on-failure, logs via journald docs: Update mcp-image-gen-ComfyUI-Setup.md - New Step 4: systemd service install + linger instructions - Step 5: manual start (moved from old Step 4) - Step 6/7 renumbered; COMFYUI_DIR env var documented - Architecture diagram added; troubleshooting rows updated
This commit is contained in:
@@ -62,7 +62,52 @@ huggingface-cli download comfyanonymous/flux_text_encoders \
|
|||||||
--local-dir ~/ComfyUI/models/clip
|
--local-dir ~/ComfyUI/models/clip
|
||||||
```
|
```
|
||||||
|
|
||||||
## Step 4: Start ComfyUI
|
## Step 4: Install the systemd User Service (Recommended)
|
||||||
|
|
||||||
|
Installing ComfyUI as a systemd user service ensures it starts automatically on login and restarts on failure.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy the bundled service file to the systemd user directory
|
||||||
|
mkdir -p ~/.config/systemd/user
|
||||||
|
cp ~/pi_mcps/mcp/mcp-image-gen/comfyui.service ~/.config/systemd/user/comfyui.service
|
||||||
|
|
||||||
|
# Reload systemd, enable + start the service
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now comfyui
|
||||||
|
|
||||||
|
# Verify it is running
|
||||||
|
systemctl --user status comfyui
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ `HSA_OVERRIDE_GFX_VERSION=11.0.0` is already set in the service file — it is mandatory for RX 7900 XTX on ROCm. Without it, model loading fails silently.
|
||||||
|
|
||||||
|
### Enable lingering (start ComfyUI even without a login session)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
loginctl enable-linger $USER
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures the service starts at boot even before you log in — recommended for headless / homelab setups.
|
||||||
|
|
||||||
|
### Managing the service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Follow live logs
|
||||||
|
journalctl --user -u comfyui -f
|
||||||
|
|
||||||
|
# Restart after model changes
|
||||||
|
systemctl --user restart comfyui
|
||||||
|
|
||||||
|
# Stop temporarily
|
||||||
|
systemctl --user stop comfyui
|
||||||
|
|
||||||
|
# Disable autostart
|
||||||
|
systemctl --user disable comfyui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Manual Start (without systemd)
|
||||||
|
|
||||||
|
If you prefer to start ComfyUI manually (e.g. for debugging):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/ComfyUI
|
cd ~/ComfyUI
|
||||||
@@ -74,26 +119,36 @@ HSA_OVERRIDE_GFX_VERSION=11.0.0 \
|
|||||||
echo "ComfyUI PID: $!"
|
echo "ComfyUI PID: $!"
|
||||||
```
|
```
|
||||||
|
|
||||||
> ⚠️ `HSA_OVERRIDE_GFX_VERSION=11.0.0` is mandatory for RX 7900 XTX on ROCm. Without it, model loading fails silently.
|
## Step 6: Verify ComfyUI is Running
|
||||||
|
|
||||||
## Step 5: Verify ComfyUI is Running
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8188/system_stats
|
curl http://localhost:8188/system_stats
|
||||||
# Should return JSON with GPU info
|
# Should return JSON with GPU info
|
||||||
```
|
```
|
||||||
|
|
||||||
## Step 6: Configure mcp-image-gen
|
## Step 7: Configure mcp-image-gen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/pplate/pi_mcps/mcp/mcp-image-gen
|
cd /home/pplate/pi_mcps/mcp/mcp-image-gen
|
||||||
|
|
||||||
# Environment variables (set in .roo/mcp.json or shell):
|
# Environment variables (set in .roo/mcp.json or shell):
|
||||||
# COMFYUI_URL=http://localhost:8188
|
# COMFYUI_URL=http://localhost:8188 — ComfyUI API endpoint
|
||||||
# IMAGE_OUTPUT_DIR=~/Pictures/mcp-generated
|
# IMAGE_OUTPUT_DIR=~/Pictures/mcp-generated — where generated images are saved
|
||||||
# COMFYUI_TIMEOUT=120
|
# COMFYUI_TIMEOUT=120 — max wait time (seconds) per image
|
||||||
|
# COMFYUI_DIR=~/ComfyUI — path to ComfyUI install (used by auto-start)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Auto-start behaviour
|
||||||
|
|
||||||
|
`mcp-image-gen` includes a **startup health check** in its lifespan. Every time the MCP server starts it:
|
||||||
|
|
||||||
|
1. Pings `http://localhost:8188/system_stats`
|
||||||
|
2. **If reachable** — logs `ComfyUI is already running ✓` and proceeds normally.
|
||||||
|
3. **If not reachable** — attempts to launch ComfyUI as a background subprocess from `COMFYUI_DIR` using `.venv/bin/python main.py --listen --port 8188` with `HSA_OVERRIDE_GFX_VERSION=11.0.0` injected automatically.
|
||||||
|
4. Polls up to 30 s for ComfyUI to become ready.
|
||||||
|
|
||||||
|
With the systemd service enabled, step 3 is never needed in practice — but the check acts as a safety net.
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|
||||||
| GPU | Model | Resolution | Steps | Time |
|
| GPU | Model | Resolution | Steps | Time |
|
||||||
@@ -101,12 +156,28 @@ cd /home/pplate/pi_mcps/mcp/mcp-image-gen
|
|||||||
| AMD RX 7900 XTX | FLUX.1-schnell | 1024×1024 | 4 | ~8s |
|
| AMD RX 7900 XTX | FLUX.1-schnell | 1024×1024 | 4 | ~8s |
|
||||||
| AMD RX 7900 XTX | FLUX.1-schnell | 1280×512 | 4 | ~7s |
|
| AMD RX 7900 XTX | FLUX.1-schnell | 1280×512 | 4 | ~7s |
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
Boot
|
||||||
|
└─ systemd --user (comfyui.service)
|
||||||
|
└─ ComfyUI at localhost:8188
|
||||||
|
|
||||||
|
VS Code / Roo Code
|
||||||
|
└─ mcp-image-gen MCP server (stdio)
|
||||||
|
├─ lifespan startup: ping localhost:8188
|
||||||
|
│ └─ if down: subprocess.Popen ComfyUI, wait ≤30s
|
||||||
|
└─ tools: generate_image, list_available_models, …
|
||||||
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
| Problem | Solution |
|
| Problem | Solution |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `HTTP 401` downloading model | Accept FLUX license on HuggingFace first |
|
| `HTTP 401` downloading model | Accept FLUX license on HuggingFace first |
|
||||||
| GPU not detected | Ensure `HSA_OVERRIDE_GFX_VERSION=11.0.0` is set |
|
| GPU not detected | Ensure `HSA_OVERRIDE_GFX_VERSION=11.0.0` is set |
|
||||||
| `Connection refused` from mcp-image-gen | Start ComfyUI first, check port 8188 |
|
| `Connection refused` from mcp-image-gen | Check `systemctl --user status comfyui`; or set `COMFYUI_DIR` so auto-start can locate the install |
|
||||||
| Slow generation (>60s) | ComfyUI may be running on CPU — check ROCm install |
|
| Slow generation (>60s) | ComfyUI may be running on CPU — check ROCm install and `HSA_OVERRIDE_GFX_VERSION` |
|
||||||
| Ollama image gen | As of April 2026: macOS-only, not available on Linux |
|
| Ollama image gen | As of April 2026: macOS-only, not available on Linux |
|
||||||
|
| Auto-start logs | `journalctl --user -u comfyui -f` or check mcp-image-gen server logs |
|
||||||
|
| Service not starting at boot | Run `loginctl enable-linger $USER` to enable session-less startup |
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=ComfyUI — Local AI Image Generation (AMD ROCm / FLUX.1-schnell)
|
||||||
|
Documentation=https://github.com/comfyanonymous/ComfyUI
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=%h/ComfyUI
|
||||||
|
ExecStart=%h/ComfyUI/.venv/bin/python main.py --listen --port 8188
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
# AMD RX 7900 XTX ROCm GFX override — required for correct GPU detection
|
||||||
|
Environment=HSA_OVERRIDE_GFX_VERSION=11.0.0
|
||||||
|
|
||||||
|
# Redirect output — follow with: journalctl --user -u comfyui -f
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
+120
-33
@@ -4,16 +4,23 @@ import asyncio
|
|||||||
import base64
|
import base64
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
from mcp.types import ImageContent, TextContent
|
from mcp.types import ImageContent, TextContent
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
logger = logging.getLogger("mcp-image-gen")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Configuration
|
# Configuration
|
||||||
@@ -23,13 +30,112 @@ COMFYUI_URL = os.environ.get("COMFYUI_URL", "http://localhost:8188").rstrip("/")
|
|||||||
IMAGE_OUTPUT_DIR = os.environ.get("IMAGE_OUTPUT_DIR", "~/Pictures/mcp-generated")
|
IMAGE_OUTPUT_DIR = os.environ.get("IMAGE_OUTPUT_DIR", "~/Pictures/mcp-generated")
|
||||||
COMFYUI_TIMEOUT = int(os.environ.get("COMFYUI_TIMEOUT", "120"))
|
COMFYUI_TIMEOUT = int(os.environ.get("COMFYUI_TIMEOUT", "120"))
|
||||||
|
|
||||||
|
# Directory where ComfyUI is installed (used for auto-start only)
|
||||||
|
# Override via COMFYUI_DIR env var. Systemd service sets this automatically.
|
||||||
|
COMFYUI_DIR = Path(
|
||||||
|
os.environ.get("COMFYUI_DIR", "~/ComfyUI")
|
||||||
|
).expanduser().resolve()
|
||||||
|
|
||||||
# Maximum number of images allowed in a single batch call
|
# Maximum number of images allowed in a single batch call
|
||||||
MAX_COUNT = 10
|
MAX_COUNT = 10
|
||||||
|
|
||||||
# Path to the bundled FLUX.1-schnell workflow template
|
# Path to the bundled FLUX.1-schnell workflow template
|
||||||
_WORKFLOW_PATH = Path(__file__).parent / "workflows" / "flux_schnell.json"
|
_WORKFLOW_PATH = Path(__file__).parent / "workflows" / "flux_schnell.json"
|
||||||
|
|
||||||
mcp = FastMCP("mcp-image-gen")
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ComfyUI health check + auto-start
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _ping_comfyui(url: str, timeout: float = 5.0) -> bool:
|
||||||
|
"""Return True if ComfyUI is reachable at *url*/system_stats."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
resp = await client.get(f"{url}/system_stats")
|
||||||
|
return resp.status_code == 200
|
||||||
|
except (httpx.ConnectError, httpx.TimeoutException, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def check_and_start_comfyui() -> None:
|
||||||
|
"""Ping ComfyUI; if not reachable, attempt to launch it as a subprocess.
|
||||||
|
|
||||||
|
Called once at server startup from the lifespan context manager.
|
||||||
|
Uses COMFYUI_DIR to locate the installation and its venv Python.
|
||||||
|
The HSA_OVERRIDE_GFX_VERSION=11.0.0 env var is injected automatically
|
||||||
|
for AMD ROCm / RX 7900 XTX compatibility.
|
||||||
|
"""
|
||||||
|
if await _ping_comfyui(COMFYUI_URL):
|
||||||
|
logger.info("ComfyUI is already running at %s ✓", COMFYUI_URL)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"ComfyUI not reachable at %s — attempting to start from %s",
|
||||||
|
COMFYUI_URL, COMFYUI_DIR,
|
||||||
|
)
|
||||||
|
|
||||||
|
python = COMFYUI_DIR / ".venv" / "bin" / "python"
|
||||||
|
main_py = COMFYUI_DIR / "main.py"
|
||||||
|
|
||||||
|
if not python.exists():
|
||||||
|
logger.error(
|
||||||
|
"ComfyUI venv Python not found at %s. "
|
||||||
|
"Install ComfyUI first (see docs/wiki/pages/mcp-image-gen-ComfyUI-Setup.md).",
|
||||||
|
python,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not main_py.exists():
|
||||||
|
logger.error(
|
||||||
|
"ComfyUI main.py not found at %s — is COMFYUI_DIR correct?",
|
||||||
|
main_py,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build environment: inherit current env, set ROCm override for AMD RX 7900 XTX
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.setdefault("HSA_OVERRIDE_GFX_VERSION", "11.0.0")
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[str(python), str(main_py), "--listen", "--port", "8188"],
|
||||||
|
cwd=str(COMFYUI_DIR),
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True, # detach from MCP server process group
|
||||||
|
)
|
||||||
|
logger.info("ComfyUI launched (PID %d) — waiting for readiness…", proc.pid)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error("Failed to start ComfyUI subprocess: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Wait up to 30 s for ComfyUI to become ready (polls every 2 s)
|
||||||
|
wait_limit = 30
|
||||||
|
for attempt in range(wait_limit // 2):
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
if await _ping_comfyui(COMFYUI_URL):
|
||||||
|
logger.info(
|
||||||
|
"ComfyUI ready at %s after ~%ds ✓", COMFYUI_URL, (attempt + 1) * 2
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"ComfyUI did not respond within %ds. "
|
||||||
|
"Generation calls will fail until it is ready. "
|
||||||
|
"Check logs: journalctl --user -u comfyui -f",
|
||||||
|
wait_limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app):
|
||||||
|
"""FastMCP lifespan: run ComfyUI health check at server startup."""
|
||||||
|
await check_and_start_comfyui()
|
||||||
|
yield # server is live here
|
||||||
|
# Nothing to tear down — ComfyUI is managed by systemd, not this process
|
||||||
|
|
||||||
|
|
||||||
|
mcp = FastMCP("mcp-image-gen", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -332,40 +438,22 @@ async def _generate_single(
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def generate_image(
|
async def generate_image(
|
||||||
prompt: str,
|
prompt: Annotated[str, Field(description="Text description of the image to generate.")],
|
||||||
width: int = 1024,
|
width: Annotated[int, Field(description="Image width in pixels (default: 1024).")] = 1024,
|
||||||
height: int = 1024,
|
height: Annotated[int, Field(description="Image height in pixels (default: 1024).")] = 1024,
|
||||||
steps: int = 4,
|
steps: Annotated[int, Field(description="Number of inference steps. FLUX.1-schnell works well at 4.")] = 4,
|
||||||
model: str = "flux1-schnell.safetensors",
|
model: Annotated[str, Field(description="ComfyUI model filename (default: flux1-schnell.safetensors).")] = "flux1-schnell.safetensors",
|
||||||
seed: int = -1,
|
seed: Annotated[int, Field(description="Random seed for reproducibility. -1 = random. When count > 1 and seed != -1, seeds are incremented per image (seed, seed+1, seed+2, ...) to produce deterministic variation.")] = -1,
|
||||||
negative_prompt: str = "",
|
negative_prompt: Annotated[str, Field(description="Things to exclude from the image (optional).")] = "",
|
||||||
output_dir: str = "",
|
output_dir: Annotated[str, Field(description="Override output directory. Defaults to IMAGE_OUTPUT_DIR env var or ~/Pictures/mcp-generated.")] = "",
|
||||||
name: str = "",
|
name: Annotated[str, Field(description="Optional filename prefix. Saved as {name}_{timestamp}_{seed}.png. Useful to avoid confusion with auto-generated timestamp filenames.")] = "",
|
||||||
count: int = 1,
|
count: Annotated[int, Field(description="Number of images to generate (1–10). Each image is generated sequentially. Partial failures are returned inline — the batch continues even if one image fails.")] = 1,
|
||||||
) -> list:
|
) -> list:
|
||||||
"""Generate an image from a text prompt using ComfyUI.
|
"""Generate an image from a text prompt using ComfyUI.
|
||||||
|
|
||||||
Returns both a file path (for persistence) and an inline base64 image
|
Returns both a file path (for persistence) and an inline base64 image
|
||||||
(for display in Claude / Roo Code chat).
|
(for display in Claude / Roo Code chat).
|
||||||
|
|
||||||
Args:
|
|
||||||
prompt: Text description of the image to generate.
|
|
||||||
width: Image width in pixels (default: 1024).
|
|
||||||
height: Image height in pixels (default: 1024).
|
|
||||||
steps: Number of inference steps. FLUX.1-schnell works well at 4.
|
|
||||||
model: ComfyUI model filename (default: flux1-schnell.safetensors).
|
|
||||||
seed: Random seed for reproducibility. -1 = random.
|
|
||||||
When count > 1 and seed != -1, seeds are incremented per image
|
|
||||||
(seed, seed+1, seed+2, ...) to produce deterministic variation.
|
|
||||||
negative_prompt: Things to exclude from the image (optional).
|
|
||||||
output_dir: Override output directory. Defaults to IMAGE_OUTPUT_DIR env var
|
|
||||||
or ~/Pictures/mcp-generated.
|
|
||||||
name: Optional filename prefix. Saved as {name}_{timestamp}_{seed}.png.
|
|
||||||
Useful to avoid confusion with auto-generated timestamp filenames.
|
|
||||||
count: Number of images to generate (1–10). Each image is generated
|
|
||||||
sequentially. Partial failures are returned inline — the batch
|
|
||||||
continues even if one image fails.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Flat interleaved list: [TextContent1, ImageContent1, TextContent2, ImageContent2, ...]
|
Flat interleaved list: [TextContent1, ImageContent1, TextContent2, ImageContent2, ...]
|
||||||
On error for any single image, that slot contains only [TextContent(error)].
|
On error for any single image, that slot contains only [TextContent(error)].
|
||||||
@@ -442,12 +530,11 @@ async def list_available_models() -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def get_generation_status(prompt_id: str) -> dict:
|
async def get_generation_status(
|
||||||
|
prompt_id: Annotated[str, Field(description="The prompt ID returned by a previous generate_image call.")],
|
||||||
|
) -> dict:
|
||||||
"""Check the status of a queued or running generation job.
|
"""Check the status of a queued or running generation job.
|
||||||
|
|
||||||
Args:
|
|
||||||
prompt_id: The prompt ID returned by a previous generate_image call.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with 'status' key: "pending", "running", "completed", or "not_found".
|
Dict with 'status' key: "pending", "running", "completed", or "not_found".
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user