366 lines
12 KiB
Markdown
366 lines
12 KiB
Markdown
# Workbench Server v2 Design Spec
|
|
|
|
**Date:** 2026-03-30
|
|
**Status:** Draft
|
|
**Repo:** https://git.sethpc.xyz/Seth/workbench-server
|
|
|
|
## Summary
|
|
|
|
Workbench is an MCP server that lets any AI CLI create and control interactive web pages served over LAN. The AI pushes arbitrary HTML/CSS/JS to a browser page via WebSocket. Use cases: hardware diagnostics, guided procedures, dashboards, data collection — anything that benefits from a visual display surface the AI can control.
|
|
|
|
v2 adds: server persistence (survives AI CLI restarts), pip-installable packaging, AI-guided setup (INSTALL.md/START.md), and a simplified desktop-focused layout (no embedded terminal panel).
|
|
|
|
## What Changes From v1
|
|
|
|
| Aspect | v1 | v2 |
|
|
|--------|----|----|
|
|
| Server persistence | In-memory only — lost on restart | `.server.json` on disk, reconnect on startup |
|
|
| Layout | Split: diagnostic panel + sethmux iframe | Full-width display surface, no terminal |
|
|
| Mobile support | Responsive stacking | Desktop only |
|
|
| Dependencies | mcp, aiohttp | mcp, aiohttp (unchanged) |
|
|
| Packaging | Raw script | pip-installable package |
|
|
| Setup | Manual | AI-guided (INSTALL.md → START.md) |
|
|
| tmux/sethmux | Embedded iframe | Removed entirely |
|
|
|
|
## What Stays The Same
|
|
|
|
- MCP server on stdio transport
|
|
- 6 MCP tools: `workbench_scaffold`, `workbench_state`, `workbench_log`, `workbench_read_log`, `workbench_list`, `workbench_stop`
|
|
- HTTP + WebSocket per project (aiohttp)
|
|
- AI pushes arbitrary HTML/CSS/JS — no widget system, full creative freedom
|
|
- Dual-format session logging (session.md + session.jsonl)
|
|
- Project directories at `~/workbench/<name>/`
|
|
- LAN-native, no cloud, no auth
|
|
- Cost tracking (cost-log.jsonl)
|
|
|
|
## Architecture
|
|
|
|
```
|
|
┌─────────────────────┐ ┌──────────────────────────────┐
|
|
│ AI CLI terminal │ │ Browser (desktop) │
|
|
│ │ │ │
|
|
│ AI calls MCP tools │ stdio │ ┌────────────────────────┐ │
|
|
│ workbench_state ──┼────┐ │ │ AI-generated content │ │
|
|
│ workbench_log │ │ │ │ HTML / CSS / JS │ │
|
|
│ etc. │ │ │ │ updated live via WS │ │
|
|
│ │ ▼ │ │ │ │
|
|
│ │ MCP │ │ Schematics, tables, │ │
|
|
│ │ server │ │ checklists, dashboards│ │
|
|
│ │ │ │ │ — anything the AI │ │
|
|
│ │ │ │ │ decides to build │ │
|
|
│ │ ▼ │ ├────────────────────────┤ │
|
|
│ │ HTTP + │ │ Log feed │ │
|
|
│ │ WS on │ └────────────────────────┘ │
|
|
│ │ LAN │ ● Connected │ project-name │
|
|
└─────────────────────┘ └──────────────────────────────┘
|
|
```
|
|
|
|
## Persistence Mechanism
|
|
|
|
### Problem
|
|
|
|
The MCP server runs as a subprocess of the AI CLI. When the AI restarts, the MCP server restarts, and all in-memory state (running HTTP servers, WebSocket clients) is lost. The AI then calls `workbench_scaffold` again and starts a duplicate server.
|
|
|
|
### Solution
|
|
|
|
Persist server state to disk. On startup and on each `workbench_scaffold` call, check for an existing running server before starting a new one.
|
|
|
|
**Per-project server file:** `~/workbench/<name>/.server.json`
|
|
|
|
```json
|
|
{
|
|
"pid": 12345,
|
|
"port": 8070,
|
|
"started": "2026-03-30T10:00:00-04:00"
|
|
}
|
|
```
|
|
|
|
### workbench_scaffold flow
|
|
|
|
```
|
|
workbench_scaffold(name, title)
|
|
│
|
|
├─ Project dir exists?
|
|
│ ├─ No → create dir, write scaffold HTML, init logs
|
|
│ └─ Yes → keep existing files
|
|
│
|
|
├─ .server.json exists?
|
|
│ ├─ No → start new HTTP server, write .server.json
|
|
│ └─ Yes → read port from file
|
|
│ ├─ HTTP GET localhost:<port> returns 200?
|
|
│ │ ├─ Yes → server is alive, reattach (add to active_projects)
|
|
│ │ └─ No → server is dead, clean up .server.json, start new server
|
|
│ └─ PID still running? (fallback check)
|
|
│
|
|
└─ Return {"path": "...", "url": "http://<lan-ip>:<port>"}
|
|
```
|
|
|
|
### MCP server startup (reconnect)
|
|
|
|
When the MCP server process starts (before any tool calls), scan for running servers:
|
|
|
|
```python
|
|
for project_dir in ~/workbench/*/:
|
|
server_file = project_dir / ".server.json"
|
|
if server_file.exists():
|
|
info = json.loads(server_file.read_text())
|
|
if is_server_alive(info["port"]):
|
|
active_projects[name] = {"port": info["port"], ...}
|
|
```
|
|
|
|
This means: AI CLI restarts → MCP server restarts → immediately knows about all running project servers → `workbench_list` and `workbench_state` work without calling `workbench_scaffold` again.
|
|
|
|
### workbench_stop flow
|
|
|
|
```
|
|
workbench_stop(project)
|
|
│
|
|
├─ Log session end to cost-log.jsonl
|
|
├─ Stop HTTP server (runner.cleanup())
|
|
├─ Delete .server.json
|
|
└─ Remove from active_projects
|
|
```
|
|
|
|
## Scaffold HTML (v2)
|
|
|
|
Full-width desktop layout. No split pane, no iframe, no terminal embed.
|
|
|
|
```html
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<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;
|
|
}
|
|
|
|
// Restore state from localStorage on load
|
|
try {
|
|
const saved = JSON.parse(localStorage.getItem('workbench-state'));
|
|
if (saved) handleState(saved);
|
|
} catch(e) {}
|
|
|
|
connect();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
Key changes from v1:
|
|
- No split layout, no divider, no term-panel, no iframe
|
|
- Full-width `#content` area
|
|
- Flexbox column layout (content grows, log and status at bottom)
|
|
- No mobile media queries
|
|
- Same WebSocket reconnect logic
|
|
- Same localStorage state persistence
|
|
|
|
## MCP Tools (unchanged API)
|
|
|
|
The 6 tools keep the same interface. Only internal behavior changes for persistence.
|
|
|
|
### workbench_scaffold
|
|
|
|
Same parameters: `name`, `title`, `description`.
|
|
New behavior: checks `.server.json` before starting a new server (see Persistence Mechanism above).
|
|
Returns: `{"path": "...", "url": "http://<lan-ip>:<port>"}` (unchanged).
|
|
|
|
### workbench_state
|
|
|
|
Unchanged. Pushes JSON to browser via WebSocket. `template`, `styles`, `script` fields.
|
|
|
|
### workbench_log
|
|
|
|
Unchanged. Appends to session.md and session.jsonl, pushes to browser log feed.
|
|
|
|
### workbench_read_log
|
|
|
|
Unchanged. Returns recent log entries from session.jsonl.
|
|
|
|
### workbench_list
|
|
|
|
Same return format, but now also checks `.server.json` files to detect servers that survived an MCP restart.
|
|
|
|
### workbench_stop
|
|
|
|
Same behavior, plus deletes `.server.json` on stop.
|
|
|
|
## Source Structure
|
|
|
|
```
|
|
workbench-server/
|
|
pyproject.toml
|
|
README.md
|
|
INSTALL.md
|
|
LICENSE
|
|
src/
|
|
workbench/
|
|
__init__.py
|
|
__main__.py # python -m workbench
|
|
cli.py # CLI: mcp, serve, list, help
|
|
server.py # MCP server + HTTP/WS management + persistence
|
|
project.py # Project dir management, logging
|
|
scaffold.html # HTML template
|
|
tests/
|
|
conftest.py
|
|
test_project.py
|
|
test_persistence.py
|
|
test_server.py
|
|
```
|
|
|
|
## Packaging
|
|
|
|
```toml
|
|
[build-system]
|
|
requires = ["setuptools>=68.0"]
|
|
build-backend = "setuptools.build_meta"
|
|
|
|
[project]
|
|
name = "workbench-server"
|
|
version = "0.1.0"
|
|
description = "MCP server that lets AI CLIs build interactive web pages served over LAN"
|
|
requires-python = ">=3.10"
|
|
dependencies = [
|
|
"mcp>=1.26.0",
|
|
"aiohttp>=3.9.0",
|
|
]
|
|
|
|
[project.scripts]
|
|
workbench = "workbench.cli:main"
|
|
```
|
|
|
|
## INSTALL.md
|
|
|
|
Same AI-guided pattern as kitty-workbench, but simpler (no terminal detection needed):
|
|
|
|
1. Clone + pip install
|
|
2. Detect platform and browser availability
|
|
3. Configure MCP for user's AI CLI
|
|
4. SSH ControlMaster setup (if applicable)
|
|
5. Smoke test
|
|
6. Write `~/workbench/START.md`
|
|
|
|
## README.md
|
|
|
|
Public-facing. Setup is "paste the URL" or "read INSTALL.md". Desktop browser as the display surface. No mention of kitty or terminal splitting. Examples: hardware diagnostics, guided procedures, data collection.
|
|
|
|
## CLI Interface
|
|
|
|
```
|
|
workbench mcp # start MCP server (stdio transport)
|
|
workbench serve <name> # serve a project without MCP (standalone)
|
|
workbench list # list projects
|
|
workbench help # usage
|
|
```
|