Compare commits

...

11 Commits

Author SHA1 Message Date
pplate 1340d3098f fix(mcp): finalize alwaysAllow restrictions in mcp.json 2026-04-04 12:16:14 +02:00
pplate 8cbeb6571b docs(mcp-image-gen): add USAGE.md and expand tests to 19 2026-04-04 12:16:03 +02:00
pplate b0ce5c55ed fix(mcp): further restrict alwaysAllow in mcp.json after merge 2026-04-04 12:15:58 +02:00
pplate ef960a4b59 feat(mcp): limit tools to fix overload (#1)
Restrict alwaysAllow in .roo/mcp.json to essential tools per server:
- git: 5 tools (status, diff, log, add, commit) — was wildcard *
- gitea: 8 tools (create/list/get/edit issues, PR, repo) — was wildcard *
- playwright: 6 tools (navigate, click, fill, screenshot, close, new_context) — was unrestricted

Reduces total registered tools from 105+ to ~40, eliminating context
bloat and VS Code/Roo registration failures.

Closes #1
2026-04-04 12:03:07 +02:00
Patrick Plate 93b250c7a1 Merge branch 'chore/roo/mcp-config-update' 2026-04-04 11:54:33 +02:00
Patrick Plate 0a58541f1e chore(roo): update mcp.json config 2026-04-04 11:54:26 +02:00
Patrick Plate b30919cabb Merge branch 'feat/mcp-image-gen/comfyui-image-generation-server' 2026-04-04 11:49:44 +02:00
Patrick Plate 8112ff2f12 feat(mcp-image-gen): scaffold ComfyUI-backed image generation MCP server
- FastMCP server with 4 tools: generate_image, list_available_models,
  get_generation_status, get_output_directory
- ComfyUI REST API client (httpx) polling lifecycle
- FLUX.1-schnell workflow JSON template
- Dual output: TextContent (path + seed) + ImageContent (base64 PNG)
- 14 passing pytest tests with respx HTTP mocking
- ROCm/AMD RX 7900 XTX optimized setup in README
- Ollama Linux migration path documented (future)
2026-04-04 11:49:31 +02:00
Patrick Plate ba7d4bc248 feat(roo): merge gitea-playwright-mcp into main 2026-04-04 11:14:53 +02:00
Patrick Plate 29d6463f7c feat(roo): add forgejo-mcp + playwright MCP to .roo/mcp.json
- forgejo-mcp v0.0.7 binary installed at ~/.local/bin/forgejo-mcp
  (downloaded from github.com/raohwork/forgejo-mcp releases)
  Enables: Issues, labels, milestones, wiki, PRs, releases via Gitea REST API
- @playwright/mcp added for browser automation (replaces archived puppeteer MCP)
- Gitea pi_mcps repo bootstrapped:
  Labels: bigmind, webscraper, cannamanage, roo, bug, feat, docs, chore
  Milestone: BigMind v3.1 (#1)
2026-04-04 11:14:52 +02:00
Patrick Plate 768201909a chore(roo): merge branching-strategy into main 2026-04-04 11:01:17 +02:00
13 changed files with 2636 additions and 2 deletions
+55 -2
View File
@@ -8,7 +8,13 @@
"/home/pplate/pi_mcps/"
],
"alwaysAllow": [
"*"
"git_status",
"git_diff_unstaged",
"git_log",
"git_add",
"git_commit",
"git_branch",
"git_create_branch"
]
},
"filesystem": {
@@ -30,6 +36,53 @@
"alwaysAllow": [
"webscraper_fetch"
]
},
"gitea": {
"command": "/home/pplate/.local/bin/forgejo-mcp",
"args": [
"stdio",
"--server",
"http://192.168.188.119:30008",
"--token",
"8bf0c734ebda3e61d9c9068489ce58a2bf8d33db"
],
"alwaysAllow": [
"create_issue",
"list_repo_issues",
"get_issue",
"edit_issue",
"create_issue_comment",
"create_pull_request",
"get_repository",
"list_my_repositories"
]
},
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
],
"alwaysAllow": [
"browser_navigate",
"browser_click",
"browser_fill",
"browser_screenshot",
"browser_close",
"browser_new_context"
]
},
"mcp-image-gen": {
"command": "uv",
"args": [
"--directory",
"/home/pplate/pi_mcps/mcp/mcp-image-gen",
"run",
"src/server.py"
],
"env": {
"COMFYUI_URL": "http://localhost:8188",
"IMAGE_OUTPUT_DIR": "/home/pplate/Pictures/mcp-generated"
}
}
}
}
}
+199
View File
@@ -0,0 +1,199 @@
# mcp-image-gen — Architecture Assessment
**Date:** 2026-04-04
**Author:** Lumen (for Patrick / pplate)
**Status:** ✅ APPROVED — ready for implementation
**BigMind Research Session:** `39809470-6ac8-4713-adf2-79ac0eb36ba7`
---
## 1. Problem Statement
LLM agents (Claude, local models via Ollama) have no native ability to generate images. While
language models excel at text, creative and technical workflows increasingly need image output —
concept art, diagrams, product mockups, illustrations — all driven by a text prompt.
A FastMCP wrapper around a local image generation backend would give any MCP-capable IDE or
agent the ability to produce images on demand, with full control over resolution, steps, model,
and seed — without sending data to external cloud APIs.
**Gap being filled:** Local AI image generation accessible to LLM agents via MCP protocol,
running entirely on Patrick's AMD RX 7900 XTX (24GB VRAM) with ROCm.
---
## 2. Requirements
### 2.1 Functional Requirements
| ID | Requirement |
|----|-------------|
| F-1 | Generate an image from a text prompt |
| F-2 | Support configurable resolution (width × height) |
| F-3 | Support configurable inference steps and seed for reproducibility |
| F-4 | Support negative prompts to exclude unwanted content |
| F-5 | List available models from the backend |
| F-6 | Check the status of an in-progress generation job |
| F-7 | Return generated image as both a file path AND inline base64 for agent display |
| F-8 | Configure output directory for saved images |
| F-9 | Support FLUX.1-schnell as the default model |
### 2.2 Non-Functional Requirements
| ID | Requirement |
|----|-------------|
| NF-1 | Generation time < 30 seconds for FLUX.1-schnell at 1024×1024, 4 steps |
| NF-2 | VRAM footprint < 12GB (leaves headroom on 24GB for Ollama co-existence) |
| NF-3 | Must work on AMD ROCm — no CUDA-only dependencies in the MCP server layer |
| NF-4 | No cloud API calls — fully local execution |
| NF-5 | Graceful error messages when ComfyUI is not running |
| NF-6 | MCP tools must work with FastMCP and be discoverable by Claude / Roo Code |
---
## 3. Technology Decision
### 3.1 Candidate Backends
| Backend | Stars | ROCm | REST API | FLUX Support | Verdict |
|---------|-------|------|----------|--------------|---------|
| **ComfyUI** | 108k | ✅ Native | ✅ localhost:8188 | ✅ FLUX.1-schnell, FLUX.1-dev | ✅ **CHOSEN** |
| stable-diffusion.cpp | ~15k | ✅ ROCm/Vulkan | ❌ CLI only | ✅ FLUX.1-schnell | ⚠️ Viable alternative |
| PyTorch + diffusers | — | ✅ ROCm 7.2.1 | ❌ No REST | ✅ All models | ❌ Too complex to manage |
| Ollama image gen | — | ❌ Linux: N/A | ✅ /api/generate | ✅ FLUX.2, Z-Image | ❌ macOS-only as of April 2026 |
| A1111 / Forge WebUI | — | ⚠️ Limited | ✅ :7860 | ❌ SDXL primary | ❌ Not FLUX-native |
### 3.2 Why ComfyUI
1. **ROCm native** — ComfyUI's PyTorch backend runs on AMD GPUs via ROCm without forks or patches.
2. **REST API** — ComfyUI exposes a stable HTTP API at `localhost:8188` making it trivially
wrappable with `httpx`. No subprocess management or binary spawning needed.
3. **Workflow-based** — ComfyUI workflows are JSON graphs. The MCP server ships a minimal
FLUX.1-schnell workflow that can be parameterized with prompt, size, steps, seed at runtime.
4. **Model ecosystem** — ComfyUI's model manager supports FLUX.1, SDXL, SD3.5, ControlNet,
LoRA — giving a future-proof upgrade path.
5. **Community size** — 108k GitHub stars; extensive community support, model nodes, extensions.
6. **VRAM efficiency** — FLUX.1-schnell requires ~8GB VRAM. Patrick's 24GB card runs it
comfortably alongside Ollama.
### 3.3 Why NOT the Alternatives
- **Ollama:** Definitively blocked on Linux until further notice. No ETA for Linux image gen.
- **stable-diffusion.cpp:** CLI-based only — the MCP server would need to manage a subprocess,
parse stdout, handle crashes. More fragile than an HTTP API.
- **PyTorch + diffusers direct:** Requires managing Python environments, device placement, model
loading, memory management inside the MCP server process — adds significant complexity and
risk of VRAM conflicts.
---
## 4. Architecture Decision
### 4.1 System Overview
```
┌─────────────────────────────────────────────────────────┐
│ LLM Agent (Claude / Roo Code / local Ollama) │
└───────────────────────────┬─────────────────────────────┘
│ MCP Protocol (stdio)
┌───────────────────────────▼─────────────────────────────┐
│ mcp-image-gen (FastMCP Python server) │
│ │
│ Tools: │
│ • generate_image(prompt, width, height, steps, ...) │
│ • list_available_models() │
│ • get_generation_status(prompt_id) │
│ • get_output_directory() │
└───────────────────────────┬─────────────────────────────┘
│ HTTP REST (httpx)
┌───────────────────────────▼─────────────────────────────┐
│ ComfyUI (localhost:8188) │
│ AMD ROCm + PyTorch │
│ FLUX.1-schnell model │
└─────────────────────────────────────────────────────────┘
┌───────▼───────┐
│ ~/Pictures/ │
│ mcp-generated│
└───────────────┘
```
### 4.2 Key Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| HTTP client | `httpx` (async) | Already used in webscraper; async-friendly; clean timeout handling |
| Image return | dual: path + base64 | File path for persistence; base64 `ImageContent` for inline Claude display |
| ImageContent type | `mcp.types.ImageContent` | FastMCP 3.x: **never** use `fastmcp.utilities.types.Image` with `-> Image` annotation — it breaks serialization. Return `ImageContent` directly as a `ContentBlock`. |
| Job polling | loop with sleep | ComfyUI `/api/queue` returns pending/running/done status; poll until done or timeout |
| Workflow format | ComfyUI API JSON | Minimal FLUX.1-schnell graph parameterized at runtime |
| Config | env vars | `COMFYUI_URL`, `IMAGE_OUTPUT_DIR` — no hardcoded paths |
| Output naming | `{timestamp}_{seed}.png` | Reproducible, collision-free, sortable |
---
## 5. Risks
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| ComfyUI not running when tool is called | High | High | Return clear error: "ComfyUI not reachable at {url}. Start with: `python main.py --listen`" |
| Generation timeout (>60s) | Medium | Medium | Configurable timeout; return partial status message with `prompt_id` so agent can poll manually |
| VRAM contention with Ollama | Medium | Medium | FLUX.1-schnell uses ~8GB; 24GB card has 16GB headroom. Document that running both simultaneously may compete at >8GB Ollama model sizes |
| ROCm driver instability | Low | High | ComfyUI falls back to CPU if ROCm unavailable — slow but functional. Document ROCm setup. |
| ComfyUI API changes | Low | Medium | Pin ComfyUI version in setup docs; the `/api/prompt`, `/api/queue`, `/api/view` endpoints are stable |
| Large output files | Low | Low | PNG default; add optional JPEG quality param in v2 |
| Malformed workflow JSON | Low | High | Ship a tested, minimal FLUX.1-schnell workflow; validate before submit |
---
## 6. Alternatives Considered
### 6.1 Ollama (Blocked)
Ollama added image generation in January 2026 (Z-Image Turbo, FLUX.2 Klein) but the feature is
**macOS-only** as of April 2026. Linux support is listed as "coming soon" with no ETA. This was
the originally preferred path (uniform API with text generation), but it is not viable on Fedora
Linux today.
**Migration path:** When Ollama Linux image gen ships, a thin backend adapter can be added to
`mcp-image-gen` so it routes to Ollama instead of ComfyUI — same MCP tool signatures, different
HTTP target.
### 6.2 stable-diffusion.cpp
DiffuGen MCP server uses this approach. Requires:
- Building sd.cpp with ROCm/Vulkan flags
- Spawning a subprocess and parsing CLI output
- No REST API — process management in Python
Viable but more fragile than ComfyUI's HTTP API. Chosen only if ComfyUI proves unworkable.
### 6.3 diffusers (Python library, direct)
Would run diffusion pipeline inside the MCP server process. Problems:
- MCP server process cannot easily share GPU memory with Ollama
- Model loading adds 5-15s cold start to every MCP invocation
- Complex device placement / fp16 / ROCm configuration in server code
- Risk: VRAM OOM crashes the MCP server process entirely
---
## 7. Success Criteria
| Criterion | Measure |
|-----------|---------|
| `generate_image` returns a valid PNG | File exists on disk, base64 decodes to valid PNG bytes |
| Claude can display the image inline | `ImageContent` returned in tool response, visible in Roo Code chat |
| FLUX.1-schnell at 1024×1024 4-step completes in <30s | Measured on RX 7900 XTX with ROCm |
| `list_available_models` returns ComfyUI model list | At minimum includes `flux1-schnell.safetensors` |
| ComfyUI offline → clear error, not crash | Tool returns error string, no MCP server exception |
| All pytest tests pass | `uv run pytest tests/ -v` exits 0 with ≥80% coverage |
| Server wired into `.roo/mcp.json` | Tool appears in Roo Code MCP tool list |
---
## 8. Open Questions
| # | Question | Owner | Priority |
|---|----------|-------|----------|
| Q1 | Should `generate_image` be synchronous (block until done) or return a `prompt_id` immediately? | Patrick | High — MVP will be synchronous; async polling is v2 |
| Q2 | Default output directory: `~/Pictures/mcp-generated` or `~/mcp-images`? | Patrick | Low — configurable via env var |
| Q3 | Should we support SDXL as a second model in v1, or FLUX.1-schnell only? | Patrick | Low — FLUX.1-schnell only for v1 |
| Q4 | WebSocket API vs REST polling for job status? | — | ComfyUI has both; REST polling is simpler for v1 |
+496
View File
@@ -0,0 +1,496 @@
# mcp-image-gen — Implementation Plan
**Date:** 2026-04-04
**Author:** Lumen (for Patrick / pplate)
**Status:** Ready for implementation
**Assessment:** [ASSESSMENT.md](./ASSESSMENT.md)
**Research Session:** `39809470-6ac8-4713-adf2-79ac0eb36ba7`
---
## 1. Directory Structure
```
mcp/mcp-image-gen/
├── ASSESSMENT.md ← Architecture assessment (this session)
├── PLAN.md ← This file
├── README.md ← Usage docs, tool table, env vars
├── pyproject.toml ← uv project + deps
├── run.sh ← Launch script (used by .roo/mcp.json)
├── src/
│ ├── __init__.py
│ ├── server.py ← FastMCP server + all tools
│ └── workflows/
│ └── flux_schnell.json ← Minimal ComfyUI API-format workflow
└── tests/
├── __init__.py
├── conftest.py ← sys.path + shared fixtures
└── test_server.py ← All tool tests (mocked ComfyUI)
```
---
## 2. Tool Definitions
### 2.1 `generate_image`
```python
@mcp.tool()
async def generate_image(
prompt: str,
width: int = 1024,
height: int = 1024,
steps: int = 4,
model: str = "flux1-schnell.safetensors",
seed: int = -1,
negative_prompt: str = "",
output_dir: str = "",
) -> list:
"""
Generate an image from a text prompt using ComfyUI.
Returns both a file path (for persistence) and an inline base64 image
(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.
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.
Returns:
[TextContent(path + metadata), ImageContent(base64 PNG)]
"""
```
**Return type:** `list` containing:
1. `mcp.types.TextContent` — human-readable summary with file path, seed, elapsed time
2. `mcp.types.ImageContent``type="image"`, `data=base64_encoded_png`, `mimeType="image/png"`
> ⚠️ **FastMCP 3.x rule:** NEVER annotate return as `-> Image` (fastmcp utility type). It triggers
> `output_schema` generation which breaks the early-return path. Return `mcp.types.ImageContent`
> directly as part of a `list` — it is a `ContentBlock` and passes through cleanly.
---
### 2.2 `list_available_models`
```python
@mcp.tool()
async def list_available_models() -> str:
"""
List all checkpoint models available in ComfyUI.
Returns a newline-separated list of model filenames.
Requires ComfyUI to be running at COMFYUI_URL.
"""
```
**Implementation:** `GET {COMFYUI_URL}/object_info/CheckpointLoaderSimple` → parse
`input.required.ckpt_name[0]` list → join with newlines.
---
### 2.3 `get_generation_status`
```python
@mcp.tool()
async def get_generation_status(prompt_id: str) -> str:
"""
Check the status of a queued or running generation job.
Args:
prompt_id: The prompt ID returned by a previous generate_image call.
Returns:
Status string: "pending", "running", "completed", or "not_found".
"""
```
**Implementation:** `GET {COMFYUI_URL}/api/queue` → check `queue_running` and `queue_pending`
lists for matching `prompt_id`. If not found in either, check history endpoint.
---
### 2.4 `get_output_directory`
```python
@mcp.tool()
def get_output_directory() -> str:
"""
Return the directory where generated images are saved.
Returns:
Absolute path to the output directory.
"""
```
**Implementation:** Resolve `IMAGE_OUTPUT_DIR` env var or default `~/Pictures/mcp-generated`,
expand `~`, return as string.
---
## 3. ComfyUI Integration
### 3.1 Workflow: Submit → Poll → Retrieve
```
generate_image()
├── 1. Load flux_schnell.json workflow template
├── 2. Parameterize: inject prompt, width, height, steps, seed, model
├── 3. POST {COMFYUI_URL}/api/prompt → {"prompt_id": "uuid"}
├── 4. POLL loop (max 120s, sleep 2s between)
│ GET {COMFYUI_URL}/api/queue
│ → check queue_running[].prompt_id == our id
│ → check queue_pending[].prompt_id == our id
│ → if neither: job is done
├── 5. GET {COMFYUI_URL}/api/history/{prompt_id}
│ → find output image filename + subfolder
├── 6. GET {COMFYUI_URL}/api/view?filename={name}&subfolder={subfolder}&type=output
│ → raw PNG bytes
├── 7. Save PNG to output_dir/{timestamp}_{seed}.png
└── 8. Return [TextContent(path + meta), ImageContent(base64)]
```
### 3.2 API Endpoints Used
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/prompt` | POST | Submit workflow for generation |
| `/api/queue` | GET | Poll queue status (pending + running) |
| `/api/history/{prompt_id}` | GET | Get completed job output filenames |
| `/api/view` | GET | Download image bytes by filename |
| `/object_info/CheckpointLoaderSimple` | GET | List available checkpoint models |
### 3.3 Error Handling
| Condition | Response |
|-----------|----------|
| ComfyUI unreachable | `"ComfyUI not reachable at {url}. Start it with: python main.py --listen"` |
| Timeout (>120s) | `"Generation timed out after 120s. prompt_id={id} — use get_generation_status to check"` |
| ComfyUI returns error in history | Extract and return the error message from history response |
| Invalid model name | ComfyUI returns error in history; surface it clearly |
| Output dir not writable | `"Cannot write to output directory: {path}"` |
---
## 4. Configuration
All configuration via environment variables. No hardcoded paths.
| Variable | Default | Description |
|----------|---------|-------------|
| `COMFYUI_URL` | `http://localhost:8188` | Base URL of running ComfyUI instance |
| `IMAGE_OUTPUT_DIR` | `~/Pictures/mcp-generated` | Where to save generated PNG files |
| `COMFYUI_TIMEOUT` | `120` | Max seconds to wait for generation (int) |
### `.roo/mcp.json` entry (to be added during implementation):
```json
"mcp-image-gen": {
"command": "uv",
"args": [
"--directory", "/home/pplate/pi_mcps/mcp/mcp-image-gen",
"run", "src/server.py"
],
"env": {
"COMFYUI_URL": "http://localhost:8188",
"IMAGE_OUTPUT_DIR": "/home/pplate/Pictures/mcp-generated"
}
}
```
---
## 5. `pyproject.toml`
```toml
[project]
name = "mcp-image-gen"
version = "0.1.0"
requires-python = ">=3.11"
description = "MCP server for local AI image generation via ComfyUI"
dependencies = [
"fastmcp>=0.1.0",
"httpx>=0.27.0",
"pillow>=10.0.0",
]
[project.optional-dependencies]
test = [
"pytest>=7.0",
"pytest-mock>=3.0",
"pytest-cov>=4.0",
"pytest-asyncio>=0.23",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pytest.ini_options]
asyncio_mode = "auto"
```
**Dependency rationale:**
- `fastmcp` — MCP framework
- `httpx` — async HTTP client for ComfyUI REST API
- `pillow` — validate PNG output, potential future thumbnail generation
- `pytest-asyncio` — needed for async tool tests
---
## 6. FLUX.1-schnell Workflow JSON
The minimal ComfyUI API-format workflow for FLUX.1-schnell text-to-image.
This is the "API format" (node-graph JSON), not the UI export format.
File: `src/workflows/flux_schnell.json`
```json
{
"6": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": ["30", 1],
"text": "PROMPT_PLACEHOLDER"
}
},
"8": {
"class_type": "VAEDecode",
"inputs": {
"samples": ["13", 0],
"vae": ["30", 2]
}
},
"9": {
"class_type": "SaveImage",
"inputs": {
"filename_prefix": "mcp-image-gen",
"images": ["8", 0]
}
},
"13": {
"class_type": "KSampler",
"inputs": {
"cfg": 1.0,
"denoise": 1.0,
"latent_image": ["27", 0],
"model": ["30", 0],
"negative": ["33", 0],
"positive": ["6", 0],
"sampler_name": "euler",
"scheduler": "simple",
"seed": 42,
"steps": 4
}
},
"27": {
"class_type": "EmptySD3LatentImage",
"inputs": {
"batch_size": 1,
"height": 1024,
"width": 1024
}
},
"30": {
"class_type": "CheckpointLoaderSimple",
"inputs": {
"ckpt_name": "flux1-schnell.safetensors"
}
},
"33": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": ["30", 1],
"text": "NEGATIVE_PLACEHOLDER"
}
}
}
```
**Parameterization at runtime** (in `server.py`):
```python
import json, copy
def _build_workflow(prompt, negative_prompt, width, height, steps, seed, model):
with open(Path(__file__).parent / "workflows/flux_schnell.json") as f:
wf = json.load(f)
wf = copy.deepcopy(wf)
wf["6"]["inputs"]["text"] = prompt
wf["33"]["inputs"]["text"] = negative_prompt
wf["27"]["inputs"]["width"] = width
wf["27"]["inputs"]["height"] = height
wf["13"]["inputs"]["steps"] = steps
wf["13"]["inputs"]["seed"] = seed if seed != -1 else random.randint(0, 2**32 - 1)
wf["30"]["inputs"]["ckpt_name"] = model
return wf
```
---
## 7. Testing Strategy
### 7.1 Test Structure (`tests/test_server.py`)
All tests mock `httpx.AsyncClient` — no real ComfyUI needed.
| Test | Description |
|------|-------------|
| `test_generate_image_happy_path` | Mock submit → poll done → history → view → returns TextContent + ImageContent |
| `test_generate_image_comfyui_offline` | httpx.ConnectError → returns clear error string |
| `test_generate_image_timeout` | Poll loop exceeds COMFYUI_TIMEOUT → returns timeout message with prompt_id |
| `test_generate_image_saves_file` | Verify PNG written to output_dir with correct filename pattern |
| `test_generate_image_random_seed` | seed=-1 → seed in output filename is a valid integer |
| `test_generate_image_custom_params` | Non-default width/height/steps/model passed through to workflow |
| `test_generate_image_returns_image_content` | Second item in result list is `mcp.types.ImageContent` with valid base64 |
| `test_list_available_models_happy_path` | Mock object_info response → returns model name list |
| `test_list_available_models_offline` | ConnectError → returns error string |
| `test_get_generation_status_pending` | prompt_id found in queue_pending → "pending" |
| `test_get_generation_status_running` | prompt_id found in queue_running → "running" |
| `test_get_generation_status_not_found` | prompt_id not in queue, not in history → "not_found" |
| `test_get_output_directory_default` | No env var → returns expanded ~/Pictures/mcp-generated |
| `test_get_output_directory_custom` | IMAGE_OUTPUT_DIR set → returns that path |
| `test_build_workflow_parameterization` | _build_workflow() injects all params correctly into JSON |
### 7.2 conftest.py fixtures
```python
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
@pytest.fixture
def mock_comfyui_submit_response():
return {"prompt_id": "test-uuid-1234"}
@pytest.fixture
def mock_comfyui_queue_empty():
return {"queue_running": [], "queue_pending": []}
@pytest.fixture
def mock_comfyui_history():
return {
"test-uuid-1234": {
"outputs": {
"9": {
"images": [{"filename": "mcp-image-gen_00001_.png", "subfolder": "", "type": "output"}]
}
}
}
}
@pytest.fixture
def sample_png_bytes():
"""Minimal valid 1x1 PNG in bytes."""
import base64
# 1x1 red pixel PNG
data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=="
return base64.b64decode(data)
```
### 7.3 Run command
```bash
cd mcp/mcp-image-gen && uv run pytest tests/ -v --cov=src --cov-report=term-missing
```
---
## 8. `run.sh`
```bash
#!/usr/bin/env bash
BASEDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PATH="$HOME/.local/bin:$PATH"
# Create output dir if it doesn't exist
OUTPUT_DIR="${IMAGE_OUTPUT_DIR:-$HOME/Pictures/mcp-generated}"
mkdir -p "$OUTPUT_DIR"
cd "$BASEDIR"
exec uv run src/server.py
```
---
## 9. Future: Ollama Migration Path
When Ollama adds Linux image generation support (ETA unknown, announced "coming soon" April 2026):
### Adapter pattern (no breaking changes to MCP tool signatures)
```python
BACKEND = os.getenv("IMAGE_BACKEND", "comfyui") # or "ollama"
async def _generate_comfyui(prompt, width, height, steps, model, seed, negative_prompt, output_dir):
# current ComfyUI implementation
...
async def _generate_ollama(prompt, width, height, steps, model, seed, negative_prompt, output_dir):
# POST http://localhost:11434/api/generate
# with model=Z-Image-Turbo or FLUX.2-Klein
# width, height, steps in request body
# save returned image path
...
@mcp.tool()
async def generate_image(prompt, width=1024, height=1024, steps=4, ...):
if BACKEND == "ollama":
return await _generate_ollama(...)
return await _generate_comfyui(...)
```
**No changes to:** tool signatures, return types, env vars (add `IMAGE_BACKEND`), tests structure.
---
## 10. Implementation Order (for Code mode)
1. `src/workflows/flux_schnell.json` — write and validate JSON structure
2. `pyproject.toml` — set up project + deps
3. `src/__init__.py` — empty
4. `src/server.py` — implement all 4 tools + `_build_workflow` + polling helpers
5. `tests/conftest.py` — fixtures + sys.path
6. `tests/test_server.py` — all 15 tests
7. `run.sh` — launch script
8. `README.md` — usage docs
9. `.roo/mcp.json` — wire server in (requires switching to Code or Homelab mode for that file)
10. `uv sync && uv run pytest tests/ -v` — confirm all tests pass
---
## 11. ComfyUI Setup Notes (for README)
These are prerequisites for the MCP server to work. Patrick must have ComfyUI installed:
```bash
# Install ComfyUI (ROCm/AMD)
pip install comfyui
# Download FLUX.1-schnell model (~8GB)
# Place in ComfyUI/models/checkpoints/flux1-schnell.safetensors
# Source: https://huggingface.co/black-forest-labs/FLUX.1-schnell
# Start ComfyUI with AMD ROCm
HSA_OVERRIDE_GFX_VERSION=11.0.0 python main.py --listen
# Verify API is running
curl http://localhost:8188/system_stats
```
> The `HSA_OVERRIDE_GFX_VERSION=11.0.0` env var may be needed for RX 7900 XTX (gfx1100)
> to identify correctly to ROCm libraries.
+178
View File
@@ -0,0 +1,178 @@
# mcp-image-gen
**FastMCP server for AI image generation via ComfyUI.**
This MCP server wraps a locally running [ComfyUI](https://github.com/comfyanonymous/ComfyUI) instance, exposing image generation as MCP tools callable from Roo Code, Claude Desktop, or any MCP-compatible client. It supports FLUX.1-schnell, FLUX.1-dev, SDXL, and any other ComfyUI-compatible checkpoint model. Generated images are saved to disk **and** returned as inline base64 so Claude can display them directly in chat.
---
## Prerequisites
1. **ComfyUI** installed and running at `http://localhost:8188`
2. At least one checkpoint model downloaded (see ComfyUI Setup below)
3. **Python 3.11+** and **uv** installed on the system
---
## Installation
```bash
cd mcp/mcp-image-gen
uv sync
```
---
## Configuration
All configuration is via environment variables:
| Variable | Default | Description |
|---|---|---|
| `COMFYUI_URL` | `http://localhost:8188` | Base URL of the running ComfyUI instance |
| `IMAGE_OUTPUT_DIR` | `~/Pictures/mcp-generated` | Directory where generated PNG files are saved |
| `COMFYUI_TIMEOUT` | `120` | Max seconds to wait for generation before timeout |
---
## Usage
### Add to `.roo/mcp.json` (Roo Code)
```json
"mcp-image-gen": {
"command": "uv",
"args": [
"--directory", "/home/pplate/pi_mcps/mcp/mcp-image-gen",
"run", "src/server.py"
],
"env": {
"COMFYUI_URL": "http://localhost:8188",
"IMAGE_OUTPUT_DIR": "/home/pplate/Pictures/mcp-generated"
}
}
```
### Add to Claude Desktop (`claude_desktop_config.json`)
```json
{
"mcpServers": {
"mcp-image-gen": {
"command": "uv",
"args": [
"--directory", "/home/pplate/pi_mcps/mcp/mcp-image-gen",
"run", "src/server.py"
],
"env": {
"COMFYUI_URL": "http://localhost:8188",
"IMAGE_OUTPUT_DIR": "/home/pplate/Pictures/mcp-generated"
}
}
}
}
```
### Run directly
```bash
cd mcp/mcp-image-gen
./run.sh
```
---
## Available Tools
| Tool | Description |
|---|---|
| `generate_image` | Generate an image from a text prompt. Returns file path + inline base64 PNG. |
| `list_available_models` | List all checkpoint models loaded in ComfyUI. |
| `get_generation_status` | Check status of a running/queued generation by `prompt_id`. |
| `get_output_directory` | Return the current output directory path. |
### `generate_image` parameters
| Parameter | Default | Description |
|---|---|---|
| `prompt` | *(required)* | Text description of the image |
| `width` | `1024` | Image width in pixels |
| `height` | `1024` | Image height in pixels |
| `steps` | `4` | Inference steps (FLUX.1-schnell: 4 is optimal) |
| `model` | `flux1-schnell.safetensors` | Checkpoint model filename |
| `seed` | `-1` | Seed for reproducibility (`-1` = random) |
| `negative_prompt` | `""` | Things to exclude from the image |
| `output_dir` | *(IMAGE_OUTPUT_DIR)* | Override output directory |
---
## ComfyUI Setup (Fedora + AMD ROCm)
```bash
# Install ComfyUI
pip install comfyui
# Download FLUX.1-schnell model (~8GB, Apache 2.0)
# Place in: ComfyUI/models/checkpoints/flux1-schnell.safetensors
# Source: https://huggingface.co/black-forest-labs/FLUX.1-schnell
# Start ComfyUI with ROCm support for AMD RX 7900 XTX
HSA_OVERRIDE_GFX_VERSION=11.0.0 python main.py --listen
# Verify the API is reachable
curl http://localhost:8188/system_stats
```
> **Note:** `HSA_OVERRIDE_GFX_VERSION=11.0.0` may be needed for the RX 7900 XTX (gfx1100)
> to be recognized correctly by ROCm libraries.
### PyTorch with ROCm (if needed separately)
```bash
pip install torch torchvision --index-url https://download.pytorch.org/whl/rocm6.1
```
---
## Testing
```bash
cd mcp/mcp-image-gen
uv run pytest tests/ -v
```
All tests mock the ComfyUI HTTP API — no running ComfyUI instance needed.
---
## Ollama Migration Path
When Ollama adds Linux image generation support (announced "coming soon" as of April 2026, currently macOS-only), this server can switch backends via a single env var:
```bash
IMAGE_BACKEND=ollama # currently only "comfyui" is implemented
```
The tool signatures, return types, and MCP interface will remain unchanged — only the underlying HTTP calls switch from ComfyUI to Ollama's `/api/generate` endpoint.
---
## Architecture
```
Roo Code / Claude Desktop
│ MCP (stdio)
mcp-image-gen (FastMCP)
│ HTTP REST
ComfyUI @ localhost:8188
│ ROCm / AMD GPU
FLUX.1-schnell / SDXL / SD3.5
```
The server submits a FLUX.1-schnell ComfyUI API-format workflow, polls until complete, downloads the PNG, saves it to disk, and returns both a text summary and a base64-encoded inline image.
+588
View File
@@ -0,0 +1,588 @@
# mcp-image-gen — Usage Guide
> **Comprehensive reference for using the ComfyUI-backed image generation MCP server**
---
## Table of Contents
1. [Prerequisites — ComfyUI Setup](#1-prerequisites--comfyui-setup)
2. [Quick Start — Running the MCP Server](#2-quick-start--running-the-mcp-server)
3. [How to Ask Lumen to Generate Images](#3-how-to-ask-lumen-to-generate-images)
4. [Available Tools](#4-available-tools)
5. [Parameters Reference](#5-parameters-reference)
6. [Output Format](#6-output-format)
7. [Environment Variables](#7-environment-variables)
8. [Test Status](#8-test-status)
9. [Prompt Tips for FLUX.1-schnell](#9-prompt-tips-for-flux1-schnell)
10. [Known Limitations](#10-known-limitations)
---
## 1. Prerequisites — ComfyUI Setup
### ComfyUI must be running before any image generation tool call succeeds.
The MCP server connects to ComfyUI's REST API at `http://localhost:8188`. If ComfyUI is not running, `generate_image` and `list_available_models` will return a graceful error message — no crash.
### Install ComfyUI
```bash
# Option A — pip install (simplest)
pip install comfyui
# Option B — git clone (more control)
git clone https://github.com/comfyanonymous/ComfyUI.git
cd ComfyUI
pip install -r requirements.txt
```
### Install PyTorch with ROCm (AMD RX 7900 XTX)
Patrick's RX 7900 XTX (gfx1100, 24GB VRAM) uses the ROCm backend. Standard CUDA builds **will not work** on AMD hardware.
```bash
# PyTorch with ROCm 6.1 support
pip install torch torchvision --index-url https://download.pytorch.org/whl/rocm6.1
```
> **ROCm version note:** ROCm 7.2.1 is the current production release as of April 2026.
> Check `rocm-smi` to confirm your ROCm version before installing torch.
### Download FLUX.1-schnell (Primary Model)
FLUX.1-schnell is the recommended model — fast (4 steps), Apache 2.0 licensed, excellent quality.
```bash
# Download (~8GB) — place in ComfyUI/models/checkpoints/
wget https://huggingface.co/black-forest-labs/FLUX.1-schnell/resolve/main/flux1-schnell.safetensors \
-O ~/ComfyUI/models/checkpoints/flux1-schnell.safetensors
# Or use huggingface_hub:
huggingface-cli download black-forest-labs/FLUX.1-schnell \
flux1-schnell.safetensors \
--local-dir ~/ComfyUI/models/checkpoints/
```
You'll also need the CLIP and VAE models — see the [ComfyUI FLUX guide](https://github.com/comfyanonymous/ComfyUI/blob/master/README.md) for full model list.
### Start ComfyUI (AMD ROCm)
```bash
# Standard start — listens on all interfaces at port 8188
HSA_OVERRIDE_GFX_VERSION=11.0.0 python main.py --listen
# Or with explicit port
HSA_OVERRIDE_GFX_VERSION=11.0.0 python main.py --listen --port 8188
```
> **`HSA_OVERRIDE_GFX_VERSION=11.0.0`** — Required for RX 7900 XTX (gfx1100).
> Without this, ROCm may fail to detect the GPU correctly. This tells the HIP runtime
> to treat the GPU as gfx1100 architecture.
### Verify ComfyUI is Running
```bash
curl -s http://localhost:8188/system_stats | python3 -m json.tool | head -20
```
Expected response includes `system` object with `python_version`, `pytorch_version`, `embedded_python`, and `comfyui_version`.
---
## 2. Quick Start — Running the MCP Server
### Via `run.sh` (recommended)
```bash
cd /home/pplate/pi_mcps/mcp/mcp-image-gen
./run.sh
```
[`run.sh`](run.sh) automatically:
- Sets `PATH` to include `~/.local/bin` for `uv`
- Creates `IMAGE_OUTPUT_DIR` (`~/Pictures/mcp-generated`) if it doesn't exist
- Launches the FastMCP server via `uv run src/server.py` (stdio transport)
### Via uv directly
```bash
cd /home/pplate/pi_mcps/mcp/mcp-image-gen
uv run src/server.py
```
### Wired into `.roo/mcp.json`
The server is already configured in [`.roo/mcp.json`](../../.roo/mcp.json):
```json
"mcp-image-gen": {
"command": "uv",
"args": [
"--directory", "/home/pplate/pi_mcps/mcp/mcp-image-gen",
"run", "src/server.py"
],
"env": {
"COMFYUI_URL": "http://localhost:8188",
"IMAGE_OUTPUT_DIR": "/home/pplate/Pictures/mcp-generated"
}
}
```
Roo Code / Claude Desktop will auto-start the server when any image generation tool is invoked. The MCP server itself starts in ~1 second — ComfyUI must already be running separately.
### Install dependencies (first time)
```bash
cd /home/pplate/pi_mcps/mcp/mcp-image-gen
uv sync
```
---
## 3. How to Ask Lumen to Generate Images
Just speak naturally. Lumen will call the appropriate MCP tool automatically.
### Basic generation
> *"Generate an image of a futuristic city at sunset"*
```
→ generate_image(prompt="futuristic city at sunset", width=1024, height=1024, steps=4)
```
### Specific style and size
> *"Create a portrait of a red fox in watercolor style, 1024x1024"*
```
→ generate_image(
prompt="portrait of a red fox, watercolor style, detailed fur, soft brushstrokes",
width=1024, height=1024
)
```
### Reproducible with a fixed seed
> *"Make an image with seed 42 so I can reproduce it"*
```
→ generate_image(prompt="...", seed=42)
```
The seed is reported in the text output so you can use the same seed again.
### Landscape format
> *"Generate a wide cinematic landscape of a Norwegian fjord, 1920x1080"*
```
→ generate_image(prompt="Norwegian fjord, cinematic, golden hour", width=1920, height=1080)
```
### Excluding unwanted elements
> *"Generate a clean product photo of a coffee mug, no background clutter, no text"*
```
→ generate_image(
prompt="product photo of a ceramic coffee mug, studio lighting, white background",
negative_prompt="clutter, text, watermark, blurry, shadows"
)
```
### More inference steps for higher quality
> *"Generate a highly detailed oil painting of a medieval castle, use 20 steps"*
```
→ generate_image(
prompt="oil painting of a medieval castle, highly detailed, dramatic lighting",
steps=20,
model="flux1-dev.safetensors" # FLUX.1-dev supports higher step counts better
)
```
### Check what models are available
> *"List what models are available in ComfyUI"*
```
→ list_available_models()
```
### Check status of a long-running job
> *"What's the status of prompt ID abc-123?"*
```
→ get_generation_status(prompt_id="abc-123")
```
### Find out where images are saved
> *"Where are my generated images being saved?"*
```
→ get_output_directory()
```
---
## 4. Available Tools
### `generate_image`
Generate an image from a text prompt using ComfyUI's FLUX.1-schnell workflow.
**Full signature:**
```python
async def generate_image(
prompt: str,
width: int = 1024,
height: int = 1024,
steps: int = 4,
model: str = "flux1-schnell.safetensors",
seed: int = -1,
negative_prompt: str = "",
output_dir: str = "",
) -> list[TextContent | ImageContent]
```
**What it does:**
1. Loads the bundled `flux_schnell.json` ComfyUI API workflow template
2. Injects your prompt, dimensions, seed, model into the correct workflow nodes
3. Submits the workflow to ComfyUI via `POST /api/prompt`
4. Polls `/api/queue` every 2 seconds until the job leaves the queue
5. Fetches history via `/api/history/{prompt_id}` to find the output filename
6. Downloads the PNG from `/api/view`
7. Saves the PNG to disk as `YYYYMMDD_HHMMSS_{seed}.png`
8. Returns `[TextContent(path + metadata), ImageContent(base64 PNG)]`
---
### `list_available_models`
List all checkpoint models currently available in ComfyUI.
```python
async def list_available_models() -> list[str]
```
Calls `/object_info/CheckpointLoaderSimple` and extracts the checkpoint name list. Use this to discover what models are installed before passing a `model` name to `generate_image`.
**Example return:**
```json
["flux1-schnell.safetensors", "flux1-dev.safetensors", "sd_xl_base_1.0.safetensors"]
```
---
### `get_generation_status`
Check the status of a queued or running generation job.
```python
async def get_generation_status(prompt_id: str) -> dict
```
**Return values:**
| `status` | Meaning |
|---|---|
| `"pending"` | Job is in the queue, not yet started |
| `"running"` | Job is currently being processed |
| `"completed"` | Job finished — image is in ComfyUI's history |
| `"not_found"` | Unknown prompt_id — may have expired from history |
| `"error"` | ComfyUI was unreachable |
Useful when `generate_image` times out (default 120s) — the job may still be running in ComfyUI.
---
### `get_output_directory`
Return the absolute path where generated images will be saved.
```python
def get_output_directory() -> str
```
Returns the expanded, absolute path derived from `IMAGE_OUTPUT_DIR` env var (or `~/Pictures/mcp-generated` default). The directory may not exist yet — `generate_image` creates it on first use.
---
## 5. Parameters Reference
Full parameter table for `generate_image`:
| Parameter | Type | Default | Description |
|---|---|---|---|
| `prompt` | `str` | *(required)* | Text description of the image. Goes into the positive CLIP text encoder node. |
| `width` | `int` | `1024` | Image width in pixels. FLUX.1-schnell: 5122048 recommended. |
| `height` | `int` | `1024` | Image height in pixels. FLUX.1-schnell: 5122048 recommended. |
| `steps` | `int` | `4` | Number of KSampler inference steps. FLUX.1-schnell is designed for 18 steps. |
| `model` | `str` | `"flux1-schnell.safetensors"` | Checkpoint model filename as listed by `list_available_models`. |
| `seed` | `int` | `-1` | RNG seed for reproducibility. `-1` = new random seed each call (0 to 2³²−1). |
| `negative_prompt` | `str` | `""` | Text description of things to exclude. Goes into negative CLIP encoder node. |
| `output_dir` | `str` | `""` | Override save directory. Empty = uses `IMAGE_OUTPUT_DIR` env var or default. |
### Recommended dimensions
| Use case | Width | Height |
|---|---|---|
| Square (default) | 1024 | 1024 |
| Portrait | 768 | 1024 |
| Landscape | 1024 | 768 |
| Widescreen | 1280 | 720 |
| HD widescreen | 1920 | 1080 |
| Tall portrait | 512 | 768 |
> **VRAM note:** Patrick's RX 7900 XTX has 24GB VRAM. FLUX.1-schnell requires ~8GB,
> so you can comfortably run 1920×1080 and even larger. FLUX.1-dev requires ~12GB.
---
## 6. Output Format
`generate_image` returns a list with **two items** when successful:
### Item 1 — `TextContent` (file path + metadata)
```
Generated: /home/pplate/Pictures/mcp-generated/20260404_121500_3847291045.png
Seed: 3847291045
Elapsed: 8.3s
Size: 1024x1024, Steps: 4, Model: flux1-schnell.safetensors
```
The filename format is `YYYYMMDD_HHMMSS_{seed}.png` — the seed is embedded so you can reproduce the exact image by passing it back as the `seed` parameter.
### Item 2 — `ImageContent` (inline base64 PNG)
The image displays **directly in Roo Code / Claude Desktop chat** as an inline image — no need to open a file browser. The same PNG is also saved to disk at the path shown in the TextContent.
```json
{
"type": "image",
"mimeType": "image/png",
"data": "<base64-encoded PNG bytes>"
}
```
### Error responses
When ComfyUI is unreachable or an error occurs, only **one** `TextContent` is returned (no ImageContent):
```
ComfyUI not reachable at http://localhost:8188. Start it with: python main.py --listen
```
```
Generation timed out after 120s. prompt_id=abc-123 — use get_generation_status to check
```
---
## 7. Environment Variables
Configure via environment variables in [`.roo/mcp.json`](../../.roo/mcp.json) or shell:
| Variable | Default | Description |
|---|---|---|
| `COMFYUI_URL` | `http://localhost:8188` | Base URL of the running ComfyUI REST API. Change this if ComfyUI runs on a different host or port. |
| `IMAGE_OUTPUT_DIR` | `~/Pictures/mcp-generated` | Directory where generated PNG files are saved. Supports `~` expansion. Created automatically on first generation. |
| `COMFYUI_TIMEOUT` | `120` | Maximum seconds to wait for a generation job before returning a timeout error. Increase for very large images or slow hardware. |
### Setting via shell
```bash
export COMFYUI_URL="http://localhost:8188"
export IMAGE_OUTPUT_DIR="/home/pplate/Pictures/ai-art"
export COMFYUI_TIMEOUT="300"
./run.sh
```
### Setting via mcp.json env block
```json
"mcp-image-gen": {
"command": "uv",
"args": ["--directory", "/home/pplate/pi_mcps/mcp/mcp-image-gen", "run", "src/server.py"],
"env": {
"COMFYUI_URL": "http://localhost:8188",
"IMAGE_OUTPUT_DIR": "/home/pplate/Pictures/mcp-generated",
"COMFYUI_TIMEOUT": "120"
}
}
```
---
## 8. Test Status
**19 pytest tests — all passing.** Tests mock all ComfyUI HTTP calls using [respx](https://lundberg.github.io/respx/). No running ComfyUI instance is needed to run the tests.
```bash
cd /home/pplate/pi_mcps/mcp/mcp-image-gen
uv run pytest tests/ -v
```
### Test coverage breakdown
| Test file | Tests | Coverage area |
|---|---|---|
| [`tests/test_server.py`](tests/test_server.py) | 19 | All 4 tools + workflow builder |
| Test name | What it verifies |
|---|---|
| `test_build_flux_workflow_structure` | Workflow has correct node class_types |
| `test_build_flux_workflow_params_injected` | All params injected into correct nodes |
| `test_negative_prompt_included` | Negative prompt goes to node 33 |
| `test_random_seed_generated` | `seed=-1` produces a valid integer in `_meta` |
| `test_list_available_models` | Returns model list from mocked `/object_info` |
| `test_list_available_models_comfyui_offline` | ConnectError → graceful error string |
| `test_get_generation_status_pending` | `prompt_id` in queue_pending → `"pending"` |
| `test_get_generation_status_running` | `prompt_id` in queue_running → `"running"` |
| `test_get_generation_status_complete` | Not in queue + in history → `"completed"` |
| `test_get_output_directory_default` | No env var → `~/Pictures/mcp-generated` expanded |
| `test_get_output_directory_custom` | Custom env var → that path returned |
| `test_generate_image_success` | Full lifecycle: queue→poll→history→view→save |
| `test_generate_image_comfyui_unavailable` | ConnectError → single TextContent error |
| `test_generate_image_timeout` | COMFYUI_TIMEOUT=0 → timeout TextContent |
| `test_generate_image_empty_prompt` | Empty string prompt → still succeeds |
| `test_generate_image_long_prompt` | 500-char prompt → not truncated, succeeds |
| `test_generate_image_invalid_model` | 404 from /prompt → error TextContent, no file saved |
| `test_generate_image_custom_output_dir` | Custom `output_dir` param → saved there, dir created |
| `test_generate_image_random_seed_variance` | `seed=-1` × 2 → different seeds, different filenames |
### Test mock stack
- **[respx](https://lundberg.github.io/respx/)** — HTTP-level mocking for all ComfyUI API endpoints
- **[Pillow](https://pillow.readthedocs.io/)** (in conftest) — generates real PNG bytes for image response fixtures
- **monkeypatch** — env vars (`IMAGE_OUTPUT_DIR`, `COMFYUI_URL`, `COMFYUI_TIMEOUT`) and server module attributes
Real image generation requires ComfyUI to be running. Tests prove the tool logic is correct at the protocol level.
---
## 9. Prompt Tips for FLUX.1-schnell
FLUX.1-schnell is a guidance-distilled model designed for speed at 18 steps. It responds differently from SDXL or SD1.5.
### Prompt structure that works well
```
[subject], [style/medium], [lighting], [camera/composition], [mood/atmosphere], [quality modifiers]
```
**Example:**
```
ancient library at night, oil painting, warm candlelight, wide angle, mysterious atmosphere, highly detailed, sharp focus
```
### Style keywords
| Style | Prompt keywords |
|---|---|
| Photography | `cinematic photograph, DSLR, 85mm lens, shallow depth of field, bokeh` |
| Oil painting | `oil painting, thick brushstrokes, textured canvas, impressionist` |
| Watercolor | `watercolor painting, soft washes, paper texture, flowing colors` |
| Digital art | `digital art, concept art, artstation, octane render` |
| Anime/illustration | `anime style, cel shading, vibrant colors, clean linework` |
| Sketch | `pencil sketch, hand drawn, crosshatching, charcoal` |
### Lighting keywords
- `golden hour`, `blue hour`, `dramatic lighting`, `rim lighting`
- `studio lighting`, `soft diffused light`, `volumetric light`
- `neon glow`, `bioluminescent`, `moonlit`, `candlelight`
### What works well with FLUX.1-schnell
- **Clear subject + style** — "red panda in a cozy library, watercolor style"
- **Landscape scenes** — fjords, forests, cities, abstract environments
- **Portrait shots** — animals and characters with descriptive appearance
- **Concept art** — futuristic cities, sci-fi environments, fantasy scenes
- **Low step counts** — 4 steps is designed to be near-optimal for this model
### What to avoid
- **Booru-style tag dumps** (FLUX handles natural language better than SD1.5)
- **Contradictory instructions** — "dark AND bright", "realistic AND cartoon"
- **Overly complex scenes** at very small resolutions
### Using the negative prompt
FLUX.1-schnell has reduced CFG guidance so negative prompts have less impact than in SDXL.
Use them for broad exclusions:
```
negative_prompt="blurry, out of focus, watermark, text, signature, low quality, artifacts"
```
### Reproducibility
Always save the seed from the TextContent output if you want to reproduce a result:
```
Seed: 3847291045
```
Then pass it back: `seed=3847291045`
---
## 10. Known Limitations
### ComfyUI must run locally
The MCP server connects to `COMFYUI_URL` (default: `http://localhost:8188`). ComfyUI is a local application — it does not have a cloud API. You must start it before requesting image generation. The server returns a clear error message if ComfyUI is not reachable.
### Model must be pre-loaded
ComfyUI loads checkpoint models into VRAM on first use. The first generation with a model takes longer as VRAM is allocated (FLUX.1-schnell: ~8GB). Subsequent generations with the same model are faster.
```bash
# Verify model is installed before generation
# → ask Lumen: "list available models in ComfyUI"
```
### AMD ROCm setup complexity
AMD GPU support requires:
1. ROCm drivers installed (`rocm-smi` working)
2. PyTorch built with ROCm support (not the default CUDA build)
3. `HSA_OVERRIDE_GFX_VERSION=11.0.0` for RX 7900 XTX (gfx1100)
Without these, ComfyUI will fall back to CPU — very slow (minutes per image vs. ~8 seconds on RX 7900 XTX).
Check GPU is being used:
```bash
# In another terminal while generating:
watch -n 1 rocm-smi
# VRAM usage should spike to ~8GB during generation
```
### Timeout on large images
The default `COMFYUI_TIMEOUT=120` (2 minutes) may not be enough for:
- Very large resolutions (2048×2048+)
- High step counts (20+)
- First generation loading a new model
Increase via env var:
```bash
export COMFYUI_TIMEOUT=300 # 5 minutes
```
If `generate_image` returns a timeout error, the job may still be running in ComfyUI. Use `get_generation_status(prompt_id)` to check.
### Ollama image gen is macOS-only (April 2026)
Ollama launched experimental image generation in January 2026, but it is **macOS-only** as of April 2026. Linux support is announced as "coming soon." When Linux support arrives, the server can switch backends via `IMAGE_BACKEND=ollama` without changing any tool signatures.
### ComfyUI history is ephemeral
ComfyUI keeps generation history in memory — it is lost on restart. The `get_generation_status` tool will return `"not_found"` for old prompt IDs after a ComfyUI restart. The saved PNG file on disk persists regardless.
+41
View File
@@ -0,0 +1,41 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "mcp-image-gen"
version = "0.1.0"
description = "MCP server for AI image generation via ComfyUI (FLUX, SDXL)"
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
authors = [{name = "Patrick Plate", email = "patrickplate@gmx.de"}]
dependencies = [
"fastmcp>=2.0.0",
"httpx>=0.27.0",
"pillow>=10.0.0",
]
[tool.hatch.version]
path = "src/__init__.py"
[tool.hatch.build.targets.sdist]
include = ["/src", "/tests"]
[tool.hatch.build.targets.wheel]
include = ["/src", "/tests"]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = "test_*.py"
python_classes = "Test*"
python_functions = "test_*"
asyncio_mode = "auto"
[dependency-groups]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"respx>=0.21.0",
"pillow>=10.0.0",
]
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Run mcp-image-gen MCP server
set -euo pipefail
BASEDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PATH="$HOME/.local/bin:$PATH"
# Create output dir if it doesn't exist
OUTPUT_DIR="${IMAGE_OUTPUT_DIR:-$HOME/Pictures/mcp-generated}"
mkdir -p "$OUTPUT_DIR"
cd "$BASEDIR"
exec uv run src/server.py
View File
+384
View File
@@ -0,0 +1,384 @@
"""mcp-image-gen — FastMCP server for AI image generation via ComfyUI."""
import asyncio
import base64
import copy
import json
import os
import random
import time
from datetime import datetime
from pathlib import Path
import httpx
from fastmcp import FastMCP
from mcp.types import ImageContent, TextContent
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
COMFYUI_URL = os.environ.get("COMFYUI_URL", "http://localhost:8188").rstrip("/")
IMAGE_OUTPUT_DIR = os.environ.get("IMAGE_OUTPUT_DIR", "~/Pictures/mcp-generated")
COMFYUI_TIMEOUT = int(os.environ.get("COMFYUI_TIMEOUT", "120"))
# Path to the bundled FLUX.1-schnell workflow template
_WORKFLOW_PATH = Path(__file__).parent / "workflows" / "flux_schnell.json"
mcp = FastMCP("mcp-image-gen")
# ---------------------------------------------------------------------------
# ComfyUI client
# ---------------------------------------------------------------------------
class ComfyUIClient:
"""Async HTTP client wrapper for the ComfyUI REST API."""
def __init__(self, base_url: str = COMFYUI_URL):
self.base_url = base_url.rstrip("/")
async def queue_prompt(self, workflow: dict) -> str:
"""Submit a workflow to ComfyUI and return the prompt_id."""
payload = {"prompt": workflow}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(f"{self.base_url}/api/prompt", json=payload)
resp.raise_for_status()
return resp.json()["prompt_id"]
async def get_status(self, prompt_id: str) -> dict:
"""Return the current queue state (queue_running + queue_pending lists)."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{self.base_url}/api/queue")
resp.raise_for_status()
return resp.json()
async def get_history(self, prompt_id: str) -> dict:
"""Return the history entry for a completed prompt_id."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{self.base_url}/api/history/{prompt_id}")
resp.raise_for_status()
return resp.json()
async def get_image(self, filename: str, subfolder: str, folder_type: str) -> bytes:
"""Download image bytes from ComfyUI's /api/view endpoint."""
params = {"filename": filename, "subfolder": subfolder, "type": folder_type}
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.get(f"{self.base_url}/api/view", params=params)
resp.raise_for_status()
return resp.content
async def get_models(self) -> list[str]:
"""Return the list of available checkpoint model filenames."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{self.base_url}/object_info/CheckpointLoaderSimple"
)
resp.raise_for_status()
data = resp.json()
# ComfyUI returns: {"CheckpointLoaderSimple": {"input": {"required": {"ckpt_name": [["model1.safetensors", ...], ...]}}}}
node_info = data.get("CheckpointLoaderSimple", {})
ckpt_list = (
node_info.get("input", {})
.get("required", {})
.get("ckpt_name", [[]])[0]
)
return ckpt_list if isinstance(ckpt_list, list) else []
# ---------------------------------------------------------------------------
# Workflow builder
# ---------------------------------------------------------------------------
def build_flux_workflow(
prompt: str,
neg_prompt: str,
width: int,
height: int,
steps: int,
seed: int,
model: str,
) -> dict:
"""Build a ComfyUI API-format workflow dict for FLUX.1-schnell text-to-image.
This is a pure function — no I/O, fully testable.
"""
with open(_WORKFLOW_PATH) as f:
wf = json.load(f)
wf = copy.deepcopy(wf)
actual_seed = seed if seed != -1 else random.randint(0, 2**32 - 1)
wf["6"]["inputs"]["text"] = prompt
wf["33"]["inputs"]["text"] = neg_prompt
wf["27"]["inputs"]["width"] = width
wf["27"]["inputs"]["height"] = height
wf["13"]["inputs"]["steps"] = steps
wf["13"]["inputs"]["seed"] = actual_seed
wf["30"]["inputs"]["ckpt_name"] = model
# Attach the actual seed as metadata so callers can retrieve it
wf["_meta"] = {"actual_seed": actual_seed}
return wf
# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------
@mcp.tool()
async def generate_image(
prompt: str,
width: int = 1024,
height: int = 1024,
steps: int = 4,
model: str = "flux1-schnell.safetensors",
seed: int = -1,
negative_prompt: str = "",
output_dir: str = "",
) -> list:
"""Generate an image from a text prompt using ComfyUI.
Returns both a file path (for persistence) and an inline base64 image
(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.
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.
Returns:
[TextContent(path + metadata), ImageContent(base64 PNG)]
"""
# Resolve output directory
resolved_output_dir = Path(
output_dir or IMAGE_OUTPUT_DIR
).expanduser().resolve()
client = ComfyUIClient(COMFYUI_URL)
# Build and submit workflow
try:
workflow = build_flux_workflow(
prompt=prompt,
neg_prompt=negative_prompt,
width=width,
height=height,
steps=steps,
seed=seed,
model=model,
)
actual_seed = workflow["_meta"]["actual_seed"]
prompt_id = await client.queue_prompt(workflow)
except httpx.ConnectError:
return [
TextContent(
type="text",
text=(
f"ComfyUI not reachable at {COMFYUI_URL}. "
"Start it with: python main.py --listen"
),
)
]
except httpx.HTTPStatusError as e:
return [
TextContent(
type="text",
text=f"ComfyUI returned an error: {e.response.status_code}{e.response.text}",
)
]
# Poll until done
start = time.time()
while True:
elapsed = time.time() - start
if elapsed > COMFYUI_TIMEOUT:
return [
TextContent(
type="text",
text=(
f"Generation timed out after {COMFYUI_TIMEOUT}s. "
f"prompt_id={prompt_id} — use get_generation_status to check"
),
)
]
try:
queue = await client.get_status(prompt_id)
except (httpx.ConnectError, httpx.HTTPStatusError):
await asyncio.sleep(2)
continue
running_ids = [item[1] for item in queue.get("queue_running", [])]
pending_ids = [item[1] for item in queue.get("queue_pending", [])]
if prompt_id not in running_ids and prompt_id not in pending_ids:
break # Job is done
await asyncio.sleep(2)
elapsed = time.time() - start
# Retrieve history to find output filename
try:
history = await client.get_history(prompt_id)
except (httpx.ConnectError, httpx.HTTPStatusError) as e:
return [
TextContent(
type="text",
text=f"Failed to retrieve generation history: {e}",
)
]
job = history.get(prompt_id, {})
outputs = job.get("outputs", {})
# Find SaveImage node output (node "9" in our workflow)
image_info = None
for node_id, node_output in outputs.items():
images = node_output.get("images", [])
if images:
image_info = images[0]
break
if not image_info:
return [
TextContent(
type="text",
text=f"No output image found in history for prompt_id={prompt_id}",
)
]
# Download image bytes
try:
image_bytes = await client.get_image(
filename=image_info["filename"],
subfolder=image_info.get("subfolder", ""),
folder_type=image_info.get("type", "output"),
)
except (httpx.ConnectError, httpx.HTTPStatusError) as e:
return [
TextContent(
type="text",
text=f"Failed to download generated image: {e}",
)
]
# Save to disk
try:
resolved_output_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{timestamp}_{actual_seed}.png"
out_path = resolved_output_dir / filename
out_path.write_bytes(image_bytes)
except OSError as e:
return [
TextContent(
type="text",
text=f"Cannot write to output directory: {resolved_output_dir}{e}",
)
]
# Encode as base64 for inline display
b64_data = base64.b64encode(image_bytes).decode("utf-8")
return [
TextContent(
type="text",
text=(
f"Generated: {out_path}\n"
f"Seed: {actual_seed}\n"
f"Elapsed: {elapsed:.1f}s\n"
f"Size: {width}x{height}, Steps: {steps}, Model: {model}"
),
),
ImageContent(
type="image",
data=b64_data,
mimeType="image/png",
),
]
@mcp.tool()
async def list_available_models() -> list[str]:
"""List all checkpoint models available in ComfyUI.
Returns a list of model filenames available for use with generate_image.
Requires ComfyUI to be running at COMFYUI_URL.
"""
client = ComfyUIClient(COMFYUI_URL)
try:
return await client.get_models()
except httpx.ConnectError:
return [
f"ComfyUI not reachable at {COMFYUI_URL}. "
"Start it with: python main.py --listen"
]
except httpx.HTTPStatusError as e:
return [f"ComfyUI error: {e.response.status_code}"]
@mcp.tool()
async def get_generation_status(prompt_id: str) -> dict:
"""Check the status of a queued or running generation job.
Args:
prompt_id: The prompt ID returned by a previous generate_image call.
Returns:
Dict with 'status' key: "pending", "running", "completed", or "not_found".
"""
client = ComfyUIClient(COMFYUI_URL)
try:
queue = await client.get_status(prompt_id)
running_ids = [item[1] for item in queue.get("queue_running", [])]
pending_ids = [item[1] for item in queue.get("queue_pending", [])]
if prompt_id in running_ids:
return {"status": "running", "prompt_id": prompt_id}
if prompt_id in pending_ids:
return {"status": "pending", "prompt_id": prompt_id}
# Not in queue — check history
try:
history = await client.get_history(prompt_id)
if prompt_id in history:
return {"status": "completed", "prompt_id": prompt_id}
except (httpx.ConnectError, httpx.HTTPStatusError):
pass
return {"status": "not_found", "prompt_id": prompt_id}
except httpx.ConnectError:
return {
"status": "error",
"message": f"ComfyUI not reachable at {COMFYUI_URL}",
}
except httpx.HTTPStatusError as e:
return {"status": "error", "message": f"HTTP {e.response.status_code}"}
@mcp.tool()
def get_output_directory() -> str:
"""Return the directory where generated images are saved.
Returns:
Absolute path to the output directory (may not exist yet).
"""
return str(Path(IMAGE_OUTPUT_DIR).expanduser().resolve())
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
mcp.run(transport="stdio")
@@ -0,0 +1,59 @@
{
"6": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": ["30", 1],
"text": "PROMPT_PLACEHOLDER"
}
},
"8": {
"class_type": "VAEDecode",
"inputs": {
"samples": ["13", 0],
"vae": ["30", 2]
}
},
"9": {
"class_type": "SaveImage",
"inputs": {
"filename_prefix": "mcp-image-gen",
"images": ["8", 0]
}
},
"13": {
"class_type": "KSampler",
"inputs": {
"cfg": 1.0,
"denoise": 1.0,
"latent_image": ["27", 0],
"model": ["30", 0],
"negative": ["33", 0],
"positive": ["6", 0],
"sampler_name": "euler",
"scheduler": "simple",
"seed": 42,
"steps": 4
}
},
"27": {
"class_type": "EmptySD3LatentImage",
"inputs": {
"batch_size": 1,
"height": 1024,
"width": 1024
}
},
"30": {
"class_type": "CheckpointLoaderSimple",
"inputs": {
"ckpt_name": "flux1-schnell.safetensors"
}
},
"33": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": ["30", 1],
"text": "NEGATIVE_PLACEHOLDER"
}
}
}
View File
+76
View File
@@ -0,0 +1,76 @@
"""Pytest fixtures for mcp-image-gen tests."""
import base64
import io
import sys
from pathlib import Path
import pytest
# Make src/ importable
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
@pytest.fixture(autouse=True)
def comfyui_url(monkeypatch):
"""Set COMFYUI_URL to a test URL for all tests."""
monkeypatch.setenv("COMFYUI_URL", "http://test-comfyui:8188")
# Also patch the module-level constant in server
import server
monkeypatch.setattr(server, "COMFYUI_URL", "http://test-comfyui:8188")
@pytest.fixture
def sample_image_bytes():
"""Generate a 1x1 red pixel PNG as bytes using Pillow."""
from PIL import Image
img = Image.new("RGB", (1, 1), color=(255, 0, 0))
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
@pytest.fixture
def mock_history_response():
"""Sample ComfyUI history response for prompt_id='test-uuid-1234'."""
return {
"test-uuid-1234": {
"outputs": {
"9": {
"images": [
{
"filename": "mcp-image-gen_00001_.png",
"subfolder": "",
"type": "output",
}
]
}
},
"status": {"completed": True},
}
}
@pytest.fixture
def queue_empty():
"""ComfyUI queue response with nothing running or pending."""
return {"queue_running": [], "queue_pending": []}
@pytest.fixture
def queue_with_pending():
"""ComfyUI queue response with our test prompt pending."""
return {
"queue_running": [],
"queue_pending": [[1, "test-uuid-1234", {}, {}]],
}
@pytest.fixture
def queue_with_running():
"""ComfyUI queue response with our test prompt running."""
return {
"queue_running": [[1, "test-uuid-1234", {}, {}]],
"queue_pending": [],
}
+547
View File
@@ -0,0 +1,547 @@
"""Tests for mcp-image-gen server — all ComfyUI HTTP calls mocked via respx."""
import base64
import json
import os
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
import respx
# Import the server module (sys.path set by conftest.py)
import server
from server import (
ComfyUIClient,
build_flux_workflow,
generate_image,
get_generation_status,
get_output_directory,
list_available_models,
)
COMFYUI_BASE = "http://test-comfyui:8188"
# ---------------------------------------------------------------------------
# build_flux_workflow — pure function, no mocking needed
# ---------------------------------------------------------------------------
def test_build_flux_workflow_structure():
"""Verify build_flux_workflow returns a dict with correct node types."""
wf = build_flux_workflow(
prompt="a red cat",
neg_prompt="ugly",
width=512,
height=768,
steps=8,
seed=42,
model="flux1-schnell.safetensors",
)
assert wf["6"]["class_type"] == "CLIPTextEncode"
assert wf["8"]["class_type"] == "VAEDecode"
assert wf["9"]["class_type"] == "SaveImage"
assert wf["13"]["class_type"] == "KSampler"
assert wf["27"]["class_type"] == "EmptySD3LatentImage"
assert wf["30"]["class_type"] == "CheckpointLoaderSimple"
assert wf["33"]["class_type"] == "CLIPTextEncode"
def test_build_flux_workflow_params_injected():
"""Verify all parameters are injected into correct nodes."""
wf = build_flux_workflow(
prompt="a blue whale",
neg_prompt="cartoonish",
width=512,
height=768,
steps=8,
seed=12345,
model="sdxl.safetensors",
)
assert wf["6"]["inputs"]["text"] == "a blue whale"
assert wf["33"]["inputs"]["text"] == "cartoonish"
assert wf["27"]["inputs"]["width"] == 512
assert wf["27"]["inputs"]["height"] == 768
assert wf["13"]["inputs"]["steps"] == 8
assert wf["13"]["inputs"]["seed"] == 12345
assert wf["30"]["inputs"]["ckpt_name"] == "sdxl.safetensors"
def test_negative_prompt_included():
"""Verify negative prompt appears in workflow node 33 when provided."""
wf = build_flux_workflow(
prompt="forest",
neg_prompt="blurry, dark",
width=1024,
height=1024,
steps=4,
seed=1,
model="flux1-schnell.safetensors",
)
assert wf["33"]["inputs"]["text"] == "blurry, dark"
def test_random_seed_generated():
"""seed=-1 generates a random seed each call."""
wf1 = build_flux_workflow("cat", "", 512, 512, 4, -1, "flux1-schnell.safetensors")
wf2 = build_flux_workflow("cat", "", 512, 512, 4, -1, "flux1-schnell.safetensors")
seed1 = wf1["_meta"]["actual_seed"]
seed2 = wf2["_meta"]["actual_seed"]
# Both are valid integers
assert isinstance(seed1, int)
assert 0 <= seed1 < 2**32
# With overwhelming probability they differ
# (1/2^32 chance of collision — negligible for a test)
# We just verify _meta is populated
assert "_meta" in wf1
assert "_meta" in wf2
# ---------------------------------------------------------------------------
# list_available_models
# ---------------------------------------------------------------------------
@respx.mock
@pytest.mark.asyncio
async def test_list_available_models():
"""Mock /object_info, verify model list is returned."""
mock_response = {
"CheckpointLoaderSimple": {
"input": {
"required": {
"ckpt_name": [
["flux1-schnell.safetensors", "sdxl.safetensors"],
{},
]
}
}
}
}
respx.get(f"{COMFYUI_BASE}/object_info/CheckpointLoaderSimple").mock(
return_value=httpx.Response(200, json=mock_response)
)
result = await list_available_models()
assert "flux1-schnell.safetensors" in result
assert "sdxl.safetensors" in result
@respx.mock
@pytest.mark.asyncio
async def test_list_available_models_comfyui_offline():
"""When ComfyUI is unreachable, list_available_models returns error message."""
respx.get(f"{COMFYUI_BASE}/object_info/CheckpointLoaderSimple").mock(
side_effect=httpx.ConnectError("connection refused")
)
result = await list_available_models()
assert len(result) == 1
assert "not reachable" in result[0].lower()
# ---------------------------------------------------------------------------
# get_generation_status
# ---------------------------------------------------------------------------
@respx.mock
@pytest.mark.asyncio
async def test_get_generation_status_pending(queue_with_pending):
"""prompt_id in queue_pending → status is 'pending'."""
respx.get(f"{COMFYUI_BASE}/api/queue").mock(
return_value=httpx.Response(200, json=queue_with_pending)
)
result = await get_generation_status("test-uuid-1234")
assert result["status"] == "pending"
assert result["prompt_id"] == "test-uuid-1234"
@respx.mock
@pytest.mark.asyncio
async def test_get_generation_status_running(queue_with_running):
"""prompt_id in queue_running → status is 'running'."""
respx.get(f"{COMFYUI_BASE}/api/queue").mock(
return_value=httpx.Response(200, json=queue_with_running)
)
result = await get_generation_status("test-uuid-1234")
assert result["status"] == "running"
@respx.mock
@pytest.mark.asyncio
async def test_get_generation_status_complete(queue_empty, mock_history_response):
"""prompt_id not in queue + found in history → status is 'completed'."""
respx.get(f"{COMFYUI_BASE}/api/queue").mock(
return_value=httpx.Response(200, json=queue_empty)
)
respx.get(f"{COMFYUI_BASE}/api/history/test-uuid-1234").mock(
return_value=httpx.Response(200, json=mock_history_response)
)
result = await get_generation_status("test-uuid-1234")
assert result["status"] == "completed"
# ---------------------------------------------------------------------------
# get_output_directory
# ---------------------------------------------------------------------------
def test_get_output_directory_default(monkeypatch):
"""No IMAGE_OUTPUT_DIR env var → returns expanded ~/Pictures/mcp-generated."""
monkeypatch.delenv("IMAGE_OUTPUT_DIR", raising=False)
monkeypatch.setattr(server, "IMAGE_OUTPUT_DIR", "~/Pictures/mcp-generated")
result = get_output_directory()
assert result == str(Path("~/Pictures/mcp-generated").expanduser().resolve())
assert "~" not in result # expanded
def test_get_output_directory_custom(monkeypatch, tmp_path):
"""IMAGE_OUTPUT_DIR set → returns that path."""
custom = str(tmp_path / "custom-output")
monkeypatch.setenv("IMAGE_OUTPUT_DIR", custom)
monkeypatch.setattr(server, "IMAGE_OUTPUT_DIR", custom)
result = get_output_directory()
assert result == str(Path(custom).expanduser().resolve())
# ---------------------------------------------------------------------------
# generate_image
# ---------------------------------------------------------------------------
@respx.mock
@pytest.mark.asyncio
async def test_generate_image_success(
tmp_path, sample_image_bytes, mock_history_response, queue_empty, monkeypatch
):
"""Mock full lifecycle: queue → poll done → history → view. Verify outputs."""
monkeypatch.setattr(server, "IMAGE_OUTPUT_DIR", str(tmp_path))
# 1. POST /api/prompt → prompt_id
respx.post(f"{COMFYUI_BASE}/api/prompt").mock(
return_value=httpx.Response(200, json={"prompt_id": "test-uuid-1234"})
)
# 2. GET /api/queue → empty (job done immediately)
respx.get(f"{COMFYUI_BASE}/api/queue").mock(
return_value=httpx.Response(200, json=queue_empty)
)
# 3. GET /api/history/test-uuid-1234
respx.get(f"{COMFYUI_BASE}/api/history/test-uuid-1234").mock(
return_value=httpx.Response(200, json=mock_history_response)
)
# 4. GET /api/view → image bytes
respx.get(f"{COMFYUI_BASE}/api/view").mock(
return_value=httpx.Response(200, content=sample_image_bytes)
)
result = await generate_image(
prompt="a red cat",
output_dir=str(tmp_path),
)
# Should return [TextContent, ImageContent]
assert len(result) == 2
text_content = result[0]
image_content = result[1]
# TextContent has path info
assert "Generated:" in text_content.text
assert str(tmp_path) in text_content.text
# ImageContent has valid base64 PNG
assert image_content.type == "image"
assert image_content.mimeType == "image/png"
decoded = base64.b64decode(image_content.data)
assert decoded[:8] == b"\x89PNG\r\n\x1a\n" # PNG magic bytes
# File was actually saved
saved_files = list(tmp_path.glob("*.png"))
assert len(saved_files) == 1
@respx.mock
@pytest.mark.asyncio
async def test_generate_image_comfyui_unavailable():
"""ComfyUI unreachable → returns graceful error message as single TextContent."""
respx.post(f"{COMFYUI_BASE}/api/prompt").mock(
side_effect=httpx.ConnectError("connection refused")
)
result = await generate_image(prompt="a cat")
assert len(result) == 1
assert "not reachable" in result[0].text.lower()
@respx.mock
@pytest.mark.asyncio
async def test_generate_image_timeout(monkeypatch, queue_with_pending):
"""Poll loop never completes within timeout → returns timeout error."""
monkeypatch.setattr(server, "COMFYUI_TIMEOUT", 0) # instant timeout
respx.post(f"{COMFYUI_BASE}/api/prompt").mock(
return_value=httpx.Response(200, json={"prompt_id": "test-uuid-1234"})
)
# Queue always shows job pending → never finishes
respx.get(f"{COMFYUI_BASE}/api/queue").mock(
return_value=httpx.Response(200, json=queue_with_pending)
)
result = await generate_image(prompt="slow image")
assert len(result) == 1
assert "timed out" in result[0].text.lower()
assert "test-uuid-1234" in result[0].text
@respx.mock
@pytest.mark.asyncio
async def test_generate_image_empty_prompt(tmp_path, sample_image_bytes, mock_history_response, queue_empty, monkeypatch):
"""Empty prompt → workflow has empty text in positive node, but generation succeeds."""
monkeypatch.setattr(server, "IMAGE_OUTPUT_DIR", str(tmp_path))
respx.post(f"{COMFYUI_BASE}/api/prompt").mock(
return_value=httpx.Response(200, json={"prompt_id": "test-empty-uuid"})
)
respx.get(f"{COMFYUI_BASE}/api/queue").mock(
return_value=httpx.Response(200, json=queue_empty)
)
mock_history_empty = {
"test-empty-uuid": {
"outputs": {
"9": {
"images": [
{
"filename": "mcp-image-gen_00001_.png",
"subfolder": "",
"type": "output",
}
]
}
},
"status": {"completed": True},
}
}
respx.get(f"{COMFYUI_BASE}/api/history/test-empty-uuid").mock(
return_value=httpx.Response(200, json=mock_history_empty)
)
respx.get(f"{COMFYUI_BASE}/api/view").mock(
return_value=httpx.Response(200, content=sample_image_bytes)
)
result = await generate_image(prompt="", output_dir=str(tmp_path))
assert len(result) == 2
text_content = result[0]
image_content = result[1]
assert "Generated:" in text_content.text
assert str(tmp_path) in text_content.text
# Verify workflow was built with empty prompt (indirectly via success)
assert image_content.mimeType == "image/png"
@respx.mock
@pytest.mark.asyncio
async def test_generate_image_long_prompt(tmp_path, sample_image_bytes, mock_history_response, queue_empty, monkeypatch):
"""Very long prompt → passed as-is to workflow without truncation."""
monkeypatch.setattr(server, "IMAGE_OUTPUT_DIR", str(tmp_path))
long_prompt = "a " + "very long descriptive prompt " * 50 # ~500 chars
respx.post(f"{COMFYUI_BASE}/api/prompt").mock(
return_value=httpx.Response(200, json={"prompt_id": "test-long-uuid"})
)
respx.get(f"{COMFYUI_BASE}/api/queue").mock(
return_value=httpx.Response(200, json=queue_empty)
)
mock_history_long = {
"test-long-uuid": {
"outputs": {
"9": {
"images": [
{
"filename": "mcp-image-gen_00001_.png",
"subfolder": "",
"type": "output",
}
]
}
},
"status": {"completed": True},
}
}
respx.get(f"{COMFYUI_BASE}/api/history/test-long-uuid").mock(
return_value=httpx.Response(200, json=mock_history_long)
)
respx.get(f"{COMFYUI_BASE}/api/view").mock(
return_value=httpx.Response(200, content=sample_image_bytes)
)
result = await generate_image(prompt=long_prompt, output_dir=str(tmp_path))
assert len(result) == 2
# Success implies long prompt was accepted (ComfyUI handles it)
saved_files = list(tmp_path.glob("*.png"))
assert len(saved_files) == 1
@respx.mock
@pytest.mark.asyncio
async def test_generate_image_invalid_model(tmp_path, monkeypatch):
"""Invalid model → ComfyUI /prompt returns 500 or 404, tool returns error TextContent."""
monkeypatch.setattr(server, "IMAGE_OUTPUT_DIR", str(tmp_path))
respx.post(f"{COMFYUI_BASE}/api/prompt").mock(
return_value=httpx.Response(404, json={"error": "Model not found"})
)
result = await generate_image(
prompt="a cat",
model="nonexistent-model.safetensors",
output_dir=str(tmp_path)
)
assert len(result) == 1
assert "404" in result[0].text
assert "Model not found" in result[0].text
# No file saved
saved_files = list(tmp_path.glob("*.png"))
assert len(saved_files) == 0
@respx.mock
@pytest.mark.asyncio
async def test_generate_image_custom_output_dir(tmp_path, sample_image_bytes, mock_history_response, queue_empty, monkeypatch):
"""Custom output_dir → image saved there, path reflects it."""
custom_dir = tmp_path / "custom"
monkeypatch.setattr(server, "IMAGE_OUTPUT_DIR", str(tmp_path)) # Base for default
respx.post(f"{COMFYUI_BASE}/api/prompt").mock(
return_value=httpx.Response(200, json={"prompt_id": "test-custom-uuid"})
)
respx.get(f"{COMFYUI_BASE}/api/queue").mock(
return_value=httpx.Response(200, json=queue_empty)
)
mock_history_custom = {
"test-custom-uuid": {
"outputs": {
"9": {
"images": [
{
"filename": "mcp-image-gen_00001_.png",
"subfolder": "",
"type": "output",
}
]
}
},
"status": {"completed": True},
}
}
respx.get(f"{COMFYUI_BASE}/api/history/test-custom-uuid").mock(
return_value=httpx.Response(200, json=mock_history_custom)
)
respx.get(f"{COMFYUI_BASE}/api/view").mock(
return_value=httpx.Response(200, content=sample_image_bytes)
)
result = await generate_image(
prompt="a dog",
output_dir=str(custom_dir),
)
assert len(result) == 2
text_content = result[0]
assert str(custom_dir) in text_content.text
# Directory was created
assert custom_dir.exists()
saved_files = list(custom_dir.glob("*.png"))
assert len(saved_files) == 1
@respx.mock
@pytest.mark.asyncio
async def test_generate_image_random_seed_variance(tmp_path, sample_image_bytes, mock_history_response, queue_empty, monkeypatch):
"""seed=-1 → different actual_seed each call, reflected in filename."""
monkeypatch.setattr(server, "IMAGE_OUTPUT_DIR", str(tmp_path))
# First generation
respx.post(f"{COMFYUI_BASE}/api/prompt").mock(
return_value=httpx.Response(200, json={"prompt_id": "seed1-uuid"})
)
respx.get(f"{COMFYUI_BASE}/api/queue").mock(
return_value=httpx.Response(200, json=queue_empty)
)
mock_history_seed1 = {
"seed1-uuid": {
"outputs": {
"9": {
"images": [
{
"filename": "mcp-image-gen_00001_.png",
"subfolder": "",
"type": "output",
}
]
}
},
"status": {"completed": True},
}
}
respx.get(f"{COMFYUI_BASE}/api/history/seed1-uuid").mock(
return_value=httpx.Response(200, json=mock_history_seed1)
)
respx.get(f"{COMFYUI_BASE}/api/view").mock(
return_value=httpx.Response(200, content=sample_image_bytes)
)
result1 = await generate_image(prompt="cat", seed=-1, output_dir=str(tmp_path))
seed1 = [line for line in result1[0].text.split("\n") if "Seed:" in line][0].split(": ")[1]
filename1 = Path(result1[0].text.split("Generated: ")[1].split("\n")[0]).name
assert "Seed:" in result1[0].text
assert int(seed1) != 0 # Not default
# Reset mocks for second call
respx.reset()
respx.post(f"{COMFYUI_BASE}/api/prompt").mock(
return_value=httpx.Response(200, json={"prompt_id": "seed2-uuid"})
)
respx.get(f"{COMFYUI_BASE}/api/queue").mock(
return_value=httpx.Response(200, json=queue_empty)
)
mock_history_seed2 = {
"seed2-uuid": {
"outputs": {
"9": {
"images": [
{
"filename": "mcp-image-gen_00002_.png",
"subfolder": "",
"type": "output",
}
]
}
},
"status": {"completed": True},
}
}
respx.get(f"{COMFYUI_BASE}/api/history/seed2-uuid").mock(
return_value=httpx.Response(200, json=mock_history_seed2)
)
respx.get(f"{COMFYUI_BASE}/api/view").mock(
return_value=httpx.Response(200, content=sample_image_bytes)
)
result2 = await generate_image(prompt="cat", seed=-1, output_dir=str(tmp_path))
seed2 = [line for line in result2[0].text.split("\n") if "Seed:" in line][0].split(": ")[1]
filename2 = Path(result2[0].text.split("Generated: ")[1].split("\n")[0]).name
# Different seeds and filenames
assert seed1 != seed2
assert filename1 != filename2
# Both saved
saved_files = list(tmp_path.glob("*.png"))
assert len(saved_files) == 2