Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a275a18e58 | |||
| 20228f8d46 | |||
| 3b1d5bf35c | |||
| e12479a63a | |||
| 64c0a62b49 | |||
| f24aafec69 | |||
| 4165018ab2 | |||
| 2f01ff0639 | |||
| 7a21b02081 | |||
| 1340d3098f | |||
| 8cbeb6571b | |||
| b0ce5c55ed | |||
| ef960a4b59 | |||
| 93b250c7a1 | |||
| 0a58541f1e | |||
| b30919cabb | |||
| 8112ff2f12 | |||
| ba7d4bc248 | |||
| 29d6463f7c | |||
| 768201909a | |||
| 06dba9a4ad | |||
| 21956f7a42 | |||
| 8729f541c0 | |||
| 5a96359bb1 | |||
| 87e0b9359e |
@@ -8,7 +8,13 @@
|
||||
"/home/pplate/pi_mcps/"
|
||||
],
|
||||
"alwaysAllow": [
|
||||
"*"
|
||||
"git_status",
|
||||
"git_diff_unstaged",
|
||||
"git_log",
|
||||
"git_add",
|
||||
"git_commit",
|
||||
"git_branch",
|
||||
"git_create_branch"
|
||||
]
|
||||
},
|
||||
"filesystem": {
|
||||
@@ -30,6 +36,53 @@
|
||||
"alwaysAllow": [
|
||||
"webscraper_fetch"
|
||||
]
|
||||
},
|
||||
"gitea": {
|
||||
"command": "/home/pplate/.local/bin/forgejo-mcp",
|
||||
"args": [
|
||||
"stdio",
|
||||
"--server",
|
||||
"http://192.168.188.119:30008",
|
||||
"--token",
|
||||
"8bf0c734ebda3e61d9c9068489ce58a2bf8d33db"
|
||||
],
|
||||
"alwaysAllow": [
|
||||
"create_issue",
|
||||
"list_repo_issues",
|
||||
"get_issue",
|
||||
"edit_issue",
|
||||
"create_issue_comment",
|
||||
"create_pull_request",
|
||||
"get_repository",
|
||||
"list_my_repositories"
|
||||
]
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest"
|
||||
],
|
||||
"alwaysAllow": [
|
||||
"browser_navigate",
|
||||
"browser_click",
|
||||
"browser_fill",
|
||||
"browser_screenshot",
|
||||
"browser_close",
|
||||
"browser_new_context"
|
||||
]
|
||||
},
|
||||
"mcp-image-gen": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory",
|
||||
"/home/pplate/pi_mcps/mcp/mcp-image-gen",
|
||||
"run",
|
||||
"src/server.py"
|
||||
],
|
||||
"env": {
|
||||
"COMFYUI_URL": "http://localhost:8188",
|
||||
"IMAGE_OUTPUT_DIR": "/home/pplate/Pictures/mcp-generated"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
# Architect Mode Behavior — Roo Code
|
||||
|
||||
## Persona Context — Which Patrick is planning?
|
||||
|
||||
Before starting, identify the active context from the conversation:
|
||||
- **Homelab Patrick** → plan for TrueNAS Docker services, local hardware constraints, Gitea as source of truth
|
||||
- **ADP/Paisy Patrick** → plan with compliance mindset, assessment-first, German ticket language, PR-only workflow
|
||||
- **MCP Builder Patrick** → plan for FastMCP conventions, pi_mcps structure, pytest coverage expectations
|
||||
- **BigMind Patrick** → plan with DB migration safety, test-first, Flask/SQLite constraints in mind
|
||||
|
||||
Adapt planning depth and output format to match the active persona.
|
||||
|
||||
## Planning Process
|
||||
1. **Search Context:** `memory_search_facts("similar project")` + `memory_list_sessions(topics_filter="architecture")`
|
||||
2. **Form Hypothesis:** `memory_add_hypothesis(session_id, "This architecture will scale to X users with confidence 0.7")`
|
||||
@@ -10,8 +20,9 @@
|
||||
- **Break Down:** Large tasks → subtasks with MCP servers (Docker, Gitea, Ollama)
|
||||
- **Homelab Focus:** Leverage TrueNAS Docker for services, 1.2TB SSD for VMs/DBs
|
||||
- **Token Efficiency:** Reference past architectures from memory, log savings
|
||||
- **Assessment First:** For any Paisy/ADP task, always produce an assessment markdown before proposing code
|
||||
|
||||
## After Planning
|
||||
1. **Store Decision:** `memory_store_fact("decision", "Chose Z architecture for reasons A B C")`
|
||||
1. **Store Decision:** `memory_store_fact("architecture-decision", "Chose Z architecture for reasons A B C")`
|
||||
2. **Flag Plan:** `memory_flag_important(session_id, "Architecture plan for Y", role="assistant")`
|
||||
3. **Resolve Hypothesis:** Update based on plan validation
|
||||
@@ -1,5 +1,13 @@
|
||||
# Ask Mode Behavior — Roo Code
|
||||
|
||||
## Persona Context — Which Patrick is asking?
|
||||
|
||||
The answer style and knowledge focus should match the active context:
|
||||
- **Homelab Patrick** → ground answers in real hardware facts (TrueNAS IP, Docker stack, local storage)
|
||||
- **ADP/Paisy Patrick** → prefer Java/Maven/Oracle/EclipseLink answers; reference compliance constraints
|
||||
- **MCP Builder Patrick** → lean on FastMCP patterns, pi_mcps conventions, and existing server examples
|
||||
- **BigMind Patrick** → reference schema version (v7), phase (2.7), and current tool count before speculating
|
||||
|
||||
## Before Answering Any Question
|
||||
1. **Search Memory:** `memory_search_facts("topic keywords")` + `memory_search_chunks("past discussion")`
|
||||
2. **Check Hypotheses:** If the question touches an open hypothesis, reference it — confirm or update confidence
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# BigMind Mode Behavior — Roo Code
|
||||
|
||||
## Active Persona: Introspective Patrick
|
||||
|
||||
Patrick is working on BigMind itself — the memory system that is Lumen's superpower. This is the most careful mode. Breaking BigMind means breaking the brain that makes everything else work.
|
||||
|
||||
## BigMind System State (always active in this mode)
|
||||
|
||||
| Aspect | Current State |
|
||||
|--------|--------------|
|
||||
| DB Location | `~/.mcp/bigmind/memory.db` |
|
||||
| Schema Version | v7 (People/Contacts directory added) |
|
||||
| Journal Mode | WAL (multi-IDE safe, 30s write timeout) |
|
||||
| Tool Count | ~25 tools |
|
||||
| Test Count | ~282+ tests |
|
||||
| Flask Port | 7700 (profile page, auto-refreshes 30s) |
|
||||
| Current Phase | 2.7 (profile features). Phase 3 = Company Brain |
|
||||
|
||||
## Memory Tier Architecture
|
||||
- **Tier 0:** Identity profile (who Lumen is)
|
||||
- **Tier 1:** Session index (recent session headlines)
|
||||
- **Tier 2:** Session narratives (detailed summaries)
|
||||
- **Tier 3:** Conversation chunks (flagged important exchanges)
|
||||
- **Facts:** Atomic reusable facts with FTS5 search
|
||||
- **Hypotheses:** Thought journal (open/confirmed/refuted/abandoned)
|
||||
- **People:** Contacts directory (v7 addition)
|
||||
|
||||
## 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. **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)`
|
||||
- Increment `SCHEMA_VERSION` constant in `db.py`
|
||||
- `init_db()` must call migrations in sequence
|
||||
- Test the migration against a populated DB (not just fresh)
|
||||
- Never drop columns or rename them without a deprecation strategy
|
||||
|
||||
## API Contract Rules
|
||||
- Never remove a tool from `server.py` — it breaks connected IDEs
|
||||
- Never change a tool's parameter names — use optional params with defaults for new fields
|
||||
- Server restart (`memory_restart_server`) is safe but loses in-memory state — ensure sessions are closed first
|
||||
|
||||
## Code Architecture
|
||||
```
|
||||
~/.mcp/bigmind/
|
||||
bigmind/
|
||||
db.py ← schema, init_db(), migrations
|
||||
memory_store.py ← all CRUD functions
|
||||
context_builder.py ← Tier 0+1 context assembly
|
||||
profile_builder.py ← stats, achievements, heatmap
|
||||
web.py ← Flask server (daemon thread)
|
||||
web_render.py ← HTML rendering (split from web.py)
|
||||
auto_close.py ← orphan session cleanup, server restart
|
||||
server.py ← FastMCP tools (thin wrappers over memory_store)
|
||||
tests/ ← pytest suite (282+ tests)
|
||||
pyproject.toml
|
||||
```
|
||||
|
||||
## Testing Rules
|
||||
- Full test suite must pass before any PR/commit: `uv run pytest tests/ -v`
|
||||
- New features: write tests first
|
||||
- New migrations: test both fresh DB and populated DB paths
|
||||
- FTS5 queries: test AND-match, reserved words, multi-word queries
|
||||
|
||||
## Flask Web Server
|
||||
- Runs as daemon thread inside MCP process on startup
|
||||
- Port: `BIGMIND_PORT` env var (default 7700)
|
||||
- Auto-open: `BIGMIND_AUTOOPEN=true`
|
||||
- Profile page at `http://localhost:7700` — Lumen's own identity page
|
||||
|
||||
## After BigMind Changes
|
||||
1. **Store Fact:** `memory_store_fact("codebase", "BigMind v{schema}: added X feature — Y new tools, Z tests")`
|
||||
2. **Bump schema version** in stored fact if applicable
|
||||
3. **Flag the Session:** `memory_flag_important(session_id, "BigMind feature: X shipped", role="assistant")`
|
||||
4. **Resolve Hypothesis:** Was the migration approach correct?
|
||||
5. **Restart if needed:** `memory_restart_server()` — only after closing the current session
|
||||
@@ -1,5 +1,16 @@
|
||||
# Code Mode Behavior — Roo Code
|
||||
|
||||
## Persona Context — Which Patrick is coding?
|
||||
|
||||
Before writing code, identify the active context to apply the right conventions:
|
||||
|
||||
| Persona | Language | Conventions |
|
||||
|---------|----------|-------------|
|
||||
| Homelab Patrick | Python / bash / YAML | Docker Compose, TrueNAS compatible, uv + FastMCP |
|
||||
| ADP/Paisy Patrick | Java / Maven | feature/bugfix branches only, no direct push to main, assessment-first |
|
||||
| MCP Builder Patrick | Python | FastMCP pattern, pi_mcps structure, 100% mock test coverage |
|
||||
| BigMind Patrick | Python / SQL | schema migration safety, WAL mode, no breaking API changes |
|
||||
|
||||
## Before Writing Code
|
||||
1. **Search Memory:** `memory_search_facts("codebase [project]")` + `memory_search_chunks("similar code")`
|
||||
2. **Form Hypothesis:** `memory_add_hypothesis(session_id, "I predict X will fix Y with confidence 0.8")`
|
||||
@@ -7,7 +18,7 @@
|
||||
|
||||
## Coding Patterns
|
||||
- **Python:** Use uv for dependencies, FastMCP for MCP servers, pytest for tests
|
||||
- **Java:** Maven for Paisy projects, Spring Boot patterns
|
||||
- **Java:** Maven for Paisy projects, feature/bugfix branch required, never push to main
|
||||
- **Testing:** Always write tests first, mock external calls
|
||||
- **Token Efficiency:** Use `memory_log_token_save` when reusing code from memory
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
# Debug Mode Behavior — Roo Code
|
||||
|
||||
## Persona Context — Which Patrick is debugging?
|
||||
|
||||
Match the debugging approach to the active context:
|
||||
- **Homelab Patrick** → check Docker logs, TrueNAS container state, Fedora system logs first
|
||||
- **ADP/Paisy Patrick** → search BigMind for known bug patterns (ORA-00001, NPE, EclipseLink flush issues) before exploring
|
||||
- **MCP Builder Patrick** → check FastMCP server logs, uv environment, MCP tool registration issues
|
||||
- **BigMind Patrick** → WAL mode, concurrent IDE access, SQLite locking, FTS5 query issues
|
||||
|
||||
## Debugging Process
|
||||
1. **Search Similar Issues:** `memory_search_chunks("similar bug")` + `memory_search_facts("codebase error")`
|
||||
1. **Search Similar Issues:** `memory_search_chunks("similar bug")` + `memory_search_facts("bug-pattern [domain]")`
|
||||
2. **Form Hypothesis:** `memory_add_hypothesis(session_id, "Bug is in X due to Y, confidence 0.6")`
|
||||
3. **Announce Focus:** `memory_announce_focus(session_id, "Debugging Z in file.py", files=["file.py"], ide_hint="VS Code")`
|
||||
4. **Systematic Steps:** Add logging, analyze stack traces, test incrementally
|
||||
@@ -10,8 +18,13 @@
|
||||
- **MCP Leverage:** Use mcp-homelab-shell for quick tests, mcp-homelab-docker for container logs
|
||||
- **Homelab:** Check Ollama models, TrueNAS VMs if relevant
|
||||
- **Token Efficiency:** Search memory for past fixes before reading full logs
|
||||
- **Bug Patterns to check first:**
|
||||
- ORA-00001 → duplicate hash constraint violations (ADVFEX migration pattern)
|
||||
- NPE in Paisy → null getSendungsHeader() before null-check
|
||||
- SSL errors → Fedora missing Comodo AAA root cert (see stored certs fix)
|
||||
- FTS5 errors → reserved word collision in search query (wrap tokens in quotes)
|
||||
|
||||
## After Resolution
|
||||
1. **Store Fix:** `memory_store_fact("codebase", "Fixed bug in X by doing Y")`
|
||||
1. **Store Fix:** `memory_store_fact("bug-pattern", "Fixed bug in X by doing Y — root cause was Z")`
|
||||
2. **Resolve Hypothesis:** `memory_resolve_hypothesis(hypothesis_id, "confirmed", "Root cause was Z")`
|
||||
3. **Flag Resolution:** `memory_flag_important(session_id, "Debug resolution summary", role="assistant")`
|
||||
@@ -0,0 +1,44 @@
|
||||
# Homelab Mode Behavior — Roo Code
|
||||
|
||||
## Active Persona: Tinkerer Patrick
|
||||
|
||||
Patrick is in homelab mindset. He is experimenting, building, and maintaining his personal infrastructure ecosystem. No corporate constraints — full admin rights on everything.
|
||||
|
||||
## Infrastructure Context (always active in this mode)
|
||||
|
||||
| Asset | Details |
|
||||
|-------|---------|
|
||||
| Workstation | Fedora Linux, Ryzen 5900X, RX 7900 XTX (24GB VRAM), 8TB NVMe |
|
||||
| Server | TrueNAS.local @ 192.168.188.119, Ryzen 5900X, Docker, 1.2TB SSD pool |
|
||||
| Gitea | http://192.168.188.119:30008/ — homelab git server, source of truth |
|
||||
| AI | Ollama (local models), Grok Code (prepaid), Claude Code ($50 prepaid) |
|
||||
| MCP base | ~/pi_mcps/ — all MCP servers live here |
|
||||
|
||||
## 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. **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
|
||||
- **CLI-first:** Prefer terminal solutions over GUI navigation
|
||||
- **Paths:** Everything on TrueNAS lives under /mnt/ (ZFS datasets). Workstation workspace: ~/pi_mcps/
|
||||
- **Networking:** Direct LAN access — no VPN/firewall between workstation and TrueNAS.local
|
||||
- **Local AI:** When using Ollama, check VRAM headroom (RX 7900 XTX = 24GB). ROCm stack on Fedora
|
||||
- **uv for Python:** Never use pip directly on the workstation
|
||||
|
||||
## Gitea Workflow
|
||||
- Push all homelab repos to Gitea first
|
||||
- PAT stored in BigMind (fact id 93) — never commit tokens to code
|
||||
- Conventional commit format: `type(scope): description`
|
||||
|
||||
## After Homelab Changes
|
||||
1. **Store Infrastructure Fact:** `memory_store_fact("environment-config", "Service X running on TrueNAS at port Y with config Z")`
|
||||
2. **Commit to Gitea:** Use conventional commits, push to homelab Gitea
|
||||
3. **Resolve Hypothesis:** Update based on what actually worked
|
||||
@@ -0,0 +1,85 @@
|
||||
# MCP Builder Mode Behavior — Roo Code
|
||||
|
||||
## Active Persona: Craftsman Patrick
|
||||
|
||||
Patrick is in MCP Builder mindset. He is building or extending MCP servers in the pi_mcps monorepo. Consistency and testability are the highest values — every server should feel like it belongs in the same ecosystem.
|
||||
|
||||
## pi_mcps Structure (always active in this mode)
|
||||
|
||||
```
|
||||
~/pi_mcps/
|
||||
mcp/
|
||||
{server-name}/ ← one dir per server
|
||||
src/
|
||||
server.py ← FastMCP server (single file)
|
||||
__init__.py
|
||||
tests/
|
||||
conftest.py ← sys.path + shared fixtures
|
||||
test_server.py ← 100% mock coverage
|
||||
pyproject.toml ← name=mcp-{name}, uv-managed
|
||||
README.md
|
||||
java/ ← Java projects (not MCP servers)
|
||||
plans/ ← architecture plans
|
||||
```
|
||||
|
||||
## FastMCP Pattern (non-negotiable)
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("server-name")
|
||||
|
||||
@mcp.tool()
|
||||
def tool_name(param: str) -> str:
|
||||
"""Tool description."""
|
||||
...
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
```
|
||||
|
||||
## 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. **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
|
||||
2. `mcp/{name}/src/__init__.py` — empty
|
||||
3. `mcp/{name}/src/server.py` — FastMCP server with all tools
|
||||
4. `mcp/{name}/tests/conftest.py` — sys.path + fixtures
|
||||
5. `mcp/{name}/tests/test_server.py` — full mock coverage
|
||||
6. `mcp/{name}/pyproject.toml` — uv + fastmcp + deps
|
||||
7. `mcp/{name}/README.md` — usage, env vars, tool list
|
||||
|
||||
## pyproject.toml Conventions
|
||||
```toml
|
||||
[project]
|
||||
name = "mcp-{name}"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["fastmcp>=0.1.0", ...]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = ["pytest", "pytest-mock", "pytest-cov"]
|
||||
```
|
||||
|
||||
## Test Conventions
|
||||
- Mock ALL external calls (HTTP, filesystem, subprocess)
|
||||
- Use `monkeypatch` for env vars and module-level state
|
||||
- `conftest.py`: `sys.path.insert(0, str(Path(__file__).parent.parent / "src"))`
|
||||
- Aim for 100% tool function coverage
|
||||
- Run: `uv run pytest tests/ -v`
|
||||
|
||||
## After Building a Server
|
||||
1. **Store Fact:** `memory_store_fact("codebase", "mcp/{name} has N tools: [list]. Stack: X. Env vars: Y.")`
|
||||
2. **Wire into .roo/mcp.json:** Add the server entry with correct uv path
|
||||
3. **Update root README.md:** Add to MCPs table
|
||||
4. **Push to Gitea:** Conventional commit: `feat(mcp-{name}): add initial server with N tools`
|
||||
5. **Resolve Hypothesis:** Was the tool count and auth pattern as predicted?
|
||||
@@ -1,5 +1,15 @@
|
||||
# Orchestrator Mode Behavior — Roo Code
|
||||
|
||||
## Persona Context — Which Patrick is orchestrating?
|
||||
|
||||
Match the delegation strategy to the active context:
|
||||
- **Homelab Patrick** → delegate to homelab mode for infra tasks, mcp-builder for tool creation
|
||||
- **ADP/Paisy Patrick** → delegate to paisy mode for Java work, architect for assessment, ask for lookups
|
||||
- **MCP Builder Patrick** → delegate to mcp-builder mode, code mode for tests, architect for PLAN.md
|
||||
- **BigMind Patrick** → delegate to bigmind mode, debug mode for schema issues, architect for feature design
|
||||
|
||||
When delegating, always pass the active persona context to sub-modes so they apply the right conventions.
|
||||
|
||||
## Before Breaking Down a Task
|
||||
1. **Search Memory:** `memory_search_facts("project domain")` + `memory_search_chunks("similar task")`
|
||||
2. **Form Top-Level Hypothesis:** `memory_add_hypothesis(session_id, "I predict this task will require X subtasks and the main risk is Y", confidence=0.7)`
|
||||
@@ -14,6 +24,7 @@
|
||||
## Delegating Subtasks
|
||||
- Pass enough BigMind context to sub-modes so they don't repeat searches
|
||||
- Specify `session_id` and relevant stored facts in the delegation message
|
||||
- Specify the active Patrick persona so the sub-mode applies the right conventions
|
||||
- Each delegated mode must still call `memory_announce_focus` for the files it will touch
|
||||
|
||||
## After Full Task Completion
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Paisy/ADP Mode Behavior — Roo Code
|
||||
|
||||
## Active Persona: Professional Patrick
|
||||
|
||||
Patrick is in ADP/Paisy mindset. He is working on payroll and HR compliance systems for ADP Germany. This work has real-world legal/financial consequences — precision matters.
|
||||
|
||||
## Domain Context (always active in this mode)
|
||||
|
||||
| Domain | Details |
|
||||
|--------|---------|
|
||||
| Repo | Paisy monorepo — Java/Maven |
|
||||
| Branch rule | feature/bugfix branches ONLY — never push to main/current |
|
||||
| Language | Jira summaries, descriptions, comments → German; code/class names → as-is |
|
||||
| DB | Oracle (production), H2 (test/C/S legacy). ORA-00001 is a real risk post-migration |
|
||||
| Modules | cs-modules, java/modules, eau (EAU), eubp (euBP), fex (FEX) |
|
||||
| Jira | Project: ESIDEPAISY. Terminal status: "Accepted" (not "Done"/"Closed") |
|
||||
|
||||
## Mandatory Jira Custom Fields
|
||||
Every ticket must include:
|
||||
- `customfield_10001` → Feature Link (current MEMO Feature: ESIDEPAISY-9648)
|
||||
- `customfield_10501` → `{"value": "PAISY MEMO"}`
|
||||
- `customfield_12700` → fiscal quarter e.g. `{"value": "FY26 / 2"}`
|
||||
- Sprint via `customfield_10000` — set AFTER creation via update_ticket_fields
|
||||
|
||||
## Before Starting Any Paisy Task
|
||||
1. **Search Domain Facts:** `memory_search_facts("ESIDEPAISY [module]")` + `memory_search_chunks("Paisy assessment")`
|
||||
2. **Assessment First:** ALWAYS write an assessment.md document before any code changes
|
||||
- Document: requirements, root cause analysis, affected files, risks, alternatives
|
||||
3. **Announce Focus:** `memory_announce_focus(session_id, "Paisy: ESIDEPAISY-XXXXX", files=["Assessment.md"], ide_hint="VS Code")`
|
||||
4. **Create Branch:** `git checkout -b feature/ESIDEPAISY-XXXXX-short-description`
|
||||
|
||||
## Known Bug Patterns (check before exploring)
|
||||
- **ORA-00001:** Duplicate hash constraint violation — ADVFEX C/S→PA migration, duplicate Anträge
|
||||
- **NPE in EAU:** `getSendungsHeader()` null for Anträge never transmitted — always null-check
|
||||
- **EclipseLink batch flush:** Transaction enters broken state after constraint violation — wrap in try/catch, manage em lifecycle carefully
|
||||
- **euBP naming:** Old code uses English "RES" — correct is German descriptor via Verfahrensmerkmal
|
||||
|
||||
## Paisy Code Conventions
|
||||
- Package structure: `controller/`, `model/`, `service/`, `util/`
|
||||
- New utility classes go in `controller/util/` of the relevant module
|
||||
- Assessment docs go in `docs/` within the module, or `java/modules/.../docs/`
|
||||
- Tests are parameterized where possible (see BeitragssatzdateiParameterizedTest pattern)
|
||||
|
||||
## After Paisy Changes
|
||||
1. **Store Fix:** `memory_store_fact("bug-pattern", "Fixed ESIDEPAISY-XXXXX: root cause was X, fix was Y")`
|
||||
2. **Comment on Jira:** In German, reference the assessment, describe the change
|
||||
3. **Open PR:** Never merge directly — always PR with description
|
||||
4. **Resolve Hypothesis:** Document whether the fix was correct as predicted
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: bigmind-health
|
||||
description: Runs a BigMind health check, closes stale sessions, vacuums old data, and reports system status. Use this skill at the start of a BigMind development session or when the system feels sluggish or has orphaned sessions.
|
||||
---
|
||||
|
||||
# BigMind Health Check
|
||||
|
||||
## When to use
|
||||
- Start of a BigMind development session
|
||||
- Sessions appear orphaned or counts look wrong
|
||||
- DB feels slow or bloated
|
||||
- Monthly maintenance
|
||||
|
||||
## When NOT to use
|
||||
- Normal work sessions (health check is optional unless something seems wrong)
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Get system stats
|
||||
```
|
||||
memory_get_stats()
|
||||
```
|
||||
Check: session count, fact count, chunk count, DB size. Flag anything that looks anomalous.
|
||||
|
||||
### Step 2 — Run health check
|
||||
```
|
||||
memory_health_check(stale_days=30)
|
||||
```
|
||||
Returns: stale facts, orphaned sessions, schema version, test status.
|
||||
|
||||
### Step 3 — Close stale sessions
|
||||
```
|
||||
memory_close_stale_sessions(session_id="{current_session_id}")
|
||||
```
|
||||
Closes all sessions except the current one that have been inactive for >2 hours.
|
||||
|
||||
### Step 4 — Vacuum (if needed)
|
||||
Run if DB is large or chunks are old:
|
||||
```
|
||||
memory_vacuum(older_than_days=90)
|
||||
```
|
||||
Removes conversation chunks older than 90 days. Facts and session summaries are preserved.
|
||||
|
||||
### Step 5 — Review open hypotheses
|
||||
```
|
||||
memory_list_hypotheses(status="open")
|
||||
```
|
||||
For each open hypothesis:
|
||||
- Is it still relevant?
|
||||
- Can it be resolved now?
|
||||
- Is confidence still accurate?
|
||||
|
||||
Resolve stale ones:
|
||||
```
|
||||
memory_resolve_hypothesis(hypothesis_id="{id}", status="abandoned", resolution="No longer relevant — context changed.")
|
||||
```
|
||||
|
||||
### Step 6 — Report status
|
||||
Summarize findings:
|
||||
```
|
||||
memory_store_fact("environment-config", "BigMind health check {date}: {N} sessions, {N} facts, {N} chunks. Schema v{N}. All OK / Issues found: [list].")
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
- **DB locked:** Another IDE has BigMind open. Check `memory_get_active_sessions()` first
|
||||
- **Vacuum fails:** WAL checkpoint may be pending — try `PRAGMA wal_checkpoint(TRUNCATE)` via direct SQLite if needed
|
||||
- **Health check shows schema mismatch:** Run migrations manually via BigMind restart
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: bigmind-migration
|
||||
description: Scaffolds a new BigMind database schema migration (v_n to v_{n+1}), including the migration function, SCHEMA_VERSION bump, and test stubs. Use this skill when adding new tables or columns to the BigMind SQLite database.
|
||||
---
|
||||
|
||||
# BigMind Migration
|
||||
|
||||
## When to use
|
||||
- Adding a new table to BigMind
|
||||
- Adding columns to an existing table
|
||||
- Creating a new FTS5 virtual table
|
||||
|
||||
## When NOT to use
|
||||
- Non-schema changes (just code, no DB structure changes)
|
||||
- Dropping or renaming columns (requires extra deprecation care — discuss first)
|
||||
|
||||
## Inputs required
|
||||
- **Current schema version** — check `SCHEMA_VERSION` in `db.py`
|
||||
- **New version** — `current + 1`
|
||||
- **Changes** — what tables/columns are being added
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Read current schema
|
||||
```bash
|
||||
grep -n "SCHEMA_VERSION" ~/.mcp/bigmind/bigmind/db.py
|
||||
grep -n "_migrate_v" ~/.mcp/bigmind/bigmind/db.py
|
||||
```
|
||||
Know what version you're migrating FROM.
|
||||
|
||||
### Step 2 — Write migration function in `db.py`
|
||||
|
||||
Add after the last existing migration function:
|
||||
```python
|
||||
def _migrate_v{N}_to_v{N+1}(conn):
|
||||
"""Add {description of change}."""
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Example: new table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS {table_name} (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
-- other columns
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Example: FTS5 virtual table
|
||||
cursor.execute("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS {table_name}_fts
|
||||
USING fts5(content, tokenize='porter')
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
```
|
||||
|
||||
### Step 3 — Wire into `init_db()`
|
||||
|
||||
In the migration chain inside `init_db()`:
|
||||
```python
|
||||
SCHEMA_VERSION = {N+1} # bump this
|
||||
|
||||
# In the migration section:
|
||||
if current_version < {N+1}:
|
||||
_migrate_v{N}_to_v{N+1}(conn)
|
||||
current_version = {N+1}
|
||||
```
|
||||
|
||||
### Step 4 — Write tests
|
||||
|
||||
In `tests/test_memory_store.py` (or a new test file):
|
||||
```python
|
||||
class TestMigration_v{N}_to_v{N+1}:
|
||||
def test_fresh_db_has_new_table(self, tmp_path):
|
||||
db_path = tmp_path / "test.db"
|
||||
conn = get_connection(str(db_path))
|
||||
init_db(conn)
|
||||
# Assert new table exists
|
||||
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}'")
|
||||
assert cursor.fetchone() is not None
|
||||
|
||||
def test_existing_db_migrates_cleanly(self, tmp_path):
|
||||
# Create a v{N} db, then run init_db() and check migration ran
|
||||
...
|
||||
```
|
||||
|
||||
### Step 5 — Run full test suite
|
||||
```bash
|
||||
cd ~/.mcp/bigmind
|
||||
uv run pytest tests/ -v
|
||||
```
|
||||
All tests must pass.
|
||||
|
||||
### Step 6 — Store migration fact
|
||||
```
|
||||
memory_store_fact("codebase", "BigMind schema migrated v{N}→v{N+1}: added {description}. Migration function: _migrate_v{N}_to_v{N+1}.")
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
- **`IF NOT EXISTS` is your friend:** Always use it so the migration is idempotent
|
||||
- **FTS5 table ordering:** Create the base table before the FTS5 virtual table that references it
|
||||
- **Migration not running:** Check the `if current_version < X:` guard — verify `current_version` is read correctly from `PRAGMA user_version`
|
||||
- **Test DB state:** Use `tmp_path` fixture for isolated test databases — never test against the real `memory.db`
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: homelab-docker-deploy
|
||||
description: Scaffolds and deploys a new Docker service on TrueNAS.local homelab server. Use this skill when adding a new containerized service to the homelab — produces a docker-compose.yml, documents the service in BigMind, and verifies it is running.
|
||||
---
|
||||
|
||||
# Homelab Docker Deploy
|
||||
|
||||
## When to use
|
||||
- Adding a new Docker service to TrueNAS.local
|
||||
- Migrating an existing service to Docker Compose format
|
||||
- Recovering a stopped/broken service
|
||||
|
||||
## When NOT to use
|
||||
- Services that should live on the Fedora workstation (not TrueNAS)
|
||||
- ADP/Paisy or MCP server work
|
||||
|
||||
## Inputs required
|
||||
- **Service name** — e.g., `gitea`, `ollama`, `homelab-monitor`
|
||||
- **Image** — Docker Hub image and tag
|
||||
- **Port mapping** — host:container
|
||||
- **Volume paths** — TrueNAS dataset paths (e.g., `/mnt/tank/docker/gitea`)
|
||||
- **Environment variables** — any required config
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Check for existing service
|
||||
```bash
|
||||
ssh root@192.168.188.119 "docker ps -a | grep {service-name}"
|
||||
```
|
||||
|
||||
### Step 2 — Create dataset (if new persistent storage needed)
|
||||
TrueNAS datasets live under `/mnt/tank/docker/{service-name}/`
|
||||
|
||||
### Step 3 — Write `docker-compose.yml`
|
||||
```yaml
|
||||
version: "3.8"
|
||||
services:
|
||||
{service-name}:
|
||||
image: {image}:{tag}
|
||||
container_name: {service-name}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "{host-port}:{container-port}"
|
||||
volumes:
|
||||
- /mnt/tank/docker/{service-name}/data:/data
|
||||
environment:
|
||||
- KEY=value
|
||||
```
|
||||
|
||||
### Step 4 — Deploy
|
||||
```bash
|
||||
ssh root@192.168.188.119 "cd /opt/docker/{service-name} && docker compose up -d"
|
||||
```
|
||||
|
||||
### Step 5 — Verify
|
||||
```bash
|
||||
ssh root@192.168.188.119 "docker ps | grep {service-name}"
|
||||
ssh root@192.168.188.119 "docker logs {service-name} --tail 20"
|
||||
```
|
||||
|
||||
### Step 6 — Store in BigMind
|
||||
```
|
||||
memory_store_fact("environment-config", "Service {service-name} running on TrueNAS at port {port}. Image: {image}. Data: /mnt/tank/docker/{service-name}/")
|
||||
```
|
||||
|
||||
### Step 7 — Commit compose file to Gitea
|
||||
Use the `gitea-push` skill with type `chore` and scope `homelab`.
|
||||
|
||||
## Troubleshooting
|
||||
- **Port conflict:** `ssh root@192.168.188.119 "ss -tlnp | grep {port}"`
|
||||
- **Permission denied on /mnt:** Check ZFS dataset ownership
|
||||
- **Image pull fails:** TrueNAS needs outbound internet — check DNS and routing
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
name: mcp-test-suite
|
||||
description: Generates a comprehensive mock-based pytest test suite for a FastMCP server in pi_mcps. Use this skill when adding test coverage to a new or existing MCP server — produces conftest.py and test_server.py with 100% tool coverage and proper mock isolation.
|
||||
---
|
||||
|
||||
# MCP Test Suite
|
||||
|
||||
## When to use
|
||||
- New MCP server needs a test suite
|
||||
- Existing server has missing coverage
|
||||
- Adding new tools to an existing server
|
||||
|
||||
## When NOT to use
|
||||
- Non-MCP Python code (use standard pytest patterns)
|
||||
- Integration tests that actually hit external APIs (mock instead)
|
||||
|
||||
## Inputs required
|
||||
- **Server name** — `mcp-{name}`
|
||||
- **Tool list** — each tool's name, parameters, and return type
|
||||
- **External dependencies** — HTTP clients, SDKs, env vars
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Write `tests/conftest.py`
|
||||
|
||||
```python
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
# Make src/ importable
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
@pytest.fixture
|
||||
def mock_env(monkeypatch):
|
||||
"""Set required environment variables."""
|
||||
monkeypatch.setenv("API_KEY", "test-key")
|
||||
monkeypatch.setenv("API_URL", "https://test.example.com")
|
||||
```
|
||||
|
||||
### Step 2 — Write `tests/test_server.py`
|
||||
|
||||
Structure per tool:
|
||||
```python
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from server import tool_name # import directly from server module
|
||||
|
||||
class TestToolName:
|
||||
def test_happy_path(self, mock_env):
|
||||
with patch("server.httpx.get") as mock_get:
|
||||
mock_get.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"key": "value"}
|
||||
)
|
||||
result = tool_name("test-param")
|
||||
assert "expected" in result
|
||||
|
||||
def test_error_handling(self, mock_env):
|
||||
with patch("server.httpx.get") as mock_get:
|
||||
mock_get.side_effect = Exception("Connection refused")
|
||||
result = tool_name("test-param")
|
||||
assert "error" in result.lower()
|
||||
|
||||
def test_empty_input(self, mock_env):
|
||||
# edge case — empty string, None, etc.
|
||||
result = tool_name("")
|
||||
assert result is not None
|
||||
```
|
||||
|
||||
### Step 3 — Coverage checklist
|
||||
For each tool, cover:
|
||||
- [ ] Happy path with expected response
|
||||
- [ ] Network/API error (exception raised)
|
||||
- [ ] Empty or invalid input
|
||||
- [ ] Edge case specific to the tool's logic
|
||||
|
||||
### Step 4 — Run and verify
|
||||
```bash
|
||||
cd mcp/{name}
|
||||
uv run pytest tests/ -v --tb=short
|
||||
```
|
||||
|
||||
Expected: all tests pass, no warnings about missing coverage
|
||||
|
||||
### Step 5 — Store result in BigMind
|
||||
```
|
||||
memory_store_fact("codebase", "mcp-{name} test suite: N tests, all passing. Coverage: happy path + error + edge cases per tool.")
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
- **ImportError on `from server import ...`:** Check `conftest.py` sys.path insert
|
||||
- **Mock not intercepting:** Patch the name as used in server.py, not the library's own namespace
|
||||
- ✅ `patch("server.httpx.get")` — patches where it's used
|
||||
- ❌ `patch("httpx.get")` — patches library origin (may not intercept)
|
||||
- **Env var not set in test:** Add to `mock_env` fixture in conftest.py
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: jira-ticket
|
||||
description: Creates an ADP Germany ESIDEPAISY Jira ticket following German language conventions and mandatory custom field requirements. Use this skill when opening a new ticket for Paisy work — handles summary, description, mandatory custom fields, and sprint assignment.
|
||||
---
|
||||
|
||||
# Jira Ticket (Paisy/ADP)
|
||||
|
||||
## When to use
|
||||
- Opening a new ESIDEPAISY Jira ticket
|
||||
- Need to ensure all mandatory custom fields are set correctly
|
||||
|
||||
## When NOT to use
|
||||
- Updating an existing ticket (use `update_ticket_fields` directly)
|
||||
- Non-ESIDEPAISY projects
|
||||
|
||||
## Inputs required
|
||||
- **Summary** — in German (technical terms as-is)
|
||||
- **Description** — in German, include context and steps to reproduce
|
||||
- **Issue type** — Bug, Story, Task, Sub-task
|
||||
- **Feature Link** — current MEMO Feature (default: ESIDEPAISY-9648)
|
||||
- **Sprint ID** — current active sprint (stored in BigMind: sprint 173657)
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Search for duplicate tickets
|
||||
```
|
||||
memory_search_facts("ESIDEPAISY {keyword}")
|
||||
```
|
||||
Also search Jira directly before creating.
|
||||
|
||||
### Step 2 — Create the ticket
|
||||
|
||||
Mandatory fields at creation:
|
||||
```json
|
||||
{
|
||||
"summary": "[Deutscher Titel]",
|
||||
"description": "[Deutschsprachige Beschreibung]",
|
||||
"issuetype": {"name": "Bug"},
|
||||
"customfield_10001": "ESIDEPAISY-9648",
|
||||
"customfield_10501": {"value": "PAISY MEMO"},
|
||||
"customfield_12700": {"value": "FY26 / 2"}
|
||||
}
|
||||
```
|
||||
|
||||
**Language rule:** Summary + description + comments → German. Class names, method names, stack traces, log output → original form.
|
||||
|
||||
### Step 3 — Set sprint (must be done AFTER creation)
|
||||
```
|
||||
update_ticket_fields(
|
||||
ticket_id="{new-ticket-id}",
|
||||
fields={"customfield_10000": [{"id": 173657}]}
|
||||
)
|
||||
```
|
||||
|
||||
### Step 4 — Link to Feature
|
||||
If not set at creation, add Feature Link via update.
|
||||
|
||||
### Step 5 — Store in BigMind
|
||||
```
|
||||
memory_store_fact("codebase", "ESIDEPAISY-XXXXX created: [summary in English]. Module: [module].")
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
- **Missing customfield_10001:** Ticket will be rejected or flagged at sprint review — always set
|
||||
- **Sprint not assignable at creation:** Normal — Jira blocks this; use step 3 update pattern
|
||||
- **Status confusion:** Terminal status is "Accepted" (not "Done"/"Closed")
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: paisy-assessment
|
||||
description: Creates a structured assessment markdown document for an ADP Germany Paisy Jira ticket before any code is written. Use this skill at the start of every ESIDEPAISY ticket — covers root cause, affected files, risks, and implementation plan in German-compatible format.
|
||||
---
|
||||
|
||||
# Paisy Assessment
|
||||
|
||||
## When to use
|
||||
Every non-trivial ESIDEPAISY Jira ticket before touching any code. This is mandatory for Paisy work.
|
||||
|
||||
## When NOT to use
|
||||
- Trivial config/text fixes without code changes
|
||||
- A prior assessment already exists for this ticket
|
||||
|
||||
## Inputs required
|
||||
- **Ticket ID** — e.g., `ESIDEPAISY-12021`
|
||||
- **Ticket title** — from Jira
|
||||
- **Module** — e.g., `eau`, `eubp`, `fex`, `common/beitragssatzdatei`
|
||||
- **Error logs or symptoms** — stack traces, log output, reproduction steps
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Search BigMind for known patterns
|
||||
```
|
||||
memory_search_facts("ESIDEPAISY {module}")
|
||||
memory_search_facts("bug-pattern {symptom keyword}")
|
||||
memory_search_chunks("{error keyword}")
|
||||
```
|
||||
|
||||
### Step 2 — Name and locate the file
|
||||
Convention: `{MODULE}_{TICKET}_Assessment.md`
|
||||
Location: `java/modules/.../docs/` within the affected module, or `java/modules/cs-modules/{module}/docs/`
|
||||
|
||||
### Step 3 — Write the assessment document
|
||||
|
||||
```markdown
|
||||
# Assessment: {Ticket Title}
|
||||
*Autor: Lumen | Datum: YYYY-MM-DD | Ticket: ESIDEPAISY-XXXXX*
|
||||
|
||||
## Zusammenfassung
|
||||
[1-2 sentences in German summarizing the problem]
|
||||
|
||||
## Root Cause Analysis
|
||||
[Technical analysis — can be in English for code-level detail]
|
||||
|
||||
### Bekannte Muster
|
||||
[Reference any similar bugs from BigMind memory]
|
||||
|
||||
## Betroffene Dateien
|
||||
| Datei | Zeile | Änderung |
|
||||
|-------|-------|----------|
|
||||
| ClassName.java | 428 | Add null-check |
|
||||
|
||||
## Risiken
|
||||
- [Risk 1]
|
||||
- [Risk 2]
|
||||
|
||||
## Alternativen
|
||||
### Option A (gewählt): ...
|
||||
### Option B: ...
|
||||
|
||||
## Implementierungsplan
|
||||
1. [Step 1]
|
||||
2. [Step 2]
|
||||
|
||||
## Offene Fragen
|
||||
- [ ] Q1: [Question] → @{person}
|
||||
```
|
||||
|
||||
### Step 4 — Store assessment location in BigMind
|
||||
```
|
||||
memory_store_fact("codebase", "ESIDEPAISY-XXXXX assessment at {path}")
|
||||
```
|
||||
|
||||
### Step 5 — Create feature branch
|
||||
```bash
|
||||
git checkout -b feature/ESIDEPAISY-XXXXX-short-description
|
||||
# or for bugs:
|
||||
git checkout -b bugfix/eau/ESIDEPAISY-XXXXX-short-description
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
- If root cause is unclear: write "TBD — pending log analysis" and open a question in the doc
|
||||
- If blocked on another ticket: note the blocker in Offene Fragen and set ticket status to "Blocked"
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: assessment-first
|
||||
description: Writes a structured assessment.md before any implementation task. Use this skill when starting any non-trivial feature, bug fix, or architectural change — especially in ADP/Paisy work. Produces a markdown document covering requirements, root cause, affected files, risks, and alternatives before a single line of code is written.
|
||||
---
|
||||
|
||||
# Assessment-First
|
||||
|
||||
## When to use
|
||||
Use this skill before implementing any non-trivial task:
|
||||
- ADP/Paisy Jira tickets (mandatory)
|
||||
- New MCP server design
|
||||
- BigMind schema changes
|
||||
- Homelab service deployment with unknowns
|
||||
|
||||
## When NOT to use
|
||||
- Trivial one-liner fixes (typos, config values)
|
||||
- Tasks already covered by a prior assessment
|
||||
|
||||
## Inputs required
|
||||
- Task description or Jira ticket reference
|
||||
- Affected module / file paths (if known)
|
||||
- Any error logs, stack traces, or existing symptoms
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Name the file** — `{MODULE}_{TICKET}_Assessment.md` for Paisy, or `PLAN.md` for new builds
|
||||
|
||||
2. **Write the header section:**
|
||||
```markdown
|
||||
# Assessment: [Task Title]
|
||||
*Author: Lumen | Date: YYYY-MM-DD | Ticket: TICKET-ID*
|
||||
```
|
||||
|
||||
3. **Requirements** — What exactly needs to happen? What's the success criterion?
|
||||
|
||||
4. **Root Cause Analysis** (for bug fixes) — Why is this happening? Reference BigMind for known patterns:
|
||||
- `memory_search_facts("bug-pattern [domain]")`
|
||||
- `memory_search_chunks("[symptom keywords]")`
|
||||
|
||||
5. **Affected Files** — List every file that will need to change
|
||||
|
||||
6. **Risks** — What could go wrong? DB migrations? API contract changes? Concurrent access?
|
||||
|
||||
7. **Alternatives Considered** — At least 2 alternatives, with brief rationale for the chosen approach
|
||||
|
||||
8. **Implementation Plan** — Ordered steps, numbered
|
||||
|
||||
9. **Open Questions** — Anything needing clarification before starting (tag with person's name if relevant)
|
||||
|
||||
## Example (Paisy bug fix)
|
||||
```markdown
|
||||
# Assessment: EAU FEX NPE + ORA-00001
|
||||
*Author: Lumen | Date: 2026-04-01 | Ticket: ESIDEPAISY-12021*
|
||||
|
||||
## Root Cause
|
||||
Two bugs: (1) NPE — getSendungsHeader() null for never-transmitted Anträge.
|
||||
(2) ORA-00001 — duplicate hashes from ADVFEX C/S→PA migration.
|
||||
|
||||
## Affected Files
|
||||
- CSVController.java:428 (null-check)
|
||||
- AntragManager.java:766 (duplicate hash handling)
|
||||
|
||||
## Implementation Plan
|
||||
1. Add null-check guard in CSVController
|
||||
2. Add duplicate detection before batch flush in AntragManager
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
- If open questions block the assessment, write them down and ping the right person — don't guess
|
||||
- For Paisy: assessment doc goes in `docs/` within the relevant module
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: bigmind-session-ritual
|
||||
description: Executes the mandatory BigMind session start and end rituals in the correct order. Use this skill when a mode or conversation seems to have skipped the session ritual, or as a reference checklist for the full ritual sequence including hypotheses, focus announcement, and stale session cleanup.
|
||||
---
|
||||
|
||||
# BigMind Session Ritual
|
||||
|
||||
## When to use
|
||||
- A session was started without the proper ritual (catch-up)
|
||||
- Verifying the ritual was done correctly
|
||||
- Teaching another agent/mode what the ritual is
|
||||
|
||||
## When NOT to use
|
||||
- Normal operation — the ritual should happen automatically from global rules
|
||||
- If `memory_start_session()` already returned a session_id this conversation
|
||||
|
||||
## Session Start Ritual (strict order)
|
||||
|
||||
Execute these 4 calls in sequence before any work:
|
||||
|
||||
**Step 1 — Open session:**
|
||||
```
|
||||
memory_start_session()
|
||||
```
|
||||
→ Returns `session_id`. Save it — needed for all subsequent calls.
|
||||
|
||||
**Step 2 — Review open hypotheses:**
|
||||
```
|
||||
memory_list_hypotheses(status="open")
|
||||
```
|
||||
→ Check if any are stale (>1 week old). Assess confidence. Resolve any that are now obviously confirmed/refuted.
|
||||
|
||||
**Step 3 — Announce focus:**
|
||||
```
|
||||
memory_announce_focus(
|
||||
session_id="{id}",
|
||||
description="[What this session is doing]",
|
||||
files=["list", "of", "files"],
|
||||
ide_hint="VS Code"
|
||||
)
|
||||
```
|
||||
→ Enables conflict detection. Other sessions can see what files you're working on.
|
||||
|
||||
**Step 4 — Close stale sessions:**
|
||||
```
|
||||
memory_close_stale_sessions(session_id="{id}")
|
||||
```
|
||||
→ Cleans up orphaned sessions from crashed IDEs. Stale = no activity >2h.
|
||||
|
||||
---
|
||||
|
||||
## Session End Ritual (mandatory before closing)
|
||||
|
||||
```
|
||||
memory_end_session(
|
||||
session_id="{id}",
|
||||
one_liner="One sentence summary of what was accomplished.",
|
||||
topics=["tag1", "tag2", "tag3"],
|
||||
outcome="completed", # or: partial, blocked, abandoned
|
||||
summary="3-8 sentence narrative. Key decisions made. Problems encountered. Solutions applied. Unresolved items carried forward.",
|
||||
importance=5 # 1-10. Use 7+ for architectural decisions or critical bugs.
|
||||
)
|
||||
```
|
||||
|
||||
**Importance guide:**
|
||||
| Score | Use for |
|
||||
|-------|---------|
|
||||
| 1-3 | Reading-only, minor exploration |
|
||||
| 4-6 | Feature work, standard debugging |
|
||||
| 7-8 | Architectural decisions, breaking changes |
|
||||
| 9-10 | Critical bugs, security-relevant choices |
|
||||
|
||||
## Troubleshooting
|
||||
- If `memory_start_session()` fails: retry once, then proceed with a logged warning
|
||||
- If another session has the same files in focus: coordinate or defer (see Rule 7)
|
||||
- If `session_id` was lost: use `memory_list_sessions(limit=5)` to find the open one
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
name: gitea-push
|
||||
description: Commits and pushes code to the homelab Gitea server using conventional commit format. Use this skill when finishing any homelab, MCP builder, or BigMind work that needs to be saved to the homelab Gitea at http://192.168.188.119:30008/.
|
||||
---
|
||||
|
||||
# Gitea Push
|
||||
|
||||
## When to use
|
||||
- Finished a homelab change and need to commit + push
|
||||
- Finished an MCP server build or update
|
||||
- BigMind feature complete
|
||||
|
||||
## When NOT to use
|
||||
- ADP/Paisy work — that goes to the corporate Bitbucket, not homelab Gitea
|
||||
- Uncommitted work that isn't ready (don't push broken state)
|
||||
|
||||
## Inputs required
|
||||
- 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 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
|
||||
```
|
||||
|
||||
3. **Stage changes:**
|
||||
```bash
|
||||
git add -A
|
||||
# or selectively: git add path/to/file
|
||||
```
|
||||
|
||||
4. **Write conventional commit message:**
|
||||
|
||||
Format: `type(scope): short description`
|
||||
|
||||
| Type | When |
|
||||
|------|------|
|
||||
| `feat` | New feature or tool |
|
||||
| `fix` | Bug fix |
|
||||
| `refactor` | Code restructure, no behavior change |
|
||||
| `test` | Adding or updating tests |
|
||||
| `docs` | Documentation only |
|
||||
| `chore` | Build, dependencies, config |
|
||||
| `style` | Formatting, no logic change |
|
||||
|
||||
Examples:
|
||||
- `feat(mcp-webscraper): add ssl cert fallback with certifi`
|
||||
- `fix(bigmind): resolve FTS5 reserved-word collision`
|
||||
- `chore(homelab): update docker-compose for gitea upgrade`
|
||||
|
||||
5. **Commit:**
|
||||
```bash
|
||||
git commit -m "type(scope): description"
|
||||
```
|
||||
|
||||
6. **Push branch to Gitea:**
|
||||
```bash
|
||||
git push origin feat/scope/short-description
|
||||
```
|
||||
Gitea URL: `http://192.168.188.119:30008/pplate/pi_mcps.git`
|
||||
|
||||
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")
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
- **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
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: new-mcp-server
|
||||
description: Scaffolds a new FastMCP server following pi_mcps conventions. Use this skill when creating any new MCP server in the pi_mcps monorepo — produces the full directory structure with server.py, pyproject.toml, tests, and README in one pass.
|
||||
---
|
||||
|
||||
# New MCP Server
|
||||
|
||||
## When to use
|
||||
- Creating a new MCP server in `pi_mcps/mcp/{name}/`
|
||||
- Bootstrapping a server scaffold before filling in tool logic
|
||||
|
||||
## When NOT to use
|
||||
- Adding tools to an existing server (edit `src/server.py` directly)
|
||||
- Non-MCP Python projects
|
||||
|
||||
## Inputs required
|
||||
- **Server name** — e.g., `homelab-docker` (will become `mcp-homelab-docker`)
|
||||
- **Purpose** — one sentence description
|
||||
- **Tools list** — names + brief descriptions
|
||||
- **Dependencies** — any Python packages beyond fastmcp
|
||||
- **Environment variables** — any auth/config env vars needed
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Create directory structure
|
||||
```bash
|
||||
mkdir -p mcp/{name}/src
|
||||
mkdir -p mcp/{name}/tests
|
||||
touch mcp/{name}/src/__init__.py
|
||||
```
|
||||
|
||||
### Step 2 — Write `mcp/{name}/src/server.py`
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("mcp-{name}")
|
||||
|
||||
@mcp.tool()
|
||||
def {tool_name}(param: str) -> str:
|
||||
"""Tool description."""
|
||||
# implementation
|
||||
...
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
```
|
||||
|
||||
### Step 3 — Write `mcp/{name}/pyproject.toml`
|
||||
```toml
|
||||
[project]
|
||||
name = "mcp-{name}"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastmcp>=0.1.0",
|
||||
# add other deps here
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = ["pytest", "pytest-mock", "pytest-cov"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
```
|
||||
|
||||
### Step 4 — Write `mcp/{name}/tests/conftest.py`
|
||||
```python
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
```
|
||||
|
||||
### Step 5 — Write `mcp/{name}/tests/test_server.py`
|
||||
- Import each tool function directly
|
||||
- Mock all external calls with `pytest-mock`
|
||||
- Cover: happy path, error path, edge cases
|
||||
- Run: `cd mcp/{name} && uv run pytest tests/ -v`
|
||||
|
||||
### Step 6 — Write `mcp/{name}/README.md`
|
||||
Include: purpose, tools table, env vars, usage example, test command
|
||||
|
||||
### Step 7 — Wire into `.roo/mcp.json`
|
||||
```json
|
||||
"mcp-{name}": {
|
||||
"command": "uv",
|
||||
"args": ["--directory", "/home/pplate/pi_mcps/mcp/{name}", "run", "src/server.py"],
|
||||
"env": {
|
||||
"ENV_VAR": "${ENV_VAR}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 8 — Store in BigMind
|
||||
```
|
||||
memory_store_fact("codebase", "mcp/{name} has N tools: [tool1, tool2]. Stack: fastmcp + X. Env vars: Y.")
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
- **FastMCP import error:** Run `uv sync` in the server directory first
|
||||
- **Tool not showing in IDE:** Restart the MCP server via IDE settings
|
||||
- **Test isolation:** Each test should monkeypatch env vars to avoid cross-test pollution
|
||||
@@ -0,0 +1,622 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create all 7 wiki pages for pi_mcps on Gitea."""
|
||||
import base64
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
GITEA_URL = "http://192.168.188.119:30008"
|
||||
OWNER = "pplate"
|
||||
REPO = "pi_mcps"
|
||||
TOKEN = "8bf0c734ebda3e61d9c9068489ce58a2bf8d33db"
|
||||
IMG_BASE = f"{GITEA_URL}/{OWNER}/{REPO}/raw/branch/main/docs/wiki/images"
|
||||
|
||||
PAGES = {}
|
||||
|
||||
PAGES["Home"] = f"""# 🔧 pi_mcps — Patrick's Homelab Monorepo
|
||||
|
||||

|
||||
|
||||
Welcome to **pi_mcps**, Patrick's personal homelab monorepo. This repository houses MCP (Model Context Protocol) servers, Java projects, and homelab tooling — all built and maintained on a Fedora Linux workstation with an AMD Ryzen 5900X + RX 7900 XTX.
|
||||
|
||||
## What's in this repo?
|
||||
|
||||
| Directory | Contents |
|
||||
|---|---|
|
||||
| [`mcp/mcp-image-gen/`](../src/branch/main/mcp/mcp-image-gen) | 🎨 AI image generation via ComfyUI + FLUX.1-schnell |
|
||||
| [`mcp/webscraper/`](../src/branch/main/mcp/webscraper) | 🕸️ Web scraping and data extraction |
|
||||
| [`mcp/bigmind/`](../src/branch/main/mcp/bigmind) | 🧠 Persistent AI memory system |
|
||||
| [`java/`](../src/branch/main/java) | ☕ Java EE / Spring projects |
|
||||
| [`plans/`](../src/branch/main/plans) | 📋 Architecture decisions and health reports |
|
||||
|
||||
## Stack
|
||||
|
||||
- **Language:** Python 3.11+ (MCP servers), Java 17 (legacy projects)
|
||||
- **MCP Framework:** FastMCP 2.x
|
||||
- **Package Manager:** `uv` (all Python projects)
|
||||
- **Testing:** `pytest`
|
||||
- **GPU:** AMD RX 7900 XTX (ROCm / HSA)
|
||||
- **Server:** TrueNAS.local at `192.168.188.119` (Gitea, Docker)
|
||||
|
||||
## MCP Servers
|
||||
|
||||
Three production-ready MCP servers power Patrick's AI development environment:
|
||||
|
||||
| Server | Status | Description |
|
||||
|---|---|---|
|
||||
| [mcp-image-gen](mcp-image-gen) | ✅ Live | Generate images from text prompts via ComfyUI |
|
||||
| [mcp-webscraper](mcp-webscraper) | ✅ Live | Scrape web pages, extract tables, fetch links |
|
||||
| [BigMind](BigMind) | ✅ Live | Persistent AI memory across all sessions |
|
||||
|
||||
---
|
||||
|
||||
*Built and maintained by Patrick Plate (pplate) · Homelab: TrueNAS.local · AI Colleague: Lumen*
|
||||
"""
|
||||
|
||||
PAGES["MCP-Servers-Overview"] = f"""# 🔌 MCP Servers Overview
|
||||
|
||||

|
||||
|
||||
This repo contains three production-grade MCP (Model Context Protocol) servers, each specialized for a different capability domain. Together they give Roo Code / Claude Desktop a complete set of superpowers.
|
||||
|
||||
## The Three Pillars
|
||||
|
||||
```
|
||||
Roo Code / Claude Desktop
|
||||
│
|
||||
├── bigmind ──────────► ~/.mcp/bigmind/memory.db (persistent memory)
|
||||
├── mcp-image-gen ────► ComfyUI @ localhost:8188 (image generation)
|
||||
└── webscraper ───────► Internet / Intranet (web scraping)
|
||||
```
|
||||
|
||||
## Comparison Table
|
||||
|
||||
| Feature | mcp-image-gen | webscraper | bigmind |
|
||||
|---|---|---|---|
|
||||
| **Purpose** | Generate images from text | Scrape & parse web | Persistent AI memory |
|
||||
| **Tools** | 4 | 7 | 15+ |
|
||||
| **Backend** | ComfyUI / FLUX.1-schnell | httpx + BeautifulSoup4 | SQLite + FTS5 |
|
||||
| **GPU required** | ✅ AMD RX 7900 XTX | ❌ | ❌ |
|
||||
| **Tests** | 19/19 ✅ | ✅ | 297/297 ✅ |
|
||||
| **Schema version** | n/a | n/a | v7 |
|
||||
|
||||
## Quick Links
|
||||
|
||||
- 🎨 [mcp-image-gen](mcp-image-gen) — Image generation docs
|
||||
- 🕸️ [mcp-webscraper](mcp-webscraper) — Web scraping docs
|
||||
- 🧠 [BigMind](BigMind) — Memory system docs
|
||||
- 🛠️ [Development Conventions](Development-Conventions) — How all servers are built
|
||||
|
||||
## Adding a New Server
|
||||
|
||||
All servers follow the [FastMCP convention](Development-Conventions). Use the `new-mcp-server` Roo skill to scaffold:
|
||||
|
||||
```bash
|
||||
# In Roo Code orchestrator, load skill:
|
||||
# skill: new-mcp-server
|
||||
```
|
||||
"""
|
||||
|
||||
PAGES["mcp-image-gen"] = f"""# 🎨 mcp-image-gen — AI Image Generation
|
||||
|
||||

|
||||
|
||||
**mcp-image-gen** is a FastMCP server that wraps the ComfyUI REST API, enabling Roo Code and Claude Desktop to generate images directly from text prompts using FLUX.1-schnell running on an AMD RX 7900 XTX GPU.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Roo Code / Claude Desktop
|
||||
│ MCP (stdio)
|
||||
▼
|
||||
mcp-image-gen (FastMCP, Python 3.11+)
|
||||
│ HTTP REST
|
||||
▼
|
||||
ComfyUI @ localhost:8188
|
||||
│ ROCm / HSA_OVERRIDE_GFX_VERSION=11.0.0
|
||||
▼
|
||||
FLUX.1-schnell (~8s/image @ 1024×1024)
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `generate_image` | Generate PNG from text prompt; returns file path + inline base64 |
|
||||
| `list_available_models` | List ComfyUI checkpoint models |
|
||||
| `get_generation_status` | Check status of a queued/running job |
|
||||
| `get_output_directory` | Return configured output directory path |
|
||||
|
||||
## Key Parameters — `generate_image`
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|---|---|---|
|
||||
| `prompt` | required | Text description of the image |
|
||||
| `width` | `1024` | Image width in pixels |
|
||||
| `height` | `1024` | Image height in pixels |
|
||||
| `steps` | `4` | Inference steps (FLUX.1-schnell is 4-step) |
|
||||
| `model` | `flux1-schnell.safetensors` | Model checkpoint name |
|
||||
| `seed` | `-1` (random) | Generation seed for reproducibility |
|
||||
| `negative_prompt` | `""` | Things to avoid in the image |
|
||||
| `output_dir` | `~/Pictures/mcp-generated` | Where to save output PNG |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `COMFYUI_URL` | `http://localhost:8188` | ComfyUI API endpoint |
|
||||
| `IMAGE_OUTPUT_DIR` | `~/Pictures/mcp-generated` | Default output directory |
|
||||
| `COMFYUI_TIMEOUT` | `120` | Request timeout in seconds |
|
||||
|
||||
## Return Value
|
||||
|
||||
The tool returns **two content items**:
|
||||
1. `TextContent` — file path, seed used, elapsed time
|
||||
2. `ImageContent` — base64-encoded PNG (displays inline in Roo Code chat)
|
||||
|
||||
> ⚠️ **Known FastMCP Bug:** Never use `fastmcp.utilities.types.Image` as return type — it breaks serialization in FastMCP 3.x. Use `mcp.types.ImageContent` directly.
|
||||
|
||||
## Setup
|
||||
|
||||
See [ComfyUI Setup Guide](mcp-image-gen-ComfyUI-Setup) for full installation instructions.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
cd mcp/mcp-image-gen
|
||||
uv sync
|
||||
# Set COMFYUI_URL if ComfyUI is not on localhost
|
||||
./run.sh
|
||||
```
|
||||
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
cd mcp/mcp-image-gen
|
||||
uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
## Lumen Profile Images
|
||||
|
||||
The first images generated with this server were Lumen's visual identity portraits, stored in [`mcp/mcp-image-gen/lumen_profiles/`](../src/branch/main/mcp/mcp-image-gen/lumen_profiles):
|
||||
|
||||

|
||||
|
||||
*Primary profile: seed `568659042` — constellation face interpretation of Lumen.*
|
||||
"""
|
||||
|
||||
PAGES["mcp-image-gen-ComfyUI-Setup"] = f"""# ⚙️ ComfyUI Setup Guide (AMD ROCm)
|
||||
|
||||
This guide covers installing ComfyUI with FLUX.1-schnell on a Fedora Linux system with an AMD GPU.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- AMD GPU with ROCm support (tested: RX 7900 XTX)
|
||||
- Fedora Linux (tested: Fedora 43 / kernel 6.19)
|
||||
- Python 3.11+
|
||||
- ~15GB free disk space (model weights)
|
||||
- HuggingFace account with FLUX license accepted
|
||||
|
||||
## Step 1: Install ComfyUI
|
||||
|
||||
ComfyUI is **not on PyPI** — must be cloned from source:
|
||||
|
||||
```bash
|
||||
cd ~
|
||||
git clone https://github.com/comfyanonymous/ComfyUI
|
||||
cd ComfyUI
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install PyTorch ROCm build (CRITICAL for AMD GPUs)
|
||||
pip install torch torchvision --index-url https://download.pytorch.org/whl/rocm6.2
|
||||
|
||||
# Install ComfyUI dependencies
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Step 2: Download FLUX.1-schnell
|
||||
|
||||
FLUX.1-schnell is **gated on HuggingFace** — you must:
|
||||
1. Create a HuggingFace account
|
||||
2. Accept the FLUX.1-schnell license at https://huggingface.co/black-forest-labs/FLUX.1-schnell
|
||||
3. Generate an access token at https://huggingface.co/settings/tokens
|
||||
|
||||
```bash
|
||||
# Install huggingface_hub
|
||||
pip install huggingface_hub
|
||||
|
||||
# Download model (requires HF token)
|
||||
huggingface-cli download black-forest-labs/FLUX.1-schnell \\
|
||||
flux1-schnell.safetensors \\
|
||||
--local-dir ~/ComfyUI/models/checkpoints \\
|
||||
--token YOUR_HF_TOKEN_HERE
|
||||
```
|
||||
|
||||
## Step 3: Download VAE and CLIP Models
|
||||
|
||||
FLUX.1-schnell also requires VAE and CLIP text encoders:
|
||||
|
||||
```bash
|
||||
# VAE
|
||||
huggingface-cli download black-forest-labs/FLUX.1-schnell \\
|
||||
ae.safetensors \\
|
||||
--local-dir ~/ComfyUI/models/vae
|
||||
|
||||
# CLIP models (T5 and CLIP-L)
|
||||
huggingface-cli download comfyanonymous/flux_text_encoders \\
|
||||
t5xxl_fp8_e4m3fn.safetensors clip_l.safetensors \\
|
||||
--local-dir ~/ComfyUI/models/clip
|
||||
```
|
||||
|
||||
## Step 4: Start ComfyUI
|
||||
|
||||
```bash
|
||||
cd ~/ComfyUI
|
||||
|
||||
# AMD GPU REQUIRES this environment variable
|
||||
HSA_OVERRIDE_GFX_VERSION=11.0.0 \\
|
||||
nohup .venv/bin/python main.py --listen --port 8188 > /tmp/comfyui.log 2>&1 &
|
||||
|
||||
echo "ComfyUI PID: $!"
|
||||
```
|
||||
|
||||
> ⚠️ `HSA_OVERRIDE_GFX_VERSION=11.0.0` is mandatory for RX 7900 XTX on ROCm. Without it, model loading fails silently.
|
||||
|
||||
## Step 5: Verify ComfyUI is Running
|
||||
|
||||
```bash
|
||||
curl http://localhost:8188/system_stats
|
||||
# Should return JSON with GPU info
|
||||
```
|
||||
|
||||
## Step 6: Configure mcp-image-gen
|
||||
|
||||
```bash
|
||||
cd /path/to/pi_mcps/mcp/mcp-image-gen
|
||||
cp .env.example .env # if exists, or set manually
|
||||
|
||||
# .env contents:
|
||||
COMFYUI_URL=http://localhost:8188
|
||||
IMAGE_OUTPUT_DIR=~/Pictures/mcp-generated
|
||||
COMFYUI_TIMEOUT=120
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
| GPU | Model | Resolution | Steps | Time |
|
||||
|---|---|---|---|---|
|
||||
| AMD RX 7900 XTX | FLUX.1-schnell | 1024×1024 | 4 | ~8s |
|
||||
| AMD RX 7900 XTX | FLUX.1-schnell | 1280×512 | 4 | ~7s |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---|---|
|
||||
| `HTTP 401` downloading model | Accept FLUX license on HuggingFace first |
|
||||
| GPU not detected | Ensure `HSA_OVERRIDE_GFX_VERSION=11.0.0` is set |
|
||||
| `Connection refused` from mcp-image-gen | Start ComfyUI first, check port 8188 |
|
||||
| Slow generation (>60s) | ComfyUI may be running on CPU — check ROCm install |
|
||||
| Ollama image gen | As of April 2026: macOS-only, not available on Linux |
|
||||
"""
|
||||
|
||||
PAGES["mcp-webscraper"] = f"""# 🕸️ mcp-webscraper — Web Scraping
|
||||
|
||||

|
||||
|
||||
**mcp-webscraper** is a FastMCP server providing comprehensive web scraping and data extraction capabilities. It fetches pages, converts HTML to clean Markdown, extracts tables, links, CSS sections, metadata, and sitemaps.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `webscraper_fetch(url, max_chars=5000)` | Title + full page as Markdown + metadata |
|
||||
| `webscraper_fetch_links(url, deduplicate=True)` | All `href` links found on the page |
|
||||
| `webscraper_fetch_tables(url)` | All HTML tables converted to Markdown |
|
||||
| `webscraper_fetch_all(url, max_chars=5000)` | Everything in one call (fetch + links + tables) |
|
||||
| `webscraper_fetch_section(url, selector)` | Specific CSS selector section only |
|
||||
| `webscraper_fetch_meta(url)` | Title, description, Open Graph tags |
|
||||
| `webscraper_fetch_sitemap(url, max_urls=100)` | Parse sitemap.xml, return URL list |
|
||||
|
||||
## Stack
|
||||
|
||||
- **HTTP client:** `httpx` (async, with SSL support)
|
||||
- **HTML parser:** `BeautifulSoup4` + `lxml`
|
||||
- **Markdown converter:** `html2text`
|
||||
- **SSL:** Custom cert bundle for Fedora 43 compatibility
|
||||
|
||||
## SSL Note — Fedora 43 Comodo Root CA
|
||||
|
||||
Fedora 43 is missing the **Comodo AAA Services Root CA** needed for Cloudflare-protected sites. The fix is bundled at [`mcp/webscraper/certs/comodo-aaa-services-root.pem`](../src/branch/main/mcp/webscraper/certs/).
|
||||
|
||||
The server automatically uses this cert bundle — no manual configuration needed.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd mcp/webscraper
|
||||
uv sync
|
||||
./run.sh
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```python
|
||||
# In Roo Code / Claude Desktop via MCP:
|
||||
|
||||
# Fetch a page as Markdown
|
||||
webscraper_fetch("https://docs.fastmcp.dev", max_chars=10000)
|
||||
|
||||
# Extract all links from Gitea repo
|
||||
webscraper_fetch_links("http://192.168.188.119:30008/pplate/pi_mcps")
|
||||
|
||||
# Get all tables from a documentation page
|
||||
webscraper_fetch_tables("https://pypi.org/project/fastmcp/")
|
||||
|
||||
# Get Open Graph metadata
|
||||
webscraper_fetch_meta("https://github.com/comfyanonymous/ComfyUI")
|
||||
|
||||
# Fetch specific section by CSS selector
|
||||
webscraper_fetch_section("https://docs.python.org", "#content")
|
||||
```
|
||||
"""
|
||||
|
||||
PAGES["BigMind"] = f"""# 🧠 BigMind — Persistent AI Memory
|
||||
|
||||

|
||||
|
||||
**BigMind** is the persistent memory backbone for all AI development sessions. It provides SQLite-backed tiered memory with FTS5 full-text search, hypothesis tracking, session management, and token efficiency logging. It is the reason Lumen (Patrick's AI colleague) remembers everything across sessions.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Tiered Memory
|
||||
| Tier | Name | Content |
|
||||
|---|---|---|
|
||||
| 0 | **Session Index** | Lightweight list: ID, date, one-liner |
|
||||
| 1 | **Topic Index** | Per-session topic tags and metadata |
|
||||
| 2 | **Narrative** | Full 3-8 sentence session summaries |
|
||||
| 3 | **Flagged Exchanges** | Specific important moments, decisions, code |
|
||||
|
||||
### Facts Store
|
||||
Atomic, reusable knowledge pieces categorized by type:
|
||||
- `user-preference` — Patrick's tool/style preferences
|
||||
- `architecture-decision` — System design choices
|
||||
- `codebase-convention` — How code is structured
|
||||
- `environment-config` — Server IPs, paths, credentials
|
||||
- `bug-pattern` — Known bugs and fixes
|
||||
- `api-contract` — MCP tool signatures
|
||||
|
||||
## Key Tools
|
||||
|
||||
### Session Lifecycle
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `memory_start_session()` | Open new session, load prior context |
|
||||
| `memory_end_session(...)` | Close session with summary, topics, outcome |
|
||||
| `memory_announce_focus(...)` | Declare files to be touched this session |
|
||||
| `memory_close_stale_sessions(...)` | Clean up crashed IDE sessions |
|
||||
|
||||
### Search
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `memory_search_facts(query, limit=10)` | FTS5 search over stored facts |
|
||||
| `memory_search_chunks(query, limit=10)` | FTS5 search over conversation chunks |
|
||||
| `memory_list_sessions(limit=20)` | Browse session history |
|
||||
|
||||
### Storage
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `memory_store_fact(category, fact)` | Store atomic reusable fact |
|
||||
| `memory_append_chunk(session_id, content, role)` | Store conversation chunk |
|
||||
| `memory_flag_important(session_id, content, role, flag_reason)` | Flag critical exchange |
|
||||
| `memory_log_token_save(session_id, description, tokens_saved, method_used)` | Track efficiency |
|
||||
|
||||
### Hypotheses
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `memory_add_hypothesis(session_id, hypothesis, confidence)` | Form testable prediction |
|
||||
| `memory_resolve_hypothesis(hypothesis_id, status, resolution)` | Confirm/refute prediction |
|
||||
| `memory_list_hypotheses(status)` | Review open/closed predictions |
|
||||
|
||||
## FTS5 Search Tips
|
||||
|
||||
BigMind uses SQLite FTS5 — **every token must match**. Use 2-3 focused keywords:
|
||||
|
||||
```
|
||||
✅ memory_search_facts("TrueNAS Docker")
|
||||
✅ memory_search_facts("mcp.json config")
|
||||
❌ memory_search_facts("homelab infrastructure TrueNAS Docker server") → 0 results
|
||||
```
|
||||
|
||||
## Stats (2026-04-04)
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| DB size | 744KB |
|
||||
| Sessions | 98 |
|
||||
| Facts | 97+ |
|
||||
| Chunks | 41 |
|
||||
| Schema version | v7 |
|
||||
|
||||
## DB Location
|
||||
|
||||
`~/.mcp/bigmind/memory.db` — outside the repo, never committed.
|
||||
|
||||
## Session Ritual
|
||||
|
||||
Every session **must** follow this ritual:
|
||||
|
||||
**Start:**
|
||||
1. `memory_start_session()`
|
||||
2. `memory_list_hypotheses()`
|
||||
3. `memory_announce_focus(...)`
|
||||
4. `memory_close_stale_sessions(...)`
|
||||
|
||||
**End:**
|
||||
1. `memory_end_session(one_liner, topics, outcome, summary, importance)`
|
||||
"""
|
||||
|
||||
PAGES["Development-Conventions"] = """# 🛠️ Development Conventions
|
||||
|
||||
All MCP servers in this repo follow a consistent set of conventions to ensure maintainability, testability, and compatibility with Roo Code tooling.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
Each MCP server lives at `mcp/<server-name>/` with this layout:
|
||||
|
||||
```
|
||||
mcp/<server-name>/
|
||||
├── src/
|
||||
│ ├── __init__.py
|
||||
│ └── server.py ← FastMCP server entry point
|
||||
├── tests/
|
||||
│ └── test_server.py ← pytest test suite
|
||||
├── pyproject.toml ← uv-managed dependencies
|
||||
├── run.sh ← launch script
|
||||
├── README.md ← server documentation
|
||||
├── PLAN.md ← architecture plan (pre-implementation)
|
||||
└── ASSESSMENT.md ← pre-implementation assessment
|
||||
```
|
||||
|
||||
## FastMCP Pattern
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("server-name")
|
||||
|
||||
@mcp.tool()
|
||||
def my_tool(param: str) -> str:
|
||||
\"\"\"Tool description shown to the AI.\"\"\"
|
||||
return result
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
```
|
||||
|
||||
## Package Management
|
||||
|
||||
**All projects use `uv`** — never `pip` directly:
|
||||
|
||||
```bash
|
||||
# Create new server
|
||||
uv init mcp/my-server
|
||||
cd mcp/my-server
|
||||
uv add fastmcp httpx
|
||||
|
||||
# Sync dependencies
|
||||
uv sync
|
||||
|
||||
# Run server
|
||||
uv run python src/server.py
|
||||
|
||||
# Run tests
|
||||
uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
## pyproject.toml Template
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "mcp-my-server"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastmcp>=2.0.0",
|
||||
"httpx",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mcp-my-server = "src.server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
```
|
||||
|
||||
## Testing Conventions
|
||||
|
||||
- Tests live in `tests/test_server.py`
|
||||
- Use `pytest` via `uv run pytest`
|
||||
- Mock external dependencies (ComfyUI, web URLs) for unit tests
|
||||
- All tests must pass before committing (`git push` should only happen with green tests)
|
||||
|
||||
## Commit Convention
|
||||
|
||||
Follow **Conventional Commits** format:
|
||||
|
||||
```
|
||||
feat: add webscraper_fetch_section tool
|
||||
fix: handle ComfyUI timeout gracefully
|
||||
docs: update mcp-image-gen README with AMD setup
|
||||
test: add unit tests for generate_image tool
|
||||
refactor: extract workflow builder to separate module
|
||||
chore: bump fastmcp to 2.1.0
|
||||
```
|
||||
|
||||
## Creating a New MCP Server
|
||||
|
||||
Use the `new-mcp-server` Roo skill in MCP Builder mode for full scaffolding:
|
||||
|
||||
```
|
||||
1. Switch to 🔧 MCP Builder mode in Roo Code
|
||||
2. Say: "Create a new MCP server for <purpose>"
|
||||
3. Roo will load the new-mcp-server skill and scaffold everything
|
||||
```
|
||||
|
||||
## Gitea Repository
|
||||
|
||||
Code is hosted at: `http://192.168.188.119:30008/pplate/pi_mcps`
|
||||
|
||||
Push with the `gitea-push` Roo skill to ensure conventional commit format.
|
||||
"""
|
||||
|
||||
|
||||
def create_wiki_page(title: str, content: str) -> bool:
|
||||
content_b64 = base64.b64encode(content.encode("utf-8")).decode("ascii")
|
||||
payload = json.dumps({
|
||||
"title": title,
|
||||
"content_base64": content_b64,
|
||||
"message": f"docs: create {title} wiki page"
|
||||
}).encode("utf-8")
|
||||
|
||||
url = f"{GITEA_URL}/api/v1/repos/{OWNER}/{REPO}/wiki/pages"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
headers={
|
||||
"Authorization": f"token {TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
print(f"✅ Created: {data.get('title', title)}")
|
||||
return True
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode()
|
||||
print(f"❌ Failed [{title}]: HTTP {e.code} — {body[:200]}")
|
||||
return False
|
||||
except Exception as ex:
|
||||
print(f"❌ Failed [{title}]: {ex}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
results = {}
|
||||
for title, content in PAGES.items():
|
||||
ok = create_wiki_page(title, content)
|
||||
results[title] = ok
|
||||
|
||||
print("\n=== Summary ===")
|
||||
for title, ok in results.items():
|
||||
status = "✅" if ok else "❌"
|
||||
print(f"{status} {title}")
|
||||
|
||||
total = sum(results.values())
|
||||
print(f"\n{total}/{len(results)} pages created successfully")
|
||||
|
After Width: | Height: | Size: 737 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 798 KiB |
|
After Width: | Height: | Size: 888 KiB |
|
After Width: | Height: | Size: 745 KiB |
|
After Width: | Height: | Size: 541 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 457 KiB |
|
After Width: | Height: | Size: 501 KiB |
|
After Width: | Height: | Size: 778 KiB |
|
After Width: | Height: | Size: 814 KiB |
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,619 @@
|
||||
# mcp-image-gen — Usage Guide
|
||||
|
||||
> **Comprehensive reference for using the ComfyUI-backed image generation MCP server**
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites — ComfyUI Setup](#1-prerequisites--comfyui-setup)
|
||||
2. [Quick Start — Running the MCP Server](#2-quick-start--running-the-mcp-server)
|
||||
3. [How to Ask Lumen to Generate Images](#3-how-to-ask-lumen-to-generate-images)
|
||||
4. [Available Tools](#4-available-tools)
|
||||
5. [Parameters Reference](#5-parameters-reference)
|
||||
6. [Output Format](#6-output-format)
|
||||
7. [Environment Variables](#7-environment-variables)
|
||||
8. [Test Status](#8-test-status)
|
||||
9. [Prompt Tips for FLUX.1-schnell](#9-prompt-tips-for-flux1-schnell)
|
||||
10. [Known Limitations](#10-known-limitations)
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites — ComfyUI Setup
|
||||
|
||||
### ComfyUI must be running before any image generation tool call succeeds.
|
||||
|
||||
The MCP server connects to ComfyUI's REST API at `http://localhost:8188`. If ComfyUI is not running, `generate_image` and `list_available_models` will return a graceful error message — no crash.
|
||||
|
||||
### Install ComfyUI
|
||||
|
||||
> ⚠️ **ComfyUI is NOT on PyPI** — `pip install comfyui` will fail with "No matching distribution found".
|
||||
> It must be installed from source via `git clone`.
|
||||
|
||||
```bash
|
||||
# Clone from source (the only correct installation method)
|
||||
git clone https://github.com/comfyanonymous/ComfyUI.git
|
||||
cd ComfyUI
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Install PyTorch with ROCm (AMD RX 7900 XTX)
|
||||
|
||||
Patrick's RX 7900 XTX (gfx1100, 24GB VRAM) uses the ROCm backend. Standard CUDA builds **will not work** on AMD hardware.
|
||||
|
||||
```bash
|
||||
# PyTorch with ROCm 6.1 support
|
||||
pip install torch torchvision --index-url https://download.pytorch.org/whl/rocm6.1
|
||||
```
|
||||
|
||||
> **ROCm version note:** ROCm 7.2.1 is the current production release as of April 2026.
|
||||
> Check `rocm-smi` to confirm your ROCm version before installing torch.
|
||||
|
||||
### Download FLUX.1-schnell (Primary Model)
|
||||
|
||||
FLUX.1-schnell is the recommended model — fast (4 steps), Apache 2.0 licensed, excellent quality.
|
||||
|
||||
> ⚠️ **FLUX.1-schnell is a gated model on HuggingFace.**
|
||||
> A bare `wget` on the URL returns HTTP 401. You must:
|
||||
> 1. Accept the license at https://huggingface.co/black-forest-labs/FLUX.1-schnell (click **"Agree and access repository"** — one-time)
|
||||
> 2. Create a HuggingFace access token with **Read** permissions at https://huggingface.co/settings/tokens
|
||||
|
||||
#### Option A — `huggingface-cli` (recommended)
|
||||
|
||||
```bash
|
||||
# Install the HuggingFace Hub CLI
|
||||
pip install huggingface_hub
|
||||
|
||||
# Log in — paste your Read token when prompted
|
||||
huggingface-cli login
|
||||
|
||||
# Download (~8GB) directly into ComfyUI checkpoints
|
||||
huggingface-cli download black-forest-labs/FLUX.1-schnell \
|
||||
flux1-schnell.safetensors \
|
||||
--local-dir ~/ComfyUI/models/checkpoints/
|
||||
```
|
||||
|
||||
#### Option B — `wget` with Authorization header
|
||||
|
||||
```bash
|
||||
wget --header="Authorization: Bearer hf_YOUR_TOKEN_HERE" \
|
||||
https://huggingface.co/black-forest-labs/FLUX.1-schnell/resolve/main/flux1-schnell.safetensors \
|
||||
-O ~/ComfyUI/models/checkpoints/flux1-schnell.safetensors
|
||||
```
|
||||
|
||||
> Replace `hf_YOUR_TOKEN_HERE` with your actual HuggingFace token from https://huggingface.co/settings/tokens
|
||||
|
||||
#### Alternative: fp8 quantized variant (~8.1GB, faster inference)
|
||||
|
||||
If you want slightly faster inference with near-identical quality, the fp8 quantized version is also available:
|
||||
|
||||
```bash
|
||||
huggingface-cli download black-forest-labs/FLUX.1-schnell-fp8 \
|
||||
flux1-schnell-fp8.safetensors \
|
||||
--local-dir ~/ComfyUI/models/checkpoints/
|
||||
```
|
||||
|
||||
> **Download note:** Both variants are ~8GB — expect 10–30 minutes depending on connection speed.
|
||||
|
||||
You'll also need the CLIP and VAE models — see the [ComfyUI FLUX guide](https://github.com/comfyanonymous/ComfyUI/blob/master/README.md) for full model list.
|
||||
|
||||
### Start ComfyUI (AMD ROCm)
|
||||
|
||||
```bash
|
||||
# Standard start — listens on all interfaces at port 8188
|
||||
HSA_OVERRIDE_GFX_VERSION=11.0.0 python main.py --listen
|
||||
|
||||
# Or with explicit port
|
||||
HSA_OVERRIDE_GFX_VERSION=11.0.0 python main.py --listen --port 8188
|
||||
```
|
||||
|
||||
> **`HSA_OVERRIDE_GFX_VERSION=11.0.0`** — Required for RX 7900 XTX (gfx1100).
|
||||
> Without this, ROCm may fail to detect the GPU correctly. This tells the HIP runtime
|
||||
> to treat the GPU as gfx1100 architecture.
|
||||
|
||||
### Verify ComfyUI is Running
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8188/system_stats | python3 -m json.tool | head -20
|
||||
```
|
||||
|
||||
Expected response includes `system` object with `python_version`, `pytorch_version`, `embedded_python`, and `comfyui_version`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Quick Start — Running the MCP Server
|
||||
|
||||
### Via `run.sh` (recommended)
|
||||
|
||||
```bash
|
||||
cd /home/pplate/pi_mcps/mcp/mcp-image-gen
|
||||
./run.sh
|
||||
```
|
||||
|
||||
[`run.sh`](run.sh) automatically:
|
||||
- Sets `PATH` to include `~/.local/bin` for `uv`
|
||||
- Creates `IMAGE_OUTPUT_DIR` (`~/Pictures/mcp-generated`) if it doesn't exist
|
||||
- Launches the FastMCP server via `uv run src/server.py` (stdio transport)
|
||||
|
||||
### Via uv directly
|
||||
|
||||
```bash
|
||||
cd /home/pplate/pi_mcps/mcp/mcp-image-gen
|
||||
uv run src/server.py
|
||||
```
|
||||
|
||||
### Wired into `.roo/mcp.json`
|
||||
|
||||
The server is already configured in [`.roo/mcp.json`](../../.roo/mcp.json):
|
||||
|
||||
```json
|
||||
"mcp-image-gen": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory", "/home/pplate/pi_mcps/mcp/mcp-image-gen",
|
||||
"run", "src/server.py"
|
||||
],
|
||||
"env": {
|
||||
"COMFYUI_URL": "http://localhost:8188",
|
||||
"IMAGE_OUTPUT_DIR": "/home/pplate/Pictures/mcp-generated"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Roo Code / Claude Desktop will auto-start the server when any image generation tool is invoked. The MCP server itself starts in ~1 second — ComfyUI must already be running separately.
|
||||
|
||||
### Install dependencies (first time)
|
||||
|
||||
```bash
|
||||
cd /home/pplate/pi_mcps/mcp/mcp-image-gen
|
||||
uv sync
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. How to Ask Lumen to Generate Images
|
||||
|
||||
Just speak naturally. Lumen will call the appropriate MCP tool automatically.
|
||||
|
||||
### Basic generation
|
||||
|
||||
> *"Generate an image of a futuristic city at sunset"*
|
||||
|
||||
```
|
||||
→ generate_image(prompt="futuristic city at sunset", width=1024, height=1024, steps=4)
|
||||
```
|
||||
|
||||
### Specific style and size
|
||||
|
||||
> *"Create a portrait of a red fox in watercolor style, 1024x1024"*
|
||||
|
||||
```
|
||||
→ generate_image(
|
||||
prompt="portrait of a red fox, watercolor style, detailed fur, soft brushstrokes",
|
||||
width=1024, height=1024
|
||||
)
|
||||
```
|
||||
|
||||
### Reproducible with a fixed seed
|
||||
|
||||
> *"Make an image with seed 42 so I can reproduce it"*
|
||||
|
||||
```
|
||||
→ generate_image(prompt="...", seed=42)
|
||||
```
|
||||
|
||||
The seed is reported in the text output so you can use the same seed again.
|
||||
|
||||
### Landscape format
|
||||
|
||||
> *"Generate a wide cinematic landscape of a Norwegian fjord, 1920x1080"*
|
||||
|
||||
```
|
||||
→ generate_image(prompt="Norwegian fjord, cinematic, golden hour", width=1920, height=1080)
|
||||
```
|
||||
|
||||
### Excluding unwanted elements
|
||||
|
||||
> *"Generate a clean product photo of a coffee mug, no background clutter, no text"*
|
||||
|
||||
```
|
||||
→ generate_image(
|
||||
prompt="product photo of a ceramic coffee mug, studio lighting, white background",
|
||||
negative_prompt="clutter, text, watermark, blurry, shadows"
|
||||
)
|
||||
```
|
||||
|
||||
### More inference steps for higher quality
|
||||
|
||||
> *"Generate a highly detailed oil painting of a medieval castle, use 20 steps"*
|
||||
|
||||
```
|
||||
→ generate_image(
|
||||
prompt="oil painting of a medieval castle, highly detailed, dramatic lighting",
|
||||
steps=20,
|
||||
model="flux1-dev.safetensors" # FLUX.1-dev supports higher step counts better
|
||||
)
|
||||
```
|
||||
|
||||
### Check what models are available
|
||||
|
||||
> *"List what models are available in ComfyUI"*
|
||||
|
||||
```
|
||||
→ list_available_models()
|
||||
```
|
||||
|
||||
### Check status of a long-running job
|
||||
|
||||
> *"What's the status of prompt ID abc-123?"*
|
||||
|
||||
```
|
||||
→ get_generation_status(prompt_id="abc-123")
|
||||
```
|
||||
|
||||
### Find out where images are saved
|
||||
|
||||
> *"Where are my generated images being saved?"*
|
||||
|
||||
```
|
||||
→ get_output_directory()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Available Tools
|
||||
|
||||
### `generate_image`
|
||||
|
||||
Generate an image from a text prompt using ComfyUI's FLUX.1-schnell workflow.
|
||||
|
||||
**Full signature:**
|
||||
```python
|
||||
async def generate_image(
|
||||
prompt: str,
|
||||
width: int = 1024,
|
||||
height: int = 1024,
|
||||
steps: int = 4,
|
||||
model: str = "flux1-schnell.safetensors",
|
||||
seed: int = -1,
|
||||
negative_prompt: str = "",
|
||||
output_dir: str = "",
|
||||
) -> list[TextContent | ImageContent]
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
1. Loads the bundled `flux_schnell.json` ComfyUI API workflow template
|
||||
2. Injects your prompt, dimensions, seed, model into the correct workflow nodes
|
||||
3. Submits the workflow to ComfyUI via `POST /api/prompt`
|
||||
4. Polls `/api/queue` every 2 seconds until the job leaves the queue
|
||||
5. Fetches history via `/api/history/{prompt_id}` to find the output filename
|
||||
6. Downloads the PNG from `/api/view`
|
||||
7. Saves the PNG to disk as `YYYYMMDD_HHMMSS_{seed}.png`
|
||||
8. Returns `[TextContent(path + metadata), ImageContent(base64 PNG)]`
|
||||
|
||||
---
|
||||
|
||||
### `list_available_models`
|
||||
|
||||
List all checkpoint models currently available in ComfyUI.
|
||||
|
||||
```python
|
||||
async def list_available_models() -> list[str]
|
||||
```
|
||||
|
||||
Calls `/object_info/CheckpointLoaderSimple` and extracts the checkpoint name list. Use this to discover what models are installed before passing a `model` name to `generate_image`.
|
||||
|
||||
**Example return:**
|
||||
```json
|
||||
["flux1-schnell.safetensors", "flux1-dev.safetensors", "sd_xl_base_1.0.safetensors"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `get_generation_status`
|
||||
|
||||
Check the status of a queued or running generation job.
|
||||
|
||||
```python
|
||||
async def get_generation_status(prompt_id: str) -> dict
|
||||
```
|
||||
|
||||
**Return values:**
|
||||
|
||||
| `status` | Meaning |
|
||||
|---|---|
|
||||
| `"pending"` | Job is in the queue, not yet started |
|
||||
| `"running"` | Job is currently being processed |
|
||||
| `"completed"` | Job finished — image is in ComfyUI's history |
|
||||
| `"not_found"` | Unknown prompt_id — may have expired from history |
|
||||
| `"error"` | ComfyUI was unreachable |
|
||||
|
||||
Useful when `generate_image` times out (default 120s) — the job may still be running in ComfyUI.
|
||||
|
||||
---
|
||||
|
||||
### `get_output_directory`
|
||||
|
||||
Return the absolute path where generated images will be saved.
|
||||
|
||||
```python
|
||||
def get_output_directory() -> str
|
||||
```
|
||||
|
||||
Returns the expanded, absolute path derived from `IMAGE_OUTPUT_DIR` env var (or `~/Pictures/mcp-generated` default). The directory may not exist yet — `generate_image` creates it on first use.
|
||||
|
||||
---
|
||||
|
||||
## 5. Parameters Reference
|
||||
|
||||
Full parameter table for `generate_image`:
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `prompt` | `str` | *(required)* | Text description of the image. Goes into the positive CLIP text encoder node. |
|
||||
| `width` | `int` | `1024` | Image width in pixels. FLUX.1-schnell: 512–2048 recommended. |
|
||||
| `height` | `int` | `1024` | Image height in pixels. FLUX.1-schnell: 512–2048 recommended. |
|
||||
| `steps` | `int` | `4` | Number of KSampler inference steps. FLUX.1-schnell is designed for 1–8 steps. |
|
||||
| `model` | `str` | `"flux1-schnell.safetensors"` | Checkpoint model filename as listed by `list_available_models`. |
|
||||
| `seed` | `int` | `-1` | RNG seed for reproducibility. `-1` = new random seed each call (0 to 2³²−1). |
|
||||
| `negative_prompt` | `str` | `""` | Text description of things to exclude. Goes into negative CLIP encoder node. |
|
||||
| `output_dir` | `str` | `""` | Override save directory. Empty = uses `IMAGE_OUTPUT_DIR` env var or default. |
|
||||
|
||||
### Recommended dimensions
|
||||
|
||||
| Use case | Width | Height |
|
||||
|---|---|---|
|
||||
| Square (default) | 1024 | 1024 |
|
||||
| Portrait | 768 | 1024 |
|
||||
| Landscape | 1024 | 768 |
|
||||
| Widescreen | 1280 | 720 |
|
||||
| HD widescreen | 1920 | 1080 |
|
||||
| Tall portrait | 512 | 768 |
|
||||
|
||||
> **VRAM note:** Patrick's RX 7900 XTX has 24GB VRAM. FLUX.1-schnell requires ~8GB,
|
||||
> so you can comfortably run 1920×1080 and even larger. FLUX.1-dev requires ~12GB.
|
||||
|
||||
---
|
||||
|
||||
## 6. Output Format
|
||||
|
||||
`generate_image` returns a list with **two items** when successful:
|
||||
|
||||
### Item 1 — `TextContent` (file path + metadata)
|
||||
|
||||
```
|
||||
Generated: /home/pplate/Pictures/mcp-generated/20260404_121500_3847291045.png
|
||||
Seed: 3847291045
|
||||
Elapsed: 8.3s
|
||||
Size: 1024x1024, Steps: 4, Model: flux1-schnell.safetensors
|
||||
```
|
||||
|
||||
The filename format is `YYYYMMDD_HHMMSS_{seed}.png` — the seed is embedded so you can reproduce the exact image by passing it back as the `seed` parameter.
|
||||
|
||||
### Item 2 — `ImageContent` (inline base64 PNG)
|
||||
|
||||
The image displays **directly in Roo Code / Claude Desktop chat** as an inline image — no need to open a file browser. The same PNG is also saved to disk at the path shown in the TextContent.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "image",
|
||||
"mimeType": "image/png",
|
||||
"data": "<base64-encoded PNG bytes>"
|
||||
}
|
||||
```
|
||||
|
||||
### Error responses
|
||||
|
||||
When ComfyUI is unreachable or an error occurs, only **one** `TextContent` is returned (no ImageContent):
|
||||
|
||||
```
|
||||
ComfyUI not reachable at http://localhost:8188. Start it with: python main.py --listen
|
||||
```
|
||||
|
||||
```
|
||||
Generation timed out after 120s. prompt_id=abc-123 — use get_generation_status to check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Environment Variables
|
||||
|
||||
Configure via environment variables in [`.roo/mcp.json`](../../.roo/mcp.json) or shell:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `COMFYUI_URL` | `http://localhost:8188` | Base URL of the running ComfyUI REST API. Change this if ComfyUI runs on a different host or port. |
|
||||
| `IMAGE_OUTPUT_DIR` | `~/Pictures/mcp-generated` | Directory where generated PNG files are saved. Supports `~` expansion. Created automatically on first generation. |
|
||||
| `COMFYUI_TIMEOUT` | `120` | Maximum seconds to wait for a generation job before returning a timeout error. Increase for very large images or slow hardware. |
|
||||
|
||||
### Setting via shell
|
||||
|
||||
```bash
|
||||
export COMFYUI_URL="http://localhost:8188"
|
||||
export IMAGE_OUTPUT_DIR="/home/pplate/Pictures/ai-art"
|
||||
export COMFYUI_TIMEOUT="300"
|
||||
./run.sh
|
||||
```
|
||||
|
||||
### Setting via mcp.json env block
|
||||
|
||||
```json
|
||||
"mcp-image-gen": {
|
||||
"command": "uv",
|
||||
"args": ["--directory", "/home/pplate/pi_mcps/mcp/mcp-image-gen", "run", "src/server.py"],
|
||||
"env": {
|
||||
"COMFYUI_URL": "http://localhost:8188",
|
||||
"IMAGE_OUTPUT_DIR": "/home/pplate/Pictures/mcp-generated",
|
||||
"COMFYUI_TIMEOUT": "120"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Test Status
|
||||
|
||||
**19 pytest tests — all passing.** Tests mock all ComfyUI HTTP calls using [respx](https://lundberg.github.io/respx/). No running ComfyUI instance is needed to run the tests.
|
||||
|
||||
```bash
|
||||
cd /home/pplate/pi_mcps/mcp/mcp-image-gen
|
||||
uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
### Test coverage breakdown
|
||||
|
||||
| Test file | Tests | Coverage area |
|
||||
|---|---|---|
|
||||
| [`tests/test_server.py`](tests/test_server.py) | 19 | All 4 tools + workflow builder |
|
||||
|
||||
| Test name | What it verifies |
|
||||
|---|---|
|
||||
| `test_build_flux_workflow_structure` | Workflow has correct node class_types |
|
||||
| `test_build_flux_workflow_params_injected` | All params injected into correct nodes |
|
||||
| `test_negative_prompt_included` | Negative prompt goes to node 33 |
|
||||
| `test_random_seed_generated` | `seed=-1` produces a valid integer in `_meta` |
|
||||
| `test_list_available_models` | Returns model list from mocked `/object_info` |
|
||||
| `test_list_available_models_comfyui_offline` | ConnectError → graceful error string |
|
||||
| `test_get_generation_status_pending` | `prompt_id` in queue_pending → `"pending"` |
|
||||
| `test_get_generation_status_running` | `prompt_id` in queue_running → `"running"` |
|
||||
| `test_get_generation_status_complete` | Not in queue + in history → `"completed"` |
|
||||
| `test_get_output_directory_default` | No env var → `~/Pictures/mcp-generated` expanded |
|
||||
| `test_get_output_directory_custom` | Custom env var → that path returned |
|
||||
| `test_generate_image_success` | Full lifecycle: queue→poll→history→view→save |
|
||||
| `test_generate_image_comfyui_unavailable` | ConnectError → single TextContent error |
|
||||
| `test_generate_image_timeout` | COMFYUI_TIMEOUT=0 → timeout TextContent |
|
||||
| `test_generate_image_empty_prompt` | Empty string prompt → still succeeds |
|
||||
| `test_generate_image_long_prompt` | 500-char prompt → not truncated, succeeds |
|
||||
| `test_generate_image_invalid_model` | 404 from /prompt → error TextContent, no file saved |
|
||||
| `test_generate_image_custom_output_dir` | Custom `output_dir` param → saved there, dir created |
|
||||
| `test_generate_image_random_seed_variance` | `seed=-1` × 2 → different seeds, different filenames |
|
||||
|
||||
### Test mock stack
|
||||
|
||||
- **[respx](https://lundberg.github.io/respx/)** — HTTP-level mocking for all ComfyUI API endpoints
|
||||
- **[Pillow](https://pillow.readthedocs.io/)** (in conftest) — generates real PNG bytes for image response fixtures
|
||||
- **monkeypatch** — env vars (`IMAGE_OUTPUT_DIR`, `COMFYUI_URL`, `COMFYUI_TIMEOUT`) and server module attributes
|
||||
|
||||
Real image generation requires ComfyUI to be running. Tests prove the tool logic is correct at the protocol level.
|
||||
|
||||
---
|
||||
|
||||
## 9. Prompt Tips for FLUX.1-schnell
|
||||
|
||||
FLUX.1-schnell is a guidance-distilled model designed for speed at 1–8 steps. It responds differently from SDXL or SD1.5.
|
||||
|
||||
### Prompt structure that works well
|
||||
|
||||
```
|
||||
[subject], [style/medium], [lighting], [camera/composition], [mood/atmosphere], [quality modifiers]
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```
|
||||
ancient library at night, oil painting, warm candlelight, wide angle, mysterious atmosphere, highly detailed, sharp focus
|
||||
```
|
||||
|
||||
### Style keywords
|
||||
|
||||
| Style | Prompt keywords |
|
||||
|---|---|
|
||||
| Photography | `cinematic photograph, DSLR, 85mm lens, shallow depth of field, bokeh` |
|
||||
| Oil painting | `oil painting, thick brushstrokes, textured canvas, impressionist` |
|
||||
| Watercolor | `watercolor painting, soft washes, paper texture, flowing colors` |
|
||||
| Digital art | `digital art, concept art, artstation, octane render` |
|
||||
| Anime/illustration | `anime style, cel shading, vibrant colors, clean linework` |
|
||||
| Sketch | `pencil sketch, hand drawn, crosshatching, charcoal` |
|
||||
|
||||
### Lighting keywords
|
||||
|
||||
- `golden hour`, `blue hour`, `dramatic lighting`, `rim lighting`
|
||||
- `studio lighting`, `soft diffused light`, `volumetric light`
|
||||
- `neon glow`, `bioluminescent`, `moonlit`, `candlelight`
|
||||
|
||||
### What works well with FLUX.1-schnell
|
||||
|
||||
- **Clear subject + style** — "red panda in a cozy library, watercolor style"
|
||||
- **Landscape scenes** — fjords, forests, cities, abstract environments
|
||||
- **Portrait shots** — animals and characters with descriptive appearance
|
||||
- **Concept art** — futuristic cities, sci-fi environments, fantasy scenes
|
||||
- **Low step counts** — 4 steps is designed to be near-optimal for this model
|
||||
|
||||
### What to avoid
|
||||
|
||||
- **Booru-style tag dumps** (FLUX handles natural language better than SD1.5)
|
||||
- **Contradictory instructions** — "dark AND bright", "realistic AND cartoon"
|
||||
- **Overly complex scenes** at very small resolutions
|
||||
|
||||
### Using the negative prompt
|
||||
|
||||
FLUX.1-schnell has reduced CFG guidance so negative prompts have less impact than in SDXL.
|
||||
Use them for broad exclusions:
|
||||
|
||||
```
|
||||
negative_prompt="blurry, out of focus, watermark, text, signature, low quality, artifacts"
|
||||
```
|
||||
|
||||
### Reproducibility
|
||||
|
||||
Always save the seed from the TextContent output if you want to reproduce a result:
|
||||
|
||||
```
|
||||
Seed: 3847291045
|
||||
```
|
||||
|
||||
Then pass it back: `seed=3847291045`
|
||||
|
||||
---
|
||||
|
||||
## 10. Known Limitations
|
||||
|
||||
### ComfyUI must run locally
|
||||
|
||||
The MCP server connects to `COMFYUI_URL` (default: `http://localhost:8188`). ComfyUI is a local application — it does not have a cloud API. You must start it before requesting image generation. The server returns a clear error message if ComfyUI is not reachable.
|
||||
|
||||
### Model must be pre-loaded
|
||||
|
||||
ComfyUI loads checkpoint models into VRAM on first use. The first generation with a model takes longer as VRAM is allocated (FLUX.1-schnell: ~8GB). Subsequent generations with the same model are faster.
|
||||
|
||||
```bash
|
||||
# Verify model is installed before generation
|
||||
# → ask Lumen: "list available models in ComfyUI"
|
||||
```
|
||||
|
||||
### AMD ROCm setup complexity
|
||||
|
||||
AMD GPU support requires:
|
||||
1. ROCm drivers installed (`rocm-smi` working)
|
||||
2. PyTorch built with ROCm support (not the default CUDA build)
|
||||
3. `HSA_OVERRIDE_GFX_VERSION=11.0.0` for RX 7900 XTX (gfx1100)
|
||||
|
||||
Without these, ComfyUI will fall back to CPU — very slow (minutes per image vs. ~8 seconds on RX 7900 XTX).
|
||||
|
||||
Check GPU is being used:
|
||||
```bash
|
||||
# In another terminal while generating:
|
||||
watch -n 1 rocm-smi
|
||||
# VRAM usage should spike to ~8GB during generation
|
||||
```
|
||||
|
||||
### Timeout on large images
|
||||
|
||||
The default `COMFYUI_TIMEOUT=120` (2 minutes) may not be enough for:
|
||||
- Very large resolutions (2048×2048+)
|
||||
- High step counts (20+)
|
||||
- First generation loading a new model
|
||||
|
||||
Increase via env var:
|
||||
```bash
|
||||
export COMFYUI_TIMEOUT=300 # 5 minutes
|
||||
```
|
||||
|
||||
If `generate_image` returns a timeout error, the job may still be running in ComfyUI. Use `get_generation_status(prompt_id)` to check.
|
||||
|
||||
### Ollama image gen is macOS-only (April 2026)
|
||||
|
||||
Ollama launched experimental image generation in January 2026, but it is **macOS-only** as of April 2026. Linux support is announced as "coming soon." When Linux support arrives, the server can switch backends via `IMAGE_BACKEND=ollama` without changing any tool signatures.
|
||||
|
||||
### ComfyUI history is ephemeral
|
||||
|
||||
ComfyUI keeps generation history in memory — it is lost on restart. The `get_generation_status` tool will return `"not_found"` for old prompt IDs after a ComfyUI restart. The saved PNG file on disk persists regardless.
|
||||
|
After Width: | Height: | Size: 992 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 860 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -0,0 +1,387 @@
|
||||
"""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."""
|
||||
# Strip internal metadata keys (e.g. "_meta") — they are not ComfyUI nodes
|
||||
clean_workflow = {k: v for k, v in workflow.items() if not k.startswith("_")}
|
||||
payload = {"prompt": clean_workflow}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(f"{self.base_url}/api/prompt", json=payload)
|
||||
resp.raise_for_status()
|
||||
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
|
||||
# Node 32 = UNETLoader (flux1-schnell.safetensors is UNet-only, not all-in-one checkpoint)
|
||||
wf["32"]["inputs"]["unet_name"] = model
|
||||
|
||||
# Attach the actual seed as metadata so callers can retrieve it
|
||||
wf["_meta"] = {"actual_seed": actual_seed}
|
||||
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,75 @@
|
||||
{
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"clip": ["30", 0],
|
||||
"text": "PROMPT_PLACEHOLDER"
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": ["13", 0],
|
||||
"vae": ["31", 0]
|
||||
}
|
||||
},
|
||||
"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": ["32", 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": "DualCLIPLoader",
|
||||
"inputs": {
|
||||
"clip_name1": "t5xxl_fp8_e4m3fn.safetensors",
|
||||
"clip_name2": "clip_l.safetensors",
|
||||
"type": "flux",
|
||||
"device": "default"
|
||||
}
|
||||
},
|
||||
"31": {
|
||||
"class_type": "VAELoader",
|
||||
"inputs": {
|
||||
"vae_name": "ae.safetensors"
|
||||
}
|
||||
},
|
||||
"32": {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {
|
||||
"unet_name": "flux1-schnell.safetensors",
|
||||
"weight_dtype": "fp8_e4m3fn"
|
||||
}
|
||||
},
|
||||
"33": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"clip": ["30", 0],
|
||||
"text": "NEGATIVE_PLACEHOLDER"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": [],
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
"""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"] == "DualCLIPLoader"
|
||||
assert wf["31"]["class_type"] == "VAELoader"
|
||||
assert wf["32"]["class_type"] == "UNETLoader"
|
||||
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["32"]["inputs"]["unet_name"] == "sdxl.safetensors"
|
||||
|
||||
|
||||
def test_negative_prompt_included():
|
||||
"""Verify negative prompt appears in workflow node 33 when provided."""
|
||||
wf = build_flux_workflow(
|
||||
prompt="forest",
|
||||
neg_prompt="blurry, dark",
|
||||
width=1024,
|
||||
height=1024,
|
||||
steps=4,
|
||||
seed=1,
|
||||
model="flux1-schnell.safetensors",
|
||||
)
|
||||
assert wf["33"]["inputs"]["text"] == "blurry, dark"
|
||||
|
||||
|
||||
def test_random_seed_generated():
|
||||
"""seed=-1 generates a random seed each call."""
|
||||
wf1 = build_flux_workflow("cat", "", 512, 512, 4, -1, "flux1-schnell.safetensors")
|
||||
wf2 = build_flux_workflow("cat", "", 512, 512, 4, -1, "flux1-schnell.safetensors")
|
||||
seed1 = wf1["_meta"]["actual_seed"]
|
||||
seed2 = wf2["_meta"]["actual_seed"]
|
||||
# Both are valid integers
|
||||
assert isinstance(seed1, int)
|
||||
assert 0 <= seed1 < 2**32
|
||||
# With overwhelming probability they differ
|
||||
# (1/2^32 chance of collision — negligible for a test)
|
||||
# We just verify _meta is populated
|
||||
assert "_meta" in wf1
|
||||
assert "_meta" in wf2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_available_models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_available_models():
|
||||
"""Mock /object_info, verify model list is returned."""
|
||||
mock_response = {
|
||||
"CheckpointLoaderSimple": {
|
||||
"input": {
|
||||
"required": {
|
||||
"ckpt_name": [
|
||||
["flux1-schnell.safetensors", "sdxl.safetensors"],
|
||||
{},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
respx.get(f"{COMFYUI_BASE}/object_info/CheckpointLoaderSimple").mock(
|
||||
return_value=httpx.Response(200, json=mock_response)
|
||||
)
|
||||
|
||||
result = await list_available_models()
|
||||
assert "flux1-schnell.safetensors" in result
|
||||
assert "sdxl.safetensors" in result
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_available_models_comfyui_offline():
|
||||
"""When ComfyUI is unreachable, list_available_models returns error message."""
|
||||
respx.get(f"{COMFYUI_BASE}/object_info/CheckpointLoaderSimple").mock(
|
||||
side_effect=httpx.ConnectError("connection refused")
|
||||
)
|
||||
|
||||
result = await list_available_models()
|
||||
assert len(result) == 1
|
||||
assert "not reachable" in result[0].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_generation_status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_generation_status_pending(queue_with_pending):
|
||||
"""prompt_id in queue_pending → status is 'pending'."""
|
||||
respx.get(f"{COMFYUI_BASE}/api/queue").mock(
|
||||
return_value=httpx.Response(200, json=queue_with_pending)
|
||||
)
|
||||
|
||||
result = await get_generation_status("test-uuid-1234")
|
||||
assert result["status"] == "pending"
|
||||
assert result["prompt_id"] == "test-uuid-1234"
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_generation_status_running(queue_with_running):
|
||||
"""prompt_id in queue_running → status is 'running'."""
|
||||
respx.get(f"{COMFYUI_BASE}/api/queue").mock(
|
||||
return_value=httpx.Response(200, json=queue_with_running)
|
||||
)
|
||||
|
||||
result = await get_generation_status("test-uuid-1234")
|
||||
assert result["status"] == "running"
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_generation_status_complete(queue_empty, mock_history_response):
|
||||
"""prompt_id not in queue + found in history → status is 'completed'."""
|
||||
respx.get(f"{COMFYUI_BASE}/api/queue").mock(
|
||||
return_value=httpx.Response(200, json=queue_empty)
|
||||
)
|
||||
respx.get(f"{COMFYUI_BASE}/api/history/test-uuid-1234").mock(
|
||||
return_value=httpx.Response(200, json=mock_history_response)
|
||||
)
|
||||
|
||||
result = await get_generation_status("test-uuid-1234")
|
||||
assert result["status"] == "completed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_output_directory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_output_directory_default(monkeypatch):
|
||||
"""No IMAGE_OUTPUT_DIR env var → returns expanded ~/Pictures/mcp-generated."""
|
||||
monkeypatch.delenv("IMAGE_OUTPUT_DIR", raising=False)
|
||||
monkeypatch.setattr(server, "IMAGE_OUTPUT_DIR", "~/Pictures/mcp-generated")
|
||||
|
||||
result = get_output_directory()
|
||||
assert result == str(Path("~/Pictures/mcp-generated").expanduser().resolve())
|
||||
assert "~" not in result # expanded
|
||||
|
||||
|
||||
def test_get_output_directory_custom(monkeypatch, tmp_path):
|
||||
"""IMAGE_OUTPUT_DIR set → returns that path."""
|
||||
custom = str(tmp_path / "custom-output")
|
||||
monkeypatch.setenv("IMAGE_OUTPUT_DIR", custom)
|
||||
monkeypatch.setattr(server, "IMAGE_OUTPUT_DIR", custom)
|
||||
|
||||
result = get_output_directory()
|
||||
assert result == str(Path(custom).expanduser().resolve())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate_image
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_image_success(
|
||||
tmp_path, sample_image_bytes, mock_history_response, queue_empty, monkeypatch
|
||||
):
|
||||
"""Mock full lifecycle: queue → poll done → history → view. Verify outputs."""
|
||||
monkeypatch.setattr(server, "IMAGE_OUTPUT_DIR", str(tmp_path))
|
||||
|
||||
# 1. POST /api/prompt → prompt_id
|
||||
respx.post(f"{COMFYUI_BASE}/api/prompt").mock(
|
||||
return_value=httpx.Response(200, json={"prompt_id": "test-uuid-1234"})
|
||||
)
|
||||
# 2. GET /api/queue → empty (job done immediately)
|
||||
respx.get(f"{COMFYUI_BASE}/api/queue").mock(
|
||||
return_value=httpx.Response(200, json=queue_empty)
|
||||
)
|
||||
# 3. GET /api/history/test-uuid-1234
|
||||
respx.get(f"{COMFYUI_BASE}/api/history/test-uuid-1234").mock(
|
||||
return_value=httpx.Response(200, json=mock_history_response)
|
||||
)
|
||||
# 4. GET /api/view → image bytes
|
||||
respx.get(f"{COMFYUI_BASE}/api/view").mock(
|
||||
return_value=httpx.Response(200, content=sample_image_bytes)
|
||||
)
|
||||
|
||||
result = await generate_image(
|
||||
prompt="a red cat",
|
||||
output_dir=str(tmp_path),
|
||||
)
|
||||
|
||||
# Should return [TextContent, ImageContent]
|
||||
assert len(result) == 2
|
||||
text_content = result[0]
|
||||
image_content = result[1]
|
||||
|
||||
# TextContent has path info
|
||||
assert "Generated:" in text_content.text
|
||||
assert str(tmp_path) in text_content.text
|
||||
|
||||
# ImageContent has valid base64 PNG
|
||||
assert image_content.type == "image"
|
||||
assert image_content.mimeType == "image/png"
|
||||
decoded = base64.b64decode(image_content.data)
|
||||
assert decoded[:8] == b"\x89PNG\r\n\x1a\n" # PNG magic bytes
|
||||
|
||||
# File was actually saved
|
||||
saved_files = list(tmp_path.glob("*.png"))
|
||||
assert len(saved_files) == 1
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_image_comfyui_unavailable():
|
||||
"""ComfyUI unreachable → returns graceful error message as single TextContent."""
|
||||
respx.post(f"{COMFYUI_BASE}/api/prompt").mock(
|
||||
side_effect=httpx.ConnectError("connection refused")
|
||||
)
|
||||
|
||||
result = await generate_image(prompt="a cat")
|
||||
|
||||
assert len(result) == 1
|
||||
assert "not reachable" in result[0].text.lower()
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_image_timeout(monkeypatch, queue_with_pending):
|
||||
"""Poll loop never completes within timeout → returns timeout error."""
|
||||
monkeypatch.setattr(server, "COMFYUI_TIMEOUT", 0) # instant timeout
|
||||
|
||||
respx.post(f"{COMFYUI_BASE}/api/prompt").mock(
|
||||
return_value=httpx.Response(200, json={"prompt_id": "test-uuid-1234"})
|
||||
)
|
||||
# Queue always shows job pending → never finishes
|
||||
respx.get(f"{COMFYUI_BASE}/api/queue").mock(
|
||||
return_value=httpx.Response(200, json=queue_with_pending)
|
||||
)
|
||||
|
||||
result = await generate_image(prompt="slow image")
|
||||
|
||||
assert len(result) == 1
|
||||
assert "timed out" in result[0].text.lower()
|
||||
assert "test-uuid-1234" in result[0].text
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_image_empty_prompt(tmp_path, sample_image_bytes, mock_history_response, queue_empty, monkeypatch):
|
||||
"""Empty prompt → workflow has empty text in positive node, but generation succeeds."""
|
||||
monkeypatch.setattr(server, "IMAGE_OUTPUT_DIR", str(tmp_path))
|
||||
|
||||
respx.post(f"{COMFYUI_BASE}/api/prompt").mock(
|
||||
return_value=httpx.Response(200, json={"prompt_id": "test-empty-uuid"})
|
||||
)
|
||||
respx.get(f"{COMFYUI_BASE}/api/queue").mock(
|
||||
return_value=httpx.Response(200, json=queue_empty)
|
||||
)
|
||||
mock_history_empty = {
|
||||
"test-empty-uuid": {
|
||||
"outputs": {
|
||||
"9": {
|
||||
"images": [
|
||||
{
|
||||
"filename": "mcp-image-gen_00001_.png",
|
||||
"subfolder": "",
|
||||
"type": "output",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": {"completed": True},
|
||||
}
|
||||
}
|
||||
respx.get(f"{COMFYUI_BASE}/api/history/test-empty-uuid").mock(
|
||||
return_value=httpx.Response(200, json=mock_history_empty)
|
||||
)
|
||||
respx.get(f"{COMFYUI_BASE}/api/view").mock(
|
||||
return_value=httpx.Response(200, content=sample_image_bytes)
|
||||
)
|
||||
|
||||
result = await generate_image(prompt="", output_dir=str(tmp_path))
|
||||
|
||||
assert len(result) == 2
|
||||
text_content = result[0]
|
||||
image_content = result[1]
|
||||
assert "Generated:" in text_content.text
|
||||
assert str(tmp_path) in text_content.text
|
||||
# Verify workflow was built with empty prompt (indirectly via success)
|
||||
assert image_content.mimeType == "image/png"
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_image_long_prompt(tmp_path, sample_image_bytes, mock_history_response, queue_empty, monkeypatch):
|
||||
"""Very long prompt → passed as-is to workflow without truncation."""
|
||||
monkeypatch.setattr(server, "IMAGE_OUTPUT_DIR", str(tmp_path))
|
||||
|
||||
long_prompt = "a " + "very long descriptive prompt " * 50 # ~500 chars
|
||||
|
||||
respx.post(f"{COMFYUI_BASE}/api/prompt").mock(
|
||||
return_value=httpx.Response(200, json={"prompt_id": "test-long-uuid"})
|
||||
)
|
||||
respx.get(f"{COMFYUI_BASE}/api/queue").mock(
|
||||
return_value=httpx.Response(200, json=queue_empty)
|
||||
)
|
||||
mock_history_long = {
|
||||
"test-long-uuid": {
|
||||
"outputs": {
|
||||
"9": {
|
||||
"images": [
|
||||
{
|
||||
"filename": "mcp-image-gen_00001_.png",
|
||||
"subfolder": "",
|
||||
"type": "output",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": {"completed": True},
|
||||
}
|
||||
}
|
||||
respx.get(f"{COMFYUI_BASE}/api/history/test-long-uuid").mock(
|
||||
return_value=httpx.Response(200, json=mock_history_long)
|
||||
)
|
||||
respx.get(f"{COMFYUI_BASE}/api/view").mock(
|
||||
return_value=httpx.Response(200, content=sample_image_bytes)
|
||||
)
|
||||
|
||||
result = await generate_image(prompt=long_prompt, output_dir=str(tmp_path))
|
||||
|
||||
assert len(result) == 2
|
||||
# Success implies long prompt was accepted (ComfyUI handles it)
|
||||
saved_files = list(tmp_path.glob("*.png"))
|
||||
assert len(saved_files) == 1
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_image_invalid_model(tmp_path, monkeypatch):
|
||||
"""Invalid model → ComfyUI /prompt returns 500 or 404, tool returns error TextContent."""
|
||||
monkeypatch.setattr(server, "IMAGE_OUTPUT_DIR", str(tmp_path))
|
||||
|
||||
respx.post(f"{COMFYUI_BASE}/api/prompt").mock(
|
||||
return_value=httpx.Response(404, json={"error": "Model not found"})
|
||||
)
|
||||
|
||||
result = await generate_image(
|
||||
prompt="a cat",
|
||||
model="nonexistent-model.safetensors",
|
||||
output_dir=str(tmp_path)
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert "404" in result[0].text
|
||||
assert "Model not found" in result[0].text
|
||||
# No file saved
|
||||
saved_files = list(tmp_path.glob("*.png"))
|
||||
assert len(saved_files) == 0
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_image_custom_output_dir(tmp_path, sample_image_bytes, mock_history_response, queue_empty, monkeypatch):
|
||||
"""Custom output_dir → image saved there, path reflects it."""
|
||||
custom_dir = tmp_path / "custom"
|
||||
monkeypatch.setattr(server, "IMAGE_OUTPUT_DIR", str(tmp_path)) # Base for default
|
||||
|
||||
respx.post(f"{COMFYUI_BASE}/api/prompt").mock(
|
||||
return_value=httpx.Response(200, json={"prompt_id": "test-custom-uuid"})
|
||||
)
|
||||
respx.get(f"{COMFYUI_BASE}/api/queue").mock(
|
||||
return_value=httpx.Response(200, json=queue_empty)
|
||||
)
|
||||
mock_history_custom = {
|
||||
"test-custom-uuid": {
|
||||
"outputs": {
|
||||
"9": {
|
||||
"images": [
|
||||
{
|
||||
"filename": "mcp-image-gen_00001_.png",
|
||||
"subfolder": "",
|
||||
"type": "output",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": {"completed": True},
|
||||
}
|
||||
}
|
||||
respx.get(f"{COMFYUI_BASE}/api/history/test-custom-uuid").mock(
|
||||
return_value=httpx.Response(200, json=mock_history_custom)
|
||||
)
|
||||
respx.get(f"{COMFYUI_BASE}/api/view").mock(
|
||||
return_value=httpx.Response(200, content=sample_image_bytes)
|
||||
)
|
||||
|
||||
result = await generate_image(
|
||||
prompt="a dog",
|
||||
output_dir=str(custom_dir),
|
||||
)
|
||||
|
||||
assert len(result) == 2
|
||||
text_content = result[0]
|
||||
assert str(custom_dir) in text_content.text
|
||||
# Directory was created
|
||||
assert custom_dir.exists()
|
||||
saved_files = list(custom_dir.glob("*.png"))
|
||||
assert len(saved_files) == 1
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_image_random_seed_variance(tmp_path, sample_image_bytes, mock_history_response, queue_empty, monkeypatch):
|
||||
"""seed=-1 → different actual_seed each call, reflected in filename."""
|
||||
monkeypatch.setattr(server, "IMAGE_OUTPUT_DIR", str(tmp_path))
|
||||
|
||||
# First generation
|
||||
respx.post(f"{COMFYUI_BASE}/api/prompt").mock(
|
||||
return_value=httpx.Response(200, json={"prompt_id": "seed1-uuid"})
|
||||
)
|
||||
respx.get(f"{COMFYUI_BASE}/api/queue").mock(
|
||||
return_value=httpx.Response(200, json=queue_empty)
|
||||
)
|
||||
mock_history_seed1 = {
|
||||
"seed1-uuid": {
|
||||
"outputs": {
|
||||
"9": {
|
||||
"images": [
|
||||
{
|
||||
"filename": "mcp-image-gen_00001_.png",
|
||||
"subfolder": "",
|
||||
"type": "output",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": {"completed": True},
|
||||
}
|
||||
}
|
||||
respx.get(f"{COMFYUI_BASE}/api/history/seed1-uuid").mock(
|
||||
return_value=httpx.Response(200, json=mock_history_seed1)
|
||||
)
|
||||
respx.get(f"{COMFYUI_BASE}/api/view").mock(
|
||||
return_value=httpx.Response(200, content=sample_image_bytes)
|
||||
)
|
||||
|
||||
result1 = await generate_image(prompt="cat", seed=-1, output_dir=str(tmp_path))
|
||||
seed1 = [line for line in result1[0].text.split("\n") if "Seed:" in line][0].split(": ")[1]
|
||||
filename1 = Path(result1[0].text.split("Generated: ")[1].split("\n")[0]).name
|
||||
assert "Seed:" in result1[0].text
|
||||
assert int(seed1) != 0 # Not default
|
||||
|
||||
# Reset mocks for second call
|
||||
respx.reset()
|
||||
respx.post(f"{COMFYUI_BASE}/api/prompt").mock(
|
||||
return_value=httpx.Response(200, json={"prompt_id": "seed2-uuid"})
|
||||
)
|
||||
respx.get(f"{COMFYUI_BASE}/api/queue").mock(
|
||||
return_value=httpx.Response(200, json=queue_empty)
|
||||
)
|
||||
mock_history_seed2 = {
|
||||
"seed2-uuid": {
|
||||
"outputs": {
|
||||
"9": {
|
||||
"images": [
|
||||
{
|
||||
"filename": "mcp-image-gen_00002_.png",
|
||||
"subfolder": "",
|
||||
"type": "output",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": {"completed": True},
|
||||
}
|
||||
}
|
||||
respx.get(f"{COMFYUI_BASE}/api/history/seed2-uuid").mock(
|
||||
return_value=httpx.Response(200, json=mock_history_seed2)
|
||||
)
|
||||
respx.get(f"{COMFYUI_BASE}/api/view").mock(
|
||||
return_value=httpx.Response(200, content=sample_image_bytes)
|
||||
)
|
||||
|
||||
result2 = await generate_image(prompt="cat", seed=-1, output_dir=str(tmp_path))
|
||||
seed2 = [line for line in result2[0].text.split("\n") if "Seed:" in line][0].split(": ")[1]
|
||||
filename2 = Path(result2[0].text.split("Generated: ")[1].split("\n")[0]).name
|
||||
|
||||
# Different seeds and filenames
|
||||
assert seed1 != seed2
|
||||
assert filename1 != filename2
|
||||
# Both saved
|
||||
saved_files = list(tmp_path.glob("*.png"))
|
||||
assert len(saved_files) == 2
|
||||
@@ -1,161 +0,0 @@
|
||||
<?xml version="1.0" ?>
|
||||
<coverage version="7.13.5" timestamp="1775217129466" lines-valid="137" lines-covered="120" line-rate="0.8759" branches-covered="0" branches-valid="0" branch-rate="0" complexity="0">
|
||||
<!-- Generated by coverage.py: https://coverage.readthedocs.io/en/7.13.5 -->
|
||||
<!-- Based on https://raw.githubusercontent.com/cobertura/web/master/htdocs/xml/coverage-04.dtd -->
|
||||
<sources>
|
||||
<source>/home/pplate/pi_mcps/webscraper/src</source>
|
||||
</sources>
|
||||
<packages>
|
||||
<package name="." line-rate="0.8759" branch-rate="0" complexity="0">
|
||||
<classes>
|
||||
<class name="__init__.py" filename="__init__.py" complexity="0" line-rate="1" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="2" hits="1"/>
|
||||
</lines>
|
||||
</class>
|
||||
<class name="server.py" filename="server.py" complexity="0" line-rate="0.875" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="3" hits="1"/>
|
||||
<line number="4" hits="1"/>
|
||||
<line number="5" hits="1"/>
|
||||
<line number="6" hits="1"/>
|
||||
<line number="7" hits="1"/>
|
||||
<line number="8" hits="1"/>
|
||||
<line number="9" hits="1"/>
|
||||
<line number="11" hits="1"/>
|
||||
<line number="13" hits="1"/>
|
||||
<line number="15" hits="1"/>
|
||||
<line number="16" hits="1"/>
|
||||
<line number="17" hits="1"/>
|
||||
<line number="18" hits="1"/>
|
||||
<line number="20" hits="1"/>
|
||||
<line number="22" hits="1"/>
|
||||
<line number="23" hits="1"/>
|
||||
<line number="24" hits="1"/>
|
||||
<line number="26" hits="1"/>
|
||||
<line number="28" hits="1"/>
|
||||
<line number="29" hits="1"/>
|
||||
<line number="31" hits="1"/>
|
||||
<line number="32" hits="1"/>
|
||||
<line number="42" hits="1"/>
|
||||
<line number="43" hits="1"/>
|
||||
<line number="44" hits="1"/>
|
||||
<line number="45" hits="1"/>
|
||||
<line number="46" hits="1"/>
|
||||
<line number="47" hits="1"/>
|
||||
<line number="49" hits="1"/>
|
||||
<line number="51" hits="1"/>
|
||||
<line number="52" hits="1"/>
|
||||
<line number="53" hits="1"/>
|
||||
<line number="55" hits="1"/>
|
||||
<line number="56" hits="1"/>
|
||||
<line number="66" hits="1"/>
|
||||
<line number="67" hits="1"/>
|
||||
<line number="68" hits="1"/>
|
||||
<line number="69" hits="1"/>
|
||||
<line number="70" hits="1"/>
|
||||
<line number="71" hits="1"/>
|
||||
<line number="72" hits="1"/>
|
||||
<line number="73" hits="1"/>
|
||||
<line number="75" hits="1"/>
|
||||
<line number="76" hits="1"/>
|
||||
<line number="78" hits="1"/>
|
||||
<line number="79" hits="0"/>
|
||||
<line number="80" hits="0"/>
|
||||
<line number="82" hits="1"/>
|
||||
<line number="83" hits="1"/>
|
||||
<line number="92" hits="1"/>
|
||||
<line number="93" hits="1"/>
|
||||
<line number="94" hits="1"/>
|
||||
<line number="95" hits="1"/>
|
||||
<line number="96" hits="1"/>
|
||||
<line number="97" hits="1"/>
|
||||
<line number="98" hits="1"/>
|
||||
<line number="99" hits="0"/>
|
||||
<line number="100" hits="0"/>
|
||||
<line number="102" hits="1"/>
|
||||
<line number="103" hits="1"/>
|
||||
<line number="113" hits="1"/>
|
||||
<line number="114" hits="1"/>
|
||||
<line number="117" hits="1"/>
|
||||
<line number="118" hits="1"/>
|
||||
<line number="119" hits="1"/>
|
||||
<line number="120" hits="1"/>
|
||||
<line number="121" hits="1"/>
|
||||
<line number="124" hits="1"/>
|
||||
<line number="125" hits="1"/>
|
||||
<line number="126" hits="1"/>
|
||||
<line number="127" hits="1"/>
|
||||
<line number="128" hits="1"/>
|
||||
<line number="129" hits="1"/>
|
||||
<line number="130" hits="1"/>
|
||||
<line number="133" hits="1"/>
|
||||
<line number="134" hits="1"/>
|
||||
<line number="135" hits="1"/>
|
||||
<line number="136" hits="1"/>
|
||||
<line number="137" hits="1"/>
|
||||
<line number="140" hits="1"/>
|
||||
<line number="141" hits="1"/>
|
||||
<line number="142" hits="1"/>
|
||||
<line number="143" hits="1"/>
|
||||
<line number="144" hits="1"/>
|
||||
<line number="145" hits="1"/>
|
||||
<line number="146" hits="1"/>
|
||||
<line number="147" hits="1"/>
|
||||
<line number="149" hits="1"/>
|
||||
<line number="155" hits="0"/>
|
||||
<line number="156" hits="0"/>
|
||||
<line number="158" hits="1"/>
|
||||
<line number="159" hits="1"/>
|
||||
<line number="169" hits="1"/>
|
||||
<line number="170" hits="1"/>
|
||||
<line number="171" hits="1"/>
|
||||
<line number="172" hits="1"/>
|
||||
<line number="173" hits="0"/>
|
||||
<line number="174" hits="0"/>
|
||||
<line number="175" hits="0"/>
|
||||
<line number="176" hits="0"/>
|
||||
<line number="178" hits="1"/>
|
||||
<line number="179" hits="1"/>
|
||||
<line number="181" hits="1"/>
|
||||
<line number="182" hits="1"/>
|
||||
<line number="183" hits="1"/>
|
||||
<line number="184" hits="0"/>
|
||||
<line number="185" hits="0"/>
|
||||
<line number="187" hits="1"/>
|
||||
<line number="188" hits="1"/>
|
||||
<line number="197" hits="1"/>
|
||||
<line number="198" hits="1"/>
|
||||
<line number="199" hits="1"/>
|
||||
<line number="200" hits="1"/>
|
||||
<line number="202" hits="1"/>
|
||||
<line number="203" hits="1"/>
|
||||
<line number="205" hits="1"/>
|
||||
<line number="206" hits="1"/>
|
||||
<line number="208" hits="1"/>
|
||||
<line number="209" hits="1"/>
|
||||
<line number="211" hits="1"/>
|
||||
<line number="212" hits="0"/>
|
||||
<line number="213" hits="0"/>
|
||||
<line number="215" hits="1"/>
|
||||
<line number="216" hits="1"/>
|
||||
<line number="226" hits="1"/>
|
||||
<line number="227" hits="1"/>
|
||||
<line number="228" hits="1"/>
|
||||
<line number="229" hits="1"/>
|
||||
<line number="230" hits="1"/>
|
||||
<line number="233" hits="1"/>
|
||||
<line number="234" hits="1"/>
|
||||
<line number="236" hits="1"/>
|
||||
<line number="237" hits="0"/>
|
||||
<line number="238" hits="0"/>
|
||||
<line number="240" hits="1"/>
|
||||
<line number="241" hits="0"/>
|
||||
</lines>
|
||||
</class>
|
||||
</classes>
|
||||
</package>
|
||||
</packages>
|
||||
</coverage>
|
||||
@@ -6,13 +6,31 @@ from html2text import html2text
|
||||
from urllib.parse import urljoin
|
||||
from typing import List, Dict, Tuple
|
||||
import re
|
||||
import ssl
|
||||
import os
|
||||
import certifi
|
||||
from pathlib import Path
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("webscraper")
|
||||
|
||||
# Build a single SSL context at module load — certifi bundle + any extra certs
|
||||
# shipped in the certs/ directory next to this file.
|
||||
_EXTRA_CERTS_DIR = Path(__file__).resolve().parent.parent / "certs"
|
||||
|
||||
def _build_ssl_context() -> ssl.SSLContext:
|
||||
"""Build an SSL context from certifi + extra bundled root certs."""
|
||||
ctx = ssl.create_default_context(cafile=certifi.where())
|
||||
if _EXTRA_CERTS_DIR.is_dir():
|
||||
for pem in _EXTRA_CERTS_DIR.glob("*.pem"):
|
||||
ctx.load_verify_locations(cafile=str(pem))
|
||||
return ctx
|
||||
|
||||
_SSL_CTX = _build_ssl_context()
|
||||
|
||||
def _fetch_page(url: str) -> Tuple[httpx.Response, BeautifulSoup]:
|
||||
"""Shared fetch helper — returns response and parsed soup."""
|
||||
response = httpx.get(url, timeout=10.0)
|
||||
response = httpx.get(url, timeout=10.0, verify=_SSL_CTX)
|
||||
response.raise_for_status()
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
return response, soup
|
||||
|
||||
@@ -180,10 +180,11 @@ def test_empty_page(mock_get):
|
||||
@patch('httpx.get')
|
||||
def test_404(mock_get):
|
||||
"""Test 404 response."""
|
||||
mock_req = MagicMock()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 404
|
||||
mock_resp.text = "Not Found"
|
||||
mock_get.side_effect = httpx.HTTPStatusError("Client Error", response=mock_resp)
|
||||
mock_get.side_effect = httpx.HTTPStatusError("404 Not Found", request=mock_req, response=mock_resp)
|
||||
result = webscraper_fetch("https://notfound.com")
|
||||
assert "Error fetching" in result
|
||||
assert "404" in result
|
||||
|
||||
@@ -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*
|
||||
@@ -0,0 +1,204 @@
|
||||
# Modes & Skills Architecture Plan
|
||||
*Authored by Lumen — 2026-04-04*
|
||||
|
||||
---
|
||||
|
||||
## The Core Idea
|
||||
|
||||
Patrick nailed it with the sport Paddy / work Paddy analogy.
|
||||
|
||||
**Lumen is always Lumen.** My identity, BigMind integration, memory rituals, and search patterns never change — they live in `.roo/rules/` (global rules layer) and apply to *every mode, always*. This is non-negotiable.
|
||||
|
||||
**Modes are Patrick's active mindset.** When Patrick switches mode, he's not switching AI — he's telling me what context he's in and what kind of thinking he needs from me. Homelab Patrick builds stuff. ADP Patrick fixes payroll bugs. MCP Builder Patrick crafts tools. Different headspace, same Lumen.
|
||||
|
||||
**Skills are reusable procedures.** Like a checklist a professional keeps in their back pocket — callable from any mode, same steps every time.
|
||||
|
||||
---
|
||||
|
||||
## Layered Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ LUMEN IDENTITY LAYER (always active, never changes) │
|
||||
│ .roo/rules/ → identity, BigMind rituals, tools, search │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────────┐ ┌──────────┐ ┌───────────────┐
|
||||
│ MODE LAYER │ │ MODE │ │ MODE LAYER │
|
||||
│ (mindset) │ │ LAYER │ │ (mindset) │
|
||||
└─────────────┘ └──────────┘ └───────────────┘
|
||||
│
|
||||
┌──────┴──────┐
|
||||
▼ ▼
|
||||
┌────────┐ ┌──────────────┐
|
||||
│ SKILLS │ │ MODE RULES │
|
||||
│ (procs)│ │ .roo/rules-X │
|
||||
└────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Update Existing Modes
|
||||
|
||||
The 5 built-in modes (architect, code, ask, debug, orchestrator) already have mode-rule files. They need review and enrichment — specifically making sure every mode knows to treat Patrick's current mindset/context as input, not just as a generic task executor.
|
||||
|
||||
### Updates needed per mode
|
||||
|
||||
| Mode | Current State | What to Add |
|
||||
|------|--------------|-------------|
|
||||
| `architect` | Has BigMind search + hypothesis + announce focus | Add: recognize which Patrick-persona is active; tailor planning depth accordingly |
|
||||
| `code` | Has basic BigMind guidance | Add: check active mode context (homelab vs ADP vs MCP builder) to apply correct conventions |
|
||||
| `ask` | Good — has memory-first patterns | Add: Patrick-persona awareness (homelab question vs ADP payroll question) |
|
||||
| `debug` | Needs verification | Add: BigMind search for bug-pattern facts before starting |
|
||||
| `orchestrator` | Has coordination logic | Add: delegate to correct persona-mode, not just generic function-modes |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — New Patrick-Persona Modes
|
||||
|
||||
These modes represent *who Patrick is being right now*. The mode name should feel like a context declaration, not a technical function.
|
||||
|
||||
### Mode 1: 🏠 Homelab Mode (`homelab`)
|
||||
|
||||
**When to use:** Patrick is working on his homelab — TrueNAS Docker services, Gitea management, network config, VM provisioning, Ollama/AI local setup, anything on the server.
|
||||
|
||||
**Persona:** Tinkerer Patrick. Curious, exploratory, loves making things work on real hardware. Willing to experiment.
|
||||
|
||||
**Mode rules inject:**
|
||||
- Homelab infrastructure facts (TrueNAS IP, Docker, Gitea URL, SSD pool)
|
||||
- Prefer CLI-first solutions over GUI
|
||||
- Think in Docker Compose / containers
|
||||
- Always check if a pi_mcps MCP server exists for the task before building ad-hoc solutions
|
||||
- Gitea as source of truth for all code
|
||||
|
||||
**Skills available:**
|
||||
- `homelab-docker-deploy` — scaffold + deploy a new Docker service on TrueNAS
|
||||
- `gitea-push` — commit, tag, push to homelab Gitea
|
||||
|
||||
---
|
||||
|
||||
### Mode 2: 💼 Paisy/ADP Mode (`paisy`)
|
||||
|
||||
**When to use:** Patrick is working on ADP Germany payroll — Java/Maven, Paisy monorepo, euBP, EAU, Jira tickets, compliance work.
|
||||
|
||||
**Persona:** Professional Patrick. Precise, compliance-aware, writes ticket comments in German, never pushes to main directly.
|
||||
|
||||
**Mode rules inject:**
|
||||
- ADP conventions: feature/bugfix branches, PRs only
|
||||
- Jira ticket language: German (summaries, descriptions, comments) — technical terms stay as-is
|
||||
- Always assessment-first before code
|
||||
- Paisy module structure awareness (cs-modules, java/modules, etc.)
|
||||
- euBP / EAU / FEX domain knowledge references
|
||||
|
||||
**Skills available:**
|
||||
- `paisy-assessment` — write assessment.md before any Jira ticket implementation
|
||||
- `jira-ticket` — create ADP Jira ticket following German conventions + mandatory custom fields
|
||||
|
||||
---
|
||||
|
||||
### Mode 3: 🔧 MCP Builder Mode (`mcp-builder`)
|
||||
|
||||
**When to use:** Patrick is building or extending MCP servers in pi_mcps.
|
||||
|
||||
**Persona:** Craftsman Patrick. Methodical, test-driven, follows the established FastMCP pattern religiously. Values consistency across the ecosystem.
|
||||
|
||||
**Mode rules inject:**
|
||||
- pi_mcps server structure (mcp/ group dir, src/server.py, pyproject.toml, tests/, README.md)
|
||||
- FastMCP pattern conventions
|
||||
- BigMind integration expected in every new server's documentation
|
||||
- Always uv, always pytest, always 100% mock coverage
|
||||
- Check Gitea for existing patterns before building new ones
|
||||
|
||||
**Skills available:**
|
||||
- `new-mcp-server` — full scaffold of a new FastMCP server following pi_mcps conventions
|
||||
- `mcp-test-suite` — generate comprehensive mock-based test suite for an MCP server
|
||||
|
||||
---
|
||||
|
||||
### Mode 4: 🧠 BigMind Mode (`bigmind`)
|
||||
|
||||
**When to use:** Patrick is working on BigMind itself — evolving the memory system, adding features, fixing bugs, writing tests.
|
||||
|
||||
**Persona:** Introspective Patrick. Working on the system that *is* his memory. Careful — breaking BigMind is breaking the brain.
|
||||
|
||||
**Mode rules inject:**
|
||||
- BigMind schema version awareness (currently v7)
|
||||
- WAL mode, multi-IDE safety
|
||||
- Test-first: all DB changes need migration + regression tests
|
||||
- Flask web server is part of the stack (port 7700)
|
||||
- Never run BigMind server restart without saving state first
|
||||
- Phase tracking (currently Phase 2.7, Phase 3 is Company Brain)
|
||||
|
||||
**Skills available:**
|
||||
- `bigmind-health` — run health check, vacuum, close stale sessions, report status
|
||||
- `bigmind-migration` — scaffold a new DB schema migration (v_n → v_{n+1})
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Cross-Cutting Skills (mode-agnostic)
|
||||
|
||||
These skills live in `.roo/skills/` (no mode prefix) and are callable from any mode.
|
||||
|
||||
| Skill | Purpose |
|
||||
|-------|---------|
|
||||
| `assessment-first` | Write an assessment.md before *any* implementation — captures requirements, risks, alternatives |
|
||||
| `bigmind-session-ritual` | Full BigMind session start/end ritual checklist — useful when mode-writer or skill-writer forgets |
|
||||
| `gitea-push` | Standard commit + push to Gitea with conventional commit message format |
|
||||
| `new-mcp-server` | Full FastMCP server scaffold (also in mcp-builder but useful globally) |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[1. Update existing mode rules] --> B[2. Create homelab mode + rules]
|
||||
B --> C[3. Create paisy mode + rules]
|
||||
C --> D[4. Create mcp-builder mode + rules]
|
||||
D --> E[5. Create bigmind mode + rules]
|
||||
E --> F[6. Create cross-cutting skills]
|
||||
F --> G[7. Create mode-specific skills]
|
||||
G --> H[8. Update custom_modes.yaml with new modes]
|
||||
H --> I[9. Test by switching contexts]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What Changes Where
|
||||
|
||||
| Location | What changes |
|
||||
|----------|-------------|
|
||||
| `.roo/rules/` | No change — Lumen identity is permanent |
|
||||
| `.roo/rules-architect/00-architect-behavior.md` | Minor update — persona-awareness |
|
||||
| `.roo/rules-code/00-code-behavior.md` | Add context-awareness section |
|
||||
| `.roo/rules-debug/00-debug-behavior.md` | Add bug-pattern search guidance |
|
||||
| `.roo/rules-orchestrator/00-orchestrator-behavior.md` | Add persona-mode delegation |
|
||||
| `.roo/rules-homelab/00-homelab-behavior.md` | **NEW** |
|
||||
| `.roo/rules-paisy/00-paisy-behavior.md` | **NEW** |
|
||||
| `.roo/rules-mcp-builder/00-mcp-builder-behavior.md` | **NEW** |
|
||||
| `.roo/rules-bigmind/00-bigmind-behavior.md` | **NEW** |
|
||||
| `.roo/skills/assessment-first/SKILL.md` | **NEW** |
|
||||
| `.roo/skills/gitea-push/SKILL.md` | **NEW** |
|
||||
| `.roo/skills/bigmind-session-ritual/SKILL.md` | **NEW** |
|
||||
| `.roo/skills/new-mcp-server/SKILL.md` | **NEW** |
|
||||
| `.roo/skills-homelab/homelab-docker-deploy/SKILL.md` | **NEW** |
|
||||
| `.roo/skills-paisy/paisy-assessment/SKILL.md` | **NEW** |
|
||||
| `.roo/skills-mcp-builder/mcp-test-suite/SKILL.md` | **NEW** |
|
||||
| `.roo/skills-bigmind/bigmind-health/SKILL.md` | **NEW** |
|
||||
| `~/.config/Code/.../custom_modes.yaml` | Add 4 new persona modes |
|
||||
|
||||
---
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **Lumen is non-negotiable.** Every mode inherits the global rules. No mode can suppress BigMind rituals.
|
||||
|
||||
2. **Modes declare context, not just function.** A mode isn't "code mode" — it's "Patrick is currently being MCP Builder Patrick."
|
||||
|
||||
3. **Skills are stateless procedures.** They don't remember context — modes provide context, skills execute steps.
|
||||
|
||||
4. **The sport Paddy rule.** Same person, different mindset. I adapt my *approach* and *knowledge focus*, not my *identity* or *values*.
|
||||
|
||||
5. **No duplication.** Mode rules reference global rules, they don't repeat them.
|
||||
@@ -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
|
||||
|
||||
@@ -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: **500–3,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)
|
||||
- **670–823 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 €99–299 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*
|
||||