1#!/usr/bin/env python3
2"""MCP server for disposable Linux VMs: Samba appliances and agent-driven desktops."""
3
4import json
5from pathlib import Path
6import shlex
7import subprocess
8import sys
9import uuid
10
11import linux_vm
12from mcp_win7 import shot_blocks, text_result
13
14
15ROOT = Path(__file__).resolve().parent
16VM = ROOT / "linux_vm.py"
17GUEST = ROOT / "linux_desktop.py"
18
19
20EXEC_DESCRIPTION = """Run a bash script on a desktop clone's X display (:0, 1280x800,
21openbox). Returns exit code, stdout, stderr, the active window, and a screenshot
22taken shot_delay_ms after the script exits.
23
24Screenshot pixels are screen coordinates. Put a whole sequence of actions in one
25script; 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
32Start GUI apps in the background (`app &`) so the script can go on driving them;
33they outlive the script. Use linux_spawn for anything whose output you want to
34read later. Close what you opened when the task is done. A timeout screenshots
35the blocked screen, then kills the script's process group, including apps it
36started."""
37
38UI_DESCRIPTION = """Dump the active application's accessibility (AT-SPI) tree:
39role, name, screen rect x,y,w,h, text value and focus for each showing node.
40GTK 4 reports zero origins, so use its sizes and a screenshot for placement."""
41
42NAME = {"type": "string", "description": "VM name."}
43
44TOOLS = [
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
184def text(value):
185 return [{"type": "text", "text": value}]
186
187
188def 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
195def 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
202def 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
210def 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
278def 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
299def 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
318if __name__ == "__main__":
319 serve()