Files
2026-03-30 07:19:20 -04:00

38 KiB

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

mkdir -p ~/bin/workbench-server/src/workbench
mkdir -p ~/bin/workbench-server/tests
  • Step 2: Write pyproject.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
"""Workbench — MCP server for AI-driven interactive web pages."""

__version__ = "0.2.0"
  • Step 4: Write __main__.py
"""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

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
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
# 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
# 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
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
"""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:

<!DOCTYPE html>
<html><head><title>{{TITLE}}</title></head>
<body><h1>{{TITLE}}</h1><p>{{DESCRIPTION}}</p></body></html>
  • Step 6: Run tests to verify they pass
pytest tests/test_project.py -v

Expected: All 9 tests pass.

  • Step 7: Commit
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.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{TITLE}}</title>
<style>
  :root {
    --bg: #0a0f0c;
    --panel-bg: #111a15;
    --border: #2a3a2e;
    --text: #aaccaa;
    --text-dim: #557755;
    --accent: #33ff66;
  }
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    background: var(--bg);
    color: var(--text);
    font-family: monospace;
    font-size: 14px;
    height: 100vh;
    display: flex;
    flex-direction: column;
  }
  #content {
    flex: 1;
    overflow-y: auto;
    padding: 16px;
  }
  #log-feed {
    border-top: 1px solid var(--border);
    padding: 8px 12px;
    max-height: 200px;
    overflow-y: auto;
  }
  #log-feed h3 {
    color: var(--text-dim);
    font-size: 11px;
    letter-spacing: 2px;
    text-transform: uppercase;
    margin-bottom: 4px;
  }
  .log-entry {
    font-size: 12px;
    color: var(--text-dim);
    padding: 2px 0;
  }
  .log-entry .log-time {
    color: var(--accent);
    margin-right: 8px;
  }
  #status {
    background: var(--panel-bg);
    border-top: 1px solid var(--border);
    padding: 4px 12px;
    font-size: 11px;
    color: var(--text-dim);
  }
  #status.connected { color: var(--accent); }
</style>
</head>
<body>
  <div id="content">
    <h1 style="color: var(--accent); font-size: 18px;">{{TITLE}}</h1>
    <p style="color: var(--text-dim); margin-top: 8px;">{{DESCRIPTION}}</p>
    <p style="color: var(--text-dim); margin-top: 16px;">Waiting for content...</p>
  </div>
  <div id="log-feed">
    <h3>Session Log</h3>
    <div id="log-entries"></div>
  </div>
  <div id="status">Connecting...</div>

<script>
const WS_URL = `ws://${location.host}/ws`;
let ws;

function connect() {
  ws = new WebSocket(WS_URL);
  ws.onopen = () => {
    document.getElementById('status').textContent = 'Connected';
    document.getElementById('status').className = 'connected';
  };
  ws.onclose = () => {
    document.getElementById('status').textContent = 'Disconnected — reconnecting...';
    document.getElementById('status').className = '';
    setTimeout(connect, 2000);
  };
  ws.onmessage = (e) => {
    const msg = JSON.parse(e.data);
    if (msg.type === 'state') handleState(msg.state);
    if (msg.type === 'log') handleLog(msg.entry);
  };
}

function handleState(state) {
  if (state.template) {
    document.getElementById('content').innerHTML = state.template;
  }
  if (state.styles) {
    let el = document.getElementById('dynamic-styles');
    if (!el) { el = document.createElement('style'); el.id = 'dynamic-styles'; document.head.appendChild(el); }
    el.textContent = state.styles;
  }
  if (state.script) {
    try { new Function(state.script)(); } catch(e) { console.error('Script error:', e); }
  }
  window.__workbench_state = state;
  try { localStorage.setItem('workbench-state', JSON.stringify(state)); } catch(e) {}
}

function handleLog(entry) {
  const div = document.createElement('div');
  div.className = 'log-entry';
  const time = new Date().toLocaleTimeString();
  div.innerHTML = `<span class="log-time">${time}</span>${entry}`;
  const feed = document.getElementById('log-entries');
  feed.appendChild(div);
  feed.scrollTop = feed.scrollHeight;
}

try {
  const saved = JSON.parse(localStorage.getItem('workbench-state'));
  if (saved) handleState(saved);
} catch(e) {}

connect();
</script>
</body>
</html>
  • Step 2: Commit
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
# 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": "<h1>Hello</h1>"})
    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"] == "<h1>Hello</h1>"


@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
pytest tests/test_server.py -v

Expected: ImportError.

  • Step 3: Write server.py
"""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
pytest tests/test_server.py -v

Expected: All 9 tests pass.

  • Step 5: Run full suite
pytest tests/ -v

Expected: All 18 tests pass (9 project + 9 server).

  • Step 6: Commit
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

"""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
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
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

cd ~/bin/workbench-server
git rm server.py scaffold.html
  • Step 2: Run full test suite to confirm nothing breaks
pytest tests/ -v

Expected: All 18 tests pass.

  • Step 3: Commit
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):

  1. Clone + pip install
  2. Detect environment: Python version, platform, browser availability
  3. Detect SSH usage: $SSH_CONNECTION, ~/.ssh/known_hosts, ControlMaster config
  4. Present options: SSH ControlMaster if applicable
  5. Configure MCP for user's AI CLI (detect Claude Code, Gemini CLI, etc.)
  6. Smoke test: workbench list
  7. Write ~/workbench/START.md

Include SSH ControlMaster setup (same as kitty-workbench INSTALL.md — ControlMaster auto, ControlPersist yes, sockets dir).

  • Step 3: Commit
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
cd ~/bin/workbench-server
pytest tests/ -v

Expected: All 18 tests pass.

  • Step 2: Push
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