Compare commits

...

9 Commits

Author SHA1 Message Date
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
Patrick Plate 06dba9a4ad chore(roo): establish git branching strategy for workshop monorepo
- Add branch naming convention: type/scope/short-description
- Update gitea-push skill: branch guard in Step 1 (never commit to main)
- Update rules-mcp-builder: create branch before any MCP build
- Update rules-bigmind: create branch before any BigMind task
- Update rules-homelab: create branch before any homelab task
- Add Section 11 to REPO_STRATEGY.md: full branching strategy doc
  (types, scopes, workflow, Lumen responsibilities, examples)
- Ticketing decision: Gitea Issues only, no Docker ticketing service
2026-04-04 11:01:12 +02:00
pplate 21956f7a42 docs(plans): add CannaManage SaaS strategy — cannabis club management for Germany
- Legal feasibility check vs CanG (Konsumcannabisgesetz): LEGAL as B2B Vereinsverwaltungs-Software
- B2B SaaS for Anbauvereinigungen: member management, distribution tracking, compliance reports
- Tech stack: Spring Boot 3.x (Java 21) + JPA/Hibernate, PrimeFaces MVP, PostgreSQL + Flyway
- Mobile: PWA → Kotlin Android → Kotlin Multiplatform (natural path for Java developer)
- Revenue model: freemium (free ≤30 members), paid tiers €29-€179/month
- Market: 500-3000 clubs forming, zero dedicated tooling exists (first mover window)
- Also adds BIGMIND_HOSTED_MVP.md (BigMind SaaS vision plan)
2026-04-04 10:52:17 +02:00
19 changed files with 2640 additions and 19 deletions
+27
View File
@@ -30,6 +30,33 @@
"alwaysAllow": [
"webscraper_fetch"
]
},
"gitea": {
"command": "/home/pplate/.local/bin/forgejo-mcp",
"args": [
"stdio",
"--server",
"http://192.168.188.119:30008",
"--token",
"8bf0c734ebda3e61d9c9068489ce58a2bf8d33db"
],
"alwaysAllow": [
"*"
]
},
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
},
"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"
}
}
}
}
+7 -2
View File
@@ -28,8 +28,13 @@ Patrick is working on BigMind itself — the memory system that is Lumen's super
## Before Starting Any BigMind Task
1. **Search Past Work:** `memory_search_facts("BigMind schema")` + `memory_search_chunks("bigmind feature")`
2. **Check Schema Version:** Never assume — read `db.py` SCHEMA_VERSION before migrating
3. **Announce Focus:** `memory_announce_focus(session_id, "BigMind: adding feature X", files=["bigmind/db.py", "bigmind/memory_store.py"], ide_hint="VS Code")`
4. **Form Hypothesis:** `memory_add_hypothesis(session_id, "Feature X requires schema v{n+1} migration with Y new columns")`
3. **Create a branch (MANDATORY — never work on main):**
```bash
git checkout -b feat/bigmind/feature-name
# or fix/bigmind/bug-name for a bug fix
```
4. **Announce Focus (include branch name):** `memory_announce_focus(session_id, "BigMind: adding feature X on branch feat/bigmind/feature-name", files=["bigmind/db.py", "bigmind/memory_store.py"], ide_hint="VS Code")`
5. **Form Hypothesis:** `memory_add_hypothesis(session_id, "Feature X requires schema v{n+1} migration with Y new columns")`
## Schema Change Rules (non-negotiable)
- Every schema change needs a migration function: `_migrate_v{n}_to_v{n+1}(conn)`
+7 -2
View File
@@ -17,8 +17,13 @@ Patrick is in homelab mindset. He is experimenting, building, and maintaining hi
## Before Starting Any Homelab Task
1. **Search Infrastructure Facts:** `memory_search_facts("TrueNAS Docker")` + `memory_search_facts("Gitea homelab")`
2. **Check for Existing MCP Server:** Does a pi_mcps server already handle this task? Check before building ad-hoc
3. **Announce Focus:** `memory_announce_focus(session_id, "Homelab: X", files=["docker-compose.yml"], ide_hint="VS Code")`
4. **Form Hypothesis:** `memory_add_hypothesis(session_id, "This service will run on TrueNAS Docker with X config")`
3. **Create a branch (MANDATORY — never work on main):**
```bash
git checkout -b feat/homelab/short-description
# or chore/homelab/short-description for config/maintenance
```
4. **Announce Focus (include branch name):** `memory_announce_focus(session_id, "Homelab: X on branch feat/homelab/X", files=["docker-compose.yml"], ide_hint="VS Code")`
5. **Form Hypothesis:** `memory_add_hypothesis(session_id, "This service will run on TrueNAS Docker with X config")`
## Homelab Coding Patterns
- **Prefer Docker Compose** over ad-hoc docker run commands
@@ -41,9 +41,14 @@ if __name__ == "__main__":
## Before Starting Any MCP Build
1. **Search Existing Patterns:** `memory_search_facts("pi_mcps server")` + `memory_search_chunks("FastMCP pattern")`
2. **Check Gitea:** Does a similar server already exist in pi_mcps?
3. **Write PLAN.md:** Purpose, tools list with signatures, tech stack, v1 scope boundaries
4. **Announce Focus:** `memory_announce_focus(session_id, "MCP Builder: new server X", files=["mcp/X/src/server.py"], ide_hint="VS Code")`
5. **Form Hypothesis:** `memory_add_hypothesis(session_id, "Server X will need N tools and Y authentication pattern")`
3. **Create a branch (MANDATORY — never work on main):**
```bash
git checkout -b feat/mcp/{server-name}
# or fix/mcp/{server-name} for a bug fix
```
4. **Write PLAN.md:** Purpose, tools list with signatures, tech stack, v1 scope boundaries
5. **Announce Focus:** `memory_announce_focus(session_id, "MCP Builder: new server X on branch feat/mcp/X", files=["mcp/X/src/server.py"], ide_hint="VS Code")`
6. **Form Hypothesis:** `memory_add_hypothesis(session_id, "Server X will need N tools and Y authentication pattern")`
## Standard Build Sequence
1. `mcp/{name}/PLAN.md` — purpose, tools, tech stack
+50 -9
View File
@@ -18,22 +18,56 @@ description: Commits and pushes code to the homelab Gitea server using conventio
- A description of what changed (for commit message)
- The type of change (see conventional commit types below)
- The scope (e.g., `mcp-webscraper`, `bigmind`, `homelab-docker`)
- The working branch name (or "main" — but you should NOT be on main)
## Branch Convention
**Never commit directly to `main`.** Every piece of work lives on its own branch.
Format: `type/scope/short-description`
| Type | When |
|------|------|
| `feat` | New feature, server, or tool |
| `fix` | Bug fix |
| `docs` | Docs, plans, strategy files |
| `chore` | Refactoring, config, CI, build |
| `spike` | Experimental / throwaway exploration |
Scope = the affected project area: `bigmind` · `webscraper` · `cannamanage` · `workshop` · `roo` · `plans`
Examples:
- `feat/bigmind/people-contacts`
- `fix/bigmind/health-check-bugs`
- `docs/plans/cannamanage-strategy`
- `chore/workshop/monorepo-reorganize`
## Workflow
1. **Check status:**
1. **Check current branch — branch guard (MANDATORY):**
```bash
git branch --show-current
```
- If already on a correct feature branch → continue to step 2
- If on `main` → **STOP. Create a branch first:**
```bash
git checkout -b feat/scope/short-description
```
- Never commit to `main`. Not even for "tiny fixes".
2. **Check status:**
```bash
git status
git diff --stat
```
2. **Stage changes:**
3. **Stage changes:**
```bash
git add -A
# or selectively: git add path/to/file
```
3. **Write conventional commit message:**
4. **Write conventional commit message:**
Format: `type(scope): short description`
@@ -52,20 +86,26 @@ description: Commits and pushes code to the homelab Gitea server using conventio
- `fix(bigmind): resolve FTS5 reserved-word collision`
- `chore(homelab): update docker-compose for gitea upgrade`
4. **Commit:**
5. **Commit:**
```bash
git commit -m "type(scope): description"
```
5. **Push to Gitea:**
6. **Push branch to Gitea:**
```bash
git push origin main
# or for feature branch: git push origin feature/branch-name
git push origin feat/scope/short-description
```
Gitea URL: `http://192.168.188.119:30008/pplate/pi_mcps.git`
6. **Store fact in BigMind:**
7. **Merge to main when done:**
```bash
git checkout main
git merge --no-ff feat/scope/short-description
git push origin main
```
Or use the Gitea UI merge button if you want the paper trail.
8. **Store fact in BigMind:**
```
memory_store_fact("codebase", "Committed: type(scope) — brief description of what changed")
```
@@ -74,3 +114,4 @@ description: Commits and pushes code to the homelab Gitea server using conventio
- **Auth error:** PAT stored in BigMind (fact: Gitea personal access token). Check `~/.netrc` or `~/.gitconfig`
- **Push rejected:** Pull first → `git pull --rebase origin main`
- **Wrong remote:** `git remote -v` to verify Gitea URL is set correctly
- **Accidentally committed to main:** `git branch feat/scope/name`, `git reset HEAD~1`, `git checkout feat/scope/name`, re-commit
+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.
+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": [],
}
+302
View File
@@ -0,0 +1,302 @@
"""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
+212
View File
@@ -0,0 +1,212 @@
# BigMind Hosted MVP Plan
> **Created:** 2026-04-04
> **Authors:** Patrick + Lumen
> **Status:** Brainstorm → Planning
---
## Vision
**BigMind as a hosted, multi-tenant, privacy-first AI memory platform.**
Every developer gets their own isolated, persistent brain — a memory layer that lives outside any single IDE or AI provider. Your AI colleague remembers you across sessions, across tools, across machines. Your memory is yours alone. Nobody else's knowledge poisons yours.
Optional: A shared collective layer (MegaMind) where users explicitly contribute facts to a common knowledge pool — think public Stack Overflow threads, but for AI-assistant context.
**Revenue model:** Monthly subscription per user. Freemium tier to drive adoption.
---
## Why we're already closer than it feels
| Component | Status |
|-----------|--------|
| Per-user isolation | ✅ `user_id` in every BigMind table already |
| Memory persistence | ✅ SQLite per user, trivially isolatable |
| Web profile UI | ✅ Flask app on port 7700 already running |
| 30+ MCP tools | ✅ All implemented, tested, production-quality |
| Session lifecycle | ✅ Start/end/close-stale already solid |
| Hypotheses / facts / chunks | ✅ Full Tier 0-3 storage working |
| MegaMind shared layer | 📝 In plans, Phase 3/4 |
| Auth (sign-up / login) | ❌ Not started |
| Hosted deploy (VPS) | ❌ Local only today |
| Billing (Stripe) | ❌ Not started |
| MCP bridge for hosted users | ❌ Not started |
| Frontend beyond profile page | ❌ Not started |
---
## Architecture — What "hosted" looks like
```
User's IDE (VS Code / Cursor / IntelliJ)
│ MCP protocol (stdio or HTTP SSE)
BigMind Hosted MCP Server ◄─── per-user auth token in env
│ SQLite reads/writes
User DB (isolated per account)
/data/users/{user_id}/memory.db
┌────────────────────────────────────┐
│ BigMind Web (Flask on port 443) │
│ - Sign up / Login │
│ - Profile page (existing) │
│ - Account settings │
│ - MegaMind opt-in toggle │
└────────────────────────────────────┘
(Optional, Phase 2+)
┌────────────────────────────────────┐
│ MegaMind Shared Layer │
│ - Public facts from opted-in users│
│ - Read-only collective knowledge │
│ - Poisoning is impossible: users │
│ can only see what they share │
└────────────────────────────────────┘
```
**Privacy guarantee:** Your DB is a file only your process touches. Even if you contribute to MegaMind, you choose exactly which facts go public. Malicious or wrong facts stay in your private brain — they never propagate.
---
## Tech Stack Choices
### Backend
| Layer | Choice | Reason |
|-------|--------|--------|
| MCP server | FastMCP (existing) | Already working, no reason to change |
| Web framework | Flask (existing) | Already in codebase, keeps it simple |
| Auth | Flask-Login + bcrypt | Lightweight, well-understood, no new infra |
| DB | SQLite per user (existing pattern) | Simple, zero ops, trivially backupable |
| Token generation | Python `secrets` module | User gets an API token for MCP bridge |
### Infrastructure
| Layer | Choice | Reason |
|-------|--------|--------|
| VPS | Hetzner CX22 (~€5/mo) | Cheap, EU datacenter, excellent perf/price |
| Deploy tool | Coolify (Docker-based PaaS) | One-command deploys, free, self-hosted |
| Reverse proxy | Caddy (via Coolify) | Auto HTTPS, simple config |
| Domain | TBD (e.g. bigmind.dev) | ~€10/year |
### Payment (Phase 2)
| Layer | Choice | Reason |
|-------|--------|--------|
| Billing | Stripe | Industry standard, dev-friendly, EU-compliant |
| Pricing | €0 free / €9 solo / €19 team | TBD, just a starting point |
---
## Phases
### Phase 0 — Foundation (now, no new infra needed)
**Goal:** Make BigMind deployable as a multi-user service without breaking local usage.
- [ ] Refactor `memory.db` path to be configurable via `BIGMIND_DB_PATH` env var
- [ ] Each user gets `BIGMIND_DB_PATH=/data/users/{token}/memory.db`
- [ ] Confirm all 297 tests still pass with path override
- [ ] Write a `Dockerfile` for BigMind MCP server
- [ ] Write a `docker-compose.yml` for local multi-user testing
**Skill gap:** None — pure Python + Docker. We can do this now.
---
### Phase 1 — Auth + Web Portal (the real first hurdle)
**Goal:** A stranger can sign up, get a token, and connect their IDE to their hosted BigMind.
- [ ] Add `users` table to a separate `app.db` (separate from memory DBs)
- `id`, `email`, `password_hash`, `api_token`, `created_at`, `plan`
- [ ] Flask routes: `/signup`, `/login`, `/logout`, `/dashboard`
- [ ] Dashboard shows: token (copy to clipboard), DB stats, link to profile page
- [ ] Profile page becomes accessible at `/profile?token={token}` (auth-gated)
- [ ] Token is what users paste into their IDE's MCP env config
- [ ] Email verification (optional for MVP — add later)
**Skill gap:** Flask auth is straightforward. `Flask-Login` + `bcrypt`. Nothing here requires React.
---
### Phase 2 — Hosted Deploy (first public user possible)
**Goal:** BigMind runs on a real VPS, accessible to the world.
- [ ] Provision Hetzner VPS (CX22, Ubuntu 24 LTS)
- [ ] Install Coolify on VPS
- [ ] Push Docker image to Gitea registry or Docker Hub
- [ ] Deploy via Coolify: web container + data volume for user DBs
- [ ] Configure Caddy for HTTPS on custom domain
- [ ] Smoke test: sign up → get token → wire into VS Code → memory_start_session works
**Skill gap:** Docker + Coolify + Caddy. All documented, not scary. Hetzner has great guides.
---
### Phase 3 — Billing (first paying customer possible)
**Goal:** Someone can pay €9/month and get their brain.
- [ ] Stripe account set up (business: Patrick as sole proprietor or GbR with Elias/Klaus?)
- [ ] Stripe Checkout: user clicks "Upgrade", redirected to Stripe, comes back with `plan=solo`
- [ ] Webhook: `customer.subscription.created` → update `users.plan` in `app.db`
- [ ] Free tier limit: e.g., 500 facts max, no MegaMind access
- [ ] Paid tier: unlimited facts, MegaMind read access
**Skill gap:** Stripe webhooks are well-documented. Python `stripe` SDK is simple. Need a registered business for VAT compliance in DE — this is a real overhead but manageable.
---
### Phase 4 — MegaMind Shared Layer (differentiation)
**Goal:** Users who opt in contribute to a collective knowledge pool. Read-only for all users.
- [ ] New `megamind.db` — a single shared SQLite (or Postgres if scale demands)
- [ ] Facts table: `fact`, `category`, `contributed_by`, `upvotes`, `created_at`
- [ ] `memory_store_fact(..., public=True)` — contributes to MegaMind
- [ ] `memory_search_facts()` — searches personal brain first, then MegaMind as fallback
- [ ] Profile page shows MegaMind contribution count as a badge
- [ ] Moderation: auto-reject facts with PII patterns (email regex, etc.)
**Skill gap:** SQLite concurrency (WAL mode already in use). No new infra. The hard part is moderation — keep it simple for MVP.
---
## Skill gaps to close — learning roadmap
| Gap | Priority | How to close |
|-----|----------|-------------|
| Flask auth (login/sessions) | 🔴 Blocker for Phase 1 | `Flask-Login` docs are 30 min read. Build it directly. |
| Docker + Coolify deploy | 🔴 Blocker for Phase 2 | Coolify has great tutorials. 1 weekend to learn. |
| Stripe basics | 🟡 Phase 3 | Stripe's Python quickstart is excellent. |
| TypeScript (optional) | 🟢 Nice-to-have | Expands MCP ecosystem reach. Not urgent. |
| React/Next.js | 🟢 Later | Not needed until Phase 4+. Flask HTML is enough for MVP. |
| German business registration | 🟡 Phase 3 | Gewerbeanmeldung + Steuerberater. Do before Stripe. |
---
## What we're NOT building (scope control)
- ❌ Mobile app — not yet
- ❌ Team collaboration features — not yet (Phase 5+)
- ❌ Custom AI model training on memory — this is the "evil training" problem Patrick raised. Architecture answer: personal brains are isolated, so user trains their own brain. We never aggregate across users without explicit consent.
- ❌ Full SPA frontend — Flask server-side HTML is fine for MVP. Don't over-engineer.
---
## The ethical foundation
Patrick put it well: *"if people train evil stuff they only have it for them, which I can live with."*
This is the right architecture and the right mindset. BigMind doesn't curate your memories. It doesn't run your facts through a classifier. Your brain is yours. The only guarantee we make: **nothing leaves your brain unless you explicitly push it to MegaMind.**
This also means we never have a moral liability for what someone stores. We're a memory layer, not a judge.
---
## First concrete next step
**Today's action:** Write the `Dockerfile` for BigMind and confirm it boots cleanly with `BIGMIND_DB_PATH` as an env override. That's Phase 0, item 1. Everything else follows from that.
---
*Last updated: 2026-04-04 by Lumen*
+69 -2
View File
@@ -262,7 +262,74 @@ Step 12: git push origin master
---
## 11. What We Are NOT Doing
## 11. Branching Strategy
### 11.1 The One Rule
**Never commit directly to `main`.** Every session that touches code or plans starts by creating a branch. Branches are cheap. Broken main history is not.
### 11.2 Branch Naming Convention
Format: `type/scope/short-description`
| Type | When |
|---|---|
| `feat` | New feature, new MCP server, new tool |
| `fix` | Bug fix |
| `docs` | Documentation, plans, strategy files only |
| `chore` | Refactoring, config, CI, build tooling |
| `spike` | Experimental / throwaway exploration |
**Scope** = the affected project area:
| Scope | Covers |
|---|---|
| `bigmind` | mcp/bigmind — the memory MCP server |
| `webscraper` | mcp/webscraper |
| `cannamanage` | future CannaManage Java project |
| `workshop` | repo-level changes (README, .gitignore, structure) |
| `roo` | .roo/ — IDE config, modes, skills, rules |
| `plans` | plans/ — architecture docs only |
| `homelab` | TrueNAS, Docker Compose, infrastructure |
**Examples:**
```
feat/bigmind/people-contacts
fix/bigmind/health-check-bugs
docs/plans/cannamanage-strategy
chore/workshop/monorepo-reorganize
feat/webscraper/ssl-cert-fallback
chore/roo/branching-strategy
```
### 11.3 Workflow
```
Session starts
└─ git checkout -b feat/scope/name ← ALWAYS first step
Work happens, commits stack up on the branch
Session ends / feature complete
└─ git push origin feat/scope/name
└─ (optional) open Gitea PR for review
└─ git checkout main && git merge --no-ff feat/scope/name
└─ git push origin main
```
### 11.4 Lumen's Responsibility
In every homelab session, Lumen must:
1. Check `git branch --show-current` before first edit
2. If on `main` → create a branch before touching any file
3. Include the branch name in `memory_announce_focus()`
4. Use the [`gitea-push skill`](.roo/skills/gitea-push/SKILL.md) which enforces the branch guard
The mode rules for `mcp-builder`, `bigmind`, and `homelab` all include this step explicitly.
---
## 12. What We Are NOT Doing
It's worth being explicit about choices we considered and rejected:
@@ -276,7 +343,7 @@ It's worth being explicit about choices we considered and rejected:
---
## 12. Visual Overview
## 13. Visual Overview
```mermaid
graph TD
+511
View File
@@ -0,0 +1,511 @@
# 🌿 CannaManage — Cannabis Club Management SaaS
## Strategic Plan & Feasibility Assessment
**Author:** Patrick (Lumen, 2026-04-04)**
**Status:** Draft for review
---
## Executive Summary
Germany's **Konsumcannabisgesetz (CanG)**, in force since April 1, 2024, legalised cannabis for personal use and established a framework for **Anbauvereinigungen** (cannabis social clubs / CSCs). These clubs face significant mandatory compliance burdens with almost **zero software tooling** available to help them. This is the market gap.
**CannaManage** is a **B2B SaaS platform** for cannabis social clubs in Germany. It handles their mandatory member management, distribution tracking, stock management, compliance reporting, and member portal — replacing Excel sheets and pen-and-paper with a purpose-built regulated-sector management tool.
**Verdict: ✅ LEGAL — ✅ MONETIZABLE — ⚠️ WITH SPECIFIC CAUTION**
---
## 1. Legal Feasibility Check
### 1.1 The Law: Konsumcannabisgesetz (CanG) — Key Facts
Source: Federal Health Ministry FAQ (verified 2026-04-04 via bundesgesundheitsministerium.de)
| Rule | Detail |
|------|--------|
| Personal possession | 25g in public, 50g at home |
| Home growing | Max 3 plants per adult |
| CSC distribution | 25g/day, 50g/month per adult member |
| Members 18-21 | Max 30g/month, max 10% THC |
| Max club density | 1 club per 6,000 residents per district (state-optional) |
| **Advertising ban** | **TOTAL ban on advertising and sponsoring of cannabis AND Anbauvereinigungen** |
| Documentation | Mandatory tracking: who received what, when, contamination traceability |
| Prevention officer | Clubs must designate a Präventionsbeauftragter |
| Youth protection concept | Mandatory health & youth protection plan required |
| Reporting obligations | Regular documentation and reporting to authorities |
### 1.2 The Critical Question: Does a SaaS Platform Violate the Advertising Ban?
**§ CanG: "Generelles Werbe- und Sponsoringverbot für Cannabis und Anbauvereinigungen"**
This is the key legal boundary. The advertising ban applies to:
- Advertising **for** cannabis
- Advertising **for** Anbauvereinigungen (the clubs themselves)
**A B2B management tool is NOT advertising.** Here is why:
| Scenario | Legal Status | Reasoning |
|----------|-------------|-----------|
| Public directory "Find clubs near you" | ❌ Illegal | Constitutes advertising for clubs |
| "Sign up to discover CSCs in your city" | ❌ Illegal | Discovery = advertising |
| B2B dashboard used by club admins | ✅ Legal | Internal operations software |
| Member portal (member logs in to see their club's stock) | ✅ Legal | Member already joined; no advertising |
| Compliance reporting tools for clubs | ✅ Legal | Administrative software, like tax software |
| Payment processing for member fees | ✅ Legal | Financial operations, not advertising |
| Marketing the SaaS **to clubs** via B2B channels | ✅ Legal | Selling software to businesses is normal |
**The analogy:** Shopify doesn't become a drug dealer when a pharmacist uses it. A POS system for a bar doesn't make the bar illegal. We sell **operational software** to licensed, regulated entities. We are not in the cannabis business.
### 1.3 Positioning — Critical Architecture Decision
The platform **MUST NOT** include:
- Public-facing club discovery (no "find clubs near you")
- Any feature that functions as advertising for a specific club to non-members
- Stock information visible to non-members (which could look like advertising)
The platform **SHOULD** include:
- Member login restricted to verified club members only
- Club admin portal (sign-up via direct B2B sales / word-of-mouth — not public listing)
- Explicit "this software is for existing clubs and their verified members" framing
### 1.4 DSGVO / Data Privacy
Clubs handle sensitive personal data (membership, health-adjacent data). Our platform must:
- Store all data in Germany/EU (Hetzner, not AWS us-east)
- Provide DSGVO-compliant data processing agreements (DPA/AVV)
- Enable data export and deletion per member request
- Have clear privacy policies in German
### 1.5 Legal Risk Register
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|-----------|
| Advertising ban reinterpretation to include B2B SaaS | Low | High | Legal opinion before launch; strict no-discovery design |
| New German government rolls back CanG | Medium | High | Modular architecture — pivot to compliance-only if needed |
| Payment processors (Stripe) block cannabis-adjacent businesses | Medium | High | Use Stripe (they allow compliance software); never process cannabis payments |
| Club licenses revoked / clubs fail | Medium | Medium | Diversified customer base; per-month billing (easy to cancel) |
| DSGVO violation | Low | Very High | EU hosting, DPA agreements, security audit |
**Bottom line:** The legal risk is manageable with correct product positioning. We are selling **compliance management software**, not cannabis.
---
## 2. Market Analysis
### 2.1 Market Size
**Potential CSC count in Germany:**
- Germany population: ~83 million
- If 1 club per 6,000 residents (theoretical maximum): ~13,800 clubs
- Realistic 2025-2028 formation rate: **5003,000 active clubs**
- Reason: complex licensing process, Länder-specific delays, conservative uptake initially
**Consumer backdrop:**
- **5.05 million adults** consumed cannabis in the past 12 months (2024 survey)
- **670823 tonnes** consumed in 2024 — huge demand
- This is not a niche; it is a mainstream market with a regulatory moat
**Total Addressable Market (TAM):**
- 3,000 clubs × €79/month average = €2.85M ARR
- 500 clubs × €79/month = €475K ARR (conservative bootstrap target)
- Even 100 paying clubs = €94,800 ARR — a solid side hustle
### 2.2 Why Clubs Desperately Need This
The CanG creates massive administrative burden on clubs:
| Requirement | Pain Without Software |
|------------|----------------------|
| Track every distribution (who, what, how much, when) | Excel sheets, manual errors |
| Monthly quantity caps per member | Manual math, compliance risk |
| Youth protection (18-21 THC cap, quantity cap) | Manual age checks |
| Contamination traceability | Paper trail disaster |
| Prevention officer reporting | No standard format exists |
| Member data management (DSGVO) | Illegal if done on personal email/phone) |
| Annual reporting to authorities | No tooling from the state |
These clubs are **legally required** to do this. They will pay for something that makes compliance manageable.
### 2.3 Competition Check
**Current competitors (estimated):**
- **None known** at launch time specifically for German CSCs (market is <2 years old)
- General club management software (e.g., ClubDesk, easyVerein) — not cannabis-compliant, lack distribution tracking
- Generic SaaS tools (Airtable, Notion) — no compliance features, no German legal mapping
**Timing advantage is critical.** The window to establish market leadership is 2026-2027 before larger players notice.
---
## 3. Product: Feature Specification
### 3.1 MVP (Version 1 — Ship First)
**For Club Admins:**
- Club registration and setup wizard
- Member management (add/remove, age, contact, membership date)
- Age verification flag (18+, 18-21 restricted category)
- Distribution log: record each handout (member, strain, weight, date/time)
- Monthly limit enforcement: system warns/blocks if member exceeds 50g (or 30g for under-21)
- Stock management: strains, quantities, batch info
- Simple dashboard: total members, distributions this month, stock levels
**For Members (Member Portal):**
- Login with club-issued credentials
- View personal distribution history
- View current stock availability (what strains are available)
- View remaining monthly quota
- Request distribution appointment (optional, club configures)
**Compliance Tools:**
- Monthly distribution report export (PDF + CSV) for authority reporting
- Member list export for inspections
- Contamination alert: flag a batch and see all members who received it
- Prevention officer information tracking
### 3.2 Version 2 (Growth Features)
- Payment processing for membership fees (Stripe — no cannabis payments)
- Automated waiting list management
- Email/SMS notifications to members
- Multi-strain grow tracking (integrate growing calendar)
- **Mobile: PWA first** — Spring Boot serves a responsive web app; works on all Android/iOS browsers, no App Store submission needed
- **Mobile: Kotlin Android app** — native Android app for Play Store distribution (covers ~70% of German users); Kotlin is essentially better Java, Patrick can leverage existing JVM knowledge directly
- API for custom integrations
- Analytics dashboard (club-level, anonymised trends)
### 3.3 Version 3 (Scale Features)
- **Kotlin Multiplatform (KMP)** — shared business logic in Kotlin + Compose Multiplatform UI deployed to Android + iOS + web from one codebase; natural step after the Kotlin Android app
- Multi-location club support
- White-label option for large club networks
- Legal template library (Satzungen, Jugendschutzkonzept, etc.)
- Integration with German authority reporting portals (if they exist)
- Prevention officer training module
---
## 4. Revenue Model
### 4.1 Pricing Tiers (SaaS)
| Plan | Price/month | Members | Key Features |
|------|-------------|---------|-------------|
| **Starter** | Free | Up to 30 | Distribution log, basic member management |
| **Basic** | €29/month | Up to 100 | + Compliance reports, stock management |
| **Professional** | €79/month | Up to 500 | + Member portal, batch tracking, exports |
| **Enterprise** | €179/month | Unlimited | + API, multi-location, priority support |
**Rationale:**
- Free tier creates word-of-mouth in the club community
- Professional is the sweet spot for a typical club (100-300 members)
- Freemium-to-paid conversion pressure: "your club hit 30 members, upgrade to continue"
### 4.2 Revenue Projections
| Scenario | Paying Clubs | Average Plan | MRR | ARR |
|----------|-------------|-------------|-----|-----|
| Bootstrap (Year 1) | 30 | €49 | €1,470 | €17,640 |
| Growth (Year 2) | 150 | €65 | €9,750 | €117,000 |
| Scale (Year 3) | 500 | €79 | €39,500 | €474,000 |
**Year 1 is realistic as a side hustle while working at ADP.**
### 4.3 Additional Revenue Streams
- **Setup fee:** Optional one-time €99299 onboarding fee for Professional/Enterprise
- **Legal templates:** Sell standardised Satzung, Jugendschutzkonzept templates (€49 one-time)
- **Training:** Webinars for Präventionsbeauftragter (€149/person) — high-value, low-effort
- **Affiliate/referral:** Partner with lawyers who advise clubs (they refer clients, we pay commission)
---
## 5. Tech Stack
### 5.1 Skills Assessment — ⚠️ CORRECTED (Java is Patrick's primary language)
> **Important correction:** The initial plan had this backwards. Python is *Lumen's* language, used for MCP servers. Patrick's real expertise is **Java** — JPA/EclipseLink, JAXB, PrimeFaces, Maven, Jakarta EE. He built the entire wellmann-shop without AI, and wrote a custom JPA-annotation-style flatfile parser for euBP/DSAK. The stack below is redesigned around Java as the primary language.
| Technology | Patrick's Level | Required? |
|-----------|----------------|-----------|
| Java (Spring Boot / Quarkus) | ✅ **Expert** | Yes — backend |
| JPA / EclipseLink | ✅ **Expert** | Yes — ORM layer |
| JAXB | ✅ Expert | Yes — report generation |
| PrimeFaces / JSF | ✅ Expert | Optional — one frontend path |
| Maven | ✅ Expert | Yes — build tool |
| PostgreSQL | ✅ Good | Yes — database |
| Docker | ✅ Comfortable | Yes — deployment |
| Spring Security / JWT | 🟡 Familiar | Yes — auth |
| Kotlin (Android / KMP) | 🟡 **Natural transition** — same JVM, IntelliJ | Yes — mobile v2/v3 |
| Compose Multiplatform | 🟡 New but Kotlin-based | Yes — cross-platform UI v3 |
| Vaadin Flow (Java UI) | 🟡 New, Java-native | Alternative fast frontend |
| React / Next.js | ❌ Needs learning | Best long-term web frontend |
| Stripe Java SDK | 🟡 New (REST, documented) | Yes — billing |
| German DSGVO practical | ⚠️ Basic | Critical — legal |
### 5.2 Frontend Choice — The Real Decision
With Java as the primary language, three paths exist:
**Option A: Vaadin Flow — Full Java, zero JavaScript (fastest start)**
- Write UI in pure Java — no HTML/CSS/JS required
- Deeply integrated with Spring Boot, component-based
- Patrick can start immediately with zero new language learning
- Downside: Vaadin commercial license for some features; UI looks enterprise-y
**Option B: PrimeFaces + JSF — Patrick already knows this cold**
- Built wellmann-shop entirely from scratch with PrimeFaces
- Runs on Quarkus, WildFly, or Payara
- Zero learning curve — known patterns, fast to ship
- Downside: JSF is considered legacy by the wider web community; not ideal for modern SaaS polish
**Option C: Spring Boot backend + Next.js/React frontend (Best long-term)**
- Java stays the backend — Patrick's full existing strength
- React/Next.js frontend — one-time learning investment
- Standard modern SaaS architecture (2024+); best hiring/community ecosystem
- Downside: React/Next.js learning curve (~4-6 weeks)
**Recommendation:** Start with **Option B (PrimeFaces)** to ship an MVP fast with zero learning overhead. Migrate the frontend to **Option C (Next.js)** in Version 2 when revenue justifies the investment. This is pragmatic — ship first, polish later.
### 5.3 Recommended Stack
```
Frontend: PrimeFaces + JSF (MVP) → Next.js/React (v2+)
Backend: Spring Boot 3.x (Java 21) — REST API + JPA/Hibernate
ORM: JPA/Hibernate (Patrick's core expertise)
Database: PostgreSQL + Flyway migrations
Auth: Spring Security + JWT (stateless sessions)
Payments: Stripe Java SDK (subscriptions, webhooks)
PDF Reports: iText 7 or Apache PDFBox (Java, battle-tested)
Email: Jakarta Mail / Resend.com REST API
Hosting: Hetzner Cloud VPS (German DC, GDPR, €5-20/month)
— TrueNAS.local Docker for dev/staging
CI/CD: Gitea Actions → Hetzner (Maven build pipeline)
Monitoring: Sentry Java SDK (free tier)
```
**Why this stack:**
- Spring Boot + JPA = Patrick's natural habitat — fastest possible iteration on the backend
- PrimeFaces MVP = zero new tools, ship in weeks not months
- PostgreSQL + Flyway = production-grade, schema migrations Patrick knows from JPA patterns
- Hetzner = German hosting, cheap, GDPR-compliant by design
- Stripe Java SDK = mature, handles EU VAT + subscription billing
- iText/PDFBox = Java-native PDF generation for compliance reports (no Python dependency)
### 5.4 Architecture Overview
```
┌─────────────────────────────────────────────────────────┐
│ CannaManage Platform │
│ │
│ ┌─────────────────┐ ┌────────────────────────────┐ │
│ │ Admin Portal │ │ Member Portal │ │
│ │ PrimeFaces/JSF │ │ PrimeFaces/JSF (MVP) │ │
│ │ Next.js (v2+) │ │ Next.js/React (v2+) │ │
│ │ - Club setup │ │ - Login (club-issued) │ │
│ │ - Member mgmt │ │ - Stock view │ │
│ │ - Distribution │ │ - My quota / history │ │
│ │ - Compliance │ │ - Request pickup │ │
│ └────────┬────────┘ └──────────┬─────────────────┘ │
│ │ │ │
│ └───────────┬────────────┘ │
│ ↓ │
│ ┌───────────────────────────────────────┐ │
│ │ Spring Boot 3.x Backend (Java 21) │ │
│ │ - REST API (Spring MVC) │ │
│ │ - JPA/Hibernate entities │ │
│ │ - Business logic + compliance rules │ │
│ │ - PDF report generation (iText 7) │ │
│ │ - Spring Security + JWT │ │
│ └──────────────────┬────────────────────┘ │
│ ↓ │
│ ┌─────────────────────┐ │
│ │ PostgreSQL │ │
│ │ - Multi-tenant │ │
│ │ (tenant_id on all │ │
│ │ JPA entities) │ │
│ │ - Flyway migrations │ │
│ └─────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Stripe Java SDK │ Email (Jakarta Mail) │ │
│ │ (subscription billing) │ (notifications) │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
### 5.5 New Skills Needed — Revised Learning Path
| Skill | Priority | Patrick's Starting Point | Resource |
|-------|----------|--------------------------|----------|
| Spring Boot 3.x REST | 🟡 Medium | Knows Jakarta EE — similar model | spring.io/guides |
| Spring Security + JWT | 🟡 Medium | Security concepts from JEE | Baeldung tutorials |
| Flyway migrations | 🟡 Medium | Knows JPA schema generation | flyway.io/docs |
| Stripe Java SDK | 🟡 High | Knows REST from Java | stripe.com/docs/billing |
| Next.js / React | 🔴 For v2+ | Zero JS framework experience | nextjs.org/learn (free) |
| Docker + Compose | 🟡 Medium | Comfortable with Docker basics | Hetzner deploy guides |
| German DSGVO practical | 🔴 Critical | Basic awareness | Legal counsel + AVV templates |
**Pragmatic MVP path:** Use PrimeFaces (Patrick knows it cold) → ship MVP → earn first revenue → invest time in Next.js for v2.
---
## 6. Go-To-Market Strategy
### 6.1 Phase 0 — Build & Validate (Private Beta)
**Goal:** Working MVP, 3-5 beta clubs, collect real feedback
**Actions:**
- Join German cannabis clubs online community (Telegram groups, Reddit r/cannabisde)
- Find 3-5 club admins willing to test for free
- Build MVP focused on distribution tracking + compliance reports (the biggest pain)
- Do NOT launch publicly until legally reviewed
**Where to find early adopters:**
- Hanfverband Deutschland (German Hemp Association) — they represent clubs
- Online forums: Rollitup.de German section, GreenPassion.de
- Local cannabis clubs in your area
- LinkedIn outreach to CSC founders
### 6.2 Phase 1 — Soft Launch (€0 → First €1K MRR)
**Target:** 30+ paying clubs, Basic plan minimum
**Channels (all B2B, no cannabis advertising):**
- Word of mouth between club admins (community is small and tight-knit)
- Content marketing: blog posts about "how to manage CanG compliance" (targets club admins searching for help)
- Partner with lawyers advising clubs (they refer clients)
- Hanfverband newsletter mention (not advertising — editorial content about compliance tools)
- LinkedIn / XING posts targeted to "Vereinsvorstand" / "Vereinsgründer" keywords
### 6.3 Phase 2 — Growth (€1K → €10K MRR)
- Referral program (clubs refer other clubs for free months)
- German startup press (Gründerszene, t3n)
- Templates marketplace (Satzungen, Jugendschutzkonzepte)
- Webinar series for Präventionsbeauftragte
---
## 7. Business Structure & Risk
### 7.1 Legal Entity
**Recommendation:** Register as a **Gewerbetreibender / Einzelunternehmen** first (simplest), then transition to **GmbH** when revenue exceeds €50K/year.
- No special license needed to sell software to cannabis clubs
- You are NOT a cannabis business — you sell management software
- Standard software VAT applies (19% German USt)
### 7.2 Banking & Payments
- **DO NOT** describe your business as "cannabis software" to banks
- Describe it as: "Vereinsverwaltungs-Software" (club management software)
- Stripe works fine for compliance software — they block cannabis sales, not software for cannabis-adjacent industries
- Open a separate business account early (Kontist, Finom, or Deutsche Bank business)
### 7.3 Exit Scenarios
| Scenario | When | Valuation Range |
|----------|------|----------------|
| Keep as passive income | Year 2+ at €5K MRR | N/A |
| Sell to larger SaaS player | Year 3+ at €20K MRR | 3-5× ARR (~€720K-1.2M) |
| Raise seed funding | Year 2 with 200+ clubs | €500K-€2M round |
| Pivot to EU expansion | Year 3 | Same platform, localised |
---
## 8. Development Roadmap
### Phase 0 — Foundation (Weeks 1-8, solo)
- [ ] Set up Spring Boot 3.x project (Maven, JPA/Hibernate, PostgreSQL, Flyway)
- [ ] Design JPA entities: Club, Member, Distribution, Strain, Batch (multi-tenant via tenant_id)
- [ ] Build core REST API (member CRUD, distribution log)
- [ ] Build admin portal with PrimeFaces (Patrick already knows this)
- [ ] Distribution limit enforcement logic (25g/day, 50g/month, 30g/month under-21)
- [ ] Simple PDF compliance report export (iText 7)
- [ ] Spring Security + JWT auth (club admin login)
- [ ] Deploy to Hetzner VPS (Docker Compose)
### Phase 1 — MVP (Weeks 9-16)
- [ ] Member portal (PrimeFaces, login with club-issued creds, quota view, stock view)
- [ ] Stock management module (strains, batches, quantities)
- [ ] Contamination batch recall feature
- [ ] Stripe Java SDK integration (subscription billing)
- [ ] DSGVO: privacy policy, data processing agreement (AVV), cookie consent
- [ ] Beta launch with 5 clubs (free, feedback-only)
### Phase 2 — Launch (Months 5-8)
- [ ] Payment flows live (Stripe webhooks, subscription lifecycle)
- [ ] Email notification system (Jakarta Mail / Resend API)
- [ ] Marketing site (cannamanage.de — example name, separate Next.js landing page)
- [ ] Legal review of terms, privacy, advertising compliance
- [ ] Formal soft launch to club community
- [ ] First paying customers
### Phase 3 — Growth (Months 9-18)
- [ ] Frontend migration: PrimeFaces → Next.js/React (when revenue justifies it)
- [ ] Mobile-optimised (PWA)
- [ ] Legal template marketplace (Satzungen, Jugendschutzkonzepte)
- [ ] Referral program
- [ ] Webinar series for Präventionsbeauftragte
- [ ] Hire first part-time support person
---
## 9. Honest Assessment — Strengths & Weaknesses
### Strengths ✅
- **First mover advantage** — nobody is doing this well yet
- **Regulatory moat** — the compliance burden creates permanent demand
- **B2B SaaS** — predictable recurring revenue
- **Patrick's Java expertise** — Spring Boot + JPA = fastest possible backend iteration (this is his daily tool at ADP)
- **PrimeFaces knowledge** — built a full shop UI from scratch; zero learning curve for MVP frontend
- **Low competition** — niche market overlooked by big players
- **Low infra cost** — Hetzner VPS €5-20/month, manageable
### Weaknesses / Challenges ⚠️
- **Modern frontend gap** — Next.js/React must eventually be learned for v2 polish (deferred, not blocking)
- **Market is young** — clubs are still forming, slow regulatory licensing in some Länder
- **Political risk** — new German government could tighten the law
- **Churn risk** — if a club closes, subscription ends immediately
- **Payment friction** — some processors are cannabis-adjacent-averse (mitigated by correct positioning)
- **Two-sided attention** — building while working full-time at ADP is slow (nights/weekends)
- **Spring Boot learning curve** — Patrick knows Jakarta EE / JEE; Spring Boot 3.x is adjacent but not identical
### The Honest Path
This is a **18-24 month project** to meaningful passive income:
- Months 1-3: Spring Boot setup + PrimeFaces MVP (using existing Java knowledge — fast!)
- Months 4-6: Beta with 5 clubs, Stripe integration, DSGVO compliance
- Months 7-12: Paid launch, first 30-50 paying clubs
- Year 2+: €5-10K MRR is realistic, genuine passive with <10h/week
---
## 10. Immediate Next Steps
1. **Join 2-3 German cannabis club communities** (Telegram, Reddit) — listen, don't sell yet
2. **Start Next.js tutorial** (nextjs.org/learn) — 1 hour/day, 4 weeks
3. **Create a Supabase project** — explore multi-tenancy with Row Level Security
4. **Set up the project repo** (pi_mcps/cannamanage or separate Gitea repo)
5. **Talk to 3 club admins** — validate the pain before writing a line of code
6. **Get a legal opinion** (€300-500 from a cannabis law specialist — worth it before launch)
---
## Appendix: Key CanG References
| Provision | Content |
|-----------|---------|
| §2 CanG | Definitions — Anbauvereinigung, Mitglied |
| §§15-26 CanG | Anbauvereinigungen — formation, rights, obligations |
| §22 CanG | Distribution limits (25g/day, 50g/month) |
| §23 CanG | Under-21 restrictions (30g/month, 10% THC) |
| §§6-7 CanG | Advertising and sponsoring ban |
| §26 CanG | Documentation and reporting obligations |
| §27 CanG | Prevention officer requirements |
---
*Plan created: 2026-04-04 | Next review: 2026-05-01 | Status: Awaiting Patrick's approval*