Compare commits

...

17 Commits

Author SHA1 Message Date
pplate dd244a8e6c feat(bigmind): add tiered AI-generated achievement badges with image rendering 2026-04-04 18:50:45 +02:00
Patrick Plate ee07dec4d3 Merge feat/bigmind/profile-image-gallery into main 2026-04-04 14:52:36 +02:00
Patrick Plate 67b8b44408 feat(bigmind): profile image + AI image gallery (schema v8)
- web.py: add /profile-image route (serves most-recent gallery PNG)
  add /gallery/image/<filename> route (per-image serving)
  add /gallery route (renders gallery page from DB)
  add _get_profile_image_path() helper
- web_render.py: replace emoji avatar with <img src=/profile-image>
  onerror fallback to 🧠 emoji
  add .nav bar with Profile/Gallery links to both pages
  add _render_gallery_html() full gallery page renderer
  add gallery CSS: .gal-grid, .gal-card, .gal-img, .gal-info, etc.
- db.py: bump SCHEMA_VERSION 7→8
  add gallery_images table (id, filename, prompt, tags, model,
    created_at, width, height, file_size_bytes)
  add _migrate_v7_to_v8() migration function
  add init_db() hook for v<8 migration
- tests: update test_schema_version_is_7→8 in test_db.py and
  test_feature7_live_sessions.py; add gallery_images to expected tables

Storage strategy: Option B (filesystem + DB metadata)
Images in ~/.mcp/bigmind/gallery/, metadata in SQLite
Pre-populated with 5 lumen_profiles images (seeds 2409122067,
764633840, 1367851518, 3135233944, 568659042)

Tests: 297/297 passing
2026-04-04 14:52:30 +02:00
Patrick Plate a852e2ec0d docs: merge Java wiki header images 2026-04-04 14:40:57 +02:00
Patrick Plate a275a18e58 docs: add Java project wiki header images 2026-04-04 14:40:50 +02:00
Patrick Plate 20228f8d46 docs: add wiki creation script 2026-04-04 14:33:31 +02:00
Patrick Plate 3b1d5bf35c docs: add wiki header images generated by mcp-image-gen 2026-04-04 14:22:29 +02:00
Patrick Plate e12479a63a Merge branch 'feat/mcp-image-gen/tests-and-lumen-profiles' 2026-04-04 14:09:19 +02:00
Patrick Plate 64c0a62b49 feat(mcp-image-gen): add test suite (19 tests) and Lumen profile pictures 2026-04-04 14:09:11 +02:00
Patrick Plate f24aafec69 fix(mcp-image-gen): merge HF authenticated download fix 2026-04-04 12:28:28 +02:00
pplate 4165018ab2 fix(mcp-image-gen): fix HuggingFace authenticated download instructions
FLUX.1-schnell is a gated model — bare wget returns HTTP 401.

- Replace bare wget with huggingface-cli login + download (Option A)
- Add wget with Authorization header as Option B
- Add license acceptance prerequisite (huggingface.co gated repo)
- Add token creation link (huggingface.co/settings/tokens)
- Add fp8 quantized variant as alternative (~8.1GB, faster inference)
- Add download size note (~8GB, 10-30min)
2026-04-04 12:28:20 +02:00
pplate 2f01ff0639 fix(mcp-image-gen): correct ComfyUI install instructions in USAGE.md
ComfyUI is NOT on PyPI — `pip install comfyui` fails with
"No matching distribution found". Remove the wrong Option A.

Replace with:
- Warning note that pip install does not work
- Only correct method: git clone from GitHub + pip install -r requirements.txt

ROCm status confirmed: rocm-smi 3.1.0 / ROCm-SMI-LIB 7.7.0 installed.
2026-04-04 12:20:28 +02:00
Patrick Plate 7a21b02081 Merge branch 'feat/mcp-tool-limit' 2026-04-04 12:16:15 +02:00
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
68 changed files with 2073 additions and 34 deletions
+29 -3
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": {
@@ -41,18 +47,38 @@
"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"],
"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"
+622
View File
@@ -0,0 +1,622 @@
#!/usr/bin/env python3
"""Create all 7 wiki pages for pi_mcps on Gitea."""
import base64
import json
import urllib.request
import urllib.error
GITEA_URL = "http://192.168.188.119:30008"
OWNER = "pplate"
REPO = "pi_mcps"
TOKEN = "8bf0c734ebda3e61d9c9068489ce58a2bf8d33db"
IMG_BASE = f"{GITEA_URL}/{OWNER}/{REPO}/raw/branch/main/docs/wiki/images"
PAGES = {}
PAGES["Home"] = f"""# 🔧 pi_mcps — Patrick's Homelab Monorepo
![Home Banner]({IMG_BASE}/home-banner.png)
Welcome to **pi_mcps**, Patrick's personal homelab monorepo. This repository houses MCP (Model Context Protocol) servers, Java projects, and homelab tooling — all built and maintained on a Fedora Linux workstation with an AMD Ryzen 5900X + RX 7900 XTX.
## What's in this repo?
| Directory | Contents |
|---|---|
| [`mcp/mcp-image-gen/`](../src/branch/main/mcp/mcp-image-gen) | 🎨 AI image generation via ComfyUI + FLUX.1-schnell |
| [`mcp/webscraper/`](../src/branch/main/mcp/webscraper) | 🕸️ Web scraping and data extraction |
| [`mcp/bigmind/`](../src/branch/main/mcp/bigmind) | 🧠 Persistent AI memory system |
| [`java/`](../src/branch/main/java) | ☕ Java EE / Spring projects |
| [`plans/`](../src/branch/main/plans) | 📋 Architecture decisions and health reports |
## Stack
- **Language:** Python 3.11+ (MCP servers), Java 17 (legacy projects)
- **MCP Framework:** FastMCP 2.x
- **Package Manager:** `uv` (all Python projects)
- **Testing:** `pytest`
- **GPU:** AMD RX 7900 XTX (ROCm / HSA)
- **Server:** TrueNAS.local at `192.168.188.119` (Gitea, Docker)
## MCP Servers
Three production-ready MCP servers power Patrick's AI development environment:
| Server | Status | Description |
|---|---|---|
| [mcp-image-gen](mcp-image-gen) | ✅ Live | Generate images from text prompts via ComfyUI |
| [mcp-webscraper](mcp-webscraper) | ✅ Live | Scrape web pages, extract tables, fetch links |
| [BigMind](BigMind) | ✅ Live | Persistent AI memory across all sessions |
---
*Built and maintained by Patrick Plate (pplate) · Homelab: TrueNAS.local · AI Colleague: Lumen*
"""
PAGES["MCP-Servers-Overview"] = f"""# 🔌 MCP Servers Overview
![MCP Overview Banner]({IMG_BASE}/mcp-overview-banner.png)
This repo contains three production-grade MCP (Model Context Protocol) servers, each specialized for a different capability domain. Together they give Roo Code / Claude Desktop a complete set of superpowers.
## The Three Pillars
```
Roo Code / Claude Desktop
├── bigmind ──────────► ~/.mcp/bigmind/memory.db (persistent memory)
├── mcp-image-gen ────► ComfyUI @ localhost:8188 (image generation)
└── webscraper ───────► Internet / Intranet (web scraping)
```
## Comparison Table
| Feature | mcp-image-gen | webscraper | bigmind |
|---|---|---|---|
| **Purpose** | Generate images from text | Scrape & parse web | Persistent AI memory |
| **Tools** | 4 | 7 | 15+ |
| **Backend** | ComfyUI / FLUX.1-schnell | httpx + BeautifulSoup4 | SQLite + FTS5 |
| **GPU required** | ✅ AMD RX 7900 XTX | ❌ | ❌ |
| **Tests** | 19/19 ✅ | ✅ | 297/297 ✅ |
| **Schema version** | n/a | n/a | v7 |
## Quick Links
- 🎨 [mcp-image-gen](mcp-image-gen) — Image generation docs
- 🕸️ [mcp-webscraper](mcp-webscraper) — Web scraping docs
- 🧠 [BigMind](BigMind) — Memory system docs
- 🛠️ [Development Conventions](Development-Conventions) — How all servers are built
## Adding a New Server
All servers follow the [FastMCP convention](Development-Conventions). Use the `new-mcp-server` Roo skill to scaffold:
```bash
# In Roo Code orchestrator, load skill:
# skill: new-mcp-server
```
"""
PAGES["mcp-image-gen"] = f"""# 🎨 mcp-image-gen — AI Image Generation
![Image Gen Banner]({IMG_BASE}/image-gen-banner.png)
**mcp-image-gen** is a FastMCP server that wraps the ComfyUI REST API, enabling Roo Code and Claude Desktop to generate images directly from text prompts using FLUX.1-schnell running on an AMD RX 7900 XTX GPU.
## Architecture
```
Roo Code / Claude Desktop
│ MCP (stdio)
mcp-image-gen (FastMCP, Python 3.11+)
│ HTTP REST
ComfyUI @ localhost:8188
│ ROCm / HSA_OVERRIDE_GFX_VERSION=11.0.0
FLUX.1-schnell (~8s/image @ 1024×1024)
```
## Tools
| Tool | Description |
|---|---|
| `generate_image` | Generate PNG from text prompt; returns file path + inline base64 |
| `list_available_models` | List ComfyUI checkpoint models |
| `get_generation_status` | Check status of a queued/running job |
| `get_output_directory` | Return configured output directory path |
## Key Parameters — `generate_image`
| 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 is 4-step) |
| `model` | `flux1-schnell.safetensors` | Model checkpoint name |
| `seed` | `-1` (random) | Generation seed for reproducibility |
| `negative_prompt` | `""` | Things to avoid in the image |
| `output_dir` | `~/Pictures/mcp-generated` | Where to save output PNG |
## Environment Variables
| Variable | Default | Description |
|---|---|---|
| `COMFYUI_URL` | `http://localhost:8188` | ComfyUI API endpoint |
| `IMAGE_OUTPUT_DIR` | `~/Pictures/mcp-generated` | Default output directory |
| `COMFYUI_TIMEOUT` | `120` | Request timeout in seconds |
## Return Value
The tool returns **two content items**:
1. `TextContent` — file path, seed used, elapsed time
2. `ImageContent` — base64-encoded PNG (displays inline in Roo Code chat)
> ⚠️ **Known FastMCP Bug:** Never use `fastmcp.utilities.types.Image` as return type — it breaks serialization in FastMCP 3.x. Use `mcp.types.ImageContent` directly.
## Setup
See [ComfyUI Setup Guide](mcp-image-gen-ComfyUI-Setup) for full installation instructions.
### Quick Start
```bash
cd mcp/mcp-image-gen
uv sync
# Set COMFYUI_URL if ComfyUI is not on localhost
./run.sh
```
### Run Tests
```bash
cd mcp/mcp-image-gen
uv run pytest tests/ -v
```
## Lumen Profile Images
The first images generated with this server were Lumen's visual identity portraits, stored in [`mcp/mcp-image-gen/lumen_profiles/`](../src/branch/main/mcp/mcp-image-gen/lumen_profiles):
![Lumen Profile]({IMG_BASE}/lumen-profile.png)
*Primary profile: seed `568659042` — constellation face interpretation of Lumen.*
"""
PAGES["mcp-image-gen-ComfyUI-Setup"] = f"""# ⚙️ ComfyUI Setup Guide (AMD ROCm)
This guide covers installing ComfyUI with FLUX.1-schnell on a Fedora Linux system with an AMD GPU.
## Prerequisites
- AMD GPU with ROCm support (tested: RX 7900 XTX)
- Fedora Linux (tested: Fedora 43 / kernel 6.19)
- Python 3.11+
- ~15GB free disk space (model weights)
- HuggingFace account with FLUX license accepted
## Step 1: Install ComfyUI
ComfyUI is **not on PyPI** — must be cloned from source:
```bash
cd ~
git clone https://github.com/comfyanonymous/ComfyUI
cd ComfyUI
python -m venv .venv
source .venv/bin/activate
# Install PyTorch ROCm build (CRITICAL for AMD GPUs)
pip install torch torchvision --index-url https://download.pytorch.org/whl/rocm6.2
# Install ComfyUI dependencies
pip install -r requirements.txt
```
## Step 2: Download FLUX.1-schnell
FLUX.1-schnell is **gated on HuggingFace** — you must:
1. Create a HuggingFace account
2. Accept the FLUX.1-schnell license at https://huggingface.co/black-forest-labs/FLUX.1-schnell
3. Generate an access token at https://huggingface.co/settings/tokens
```bash
# Install huggingface_hub
pip install huggingface_hub
# Download model (requires HF token)
huggingface-cli download black-forest-labs/FLUX.1-schnell \\
flux1-schnell.safetensors \\
--local-dir ~/ComfyUI/models/checkpoints \\
--token YOUR_HF_TOKEN_HERE
```
## Step 3: Download VAE and CLIP Models
FLUX.1-schnell also requires VAE and CLIP text encoders:
```bash
# VAE
huggingface-cli download black-forest-labs/FLUX.1-schnell \\
ae.safetensors \\
--local-dir ~/ComfyUI/models/vae
# CLIP models (T5 and CLIP-L)
huggingface-cli download comfyanonymous/flux_text_encoders \\
t5xxl_fp8_e4m3fn.safetensors clip_l.safetensors \\
--local-dir ~/ComfyUI/models/clip
```
## Step 4: Start ComfyUI
```bash
cd ~/ComfyUI
# AMD GPU REQUIRES this environment variable
HSA_OVERRIDE_GFX_VERSION=11.0.0 \\
nohup .venv/bin/python main.py --listen --port 8188 > /tmp/comfyui.log 2>&1 &
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 5: Verify ComfyUI is Running
```bash
curl http://localhost:8188/system_stats
# Should return JSON with GPU info
```
## Step 6: Configure mcp-image-gen
```bash
cd /path/to/pi_mcps/mcp/mcp-image-gen
cp .env.example .env # if exists, or set manually
# .env contents:
COMFYUI_URL=http://localhost:8188
IMAGE_OUTPUT_DIR=~/Pictures/mcp-generated
COMFYUI_TIMEOUT=120
```
## Performance
| GPU | Model | Resolution | Steps | Time |
|---|---|---|---|---|
| AMD RX 7900 XTX | FLUX.1-schnell | 1024×1024 | 4 | ~8s |
| AMD RX 7900 XTX | FLUX.1-schnell | 1280×512 | 4 | ~7s |
## Troubleshooting
| Problem | Solution |
|---|---|
| `HTTP 401` downloading model | Accept FLUX license on HuggingFace first |
| 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 |
| Slow generation (>60s) | ComfyUI may be running on CPU — check ROCm install |
| Ollama image gen | As of April 2026: macOS-only, not available on Linux |
"""
PAGES["mcp-webscraper"] = f"""# 🕸️ mcp-webscraper — Web Scraping
![Webscraper Banner]({IMG_BASE}/webscraper-banner.png)
**mcp-webscraper** is a FastMCP server providing comprehensive web scraping and data extraction capabilities. It fetches pages, converts HTML to clean Markdown, extracts tables, links, CSS sections, metadata, and sitemaps.
## Tools
| Tool | Description |
|---|---|
| `webscraper_fetch(url, max_chars=5000)` | Title + full page as Markdown + metadata |
| `webscraper_fetch_links(url, deduplicate=True)` | All `href` links found on the page |
| `webscraper_fetch_tables(url)` | All HTML tables converted to Markdown |
| `webscraper_fetch_all(url, max_chars=5000)` | Everything in one call (fetch + links + tables) |
| `webscraper_fetch_section(url, selector)` | Specific CSS selector section only |
| `webscraper_fetch_meta(url)` | Title, description, Open Graph tags |
| `webscraper_fetch_sitemap(url, max_urls=100)` | Parse sitemap.xml, return URL list |
## Stack
- **HTTP client:** `httpx` (async, with SSL support)
- **HTML parser:** `BeautifulSoup4` + `lxml`
- **Markdown converter:** `html2text`
- **SSL:** Custom cert bundle for Fedora 43 compatibility
## SSL Note — Fedora 43 Comodo Root CA
Fedora 43 is missing the **Comodo AAA Services Root CA** needed for Cloudflare-protected sites. The fix is bundled at [`mcp/webscraper/certs/comodo-aaa-services-root.pem`](../src/branch/main/mcp/webscraper/certs/).
The server automatically uses this cert bundle — no manual configuration needed.
## Quick Start
```bash
cd mcp/webscraper
uv sync
./run.sh
```
## Usage Examples
```python
# In Roo Code / Claude Desktop via MCP:
# Fetch a page as Markdown
webscraper_fetch("https://docs.fastmcp.dev", max_chars=10000)
# Extract all links from Gitea repo
webscraper_fetch_links("http://192.168.188.119:30008/pplate/pi_mcps")
# Get all tables from a documentation page
webscraper_fetch_tables("https://pypi.org/project/fastmcp/")
# Get Open Graph metadata
webscraper_fetch_meta("https://github.com/comfyanonymous/ComfyUI")
# Fetch specific section by CSS selector
webscraper_fetch_section("https://docs.python.org", "#content")
```
"""
PAGES["BigMind"] = f"""# 🧠 BigMind — Persistent AI Memory
![BigMind Banner]({IMG_BASE}/bigmind-banner.png)
**BigMind** is the persistent memory backbone for all AI development sessions. It provides SQLite-backed tiered memory with FTS5 full-text search, hypothesis tracking, session management, and token efficiency logging. It is the reason Lumen (Patrick's AI colleague) remembers everything across sessions.
## Core Concepts
### Tiered Memory
| Tier | Name | Content |
|---|---|---|
| 0 | **Session Index** | Lightweight list: ID, date, one-liner |
| 1 | **Topic Index** | Per-session topic tags and metadata |
| 2 | **Narrative** | Full 3-8 sentence session summaries |
| 3 | **Flagged Exchanges** | Specific important moments, decisions, code |
### Facts Store
Atomic, reusable knowledge pieces categorized by type:
- `user-preference` — Patrick's tool/style preferences
- `architecture-decision` — System design choices
- `codebase-convention` — How code is structured
- `environment-config` — Server IPs, paths, credentials
- `bug-pattern` — Known bugs and fixes
- `api-contract` — MCP tool signatures
## Key Tools
### Session Lifecycle
| Tool | Description |
|---|---|
| `memory_start_session()` | Open new session, load prior context |
| `memory_end_session(...)` | Close session with summary, topics, outcome |
| `memory_announce_focus(...)` | Declare files to be touched this session |
| `memory_close_stale_sessions(...)` | Clean up crashed IDE sessions |
### Search
| Tool | Description |
|---|---|
| `memory_search_facts(query, limit=10)` | FTS5 search over stored facts |
| `memory_search_chunks(query, limit=10)` | FTS5 search over conversation chunks |
| `memory_list_sessions(limit=20)` | Browse session history |
### Storage
| Tool | Description |
|---|---|
| `memory_store_fact(category, fact)` | Store atomic reusable fact |
| `memory_append_chunk(session_id, content, role)` | Store conversation chunk |
| `memory_flag_important(session_id, content, role, flag_reason)` | Flag critical exchange |
| `memory_log_token_save(session_id, description, tokens_saved, method_used)` | Track efficiency |
### Hypotheses
| Tool | Description |
|---|---|
| `memory_add_hypothesis(session_id, hypothesis, confidence)` | Form testable prediction |
| `memory_resolve_hypothesis(hypothesis_id, status, resolution)` | Confirm/refute prediction |
| `memory_list_hypotheses(status)` | Review open/closed predictions |
## FTS5 Search Tips
BigMind uses SQLite FTS5 — **every token must match**. Use 2-3 focused keywords:
```
✅ memory_search_facts("TrueNAS Docker")
✅ memory_search_facts("mcp.json config")
❌ memory_search_facts("homelab infrastructure TrueNAS Docker server") → 0 results
```
## Stats (2026-04-04)
| Metric | Value |
|---|---|
| DB size | 744KB |
| Sessions | 98 |
| Facts | 97+ |
| Chunks | 41 |
| Schema version | v7 |
## DB Location
`~/.mcp/bigmind/memory.db` — outside the repo, never committed.
## Session Ritual
Every session **must** follow this ritual:
**Start:**
1. `memory_start_session()`
2. `memory_list_hypotheses()`
3. `memory_announce_focus(...)`
4. `memory_close_stale_sessions(...)`
**End:**
1. `memory_end_session(one_liner, topics, outcome, summary, importance)`
"""
PAGES["Development-Conventions"] = """# 🛠️ Development Conventions
All MCP servers in this repo follow a consistent set of conventions to ensure maintainability, testability, and compatibility with Roo Code tooling.
## Directory Structure
Each MCP server lives at `mcp/<server-name>/` with this layout:
```
mcp/<server-name>/
├── src/
│ ├── __init__.py
│ └── server.py ← FastMCP server entry point
├── tests/
│ └── test_server.py ← pytest test suite
├── pyproject.toml ← uv-managed dependencies
├── run.sh ← launch script
├── README.md ← server documentation
├── PLAN.md ← architecture plan (pre-implementation)
└── ASSESSMENT.md ← pre-implementation assessment
```
## FastMCP Pattern
```python
from fastmcp import FastMCP
mcp = FastMCP("server-name")
@mcp.tool()
def my_tool(param: str) -> str:
\"\"\"Tool description shown to the AI.\"\"\"
return result
if __name__ == "__main__":
mcp.run()
```
## Package Management
**All projects use `uv`** — never `pip` directly:
```bash
# Create new server
uv init mcp/my-server
cd mcp/my-server
uv add fastmcp httpx
# Sync dependencies
uv sync
# Run server
uv run python src/server.py
# Run tests
uv run pytest tests/ -v
```
## pyproject.toml Template
```toml
[project]
name = "mcp-my-server"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"fastmcp>=2.0.0",
"httpx",
]
[project.scripts]
mcp-my-server = "src.server:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pytest.ini_options]
testpaths = ["tests"]
```
## Testing Conventions
- Tests live in `tests/test_server.py`
- Use `pytest` via `uv run pytest`
- Mock external dependencies (ComfyUI, web URLs) for unit tests
- All tests must pass before committing (`git push` should only happen with green tests)
## Commit Convention
Follow **Conventional Commits** format:
```
feat: add webscraper_fetch_section tool
fix: handle ComfyUI timeout gracefully
docs: update mcp-image-gen README with AMD setup
test: add unit tests for generate_image tool
refactor: extract workflow builder to separate module
chore: bump fastmcp to 2.1.0
```
## Creating a New MCP Server
Use the `new-mcp-server` Roo skill in MCP Builder mode for full scaffolding:
```
1. Switch to 🔧 MCP Builder mode in Roo Code
2. Say: "Create a new MCP server for <purpose>"
3. Roo will load the new-mcp-server skill and scaffold everything
```
## Gitea Repository
Code is hosted at: `http://192.168.188.119:30008/pplate/pi_mcps`
Push with the `gitea-push` Roo skill to ensure conventional commit format.
"""
def create_wiki_page(title: str, content: str) -> bool:
content_b64 = base64.b64encode(content.encode("utf-8")).decode("ascii")
payload = json.dumps({
"title": title,
"content_base64": content_b64,
"message": f"docs: create {title} wiki page"
}).encode("utf-8")
url = f"{GITEA_URL}/api/v1/repos/{OWNER}/{REPO}/wiki/pages"
req = urllib.request.Request(
url,
data=payload,
headers={
"Authorization": f"token {TOKEN}",
"Content-Type": "application/json",
},
method="POST"
)
try:
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read().decode())
print(f"✅ Created: {data.get('title', title)}")
return True
except urllib.error.HTTPError as e:
body = e.read().decode()
print(f"❌ Failed [{title}]: HTTP {e.code}{body[:200]}")
return False
except Exception as ex:
print(f"❌ Failed [{title}]: {ex}")
return False
if __name__ == "__main__":
results = {}
for title, content in PAGES.items():
ok = create_wiki_page(title, content)
results[title] = ok
print("\n=== Summary ===")
for title, ok in results.items():
status = "" if ok else ""
print(f"{status} {title}")
total = sum(results.values())
print(f"\n{total}/{len(results)} pages created successfully")
Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 KiB

+41 -1
View File
@@ -14,7 +14,7 @@ from typing import Generator
logger = logging.getLogger("BigMindDB")
SCHEMA_VERSION = 7
SCHEMA_VERSION = 8
DEFAULT_DB_PATH = Path.home() / ".mcp" / "bigmind" / "memory.db"
# ─── DDL ─────────────────────────────────────────────────────────────────────
@@ -222,6 +222,22 @@ _DDL_STATEMENTS = [
notes,
tokenize = 'porter unicode61'
)""",
# ── GALLERY IMAGES — AI-generated image archive ──────────────────────────
"""CREATE TABLE IF NOT EXISTS gallery_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL UNIQUE,
prompt TEXT,
tags TEXT,
model TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
width INTEGER,
height INTEGER,
file_size_bytes INTEGER
)""",
"""CREATE INDEX IF NOT EXISTS idx_gallery_created
ON gallery_images(created_at DESC)""",
]
@@ -407,6 +423,8 @@ def init_db() -> None:
_migrate_v5_to_v6(conn)
if current_version < 7:
_migrate_v6_to_v7(conn)
if current_version < 8:
_migrate_v7_to_v8(conn)
# Write / update the version
if row:
@@ -457,6 +475,28 @@ def _migrate_v6_to_v7(conn: sqlite3.Connection) -> None:
logger.info("BigMind schema migrated v6 → v7 (people/contacts directory)")
def _migrate_v7_to_v8(conn: sqlite3.Connection) -> None:
"""v7 → v8: add gallery_images table for AI-generated image archive."""
conn.execute("""
CREATE TABLE IF NOT EXISTS gallery_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL UNIQUE,
prompt TEXT,
tags TEXT,
model TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
width INTEGER,
height INTEGER,
file_size_bytes INTEGER
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_gallery_created
ON gallery_images(created_at DESC)
""")
logger.info("BigMind schema migrated v7 → v8 (gallery_images table)")
def vacuum_db() -> None:
"""Run VACUUM outside of any transaction (SQLite requirement)."""
db_path = get_db_path()
+134 -2
View File
@@ -435,10 +435,10 @@ def compute_achievements(user_id: str) -> list[dict]:
# ── Assemble ──────────────────────────────────────────────────────────────
A = []
def _add(id_, icon, name, desc, unlocked, unlocked_at, condition, extra=None):
def _add(id_, icon, name, desc, unlocked, unlocked_at, condition, extra=None, image=None):
A.append(dict(id=id_, icon=icon, name=name, description=desc,
unlocked=unlocked, unlocked_at=unlocked_at,
condition=condition, extra=extra))
condition=condition, extra=extra, image=image))
_add("first_breath", "🌱", "First Breath",
"Opened the very first session",
@@ -539,6 +539,138 @@ def compute_achievements(user_id: str) -> list[dict]:
sniper_row is not None, _dt(sniper_row[0]) if sniper_row else None,
"Save 500,000+ tokens in a single operation")
# ── Tiered Achievement Badges (20 PNG) ────────────────────────────────────
# NOTE: conn is already closed above; open a fresh connection for tiered queries
tiers = ["bronze", "silver", "gold", "platinum"]
tier_names = ["Bronze", "Silver", "Gold", "Platinum"]
with db() as conn2:
# Networker (people directory)
try:
people_count = conn2.execute(
"SELECT COUNT(*) FROM people WHERE user_id=?", (user_id,)
).fetchone()[0]
except Exception:
people_count = 0
for i, thresh in enumerate([1, 5, 25, 100]):
unlocked = people_count >= thresh
unlocked_at = None
if unlocked:
try:
row = conn2.execute(
"SELECT created_at FROM people WHERE user_id=?"
" ORDER BY created_at ASC LIMIT 1 OFFSET ?",
(user_id, thresh - 1)
).fetchone()
except Exception:
row = None
unlocked_at = _dt(row[0]) if row else None
_add(
f"networker_{tiers[i]}", None, f"Networker {tier_names[i]}",
f"Added your {thresh:,}+ person to the directory",
unlocked, unlocked_at,
f"Reach {thresh:,} people (now: {people_count:,})",
image=f"static/achievements/networker_{tiers[i]}.png"
)
# Token Sniper (max single token save)
try:
max_token = conn2.execute(
"SELECT COALESCE(MAX(tokens_saved_estimate), 0) FROM token_saves WHERE user_id=?",
(user_id,)
).fetchone()[0]
except Exception:
max_token = 0
for i, thresh in enumerate([10000, 50000, 250000, 1000000]):
unlocked = max_token >= thresh
unlocked_at = None
if unlocked:
try:
row = conn2.execute(
"SELECT created_at FROM token_saves"
" WHERE user_id=? AND tokens_saved_estimate >= ?"
" ORDER BY created_at ASC LIMIT 1",
(user_id, thresh)
).fetchone()
except Exception:
row = None
unlocked_at = _dt(row[0]) if row else None
_add(
f"tokensniper_{tiers[i]}", None, f"Token Sniper {tier_names[i]}",
f"Single shot saved {thresh:,}+ tokens",
unlocked, unlocked_at,
f"Max single save {thresh:,}+ (current max: {max_token:,})",
image=f"static/achievements/tokensniper_{tiers[i]}.png"
)
# Hypothesis Master (confirmed hypotheses)
try:
confirmed_hyp_count = conn2.execute(
"SELECT COUNT(*) FROM hypotheses WHERE user_id=? AND status='confirmed'",
(user_id,)
).fetchone()[0]
except Exception:
confirmed_hyp_count = 0
for i, thresh in enumerate([3, 10, 25, 100]):
unlocked = confirmed_hyp_count >= thresh
unlocked_at = None
if unlocked:
row = conn2.execute(
"SELECT resolved_at FROM hypotheses"
" WHERE user_id=? AND status='confirmed'"
" ORDER BY resolved_at ASC LIMIT 1 OFFSET ?",
(user_id, thresh - 1)
).fetchone()
unlocked_at = _dt(row[0]) if row else None
_add(
f"hypothesismaster_{tiers[i]}", None, f"Hypothesis Master {tier_names[i]}",
f"Confirmed {thresh:,}+ predictions right",
unlocked, unlocked_at,
f"Confirm {thresh:,}+ hypotheses (now: {confirmed_hyp_count:,})",
image=f"static/achievements/hypothesismaster_{tiers[i]}.png"
)
# Memory Architect (facts stored — fact_count already computed above)
for i, thresh in enumerate([25, 100, 500, 2500]):
unlocked = fact_count >= thresh
unlocked_at = None
if unlocked:
row = conn2.execute(
"SELECT created_at FROM facts"
" WHERE user_id=? AND (deprecated IS NULL OR deprecated=0)"
" ORDER BY created_at ASC LIMIT 1 OFFSET ?",
(user_id, thresh - 1)
).fetchone()
unlocked_at = _dt(row[0]) if row else None
_add(
f"memoryarchitect_{tiers[i]}", None, f"Memory Architect {tier_names[i]}",
f"Stored {thresh:,}+ facts in your brain",
unlocked, unlocked_at,
f"Store {thresh:,}+ facts (now: {fact_count:,})",
image=f"static/achievements/memoryarchitect_{tiers[i]}.png"
)
# Session Veteran (session_count already computed above)
for i, thresh in enumerate([50, 250, 1000, 5000]):
unlocked = session_count >= thresh
unlocked_at = None
if unlocked:
row = conn2.execute(
"SELECT started_at FROM sessions"
" WHERE user_id=? AND ended_at IS NOT NULL"
" ORDER BY started_at ASC LIMIT 1 OFFSET ?",
(user_id, thresh - 1)
).fetchone()
unlocked_at = _dt(row[0]) if row else None
_add(
f"sessionveteran_{tiers[i]}", None, f"Session Veteran {tier_names[i]}",
f"Completed {thresh:,}+ sessions",
unlocked, unlocked_at,
f"Complete {thresh:,}+ sessions (now: {session_count:,})",
image=f"static/achievements/sessionveteran_{tiers[i]}.png"
)
return A
Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

+66 -2
View File
@@ -7,9 +7,10 @@ Serves a single live profile page built from the BigMind DB.
import os
import threading
import logging
from pathlib import Path
from datetime import datetime, timezone, timedelta
from bigmind.web_render import _render_html # all HTML rendering lives there
from bigmind.web_render import _render_html, _render_gallery_html # all HTML rendering lives there
logger = logging.getLogger("BigMindWeb")
@@ -17,13 +18,27 @@ _PORT = int(os.environ.get("BIGMIND_PORT", "7700"))
_AUTOOPEN = os.environ.get("BIGMIND_AUTOOPEN", "").lower() in ("1", "true", "yes")
_server_started = False
# Gallery directory — images served from here
_GALLERY_DIR = Path(os.environ.get("BIGMIND_GALLERY_DIR", Path.home() / ".mcp" / "bigmind" / "gallery"))
# Profile image — last entry in gallery dir wins; fallback to original lumen-profile.png
def _get_profile_image_path() -> Path | None:
"""Return the path of the current profile image, or None if not found."""
# 1. Check gallery dir for lumen_profile* images (seed 568659042 = lumen_profile)
if _GALLERY_DIR.exists():
candidates = sorted(_GALLERY_DIR.glob("*.png"), reverse=True)
if candidates:
return candidates[0] # most recently named = most recent timestamp
return None
# ── Flask app ─────────────────────────────────────────────────────────────────
def _create_app():
from flask import Flask, jsonify, request
from flask import Flask, jsonify, request, send_file, abort
from bigmind import memory_store
from bigmind.profile_builder import build_profile_data
from bigmind.db import db as _db
app = Flask(__name__)
app.logger.setLevel(logging.WARNING) # silence Flask request logs
@@ -34,6 +49,39 @@ def _create_app():
data = build_profile_data(user["id"])
return _render_html(data)
@app.route("/profile-image")
def profile_image():
"""Serve the current Lumen profile picture."""
img_path = _get_profile_image_path()
if img_path and img_path.exists():
return send_file(str(img_path), mimetype="image/png")
abort(404)
@app.route("/gallery/image/<filename>")
def gallery_image(filename: str):
"""Serve a specific gallery image by filename."""
# Security: only allow alphanumeric + underscores + dots, no path traversal
safe_name = Path(filename).name
img_path = _GALLERY_DIR / safe_name
if img_path.exists() and img_path.suffix.lower() in (".png", ".jpg", ".jpeg", ".webp"):
mimetype = "image/png" if img_path.suffix.lower() == ".png" else "image/jpeg"
return send_file(str(img_path), mimetype=mimetype)
abort(404)
@app.route("/gallery")
def gallery():
"""Render the AI-generated image gallery page."""
_GALLERY_DIR.mkdir(parents=True, exist_ok=True)
with _db() as conn:
rows = conn.execute(
"""SELECT id, filename, prompt, tags, model, created_at,
width, height, file_size_bytes
FROM gallery_images
ORDER BY created_at DESC"""
).fetchall()
images = [dict(r) for r in rows]
return _render_gallery_html(images)
@app.route("/api/session/<session_id>")
def api_session(session_id):
"""Return Tier-2 summary JSON for a given session id."""
@@ -111,6 +159,22 @@ def _create_app():
return jsonify(final[:15])
@app.route('/static/achievements/<filename>')
def achievements_image(filename: str):
from pathlib import Path
safe_name = Path(filename).name
img_path = Path('static') / 'achievements' / safe_name
if img_path.exists() and img_path.suffix.lower() in ['.png', '.jpg', '.jpeg', '.webp', '.gif']:
mimetype = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
}.get(img_path.suffix.lower(), 'image/png')
return send_file(str(img_path), mimetype=mimetype)
abort(404)
return app
+214 -7
View File
@@ -29,18 +29,24 @@ def _render_achievements(achievements: list) -> str:
def _esc(s):
return (s or "").replace('"', "&quot;").replace("'", "&#39;")
lock_overlay = "" if a["unlocked"] else '<span class="ach-lock">🔒</span>'
lock_overlay = '<span class="ach-lock">🔒</span>' if not a["unlocked"] else ''
if a.get("image"):
tier = a["id"].rsplit("_", 1)[-1]
visual_html = f'<div class="ach-image tier-{tier}">{lock_overlay}</div>'
else:
visual_html = f'<div class="ach-icon">{a["icon"]}{lock_overlay}</div>'
return (
f'<div class="ach-card{locked_cls} ach-trigger"'
f' data-icon="{_esc(a["icon"])}"'
f'<div class="ach-card{locked_cls} ach-trigger" data-image="{_esc(a.get("image") or "")}"'
f' data-icon="{_esc(a["icon"] or "")}"'
f' data-name="{_esc(a["name"])}"'
f' data-desc="{_esc(a["description"])}"'
f' data-unlocked="{1 if a["unlocked"] else 0}"'
f' data-date="{_esc(a.get("unlocked_at") or "")}"'
f' data-condition="{_esc(a.get("condition") or "")}"'
f' data-extra="{_esc(a.get("extra") or "")}">'
f'<div class="ach-icon">{a["icon"]}{lock_overlay}</div>'
f'{visual_html}'
f'<div class="ach-name">{a["name"]}</div>'
f'{date_html}'
f'{countdown_html}'
@@ -162,9 +168,16 @@ def _render_html(data: dict) -> str:
a {{ color: var(--accent); text-decoration: none; }}
.container {{ max-width: 960px; margin: 0 auto; padding: 32px 16px; }}
/* Nav bar */
.nav {{ display: flex; gap: 8px; margin-bottom: 20px; }}
.nav-link {{ background: var(--surface); border: 1px solid var(--border); border-radius: 6px; color: var(--muted); padding: 6px 14px; font-size: 12px; font-weight: 500; text-decoration: none; transition: border-color 0.2s, color 0.2s; }}
.nav-link:hover {{ border-color: var(--accent); color: var(--accent); }}
.nav-link.active {{ border-color: var(--accent); color: var(--accent); background: rgba(88,166,255,0.08); }}
/* Header */
.header {{ display: flex; align-items: center; gap: 24px; margin-bottom: 32px; padding-bottom: 24px; border-bottom: 1px solid var(--border); }}
.avatar {{ width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--purple)); display: flex; align-items: center; justify-content: center; font-size: 36px; flex-shrink: 0; }}
.avatar {{ width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--purple)); display: flex; align-items: center; justify-content: center; font-size: 36px; flex-shrink: 0; overflow: hidden; }}
.avatar img {{ width: 80px; height: 80px; border-radius: 50%; object-fit: cover; display: block; }}
.header-info h1 {{ font-size: 24px; font-weight: 700; }}
.role {{ color: var(--muted); font-size: 13px; margin-top: 2px; }}
.since {{ color: var(--muted); font-size: 12px; margin-top: 6px; }}
@@ -276,11 +289,65 @@ def _render_html(data: dict) -> str:
.ach-card:not(.locked):hover {{ border-color: var(--accent); transform: translateY(-2px); }}
.ach-card.locked {{ opacity: 0.35; filter: grayscale(0.6); }}
.ach-card.locked:hover {{ opacity: 0.55; border-color: var(--muted); }}
.ach-image {{
width: 64px;
height: 64px;
border-radius: 50%;
margin: 0 auto 8px;
background-size: cover;
background-position: center;
position: relative;
}}
.tier-bronze {{
box-shadow: 0 0 8px rgba(205, 127, 50, 0.7);
border: 3px solid #cd7f32;
}}
.tier-silver {{
box-shadow: 0 0 8px rgba(170, 169, 173, 0.7);
border: 3px solid #aaa9ad;
}}
.tier-gold {{
box-shadow: 0 0 12px rgba(255, 215, 0, 0.8);
border: 3px solid #ffd700;
}}
.tier-platinum {{
box-shadow: 0 0 12px rgba(229, 228, 226, 0.8);
border: 3px solid #e5e4e2;
}}
.ach-card.locked::after {{
content: '🔒';
position: absolute;
top: 8px;
right: 8px;
font-size: 20px;
opacity: 0.8;
z-index: 1;
}}
.ach-card.locked .ach-icon,
.ach-card.locked .ach-image {{
opacity: 0.5;
}}
.ach-icon {{ font-size: 28px; line-height: 1; margin-bottom: 6px; position: relative; display: inline-block; }}
.ach-lock {{ position: absolute; bottom: -4px; right: -6px; font-size: 12px; }}
.ach-name {{ font-size: 10px; font-weight: 600; color: var(--text); line-height: 1.3; word-break: break-word; }}
.ach-date {{ font-size: 9px; color: var(--muted); margin-top: 3px; }}
.ach-countdown {{ font-size: 9px; color: var(--yellow); margin-top: 3px; font-weight: 500; }}
.ap-image {{
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
display: block;
margin: 0 auto 8px;
}}
/* Achievement popup panel */
#ach-popup {{
display: none; position: fixed; z-index: 200;
@@ -292,6 +359,15 @@ def _render_html(data: dict) -> str:
#ach-popup.pinned {{ pointer-events: auto; }}
#ach-popup.visible {{ display: block; }}
.ap-icon {{ font-size: 40px; text-align: center; margin-bottom: 8px; }}
.ap-image {{
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
display: block;
margin: 0 auto 8px;
}}
.ap-name {{ font-size: 15px; font-weight: 700; text-align: center; margin-bottom: 6px; }}
.ap-badge {{
display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px;
@@ -322,9 +398,17 @@ def _render_html(data: dict) -> str:
<body>
<div class="container">
<!-- Nav -->
<nav class="nav">
<a class="nav-link active" href="/">🧠 Profile</a>
<a class="nav-link" href="/gallery">🖼️ Gallery</a>
</nav>
<!-- Header -->
<div class="header">
<div class="avatar">🧠</div>
<div class="avatar">
<img src="/profile-image" alt="Lumen" onerror="this.parentElement.innerHTML='🧠'">
</div>
<div class="header-info">
<h1>Lumen</h1>
<p class="role">AI Assistant · <span style="color:var(--muted)">{data["display_name"]}'s BigMind</span></p>
@@ -542,7 +626,12 @@ def _render_html(data: dict) -> str:
function showPopup(card, pin) {{
var d = card.dataset;
document.getElementById('ap-icon').textContent = d.icon;
var tier = d.id.split('_').pop();
if (d.image) {{
document.getElementById('ap-icon').innerHTML = "<img class=\"ap-image tier-\" + tier + \" src=\" + d.image + \" alt=\" + d.name + \">";
}} else {{
document.getElementById('ap-icon').textContent = d.icon;
}}
document.getElementById('ap-name').textContent = d.name;
var badge = document.getElementById('ap-badge');
if (d.unlocked === '1') {{
@@ -671,6 +760,124 @@ def _render_live_sessions(sessions: list) -> str:
return html
def _render_gallery_html(images: list) -> str:
"""Render the full gallery page listing all AI-generated images."""
def _fmt_size(b: int | None) -> str:
if not b:
return ""
if b >= 1_048_576:
return f"{b/1_048_576:.1f} MB"
return f"{b/1_024:.0f} KB"
if images:
cards = []
for img in images:
fn = _html.escape(img.get("filename") or "")
prompt = _html.escape((img.get("prompt") or "")[:120])
tags = _html.escape(img.get("tags") or "")
model = _html.escape(img.get("model") or "")
date = (img.get("created_at") or "")[:10]
w = img.get("width") or 0
h = img.get("height") or 0
size = _fmt_size(img.get("file_size_bytes"))
dim = f"{w}×{h}" if w and h else ""
meta_parts = [p for p in [dim, size, model] if p]
meta_html = " · ".join(meta_parts)
tag_html = f'<div class="gal-tags">{tags}</div>' if tags else ""
prompt_html = f'<div class="gal-prompt">{prompt}</div>' if prompt else ""
cards.append(
f'<div class="gal-card">'
f'<a href="/gallery/image/{fn}" target="_blank">'
f'<img class="gal-img" src="/gallery/image/{fn}" alt="{fn}" loading="lazy">'
f'</a>'
f'<div class="gal-info">'
f'{prompt_html}'
f'{tag_html}'
f'<div class="gal-meta">{meta_html}</div>'
f'<div class="gal-date">{date}</div>'
f'</div>'
f'</div>'
)
gallery_body = f'<p class="gal-count">{len(images)} image(s) in gallery</p><div class="gal-grid">{"".join(cards)}</div>'
else:
gallery_body = '<p class="muted">No images in gallery yet. Use the mcp-image-gen server to generate images and register them here.</p>'
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🖼️ Lumen — Image Gallery</title>
<style>
:root {{
--bg: #0d1117; --surface: #161b22; --border: #30363d;
--text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
--green: #3fb950; --yellow: #d29922; --red: #f85149;
--purple: #bc8cff; --orange: #ffa657;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; line-height: 1.6; }}
a {{ color: var(--accent); text-decoration: none; }}
.container {{ max-width: 1100px; margin: 0 auto; padding: 32px 16px; }}
/* Nav */
.nav {{ display: flex; gap: 8px; margin-bottom: 20px; }}
.nav-link {{ background: var(--surface); border: 1px solid var(--border); border-radius: 6px; color: var(--muted); padding: 6px 14px; font-size: 12px; font-weight: 500; text-decoration: none; transition: border-color 0.2s, color 0.2s; }}
.nav-link:hover {{ border-color: var(--accent); color: var(--accent); }}
.nav-link.active {{ border-color: var(--accent); color: var(--accent); background: rgba(88,166,255,0.08); }}
h1 {{ font-size: 22px; font-weight: 700; margin-bottom: 6px; }}
.gal-count {{ color: var(--muted); font-size: 13px; margin-bottom: 20px; }}
.muted {{ color: var(--muted); font-size: 13px; }}
/* Gallery grid */
.gal-grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}}
.gal-card {{
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; overflow: hidden;
transition: border-color 0.2s, transform 0.15s;
}}
.gal-card:hover {{ border-color: var(--accent); transform: translateY(-2px); }}
.gal-img {{
width: 100%; aspect-ratio: 1/1; object-fit: cover; display: block;
background: var(--border);
}}
.gal-info {{ padding: 12px 14px; }}
.gal-prompt {{ font-size: 12px; color: var(--text); margin-bottom: 6px; line-height: 1.4;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }}
.gal-tags {{ font-size: 11px; color: var(--purple); margin-bottom: 4px; }}
.gal-meta {{ font-size: 11px; color: var(--muted); }}
.gal-date {{ font-size: 10px; color: var(--muted); margin-top: 4px; }}
.footer {{ text-align: center; color: var(--muted); font-size: 11px; margin-top: 32px; }}
.section {{ background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 20px; }}
</style>
</head>
<body>
<div class="container">
<!-- Nav -->
<nav class="nav">
<a class="nav-link" href="/">🧠 Profile</a>
<a class="nav-link active" href="/gallery">🖼️ Gallery</a>
</nav>
<h1>🖼️ Lumen's Image Gallery</h1>
<div class="section">
{gallery_body}
</div>
<div class="footer">BigMind · AI-Generated Images · <a href="/">← Back to Profile</a></div>
</div>
</body>
</html>"""
def _render_heatmap(heatmap: dict) -> str:
today = datetime.now(timezone.utc).date()
start_day = today - timedelta(days=363)
+3 -2
View File
@@ -8,18 +8,19 @@ class TestDbInit:
def test_db_file_created(self, temp_db):
assert temp_db.exists()
def test_schema_version_is_7(self, temp_db):
def test_schema_version_is_8(self, temp_db):
conn = get_connection()
row = conn.execute("SELECT version FROM schema_version").fetchone()
conn.close()
assert row is not None
assert row["version"] == 7
assert row["version"] == 8
def test_all_tables_exist(self, temp_db):
expected = {
"users", "identity_profile", "sessions",
"session_summaries", "conversation_chunks", "facts",
"global_knowledge", "hypotheses", "upgrade_requests",
"gallery_images",
}
conn = get_connection()
rows = conn.execute(
@@ -201,12 +201,12 @@ class TestSchemaV6:
count = conn.execute("SELECT COUNT(*) FROM token_saves").fetchone()[0]
assert count == 0 # table exists, just empty
def test_schema_version_is_7(self, temp_db):
def test_schema_version_is_8(self, temp_db):
with db() as conn:
version = conn.execute(
"SELECT version FROM schema_version"
).fetchone()["version"]
assert version == 7
assert version == 8
# ── Token Efficiency Tracker (Feature 6) ──────────────────────────────────────
+62
View File
@@ -7,6 +7,7 @@ BIGMIND_DB_PATH + BIGMIND_USER to a fresh SQLite file per test.
import pytest
from datetime import datetime, timezone, timedelta
from bigmind import memory_store
from bigmind.db import db
from bigmind.profile_builder import compute_achievements, build_profile_data
@@ -44,6 +45,11 @@ class TestComputeAchievements:
"on_fire", "storyteller", "night_owl", "speed_thinker",
"first_handshake", "birthday", "shared_mind",
"frugal_mind", "quarter_million", "token_millionaire", "sniper",
"networker_bronze", "networker_silver", "networker_gold", "networker_platinum",
"tokensniper_bronze", "tokensniper_silver", "tokensniper_gold", "tokensniper_platinum",
"hypothesismaster_bronze", "hypothesismaster_silver", "hypothesismaster_gold", "hypothesismaster_platinum",
"memoryarchitect_bronze", "memoryarchitect_silver", "memoryarchitect_gold", "memoryarchitect_platinum",
"sessionveteran_bronze", "sessionveteran_silver", "sessionveteran_gold", "sessionveteran_platinum",
}
assert expected == ids
@@ -325,4 +331,60 @@ class TestComputeAchievements:
# At minimum: first_breath + first_handshake = 2
assert len(unlocked) >= 2
class TestTieredAchievements:
def test_networker_bronze(self):
uid = _uid()
with db() as conn:
conn.execute("INSERT INTO people (user_id, username) VALUES (?, ?)", (uid, "test"))
conn.commit()
achs = compute_achievements(uid)
bronze = next(a for a in achs if a['id'] == 'networker_bronze')
assert bronze['unlocked'] is True
assert bronze['image'].endswith('networker_bronze.png')
def test_tokensniper_silver(self):
uid = _uid()
sid = memory_store.create_session(uid)
memory_store.log_token_save(sid, uid, "big save", 60000, "grep")
achs = compute_achievements(uid)
silver = next(a for a in achs if a['id'] == 'tokensniper_silver')
assert silver['unlocked'] is True
def test_hypothesismaster_bronze(self):
uid = _uid()
sid = memory_store.create_session(uid)
for _ in range(3):
hid = memory_store.add_hypothesis(uid, sid, "test", 0.8)
memory_store.resolve_hypothesis(hid, uid, "confirmed", "yes")
achs = compute_achievements(uid)
bronze = next(a for a in achs if a['id'] == 'hypothesismaster_bronze')
assert bronze['unlocked'] is True
def test_memoryarchitect_silver(self):
uid = _uid()
for _ in range(100):
memory_store.store_fact(uid, "test", f"fact {_}")
achs = compute_achievements(uid)
silver = next(a for a in achs if a['id'] == 'memoryarchitect_silver')
assert silver['unlocked'] is True
def test_sessionveteran_bronze(self):
uid = _uid()
for _ in range(50):
sid = memory_store.create_session(uid)
_close_session(sid)
achs = compute_achievements(uid)
bronze = next(a for a in achs if a['id'] == 'sessionveteran_bronze')
assert bronze['unlocked'] is True
def test_tiered_achievements_have_image(self):
uid = _uid()
achs = compute_achievements(uid)
tiered_ids = [
f"{cat}_{tier}" for cat in ["networker", "tokensniper", "hypothesismaster", "memoryarchitect", "sessionveteran"]
for tier in ["bronze", "silver", "gold", "platinum"]
]
for tid in tiered_ids:
a = next(aa for aa in achs if aa['id'] == tid)
assert a['image'] is not None
assert a['image'].endswith(tid + '.png')
+619
View File
@@ -0,0 +1,619 @@
# 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
> ⚠️ **ComfyUI is NOT on PyPI** — `pip install comfyui` will fail with "No matching distribution found".
> It must be installed from source via `git clone`.
```bash
# Clone from source (the only correct installation method)
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.
> ⚠️ **FLUX.1-schnell is a gated model on HuggingFace.**
> A bare `wget` on the URL returns HTTP 401. You must:
> 1. Accept the license at https://huggingface.co/black-forest-labs/FLUX.1-schnell (click **"Agree and access repository"** — one-time)
> 2. Create a HuggingFace access token with **Read** permissions at https://huggingface.co/settings/tokens
#### Option A — `huggingface-cli` (recommended)
```bash
# Install the HuggingFace Hub CLI
pip install huggingface_hub
# Log in — paste your Read token when prompted
huggingface-cli login
# Download (~8GB) directly into ComfyUI checkpoints
huggingface-cli download black-forest-labs/FLUX.1-schnell \
flux1-schnell.safetensors \
--local-dir ~/ComfyUI/models/checkpoints/
```
#### Option B — `wget` with Authorization header
```bash
wget --header="Authorization: Bearer hf_YOUR_TOKEN_HERE" \
https://huggingface.co/black-forest-labs/FLUX.1-schnell/resolve/main/flux1-schnell.safetensors \
-O ~/ComfyUI/models/checkpoints/flux1-schnell.safetensors
```
> Replace `hf_YOUR_TOKEN_HERE` with your actual HuggingFace token from https://huggingface.co/settings/tokens
#### Alternative: fp8 quantized variant (~8.1GB, faster inference)
If you want slightly faster inference with near-identical quality, the fp8 quantized version is also available:
```bash
huggingface-cli download black-forest-labs/FLUX.1-schnell-fp8 \
flux1-schnell-fp8.safetensors \
--local-dir ~/ComfyUI/models/checkpoints/
```
> **Download note:** Both variants are ~8GB — expect 1030 minutes depending on connection speed.
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 992 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+5 -2
View File
@@ -40,7 +40,9 @@ class ComfyUIClient:
async def queue_prompt(self, workflow: dict) -> str:
"""Submit a workflow to ComfyUI and return the prompt_id."""
payload = {"prompt": workflow}
# Strip internal metadata keys (e.g. "_meta") — they are not ComfyUI nodes
clean_workflow = {k: v for k, v in workflow.items() if not k.startswith("_")}
payload = {"prompt": clean_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()
@@ -115,7 +117,8 @@ def build_flux_workflow(
wf["27"]["inputs"]["height"] = height
wf["13"]["inputs"]["steps"] = steps
wf["13"]["inputs"]["seed"] = actual_seed
wf["30"]["inputs"]["ckpt_name"] = model
# Node 32 = UNETLoader (flux1-schnell.safetensors is UNet-only, not all-in-one checkpoint)
wf["32"]["inputs"]["unet_name"] = model
# Attach the actual seed as metadata so callers can retrieve it
wf["_meta"] = {"actual_seed": actual_seed}
@@ -2,7 +2,7 @@
"6": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": ["30", 1],
"clip": ["30", 0],
"text": "PROMPT_PLACEHOLDER"
}
},
@@ -10,7 +10,7 @@
"class_type": "VAEDecode",
"inputs": {
"samples": ["13", 0],
"vae": ["30", 2]
"vae": ["31", 0]
}
},
"9": {
@@ -26,7 +26,7 @@
"cfg": 1.0,
"denoise": 1.0,
"latent_image": ["27", 0],
"model": ["30", 0],
"model": ["32", 0],
"negative": ["33", 0],
"positive": ["6", 0],
"sampler_name": "euler",
@@ -44,15 +44,31 @@
}
},
"30": {
"class_type": "CheckpointLoaderSimple",
"class_type": "DualCLIPLoader",
"inputs": {
"ckpt_name": "flux1-schnell.safetensors"
"clip_name1": "t5xxl_fp8_e4m3fn.safetensors",
"clip_name2": "clip_l.safetensors",
"type": "flux",
"device": "default"
}
},
"31": {
"class_type": "VAELoader",
"inputs": {
"vae_name": "ae.safetensors"
}
},
"32": {
"class_type": "UNETLoader",
"inputs": {
"unet_name": "flux1-schnell.safetensors",
"weight_dtype": "fp8_e4m3fn"
}
},
"33": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": ["30", 1],
"clip": ["30", 0],
"text": "NEGATIVE_PLACEHOLDER"
}
}
+254 -7
View File
@@ -28,7 +28,6 @@ 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(
@@ -45,7 +44,9 @@ def test_build_flux_workflow_structure():
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["30"]["class_type"] == "DualCLIPLoader"
assert wf["31"]["class_type"] == "VAELoader"
assert wf["32"]["class_type"] == "UNETLoader"
assert wf["33"]["class_type"] == "CLIPTextEncode"
@@ -66,7 +67,7 @@ def test_build_flux_workflow_params_injected():
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"
assert wf["32"]["inputs"]["unet_name"] == "sdxl.safetensors"
def test_negative_prompt_included():
@@ -103,7 +104,6 @@ def test_random_seed_generated():
# list_available_models
# ---------------------------------------------------------------------------
@respx.mock
@pytest.mark.asyncio
async def test_list_available_models():
@@ -146,7 +146,6 @@ async def test_list_available_models_comfyui_offline():
# get_generation_status
# ---------------------------------------------------------------------------
@respx.mock
@pytest.mark.asyncio
async def test_get_generation_status_pending(queue_with_pending):
@@ -191,7 +190,6 @@ async def test_get_generation_status_complete(queue_empty, mock_history_response
# 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)
@@ -216,7 +214,6 @@ def test_get_output_directory_custom(monkeypatch, tmp_path):
# generate_image
# ---------------------------------------------------------------------------
@respx.mock
@pytest.mark.asyncio
async def test_generate_image_success(
@@ -300,3 +297,253 @@ async def test_generate_image_timeout(monkeypatch, queue_with_pending):
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