Initial mcqemu: FastMCP server with lifecycle, image, snapshot, and guest-agent tools
This commit is contained in:
commit
8e7286c474
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
*.qcow2
|
||||
*.img
|
||||
*.iso
|
||||
.env
|
||||
60
README.md
Normal file
60
README.md
Normal file
@ -0,0 +1,60 @@
|
||||
# mcqemu
|
||||
|
||||
An MCP server that lets LLM agents manage QEMU virtual machines: launch and
|
||||
stop VMs, inspect them over QMP, manage disk images with qemu-img, take live
|
||||
snapshots, and run commands inside guests through qemu-guest-agent.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Linux with QEMU installed (`qemu-system-*` and `qemu-img` on PATH)
|
||||
- `/dev/kvm` access for hardware acceleration (optional — TCG emulation works
|
||||
without it, just slower)
|
||||
- Python 3.11+ managed with [uv](https://docs.astral.sh/uv/)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# From this checkout
|
||||
uv sync
|
||||
|
||||
# Add to Claude Code
|
||||
claude mcp add mcqemu -- uv run --directory /path/to/mcqemu mcqemu
|
||||
```
|
||||
|
||||
## What it can do
|
||||
|
||||
| Group | Tools |
|
||||
|---|---|
|
||||
| Lifecycle | `launch_vm`, `stop_vm`, `pause_vm`, `resume_vm`, `attach_vm`, `forget_vm` |
|
||||
| Inspect | `list_vms`, `vm_info` |
|
||||
| Live snapshots | `vm_snapshot_create` / `restore` / `delete` / `list` |
|
||||
| Disk images | `image_create`, `image_info`, `image_convert`, `image_resize`, `image_snapshot_*` |
|
||||
| Guest agent | `guest_ping`, `guest_info`, `guest_exec`, `guest_file_read`, `guest_file_write` |
|
||||
|
||||
VMs are daemonized QEMU processes with QMP control sockets, so they survive
|
||||
MCP server restarts. The registry lives in `~/.local/share/mcqemu/`, sockets
|
||||
in `$XDG_RUNTIME_DIR/mcqemu/`.
|
||||
|
||||
Guest tools (`guest_*`) need `qemu-guest-agent` installed inside the guest OS;
|
||||
the host-side virtio-serial channel is wired on every launch, so installing
|
||||
the agent in the guest is the only step.
|
||||
|
||||
## Quick start
|
||||
|
||||
```
|
||||
image_create(path="~/vms/test.qcow2", size="10G")
|
||||
launch_vm(name="test", disks=["~/vms/test.qcow2"], iso="~/isos/alpine.iso",
|
||||
port_forwards=["2222:22"])
|
||||
# ... install the OS via the serial console log ...
|
||||
stop_vm(name="test")
|
||||
launch_vm(name="test", disks=["~/vms/test.qcow2"])
|
||||
guest_exec(name="test", command="uname", args=["-a"])
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
uv run pytest # unit tests (QMP and subprocess mocked)
|
||||
uv run pytest -m integration # boots a real tiny VM (needs QEMU installed)
|
||||
uv run ruff check .
|
||||
```
|
||||
55
pyproject.toml
Normal file
55
pyproject.toml
Normal file
@ -0,0 +1,55 @@
|
||||
[project]
|
||||
name = "mcqemu"
|
||||
version = "2026.8.16"
|
||||
description = "MCP server for managing QEMU virtual machines"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
authors = [{ name = "Ryan Malloy", email = "ryan@supported.systems" }]
|
||||
license = "MIT"
|
||||
keywords = ["mcp", "qemu", "qmp", "virtualization", "kvm"]
|
||||
dependencies = [
|
||||
"fastmcp>=3.4.7,<4",
|
||||
"qemu.qmp>=0.0.6",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mcqemu = "mcqemu.server:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8",
|
||||
"pytest-asyncio>=1.0",
|
||||
"ruff>=0.12",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/mcqemu"]
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
exclude = [
|
||||
"CLAUDE.md",
|
||||
".env",
|
||||
".mcp.json",
|
||||
"tests/",
|
||||
".pytest_cache/",
|
||||
".ruff_cache/",
|
||||
"dist/",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
src = ["src", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "SIM", "ASYNC"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
markers = [
|
||||
"integration: boots real QEMU (run with '-m integration')",
|
||||
]
|
||||
addopts = "-m 'not integration'"
|
||||
3
src/mcqemu/__init__.py
Normal file
3
src/mcqemu/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""mcqemu — MCP server for managing QEMU virtual machines."""
|
||||
|
||||
__version__ = "2026.8.16"
|
||||
103
src/mcqemu/config.py
Normal file
103
src/mcqemu/config.py
Normal file
@ -0,0 +1,103 @@
|
||||
"""Path resolution, launch defaults, and validation helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import platform
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# UEFI firmware per arch. x64 ships split CODE/VARS images sized for pflash;
|
||||
# aarch64's QEMU_EFI.fd is a 2 MB image meant for -bios (pflash wants 64 MB pads).
|
||||
FIRMWARE_MAP: dict[str, dict[str, str]] = {
|
||||
"x86_64": {
|
||||
"mode": "pflash",
|
||||
"code": "/usr/share/edk2/x64/OVMF_CODE.4m.fd",
|
||||
"vars": "/usr/share/edk2/x64/OVMF_VARS.4m.fd",
|
||||
},
|
||||
"aarch64": {
|
||||
"mode": "bios",
|
||||
"code": "/usr/share/edk2/aarch64/QEMU_EFI.fd",
|
||||
},
|
||||
"riscv64": {
|
||||
"mode": "bios",
|
||||
"code": "/usr/share/edk2/riscv64/RISCV_VIRT_CODE.fd",
|
||||
},
|
||||
}
|
||||
|
||||
# Default -machine per arch; arches not listed use QEMU's own default.
|
||||
DEFAULT_MACHINE: dict[str, str] = {
|
||||
"x86_64": "q35",
|
||||
"i386": "q35",
|
||||
"aarch64": "virt",
|
||||
"riscv64": "virt",
|
||||
"riscv32": "virt",
|
||||
}
|
||||
|
||||
_VM_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,47}$")
|
||||
|
||||
|
||||
def valid_vm_name(name: str) -> bool:
|
||||
"""Names become directory and socket path components, so keep them tame."""
|
||||
return bool(_VM_NAME_RE.match(name))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
state_dir: Path
|
||||
runtime_dir: Path
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> Config:
|
||||
state = os.environ.get("MCQEMU_STATE_DIR")
|
||||
if state:
|
||||
state_dir = Path(state)
|
||||
else:
|
||||
xdg_data = os.environ.get("XDG_DATA_HOME", "~/.local/share")
|
||||
state_dir = Path(xdg_data).expanduser() / "mcqemu"
|
||||
|
||||
runtime = os.environ.get("MCQEMU_RUNTIME_DIR")
|
||||
if runtime:
|
||||
runtime_dir = Path(runtime)
|
||||
else:
|
||||
xdg_run = os.environ.get("XDG_RUNTIME_DIR")
|
||||
base = Path(xdg_run) if xdg_run else Path(f"/tmp/mcqemu-{os.getuid()}")
|
||||
runtime_dir = base / "mcqemu" if xdg_run else base
|
||||
|
||||
return cls(state_dir=state_dir, runtime_dir=runtime_dir)
|
||||
|
||||
@property
|
||||
def registry_path(self) -> Path:
|
||||
return self.state_dir / "vms.json"
|
||||
|
||||
def vm_state_dir(self, name: str) -> Path:
|
||||
return self.state_dir / "vms" / name
|
||||
|
||||
def vm_runtime_dir(self, name: str) -> Path:
|
||||
return self.runtime_dir / name
|
||||
|
||||
|
||||
def host_arch() -> str:
|
||||
return platform.machine()
|
||||
|
||||
|
||||
def kvm_available() -> bool:
|
||||
return os.access("/dev/kvm", os.R_OK | os.W_OK)
|
||||
|
||||
|
||||
def resolve_image_path(path: str, must_exist: bool = True) -> Path:
|
||||
"""Normalize a disk-image path and reject things that would surprise QEMU.
|
||||
|
||||
Raises ValueError with a human-readable reason; tools wrap it in ToolError.
|
||||
"""
|
||||
p = Path(path).expanduser().resolve()
|
||||
if p.exists():
|
||||
if not p.is_file():
|
||||
raise ValueError(f"{p} exists but is not a regular file")
|
||||
else:
|
||||
if must_exist:
|
||||
raise ValueError(f"image not found: {p}")
|
||||
if not p.parent.is_dir():
|
||||
raise ValueError(f"parent directory does not exist: {p.parent}")
|
||||
return p
|
||||
31
src/mcqemu/errors.py
Normal file
31
src/mcqemu/errors.py
Normal file
@ -0,0 +1,31 @@
|
||||
"""Map QMP / subprocess / registry failures to actionable ToolErrors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
|
||||
def vm_not_found(name: str, known: list[str]) -> ToolError:
|
||||
hint = f"Known VMs: {', '.join(sorted(known))}" if known else "No VMs are registered."
|
||||
return ToolError(f"No VM named {name!r}. {hint} Use list_vms to see current state.")
|
||||
|
||||
|
||||
def qmp_unreachable(name: str, socket_path: str, detail: str, qemu_log: str | None) -> ToolError:
|
||||
log_hint = f" Check the QEMU log at {qemu_log} for crash details." if qemu_log else ""
|
||||
return ToolError(
|
||||
f"Cannot reach QMP socket for VM {name!r} at {socket_path} ({detail}). "
|
||||
f"The VM has likely exited — run list_vms to refresh status.{log_hint}"
|
||||
)
|
||||
|
||||
|
||||
def qmp_command_failed(command: str, error_class: str, desc: str) -> ToolError:
|
||||
return ToolError(f"QMP command {command!r} failed [{error_class}]: {desc}")
|
||||
|
||||
|
||||
def guest_agent_unavailable(name: str) -> ToolError:
|
||||
return ToolError(
|
||||
f"The guest agent in VM {name!r} is not responding. The guest OS must have "
|
||||
"qemu-guest-agent installed and running (e.g. 'apt install qemu-guest-agent' "
|
||||
"or 'apk add qemu-guest-agent', then start its service). The virtio-serial "
|
||||
"channel is already wired on the host side."
|
||||
)
|
||||
193
src/mcqemu/launcher.py
Normal file
193
src/mcqemu/launcher.py
Normal file
@ -0,0 +1,193 @@
|
||||
"""Build QEMU command lines (pure) and spawn daemonized VMs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from .config import DEFAULT_MACHINE, FIRMWARE_MAP, Config, host_arch, kvm_available
|
||||
from .models import VMConfig, VMRecord
|
||||
from .qemu_img import probe_format
|
||||
|
||||
_FWD_RE = re.compile(r"^(\d{1,5}):(\d{1,5})$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VMPaths:
|
||||
"""Everything path-shaped that one VM needs, derived from Config."""
|
||||
|
||||
qmp_socket: Path
|
||||
qga_socket: Path
|
||||
pidfile: Path
|
||||
qemu_log: Path
|
||||
serial_log: Path
|
||||
uefi_vars: Path
|
||||
|
||||
@classmethod
|
||||
def for_vm(cls, config: Config, name: str) -> VMPaths:
|
||||
run = config.vm_runtime_dir(name)
|
||||
state = config.vm_state_dir(name)
|
||||
return cls(
|
||||
qmp_socket=run / "qmp.sock",
|
||||
qga_socket=run / "qga.sock",
|
||||
pidfile=run / "pid",
|
||||
qemu_log=state / "qemu.log",
|
||||
serial_log=state / "serial.log",
|
||||
uefi_vars=state / "uefi-vars.fd",
|
||||
)
|
||||
|
||||
|
||||
def pick_accel(arch: str) -> str:
|
||||
"""KVM only helps when guest arch matches the host; otherwise TCG emulation."""
|
||||
return "kvm" if arch == host_arch() and kvm_available() else "tcg"
|
||||
|
||||
|
||||
def build_cmdline(
|
||||
cfg: VMConfig,
|
||||
paths: VMPaths,
|
||||
*,
|
||||
accel: str,
|
||||
disk_formats: dict[str, str],
|
||||
) -> list[str]:
|
||||
"""Pure function: (config, paths, probed formats) -> argv for qemu-system-*.
|
||||
|
||||
Deliberately does no I/O so it can be exhaustively table-tested.
|
||||
"""
|
||||
args: list[str] = [f"qemu-system-{cfg.arch}", "-name", cfg.name]
|
||||
|
||||
machine = cfg.machine or DEFAULT_MACHINE.get(cfg.arch)
|
||||
if machine:
|
||||
args += ["-machine", f"{machine},accel={accel}"]
|
||||
else:
|
||||
args += ["-accel", accel]
|
||||
args += ["-cpu", "host" if accel == "kvm" else "max"]
|
||||
args += ["-m", str(cfg.memory_mb), "-smp", str(cfg.cpus)]
|
||||
|
||||
if cfg.firmware == "uefi":
|
||||
fw = FIRMWARE_MAP.get(cfg.arch)
|
||||
if fw is None:
|
||||
raise ToolError(
|
||||
f"No UEFI firmware known for arch {cfg.arch!r} "
|
||||
f"(available: {', '.join(FIRMWARE_MAP)}). Use firmware='bios'."
|
||||
)
|
||||
if fw["mode"] == "pflash":
|
||||
args += [
|
||||
"-drive", f"if=pflash,format=raw,readonly=on,file={fw['code']}",
|
||||
"-drive", f"if=pflash,format=raw,file={paths.uefi_vars}",
|
||||
]
|
||||
else:
|
||||
args += ["-bios", fw["code"]]
|
||||
|
||||
for disk in cfg.disks:
|
||||
fmt = disk_formats[disk]
|
||||
args += ["-drive", f"file={disk},if=virtio,format={fmt}"]
|
||||
|
||||
if cfg.iso:
|
||||
args += ["-drive", f"file={cfg.iso},media=cdrom,readonly=on"]
|
||||
if cfg.disks:
|
||||
# Boot the installer once; subsequent boots hit the disk.
|
||||
args += ["-boot", "once=d"]
|
||||
|
||||
if cfg.no_net:
|
||||
args += ["-nic", "none"]
|
||||
else:
|
||||
netdev = "user,id=net0"
|
||||
for fwd in cfg.port_forwards:
|
||||
m = _FWD_RE.match(fwd)
|
||||
if not m:
|
||||
raise ToolError(
|
||||
f"Invalid port forward {fwd!r} — expected 'HOSTPORT:GUESTPORT', e.g. '2222:22'."
|
||||
)
|
||||
netdev += f",hostfwd=tcp::{m.group(1)}-:{m.group(2)}"
|
||||
args += ["-netdev", netdev, "-device", "virtio-net-pci,netdev=net0"]
|
||||
|
||||
args += [
|
||||
"-display", "none",
|
||||
"-serial", f"file:{paths.serial_log}",
|
||||
"-qmp", f"unix:{paths.qmp_socket},server=on,wait=off",
|
||||
"-chardev", f"socket,id=qga0,path={paths.qga_socket},server=on,wait=off",
|
||||
"-device", "virtio-serial",
|
||||
"-device", "virtserialport,chardev=qga0,name=org.qemu.guest_agent.0",
|
||||
"-daemonize",
|
||||
"-pidfile", str(paths.pidfile),
|
||||
"-D", str(paths.qemu_log),
|
||||
]
|
||||
args += cfg.extra_args
|
||||
return args
|
||||
|
||||
|
||||
async def spawn_vm(cfg: VMConfig, config: Config) -> VMRecord:
|
||||
"""Probe disks, prepare per-VM dirs, launch QEMU daemonized, return a record.
|
||||
|
||||
QEMU's -daemonize semantics: the foreground process exits 0 only after the
|
||||
VM is fully initialized (QMP socket listening), so awaiting it gives us a
|
||||
synchronous verdict with real stderr on failure.
|
||||
"""
|
||||
binary = shutil.which(f"qemu-system-{cfg.arch}")
|
||||
if binary is None:
|
||||
raise ToolError(
|
||||
f"qemu-system-{cfg.arch} not found on PATH — is that arch installed? "
|
||||
"(Arch Linux: pacman -S qemu-full)"
|
||||
)
|
||||
|
||||
paths = VMPaths.for_vm(config, cfg.name)
|
||||
config.vm_runtime_dir(cfg.name).mkdir(parents=True, exist_ok=True)
|
||||
config.vm_state_dir(cfg.name).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Leftover sockets from a previous life of this name confuse liveness checks.
|
||||
for stale in (paths.qmp_socket, paths.qga_socket, paths.pidfile):
|
||||
stale.unlink(missing_ok=True)
|
||||
|
||||
disk_formats = {disk: await probe_format(disk) for disk in cfg.disks}
|
||||
|
||||
if cfg.firmware == "uefi":
|
||||
fw = FIRMWARE_MAP.get(cfg.arch, {})
|
||||
if fw.get("mode") == "pflash" and not paths.uefi_vars.exists():
|
||||
shutil.copy(fw["vars"], paths.uefi_vars)
|
||||
|
||||
cmdline = build_cmdline(cfg, paths, accel=pick_accel(cfg.arch), disk_formats=disk_formats)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
binary,
|
||||
*cmdline[1:],
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
detail = stderr.decode(errors="replace").strip() or f"exit code {proc.returncode}"
|
||||
raise ToolError(f"QEMU failed to launch VM {cfg.name!r}: {detail}")
|
||||
|
||||
try:
|
||||
pid = int(paths.pidfile.read_text().strip())
|
||||
except (OSError, ValueError) as e:
|
||||
raise ToolError(
|
||||
f"QEMU launched but pidfile {paths.pidfile} is unreadable ({e}) — "
|
||||
"the VM state is unknown; check the QEMU log."
|
||||
) from e
|
||||
|
||||
accel = pick_accel(cfg.arch)
|
||||
return VMRecord(
|
||||
name=cfg.name,
|
||||
source="spawned",
|
||||
qmp_socket=str(paths.qmp_socket),
|
||||
qga_socket=str(paths.qga_socket),
|
||||
pidfile=str(paths.pidfile),
|
||||
pid=pid,
|
||||
binary=binary,
|
||||
arch=cfg.arch,
|
||||
accel=accel,
|
||||
cmdline=cmdline,
|
||||
config=cfg.__dict__.copy(),
|
||||
qemu_log=str(paths.qemu_log),
|
||||
serial_log=str(paths.serial_log),
|
||||
created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
)
|
||||
51
src/mcqemu/models.py
Normal file
51
src/mcqemu/models.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""Dataclasses shared across the server: VM configuration and registry records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Any, Literal
|
||||
|
||||
|
||||
@dataclass
|
||||
class VMConfig:
|
||||
"""User-facing launch parameters, persisted so relaunches are reproducible."""
|
||||
|
||||
name: str
|
||||
arch: str = "x86_64"
|
||||
machine: str | None = None
|
||||
memory_mb: int = 2048
|
||||
cpus: int = 2
|
||||
disks: list[str] = field(default_factory=list)
|
||||
iso: str | None = None
|
||||
firmware: Literal["bios", "uefi"] = "bios"
|
||||
port_forwards: list[str] = field(default_factory=list)
|
||||
no_net: bool = False
|
||||
extra_args: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VMRecord:
|
||||
"""A registry entry for one VM — spawned by us or attached externally."""
|
||||
|
||||
name: str
|
||||
source: Literal["spawned", "attached"]
|
||||
qmp_socket: str
|
||||
qga_socket: str | None = None
|
||||
pidfile: str | None = None
|
||||
pid: int | None = None
|
||||
binary: str | None = None
|
||||
arch: str | None = None
|
||||
accel: str | None = None
|
||||
cmdline: list[str] = field(default_factory=list)
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
qemu_log: str | None = None
|
||||
serial_log: str | None = None
|
||||
created_at: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> VMRecord:
|
||||
known = {f for f in cls.__dataclass_fields__}
|
||||
return cls(**{k: v for k, v in data.items() if k in known})
|
||||
29
src/mcqemu/prompts.py
Normal file
29
src/mcqemu/prompts.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""MCP prompts: reusable recipes for common VM workflows."""
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
@mcp.prompt(
|
||||
name="provision_test_vm",
|
||||
description="Step-by-step recipe for provisioning a fresh test VM from an installer ISO.",
|
||||
)
|
||||
def provision_test_vm(os_hint: str = "a small Linux distro like Alpine") -> str:
|
||||
return f"""Provision a fresh test VM running {os_hint} using the mcqemu tools:
|
||||
|
||||
1. Create a disk: image_create(path="~/vms/test.qcow2", size="10G") — qcow2 grows
|
||||
on demand, so a generous virtual size is free.
|
||||
2. Launch with the installer: launch_vm(name="test", disks=["~/vms/test.qcow2"],
|
||||
iso="/path/to/installer.iso", port_forwards=["2222:22"], memory_mb=2048).
|
||||
The VM boots the ISO first, then the disk on later boots.
|
||||
3. Watch progress through the serial log (path is in the launch result) — many
|
||||
installers support serial consoles; check vm_info for status.
|
||||
4. After installation completes inside the guest, stop_vm(name="test") and
|
||||
launch_vm again WITHOUT the iso parameter to boot from disk.
|
||||
5. Install qemu-guest-agent inside the guest, then verify with
|
||||
guest_ping(name="test") — that unlocks guest_exec and guest_file_* tools.
|
||||
6. SSH is reachable at host port 2222 (forwarded to guest port 22) if the guest
|
||||
runs sshd.
|
||||
|
||||
Before starting: confirm the ISO path exists, and pick VM names that are short
|
||||
and filesystem-safe."""
|
||||
55
src/mcqemu/qemu_img.py
Normal file
55
src/mcqemu/qemu_img.py
Normal file
@ -0,0 +1,55 @@
|
||||
"""Async wrapper around the qemu-img CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
QEMU_IMG = "qemu-img"
|
||||
|
||||
|
||||
async def run_qemu_img(*args: str) -> str:
|
||||
"""Run qemu-img, returning stdout. Raises ToolError on failure."""
|
||||
binary = shutil.which(QEMU_IMG)
|
||||
if binary is None:
|
||||
raise ToolError("qemu-img not found on PATH — install the qemu-img package.")
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
binary,
|
||||
*args,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
err = stderr.decode(errors="replace").strip()
|
||||
if "Failed to get" in err and "lock" in err:
|
||||
raise ToolError(
|
||||
f"qemu-img: image is locked — it is attached to a running VM. "
|
||||
f"Stop the VM first. Underlying error: {err.splitlines()[-1]}"
|
||||
)
|
||||
tail = "\n".join(err.splitlines()[-4:]) or f"exit code {proc.returncode}"
|
||||
raise ToolError(f"qemu-img {args[0]} failed: {tail}")
|
||||
return stdout.decode(errors="replace")
|
||||
|
||||
|
||||
async def image_info_json(path: str, backing_chain: bool = False) -> Any:
|
||||
args = ["info", "--output=json"]
|
||||
if backing_chain:
|
||||
args.append("--backing-chain")
|
||||
args.append(path)
|
||||
out = await run_qemu_img(*args)
|
||||
return json.loads(out)
|
||||
|
||||
|
||||
async def probe_format(path: str) -> str:
|
||||
"""Detect an image's format so QEMU never has to guess (raw vs qcow2
|
||||
ambiguity is a classic security/correctness trap)."""
|
||||
info = await image_info_json(path)
|
||||
if isinstance(info, list): # --backing-chain shape safety
|
||||
info = info[0]
|
||||
return info["format"]
|
||||
56
src/mcqemu/qga.py
Normal file
56
src/mcqemu/qga.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""Guest-agent sessions.
|
||||
|
||||
The QEMU guest agent speaks the QMP wire protocol but sends no greeting and
|
||||
does no capability negotiation, so the same qemu.qmp client works with those
|
||||
two switches off. A guest-sync handshake (mandatory per the QGA protocol docs)
|
||||
doubles as our "is an agent actually alive in there?" probe — its timeout is
|
||||
the signal that the guest lacks qemu-guest-agent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator
|
||||
|
||||
from fastmcp.exceptions import ToolError
|
||||
from qemu.qmp import QMPClient
|
||||
|
||||
from .errors import guest_agent_unavailable
|
||||
from .models import VMRecord
|
||||
|
||||
SYNC_TIMEOUT = 3.0
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def qga_session(record: VMRecord) -> AsyncIterator[QMPClient]:
|
||||
if not record.qga_socket:
|
||||
raise ToolError(
|
||||
f"VM {record.name!r} has no guest-agent socket registered. "
|
||||
"For attached VMs, re-attach with qga_socket= pointing at the "
|
||||
"virtio-serial chardev socket."
|
||||
)
|
||||
client = QMPClient(f"{record.name}-qga")
|
||||
client.await_greeting = False
|
||||
client.negotiate = False
|
||||
try:
|
||||
try:
|
||||
await asyncio.wait_for(client.connect(record.qga_socket), timeout=SYNC_TIMEOUT)
|
||||
token = secrets.randbelow(2**31)
|
||||
reply = await asyncio.wait_for(
|
||||
client.execute("guest-sync", {"id": token}), timeout=SYNC_TIMEOUT
|
||||
)
|
||||
if reply != token:
|
||||
raise guest_agent_unavailable(record.name)
|
||||
except (asyncio.TimeoutError, OSError):
|
||||
raise guest_agent_unavailable(record.name) from None
|
||||
except ToolError:
|
||||
raise
|
||||
except Exception: # qemu.qmp ConnectError et al.
|
||||
raise guest_agent_unavailable(record.name) from None
|
||||
yield client
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
await client.disconnect()
|
||||
67
src/mcqemu/qmp.py
Normal file
67
src/mcqemu/qmp.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""Scoped QMP sessions over qemu.qmp's asyncio client.
|
||||
|
||||
QEMU's QMP unix socket accepts one client at a time, so every tool call
|
||||
opens a short-lived session and disconnects — the socket stays free for
|
||||
qmp-shell or any other tooling between calls, and there is no stale
|
||||
connection state to reconcile after either side restarts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
from qemu.qmp import ExecuteError, QMPClient
|
||||
|
||||
from .errors import qmp_command_failed, qmp_unreachable
|
||||
from .models import VMRecord
|
||||
|
||||
CONNECT_TIMEOUT = 5.0
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def qmp_session(record: VMRecord) -> AsyncIterator[QMPClient]:
|
||||
client = QMPClient(record.name)
|
||||
try:
|
||||
try:
|
||||
await asyncio.wait_for(client.connect(record.qmp_socket), timeout=CONNECT_TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
raise qmp_unreachable(
|
||||
record.name, record.qmp_socket, "connect timed out", record.qemu_log
|
||||
) from None
|
||||
except OSError as e:
|
||||
raise qmp_unreachable(record.name, record.qmp_socket, str(e), record.qemu_log) from e
|
||||
except Exception as e: # qemu.qmp wraps failures in its own ConnectError
|
||||
raise qmp_unreachable(record.name, record.qmp_socket, str(e), record.qemu_log) from e
|
||||
yield client
|
||||
finally:
|
||||
# After a 'quit' the peer hangs up first; EOF on teardown is normal.
|
||||
with contextlib.suppress(Exception):
|
||||
await client.disconnect()
|
||||
|
||||
|
||||
async def execute(client: QMPClient, command: str, arguments: dict[str, Any] | None = None) -> Any:
|
||||
"""Run one QMP command, translating QMP-level errors to ToolError."""
|
||||
try:
|
||||
return await client.execute(command, arguments or {})
|
||||
except ExecuteError as e:
|
||||
error_class = getattr(e, "error_class", None) or "GenericError"
|
||||
raise qmp_command_failed(command, error_class, str(e)) from e
|
||||
|
||||
|
||||
async def wait_for_event(client: QMPClient, event_name: str, timeout: float) -> bool:
|
||||
"""Drain the session's event stream until event_name arrives or time runs out."""
|
||||
|
||||
async def _drain() -> None:
|
||||
while True:
|
||||
event = await client.events.get()
|
||||
if event.get("event") == event_name:
|
||||
return
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(_drain(), timeout=timeout)
|
||||
return True
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
91
src/mcqemu/registry.py
Normal file
91
src/mcqemu/registry.py
Normal file
@ -0,0 +1,91 @@
|
||||
"""Persistent VM registry with PID-liveness staleness detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Config
|
||||
from .models import VMRecord
|
||||
|
||||
|
||||
def pid_alive(pid: int | None) -> bool:
|
||||
"""True iff the PID exists AND is actually a qemu-system process.
|
||||
|
||||
The /proc/<pid>/comm check guards against PID reuse after reboot or
|
||||
long uptimes — a recycled PID belonging to some other process must not
|
||||
make a dead VM look alive.
|
||||
"""
|
||||
if not pid or pid <= 0:
|
||||
return False
|
||||
try:
|
||||
comm = Path(f"/proc/{pid}/comm").read_text().strip()
|
||||
except OSError:
|
||||
return False
|
||||
return comm.startswith("qemu-system")
|
||||
|
||||
|
||||
class VMRegistry:
|
||||
def __init__(self, config: Config) -> None:
|
||||
self._config = config
|
||||
self._vms: dict[str, VMRecord] = {}
|
||||
|
||||
def load(self) -> None:
|
||||
path = self._config.registry_path
|
||||
if not path.exists():
|
||||
self._vms = {}
|
||||
return
|
||||
data = json.loads(path.read_text())
|
||||
self._vms = {name: VMRecord.from_dict(rec) for name, rec in data.items()}
|
||||
|
||||
def save(self) -> None:
|
||||
path = self._config.registry_path
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps({n: r.to_dict() for n, r in self._vms.items()}, indent=2))
|
||||
os.replace(tmp, path)
|
||||
|
||||
def get(self, name: str) -> VMRecord | None:
|
||||
return self._vms.get(name)
|
||||
|
||||
def add(self, record: VMRecord) -> None:
|
||||
self._vms[record.name] = record
|
||||
self.save()
|
||||
|
||||
def remove(self, name: str) -> None:
|
||||
self._vms.pop(name, None)
|
||||
self.save()
|
||||
|
||||
def all(self) -> list[VMRecord]:
|
||||
return list(self._vms.values())
|
||||
|
||||
def names(self) -> list[str]:
|
||||
return list(self._vms)
|
||||
|
||||
def refresh_pid(self, record: VMRecord) -> int | None:
|
||||
"""Re-read the pidfile for a spawned VM; returns the PID or None."""
|
||||
if record.pidfile:
|
||||
try:
|
||||
record.pid = int(Path(record.pidfile).read_text().strip())
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return record.pid
|
||||
|
||||
def is_process_alive(self, record: VMRecord) -> bool:
|
||||
"""Liveness for spawned VMs via pidfile; attached VMs are judged by
|
||||
their QMP socket instead (we may not know their PID)."""
|
||||
if record.source == "attached" and record.pid is None:
|
||||
return Path(record.qmp_socket).exists()
|
||||
self.refresh_pid(record)
|
||||
return pid_alive(record.pid)
|
||||
|
||||
def disks_in_use(self) -> dict[str, str]:
|
||||
"""Map of absolute disk path -> VM name, for every live registered VM."""
|
||||
used: dict[str, str] = {}
|
||||
for rec in self._vms.values():
|
||||
if not self.is_process_alive(rec):
|
||||
continue
|
||||
for disk in rec.config.get("disks", []):
|
||||
used[str(Path(disk).expanduser().resolve())] = rec.name
|
||||
return used
|
||||
19
src/mcqemu/resources.py
Normal file
19
src/mcqemu/resources.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""MCP resources: browsable views of the VM registry."""
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
@mcp.resource("mcqemu://vms", description="All registered VMs and their records (JSON).")
|
||||
async def vms_resource(ctx: Context = None) -> list[dict]:
|
||||
registry = ctx.lifespan_context.registry
|
||||
return [r.to_dict() for r in registry.all()]
|
||||
|
||||
@mcp.resource("mcqemu://vm/{name}", description="Full registry record for one VM (JSON).")
|
||||
async def vm_resource(name: str, ctx: Context = None) -> dict:
|
||||
registry = ctx.lifespan_context.registry
|
||||
record = registry.get(name)
|
||||
if record is None:
|
||||
raise ToolError(f"No VM named {name!r}.")
|
||||
return record.to_dict()
|
||||
55
src/mcqemu/server.py
Normal file
55
src/mcqemu/server.py
Normal file
@ -0,0 +1,55 @@
|
||||
"""mcqemu server — composition root and entry point."""
|
||||
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from . import __version__, prompts, resources
|
||||
from .config import Config
|
||||
from .registry import VMRegistry
|
||||
from .state import AppContext
|
||||
from .tools import register_all
|
||||
|
||||
INSTRUCTIONS = """\
|
||||
mcqemu manages QEMU virtual machines on this host.
|
||||
|
||||
Typical flows:
|
||||
- Fresh VM: image_create -> launch_vm(disks=[...], iso=...) -> watch serial log
|
||||
-> stop_vm -> relaunch without iso.
|
||||
- Inspect: list_vms / vm_info. VMs survive MCP server restarts (they are
|
||||
daemonized QEMU processes).
|
||||
- Inside the guest: guest_exec / guest_file_read / guest_file_write — these
|
||||
need qemu-guest-agent installed in the guest OS (guest_ping to check).
|
||||
- Existing QEMU processes started outside this server can be managed after
|
||||
attach_vm(name, qmp_socket=...).
|
||||
|
||||
KVM acceleration is automatic when the guest arch matches the host; other
|
||||
architectures run under TCG emulation (works, but much slower).
|
||||
"""
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(mcp: FastMCP):
|
||||
config = Config.from_env()
|
||||
config.state_dir.mkdir(parents=True, exist_ok=True)
|
||||
config.runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
registry = VMRegistry(config)
|
||||
registry.load()
|
||||
yield AppContext(config=config, registry=registry)
|
||||
registry.save() # VMs keep running by design — they are daemonized.
|
||||
|
||||
|
||||
mcp = FastMCP("mcqemu", lifespan=lifespan, instructions=INSTRUCTIONS)
|
||||
register_all(mcp)
|
||||
resources.register(mcp)
|
||||
prompts.register(mcp)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f"mcqemu v{__version__}", file=sys.stderr)
|
||||
mcp.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
14
src/mcqemu/state.py
Normal file
14
src/mcqemu/state.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""Lifespan-scoped application state shared by all tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .config import Config
|
||||
from .registry import VMRegistry
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppContext:
|
||||
config: Config
|
||||
registry: VMRegistry
|
||||
10
src/mcqemu/tools/__init__.py
Normal file
10
src/mcqemu/tools/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
"""Tool registration."""
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from . import guest, images, lifecycle, query, snapshots
|
||||
|
||||
|
||||
def register_all(mcp: FastMCP) -> None:
|
||||
for module in (lifecycle, query, snapshots, images, guest):
|
||||
module.register(mcp)
|
||||
19
src/mcqemu/tools/_common.py
Normal file
19
src/mcqemu/tools/_common.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""Helpers shared by tool modules."""
|
||||
|
||||
from fastmcp import Context
|
||||
|
||||
from ..errors import vm_not_found
|
||||
from ..models import VMRecord
|
||||
from ..state import AppContext
|
||||
|
||||
|
||||
def app(ctx: Context) -> AppContext:
|
||||
return ctx.lifespan_context
|
||||
|
||||
|
||||
def require_vm(ctx: Context, name: str) -> tuple[AppContext, VMRecord]:
|
||||
state = app(ctx)
|
||||
record = state.registry.get(name)
|
||||
if record is None:
|
||||
raise vm_not_found(name, state.registry.names())
|
||||
return state, record
|
||||
147
src/mcqemu/tools/guest.py
Normal file
147
src/mcqemu/tools/guest.py
Normal file
@ -0,0 +1,147 @@
|
||||
"""Tools that reach inside the guest via qemu-guest-agent."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from ..qga import qga_session
|
||||
from ._common import require_vm
|
||||
|
||||
_READ_CHUNK = 48 * 1024 # QGA caps single reads at 48 MiB of b64; stay modest.
|
||||
|
||||
|
||||
async def guest_ping(name: str, ctx: Context = None) -> dict:
|
||||
"""Check whether the qemu-guest-agent inside the VM is alive and
|
||||
responding. A failure means the guest OS doesn't have the agent installed
|
||||
or running — the other guest_* tools won't work until it does."""
|
||||
_, record = require_vm(ctx, name)
|
||||
async with qga_session(record) as client:
|
||||
await asyncio.wait_for(client.execute("guest-ping"), timeout=3.0)
|
||||
return {"name": name, "guest_agent": "responding"}
|
||||
|
||||
|
||||
async def guest_info(name: str, ctx: Context = None) -> dict:
|
||||
"""Report the guest OS details (name, version, kernel) and the guest
|
||||
agent's version and supported commands."""
|
||||
_, record = require_vm(ctx, name)
|
||||
async with qga_session(record) as client:
|
||||
agent = await client.execute("guest-info")
|
||||
try:
|
||||
osinfo = await client.execute("guest-get-osinfo")
|
||||
except Exception:
|
||||
osinfo = None
|
||||
supported = sorted(
|
||||
c["name"] for c in agent.get("supported_commands", []) if c.get("enabled")
|
||||
)
|
||||
return {
|
||||
"name": name,
|
||||
"agent_version": agent.get("version"),
|
||||
"os": osinfo,
|
||||
"supported_commands": supported,
|
||||
}
|
||||
|
||||
|
||||
async def guest_exec(
|
||||
name: str,
|
||||
command: str,
|
||||
args: list[str] | None = None,
|
||||
stdin: str | None = None,
|
||||
timeout: int = 30,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Run a command inside the guest OS and return its stdout, stderr, and
|
||||
exit code. `command` is the executable path or name; pass arguments
|
||||
separately in `args` (this is exec, not a shell — for shell features use
|
||||
command="/bin/sh", args=["-c", "your | pipeline"]). Requires
|
||||
qemu-guest-agent in the guest."""
|
||||
_, record = require_vm(ctx, name)
|
||||
exec_args: dict = {"path": command, "capture-output": True}
|
||||
if args:
|
||||
exec_args["arg"] = args
|
||||
if stdin is not None:
|
||||
exec_args["input-data"] = base64.b64encode(stdin.encode()).decode()
|
||||
|
||||
async with qga_session(record) as client:
|
||||
reply = await client.execute("guest-exec", exec_args)
|
||||
pid = reply["pid"]
|
||||
deadline = asyncio.get_event_loop().time() + timeout
|
||||
while True:
|
||||
status = await client.execute("guest-exec-status", {"pid": pid})
|
||||
if status.get("exited"):
|
||||
break
|
||||
if asyncio.get_event_loop().time() > deadline:
|
||||
raise ToolError(
|
||||
f"Command still running in guest after {timeout}s (guest pid {pid}). "
|
||||
"It continues in the background; raise timeout for long commands."
|
||||
)
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
def _decode(field: str) -> str:
|
||||
data = status.get(field)
|
||||
return base64.b64decode(data).decode(errors="replace") if data else ""
|
||||
|
||||
return {
|
||||
"exitcode": status.get("exitcode"),
|
||||
"stdout": _decode("out-data"),
|
||||
"stderr": _decode("err-data"),
|
||||
"stdout_truncated": status.get("out-truncated", False),
|
||||
"stderr_truncated": status.get("err-truncated", False),
|
||||
}
|
||||
|
||||
|
||||
async def guest_file_read(
|
||||
name: str, path: str, max_bytes: int = 1_048_576, ctx: Context = None
|
||||
) -> dict:
|
||||
"""Read a text file from inside the guest (up to max_bytes, default 1 MiB).
|
||||
Requires qemu-guest-agent in the guest."""
|
||||
_, record = require_vm(ctx, name)
|
||||
async with qga_session(record) as client:
|
||||
handle = await client.execute("guest-file-open", {"path": path, "mode": "r"})
|
||||
try:
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
truncated = False
|
||||
while total < max_bytes:
|
||||
count = min(_READ_CHUNK, max_bytes - total)
|
||||
reply = await client.execute("guest-file-read", {"handle": handle, "count": count})
|
||||
if reply.get("count", 0) > 0:
|
||||
chunk = base64.b64decode(reply["buf-b64"])
|
||||
chunks.append(chunk)
|
||||
total += len(chunk)
|
||||
if reply.get("eof"):
|
||||
break
|
||||
else:
|
||||
truncated = True
|
||||
finally:
|
||||
await client.execute("guest-file-close", {"handle": handle})
|
||||
return {
|
||||
"path": path,
|
||||
"content": b"".join(chunks).decode(errors="replace"),
|
||||
"bytes_read": total,
|
||||
"truncated": truncated,
|
||||
}
|
||||
|
||||
|
||||
async def guest_file_write(
|
||||
name: str, path: str, content: str, append: bool = False, ctx: Context = None
|
||||
) -> dict:
|
||||
"""Write a text file inside the guest (mode 'w' truncates, append=True
|
||||
appends). Requires qemu-guest-agent in the guest."""
|
||||
_, record = require_vm(ctx, name)
|
||||
payload = base64.b64encode(content.encode()).decode()
|
||||
async with qga_session(record) as client:
|
||||
handle = await client.execute(
|
||||
"guest-file-open", {"path": path, "mode": "a" if append else "w"}
|
||||
)
|
||||
try:
|
||||
reply = await client.execute("guest-file-write", {"handle": handle, "buf-b64": payload})
|
||||
finally:
|
||||
await client.execute("guest-file-close", {"handle": handle})
|
||||
return {"path": path, "bytes_written": reply.get("count", 0), "appended": append}
|
||||
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
for fn in (guest_ping, guest_info, guest_exec, guest_file_read, guest_file_write):
|
||||
mcp.tool(fn)
|
||||
169
src/mcqemu/tools/images.py
Normal file
169
src/mcqemu/tools/images.py
Normal file
@ -0,0 +1,169 @@
|
||||
"""Offline disk-image tools wrapping qemu-img."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from ..config import resolve_image_path
|
||||
from ..qemu_img import image_info_json, probe_format, run_qemu_img
|
||||
from ._common import app
|
||||
|
||||
|
||||
def _refuse_if_in_use(ctx: Context, path: Path, operation: str) -> None:
|
||||
in_use = app(ctx).registry.disks_in_use()
|
||||
if str(path) in in_use:
|
||||
raise ToolError(
|
||||
f"Refusing to {operation} {path}: it is attached to running VM "
|
||||
f"{in_use[str(path)]!r}. Stop that VM first."
|
||||
)
|
||||
|
||||
|
||||
def _resolve(path: str, must_exist: bool = True) -> Path:
|
||||
try:
|
||||
return resolve_image_path(path, must_exist=must_exist)
|
||||
except ValueError as e:
|
||||
raise ToolError(str(e)) from e
|
||||
|
||||
|
||||
async def image_create(
|
||||
path: str,
|
||||
size: str,
|
||||
format: str = "qcow2",
|
||||
backing_file: str | None = None,
|
||||
overwrite: bool = False,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Create a new disk image. `size` uses qemu-img suffixes, e.g. "20G".
|
||||
qcow2 grows on demand, so a large virtual size costs almost nothing up
|
||||
front. With `backing_file`, the new image becomes a copy-on-write overlay —
|
||||
great for cheap disposable clones of a base image. Refuses to replace an
|
||||
existing file unless overwrite=True."""
|
||||
p = _resolve(path, must_exist=False)
|
||||
if p.exists():
|
||||
if not overwrite:
|
||||
raise ToolError(f"{p} already exists. Pass overwrite=True to replace it.")
|
||||
_refuse_if_in_use(ctx, p, "overwrite")
|
||||
|
||||
args = ["create", "-f", format]
|
||||
if backing_file:
|
||||
backing = _resolve(backing_file)
|
||||
backing_fmt = await probe_format(str(backing))
|
||||
args += ["-b", str(backing), "-F", backing_fmt]
|
||||
args += [str(p), size]
|
||||
await run_qemu_img(*args)
|
||||
info = await image_info_json(str(p))
|
||||
return {
|
||||
"path": str(p),
|
||||
"format": info["format"],
|
||||
"virtual_size_bytes": info["virtual-size"],
|
||||
"backing_file": backing_file,
|
||||
}
|
||||
|
||||
|
||||
async def image_info(path: str, backing_chain: bool = False, ctx: Context = None) -> dict | list:
|
||||
"""Inspect a disk image: format, virtual and on-disk size, internal
|
||||
snapshots, and (with backing_chain=True) the full copy-on-write chain."""
|
||||
p = _resolve(path)
|
||||
return await image_info_json(str(p), backing_chain=backing_chain)
|
||||
|
||||
|
||||
async def image_convert(
|
||||
source: str,
|
||||
dest: str,
|
||||
format: str = "qcow2",
|
||||
compress: bool = False,
|
||||
overwrite: bool = False,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Convert a disk image to another format (e.g. raw -> qcow2, vmdk ->
|
||||
qcow2). Conversion flattens any backing chain into a standalone image.
|
||||
compress=True enables qcow2 compression (smaller, slower)."""
|
||||
src = _resolve(source)
|
||||
dst = _resolve(dest, must_exist=False)
|
||||
if dst.exists():
|
||||
if not overwrite:
|
||||
raise ToolError(f"{dst} already exists. Pass overwrite=True to replace it.")
|
||||
_refuse_if_in_use(ctx, dst, "overwrite")
|
||||
_refuse_if_in_use(ctx, src, "convert")
|
||||
|
||||
src_fmt = await probe_format(str(src))
|
||||
args = ["convert", "-f", src_fmt, "-O", format]
|
||||
if compress:
|
||||
if format != "qcow2":
|
||||
raise ToolError("compress=True is only supported for qcow2 output.")
|
||||
args.append("-c")
|
||||
args += [str(src), str(dst)]
|
||||
await run_qemu_img(*args)
|
||||
info = await image_info_json(str(dst))
|
||||
return {"path": str(dst), "format": info["format"], "actual_size_bytes": info["actual-size"]}
|
||||
|
||||
|
||||
async def image_resize(path: str, size: str, shrink: bool = False, ctx: Context = None) -> dict:
|
||||
"""Resize a disk image's virtual size (e.g. size="30G", or "+10G" to grow
|
||||
relatively). Growing is safe; shrinking DESTROYS data beyond the new size
|
||||
and requires shrink=True as explicit confirmation (shrink the guest
|
||||
filesystem first!)."""
|
||||
p = _resolve(path)
|
||||
_refuse_if_in_use(ctx, p, "resize")
|
||||
args = ["resize"]
|
||||
if shrink:
|
||||
args.append("--shrink")
|
||||
args += [str(p), size]
|
||||
await run_qemu_img(*args)
|
||||
info = await image_info_json(str(p))
|
||||
return {"path": str(p), "virtual_size_bytes": info["virtual-size"]}
|
||||
|
||||
|
||||
async def image_snapshot_list(path: str, ctx: Context = None) -> list[dict]:
|
||||
"""List internal snapshots stored in a (not currently running) qcow2 image."""
|
||||
p = _resolve(path)
|
||||
info = await image_info_json(str(p))
|
||||
return [
|
||||
{
|
||||
"tag": s.get("name"),
|
||||
"vm_state_size": s.get("vm-state-size"),
|
||||
"date": s.get("date-sec"),
|
||||
}
|
||||
for s in info.get("snapshots", [])
|
||||
]
|
||||
|
||||
|
||||
async def image_snapshot_create(path: str, tag: str, ctx: Context = None) -> dict:
|
||||
"""Create an internal disk-only snapshot in an offline qcow2 image. For
|
||||
running VMs use vm_snapshot_create instead (it also captures RAM)."""
|
||||
p = _resolve(path)
|
||||
_refuse_if_in_use(ctx, p, "snapshot")
|
||||
await run_qemu_img("snapshot", "-c", tag, str(p))
|
||||
return {"path": str(p), "snapshot": tag, "created": True}
|
||||
|
||||
|
||||
async def image_snapshot_apply(path: str, tag: str, ctx: Context = None) -> dict:
|
||||
"""Revert an offline qcow2 image to internal snapshot `tag`. Data written
|
||||
after the snapshot is lost."""
|
||||
p = _resolve(path)
|
||||
_refuse_if_in_use(ctx, p, "revert")
|
||||
await run_qemu_img("snapshot", "-a", tag, str(p))
|
||||
return {"path": str(p), "snapshot": tag, "applied": True}
|
||||
|
||||
|
||||
async def image_snapshot_delete(path: str, tag: str, ctx: Context = None) -> dict:
|
||||
"""Delete internal snapshot `tag` from an offline qcow2 image."""
|
||||
p = _resolve(path)
|
||||
_refuse_if_in_use(ctx, p, "modify")
|
||||
await run_qemu_img("snapshot", "-d", tag, str(p))
|
||||
return {"path": str(p), "snapshot": tag, "deleted": True}
|
||||
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
for fn in (
|
||||
image_create,
|
||||
image_info,
|
||||
image_convert,
|
||||
image_resize,
|
||||
image_snapshot_list,
|
||||
image_snapshot_create,
|
||||
image_snapshot_apply,
|
||||
image_snapshot_delete,
|
||||
):
|
||||
mcp.tool(fn)
|
||||
201
src/mcqemu/tools/lifecycle.py
Normal file
201
src/mcqemu/tools/lifecycle.py
Normal file
@ -0,0 +1,201 @@
|
||||
"""VM lifecycle tools: launch, stop, pause/resume, attach, forget."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from ..config import resolve_image_path, valid_vm_name
|
||||
from ..launcher import spawn_vm
|
||||
from ..models import VMConfig
|
||||
from ..qmp import execute, qmp_session, wait_for_event
|
||||
from ..registry import pid_alive
|
||||
from ._common import app, require_vm
|
||||
|
||||
|
||||
async def launch_vm(
|
||||
name: str,
|
||||
arch: str = "x86_64",
|
||||
disks: list[str] | None = None,
|
||||
iso: str | None = None,
|
||||
memory_mb: int = 2048,
|
||||
cpus: int = 2,
|
||||
machine: str | None = None,
|
||||
firmware: Literal["bios", "uefi"] = "bios",
|
||||
port_forwards: list[str] | None = None,
|
||||
no_net: bool = False,
|
||||
extra_args: list[str] | None = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Launch a new QEMU virtual machine and register it for management.
|
||||
|
||||
Use `disks` for existing image files (create them first with image_create);
|
||||
use `iso` to boot an installer or live CD. With both, the VM boots the ISO
|
||||
once then the disk afterwards. `port_forwards` entries like "2222:22" map
|
||||
host port 2222 to guest port 22 (user-mode networking). KVM acceleration is
|
||||
used automatically when the guest arch matches the host. The VM keeps
|
||||
running even if this MCP server restarts; stop it with stop_vm.
|
||||
"""
|
||||
state = app(ctx)
|
||||
if not valid_vm_name(name):
|
||||
raise ToolError(
|
||||
f"Invalid VM name {name!r}: use letters, digits, '.', '_', '-' (max 48 chars)."
|
||||
)
|
||||
|
||||
existing = state.registry.get(name)
|
||||
if existing is not None:
|
||||
if state.registry.is_process_alive(existing):
|
||||
raise ToolError(
|
||||
f"A VM named {name!r} is already running. Stop it first, or pick another name."
|
||||
)
|
||||
state.registry.remove(name) # dead leftover — reuse the name
|
||||
|
||||
resolved_disks: list[str] = []
|
||||
in_use = state.registry.disks_in_use()
|
||||
for disk in disks or []:
|
||||
try:
|
||||
p = resolve_image_path(disk)
|
||||
except ValueError as e:
|
||||
raise ToolError(f"Bad disk path: {e}. Create images with image_create.") from e
|
||||
if str(p) in in_use:
|
||||
raise ToolError(f"Disk {p} is already attached to running VM {in_use[str(p)]!r}.")
|
||||
resolved_disks.append(str(p))
|
||||
|
||||
resolved_iso: str | None = None
|
||||
if iso:
|
||||
try:
|
||||
resolved_iso = str(resolve_image_path(iso))
|
||||
except ValueError as e:
|
||||
raise ToolError(f"Bad ISO path: {e}") from e
|
||||
|
||||
cfg = VMConfig(
|
||||
name=name,
|
||||
arch=arch,
|
||||
machine=machine,
|
||||
memory_mb=memory_mb,
|
||||
cpus=cpus,
|
||||
disks=resolved_disks,
|
||||
iso=resolved_iso,
|
||||
firmware=firmware,
|
||||
port_forwards=port_forwards or [],
|
||||
no_net=no_net,
|
||||
extra_args=extra_args or [],
|
||||
)
|
||||
record = await spawn_vm(cfg, state.config)
|
||||
state.registry.add(record)
|
||||
return {
|
||||
"name": name,
|
||||
"status": "running",
|
||||
"accel": record.accel,
|
||||
"pid": record.pid,
|
||||
"qmp_socket": record.qmp_socket,
|
||||
"serial_log": record.serial_log,
|
||||
"qemu_log": record.qemu_log,
|
||||
"port_forwards": cfg.port_forwards,
|
||||
"note": "TCG software emulation in use (slower than KVM)"
|
||||
if record.accel == "tcg"
|
||||
else "KVM hardware acceleration active",
|
||||
}
|
||||
|
||||
|
||||
async def stop_vm(name: str, force: bool = False, timeout: int = 30, ctx: Context = None) -> dict:
|
||||
"""Stop a VM. By default sends a graceful ACPI power-button press and waits
|
||||
for the guest to shut down; if the guest ignores it (no OS booted, or OS
|
||||
without ACPI handling), the call fails with advice to retry with force=True,
|
||||
which terminates QEMU immediately (like pulling the power cord).
|
||||
"""
|
||||
state, record = require_vm(ctx, name)
|
||||
if not state.registry.is_process_alive(record) and not Path(record.qmp_socket).exists():
|
||||
return {"name": name, "status": "stopped", "note": "VM was already stopped."}
|
||||
|
||||
if force:
|
||||
async with qmp_session(record) as client:
|
||||
await execute(client, "quit")
|
||||
deadline = asyncio.get_event_loop().time() + 10
|
||||
while pid_alive(record.pid) and asyncio.get_event_loop().time() < deadline:
|
||||
await asyncio.sleep(0.1)
|
||||
return {"name": name, "status": "stopped", "method": "force quit"}
|
||||
|
||||
async with qmp_session(record) as client:
|
||||
await execute(client, "system_powerdown")
|
||||
if not await wait_for_event(client, "SHUTDOWN", timeout=timeout):
|
||||
raise ToolError(
|
||||
f"VM {name!r} did not shut down within {timeout}s after the ACPI "
|
||||
"power signal — the guest may have no OS or ignores ACPI. "
|
||||
"Retry with force=True to terminate it immediately."
|
||||
)
|
||||
deadline = asyncio.get_event_loop().time() + 10
|
||||
while pid_alive(record.pid) and asyncio.get_event_loop().time() < deadline:
|
||||
await asyncio.sleep(0.1)
|
||||
return {"name": name, "status": "stopped", "method": "graceful ACPI shutdown"}
|
||||
|
||||
|
||||
async def pause_vm(name: str, ctx: Context = None) -> dict:
|
||||
"""Pause (freeze) a running VM's virtual CPUs. The VM stays in memory;
|
||||
resume it with resume_vm."""
|
||||
_, record = require_vm(ctx, name)
|
||||
async with qmp_session(record) as client:
|
||||
await execute(client, "stop")
|
||||
return {"name": name, "status": "paused"}
|
||||
|
||||
|
||||
async def resume_vm(name: str, ctx: Context = None) -> dict:
|
||||
"""Resume a VM previously frozen with pause_vm."""
|
||||
_, record = require_vm(ctx, name)
|
||||
async with qmp_session(record) as client:
|
||||
await execute(client, "cont")
|
||||
return {"name": name, "status": "running"}
|
||||
|
||||
|
||||
async def attach_vm(
|
||||
name: str,
|
||||
qmp_socket: str,
|
||||
qga_socket: str | None = None,
|
||||
pid: int | None = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Register an externally launched QEMU process so the other tools can
|
||||
manage it. Point qmp_socket at its QMP unix socket (the QEMU process must
|
||||
have been started with e.g. -qmp unix:/path,server=on,wait=off). Optionally
|
||||
provide qga_socket for guest-agent tools and pid for liveness tracking.
|
||||
"""
|
||||
state = app(ctx)
|
||||
if not valid_vm_name(name):
|
||||
raise ToolError(
|
||||
f"Invalid VM name {name!r}: use letters, digits, '.', '_', '-' (max 48 chars)."
|
||||
)
|
||||
if state.registry.get(name) is not None:
|
||||
raise ToolError(f"A VM named {name!r} is already registered. Use forget_vm first.")
|
||||
if not Path(qmp_socket).exists():
|
||||
raise ToolError(f"No socket at {qmp_socket}. Is the QEMU process running with -qmp?")
|
||||
|
||||
from ..models import VMRecord
|
||||
|
||||
record = VMRecord(
|
||||
name=name, source="attached", qmp_socket=qmp_socket, qga_socket=qga_socket, pid=pid
|
||||
)
|
||||
async with qmp_session(record) as client:
|
||||
status = await execute(client, "query-status")
|
||||
state.registry.add(record)
|
||||
return {"name": name, "source": "attached", "status": status.get("status", "unknown")}
|
||||
|
||||
|
||||
async def forget_vm(name: str, force: bool = False, ctx: Context = None) -> dict:
|
||||
"""Remove a VM from the registry WITHOUT stopping it — the QEMU process is
|
||||
left untouched. Refuses to forget a running VM this server spawned unless
|
||||
force=True (to avoid orphaning processes by accident)."""
|
||||
state, record = require_vm(ctx, name)
|
||||
if record.source == "spawned" and state.registry.is_process_alive(record) and not force:
|
||||
raise ToolError(
|
||||
f"VM {name!r} is still running (pid {record.pid}). Stop it with stop_vm, "
|
||||
"or pass force=True to deliberately orphan the process."
|
||||
)
|
||||
state.registry.remove(name)
|
||||
return {"name": name, "forgotten": True, "process_left_running": bool(record.pid and pid_alive(record.pid))}
|
||||
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
for fn in (launch_vm, stop_vm, pause_vm, resume_vm, attach_vm, forget_vm):
|
||||
mcp.tool(fn)
|
||||
84
src/mcqemu/tools/query.py
Normal file
84
src/mcqemu/tools/query.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""Read-only VM inspection tools."""
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from ..qmp import execute, qmp_session
|
||||
from ._common import app, require_vm
|
||||
|
||||
|
||||
async def _live_status(state, record) -> str:
|
||||
if not state.registry.is_process_alive(record):
|
||||
return "stopped"
|
||||
try:
|
||||
async with qmp_session(record) as client:
|
||||
reply = await execute(client, "query-status")
|
||||
return reply.get("status", "unknown")
|
||||
except ToolError:
|
||||
return "unreachable"
|
||||
|
||||
|
||||
async def list_vms(ctx: Context = None) -> list[dict]:
|
||||
"""List every registered VM with its live status (running, paused,
|
||||
shutdown, stopped, or unreachable). Includes both VMs launched by this
|
||||
server and externally attached ones."""
|
||||
state = app(ctx)
|
||||
out = []
|
||||
for record in state.registry.all():
|
||||
status = await _live_status(state, record)
|
||||
out.append(
|
||||
{
|
||||
"name": record.name,
|
||||
"status": status,
|
||||
"source": record.source,
|
||||
"arch": record.arch,
|
||||
"accel": record.accel,
|
||||
"pid": record.pid,
|
||||
"created_at": record.created_at,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
async def vm_info(name: str, ctx: Context = None) -> dict:
|
||||
"""Detailed information about one VM: its launch configuration, log file
|
||||
paths, and — when running — live QMP state (status, vCPUs, block devices)."""
|
||||
state, record = require_vm(ctx, name)
|
||||
info: dict = {
|
||||
"name": record.name,
|
||||
"source": record.source,
|
||||
"arch": record.arch,
|
||||
"accel": record.accel,
|
||||
"pid": record.pid,
|
||||
"qmp_socket": record.qmp_socket,
|
||||
"qga_socket": record.qga_socket,
|
||||
"qemu_log": record.qemu_log,
|
||||
"serial_log": record.serial_log,
|
||||
"created_at": record.created_at,
|
||||
"config": record.config,
|
||||
}
|
||||
status = await _live_status(state, record)
|
||||
info["status"] = status
|
||||
if status in ("stopped", "unreachable"):
|
||||
return info
|
||||
|
||||
async with qmp_session(record) as client:
|
||||
cpus = await execute(client, "query-cpus-fast")
|
||||
blocks = await execute(client, "query-block")
|
||||
info["vcpus"] = len(cpus)
|
||||
info["block_devices"] = [
|
||||
{
|
||||
"device": b.get("device") or b.get("qdev", ""),
|
||||
"file": (b.get("inserted") or {}).get("file"),
|
||||
"format": (b.get("inserted") or {}).get("drv"),
|
||||
"read_only": (b.get("inserted") or {}).get("ro"),
|
||||
}
|
||||
for b in blocks
|
||||
if b.get("inserted")
|
||||
]
|
||||
return info
|
||||
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
for fn in (list_vms, vm_info):
|
||||
mcp.tool(fn)
|
||||
73
src/mcqemu/tools/snapshots.py
Normal file
73
src/mcqemu/tools/snapshots.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""Live (internal) snapshots of running VMs.
|
||||
|
||||
v1 drives savevm/loadvm/delvm through QMP's human-monitor-command passthrough;
|
||||
the QMP jobs API (snapshot-save/-load) is the eventual upgrade path but needs
|
||||
job polling for the same v1 outcome. Internal snapshots require all writable
|
||||
disks to be qcow2.
|
||||
"""
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from ..qmp import execute, qmp_session
|
||||
from ._common import require_vm
|
||||
|
||||
|
||||
async def _hmp(client, command_line: str) -> str:
|
||||
return await execute(client, "human-monitor-command", {"command-line": command_line})
|
||||
|
||||
|
||||
async def _hmp_expect_silence(client, command_line: str, what: str) -> None:
|
||||
out = await _hmp(client, command_line)
|
||||
if out and out.strip():
|
||||
raise ToolError(f"{what} failed: {out.strip()}")
|
||||
|
||||
|
||||
def _check_tag(tag: str) -> None:
|
||||
if not tag or any(c.isspace() for c in tag) or tag.startswith("-"):
|
||||
raise ToolError(f"Invalid snapshot tag {tag!r}: no whitespace, must not start with '-'.")
|
||||
|
||||
|
||||
async def vm_snapshot_create(name: str, tag: str, ctx: Context = None) -> dict:
|
||||
"""Save a live internal snapshot (RAM + device + disk state) of a running
|
||||
VM under `tag`. Requires the VM's writable disks to be qcow2. The VM pauses
|
||||
briefly while state is written. Restore later with vm_snapshot_restore."""
|
||||
_check_tag(tag)
|
||||
_, record = require_vm(ctx, name)
|
||||
async with qmp_session(record) as client:
|
||||
await _hmp_expect_silence(client, f"savevm {tag}", f"savevm {tag!r}")
|
||||
return {"name": name, "snapshot": tag, "created": True}
|
||||
|
||||
|
||||
async def vm_snapshot_restore(name: str, tag: str, ctx: Context = None) -> dict:
|
||||
"""Roll a running VM back to the internal snapshot `tag` (RAM, devices and
|
||||
disks all revert). Anything that happened after the snapshot is lost."""
|
||||
_check_tag(tag)
|
||||
_, record = require_vm(ctx, name)
|
||||
async with qmp_session(record) as client:
|
||||
await _hmp_expect_silence(client, f"loadvm {tag}", f"loadvm {tag!r}")
|
||||
return {"name": name, "snapshot": tag, "restored": True}
|
||||
|
||||
|
||||
async def vm_snapshot_delete(name: str, tag: str, ctx: Context = None) -> dict:
|
||||
"""Delete the internal snapshot `tag` from a running VM's disks. The VM
|
||||
keeps running; only the saved snapshot is removed."""
|
||||
_check_tag(tag)
|
||||
_, record = require_vm(ctx, name)
|
||||
async with qmp_session(record) as client:
|
||||
await _hmp_expect_silence(client, f"delvm {tag}", f"delvm {tag!r}")
|
||||
return {"name": name, "snapshot": tag, "deleted": True}
|
||||
|
||||
|
||||
async def vm_snapshot_list(name: str, ctx: Context = None) -> dict:
|
||||
"""List internal snapshots visible to a running VM. For stopped VMs use
|
||||
image_snapshot_list on the disk file instead."""
|
||||
_, record = require_vm(ctx, name)
|
||||
async with qmp_session(record) as client:
|
||||
out = await _hmp(client, "info snapshots")
|
||||
return {"name": name, "snapshots_raw": out.strip() or "(none)"}
|
||||
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
for fn in (vm_snapshot_create, vm_snapshot_restore, vm_snapshot_delete, vm_snapshot_list):
|
||||
mcp.tool(fn)
|
||||
Loading…
x
Reference in New Issue
Block a user