| 1 | #!/usr/bin/env python3 |
| 2 | """MCP server for disposable Linux VMs: Samba appliances and agent-driven desktops.""" |
| 3 | |
| 4 | import json |
| 5 | from pathlib import Path |
| 6 | import shlex |
| 7 | import subprocess |
| 8 | import sys |
| 9 | import uuid |
| 10 | |
| 11 | import linux_vm |
| 12 | from mcp_win7 import shot_blocks, text_result |
| 13 | |
| 14 | |
| 15 | ROOT = Path(__file__).resolve().parent |
| 16 | VM = ROOT / "linux_vm.py" |
| 17 | GUEST = ROOT / "linux_desktop.py" |
| 18 | |
| 19 | |
| 20 | EXEC_DESCRIPTION = """Run a bash script on a desktop clone's X display (:0, 1280x800, |
| 21 | openbox). Returns exit code, stdout, stderr, the active window, and a screenshot |
| 22 | taken shot_delay_ms after the script exits. |
| 23 | |
| 24 | Screenshot pixels are screen coordinates. Put a whole sequence of actions in one |
| 25 | script; one call per click is slow and blind. Input is xdotool: |
| 26 | |
| 27 | xdotool mousemove 640 400 click 1 |
| 28 | xdotool type --delay 5 'literal text, {braces} and all' |
| 29 | xdotool key ctrl+a ctrl+c && xclip -o -selection clipboard |
| 30 | xdotool search --sync --name 'Title' windowactivate --sync |
| 31 | |
| 32 | Start GUI apps in the background (`app &`) so the script can go on driving them; |
| 33 | they outlive the script. Use linux_spawn for anything whose output you want to |
| 34 | read later. Close what you opened when the task is done. A timeout screenshots |
| 35 | the blocked screen, then kills the script's process group, including apps it |
| 36 | started.""" |
| 37 | |
| 38 | UI_DESCRIPTION = """Dump the active application's accessibility (AT-SPI) tree: |
| 39 | role, name, screen rect x,y,w,h, text value and focus for each showing node. |
| 40 | GTK 4 reports zero origins, so use its sizes and a screenshot for placement.""" |
| 41 | |
| 42 | NAME = {"type": "string", "description": "VM name."} |
| 43 | |
| 44 | TOOLS = [ |
| 45 | { |
| 46 | "name": "linux_vm_fetch", |
| 47 | "description": ( |
| 48 | "Download and SHA-512 verify the official Debian 13 arm64 cloud base. " |
| 49 | "The immutable image is stored outside the repository." |
| 50 | ), |
| 51 | "inputSchema": {"type": "object", "properties": {}}, |
| 52 | }, |
| 53 | { |
| 54 | "name": "linux_vm_up", |
| 55 | "description": ( |
| 56 | "Create a Linux clone when absent, then boot it headlessly. Creation settings " |
| 57 | "are ignored for an existing clone. Set desktop for an X display driven by " |
| 58 | "linux_exec, linux_shot and linux_ui. Set wait to return after cloud-init, " |
| 59 | "Samba and (for desktops) display validation. One VM owns the shared lab " |
| 60 | "address 192.168.77.1." |
| 61 | ), |
| 62 | "inputSchema": { |
| 63 | "type": "object", |
| 64 | "properties": {"name": {"type": "string", |
| 65 | "description": "Unique lowercase VM name."}, |
| 66 | "cpus": {"type": "integer", "default": 2, |
| 67 | "minimum": 1, "maximum": 16}, |
| 68 | "memory_mb": {"type": "integer", "default": 2048, |
| 69 | "minimum": 512, "maximum": 65536}, |
| 70 | "disk_gb": {"type": "integer", "default": 16, |
| 71 | "minimum": 8, "maximum": 1024}, |
| 72 | "ssh_port": {"type": "integer", "minimum": 1024, |
| 73 | "maximum": 65535}, |
| 74 | "samba_port": {"type": "integer", "minimum": 1024, |
| 75 | "maximum": 65535}, |
| 76 | "desktop": {"type": "boolean", "default": False}, |
| 77 | "wait": {"type": "boolean", "default": False}, |
| 78 | "timeout": {"type": "integer", "default": 600, |
| 79 | "minimum": 1, "maximum": 900}}, |
| 80 | "required": ["name"], |
| 81 | }, |
| 82 | }, |
| 83 | { |
| 84 | "name": "linux_ssh", |
| 85 | "description": ( |
| 86 | "Run a shell command over the clone's private SSH key. No screenshot. Use " |
| 87 | "/srv/agent for the Samba share exported to Windows as //192.168.77.1/agent." |
| 88 | ), |
| 89 | "inputSchema": { |
| 90 | "type": "object", |
| 91 | "properties": {"name": NAME, |
| 92 | "command": {"type": "string"}, |
| 93 | "timeout": {"type": "integer", "default": 120, |
| 94 | "minimum": 1, "maximum": 900}}, |
| 95 | "required": ["name", "command"], |
| 96 | }, |
| 97 | }, |
| 98 | { |
| 99 | "name": "linux_exec", |
| 100 | "description": EXEC_DESCRIPTION, |
| 101 | "inputSchema": { |
| 102 | "type": "object", |
| 103 | "properties": {"name": NAME, |
| 104 | "script": {"type": "string", "description": "bash source."}, |
| 105 | "shot_delay_ms": {"type": "integer", "default": 500}, |
| 106 | "timeout_ms": {"type": "integer", "default": 60000}}, |
| 107 | "required": ["name", "script"], |
| 108 | }, |
| 109 | }, |
| 110 | { |
| 111 | "name": "linux_shot", |
| 112 | "description": "Screenshot a desktop clone without running anything.", |
| 113 | "inputSchema": {"type": "object", "properties": {"name": NAME}, |
| 114 | "required": ["name"]}, |
| 115 | }, |
| 116 | { |
| 117 | "name": "linux_ui", |
| 118 | "description": UI_DESCRIPTION, |
| 119 | "inputSchema": {"type": "object", "properties": {"name": NAME}, |
| 120 | "required": ["name"]}, |
| 121 | }, |
| 122 | { |
| 123 | "name": "linux_spawn", |
| 124 | "description": ( |
| 125 | "Start a long-lived command as a transient systemd user unit on the display " |
| 126 | "and return its unit name immediately. Read its output with " |
| 127 | "`journalctl --user -u UNIT` and end it with `systemctl --user stop UNIT`." |
| 128 | ), |
| 129 | "inputSchema": { |
| 130 | "type": "object", |
| 131 | "properties": {"name": NAME, |
| 132 | "command": {"type": "string", "description": "bash source."}}, |
| 133 | "required": ["name", "command"], |
| 134 | }, |
| 135 | }, |
| 136 | { |
| 137 | "name": "linux_put", |
| 138 | "description": ( |
| 139 | "Copy a file from this Mac to a clone. The bytes never pass through the " |
| 140 | "conversation, so file size costs nothing." |
| 141 | ), |
| 142 | "inputSchema": { |
| 143 | "type": "object", |
| 144 | "properties": {"name": NAME, |
| 145 | "local": {"type": "string", "description": "Path on the Mac."}, |
| 146 | "remote": {"type": "string", "description": "Path on the clone."}}, |
| 147 | "required": ["name", "local", "remote"], |
| 148 | }, |
| 149 | }, |
| 150 | { |
| 151 | "name": "linux_get", |
| 152 | "description": "Copy a file from a clone back to this Mac.", |
| 153 | "inputSchema": { |
| 154 | "type": "object", |
| 155 | "properties": {"name": NAME, |
| 156 | "remote": {"type": "string", "description": "Path on the clone."}, |
| 157 | "local": {"type": "string", "description": "Path on the Mac."}}, |
| 158 | "required": ["name", "remote", "local"], |
| 159 | }, |
| 160 | }, |
| 161 | { |
| 162 | "name": "linux_vm_down", |
| 163 | "description": ( |
| 164 | "Cleanly stop one VM and delete its overlay, key, and metadata. Set " |
| 165 | "preserve_machine to keep the stopped VM for reproduction or reuse." |
| 166 | ), |
| 167 | "inputSchema": { |
| 168 | "type": "object", |
| 169 | "properties": {"name": NAME, |
| 170 | "timeout": {"type": "integer", "default": 60, |
| 171 | "minimum": 1, "maximum": 120}, |
| 172 | "preserve_machine": {"type": "boolean", "default": False}}, |
| 173 | "required": ["name"], |
| 174 | }, |
| 175 | }, |
| 176 | { |
| 177 | "name": "linux_vm_status", |
| 178 | "description": "List every Linux VM, or report whether one is absent, stopped, or running.", |
| 179 | "inputSchema": {"type": "object", "properties": {"name": {"type": "string"}}}, |
| 180 | }, |
| 181 | ] |
| 182 | |
| 183 | |
| 184 | def text(value): |
| 185 | return [{"type": "text", "text": value}] |
| 186 | |
| 187 | |
| 188 | def run(argv, timeout=120): |
| 189 | process = subprocess.run([sys.executable, str(VM)] + argv, capture_output=True, |
| 190 | text=True, timeout=timeout) |
| 191 | output = (process.stdout + process.stderr).strip() |
| 192 | return text(output or "ok"), process.returncode != 0 |
| 193 | |
| 194 | |
| 195 | def ssh(name, command, timeout, **streams): |
| 196 | try: |
| 197 | return subprocess.run(linux_vm.ssh_argv(name, command), timeout=timeout, **streams) |
| 198 | except subprocess.TimeoutExpired: |
| 199 | raise SystemExit("SSH to %s timed out after %d seconds" % (name, timeout)) |
| 200 | |
| 201 | |
| 202 | def guest(name, request, timeout=60): |
| 203 | source = GUEST.read_text() + "\nmain(%r)\n" % json.dumps(request) |
| 204 | process = ssh(name, "python3 -", timeout, input=source, capture_output=True, text=True) |
| 205 | if process.returncode: |
| 206 | raise SystemExit(process.stderr.strip() or "guest exited %d" % process.returncode) |
| 207 | return json.loads(process.stdout) |
| 208 | |
| 209 | |
| 210 | def call_tool(name, args): |
| 211 | if name == "linux_vm_fetch": |
| 212 | return run(["fetch"], 900) |
| 213 | if name == "linux_vm_up": |
| 214 | argv = ["up", args["name"]] |
| 215 | for key, option in (("cpus", "--cpus"), ("memory_mb", "--memory"), |
| 216 | ("disk_gb", "--disk"), ("ssh_port", "--ssh-port"), |
| 217 | ("samba_port", "--samba-port")): |
| 218 | if args.get(key) is not None: |
| 219 | argv += [option, str(args[key])] |
| 220 | if args.get("desktop"): |
| 221 | argv.append("--desktop") |
| 222 | timeout = args.get("timeout", 600) |
| 223 | if args.get("wait"): |
| 224 | argv += ["--wait", "--timeout", str(timeout)] |
| 225 | return run(argv, timeout + 15 if args.get("wait") else 120) |
| 226 | if name == "linux_ssh": |
| 227 | timeout = args.get("timeout", 120) |
| 228 | return run(["ssh", args["name"], "--", args["command"]], timeout) |
| 229 | if name == "linux_vm_down": |
| 230 | timeout = args.get("timeout", 60) |
| 231 | argv = ["down", args["name"], "--timeout", str(timeout)] |
| 232 | if args.get("preserve_machine"): |
| 233 | argv.append("--preserve-machine") |
| 234 | return run(argv, timeout + 15) |
| 235 | if name == "linux_vm_status": |
| 236 | return run(["status"] + ([args["name"]] if args.get("name") else [])) |
| 237 | vm = args["name"] |
| 238 | if name == "linux_exec": |
| 239 | timeout_ms = args.get("timeout_ms", 60000) |
| 240 | resp = guest(vm, {"verb": "exec", "script": args["script"], "timeout_ms": timeout_ms, |
| 241 | "shot_delay_ms": args.get("shot_delay_ms", 500)}, |
| 242 | timeout_ms // 1000 + 30) |
| 243 | return shot_blocks(resp, text_result(resp) + "\n"), False |
| 244 | if name == "linux_shot": |
| 245 | return shot_blocks(guest(vm, {"verb": "shot"})), False |
| 246 | if name == "linux_ui": |
| 247 | resp = guest(vm, {"verb": "ui"}) |
| 248 | win = resp.get("win") or {} |
| 249 | header = 'window: "%s" (%s)\n' % (win.get("title", ""), win.get("class", "")) |
| 250 | return text(header + resp["controls"]), False |
| 251 | if name == "linux_spawn": |
| 252 | unit = "spawn-" + uuid.uuid4().hex[:8] |
| 253 | process = ssh(vm, "systemd-run --user --collect --quiet --unit=%s -- bash -c %s" |
| 254 | % (unit, shlex.quote(args["command"])), 30, |
| 255 | capture_output=True, text=True) |
| 256 | return text(process.stderr.strip() or "unit=" + unit), process.returncode != 0 |
| 257 | if name == "linux_put": |
| 258 | remote = shlex.quote(args["remote"]) |
| 259 | with open(args["local"], "rb") as source: |
| 260 | process = ssh(vm, 'mkdir -p -- "$(dirname -- %s)" && cat > %s' % (remote, remote), |
| 261 | 600, stdin=source, capture_output=True) |
| 262 | if process.returncode: |
| 263 | return text(process.stderr.decode(errors="replace").strip()), True |
| 264 | return text("wrote %d bytes to %s" % (Path(args["local"]).stat().st_size, |
| 265 | args["remote"])), False |
| 266 | if name == "linux_get": |
| 267 | local = Path(args["local"]) |
| 268 | with local.open("wb") as target: |
| 269 | process = ssh(vm, "cat -- %s" % shlex.quote(args["remote"]), 600, |
| 270 | stdout=target, stderr=subprocess.PIPE) |
| 271 | if process.returncode: |
| 272 | local.unlink() |
| 273 | return text(process.stderr.decode(errors="replace").strip()), True |
| 274 | return text("read %d bytes to %s" % (local.stat().st_size, local.resolve())), False |
| 275 | return text("unknown tool: %s" % name), True |
| 276 | |
| 277 | |
| 278 | def handle(method, params): |
| 279 | if method == "initialize": |
| 280 | return {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, |
| 281 | "serverInfo": {"name": "one-linux", "version": "1.0.0"}} |
| 282 | if method == "ping": |
| 283 | return {} |
| 284 | if method == "tools/list": |
| 285 | return {"tools": TOOLS} |
| 286 | if method == "tools/call": |
| 287 | try: |
| 288 | content, is_error = call_tool(params.get("name"), params.get("arguments") or {}) |
| 289 | except SystemExit as e: # linux_vm reports every failure this way |
| 290 | content, is_error = text(str(e)), True |
| 291 | # Never structuredContent: Codex then drops content[] and its screenshot. |
| 292 | result = {"content": content, "_meta": {"codex/imageDetail": "original"}} |
| 293 | if is_error: |
| 294 | result["isError"] = True |
| 295 | return result |
| 296 | return None |
| 297 | |
| 298 | |
| 299 | def serve(): |
| 300 | for line in sys.stdin: |
| 301 | try: |
| 302 | message = json.loads(line) |
| 303 | if message.get("id") is None: |
| 304 | continue |
| 305 | result = handle(message.get("method"), message.get("params") or {}) |
| 306 | if result is None: |
| 307 | reply = {"jsonrpc": "2.0", "id": message["id"], |
| 308 | "error": {"code": -32601, "message": "unknown method"}} |
| 309 | else: |
| 310 | reply = {"jsonrpc": "2.0", "id": message["id"], "result": result} |
| 311 | except Exception as e: |
| 312 | reply = {"jsonrpc": "2.0", "id": None, |
| 313 | "error": {"code": -32603, "message": "%s: %s" % |
| 314 | (type(e).__name__, e)}} |
| 315 | print(json.dumps(reply), flush=True) |
| 316 | |
| 317 | |
| 318 | if __name__ == "__main__": |
| 319 | serve() |