# Workbench Server v2 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Restructure workbench-server into a pip-installable package with server persistence (survives AI CLI restarts), simplified desktop-only layout, and AI-guided setup. **Architecture:** The existing monolithic `server.py` gets split into a proper Python package under `src/workbench/`. A `.server.json` file per project tracks running HTTP servers. On MCP startup, the server scans for live servers and reattaches. The scaffold HTML drops the terminal panel for a full-width display surface. **Tech Stack:** Python 3.10+, `mcp` (FastMCP), `aiohttp` (HTTP/WebSocket), `pytest`/`pytest-asyncio` for tests. **Spec:** `docs/superpowers/specs/2026-03-30-workbench-v2-design.md` **Existing code:** The current `server.py` (293 lines) and `scaffold.html` (142 lines) are the starting point. We're restructuring, not rewriting from scratch — the core logic (MCP tools, HTTP server, WebSocket broadcast) is preserved. --- ## File Structure ``` workbench-server/ pyproject.toml # Package config, entry point README.md # Public-facing docs (rewritten) INSTALL.md # AI-readable setup instructions LICENSE # MIT src/ workbench/ __init__.py # Version string __main__.py # python -m workbench cli.py # CLI: mcp, serve, list, help server.py # MCP tools + HTTP/WS server management project.py # Project dir management, logging, persistence scaffold.html # HTML template (moved from root, simplified) tests/ conftest.py # Shared fixtures test_project.py # Project creation, logging, persistence test_server.py # MCP tool tests # Old files to remove: server.py # replaced by src/workbench/server.py scaffold.html # replaced by src/workbench/scaffold.html requirements.txt # replaced by pyproject.toml ``` --- ## Task 1: Package Scaffold + Move Files **Files:** - Create: `pyproject.toml` - Create: `LICENSE` - Create: `src/workbench/__init__.py` - Create: `src/workbench/__main__.py` - Remove: `requirements.txt` - Move: `server.py` → will be replaced in later tasks - Move: `scaffold.html` → will be replaced in later tasks - [ ] **Step 1: Create directory structure** ```bash mkdir -p ~/bin/workbench-server/src/workbench mkdir -p ~/bin/workbench-server/tests ``` - [ ] **Step 2: Write pyproject.toml** ```toml [build-system] requires = ["setuptools>=68.0"] build-backend = "setuptools.build_meta" [project] name = "workbench-server" version = "0.2.0" description = "MCP server that lets AI CLIs build interactive web pages served over LAN" readme = "README.md" license = "MIT" requires-python = ">=3.10" dependencies = [ "mcp>=1.26.0", "aiohttp>=3.9.0", ] [project.scripts] workbench = "workbench.cli:main" ``` - [ ] **Step 3: Write __init__.py** ```python """Workbench — MCP server for AI-driven interactive web pages.""" __version__ = "0.2.0" ``` - [ ] **Step 4: Write __main__.py** ```python """Allow running as `python -m workbench`.""" from workbench.cli import main main() ``` - [ ] **Step 5: Write LICENSE (MIT)** - [ ] **Step 6: Back up old files, remove requirements.txt** ```bash mkdir -p ~/bin/workbench-server/.backup cp ~/bin/workbench-server/server.py ~/bin/workbench-server/.backup/server.py.v1 cp ~/bin/workbench-server/scaffold.html ~/bin/workbench-server/.backup/scaffold.html.v1 rm ~/bin/workbench-server/requirements.txt ``` - [ ] **Step 7: Commit** ```bash cd ~/bin/workbench-server git add pyproject.toml LICENSE src/ .backup/ git rm requirements.txt git commit -m "feat: package scaffold — pyproject.toml, src/workbench/ layout" ``` --- ## Task 2: Project Management Module **Files:** - Create: `src/workbench/project.py` - Create: `tests/conftest.py` - Create: `tests/test_project.py` Handles project directory creation, session logging, log reading, listing, and the new `.server.json` persistence. - [ ] **Step 1: Write test file** ```python # tests/test_project.py import json from workbench.project import ( create_project, project_exists, project_path, append_log, read_log, list_projects, log_session_event, write_server_info, read_server_info, clear_server_info, WORKBENCH_DIR, ) def test_create_project(tmp_workbench): path = create_project("io102", "Heathkit IO-102", workbench_dir=tmp_workbench) assert path.exists() assert (path / "index.html").exists() assert (path / "session.md").exists() assert (path / "session.jsonl").exists() assert (path / "cost-log.jsonl").exists() assert (path / "state.json").exists() assert (path / "assets").is_dir() md = (path / "session.md").read_text() assert "Heathkit IO-102" in md def test_create_project_idempotent(tmp_workbench): create_project("io102", "First", workbench_dir=tmp_workbench) append_log("io102", "test entry", workbench_dir=tmp_workbench) create_project("io102", "Second", workbench_dir=tmp_workbench) entries = read_log("io102", workbench_dir=tmp_workbench) assert len(entries) == 1 def test_project_exists(tmp_workbench): assert not project_exists("io102", workbench_dir=tmp_workbench) create_project("io102", "Test", workbench_dir=tmp_workbench) assert project_exists("io102", workbench_dir=tmp_workbench) def test_append_and_read_log(tmp_workbench): create_project("io102", "Test", workbench_dir=tmp_workbench) append_log("io102", "R412 measured 1.05M", data={"ohms": 1050000}, workbench_dir=tmp_workbench) append_log("io102", "R413 OK", workbench_dir=tmp_workbench) entries = read_log("io102", tail=10, workbench_dir=tmp_workbench) assert len(entries) == 2 assert entries[0]["entry"] == "R412 measured 1.05M" assert entries[0]["ohms"] == 1050000 def test_read_log_tail(tmp_workbench): create_project("io102", "Test", workbench_dir=tmp_workbench) for i in range(30): append_log("io102", f"Entry {i}", workbench_dir=tmp_workbench) entries = read_log("io102", tail=5, workbench_dir=tmp_workbench) assert len(entries) == 5 assert entries[0]["entry"] == "Entry 25" def test_list_projects(tmp_workbench): assert list_projects(workbench_dir=tmp_workbench) == [] create_project("io102", "First", workbench_dir=tmp_workbench) create_project("psu-rebuild", "Second", workbench_dir=tmp_workbench) projects = list_projects(workbench_dir=tmp_workbench) assert [p["name"] for p in projects] == ["io102", "psu-rebuild"] def test_log_session_event(tmp_workbench): create_project("io102", "Test", workbench_dir=tmp_workbench) log_session_event("io102", "session_start", workbench_dir=tmp_workbench) cost_log = (tmp_workbench / "io102" / "cost-log.jsonl").read_text().strip() entry = json.loads(cost_log.split("\n")[-1]) assert entry["event"] == "session_start" def test_server_info_write_read_clear(tmp_workbench): create_project("io102", "Test", workbench_dir=tmp_workbench) write_server_info("io102", pid=12345, port=8070, workbench_dir=tmp_workbench) info = read_server_info("io102", workbench_dir=tmp_workbench) assert info is not None assert info["pid"] == 12345 assert info["port"] == 8070 assert "started" in info clear_server_info("io102", workbench_dir=tmp_workbench) assert read_server_info("io102", workbench_dir=tmp_workbench) is None ``` - [ ] **Step 2: Write conftest.py** ```python # tests/conftest.py import pytest @pytest.fixture def tmp_workbench(tmp_path): """Provide a temporary workbench directory.""" wb = tmp_path / "workbench" wb.mkdir() return wb ``` - [ ] **Step 3: Run tests to verify they fail** ```bash cd ~/bin/workbench-server pip install --break-system-packages -e . pip install --break-system-packages pytest pytest-asyncio pytest tests/test_project.py -v ``` Expected: ImportError. - [ ] **Step 4: Write project.py** ```python """Project directory management — create, log, read, list, server persistence.""" from __future__ import annotations import json import os from datetime import datetime, timezone from pathlib import Path from typing import Optional WORKBENCH_DIR = Path.home() / "workbench" SCAFFOLD_HTML = Path(__file__).parent / "scaffold.html" def _now_iso() -> str: return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") def project_path(name: str, workbench_dir: Path = WORKBENCH_DIR) -> Path: return workbench_dir / name def project_exists(name: str, workbench_dir: Path = WORKBENCH_DIR) -> bool: return project_path(name, workbench_dir).is_dir() def create_project( name: str, title: str, description: str = "", workbench_dir: Path = WORKBENCH_DIR, ) -> Path: """Create a project directory with scaffold HTML and log files. Idempotent.""" pdir = project_path(name, workbench_dir) pdir.mkdir(parents=True, exist_ok=True) (pdir / "assets").mkdir(exist_ok=True) # Write scaffold HTML (always overwrite — it's a template, not user content) if SCAFFOLD_HTML.exists(): template = SCAFFOLD_HTML.read_text() html = template.replace("{{TITLE}}", title) html = html.replace("{{DESCRIPTION}}", description) (pdir / "index.html").write_text(html) # Only write log files if they don't exist (preserve existing session data) md_path = pdir / "session.md" if not md_path.exists(): md_path.write_text(f"# {title} — Session Log\n\nStarted: {_now_iso()}\n\n") jsonl_path = pdir / "session.jsonl" if not jsonl_path.exists(): jsonl_path.write_text("") cost_path = pdir / "cost-log.jsonl" if not cost_path.exists(): cost_path.write_text("") state_path = pdir / "state.json" if not state_path.exists(): state_path.write_text("{}") return pdir def append_log( name: str, entry: str, data: Optional[dict] = None, workbench_dir: Path = WORKBENCH_DIR, ) -> dict: """Append to session.md and session.jsonl.""" pdir = project_path(name, workbench_dir) ts = _now_iso() with open(pdir / "session.md", "a") as f: f.write(f"\n### {ts}\n{entry}\n") obj = {"ts": ts, "entry": entry} if data: obj.update(data) with open(pdir / "session.jsonl", "a") as f: f.write(json.dumps(obj) + "\n") return obj def read_log( name: str, tail: int = 20, workbench_dir: Path = WORKBENCH_DIR ) -> list[dict]: """Read recent log entries from session.jsonl.""" jsonl_path = project_path(name, workbench_dir) / "session.jsonl" if not jsonl_path.exists(): return [] lines = jsonl_path.read_text().strip().split("\n") lines = [l for l in lines if l.strip()] recent = lines[-tail:] if len(lines) > tail else lines entries = [] for line in recent: try: entries.append(json.loads(line)) except json.JSONDecodeError: entries.append({"raw": line}) return entries def list_projects(workbench_dir: Path = WORKBENCH_DIR) -> list[dict]: """List all project directories.""" if not workbench_dir.exists(): return [] projects = [] for d in sorted(workbench_dir.iterdir()): if d.is_dir() and not d.name.startswith("."): projects.append({"name": d.name}) return projects def log_session_event( name: str, event: str, workbench_dir: Path = WORKBENCH_DIR, **extra ) -> None: """Append to cost-log.jsonl.""" pdir = project_path(name, workbench_dir) obj = {"ts": _now_iso(), "event": event, "project": name} obj.update(extra) with open(pdir / "cost-log.jsonl", "a") as f: f.write(json.dumps(obj) + "\n") # --- Server persistence --- def write_server_info( name: str, pid: int, port: int, workbench_dir: Path = WORKBENCH_DIR ) -> None: """Write .server.json with running server info.""" pdir = project_path(name, workbench_dir) info = {"pid": pid, "port": port, "started": _now_iso()} (pdir / ".server.json").write_text(json.dumps(info)) def read_server_info( name: str, workbench_dir: Path = WORKBENCH_DIR ) -> Optional[dict]: """Read .server.json. Returns None if not found.""" server_file = project_path(name, workbench_dir) / ".server.json" if not server_file.exists(): return None try: return json.loads(server_file.read_text()) except (json.JSONDecodeError, OSError): return None def clear_server_info( name: str, workbench_dir: Path = WORKBENCH_DIR ) -> None: """Delete .server.json.""" server_file = project_path(name, workbench_dir) / ".server.json" if server_file.exists(): server_file.unlink() ``` - [ ] **Step 5: Copy scaffold.html to src/workbench/** The scaffold HTML needs to exist at `src/workbench/scaffold.html` for `create_project` to find it. We'll write the v2 version in Task 3. For now, create a minimal placeholder so tests pass: ```html {{TITLE}}

{{TITLE}}

{{DESCRIPTION}}

``` - [ ] **Step 6: Run tests to verify they pass** ```bash pytest tests/test_project.py -v ``` Expected: All 9 tests pass. - [ ] **Step 7: Commit** ```bash git add src/workbench/project.py src/workbench/scaffold.html tests/conftest.py tests/test_project.py git commit -m "feat: project management module — create, log, read, list, server persistence" ``` --- ## Task 3: Scaffold HTML v2 **Files:** - Create: `src/workbench/scaffold.html` (overwrite placeholder from Task 2) - [ ] **Step 1: Write the v2 scaffold HTML** Full-width desktop layout. No split pane, no iframe, no terminal embed. Same WebSocket reconnect and state handling as v1. ```html {{TITLE}}

{{TITLE}}

{{DESCRIPTION}}

Waiting for content...

Session Log

Connecting...
``` - [ ] **Step 2: Commit** ```bash git add src/workbench/scaffold.html git commit -m "feat: scaffold HTML v2 — full-width desktop layout, no terminal panel" ``` --- ## Task 4: MCP Server with Persistence **Files:** - Create: `src/workbench/server.py` - Create: `tests/test_server.py` The core MCP server — same 6 tools as v1, but with persistence logic. Adapted from the original `server.py` with the `.server.json` check-and-reattach flow. - [ ] **Step 1: Write test file** ```python # tests/test_server.py import asyncio import json import os from pathlib import Path from unittest.mock import patch, AsyncMock, MagicMock import pytest from workbench.server import WorkbenchServer @pytest.fixture def server(tmp_workbench): return WorkbenchServer(workbench_dir=tmp_workbench) @pytest.mark.asyncio async def test_scaffold_creates_project(server, tmp_workbench): with patch.object(server, "_start_http_server", new_callable=AsyncMock, return_value=8070): result = await server.workbench_scaffold("test-proj", "Test Project") data = json.loads(result) assert "url" in data assert (tmp_workbench / "test-proj" / "index.html").exists() @pytest.mark.asyncio async def test_scaffold_reattaches_to_running_server(server, tmp_workbench): """If .server.json exists and server is alive, don't start a new one.""" from workbench.project import create_project, write_server_info create_project("test-proj", "Test", workbench_dir=tmp_workbench) write_server_info("test-proj", pid=os.getpid(), port=9999, workbench_dir=tmp_workbench) with patch.object(server, "_is_server_alive", return_value=True): with patch.object(server, "_start_http_server", new_callable=AsyncMock) as mock_start: result = await server.workbench_scaffold("test-proj", "Test") mock_start.assert_not_called() # Should NOT start a new server data = json.loads(result) assert "9999" in data["url"] @pytest.mark.asyncio async def test_scaffold_replaces_dead_server(server, tmp_workbench): """If .server.json exists but server is dead, start a new one.""" from workbench.project import create_project, write_server_info create_project("test-proj", "Test", workbench_dir=tmp_workbench) write_server_info("test-proj", pid=99999, port=9999, workbench_dir=tmp_workbench) with patch.object(server, "_is_server_alive", return_value=False): with patch.object(server, "_start_http_server", new_callable=AsyncMock, return_value=8070): result = await server.workbench_scaffold("test-proj", "Test") data = json.loads(result) assert "8070" in data["url"] @pytest.mark.asyncio async def test_list_empty(server): result = await server.workbench_list() data = json.loads(result) assert data["projects"] == [] @pytest.mark.asyncio async def test_log_writes_to_disk(server, tmp_workbench): with patch.object(server, "_start_http_server", new_callable=AsyncMock, return_value=8070): await server.workbench_scaffold("test-proj", "Test") result = await server.workbench_log("test-proj", "R412 measured 1.05M") data = json.loads(result) assert data["ok"] is True jsonl = (tmp_workbench / "test-proj" / "session.jsonl").read_text().strip() assert "R412 measured 1.05M" in jsonl @pytest.mark.asyncio async def test_state_saves_to_disk(server, tmp_workbench): with patch.object(server, "_start_http_server", new_callable=AsyncMock, return_value=8070): await server.workbench_scaffold("test-proj", "Test") state = json.dumps({"template": "

Hello

"}) result = await server.workbench_state("test-proj", state) data = json.loads(result) assert data["ok"] is True saved = json.loads((tmp_workbench / "test-proj" / "state.json").read_text()) assert saved["template"] == "

Hello

" @pytest.mark.asyncio async def test_stop_cleans_up(server, tmp_workbench): with patch.object(server, "_start_http_server", new_callable=AsyncMock, return_value=8070): await server.workbench_scaffold("test-proj", "Test") server._runners["test-proj"] = AsyncMock() result = await server.workbench_stop("test-proj") data = json.loads(result) assert data["ok"] is True assert not (tmp_workbench / "test-proj" / ".server.json").exists() @pytest.mark.asyncio async def test_reconnect_on_startup(tmp_workbench): """On init, server should find live servers from .server.json files.""" from workbench.project import create_project, write_server_info create_project("live-proj", "Live", workbench_dir=tmp_workbench) write_server_info("live-proj", pid=os.getpid(), port=8070, workbench_dir=tmp_workbench) create_project("dead-proj", "Dead", workbench_dir=tmp_workbench) write_server_info("dead-proj", pid=99999, port=8071, workbench_dir=tmp_workbench) srv = WorkbenchServer(workbench_dir=tmp_workbench) with patch.object(srv, "_is_server_alive", side_effect=lambda port: port == 8070): await srv.reconnect_existing_servers() assert "live-proj" in srv._active assert srv._active["live-proj"]["port"] == 8070 assert "dead-proj" not in srv._active # Dead server's .server.json should be cleaned up assert not (tmp_workbench / "dead-proj" / ".server.json").exists() ``` - [ ] **Step 2: Run tests to verify they fail** ```bash pytest tests/test_server.py -v ``` Expected: ImportError. - [ ] **Step 3: Write server.py** ```python """MCP server — 6 workbench tools with HTTP/WS server management and persistence.""" from __future__ import annotations import asyncio import json import os import socket from pathlib import Path from typing import Optional from aiohttp import web from mcp.server.fastmcp import FastMCP from workbench.project import ( create_project, project_exists, project_path, append_log, read_log, list_projects, log_session_event, write_server_info, read_server_info, clear_server_info, WORKBENCH_DIR, ) def _get_lan_ip() -> str: try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("192.168.0.1", 80)) ip = s.getsockname()[0] s.close() return ip except Exception: return "127.0.0.1" def _find_free_port(start: int = 8070) -> int: for port in range(start, start + 100): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.bind(("0.0.0.0", port)) s.close() return port except OSError: continue raise RuntimeError(f"No free port found in range {start}-{start + 99}") class WorkbenchServer: """Core server logic — testable without MCP transport.""" def __init__(self, workbench_dir: Path = WORKBENCH_DIR): self.workbench_dir = Path(workbench_dir) self._active: dict[str, dict] = {} # name -> {"port": int, "runner": AppRunner, "ws_clients": set} self._runners: dict[str, web.AppRunner] = {} def _is_server_alive(self, port: int) -> bool: """Check if an HTTP server is responding on the given port.""" try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1) s.connect(("127.0.0.1", port)) s.close() return True except (OSError, ConnectionRefusedError): return False async def reconnect_existing_servers(self) -> None: """Scan for running servers from .server.json files. Called on MCP startup.""" if not self.workbench_dir.exists(): return for d in self.workbench_dir.iterdir(): if not d.is_dir(): continue name = d.name info = read_server_info(name, workbench_dir=self.workbench_dir) if info is None: continue port = info["port"] if self._is_server_alive(port): self._active[name] = {"port": port, "ws_clients": set()} else: clear_server_info(name, workbench_dir=self.workbench_dir) async def _start_http_server(self, name: str) -> int: """Start an HTTP + WebSocket server for a project. Returns port.""" port = _find_free_port() pdir = project_path(name, self.workbench_dir) app = web.Application() app["project_name"] = name app["workbench_server"] = self async def ws_handler(request): ws = web.WebSocketResponse() await ws.prepare(request) proj = request.app["project_name"] if proj in self._active: self._active[proj]["ws_clients"].add(ws) try: async for msg in ws: pass finally: if proj in self._active: self._active[proj]["ws_clients"].discard(ws) return ws async def static_handler(request): proj = request.app["project_name"] path = request.match_info.get("path", "index.html") or "index.html" file_path = project_path(proj, self.workbench_dir) / path if not file_path.exists(): return web.Response(status=404, text="Not found") return web.FileResponse(file_path) app.router.add_get("/ws", ws_handler) app.router.add_get("/{path:.*}", static_handler) app.router.add_get("/", static_handler) runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, "0.0.0.0", port) await site.start() self._runners[name] = runner self._active[name] = {"port": port, "ws_clients": set()} write_server_info(name, pid=os.getpid(), port=port, workbench_dir=self.workbench_dir) return port async def _broadcast_ws(self, name: str, message: dict) -> None: if name not in self._active: return clients = self._active[name]["ws_clients"] dead = set() for ws in clients: try: await ws.send_json(message) except Exception: dead.add(ws) clients -= dead # --- MCP Tool implementations --- async def workbench_scaffold(self, name: str, title: str, description: str = "") -> str: pdir = project_path(name, self.workbench_dir) create_project(name, title, description, workbench_dir=self.workbench_dir) # Check for existing running server info = read_server_info(name, workbench_dir=self.workbench_dir) if info and self._is_server_alive(info["port"]): port = info["port"] if name not in self._active: self._active[name] = {"port": port, "ws_clients": set()} else: if info: clear_server_info(name, workbench_dir=self.workbench_dir) port = await self._start_http_server(name) ip = _get_lan_ip() log_session_event(name, "session_start", workbench_dir=self.workbench_dir) return json.dumps({"path": str(pdir), "url": f"http://{ip}:{port}"}) async def workbench_state(self, project: str, state: str) -> str: if not project_exists(project, workbench_dir=self.workbench_dir): return json.dumps({"error": f"Project '{project}' not found. Run workbench_scaffold first."}) state_obj = json.loads(state) pdir = project_path(project, self.workbench_dir) (pdir / "state.json").write_text(json.dumps(state_obj, indent=2)) await self._broadcast_ws(project, {"type": "state", "state": state_obj}) return json.dumps({"ok": True}) async def workbench_log(self, project: str, entry: str, data: str = "{}") -> str: if not project_exists(project, workbench_dir=self.workbench_dir): return json.dumps({"error": f"Project '{project}' not found."}) data_obj = json.loads(data) if data and data != "{}" else None append_log(project, entry, data=data_obj, workbench_dir=self.workbench_dir) await self._broadcast_ws(project, {"type": "log", "entry": entry}) return json.dumps({"ok": True}) async def workbench_read_log(self, project: str, tail: int = 20) -> str: if not project_exists(project, workbench_dir=self.workbench_dir): return json.dumps({"error": f"Project '{project}' not found."}) entries = read_log(project, tail=tail, workbench_dir=self.workbench_dir) return json.dumps({"entries": entries}) async def workbench_list(self) -> str: projects = list_projects(workbench_dir=self.workbench_dir) for p in projects: p["active"] = p["name"] in self._active if p["active"]: ip = _get_lan_ip() port = self._active[p["name"]]["port"] p["url"] = f"http://{ip}:{port}" return json.dumps({"projects": projects}) async def workbench_stop(self, project: str) -> str: if project not in self._active: return json.dumps({"error": f"Project '{project}' is not running."}) # Log session end if project_exists(project, workbench_dir=self.workbench_dir): entries = read_log(project, tail=999999, workbench_dir=self.workbench_dir) log_session_event( project, "session_end", workbench_dir=self.workbench_dir, log_entries=len(entries), ) # Shutdown HTTP server if project in self._runners: await self._runners[project].cleanup() del self._runners[project] clear_server_info(project, workbench_dir=self.workbench_dir) del self._active[project] return json.dumps({"ok": True}) def create_mcp_server(workbench_dir: Path = WORKBENCH_DIR) -> FastMCP: """Create the FastMCP instance with all workbench tools registered.""" srv = WorkbenchServer(workbench_dir=workbench_dir) mcp = FastMCP( "workbench", instructions="Workbench — build interactive web pages served over LAN. Call workbench_scaffold first.", ) @mcp.tool() async def workbench_scaffold(name: str, title: str, description: str = "") -> str: """Create a workbench project and start the HTTP server. Returns the LAN URL to open in a browser. If the project already exists and its server is running, reattaches without starting a duplicate. Always safe to call — never creates duplicates.""" return await srv.workbench_scaffold(name, title, description) @mcp.tool() async def workbench_state(project: str, state: str) -> str: """Push a state update to the browser via WebSocket. The state is a JSON string — include 'template' (HTML string) to replace the page content, 'styles' (CSS string) to inject styles, and 'script' (JS string) to execute code. The AI has full control over the page.""" return await srv.workbench_state(project, state) @mcp.tool() async def workbench_log(project: str, entry: str, data: str = "{}") -> str: """Append a log entry to the session log. Shows in the browser log feed. entry: human-readable markdown string. data: optional JSON for the machine-readable log.""" return await srv.workbench_log(project, entry, data) @mcp.tool() async def workbench_read_log(project: str, tail: int = 20) -> str: """Read recent session log entries so the AI can resume a previous session.""" return await srv.workbench_read_log(project, tail) @mcp.tool() async def workbench_list() -> str: """List all workbench projects and whether their HTTP server is running.""" return await srv.workbench_list() @mcp.tool() async def workbench_stop(project: str) -> str: """Stop the HTTP server for a project and end the session.""" return await srv.workbench_stop(project) return mcp ``` - [ ] **Step 4: Run tests** ```bash pytest tests/test_server.py -v ``` Expected: All 9 tests pass. - [ ] **Step 5: Run full suite** ```bash pytest tests/ -v ``` Expected: All 18 tests pass (9 project + 9 server). - [ ] **Step 6: Commit** ```bash git add src/workbench/server.py tests/test_server.py git commit -m "feat: MCP server with persistence — reattach to running servers on restart" ``` --- ## Task 5: CLI Entry Point **Files:** - Create: `src/workbench/cli.py` - [ ] **Step 1: Write cli.py** ```python """CLI entry point for workbench.""" from __future__ import annotations import argparse import asyncio import sys from workbench import __version__ def cmd_mcp(args): """Start the MCP server on stdio.""" from workbench.server import create_mcp_server mcp = create_mcp_server() mcp.run(transport="stdio") def cmd_serve(args): """Serve a project without MCP.""" from workbench.server import WorkbenchServer srv = WorkbenchServer() async def run(): result = await srv.workbench_scaffold(args.name, args.name) import json data = json.loads(result) print(f"Serving: {data['url']}") try: while True: await asyncio.sleep(1) except (KeyboardInterrupt, asyncio.CancelledError): await srv.workbench_stop(args.name) asyncio.run(run()) def cmd_list(args): """List all projects.""" from workbench.project import list_projects, read_server_info projects = list_projects() if not projects: print("No projects found in ~/workbench/") return for p in projects: info = read_server_info(p["name"]) status = f" (running on port {info['port']})" if info else "" print(f" {p['name']}{status}") def main(): parser = argparse.ArgumentParser( prog="workbench", description="MCP server for AI-driven interactive web pages", ) parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") sub = parser.add_subparsers(dest="command") sub.add_parser("mcp", help="Start MCP server (stdio transport)") serve_parser = sub.add_parser("serve", help="Serve a project (standalone)") serve_parser.add_argument("name", help="Project name") sub.add_parser("list", help="List all projects") sub.add_parser("help", help="Show help") args = parser.parse_args() commands = { "mcp": cmd_mcp, "serve": cmd_serve, "list": cmd_list, "help": lambda a: parser.print_help(), } if args.command is None: parser.print_help() sys.exit(0) commands[args.command](args) ``` - [ ] **Step 2: Install and verify** ```bash cd ~/bin/workbench-server pip install --break-system-packages -e . workbench --version workbench help workbench list ``` Expected: version "workbench 0.2.0", help shows subcommands, list says "No projects found". - [ ] **Step 3: Commit** ```bash git add src/workbench/cli.py git commit -m "feat: CLI entry point — mcp, serve, list, help" ``` --- ## Task 6: Clean Up Old Files **Files:** - Remove: `server.py` (root level — replaced by `src/workbench/server.py`) - Remove: `scaffold.html` (root level — replaced by `src/workbench/scaffold.html`) - [ ] **Step 1: Remove old files** ```bash cd ~/bin/workbench-server git rm server.py scaffold.html ``` - [ ] **Step 2: Run full test suite to confirm nothing breaks** ```bash pytest tests/ -v ``` Expected: All 18 tests pass. - [ ] **Step 3: Commit** ```bash git commit -m "refactor: remove old root-level server.py and scaffold.html" ``` --- ## Task 7: Docs — README, INSTALL.md, LICENSE **Files:** - Rewrite: `README.md` - Create: `INSTALL.md` - [ ] **Step 1: Rewrite README.md** Public-facing. Desktop browser as display surface. Setup is "paste URL" or "read INSTALL.md". No mention of kitty or terminal splitting. Adapted from the kitty-workbench README pattern but for browser-based display. Key sections: what it does (with ASCII art diagram), setup (clone + "tell AI to read INSTALL.md"), tools table, usage examples, project structure, requirements, FAQ. - [ ] **Step 2: Write INSTALL.md** AI-readable setup instructions. Simpler than kitty-workbench (no terminal detection needed): 0. Clone + pip install 1. Detect environment: Python version, platform, browser availability 2. Detect SSH usage: `$SSH_CONNECTION`, `~/.ssh/known_hosts`, ControlMaster config 3. Present options: SSH ControlMaster if applicable 4. Configure MCP for user's AI CLI (detect Claude Code, Gemini CLI, etc.) 5. Smoke test: `workbench list` 6. Write `~/workbench/START.md` Include SSH ControlMaster setup (same as kitty-workbench INSTALL.md — `ControlMaster auto`, `ControlPersist yes`, sockets dir). - [ ] **Step 3: Commit** ```bash git add README.md INSTALL.md git commit -m "docs: README and INSTALL.md — AI-guided setup" ``` --- ## Task 8: Push to Gitea - [ ] **Step 1: Run full test suite** ```bash cd ~/bin/workbench-server pytest tests/ -v ``` Expected: All 18 tests pass. - [ ] **Step 2: Push** ```bash cd ~/bin/workbench-server gitea push ``` --- ## Summary | Task | What | Key files | |------|------|-----------| | 1 | Package scaffold + move files | pyproject.toml, __init__.py, __main__.py, LICENSE | | 2 | Project management module | project.py, test_project.py | | 3 | Scaffold HTML v2 | scaffold.html | | 4 | MCP server with persistence | server.py, test_server.py | | 5 | CLI entry point | cli.py | | 6 | Clean up old files | remove server.py, scaffold.html from root | | 7 | Docs | README.md, INSTALL.md | | 8 | Push to Gitea | — |