feat: workbench MCP server — AI-driven hardware diagnostic tool
MCP server (Python/aiohttp) that lets any AI CLI spin up interactive hardware diagnostic web pages served over LAN with WebSocket live updates and dual-format session logging (markdown + JSONL). 6 tools: scaffold, state, log, read_log, list, stop Split-pane scaffold: diagnostic content + sethmux terminal iframe CLI wrapper: ~/bin/workbench (serve, list, mcp, help) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
mcp>=1.26.0
|
||||
aiohttp>=3.9.0
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
<!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; overflow: hidden; }
|
||||
|
||||
.layout { display: flex; height: 100vh; }
|
||||
.diag-panel { flex: 1; overflow-y: auto; padding: 16px; }
|
||||
.divider { width: 4px; background: var(--border); cursor: col-resize; }
|
||||
.term-panel { width: 40%; min-width: 300px; }
|
||||
.term-panel iframe { width: 100%; height: 100%; border: none; }
|
||||
|
||||
/* Mobile: stack vertically with tabs */
|
||||
@media (max-width: 768px) {
|
||||
.layout { flex-direction: column; }
|
||||
.divider { display: none; }
|
||||
.term-panel { width: 100%; height: 50vh; }
|
||||
.diag-panel { height: 50vh; }
|
||||
}
|
||||
|
||||
/* Log feed at bottom of diag panel */
|
||||
#log-feed { margin-top: 24px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
#log-feed h3 { color: var(--text-dim); font-size: 11px; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 8px; }
|
||||
.log-entry { font-size: 12px; color: var(--text-dim); padding: 4px 0; border-bottom: 1px solid #1a2a1e; }
|
||||
.log-entry .log-time { color: var(--accent); margin-right: 8px; }
|
||||
|
||||
/* Status bar */
|
||||
#status { position: fixed; bottom: 0; left: 0; right: 0; background: var(--panel-bg); border-top: 1px solid var(--border); padding: 4px 12px; font-size: 11px; color: var(--text-dim); z-index: 100; }
|
||||
#status.connected { color: var(--accent); }
|
||||
|
||||
/* Content area where AI pushes diagnostic UI */
|
||||
#content { min-height: 200px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<div class="diag-panel">
|
||||
<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 diagnostic content...</p>
|
||||
</div>
|
||||
<div id="log-feed">
|
||||
<h3>Session Log</h3>
|
||||
<div id="log-entries"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider" id="divider"></div>
|
||||
<div class="term-panel">
|
||||
<iframe src="{{SETHMUX_URL}}" allow="clipboard-read; clipboard-write"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
<div id="status">Connecting...</div>
|
||||
|
||||
<script>
|
||||
const WS_URL = `ws://${location.host}/ws`;
|
||||
let ws, reconnectTimer;
|
||||
|
||||
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 = '';
|
||||
reconnectTimer = 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 has a 'template' field, replace the content area HTML
|
||||
if (state.template) {
|
||||
document.getElementById('content').innerHTML = state.template;
|
||||
}
|
||||
// If state has a 'styles' field, inject/replace a style block
|
||||
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 has a 'script' field, execute it
|
||||
if (state.script) {
|
||||
try { new Function(state.script)(); } catch(e) { console.error('Script error:', e); }
|
||||
}
|
||||
// Store full state for AI-generated scripts to access
|
||||
window.__workbench_state = state;
|
||||
// Save to localStorage as fallback
|
||||
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) {}
|
||||
|
||||
// Draggable divider
|
||||
const divider = document.getElementById('divider');
|
||||
let dragging = false;
|
||||
divider.addEventListener('mousedown', () => dragging = true);
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!dragging) return;
|
||||
const pct = (e.clientX / window.innerWidth) * 100;
|
||||
document.querySelector('.diag-panel').style.flex = 'none';
|
||||
document.querySelector('.diag-panel').style.width = pct + '%';
|
||||
document.querySelector('.term-panel').style.width = (100 - pct) + '%';
|
||||
});
|
||||
document.addEventListener('mouseup', () => dragging = false);
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Workbench MCP Server — AI-driven hardware diagnostic tool.
|
||||
|
||||
Exposes 6 MCP tools for scaffolding, state management, and logging
|
||||
of interactive hardware diagnostic web pages served over LAN.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
WORKBENCH_DIR = Path.home() / "workbench"
|
||||
SCAFFOLD_HTML = Path(__file__).parent / "scaffold.html"
|
||||
DEFAULT_PORT = 8070
|
||||
SETHMUX_URL = os.environ.get("WORKBENCH_SETHMUX_URL", "https://mux.sethpc.xyz")
|
||||
|
||||
# Track active projects: {name: {"port": int, "runner": web.AppRunner, "ws_clients": set}}
|
||||
active_projects: dict = {}
|
||||
|
||||
|
||||
def get_lan_ip() -> str:
|
||||
"""Get the machine's LAN IP address."""
|
||||
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 = DEFAULT_PORT) -> int:
|
||||
"""Find a free port starting from the given port."""
|
||||
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}")
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def project_path(name: str) -> Path:
|
||||
return WORKBENCH_DIR / name
|
||||
|
||||
|
||||
# --- HTTP/WebSocket server per project ---
|
||||
|
||||
async def ws_handler(request):
|
||||
"""WebSocket endpoint for pushing state/log to browser."""
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
project_name = request.app["project_name"]
|
||||
if project_name in active_projects:
|
||||
active_projects[project_name]["ws_clients"].add(ws)
|
||||
try:
|
||||
async for msg in ws:
|
||||
pass # Browser doesn't send us anything we need
|
||||
finally:
|
||||
if project_name in active_projects:
|
||||
active_projects[project_name]["ws_clients"].discard(ws)
|
||||
return ws
|
||||
|
||||
|
||||
async def static_handler(request):
|
||||
"""Serve files from the project directory."""
|
||||
project_name = request.app["project_name"]
|
||||
path = request.match_info.get("path", "index.html") or "index.html"
|
||||
file_path = project_path(project_name) / path
|
||||
if not file_path.exists():
|
||||
return web.Response(status=404, text="Not found")
|
||||
return web.FileResponse(file_path)
|
||||
|
||||
|
||||
async def start_http_server(name: str, port: int) -> web.AppRunner:
|
||||
"""Start an HTTP + WebSocket server for a project."""
|
||||
app = web.Application()
|
||||
app["project_name"] = name
|
||||
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()
|
||||
return runner
|
||||
|
||||
|
||||
async def broadcast_ws(name: str, message: dict):
|
||||
"""Send a message to all WebSocket clients of a project."""
|
||||
if name not in active_projects:
|
||||
return
|
||||
clients = active_projects[name]["ws_clients"]
|
||||
dead = set()
|
||||
for ws in clients:
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
clients -= dead
|
||||
|
||||
|
||||
# --- MCP Tools ---
|
||||
|
||||
mcp = FastMCP("workbench", instructions="Hardware diagnostic workbench — serve interactive diagnostic pages over LAN")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def workbench_scaffold(name: str, title: str, description: str = "") -> str:
|
||||
"""Create a new workbench project with split-pane diagnostic page + sethmux terminal.
|
||||
|
||||
Returns the LAN URL to open on your phone.
|
||||
"""
|
||||
pdir = project_path(name)
|
||||
if pdir.exists():
|
||||
# Already exists — just start the server if not running
|
||||
if name not in active_projects:
|
||||
port = find_free_port()
|
||||
runner = await start_http_server(name, port)
|
||||
active_projects[name] = {"port": port, "runner": runner, "ws_clients": set()}
|
||||
ip = get_lan_ip()
|
||||
port = active_projects[name]["port"]
|
||||
return json.dumps({"path": str(pdir), "url": f"http://{ip}:{port}"})
|
||||
|
||||
pdir.mkdir(parents=True)
|
||||
(pdir / "assets").mkdir()
|
||||
|
||||
# Copy and fill scaffold
|
||||
template = SCAFFOLD_HTML.read_text()
|
||||
html = template.replace("{{TITLE}}", title)
|
||||
html = html.replace("{{DESCRIPTION}}", description)
|
||||
html = html.replace("{{SETHMUX_URL}}", SETHMUX_URL)
|
||||
(pdir / "index.html").write_text(html)
|
||||
|
||||
# Init log files
|
||||
(pdir / "session.md").write_text(f"# {title} — Diagnostic Session\n\nStarted: {now_iso()}\n\n")
|
||||
(pdir / "session.jsonl").write_text("")
|
||||
(pdir / "cost-log.jsonl").write_text(
|
||||
json.dumps({"ts": now_iso(), "event": "session_start", "project": name}) + "\n"
|
||||
)
|
||||
(pdir / "state.json").write_text("{}")
|
||||
|
||||
# Start server
|
||||
port = find_free_port()
|
||||
runner = await start_http_server(name, port)
|
||||
active_projects[name] = {"port": port, "runner": runner, "ws_clients": set()}
|
||||
|
||||
ip = get_lan_ip()
|
||||
return json.dumps({"path": str(pdir), "url": f"http://{ip}:{port}"})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def workbench_state(project: str, state: str) -> str:
|
||||
"""Push state update to the browser via WebSocket.
|
||||
|
||||
The state is arbitrary JSON — the AI decides the schema.
|
||||
Include a 'template' field (HTML string) to replace the diagnostic content area.
|
||||
Include 'styles' for CSS and 'script' for JS to execute.
|
||||
"""
|
||||
state_obj = json.loads(state)
|
||||
|
||||
# Save to disk
|
||||
pdir = project_path(project)
|
||||
if not pdir.exists():
|
||||
return json.dumps({"error": f"Project '{project}' not found. Run workbench_scaffold first."})
|
||||
|
||||
(pdir / "state.json").write_text(json.dumps(state_obj, indent=2))
|
||||
|
||||
# Push to browser
|
||||
await broadcast_ws(project, {"type": "state", "state": state_obj})
|
||||
|
||||
return json.dumps({"ok": True})
|
||||
|
||||
|
||||
@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 (e.g., "R412 measured 1.05M — drifted +16.7%, FAIL")
|
||||
data: Optional JSON for the machine-readable log
|
||||
"""
|
||||
pdir = project_path(project)
|
||||
if not pdir.exists():
|
||||
return json.dumps({"error": f"Project '{project}' not found."})
|
||||
|
||||
ts = now_iso()
|
||||
|
||||
# Append to session.md
|
||||
with open(pdir / "session.md", "a") as f:
|
||||
f.write(f"\n### {ts}\n{entry}\n")
|
||||
|
||||
# Append to session.jsonl
|
||||
data_obj = json.loads(data) if data else {}
|
||||
data_obj["ts"] = ts
|
||||
data_obj["entry"] = entry
|
||||
with open(pdir / "session.jsonl", "a") as f:
|
||||
f.write(json.dumps(data_obj) + "\n")
|
||||
|
||||
# Push to browser
|
||||
await broadcast_ws(project, {"type": "log", "entry": entry})
|
||||
|
||||
return json.dumps({"ok": True})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def workbench_read_log(project: str, tail: int = 20) -> str:
|
||||
"""Read recent session log entries so AI can resume a session."""
|
||||
pdir = project_path(project)
|
||||
if not pdir.exists():
|
||||
return json.dumps({"error": f"Project '{project}' not found."})
|
||||
|
||||
jsonl_path = pdir / "session.jsonl"
|
||||
if not jsonl_path.exists():
|
||||
return json.dumps({"entries": []})
|
||||
|
||||
lines = jsonl_path.read_text().strip().split("\n")
|
||||
recent = lines[-tail:] if len(lines) > tail else lines
|
||||
entries = []
|
||||
for line in recent:
|
||||
if line.strip():
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
entries.append({"raw": line})
|
||||
|
||||
return json.dumps({"entries": entries})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def workbench_list() -> str:
|
||||
"""List all workbench projects and their status."""
|
||||
if not WORKBENCH_DIR.exists():
|
||||
return json.dumps({"projects": []})
|
||||
|
||||
projects = []
|
||||
for d in sorted(WORKBENCH_DIR.iterdir()):
|
||||
if d.is_dir():
|
||||
info = {"name": d.name, "active": d.name in active_projects}
|
||||
if d.name in active_projects:
|
||||
ip = get_lan_ip()
|
||||
port = active_projects[d.name]["port"]
|
||||
info["url"] = f"http://{ip}:{port}"
|
||||
info["ws_clients"] = len(active_projects[d.name]["ws_clients"])
|
||||
projects.append(info)
|
||||
|
||||
return json.dumps({"projects": projects})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def workbench_stop(project: str) -> str:
|
||||
"""Stop the HTTP/WebSocket server for a project and log session end."""
|
||||
if project not in active_projects:
|
||||
return json.dumps({"error": f"Project '{project}' is not running."})
|
||||
|
||||
# Log session end to cost-log
|
||||
pdir = project_path(project)
|
||||
jsonl_path = pdir / "session.jsonl"
|
||||
entry_count = 0
|
||||
if jsonl_path.exists():
|
||||
entry_count = sum(1 for line in jsonl_path.read_text().strip().split("\n") if line.strip())
|
||||
|
||||
cost_entry = {
|
||||
"ts": now_iso(),
|
||||
"event": "session_end",
|
||||
"project": project,
|
||||
"log_entries": entry_count,
|
||||
}
|
||||
with open(pdir / "cost-log.jsonl", "a") as f:
|
||||
f.write(json.dumps(cost_entry) + "\n")
|
||||
|
||||
# Shutdown server
|
||||
runner = active_projects[project]["runner"]
|
||||
await runner.cleanup()
|
||||
del active_projects[project]
|
||||
|
||||
return json.dumps({"ok": True})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="stdio")
|
||||
Reference in New Issue
Block a user