Add the mcqemu.warehack.ing docs site

Starlight, diataxis-shaped: a tutorial pair, six how-to guides, a generated
tool reference plus a configuration reference, and four explanation pages
covering architecture, sandbox isolation, see-and-drive, and the failure
handling philosophy.

The tool reference is generated from the running MCP server's own schemas, so
its 33 tools, parameters and defaults cannot drift from the code; the
generator asserts its grouping still covers exactly the live tool set.

Infrastructure follows the warehacking cookie-cutter (multi-stage Dockerfile,
Caddy serving dist with real 404 status, compose profiles for prod and dev,
Makefile with a deploy target pointed at docker-2). Two deviations worth
noting: remark-gfm is added to the MDX pipeline because Astro enables GFM for
.md but not .mdx, so tables silently rendered as run-together paragraphs; and
the card icon palette is pinned to the accent because Starlight's staggered
grid rotates through colours including purple.

Package URLs now point at the Gitea repo and this site.
This commit is contained in:
Ryan Malloy 2026-08-17 18:46:56 -06:00
parent b5a1c3583b
commit f4c2a70bbf
29 changed files with 3111 additions and 1 deletions

19
docs-site/.dockerignore Normal file
View File

@ -0,0 +1,19 @@
node_modules/
dist/
.astro/
.env
.env.local
.env.production
.git/
.gitignore
*.log
.DS_Store
.vscode/
.idea/
# don't pull in repo-root artifacts
../artifacts/
README.md

13
docs-site/.env.example Normal file
View File

@ -0,0 +1,13 @@
# mcqemu docs-site — environment template.
# Copy to `.env` and adjust before `make prod` / `make dev`.
# Keeps this stack's containers, networks and volumes distinct from the
# sibling docs sites on the same host.
COMPOSE_PROJECT_NAME=mcqemu-docs
# Domain served. caddy-docker-proxy reads this from the labels and obtains
# a certificate over ACME DNS-01.
#
# Production: mcqemu.warehack.ing
# Local dev: mcqemu.l.warehack.ing
DOMAIN=mcqemu.warehack.ing

8
docs-site/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules/
dist/
.astro/
.env
.env.local
.env.production
*.log
package-lock.json

18
docs-site/Caddyfile Normal file
View File

@ -0,0 +1,18 @@
:80 {
root * /srv/docs
encode zstd gzip
try_files {path} {path}/
file_server
# Serve the 404 page with a REAL 404 status. The older
# `try_files ... /404.html` form returned HTTP 200 for every missing
# path, which silently broke uptime checks, smoke tests, and Search
# Console (they all saw a "live" page).
handle_errors {
@404 expression {err.status_code} == 404
handle @404 {
rewrite * /404.html
file_server
}
}
}

47
docs-site/Dockerfile Normal file
View File

@ -0,0 +1,47 @@
# Multi-stage build for the mcqemu docs site.
#
# Stages:
# - base : Node, pnpm/npm tooling, deps installed
# - dev : runs `astro dev` with HMR for local development
# - builder : produces the static `dist/`
# - prod : caddy:alpine that serves `dist/` (no Node at runtime)
#
# `docker compose --profile dev up` → dev target
# `docker compose up` (no profile) → prod target
# Pinned through the mirror.gcr.io pass-through to dodge intermittent
# Docker Hub TLS hiccups during builds. Same content, more reliable
# fetch path. The `docker pull` resolves identically against either.
FROM mirror.gcr.io/library/node:22-alpine AS base
WORKDIR /app
COPY package.json ./
RUN --mount=type=cache,target=/root/.npm \
npm install --no-audit --no-fund
# ----- dev: astro dev server with HMR -----
FROM base AS dev
# Astro's binary is in node_modules/.bin — package.json's `dev` script
# already binds to 0.0.0.0 for HMR-behind-Caddy.
COPY . .
ENV ASTRO_TELEMETRY_DISABLED=1
EXPOSE 4321
CMD ["npm", "run", "dev"]
# ----- builder: produce dist/ -----
FROM base AS builder
COPY . .
ENV ASTRO_TELEMETRY_DISABLED=1
RUN npm run build
# ----- prod: caddy serves the static build -----
FROM mirror.gcr.io/library/caddy:2-alpine AS prod
# Caddyfile is intentionally minimal — caddy-docker-proxy on the host
# handles TLS, routing, and the public-facing reverse proxy. This
# container just serves files locally; the proxy points at it.
RUN mkdir -p /srv/docs
COPY --from=builder /app/dist /srv/docs
COPY Caddyfile /etc/caddy/Caddyfile
EXPOSE 80

50
docs-site/Makefile Normal file
View File

@ -0,0 +1,50 @@
# mcqemu docs — targets follow the warehacking cookie-cutter.
SHELL := /usr/bin/env bash
.SHELLFLAGS := -eu -o pipefail -c
.DEFAULT_GOAL := help
.PHONY: help
help: ## Show this help
@awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z0-9_-]+:.*##/ {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
.PHONY: prod
prod: ## Build + run the production docs container (Caddy serves dist/)
docker compose up -d --build docs
.PHONY: dev
dev: ## Run the Astro dev server with HMR (--profile dev)
docker compose --profile dev up --build docs-dev
.PHONY: down
down: ## Stop and remove the docs containers
docker compose --profile dev down
docker compose down
.PHONY: logs
logs: ## Tail logs (works for whichever profile is up)
docker compose logs -f --tail=100
.PHONY: build
build: ## Build the static site without bringing up Caddy (CI gate)
docker compose build docs
# ---- Production deploy --------------------------------------------------
#
# Pulls origin/main on the warehack.ing prod host and rebuilds the docs
# container. Agent forwarding (-A) lets the remote git pull use the
# operator's key for Gitea, so nothing persistent is provisioned there.
#
# Use docker-2.supportedsystems.net, not warehack.ing: the latter resolves
# to an IPv4 whose port 22 is unreachable from some networks.
DEPLOY_HOST ?= warehack-ing@docker-2.supportedsystems.net
DEPLOY_PATH ?= ~/mcqemu
.PHONY: deploy
deploy: ## Pull main + rebuild the docs container on the prod host
@echo "==> deploying $(DEPLOY_HOST):$(DEPLOY_PATH)"
ssh -A $(DEPLOY_HOST) "cd $(DEPLOY_PATH) && git fetch origin main && git reset --hard origin/main && cd docs-site && make prod"
@echo "==> verifying by content (these sites return 200 for every path)"
@curl -sS https://mcqemu.warehack.ing/ | grep -oE "<title>[^<]*</title>" || echo " homepage title not found"
@curl -sS https://mcqemu.warehack.ing/definitely-not-a-page-xyz/ | grep -oE "<title>[^<]*</title>" || echo " (bogus path returned no title)"

View File

@ -0,0 +1,92 @@
// mcqemu docs — Starlight with a diataxis-shaped sidebar.
//
// Telemetry and devToolbar are off per project convention. The HMR block
// matters when the dev server runs behind Caddy: Vite's WebSocket needs an
// explicit host, protocol and clientPort or HMR drops every few seconds
// with "server connection lost".
//
// DOMAIN drives both the canonical site URL and the HMR host, so the same
// image serves mcqemu.warehack.ing (prod) and mcqemu.l.warehack.ing (dev).
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
import starlight from "@astrojs/starlight";
import { defineConfig } from "astro/config";
import remarkGfm from "remark-gfm";
import starlightLinksValidator from "starlight-links-validator";
const domain = process.env.DOMAIN ?? "mcqemu.warehack.ing";
export default defineConfig({
site: `https://${domain}`,
telemetry: false,
devToolbar: { enabled: false },
vite: {
server: {
host: "0.0.0.0",
hmr: {
host: domain,
protocol: "wss",
clientPort: 443,
},
},
},
integrations: [
starlight({
title: "mcqemu",
description:
"MCP server for QEMU: VM lifecycle, disposable sandboxes, live snapshots, guest-agent access, and screenshot-driven control.",
favicon: "/favicon.svg",
customCss: ["./src/styles/theme.css"],
social: [
{
icon: "seti:git",
label: "Source",
href: "https://git.supported.systems/warehack.ing/mcqemu",
},
],
// Diataxis order: orientation, then learning, then doing, then
// looking up, then understanding.
// Starlight v0.39+ needs {label, items:[{autogenerate}]}; the inline
// {label, autogenerate} shorthand was removed.
sidebar: [
{
label: "Start here",
items: [{ label: "What is mcqemu?", slug: "overview" }],
},
{
label: "Tutorial",
items: [{ autogenerate: { directory: "tutorial" } }],
},
{
label: "How-to",
items: [{ autogenerate: { directory: "how-to" } }],
},
{
label: "Reference",
items: [{ autogenerate: { directory: "reference" } }],
},
{
label: "Explanation",
items: [{ autogenerate: { directory: "explanation" } }],
},
],
plugins: [
// Broken internal links fail the build instead of shipping.
starlightLinksValidator({ errorOnRelativeLinks: false }),
],
pagination: true,
lastUpdated: true,
}),
// Astro enables GFM for .md, but the MDX pipeline does not inherit it,
// so tables render as run-together paragraphs without this.
mdx({ remarkPlugins: [remarkGfm] }),
sitemap(),
],
});

View File

@ -0,0 +1,68 @@
# mcqemu docs site — two profiles.
#
# Default (no --profile flag):
# production — Caddy serves the built dist/. This is what runs at
# mcqemu.warehack.ing.
#
# --profile dev:
# Astro dev server with HMR, src/ bind-mounted for live reload. The Vite
# HMR WebSocket is configured in astro.config.mjs to survive the
# TLS-terminating proxy; the caddy.reverse_proxy.* labels below keep the
# connection from being closed as idle.
#
# Both services join the external `caddy` network and advertise themselves
# to caddy-docker-proxy through labels. DOMAIN in .env switches between the
# production and local-dev tiers.
services:
docs:
profiles: ["prod", ""]
build:
context: .
target: prod
image: mcqemu-docs:prod
container_name: mcqemu-docs
restart: unless-stopped
networks:
- caddy
labels:
caddy: ${DOMAIN:-mcqemu.warehack.ing}
caddy.reverse_proxy: "{{upstreams 80}}"
docs-dev:
profiles: ["dev"]
build:
context: .
target: dev
image: mcqemu-docs:dev
container_name: mcqemu-docs-dev
restart: unless-stopped
environment:
- DOMAIN=${DOMAIN:-mcqemu.l.warehack.ing}
- ASTRO_TELEMETRY_DISABLED=1
volumes:
# node_modules stays inside the container so host/platform mismatches
# cannot break native deps.
- ./astro.config.mjs:/app/astro.config.mjs:ro
- ./tsconfig.json:/app/tsconfig.json:ro
- ./src:/app/src
- ./public:/app/public
networks:
- caddy
labels:
caddy: ${DOMAIN:-mcqemu.l.warehack.ing}
caddy.reverse_proxy: "{{upstreams 4321}}"
# Vite HMR sends no application-level pings, so Caddy's default idle
# timeouts would close the socket every 10-15s. Needs Caddy 2.10+.
caddy.reverse_proxy.flush_interval: "-1"
caddy.reverse_proxy.transport: "http"
caddy.reverse_proxy.transport.read_timeout: "0"
caddy.reverse_proxy.transport.write_timeout: "0"
caddy.reverse_proxy.transport.keepalive: "5m"
caddy.reverse_proxy.transport.keepalive_idle_conns: "10"
caddy.reverse_proxy.stream_timeout: "24h"
caddy.reverse_proxy.stream_close_delay: "5s"
networks:
caddy:
external: true

21
docs-site/package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "mcqemu-docs",
"type": "module",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "astro dev --host 0.0.0.0",
"build": "astro build",
"preview": "astro preview --host 0.0.0.0",
"astro": "astro"
},
"dependencies": {
"@astrojs/mdx": "^5.0.4",
"@astrojs/sitemap": "^3.7.2",
"@astrojs/starlight": "^0.39.2",
"astro": "^6.3.1",
"remark-gfm": "^4.0.1",
"sharp": "^0.34.0",
"starlight-links-validator": "^0.24.0"
}
}

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="mcqemu">
<rect width="32" height="32" rx="6" fill="#16170f"/>
<rect x="5" y="7" width="22" height="16" rx="2" fill="none" stroke="#b5820f" stroke-width="2"/>
<path d="M9 12l3 3-3 3" fill="none" stroke="#f0c674" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 18h7" stroke="#f0c674" stroke-width="2" stroke-linecap="round"/>
<path d="M12 26h8" stroke="#b5820f" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 533 B

View File

@ -0,0 +1,8 @@
// Starlight content collection — required for content.config.ts in Astro 6.x.
import { docsLoader } from "@astrojs/starlight/loaders";
import { docsSchema } from "@astrojs/starlight/schema";
import { defineCollection } from "astro:content";
export const collections = {
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
};

View File

@ -0,0 +1,170 @@
---
title: Architecture
description: How the MCP server, the QEMU processes, the QMP and guest-agent sockets, qemu-img and the JSON registry fit together, and why VMs are daemonized and monitor sessions are short-lived.
---
mcqemu is a thin control plane. It does not emulate anything, it does not proxy
guest traffic, and it does not keep VMs alive. Everything it does is arranged so
that the interesting state lives in the operating system (processes, sockets,
files) rather than in the server's memory, because the server is the part most
likely to be restarted at an awkward moment.
## The pieces
| Piece | What it is | Lifetime |
| --- | --- | --- |
| The MCP server | A Python process speaking MCP to your client (usually over stdio) | As long as your client keeps it |
| A VM | A `qemu-system-*` process, daemonized, with its own pidfile | Until something stops it |
| The QMP socket | A unix socket per VM, QEMU's machine monitor | The VM's lifetime |
| The guest-agent socket | A second unix socket per VM, backing a virtio-serial port | The VM's lifetime |
| `qemu-img` | A subprocess, spawned per image operation | One command |
| The registry | A JSON file plus a lock file on disk | Persistent |
The server owns none of the VMs in any operating-system sense. It is not their
parent process, it does not hold their sockets open, and if you kill it nothing
happens to them. The registry is a notebook, not an authority. When a tool needs
to know whether a VM is running, it does not consult the notebook, it looks at
`/proc` and at the socket.
The two decisions that shape everything else are the process model and the
session model.
## Decision one: VMs outlive the server
VMs are launched with `-daemonize` and `-pidfile`. QEMU forks, the foreground
process exits, and the VM continues as a session leader unattached to anything
mcqemu owns. Restarting your editor, upgrading mcqemu, or crashing the server
leaves a Debian install halfway through its partitioner exactly where it was.
This matters more than it might sound. An agent driving a VM will typically be
in the middle of a long operation (an OS installation, a package build, a boot
that takes two minutes under TCG emulation) and the MCP server is attached to an
editor session that gets reloaded for unrelated reasons. A model where the VM
dies with its parent would make those operations quietly unreliable in a way
that is very hard to attribute.
`-daemonize` also buys a clean launch verdict. QEMU's foreground process exits
zero only after the VM is fully initialized and the QMP socket is listening, so
awaiting that process gives a synchronous answer with real stderr on failure,
rather than the usual "spawn it and poll for a socket" dance. When QEMU fails
because a disk is locked or a machine type does not exist, the launch call
returns that message directly. Daemonized QEMU routes later errors to its `-D`
log file instead of stderr, so if the exit status is non-zero but stderr is
empty, the launcher reads the last line of the log.
### Liveness, and why the pidfile is not enough
Reading a PID from a pidfile and calling `kill(pid, 0)` is the obvious liveness
check and it is wrong. PIDs are recycled. A pidfile written days ago, on a host
that has since rebooted or simply wrapped its PID space, can name a process that
is very much alive and has nothing to do with your VM. Acting on that (sending a
signal, reporting the VM as running) is a bug with real consequences.
So liveness is two checks. First, `/proc/<pid>/comm` must read back as something
starting with `qemu-system`; a recycled PID belonging to a shell or a browser
fails immediately. Second, for VMs mcqemu launched, `/proc/<pid>/cmdline` is
parsed for the `-name` argument and compared with the registry key. That second
check is deliberately generous: it returns "matches" whenever it cannot prove
otherwise (unreadable cmdline, no `-name` present), because its job is to veto a
positive identification, not to manufacture a negative one.
Attached VMs are judged differently. mcqemu did not choose their `-name` and may
not know their PID at all, so when no PID was supplied their liveness comes from
connecting to the QMP socket. A stat is not enough there either: a SIGKILLed
QEMU leaves its socket file on disk, so the check opens a connection and sees
whether anything accepts.
## Decision two: QMP sessions are opened per call
QEMU's QMP unix socket accepts exactly one client at a time. That single fact
drives the session model. If mcqemu connected once at launch and held the
connection for the VM's lifetime, it would own the monitor exclusively and
everything else would be locked out: no `qmp-shell` for a human looking over the
agent's shoulder, no second tool, no debugging. It would also leave connection
state to reconcile whenever either side restarted, which for a server whose VMs
outlive it is a recurring problem rather than an edge case.
Instead every tool call opens a session, runs its commands, and disconnects. In
between calls the socket is free. You can attach `qmp-shell` to a VM the agent is
driving, poke at it, and detach, and the next tool call simply connects.
The cost is that concurrent calls against the same VM would collide, and they
would collide badly: the second connect would sit there until its five second
timeout, then report the VM unreachable, which reads exactly like a VM that has
exited. So sessions are serialized with a lock keyed by VM name. It is a mutex
table over resources rather than shared mutable state, which means two calls
against two different VMs still run in parallel, and only same-VM calls queue.
That lock exists inside one server process. It cannot serialize a second mcqemu
instance or your `qmp-shell`, and that is exactly why the connect-failure message
is careful (see [Reliability](/explanation/reliability/)): when a connect fails
but the process is demonstrably alive, the error says another client may hold the
monitor and explicitly tells the caller not to relaunch.
## The guest agent is a separate channel
Every launched VM gets a second socket wired to a virtio-serial port named
`org.qemu.guest_agent.0`. `guest_exec`, `guest_file_read`, `guest_file_write`,
`guest_ping` and `guest_info` all speak to that socket, never to QMP.
Keeping the channels separate is worth the extra socket. Guest-agent calls depend
on software inside the guest and are therefore allowed to hang, time out, or
never work at all; monitor calls control the machine from outside and must keep
working when the guest is wedged. Sharing one channel would let a frozen guest
interfere with the tools you use to deal with a frozen guest.
The guest agent happens to speak the QMP wire format, so the same client library
works with greeting and capability negotiation switched off. The protocol's
mandatory `guest-sync` handshake, which echoes a random token, doubles as the
probe for "is there actually an agent in there": if the token does not come back
within three seconds, the guest does not have a working `qemu-guest-agent` and
the tools say so rather than timing out on the real command later. See
[Install the guest agent](/how-to/install-the-guest-agent/) for the guest side.
## Images are `qemu-img` subprocesses
Image work shells out to `qemu-img` and parses its JSON output. There is no
library binding and no attempt to open qcow2 files directly.
The reason is safety rather than convenience. `qemu-img` participates in QEMU's
image locking protocol, so an attempt to convert or resize a disk that a running
VM has open fails with a lock error instead of corrupting the image. mcqemu
recognises that error and rewrites it into a sentence that names the actual
problem. The registry's own view of which disks are busy is advisory only (it
does not know an attached VM's disks and does not walk backing chains), so the
image lock is treated as the authority and the registry view as a hint.
## The registry is bookkeeping, not truth
The registry is a JSON file under the state directory holding one record per VM:
socket paths, pidfile, PID, the exact argv used, the launch config, log paths.
It exists so that a freshly started server can find VMs that a previous one
launched.
Two properties matter more than speed. It must never take the server down, and
it must tolerate more than one writer.
More than one writer is not hypothetical. A second MCP client, a stray
`uvx mcqemu`, or two clients configured against the same state directory all
share one file. So every write is a read-modify-write under an exclusive
`flock`, and the temp file used for the atomic rename carries the writing
process's PID in its name. Without the lock the last writer's snapshot silently
erases the other's VMs; with a shared temp name two writers could interleave and
publish the mixture.
Never taking the server down is the other half, and it is covered in
[Reliability](/explanation/reliability/): an unreadable registry is quarantined
and the server starts empty, reporting the damage through `list_vms` rather than
refusing to start.
## What this means in practice
VMs survive server restarts, so `list_vms` after a restart shows what is really
running rather than an empty list. The monitor socket is available to other
tooling between calls. Two agents can share a host without clobbering each
other's records, though they cannot serialize each other's monitor access, so
same-VM contention shows up as a retryable error rather than a wedge.
Paths for the state and runtime directories are documented in
[Configuration](/reference/configuration/), and every tool named here is listed
with its parameters in the [Tool reference](/reference/tools/).

View File

@ -0,0 +1,191 @@
---
title: How mcqemu handles failure
description: The four rules behind mcqemu's error handling (verify before destroying, degrade instead of dying, never guess an unchecked cause, bound every wait) and what each of them costs.
---
An MCP server that manages VMs is in an unusual position. Its callers are often
models, which are good at acting on a plausible-sounding sentence and less good
at doubting one. Its subjects are long-lived processes holding gigabytes of state
that no one wants to lose. And it works on a host that is doing other things at
the same time.
Four rules follow from that. None of them is clever; all of them are the boring
option, chosen deliberately.
## Never delete without verifying what you waited for
Waiting for something and then assuming it happened is the standard way to
destroy data. `sandbox_destroy` is the only tool in mcqemu that deletes files, so
it is where this rule is enforced hardest.
It sends `quit` over the monitor, then polls until the PID is actually gone,
giving it ten seconds. If the process is still there it escalates to `SIGKILL`
and polls again for five. If the process is *still* alive after that, it stops
and fails, and nothing is deleted. The error names the PID and says the overlay
is still open by that process.
That last branch is the point. Unlinking a disk image that QEMU still has open
does not free the space (the kernel keeps the inode alive for the open file
descriptor) and it does not stop the guest writing, so you end up with a running
VM writing into a file that no longer has a name, consuming disk you cannot
account for. Refusing to delete leaves you with a mess you can see, which is
strictly better than a mess you cannot.
The same instinct shows up in the surrounding checks. `sandbox_destroy` refuses
outright to touch a VM that was not created by `sandbox_vm`, because `stop_vm`
and `forget_vm` never delete disks and users reasonably assume the same of
everything else. It re-validates the VM name it was handed rather than trusting
the registry key it came from, since a corrupted record is exactly the input that
turns a delete into a disaster. It resolves the directories it is about to remove
and confirms they are genuinely inside the state and runtime roots, and are not
the roots themselves. Cleanup errors are collected rather than swallowed, so the
result distinguishes "destroyed" from "mostly destroyed, here is what is left".
## Degrade instead of dying
If the registry file is unreadable, mcqemu does not refuse to start. It moves the
damaged file aside to a timestamped quarantine name, starts with an empty
registry, and records what happened. `list_vms` returns that record in
`registry_warnings` alongside the (now empty) VM list. Individual records that
fail to parse are skipped one at a time with their own warning, rather than
taking the whole file down with them.
The reasoning is a direct consequence of the process model described in
[Architecture](/explanation/architecture/). VMs outlive this server. A server
that will not start because its notebook is corrupt is a server that cannot stop
the runaway VM eating the host's memory, and the notebook has no bearing on
whether that VM exists. Bookkeeping failures must not disarm the controls.
The warning matters as much as the degradation. A quietly empty list would be
read as "no VMs are running", which in this situation is exactly wrong: VMs may
well be running untracked, and their QMP sockets are still sitting under the
runtime directory where `attach_vm` can pick them up. So the tool's contract makes
the warning list impossible to miss, and its own description tells the caller to
report it rather than treat the list as complete.
A registry written by a newer mcqemu is handled the other way: if the schema
version is higher than this build understands, it is not loaded and not
quarantined, because reading it optimistically and writing it back would damage
records the newer build owns. Refusing and saying so is the safe direction.
The same preference for a usable degraded state appears at launch. If QEMU exits
zero (which means the VM is up and its monitor is listening) but the pidfile
cannot be read, the launcher does not raise. Raising there would leave a running,
unregistered VM that no tool could find or stop, which is the worst outcome
available. It returns a record without a PID, and only fails when neither a
pidfile nor a live QMP socket can be found, at which point it prints the `pgrep`
command to locate the process by hand.
## Do not guess a cause you have not checked
The most consequential error message in mcqemu is the one for a failed QMP
connect, and it has two forms.
If the process is not alive, the message says the VM has likely exited and points
at the QEMU log. If the process *is* alive, it says something quite different:
that QEMU's monitor accepts one client at a time, that another tool call or an
external `qmp-shell` may be holding it, and, explicitly, not to relaunch the VM.
The difference is not politeness. A caller told "the VM has exited" will do the
sensible thing and start it again, and if the VM was merely busy that means two
QEMU processes fighting over one disk image, or a launch that fails confusingly
on a locked image, or in the worst case a second guest writing to a filesystem
the first one is already mounting. The wrong diagnosis produces a wrong action
that produces real damage. So the liveness check runs before the message is
written, and the message only claims what was checked.
The same principle appears in a few other places:
Waiting for a shutdown polls the process, not just the event queue. The client
library's event queue never wakes on a dropped connection, so a VM that crashes
mid-wait would burn the caller's entire timeout and then be reported as a guest
that ignores ACPI, sending them to `force=True` for a machine that already died.
Polling liveness once a second turns that into "process exited during shutdown",
which is what happened.
The identity check on a PID is deliberately one-sided. It compares the `-name` in
`/proc/<pid>/cmdline` against the registry key, and returns "this is not a
different VM" whenever it cannot prove otherwise: unreadable cmdline, no `-name`
present, a truncated argument list. Its job is to veto a mistaken identification,
so an inconclusive read must not be allowed to contradict a positive liveness
check.
Liveness for an attached VM connects to its socket rather than stat-ing it,
because a SIGKILLed QEMU leaves the socket file behind and the file's existence
proves nothing. The same check protects launch: a leftover socket file under a
name you are reusing is deleted, but only after confirming that nothing is
listening on it, since an unregistered but running QEMU would otherwise lose its
monitor and become permanently unreachable.
And when a tool cannot tell, it says so. Timeouts report the events actually seen
while waiting. Guest-agent failures name the specific thing that is missing
(`qemu-guest-agent` installed and running in the guest) rather than reporting a
generic connection error, and a guest agent that refuses a command on a
RHEL-family guest gets the specific advice about `BLOCK_RPCS` in
`/etc/sysconfig/qemu-ga`.
## Bound every wait
Every operation that waits has a deadline. An unbounded wait in a server managing
multiple VMs does not just hang one call, it holds that VM's session lock and
blocks every subsequent call against the same machine, so one wedged guest takes
out an entire VM's tooling.
| Wait | Bound | Why that number |
| --- | --- | --- |
| QMP connect | 5s | Either the socket is there or it is not |
| QMP command | 30s | Ordinary monitor commands are fast; 30s means wedged |
| Guest-agent handshake | 3s | Doubles as the "is there an agent?" probe |
| Guest-agent call | 10s | The handshake succeeded, so the agent was alive a moment ago |
| `guest_exec` | Caller's `timeout`, plus an outer bound | The poll loop checks the clock between awaits, so a single hung call needs its own ceiling |
| `stop_vm` (graceful) | 30s, configurable | Long enough for a normal shutdown, short enough to notice ACPI being ignored |
| `savevm` / `loadvm` | 900s | Writing or reading all of guest RAM legitimately takes minutes |
| `sandbox_vm` agent wait | 90s, configurable | A cold boot plus the agent's own startup |
`savevm` is the interesting one. Snapshotting a large VM writes its entire RAM
into the qcow2 file and can honestly take minutes, so a 30 second bound would
turn a working operation into a spurious failure. The response is a much longer
bound rather than no bound at all, because "this can take a while" is not the same
statement as "this can take forever", and a wedged snapshot still needs to
surface eventually.
`sandbox_vm` shows the other half of bounding a wait well: it does not simply
sleep until its deadline. While waiting for the guest agent it also checks
whether the VM is still alive, so a guest that dies during boot is reported as
having exited, with the tail of the QEMU log, at the moment it happens, instead
of being blamed ninety seconds later on a missing guest agent.
## Concurrency is a failure mode too
Two mcqemu instances can share one registry file, and nothing prevents it, so
every write is a read-modify-write under an exclusive file lock and the atomic
rename uses a temp name carrying the writer's PID. Without the lock the second
writer's snapshot silently erases the first writer's VMs, and those VMs keep
running with nobody tracking them. `list_vms` re-reads the file before answering
so records another instance created are visible.
Within one process, calls against the same VM are serialized by a per-VM lock
(the monitor socket takes one client at a time), and a launch reserves its name
for the duration, because launching involves awaits between checking that a name
is free and registering it, and two concurrent launches would otherwise both pass
the check and race over the same sockets and pidfile.
## What all of this costs
More calls fail than would otherwise. A tool that refuses to delete, refuses to
guess, and gives up after a timeout will return errors in situations where a more
optimistic implementation would have carried on and usually been fine. That is
the trade being made, and it is made on purpose: the failures it avoids are the
ones that lose data or leave two VMs on one disk, and those are not recoverable
by retrying.
A bounded wait can also fire on a slow but perfectly healthy machine, which is
why the bounds most likely to be wrong for your host are parameters rather than
constants. `stop_vm` takes a `timeout`, `guest_exec` takes a `timeout`, and
`sandbox_vm` takes `wait_agent_s`. If you are running a guest under TCG emulation
on a busy laptop, raise them rather than fighting the defaults.
The errors are written to be acted on, which is worth knowing if you are reading
them as a model: they usually name the next tool to call, and when the safe move
is to do nothing they say that explicitly. Full parameter details for every tool
mentioned here are in the [Tool reference](/reference/tools/).

View File

@ -0,0 +1,154 @@
---
title: What sandboxing does and does not give you
description: The isolation a mcqemu sandbox actually provides (a separate guest kernel, a copy-on-write overlay, blocked outbound networking) and an honest account of where that boundary ends.
---
A sandbox in mcqemu is a QEMU virtual machine with two defaults changed: its disk
is a copy-on-write overlay over a base image, and its outbound networking is
switched off. `sandbox_vm` does the overlay, the launch, and the wait for the
guest agent in one call; `sandbox_destroy` tears the whole thing down. That is
the entire mechanism. Everything below is about what those two defaults buy you
and where they stop.
## A separate kernel is the real boundary
The isolation that matters here is not something mcqemu implements. It is the
fact that a guest runs its own kernel on emulated hardware. Guest processes make
syscalls into the guest kernel, which drives virtual devices, which QEMU
implements in userspace on the host. Nothing in the guest addresses host memory,
host processes, or host files, because there is no path from guest userspace to
those things that does not go through the device model.
That is a much stronger position than a container, where the guest and the host
share one kernel and isolation is a matter of namespaces and seccomp filters
being configured correctly. If you are running software you have reason to
distrust, the kernel boundary is the reason to reach for a VM.
The cost is that you pay for it. A VM boots a whole operating system, wants its
own memory, and (when the guest architecture does not match the host) runs under
TCG emulation, which is correct but slow.
## The overlay: the base image is never written
`sandbox_vm` creates a qcow2 overlay whose backing file is your base image, and
gives the VM only the overlay. Every write the guest makes lands in the overlay.
The base image is opened read-only through the backing chain and comes out
byte-identical no matter what happens inside.
This is what makes sandboxes cheap enough to be disposable. Creating one is
writing a small file with a pointer to a big one, so a fresh sandbox from a
20 GB base costs kilobytes and a second or two. It is also what makes
`sandbox_destroy` safe: it deletes the overlay and the VM's state and runtime
directories, and reports the base image path back under `base_image_untouched`
so the answer is visible rather than assumed.
Two consequences worth knowing. The overlay is recreated fresh on every
`sandbox_vm` call for a given name, so a stale overlay from a dead sandbox never
resurrects old state. And because the base is genuinely in use while a sandbox
runs, mcqemu counts it among the disks in use, so an attempt to modify the base
underneath a running sandbox is refused.
If you want state to survive, take a snapshot (see [Snapshots](/how-to/snapshots/))
or launch a normal VM against a real disk with `launch_vm` instead. Sandboxes
are for work you intend to throw away. [Disposable sandboxes](/tutorial/disposable-sandboxes/)
walks through the loop.
## Outbound networking is off by default
`sandbox_vm` launches with QEMU's user-mode networking (SLIRP) in restricted
mode. Guest-initiated traffic is dropped. Inbound port forwards keep working, so
the default forward from a free host port to guest port 22 is still how you get
in, and `guest_exec` over the agent socket is unaffected because it never touches
the network at all.
The reason this is the default is specific, and it is worth understanding rather
than taking on faith. SLIRP synthesizes a small private network for the guest,
and in that network the address `10.0.2.2` is the host's loopback interface. A
guest with unrestricted user-mode networking can therefore open connections to
services listening on the host's `127.0.0.1`: a development database that never
bothered with authentication because it only listens locally, an SSH agent
forwarding socket, an unauthenticated Ollama or Redis, a `docker` API on a TCP
port. The mental model of "it is only on localhost, so it is private" is exactly
the model SLIRP breaks. `restrict=on` closes that path along with internet
access.
When a guest legitimately needs to fetch packages, pass `allow_network=True`, and
be aware you are re-opening the host loopback path at the same time. There is no
setting that gives internet access while blocking `10.0.2.2`, because SLIRP does
not offer that distinction. If you need it, give the VM a real bridged or TAP
interface and enforce the policy in your host firewall, which means starting
QEMU yourself and registering it with
[attach_vm](/how-to/attach-existing-vm/).
`launch_vm` takes the same `restrict_net` flag, plus `no_net=True` to remove the
network card entirely. A VM with no NIC has no forwards either, so it is only
reachable through the guest agent and the display.
## Where the boundary ends
Everything above is real. None of it makes this a hardened security boundary
against a determined attacker.
**QEMU escapes exist.** The device model is a large body of C code parsing input
that a hostile guest controls, and it has had exploitable bugs, some of them
serious. mcqemu changes nothing about that surface. If your threat model
includes an adversary willing to burn a QEMU escape on you, a mcqemu sandbox is
not the control you want on its own. Real defense in depth for that case means
running QEMU as a dedicated unprivileged user, on a host that holds nothing
valuable, behind whatever seccomp and MAC policy your distribution offers, and
not on your workstation.
**The QEMU process runs as you.** It has your file permissions, your network
access, and your ability to write to your own home directory. The boundary being
defended is "the guest cannot reach the host", and it holds against the guest
playing by the rules of the device model. It does not hold against an escape,
because the thing that escapes lands with your privileges.
**`extra_args` can weaken isolation.** `launch_vm` accepts raw QEMU flags,
because there will always be a machine type or device you need and no wrapper
anticipated. That escape hatch is an operator tool. Values you wrote yourself are
fine; values derived from anything untrusted are not, because a few flags turn a
VM into a hole straight through to the host. Those are rejected outright:
| Rejected | Why |
| --- | --- |
| `-fsdev`, `-virtfs` | Host filesystem passthrough into the guest |
| `-drive` / `-blockdev` naming `/dev/...` | Attaches a host block device |
| `-chardev` with `spawn` | Runs a host command |
| `-runas` | Changes the user the QEMU process runs as |
| `-monitor` | Exposes the human monitor outside the managed socket |
| `-qmp`, `-pidfile`, `-daemonize` | Collide with the sockets and process model mcqemu manages |
Read that list as what it is: a guardrail against an agent constructing arguments
from untrusted input, not a security perimeter. It is a fixed list of known-bad
flags, not a proof that everything else is safe, and QEMU has a very large flag
surface. An operator who genuinely wants host passthrough can start QEMU by hand
and manage it with `attach_vm`, and nothing stops them, which is correct. The
point of the check is that the path to weakening isolation should require a
person deciding to do it.
**The guest agent is root inside the guest, by design.** `guest_exec` runs
commands as the agent's user, which is normally root, and `guest_file_write` will
write anywhere the agent can reach. That is not a leak, it is the feature: you
already control the VM's power button, its disks and its display from the host,
so declining to give you a shell would be theatre. What it does mean is that
installing the guest agent is a decision about the guest, not a neutral
convenience. Do not install it in a VM whose contents you are supposed to be
analysing at arm's length, and remember that a sandbox with the agent running has
no meaningful defense against the host, only against the guest reaching out.
## A summary you can act on
Protected: your host filesystem from guest processes, your host services on
loopback (with the default restricted networking), your base images from any
guest writes, and your host kernel from guest syscalls.
Not protected: anything, if QEMU itself is exploited; anything, if you pass
isolation-weakening `extra_args`; the guest's own contents from you, since the
agent and the monitor give you full control; and the host loopback path, if you
pass `allow_network=True`.
Use a sandbox to run software you have not read, to test an installer, to try a
package that wants to touch your whole system, or to give an agent somewhere to
make a mess. Do not use one as the only thing standing between you and code
written specifically to attack you.

View File

@ -0,0 +1,148 @@
---
title: Seeing and driving a VM
description: Why screenshots and scancode-level input let an agent operate a machine that has no OS yet, how absolute and relative pointers differ, and why the look-act-look loop beats a blind sequence of keystrokes.
---
Most tooling that automates a computer needs the computer's cooperation. SSH
needs a running sshd, and a network. A guest agent needs a package installed and
a service started. Configuration management needs an OS that already booted.
Every one of those requires that somebody already got the machine into a working
state, which is precisely the part that is tedious to do by hand.
mcqemu can see and drive a VM without any of it. Screenshots come out of QEMU's
framebuffer and keystrokes go into QEMU's emulated keyboard controller, so both
work at a BIOS setup screen, a GRUB menu, a partitioner, a login prompt, or a
kernel panic. That is the capability the display tools exist for.
## Screenshots come from the framebuffer, not the guest
`vm_screenshot` issues QMP's `screendump`, which asks QEMU to render whatever its
emulated display device currently holds and write it to a file on the host as a
PNG. The server reads that file, returns the image, and deletes it. The guest is
not asked, not interrupted, and does not need to be capable of anything.
Each call writes to a filename unique to that call. A shared name would let a
concurrent screenshot swap the frame between capture and use (which matters for
`vm_click`, described below), and would leave a picture of the guest's screen
sitting on disk afterwards.
Two things follow from "this is the framebuffer, not a rendering of guest state".
A blank image is a real answer, not a failure: consoles blank, guests switch to
a mode with nothing drawn yet, and a VM sitting at a black screen may be fine.
And the resolution is whatever the guest last programmed, so it changes when an
installer switches from text mode to a graphical mode, or when a display manager
starts. Coordinates from an old screenshot do not survive a mode change.
For text-mode guests there is a cheaper channel. If the guest writes to its
serial port (a kernel booted with `console=ttyS0`, or a text installer),
`vm_serial_read` returns the tail of the serial log as text, which is far easier
to read than pixels and keeps scrollback that a screenshot cannot. It returns
nothing useful for a guest that only draws to the screen.
## Keyboard input is synthesized at the scancode level
`vm_send_keys` uses QMP's `send-key`, which injects key events into the emulated
keyboard controller as QEMU's `qcode` key identifiers. The guest sees exactly
what it would see from a physical keyboard on the emulated hardware. Nothing
above the hardware layer is involved, which is why this works before an OS
exists.
Each entry in the `keys` list is one press. A single key is `"ret"`, `"esc"`,
`"f2"`, `"a"`; a chord is written with hyphens (`"ctrl-alt-f2"`, `"ctrl-c"`) and
its keys are held together. Friendly aliases are accepted, so `"enter"`,
`"space"` and `"escape"` resolve to `ret`, `spc` and `esc`. Between entries there
is a configurable delay, and each press has a hold time, because firmware menus
and some bootloaders sample the keyboard on a timer and simply miss a press that
is too brief.
`vm_type_text` is the same mechanism with a translation layer: each character is
mapped through a US layout to a qcode plus an optional shift, and sent as its own
press. This has consequences you should expect rather than discover. The layout
is fixed, so a guest configured for a non-US keyboard will produce different
characters than you typed, most visibly for symbols and for anything involving
AltGr. And every character is a separate round trip over the monitor, so typing
is slow and is capped at 4096 characters. Writing a config file by typing it into
an editor works and is sometimes the only option; when the guest agent is
available, `guest_file_write` is the right tool for anything longer than a
command line.
## Pointer input has two modes, and they behave differently
**Absolute (the default).** VMs launched by mcqemu get a `virtio-tablet-pci`
device, which reports positions rather than movements, exactly like a touchscreen
or a drawing tablet. `vm_click` takes pixel coordinates that match what
`vm_screenshot` showed you. It takes a screenshot first, both to prove a display
exists and to read the resolution, checks that your coordinates are inside it,
scales them into QEMU's absolute coordinate space (0 to 32767 on each axis), then
sends the position and the button events. There is no cursor to chase: the
pointer arrives where you said.
The catch is that the guest needs a driver for that device. Modern Linux, recent
Windows with virtio drivers, and many live images have one. A guest from before
about 2010, or a minimal install without the driver, does not, and `vm_click`
will appear to do nothing at all. That silent non-effect is the symptom to
recognise.
**Relative (the fallback).** `vm_mouse_move` drives the emulated PS/2 mouse
through the human monitor, which every guest with any mouse support understands.
The trade is that you are no longer specifying a position, you are specifying
movement, and the guest decides what to do with it. Pointer acceleration means a
50 pixel delta may move the cursor 50 pixels, or 90, depending on the guest's
settings. Large deltas also desync some guests outright, which is why motion is
sent in small packets (32 pixels by default, never more than 120, because PS/2
deltas are small signed values).
Because you cannot address a position directly, the reliable technique is to
manufacture one. Pass `home="bottom-right"` (or any corner) and mcqemu pushes the
cursor hard against that corner, overshooting deliberately, which pins it to a
known location regardless of where it was. From there you move toward the target
in small steps, take a screenshot to see where the cursor actually landed,
correct with further small moves, and only then click. The tool's own response
says as much, because getting this wrong is the default outcome and a click in
the wrong place in an installer is expensive.
## The look-act-look loop
The single most useful habit when driving a VM is: take a screenshot, perform one
action, take another screenshot. It feels wasteful and it is not.
Blind sequences of keystrokes go wrong for reasons that have nothing to do with
the sequence being incorrect. Guests take unpredictable time to react, and a VM
under TCG emulation can be an order of magnitude slower than the same guest with
KVM, so timing tuned once does not transfer. Input sent while the guest is
probing devices or switching video modes is dropped on the floor with no error
anywhere; the keystroke simply never happened. Installers reorder or insert
screens depending on what hardware they find, what mirror they reach, and whether
a disk already has a partition table. Focus moves on its own when a dialog
appears. And the failure mode compounds: once one keystroke lands on the wrong
screen, every subsequent keystroke in the sequence is being interpreted by a
program you did not intend, and the machine can end up in a state that is worse
than where it started (a partitioner that has silently selected the wrong disk is
the memorable example).
None of this is detectable from the return value of a key-send. The keystroke was
delivered to the emulated keyboard successfully; that is all the success of that
call means. The only evidence about what actually happened is the next
screenshot.
So the loop is: look at where you are, decide one action, take it, look again to
confirm it did what you expected. When a step is slow (a package installation, a
reboot, a filesystem being written) look repeatedly rather than sleeping for a
guess. When something unexpected appears, you find out one action after it
happened instead of twenty. [Drive an installer](/how-to/drive-an-installer/)
applies this to a concrete case, and [your first sandbox](/tutorial/first-sandbox/)
is a gentler place to get a feel for it.
## What this does not give you
The model is reading pixels. There is no accessibility tree, no DOM, no list of
widgets, and no text extraction: a button is a shape with letters drawn on it, so
low-contrast themes, unusual fonts, small text and scaled displays all degrade
the read. Coordinates are display-space and go stale on a mode change. There is
no way to ask what has focus, so a screenshot has to be interpreted for it.
The compensation is that all of this works on absolutely any guest, including one
whose OS does not exist yet. Once a guest is booted and has the agent installed,
`guest_exec` is faster, more precise and much easier to check for success, and it
is the right tool for anything reachable that way. Screenshots and scancodes are
for getting to that point, and for the times when the guest cannot help you.

View File

@ -0,0 +1,149 @@
---
title: Manage a VM you started yourself
description: Register an externally launched QEMU process with attach_vm, know which tools work on it, and understand what forget_vm does.
---
import { Steps, Aside } from '@astrojs/starlight/components';
`launch_vm` builds its own QEMU command line, which is convenient until you
need something it does not offer: a device model it never adds, a bridged
network, a specific machine type, or a VM that libvirt or a script already
starts for you.
`attach_vm` covers that case. You start QEMU however you like, and mcqemu
manages it through its QMP control socket. It never rewrites your command line
and never launches the process; it only connects to what is already there.
## Give QEMU the sockets
The one hard requirement is a QMP unix socket, added with
`-qmp unix:/path,server=on,wait=off`. The `server=on` part makes QEMU create
and listen on the socket, and `wait=off` stops it from blocking at startup
until a client connects.
Two optional additions are worth including, because retrofitting them means
restarting the guest:
- A virtio-serial channel named `org.qemu.guest_agent.0` for the `guest_*`
tools.
- `virtio-tablet-pci` for absolute pointer positioning, which is what
`vm_click` needs.
A complete example:
```bash
qemu-system-x86_64 \
-name legacy \
-machine q35,accel=kvm -cpu host -m 4096 -smp 4 \
-drive file=$HOME/vms/legacy.qcow2,if=virtio,format=qcow2 \
-display none \
-qmp unix:$XDG_RUNTIME_DIR/legacy-qmp.sock,server=on,wait=off \
-chardev socket,id=qga0,path=$XDG_RUNTIME_DIR/legacy-qga.sock,server=on,wait=off \
-device virtio-serial \
-device virtserialport,chardev=qga0,name=org.qemu.guest_agent.0 \
-device virtio-tablet-pci \
-daemonize -pidfile $XDG_RUNTIME_DIR/legacy.pid
```
If the VM is already running without a QMP socket, there is no way to add one
without restarting it. QMP is the only channel these tools have.
## Attach it
<Steps>
1. Register the process:
```json
attach_vm(name="legacy",
qmp_socket="/run/user/1000/legacy-qmp.sock",
qga_socket="/run/user/1000/legacy-qga.sock",
pid=48213)
```
Only `name` and `qmp_socket` are required. `qga_socket` enables the
`guest_*` tools; `pid` lets liveness checks tell "stopped" apart from
"unreachable" without probing the socket.
2. The call verifies the socket exists, is really a unix socket, and answers a
QMP status query before registering anything, then returns the guest's
current status. A failure here means QEMU is not listening where you said.
3. Confirm it shows up alongside everything else:
```json
list_vms()
```
The entry has `source: "attached"`, which is how the other tools know it was
not spawned here.
</Steps>
Names must be unique across the registry. If the name is taken, either pick
another or `forget_vm` the old entry first.
## What works, and what does not
Once attached, almost everything behaves as it does for a launched VM:
- `vm_info`, `list_vms`, `pause_vm`, `resume_vm` all work over QMP.
- `vm_screenshot`, `vm_send_keys` and `vm_type_text` work if the VM has a
display device.
- `vm_click` needs the tablet device; without it, use `vm_mouse_move` (see
[Drive an installer](/how-to/drive-an-installer/)).
- `guest_*` tools work if you passed `qga_socket` and the guest is running
`qemu-guest-agent`.
- `vm_snapshot_*` works if the VM's writable disks are qcow2.
- `stop_vm` sends an ACPI power button press over QMP, and with `force=true`
tells QEMU to quit outright.
The exception is `vm_serial_read`. mcqemu reads the serial log file it set up
at launch, and an attached VM's serial output goes wherever your own command
line sent it. The tool reports that rather than guessing. Read your own log
file directly, or use `vm_screenshot`.
`sandbox_destroy` also refuses attached VMs, along with any VM that
`sandbox_vm` did not create. It deletes disks, and it will only delete overlays
it made itself.
<Aside type="caution">
`stop_vm(name="legacy", force=true)` terminates the QEMU process immediately,
exactly as if you had pulled the power cord, whoever started it. If the VM
belongs to something else that supervises it (libvirt, a systemd unit, a
script), stop it through that system instead, or you will race whatever
restarts it.
</Aside>
## What forget_vm does
`forget_vm` removes the registry entry. That is all it does.
```json
forget_vm(name="legacy")
```
It does not stop the VM, does not touch the QEMU process, and never deletes a
disk. Afterwards the VM keeps running exactly as before, and mcqemu simply has
no record of it. The result reports `process_left_running` so you know whether
you have just stopped tracking a live process.
Use it to hand a VM back to whatever else owns it, to clear a stale entry for a
VM that died elsewhere, or to free up a name.
For a VM that this server launched, `forget_vm` refuses while the process is
still alive unless you pass `force=true`. Forgetting a running spawned VM
orphans it: the process stays up holding its disks and ports, and no tool can
find it any more. The refusal exists so that only happens on purpose. If you do
orphan one, find it with `pgrep -af qemu-system` and either kill it or attach
it again by its socket path.
Re-attaching later is just `attach_vm` with the same socket paths. Nothing
about the running VM changed while it was unregistered.
## Related
- [Install and connect](/how-to/install-and-connect/) for host requirements.
- [Architecture](/explanation/architecture/) for how the registry tracks VMs
and why they outlive the server.
- [Tool reference](/reference/tools/) for parameters and defaults.

View File

@ -0,0 +1,214 @@
---
title: Drive an installer
description: Use the screenshot, act, screenshot loop to get through a bootloader, a text installer or a graphical one, including when to use vm_click versus vm_mouse_move.
---
import { Steps, Aside } from '@astrojs/starlight/components';
Installers run before there is anything inside the guest to talk to. No SSH, no
guest agent, no shell. The tools in this page work at the level QEMU emulates
hardware: a framebuffer you can photograph, a keyboard that emits scancodes,
and a mouse. That means they work in BIOS menus, bootloaders, partitioners and
desktop environments alike, on any operating system, with nothing installed in
the guest.
This page assumes a VM is already running (see
[Your first virtual machine](/tutorial/first-sandbox/) if not).
## The loop
Every interaction is the same three beats: look, act, look again.
<Steps>
1. `vm_screenshot(name="vm")` returns the display as a PNG. Read what is
actually on screen.
2. Send exactly one meaningful action: a keystroke, a line of text, a click.
3. Screenshot again to see what it did.
</Steps>
The second screenshot is not optional bookkeeping, it is the whole method. A
guest takes time to react, and an installer's next question is rarely the one
you predicted. Chaining three blind actions and screenshotting at the end
usually means finding out that action one landed somewhere unexpected and
actions two and three went into a dialog you did not know was there.
<Aside type="caution">
Blind sequences are how installers get told to erase the wrong disk. Answer one
prompt per screenshot, especially at partitioning steps and any confirmation
dialog.
</Aside>
Guests need time. After pressing Enter on a step that formats a filesystem or
copies packages, wait before screenshotting again, and if the screen has not
changed, wait longer rather than sending the key again. Repeated keystrokes
queue up and fire all at once when the guest catches up.
## Keyboard
`vm_send_keys` presses keys and chords. Each entry in `keys` is one press:
```json
vm_send_keys(name="vm", keys=["down", "down", "ret"])
vm_send_keys(name="vm", keys=["ctrl-alt-f2"])
vm_send_keys(name="vm", keys=["esc"])
```
A chord is written with hyphens and is pressed together, so `"ctrl-c"` is one
entry, not two. Aliases like `enter`, `space` and `escape` work alongside the
QEMU names `ret`, `spc`, `esc`. Function keys are `f1` through `f12`.
Two timing knobs matter when a guest is slow or an installer eats keys during
a redraw: `hold_ms` (default 100) is how long each key is held down, and
`delay_ms` (default 50) is the gap between entries. Raising `delay_ms` to 200
or so is the usual fix for a menu that seems to miss presses.
`vm_type_text` types a string character by character, which is what you want
for hostnames, passwords and shell commands:
```json
vm_type_text(name="vm", text="setup-alpine", enter=true)
```
`enter=true` presses Enter at the end. The layout is US ASCII: printable
characters plus tab and newline. There is a 4096 character limit, because every
character is a separate round trip to the guest. If you need to put a large
file into a guest, wait until the guest agent is available and use
`guest_file_write` instead of typing it.
## Text installers: read the serial console
Many text-mode installers and Linux kernels booted with `console=ttyS0` write
to the serial port, and mcqemu logs that to a file for every VM it launches:
```json
vm_serial_read(name="vm", tail_lines=100)
```
Text is much cheaper to read than a screenshot and it carries scrollback, so
prefer it when the guest is producing it. Guests that only paint a graphical
screen log nothing here, and VMs registered with `attach_vm` manage their own
serial output, so `vm_serial_read` has nothing to read for those.
## Mouse: which tool
Two tools move the pointer, and picking the wrong one wastes a lot of time
because the failure is silent. Nothing errors, the click just does not land.
**`vm_click` is the one to try first.** It takes pixel coordinates that match
what `vm_screenshot` showed you, and drives an absolute-position tablet device
that every VM launched by this server has:
```json
vm_click(name="vm", x=412, y=337)
vm_click(name="vm", x=88, y=120, button="right")
vm_click(name="vm", x=200, y=150, double=true)
```
Coordinates are validated against the current display size, so a click outside
the screen is rejected rather than silently dropped.
This works when the guest has a driver for the tablet device. Modern Linux,
Windows 7 and later, and current BSDs do. Screenshot after the click: if the
pointer did not move to where you clicked, the guest has no absolute pointer
driver and you need the other tool.
**`vm_mouse_move` is for guests without tablet drivers**, which in practice
means most operating systems older than about 2010, and some installers before
their drivers load. It drives the emulated PS/2 mouse, which only understands
relative motion: "three pixels left", never "go to 412, 337". The guest may
also apply its own pointer acceleration, so a request to move 200 pixels can
land 260 pixels away.
The reliable pattern is corner, step, verify:
<Steps>
1. Home the cursor against a corner so you know where it is. Motion is
deliberately overshot toward the edge, so the cursor pins there whatever its
previous position was.
```json
vm_mouse_move(name="vm", home="top-left")
```
Valid corners are `top-left`, `top-right`, `bottom-left`, `bottom-right`.
2. Move toward the target with `dx` and `dy` relative to that corner:
```json
vm_mouse_move(name="vm", home="top-left", dx=412, dy=337)
```
Motion is sent in packets no larger than `step` pixels (default 32) because
guests commonly desync or over-accelerate on large deltas. Lower `step` if a
guest behaves erratically; the cost is more round trips.
3. Screenshot and find the cursor. Acceleration means it is often not where you
asked.
4. Correct with small relative moves, no `home` this time, until the cursor is
on the target:
```json
vm_mouse_move(name="vm", dx=-14, dy=6)
```
5. Screenshot to confirm, then click:
```json
vm_mouse_move(name="vm", click="left")
```
You can also move and click in one call by passing `click` alongside `dx`
and `dy`, but only once you trust that guest's scaling. `double=true`
double-clicks.
</Steps>
Homing costs a screenshot and a burst of motion packets, so for a sequence of
clicks in the same area, home once and then work relatively from where you
know the cursor is, re-homing whenever you lose track.
## Worked example: a graphical installer
<Steps>
1. Screenshot. The bootloader menu is showing.
2. `vm_send_keys(name="vm", keys=["ret"])` to take the default entry.
3. Wait, then screenshot. Repeat until the installer's first screen appears;
graphical installers can take a minute to start under emulation.
4. Screenshot, find the "Next" button, and `vm_click` on its centre.
5. Screenshot. If the button highlighted or the page advanced, the tablet works
and you can use `vm_click` for the rest of the installation. If nothing
moved, switch to `vm_mouse_move` with the corner, step, verify pattern.
6. For text fields, click into the field, screenshot to confirm the caret is
there, then `vm_type_text`. Do not assume focus; installers move it around
between pages.
7. At the partitioning step, screenshot before and after every action, and read
the confirmation dialog before answering it.
</Steps>
## After the install
When the guest is up, install `qemu-guest-agent` inside it (see
[Install the guest agent](/how-to/install-the-guest-agent/)). Screenshots and
keystrokes keep working afterwards, and stay the right tool for boot menus and
crash screens, but for anything the guest can do itself, `guest_exec` gives you
exit codes and text instead of pixels.
## Related
- [See and drive](/explanation/see-and-drive/) for how input injection and
screenshots work underneath.
- [Tool reference](/reference/tools/) for the full parameter list.

View File

@ -0,0 +1,172 @@
---
title: Install and connect
description: Install mcqemu with uv, register it with Claude Code at project or user scope, and confirm the host can actually run VMs.
---
import { Steps, Aside, Tabs, TabItem } from '@astrojs/starlight/components';
## Requirements
mcqemu drives local QEMU processes, so it runs where QEMU runs:
- Linux, with `qemu-system-x86_64` and `qemu-img` on your PATH. Other guest
architectures need their own binary (`qemu-system-aarch64` and so on).
- Python 3.11 or newer, managed with [uv](https://docs.astral.sh/uv/).
- Read and write access to `/dev/kvm` for hardware acceleration. This is
optional: without it QEMU falls back to TCG software emulation, which works
and is considerably slower.
Check the host before blaming the server:
```bash
qemu-system-x86_64 --version
qemu-img --version
ls -l /dev/kvm && test -w /dev/kvm && echo "kvm writable"
```
If `/dev/kvm` exists but is not writable, add yourself to the group that owns
it (usually `kvm`) and log back in.
## Install
<Tabs>
<TabItem label="From PyPI">
`uvx` downloads and runs the published package, with no install step to
maintain:
```bash
uvx mcqemu
```
It prints a version banner to stderr and then waits for an MCP client on
stdin. That is a healthy server; press Ctrl-C.
</TabItem>
<TabItem label="From a checkout">
Working on mcqemu itself, or pinning to a local revision:
```bash
git clone https://git.supported.systems/warehack.ing/mcqemu
cd mcqemu
uv sync
uv run mcqemu
```
</TabItem>
</Tabs>
## Register with Claude Code
Pick the scope by asking who should get these tools.
<Tabs>
<TabItem label="Project scope">
Project scope writes `.mcp.json` in the repository, so anyone who checks it out
gets the same server. Use this when VMs are part of the project's workflow,
such as a repository whose tests need a throwaway guest.
```bash
claude mcp add --scope project mcqemu -- uvx mcqemu
```
The resulting file, which you can also write by hand:
```json title=".mcp.json"
{
"mcpServers": {
"mcqemu": {
"type": "stdio",
"command": "uvx",
"args": ["mcqemu"],
"env": {}
}
}
}
```
From a checkout, use `"command": "uv"` with
`"args": ["run", "--directory", "/path/to/mcqemu", "mcqemu"]` so the server
runs regardless of which directory Claude Code starts in.
</TabItem>
<TabItem label="User scope">
User scope makes the tools available in every project you open. Use this when
VMs are part of how you work rather than part of one repository.
```bash
claude mcp add --scope user mcqemu -- uvx mcqemu
```
</TabItem>
</Tabs>
<Aside>
VMs are host-wide, not project-wide. A VM launched from one project is visible
and controllable from any other project that has mcqemu connected, because they
all read the same registry under `~/.local/share/mcqemu/`. Scope decides who
gets the tools, not which VMs they can see.
</Aside>
## Confirm it works
<Steps>
1. Run `/mcp` in Claude Code. `mcqemu` should be listed as connected. If it is
listed as failed, run the exact command from your config in a terminal; the
error is usually a missing `uv` on PATH or a wrong `--directory`.
2. Ask the agent to call `list_vms`. A fresh install answers with an empty list
and empty `registry_warnings`.
3. Launch something disposable to prove QEMU itself works. Any ISO will do, and
it does not need to boot anywhere useful:
```json
launch_vm(name="smoketest", iso="~/isos/alpine-virt.iso")
```
The result reports `accel: "kvm"` or `accel: "tcg"`, which tells you whether
acceleration is active.
4. Take a screenshot with `vm_screenshot(name="smoketest")`. If you get a PNG of
a bootloader, every layer is working: the server, QEMU, and the QMP control
channel.
5. Clean up: `stop_vm(name="smoketest", force=true)`. Force is appropriate here
because no operating system is running to answer a power button press.
</Steps>
## When something fails
**`qemu-system-x86_64 not found on PATH`.** QEMU is not installed, or not for
that architecture. On Arch, `pacman -S qemu-full`; on Debian and Ubuntu,
`apt install qemu-system-x86`.
**The launch result says `accel: "tcg"` and you expected KVM.** Either
`/dev/kvm` is not writable by your user, or the guest architecture does not
match the host. KVM only applies when the two match; an aarch64 guest on an
x86_64 host is always emulated.
**A VM is running but no tool can see it.** VMs survive restarts of the MCP
server because they are daemonized QEMU processes, and the registry on disk is
what connects them. If `list_vms` returns a non-empty `registry_warnings`, that
bookkeeping was damaged and VMs may be running untracked. Find them with
`pgrep -af qemu-system` before launching anything with the same name.
**You want the state kept somewhere else.** `MCQEMU_STATE_DIR` and
`MCQEMU_RUNTIME_DIR` move the registry, logs and sockets;
`MCQEMU_LOG_LEVEL=DEBUG` makes the server chattier on stderr. See
[Configuration](/reference/configuration/).
## Next
- [Your first virtual machine](/tutorial/first-sandbox/) for a guided
install from an ISO.
- [Tool reference](/reference/tools/) for every tool, parameter and default.
- [Architecture](/explanation/architecture/) for what the server keeps on disk
and why VMs outlive it.

View File

@ -0,0 +1,208 @@
---
title: Install the guest agent
description: Get qemu-guest-agent into a guest, either from inside a running VM or by injecting it into a stopped disk image with qemu-nbd and chroot.
---
import { Steps, Aside } from '@astrojs/starlight/components';
`guest_exec`, `guest_file_read` and `guest_file_write` talk to
`qemu-guest-agent` running inside the guest operating system. mcqemu wires the
host side of that channel (a virtio-serial port named
`org.qemu.guest_agent.0`) into every VM it launches, so the only missing piece
is the package inside the guest.
Check what you have:
```json
guest_ping(name="vm")
```
Success means you are done. Failure means the agent is not installed, not
running, or the guest is paused or frozen. `guest_info` then reports the guest
OS, the agent version, and which agent commands are enabled.
## Option 1: from inside a running guest
Use this when the guest is up and you can reach a console (with
`vm_type_text`, see [Drive an installer](/how-to/drive-an-installer/)) or an
SSH session through a forwarded port.
The guest needs package downloads to work, so if this VM was created with
`sandbox_vm`, it has outbound networking blocked by default; recreate it with
`allow_network=true` for the install.
Run the pair of commands for the distribution, as root:
```bash
# Debian, Ubuntu
apt-get update && apt-get install -y qemu-guest-agent
systemctl enable --now qemu-guest-agent
# Fedora, RHEL, CentOS, Rocky, Alma
dnf install -y qemu-guest-agent
systemctl enable --now qemu-guest-agent
# Arch
pacman -S --noconfirm qemu-guest-agent
systemctl enable --now qemu-guest-agent
# Alpine
apk add qemu-guest-agent
rc-update add qemu-guest-agent default && rc-service qemu-guest-agent start
# openSUSE
zypper install -y qemu-guest-agent
systemctl enable --now qemu-guest-agent
```
Windows guests get the agent from the
[virtio-win](https://github.com/virtio-win/virtio-win-pkg-scripts) ISO: attach
it with `launch_vm(..., iso="/path/to/virtio-win.iso")` and run
`guest-agent\qemu-ga-x86_64.msi` from inside the guest.
Then confirm from the host:
```json
guest_ping(name="vm")
guest_exec(name="vm", command="uname", args=["-a"])
```
<Aside>
On RHEL-family guests the agent ships with several remote procedure calls
disabled, and `guest_exec` is one of them. If `guest_ping` succeeds but
`guest_exec` is refused, remove the relevant entries from `BLOCK_RPCS` in
`/etc/sysconfig/qemu-ga` inside the guest and restart the
`qemu-guest-agent` service.
</Aside>
Enabling the service matters as much as installing the package. An agent that
is running now but not enabled at boot will be missing the next time you launch
that image, and every sandbox built on it.
## Option 2: inject it into a stopped disk image
Use this when you cannot get a console: a downloaded cloud image with no
password set, an image whose network never comes up, or when you are preparing
a base image and would rather not boot it at all.
The technique exposes the disk image to the host as a block device with
`qemu-nbd`, mounts the guest's root filesystem, and installs the package into
it through `chroot`.
<Aside type="caution">
The VM must not be running, and nothing else may have the image open. Two
writers on one disk image corrupts it. Run `list_vms` and confirm the image is
not attached to anything before you connect it.
Every command below needs root on the host. You are mounting a filesystem you
did not create and running its package manager; do this with images you trust.
</Aside>
<Steps>
1. Load the network block device module and connect the image:
```bash
sudo modprobe nbd max_part=8
sudo qemu-nbd --connect=/dev/nbd0 ~/vms/debian.qcow2
```
2. Find the root partition. Do not guess: images vary, and picking a boot or
EFI partition wastes time.
```bash
lsblk /dev/nbd0
sudo blkid /dev/nbd0p*
```
The root filesystem is usually the largest ext4, xfs or btrfs partition.
If `lsblk` shows LVM physical volumes instead of a plain filesystem, run
`sudo vgchange -ay` and mount the resulting device under `/dev/mapper/`.
3. Mount it, plus the pseudo-filesystems the package manager needs:
```bash
sudo mkdir -p /mnt/guest
sudo mount /dev/nbd0p1 /mnt/guest
sudo mount --bind /dev /mnt/guest/dev
sudo mount --bind /proc /mnt/guest/proc
sudo mount --bind /sys /mnt/guest/sys
```
If the image has a separate `/boot` or EFI partition, mount those inside
`/mnt/guest` too. Installing the agent does not need them, but a package
manager that decides to regenerate an initramfs will fail without them.
4. Give the chroot working DNS, keeping the original so you can put it back:
```bash
sudo cp /mnt/guest/etc/resolv.conf /mnt/guest/etc/resolv.conf.orig
sudo cp /etc/resolv.conf /mnt/guest/etc/resolv.conf
```
5. Install the agent inside the image:
```bash
sudo chroot /mnt/guest apt-get update
sudo chroot /mnt/guest apt-get install -y qemu-guest-agent
sudo chroot /mnt/guest systemctl enable qemu-guest-agent
```
`systemctl enable` works in a chroot because it only creates symlinks; it
does not need systemd to be running. `systemctl start` would fail, which is
fine, because the guest starts the service on its next boot. For dnf-based
images substitute `dnf install -y qemu-guest-agent`, and for Alpine
`apk add qemu-guest-agent` with `rc-update add qemu-guest-agent default`.
6. Restore the guest's own resolver:
```bash
sudo mv /mnt/guest/etc/resolv.conf.orig /mnt/guest/etc/resolv.conf
```
7. Tear down in reverse order, and check each step succeeded:
```bash
sudo umount -R /mnt/guest
sudo qemu-nbd --disconnect /dev/nbd0
```
If `umount` reports the target is busy, find what is holding it with
`sudo lsof +D /mnt/guest` and stop that before retrying. Disconnecting the
nbd device while a filesystem is still mounted loses writes.
8. Boot it and check:
```json
launch_vm(name="debian", disks=["~/vms/debian.qcow2"])
guest_ping(name="debian")
```
</Steps>
<Aside type="caution">
Never skip `qemu-nbd --disconnect`. A still-connected image is held open by the
kernel, so a later `launch_vm` on it either fails or, worse, gives you two
writers on one disk. `sudo nbd-client -c /dev/nbd0` or a quick `lsblk` tells
you whether the device is still attached.
</Aside>
## Making it a base image
Once `guest_ping` answers, that image is ready to be the base for disposable
clones. Shut the VM down cleanly with `stop_vm` so the filesystem is
consistent, then never launch the base directly again; build sandboxes on it
instead, as in [Disposable sandboxes](/tutorial/disposable-sandboxes/).
While you are in there, two things are worth doing to the image because every
future sandbox inherits them: install the packages you always want, and make
sure the SSH server is enabled if you plan to use the forwarded port that
`sandbox_vm` sets up.
## Related
- [Disposable sandboxes](/tutorial/disposable-sandboxes/) for using the
finished base image.
- [Snapshots](/how-to/snapshots/) for checkpointing an image before you modify
it.
- [Tool reference](/reference/tools/) for the `guest_*` tool parameters.

View File

@ -0,0 +1,132 @@
---
title: Take and restore snapshots
description: Live snapshots of running VMs capture RAM and restore instantly; offline image snapshots work on stopped disks. When to use each.
---
import { Steps, Aside } from '@astrojs/starlight/components';
There are two kinds of snapshot, and choosing between them comes down to one
question: is the VM running right now?
| | Live snapshot | Offline image snapshot |
|---|---|---|
| Tools | `vm_snapshot_create` / `_restore` / `_delete` / `_list` | `image_snapshot_create` / `_apply` / `_delete` / `_list` |
| Addressed by | VM name | disk image path |
| VM state | must be running | must be stopped |
| Captures | RAM, devices and disk | disk contents only |
| Restoring gives you | the exact moment you snapshotted, applications still open | the disk as it was, which then boots from cold |
| Requires | writable disks in qcow2 format | a qcow2 image |
Both store their data inside the qcow2 file itself, so snapshots travel with
the image when you copy it, and they cost disk space in the same file.
## Live snapshots of a running VM
Use these for checkpoints in the middle of work: before applying an update,
before a change you expect to have to undo, before letting something untrusted
run.
<Steps>
1. Create the checkpoint. The VM pauses briefly while its RAM is written into
the disk image, then continues.
```json
vm_snapshot_create(name="dev", tag="before-upgrade")
```
Tags use letters, digits, dots, underscores and hyphens, start with a letter
or digit, and are at most 64 characters.
2. Do the risky thing.
3. List what you have if you have lost track:
```json
vm_snapshot_list(name="dev")
```
4. Roll back. RAM, devices and disk all revert together, so the guest comes
back mid-sentence rather than booting.
```json
vm_snapshot_restore(name="dev", tag="before-upgrade")
```
5. Drop a checkpoint you no longer need. The VM keeps running; only the saved
state is removed.
```json
vm_snapshot_delete(name="dev", tag="before-upgrade")
```
</Steps>
<Aside type="caution">
Restoring throws away everything that happened after the snapshot, with no
second chance. If work since the checkpoint matters, copy it out first with
`guest_file_read` or through a forwarded port. A useful habit is to snapshot
again under a new tag before restoring an old one, which costs seconds and
keeps a way back.
</Aside>
How long a snapshot takes scales with how much RAM the VM has, because that RAM
is being written to disk. A 2 GB VM is a few seconds; a 32 GB VM is a coffee
break. Restoring is the same work in reverse and is still far quicker than a
boot plus getting an application back to the state it was in.
Live snapshots need every writable disk to be qcow2. If a VM has a raw disk
attached, `vm_snapshot_create` fails, and the fix is to convert the disk while
the VM is stopped:
```json
image_convert(source="~/vms/data.img", dest="~/vms/data.qcow2", format="qcow2")
```
## Offline snapshots of a stopped image
Use these when the VM is not running: marking a known-good state of a base
image, or checkpointing a disk before you modify it from the host (for example
before injecting the guest agent, see
[Install the guest agent](/how-to/install-the-guest-agent/)).
```json
image_snapshot_create(path="~/vms/base.qcow2", tag="clean-install")
image_snapshot_list(path="~/vms/base.qcow2")
image_snapshot_apply(path="~/vms/base.qcow2", tag="clean-install")
image_snapshot_delete(path="~/vms/base.qcow2", tag="clean-install")
```
These tools refuse to touch a disk that is attached to a running VM, and say
which VM is holding it. Stop that VM first. The refusal is deliberate: editing
a disk underneath a live guest corrupts it, and the guest would not notice
until much later.
Only disk contents are stored, so `image_snapshot_apply` gives you an image
that boots from cold. That is exactly what you want for a base image, and not
what you want mid-session, which is why the live tools exist.
## Choosing
**Working inside a VM and about to do something reversible.** Live snapshot.
The restore puts you back in the room you were standing in.
**Preparing a base image, or about to edit a disk from the host.** Offline
snapshot, with the VM stopped.
**You want a clean throwaway VM every time, not a checkpoint.** Neither. Use
`sandbox_vm` with an overlay, which is faster to create and cheaper to discard
than any snapshot; see [Disposable sandboxes](/tutorial/disposable-sandboxes/).
Snapshots are for going back inside one VM's history; overlays are for never
changing the original in the first place.
**You need a copy you can move to another machine or keep for months.**
Neither. `image_convert` writes a standalone image, flattening any backing
chain, which is easier to reason about than a snapshot living inside a file
that also holds other states.
## Related
- [Tool reference](/reference/tools/) for exact parameters.
- [Architecture](/explanation/architecture/) for how snapshot state is stored
in qcow2.

View File

@ -0,0 +1,52 @@
---
title: mcqemu
description: An MCP server for QEMU. Launch and drive virtual machines, run disposable sandboxes, take live snapshots, and reach inside guests, all as tools an agent can call.
template: splash
hero:
tagline: Give an agent a hypervisor. Launch VMs, watch their screens, type into them, snapshot them mid-flight, and throw them away.
actions:
- text: Start the tutorial
link: /tutorial/first-sandbox/
icon: right-arrow
variant: primary
- text: Tool reference
link: /reference/tools/
icon: open-book
variant: minimal
---
import { Card, CardGrid } from '@astrojs/starlight/components';
<CardGrid stagger>
<Card title="Disposable sandboxes" icon="rocket">
One call gives you a copy-on-write clone of a base image, booted, with the
guest agent answering. One more call destroys it. The base image is never
written to, and outbound networking is off unless you ask for it.
</Card>
<Card title="See and drive" icon="laptop">
Screenshots come from the framebuffer, so they work at a BIOS menu or a
bootloader with no guest cooperation. Keys are injected as scancodes, which
is why they work before an OS exists.
</Card>
<Card title="Live snapshots" icon="seti:db">
Save RAM, devices and disks at a moment in time, keep working, then roll the
whole machine back. Restore is instant because the memory came with it.
</Card>
<Card title="Inside the guest" icon="seti:shell">
With qemu-guest-agent installed, run commands and read or write files over
virtio-serial. No SSH, no network, no credentials.
</Card>
</CardGrid>
## Install
```bash
uvx mcqemu # run it
claude mcp add mcqemu -- uvx mcqemu # add it to Claude Code
```
Requires Linux with QEMU on the PATH. `/dev/kvm` access is optional; without it
guests run under software emulation, which works but is much slower.
New here? [What is mcqemu?](/overview/) explains the shape of it in a couple of
minutes, then the [tutorial](/tutorial/first-sandbox/) gets you to a running VM.

View File

@ -0,0 +1,59 @@
---
title: What is mcqemu?
description: "The shape of the project in two minutes: what it manages, what an agent can do with it, and what it deliberately does not try to be."
---
mcqemu is an MCP server that turns QEMU into tools an agent can call. It manages
virtual machines on the machine it runs on: creating disks, booting them,
watching their screens, typing into them, snapshotting them, running commands
inside them, and tearing them down.
QEMU already speaks a machine-friendly protocol. QMP is JSON over a unix socket,
and the guest agent speaks the same framing over a virtio-serial channel. Most of
mcqemu is a careful mapping from that surface onto tools with names and
descriptions written for the thing calling them.
## What you can do with it
Boot an operating system from an installer ISO and drive the installation by
looking at screenshots and sending keystrokes, without a display attached.
Keep a base image around with the guest agent installed, then spin up disposable
clones of it in seconds and destroy them when you are done. Freeze a running
machine, try something destructive, and roll back to the exact moment before,
memory included. Reach into a guest to run a command or drop in a file when there
is no network and no credentials.
## The pieces
A VM launched by mcqemu is a daemonized QEMU process with two unix sockets: one
for QMP, one for the guest agent. A small JSON registry records what exists.
Because the QEMU processes are daemonized, they outlive the MCP server, so
restarting your editor does not take down your machines. See
[architecture](/explanation/architecture/) for why that shape was chosen and what
it costs.
Disk images are managed through `qemu-img`. Sandboxes are copy-on-write overlays
on top of a base image, which is what makes them cheap to create and safe to
throw away.
## What it is not
It is not a cluster manager. There is no scheduler, no migration, no multi-host
awareness; mcqemu manages VMs on one machine and says so.
It is not a hardened security boundary. A sandbox is a real VM with a separate
kernel and no outbound network by default, which is a meaningful barrier against
software that misbehaves, but it is not a claim that a determined attacker with a
QEMU escape cannot get out. [Sandboxing](/explanation/sandboxing/) is explicit
about where the line sits.
It does not require anything of a guest in order to watch or drive it. Screens
and keystrokes work at the firmware level. Only the `guest_*` tools need
cooperation, in the form of `qemu-guest-agent` running inside.
## Where to go next
The [tutorial](/tutorial/first-sandbox/) starts from nothing and ends with a
working VM you can talk to. The [tool reference](/reference/tools/) lists every
tool with its parameters. If you would rather understand the machinery first,
start with [architecture](/explanation/architecture/).

View File

@ -0,0 +1,93 @@
---
title: Configuration and file layout
description: Environment variables, where state and sockets live, launch defaults, and how port forwarding and firmware are chosen.
sidebar:
order: 2
---
## Environment variables
| Variable | Default | What it does |
| --- | --- | --- |
| `MCQEMU_STATE_DIR` | `$XDG_DATA_HOME/mcqemu`, else `~/.local/share/mcqemu` | Registry, per-VM logs, sandbox overlays, UEFI variable stores |
| `MCQEMU_RUNTIME_DIR` | `$XDG_RUNTIME_DIR/mcqemu`, else `/tmp/mcqemu-$UID` | QMP sockets, guest-agent sockets, pidfiles |
| `MCQEMU_LOG_LEVEL` | `INFO` | Log level for the server's own stderr logging |
Both directory variables are useful for tests and for running more than one
independent instance. Sockets live under the runtime directory partly because
unix socket paths are limited to about 107 bytes, and a short path leaves room
for long VM names.
## What lives where
```
$MCQEMU_STATE_DIR/
vms.json registry (schema-versioned, atomically written)
vms.json.lock advisory lock held during read-modify-write
vms/<name>/
qemu.log QEMU's own log, written after daemonizing
serial.log the guest's serial console
overlay.qcow2 sandbox overlay disk (sandboxes only)
uefi-vars.fd per-VM UEFI variable store (firmware="uefi")
$MCQEMU_RUNTIME_DIR/<name>/
qmp.sock QMP control socket
qga.sock guest-agent channel
pid pidfile written by QEMU
```
The registry is rewritten under an exclusive lock, re-reading first, so a second
mcqemu instance sharing the same state directory merges rather than overwrites.
If the file is ever unreadable it is quarantined as `vms.corrupt.<epoch>.json`
and the server starts with an empty registry, reporting the damage in the
`registry_warnings` field of `list_vms`. Running VMs are unaffected by that; they
can be re-registered with `attach_vm` using the sockets above.
## Launch defaults
`launch_vm` aims for a sensible modern machine and lets you override any of it.
| Aspect | Default |
| --- | --- |
| Machine type | `q35` on x86, `virt` on aarch64 and riscv64, QEMU's own default elsewhere |
| Acceleration | KVM with `-cpu host` when the guest architecture matches the host and `/dev/kvm` is writable, otherwise TCG with `-cpu max` |
| Memory and CPUs | 2048 MB, 2 vCPUs |
| Disks | virtio, with the format probed by `qemu-img` rather than guessed |
| Display | none, with a display device still present so screenshots work |
| Serial | written to `serial.log` |
| Pointer | virtio tablet, so `vm_click` can target exact pixels |
| Guest agent | virtio-serial channel wired on every launch |
| Networking | user-mode with virtio-net; `restrict=on` when `restrict_net` is set |
The result of `launch_vm` reports which accelerator was used, so a VM that
quietly fell back to software emulation is visible rather than merely slow.
## Port forwarding
`port_forwards` accepts three spellings:
| Form | Meaning |
| --- | --- |
| `"2222:22"` | Host port 2222 to guest port 22. The host port is bind-tested first, so a collision fails immediately with a clear message. |
| `"auto:22"` | A free host port is chosen. The launch result reports which one. |
| `"22"` | Shorthand for `auto:22`. |
Duplicate host ports within one list are rejected rather than left for QEMU to
fail on later.
## Firmware
`firmware="bios"` is the default. `firmware="uefi"` uses the edk2 images
installed on the host: on x86_64 that is a read-only `OVMF_CODE` pflash plus a
writable per-VM copy of `OVMF_VARS`, so UEFI variables persist per VM without
touching the shared firmware. On aarch64 and riscv64 the firmware is supplied
with `-bios` instead.
## Escape hatch
`extra_args` appends raw flags to the QEMU command line. It exists for tuning
the tool surface does not cover, and it is for values you wrote yourself, never
for values derived from untrusted input. Flags that would breach the isolation a
VM is supposed to provide are rejected: host filesystem passthrough (`-fsdev`,
`-virtfs`), host block devices, spawning chardevs, `-runas`, and flags that would
collide with the sockets and pidfile mcqemu manages.

View File

@ -0,0 +1,448 @@
---
title: Tool reference
description: "Every mcqemu tool, its parameters and defaults, grouped by what it does."
sidebar:
order: 1
---
The full tool surface, generated from the running server so it matches the code
exactly. Descriptions are the text the calling model sees. Parameters marked
`required` have no default; everything else may be omitted.
## Sandboxes
One call to a working disposable VM, and one to remove every trace of it.
### `sandbox_vm`
Spin up a disposable sandbox VM from a base disk image, in one call: creates a copy-on-
write overlay (the base image is never modified), launches the VM, and waits for the
guest agent to come up so guest_exec / guest_file_* are immediately usable. By default a
free host port is forwarded to guest port 22 (pass port_forwards=[] to disable, or your
own list). If no `name` is given, sandbox / sandbox-2 / ... is chosen. Tear everything
down later with sandbox_destroy. Outbound networking is OFF by default: the guest cannot
reach the internet or any service on the host, which is what makes it a sandbox. Inbound
port forwards still work. Pass allow_network=True when the guest legitimately needs to
fetch packages. Note that the guest agent answers well before the guest finishes
booting, so this returns while services like networking are still starting. If a command
depends on one, wait for it inside the guest (e.g. poll 'systemctl is-active
NetworkManager') rather than assuming it is up.
| Parameter | Type | Default |
| --- | --- | --- |
| `base_image` | string | required |
| `name` | string | `null` |
| `memory_mb` | integer | `2048` |
| `cpus` | integer | `2` |
| `port_forwards` | array | `null` |
| `allow_network` | boolean | `false` |
| `wait_agent_s` | integer | `90` |
### `sandbox_destroy`
Destroy a sandbox created with sandbox_vm: force-stop the VM, remove it from the
registry, and delete its overlay disk and logs. The base image is untouched. Refuses to
operate on VMs that were not created by sandbox_vm — use stop_vm / forget_vm for those
(they never delete disks). If the VM cannot be killed, nothing is deleted and the call
fails.
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
## Lifecycle
Starting, stopping and tracking virtual machines.
### `launch_vm`
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` maps host ports to guest ports (user-mode networking): "2222:22" is
explicit, "auto:22" (or just "22") picks a free host port — the result reports what was
chosen. 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.
restrict_net=True drops all guest-initiated traffic (no internet, and no reaching
services on the host) while keeping port_forwards working inbound — use it when running
untrusted software. no_net=True removes the NIC entirely. extra_args passes raw flags to
qemu-system-* and is an operator escape hatch: only use values you wrote yourself, never
values derived from untrusted input. Flags that would breach VM isolation (host
filesystem passthrough, host block devices, spawning chardevs) are rejected.
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
| `arch` | string | `"x86_64"` |
| `disks` | array | `null` |
| `iso` | string | `null` |
| `memory_mb` | integer | `2048` |
| `cpus` | integer | `2` |
| `machine` | string | `null` |
| `firmware` | string | `"bios"` |
| `port_forwards` | array | `null` |
| `no_net` | boolean | `false` |
| `restrict_net` | boolean | `false` |
| `extra_args` | array | `null` |
### `stop_vm`
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).
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
| `force` | boolean | `false` |
| `timeout` | integer | `30` |
### `pause_vm`
Pause (freeze) a running VM's virtual CPUs. The VM stays in memory; resume it with
resume_vm.
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
### `resume_vm`
Resume a VM previously frozen with pause_vm.
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
### `attach_vm`
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.
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
| `qmp_socket` | string | required |
| `qga_socket` | string | `null` |
| `pid` | integer | `null` |
### `forget_vm`
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).
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
| `force` | boolean | `false` |
## Inspection
What exists and what state it is in.
### `list_vms`
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.
Returns \{"vms": [...], "registry_warnings": [...]\}. A non-empty registry_warnings
means bookkeeping was damaged and some VMs may be running but untracked — report it
rather than assuming the list is complete.
### `vm_info`
Detailed information about one VM: its launch configuration, log file paths, and — when
running — live QMP state (status, vCPUs, block devices).
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
## See and drive
Watching a VM's display and injecting input, with no guest software required.
### `vm_screenshot`
Capture the VM's current display as a PNG image. Works on any running VM with a display
device (the default for launched x86 VMs) — no guest software needed. Use this to watch
installers, read console output, or verify GUI state before sending keys with
vm_send_keys / vm_type_text.
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
### `vm_send_keys`
Press keys or key combos in the VM. Each list entry is one press: a single key ("ret",
"esc", "f2", "a") or a chord pressed together ("ctrl-alt-f2", "ctrl-c"). Entries are
sent in order with delay_ms between them. Aliases like "enter", "space", "escape" work.
To type prose, use vm_type_text instead.
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
| `keys` | array | required |
| `hold_ms` | integer | `100` |
| `delay_ms` | integer | `50` |
### `vm_type_text`
Type a string into the VM, character by character (US keyboard layout; printable ASCII
plus tab and newline). Set enter=True to press Enter at the end — handy for shell
commands at a console login or terminal.
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
| `text` | string | required |
| `enter` | boolean | `false` |
| `delay_ms` | integer | `30` |
### `vm_click`
Click at pixel coordinates (x, y) on the VM display — coordinates match what
vm_screenshot shows. Requires the VM's tablet device for absolute positioning (present
on VMs launched by this server). button: left, right, or middle.
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
| `x` | integer | required |
| `y` | integer | required |
| `button` | string | `"left"` |
| `double` | boolean | `false` |
### `vm_mouse_move`
Relative mouse control for guests WITHOUT absolute-pointer (tablet) drivers — most
pre-2010 OSes. Use this when vm_click has no visible effect. Motion goes to the emulated
PS/2 mouse in small steps (\<= `step` px per packet) because guests often desync or
apply acceleration on large deltas. Recommended pattern: pass home="bottom-right" (or
another corner) to pin the cursor to a known position first, then dx/dy toward the
target, then vm_screenshot to verify where the cursor actually landed (guest
acceleration may scale motion), correct with further small moves, and finally click.
`click` presses that button after moving; double=True double-clicks.
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
| `dx` | integer | `0` |
| `dy` | integer | `0` |
| `home` | string | `null` |
| `click` | string | `null` |
| `double` | boolean | `false` |
| `step` | integer | `32` |
### `vm_serial_read`
Read the last lines of the VM's serial console log. Only useful when the guest writes to
its serial port (kernel console=ttyS0, or text-mode installers); GUI-only guests log
nothing here — use vm_screenshot for those.
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
| `tail_lines` | integer | `50` |
## Live snapshots
Point-in-time state of a running VM, including RAM. Requires qcow2 disks.
### `vm_snapshot_create`
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.
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
| `tag` | string | required |
### `vm_snapshot_restore`
Roll a running VM back to the internal snapshot `tag` (RAM, devices and disks all
revert). Anything that happened after the snapshot is lost.
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
| `tag` | string | required |
### `vm_snapshot_delete`
Delete the internal snapshot `tag` from a running VM's disks. The VM keeps running; only
the saved snapshot is removed.
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
| `tag` | string | required |
### `vm_snapshot_list`
List internal snapshots visible to a running VM. For stopped VMs use image_snapshot_list
on the disk file instead.
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
## Disk images
qemu-img operations on images that are not currently attached to a running VM.
### `image_create`
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.
| Parameter | Type | Default |
| --- | --- | --- |
| `path` | string | required |
| `size` | string | required |
| `format` | string | `"qcow2"` |
| `backing_file` | string | `null` |
| `overwrite` | boolean | `false` |
### `image_info`
Inspect a disk image: format, virtual and on-disk size, internal snapshots, and (with
backing_chain=True) the full copy-on-write chain.
| Parameter | Type | Default |
| --- | --- | --- |
| `path` | string | required |
| `backing_chain` | boolean | `false` |
### `image_convert`
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).
| Parameter | Type | Default |
| --- | --- | --- |
| `source` | string | required |
| `dest` | string | required |
| `format` | string | `"qcow2"` |
| `compress` | boolean | `false` |
| `overwrite` | boolean | `false` |
### `image_resize`
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!).
| Parameter | Type | Default |
| --- | --- | --- |
| `path` | string | required |
| `size` | string | required |
| `shrink` | boolean | `false` |
### `image_snapshot_create`
Create an internal disk-only snapshot in an offline qcow2 image. For running VMs use
vm_snapshot_create instead (it also captures RAM).
| Parameter | Type | Default |
| --- | --- | --- |
| `path` | string | required |
| `tag` | string | required |
### `image_snapshot_apply`
Revert an offline qcow2 image to internal snapshot `tag`. Data written after the
snapshot is lost.
| Parameter | Type | Default |
| --- | --- | --- |
| `path` | string | required |
| `tag` | string | required |
### `image_snapshot_delete`
Delete internal snapshot `tag` from an offline qcow2 image.
| Parameter | Type | Default |
| --- | --- | --- |
| `path` | string | required |
| `tag` | string | required |
### `image_snapshot_list`
List internal snapshots stored in a (not currently running) qcow2 image.
| Parameter | Type | Default |
| --- | --- | --- |
| `path` | string | required |
## Guest agent
Reaching inside a guest. Needs qemu-guest-agent installed and running in the guest OS.
### `guest_ping`
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.
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
### `guest_info`
Report the guest OS details (name, version, kernel) and the guest agent's version and
supported commands.
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
### `guest_exec`
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.
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
| `command` | string | required |
| `args` | array | `null` |
| `stdin` | string | `null` |
| `timeout` | integer | `30` |
### `guest_file_read`
Read a text file from inside the guest (up to max_bytes, default 1 MiB, 8 MiB ceiling).
Requires qemu-guest-agent in the guest.
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
| `path` | string | required |
| `max_bytes` | integer | `1048576` |
### `guest_file_write`
Write a text file inside the guest (mode 'w' truncates, append=True appends). Requires
qemu-guest-agent in the guest.
| Parameter | Type | Default |
| --- | --- | --- |
| `name` | string | required |
| `path` | string | required |
| `content` | string | required |
| `append` | boolean | `false` |

View File

@ -0,0 +1,206 @@
---
title: Disposable sandboxes
description: Turn a base image into throwaway VMs that boot in seconds, run something in one, then delete it without touching the base.
sidebar:
order: 2
---
import { Steps, Aside } from '@astrojs/starlight/components';
In [Your first virtual machine](/tutorial/first-sandbox/) we spent twenty
minutes installing an operating system. In this tutorial we get a working VM
from that same image in a few seconds, run a command in it, deliberately make a
mess inside it, and throw it away. Then we do it again and watch the mess be
gone.
This is the loop worth internalising: build a base image once, then treat
individual VMs as cheap and disposable.
## What you need
The base image from the first tutorial, `~/vms/alpine-agent.qcow2`, with
`qemu-guest-agent` installed inside it, and the VM built on it shut down.
<Aside type="caution">
The base image must not be in use by a running VM. A sandbox reads from the
base while the guest writes into an overlay on top; if something else is
writing to the base at the same time, both disks are corrupted. Call `list_vms`
first and stop anything still running on that image.
</Aside>
## Create a sandbox
<Steps>
1. Ask the agent for a sandbox:
```json
sandbox_vm(base_image="~/vms/alpine-agent.qcow2")
```
One call does four things: it creates a copy-on-write overlay disk, launches
a VM on that overlay, forwards a free host port to guest port 22, and waits
for the guest agent to answer before returning.
2. Read the result closely, because it tells us what we got:
```json
{
"name": "sandbox",
"status": "running",
"sandbox": true,
"overlay": "~/.local/share/mcqemu/vms/sandbox/overlay.qcow2",
"base_image": "/home/you/vms/alpine-agent.qcow2",
"network": "outbound blocked",
"guest_agent": "responding",
"port_forwards": ["39221:22"]
}
```
We did not pass a name, so it picked `sandbox` (the next one would be
`sandbox-2`). `guest_agent: responding` means `guest_exec` will work right
now, with no waiting and no login.
3. Prove the guest is real:
```json
guest_exec(name="sandbox", command="uname", args=["-a"])
```
</Steps>
<Aside>
The guest agent answers well before the guest has finished booting, so
`sandbox_vm` can return while services like networking are still starting. If
a command depends on one, poll for it inside the guest (for example
`guest_exec(name="sandbox", command="/bin/sh", args=["-c", "rc-service networking status"])`)
rather than assuming it is up.
</Aside>
## The base image is never modified
The sandbox writes to an overlay file, and the overlay refers back to the base
for anything it has not changed. Let us see it.
<Steps>
1. Write a file inside the guest:
```json
guest_file_write(name="sandbox", path="/root/evidence.txt",
content="this sandbox was here\n")
```
2. Read it back to confirm it landed:
```json
guest_file_read(name="sandbox", path="/root/evidence.txt")
```
3. Now do some real damage, the kind you would never risk on a machine you
cared about:
```json
guest_exec(name="sandbox", command="/bin/sh", args=["-c", "rm -rf /etc/apk"])
```
4. Look at the relationship between the two disks:
```json
image_info(path="~/.local/share/mcqemu/vms/sandbox/overlay.qcow2", backing_chain=true)
```
The chain shows the overlay on top and `~/vms/alpine-agent.qcow2` below it
as the backing file. Check the base image's modification time on the host:
it has not changed. Every write the guest made went into the overlay.
</Steps>
## Outbound networking is off by default
Try to reach the internet from inside the sandbox:
```json
guest_exec(name="sandbox", command="/bin/sh", args=["-c", "apk update"])
```
It fails. That is the point of the default: traffic the guest starts is
dropped, so it cannot reach the internet or any service listening on your host.
Inbound port forwards still work, which is why the forwarded SSH port is
useful even with networking restricted.
When a sandbox legitimately needs to fetch packages, ask for it explicitly with
`allow_network=true`, and know that you have opened the door on purpose.
## Throw it away
<Steps>
1. Destroy the sandbox:
```json
sandbox_destroy(name="sandbox")
```
The result confirms `overlay_deleted: true` and names the base image it left
alone. The VM is force-stopped, removed from the registry, and its overlay
and logs are deleted.
2. Create a fresh one from the same base:
```json
sandbox_vm(base_image="~/vms/alpine-agent.qcow2")
```
3. Look for the damage:
```json
guest_file_read(name="sandbox", path="/root/evidence.txt")
```
The file does not exist, and `/etc/apk` is back. The new sandbox started
from the base image exactly as it was.
4. Clean up:
```json
sandbox_destroy(name="sandbox")
```
</Steps>
<Aside type="caution">
`sandbox_destroy` deletes the overlay disk, and with it everything that
happened inside that VM. There is no undo. If a sandbox produced something you
want to keep, copy it out first with `guest_file_read`, or through the
forwarded port, before destroying it.
It only ever touches VMs that `sandbox_vm` created. Ask it to destroy a VM you
launched with `launch_vm` and it refuses, because those disks are yours and
deleting them would be a surprise.
</Aside>
## What we learned
A base image plus an overlay gives us VMs that cost seconds instead of minutes,
because the expensive part (installing an operating system) already happened
and is shared by every sandbox built on it.
Overlays make throwing a VM away the normal outcome rather than a loss.
`sandbox_destroy` then `sandbox_vm` is a full reset back to a known state, and
the base image is safe from anything the guest does because the guest can only
write to the overlay.
Restricting outbound traffic by default is what makes it a sandbox rather than
just a fast VM. Software running inside it cannot phone anywhere or poke at
services on your host unless you decide otherwise.
## Next
- [Snapshots](/how-to/snapshots/) if you want checkpoints inside a
longer-lived VM instead of a clean slate every time.
- [Install the guest agent](/how-to/install-the-guest-agent/) to turn another
operating system into a base image, including injecting the agent into a
downloaded cloud image offline.
- [Sandboxing](/explanation/sandboxing/) for what these isolation boundaries do
and do not promise.

View File

@ -0,0 +1,283 @@
---
title: Your first virtual machine
description: Install mcqemu, create a disk, install an operating system from an ISO, and end with a VM your agent can run commands inside.
sidebar:
order: 1
---
import { Steps, Aside } from '@astrojs/starlight/components';
In this tutorial we install mcqemu, create a blank disk, install Alpine Linux
onto it from an installer ISO while watching the screen, and finish with a VM
whose guest agent answers, so the agent can run commands inside it.
We will drive everything through an LLM agent (the examples assume Claude Code).
Each step shows the tool call to ask for, and what the result should look like,
so you can tell at a glance whether the step worked.
Expect this to take around twenty minutes, most of which is the operating
system installer doing its own thing.
## What you need
A Linux host with QEMU installed (`qemu-system-x86_64` and `qemu-img` on your
PATH), [uv](https://docs.astral.sh/uv/), and about 2 GB of free disk space.
Access to `/dev/kvm` makes everything much faster, but the tutorial works
without it under software emulation.
Download the Alpine "virt" ISO before starting, from
[alpinelinux.org/downloads](https://alpinelinux.org/downloads/). It is around
60 MB. We will assume it is at `~/isos/alpine-virt.iso`, and we will keep our
VM disk at `~/vms/alpine-agent.qcow2`, so create those directories now:
```bash
mkdir -p ~/isos ~/vms
```
## Install and connect the server
<Steps>
1. Check that the server starts. `uvx` fetches mcqemu and runs it; the version
banner appears on stderr and then it waits for a client, which is what an
MCP server is supposed to do.
```bash
uvx mcqemu
```
Press Ctrl-C to stop it. Seeing `mcqemu v...` is all we needed.
2. Register it with Claude Code:
```bash
claude mcp add mcqemu -- uvx mcqemu
```
3. Start Claude Code in any directory and run `/mcp`. The list should include
`mcqemu` as connected. If it does not, [Install and connect](/how-to/install-and-connect/)
covers the usual causes.
4. Ask the agent to call `list_vms`. On a fresh install it answers with an
empty list:
```json
{"vms": [], "registry_warnings": []}
```
An empty `registry_warnings` means the bookkeeping is healthy. We now have a
working server with nothing to manage yet.
</Steps>
## Create a disk and start the installer
<Steps>
1. Ask the agent to create the disk:
```json
image_create(path="~/vms/alpine-agent.qcow2", size="8G")
```
The result reports `format: qcow2` and a virtual size of 8 GB. Check the
file on the host and you will see it occupies only a couple of hundred
kilobytes. qcow2 images grow as the guest writes, so a generous virtual size
costs almost nothing up front.
2. Launch the VM with the installer ISO attached:
```json
launch_vm(name="alpine", disks=["~/vms/alpine-agent.qcow2"],
iso="~/isos/alpine-virt.iso", memory_mb=2048)
```
The result tells us the VM is running, which acceleration it got (`kvm` or
`tcg`), the process ID, and paths to its logs. When a VM has both a disk and
an ISO, it boots the ISO this once and the disk on every later boot, so we
will not have to detach anything by hand.
3. Give it fifteen seconds, then look at the screen:
```json
vm_screenshot(name="alpine")
```
The agent gets a PNG of the guest display back. You should see Alpine's
boot messages or a `localhost login:` prompt. Nothing is installed inside
the guest to make this work; QEMU is handing over the framebuffer directly,
which is why screenshots work in bootloaders and BIOS menus too.
If the screen still shows a bootloader, wait and take another screenshot.
Watching, rather than assuming, is the habit this whole workflow is built
on.
</Steps>
## Install Alpine onto the disk
The installer is an interactive console session, so we type into it the same
way a person at a keyboard would.
<Steps>
1. Log in as root (Alpine's live image has no root password):
```json
vm_type_text(name="alpine", text="root", enter=true)
```
Take a screenshot to confirm we landed at a shell prompt before continuing.
2. Start the installer:
```json
vm_type_text(name="alpine", text="setup-alpine", enter=true)
```
3. Work through the questions with `vm_type_text`, taking a screenshot after
each answer to see what is being asked next. Most answers can be the
default (press Enter with `vm_send_keys(name="alpine", keys=["ret"])`).
The three that matter:
- Set a root password when asked, and remember it. We need it to log in later.
- When asked which disk to use, answer `vda`. Virtio disks appear under that
name inside the guest.
- When asked how to use it, answer `sys`. That installs to the disk rather
than running from RAM.
Alpine asks for a final `y` to erase the disk. Nothing else on your machine
is at risk; the only disk the VM can see is the image file we created.
4. When the installer reports it is done, shut the guest down cleanly:
```json
stop_vm(name="alpine")
```
`stop_vm` presses the virtual power button and waits for the guest to
shut itself down, so filesystems get flushed properly.
</Steps>
<Aside type="caution">
If a guest ignores the power button (no operating system booted yet, or one
without ACPI support), `stop_vm` fails and suggests `force=true`. That is the
equivalent of pulling the power cord out of the wall: instant, and it can leave
a half-written filesystem behind. Reach for it when a guest is genuinely stuck,
not as a default.
</Aside>
## Boot from disk
<Steps>
1. Launch again, this time with no ISO, and forward a host port to the guest's
SSH port so we have a way in later:
```json
launch_vm(name="alpine", disks=["~/vms/alpine-agent.qcow2"],
port_forwards=["auto:22"])
```
`auto:22` means "pick any free host port"; the result reports which one it
chose, for example `port_forwards: ["43617:22"]`. You can also ask for a
specific one with `"2222:22"`, and mcqemu checks up front that the port is
free instead of letting QEMU fail obscurely.
2. Screenshot after a few seconds. This time the login prompt comes from the
installed system on the disk, not from the ISO.
3. Log in with `root` and the password you set:
```json
vm_type_text(name="alpine", text="root", enter=true)
vm_type_text(name="alpine", text="<your password>", enter=true)
```
</Steps>
## Install the guest agent
Everything so far worked through the screen and keyboard, with no cooperation
from the guest operating system. The `guest_*` tools are different: they need
`qemu-guest-agent` running inside the guest. mcqemu wires up the host side of
that channel on every launch, so installing the package is the only step left.
<Steps>
1. Confirm the agent is genuinely missing, so the difference is visible:
```json
guest_ping(name="alpine")
```
This fails, and the error says the guest does not have the agent installed
or running.
2. Type these three commands into the guest console, one at a time, with
`vm_type_text(..., enter=true)`:
```text
apk add qemu-guest-agent
rc-update add qemu-guest-agent default
rc-service qemu-guest-agent start
```
The VM has outbound network access (we did not restrict it), so `apk` can
reach Alpine's mirrors. Take a screenshot after the first command to check
the download succeeded; if `apk` cannot find the package, run
`setup-apkrepos -c -1` to enable the community repository and try again.
3. Ask again:
```json
guest_ping(name="alpine")
```
Now it answers `guest_agent: responding`.
4. Run a command inside the guest without touching the keyboard:
```json
guest_exec(name="alpine", command="uname", args=["-a"])
```
The result carries the guest's stdout, stderr, and exit code. Note that
`command` is an executable and `args` are its arguments; this is exec, not a
shell. For pipelines, use `command="/bin/sh"` with
`args=["-c", "your | pipeline"]`.
5. Shut the VM down. We want the image quiescent for the next tutorial.
```json
stop_vm(name="alpine")
```
</Steps>
## What we built
`~/vms/alpine-agent.qcow2` is now an installed Alpine system with a working
guest agent. Along the way we saw the two ways to interact with a VM:
- Through the display and keyboard (`vm_screenshot`, `vm_type_text`,
`vm_send_keys`), which works on any guest at any stage of boot, including
installers and bootloaders, because it operates below the operating system.
- Through the guest agent (`guest_ping`, `guest_exec`), which is faster and
gives you structured output, but only after you have put the agent inside
the guest.
We also saw that disks and VMs are separate things: `image_create` makes a
disk, `launch_vm` runs a VM around it, and the same disk can be booted again
later with different settings.
## Next
Keep that image. In [Disposable sandboxes](/tutorial/disposable-sandboxes/) we
use it as a base to spin up throwaway clones in seconds, run untrusted things
in them, and delete them without the base ever changing.
If you want to install something bigger than Alpine next, read
[Drive an installer](/how-to/drive-an-installer/) for the screenshot and input
loop in detail, including mouse control for graphical installers.

View File

@ -0,0 +1,74 @@
/* mcqemu a restrained terminal palette.
*
* The accent is the amber of a monitor that has been on too long, against
* slate greys. Starlight derives most of its surface colours from these
* tokens, so overriding the scale is enough for both themes.
*/
:root {
--sl-color-accent-low: #3a2a08;
--sl-color-accent: #b5820f;
--sl-color-accent-high: #f0c674;
--sl-color-white: #f4f4f2;
--sl-color-gray-1: #e3e4e1;
--sl-color-gray-2: #c0c2be;
--sl-color-gray-3: #8b8e88;
--sl-color-gray-4: #55584f;
--sl-color-gray-5: #34362f;
--sl-color-gray-6: #24261f;
--sl-color-black: #16170f;
--sl-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--sl-font-mono: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, monospace;
}
:root[data-theme="light"] {
--sl-color-accent-low: #f5e6c0;
--sl-color-accent: #8a610a;
--sl-color-accent-high: #4a3405;
--sl-color-white: #1b1c16;
--sl-color-gray-1: #24261f;
--sl-color-gray-2: #3f423a;
--sl-color-gray-3: #6b6e66;
--sl-color-gray-4: #9a9d94;
--sl-color-gray-5: #d3d5cf;
--sl-color-gray-6: #eceee8;
--sl-color-gray-7: #f6f7f3;
--sl-color-black: #ffffff;
}
/* Starlight's staggered CardGrid rotates card icon backgrounds through a
* built-in palette that includes purple and magenta. Hold the whole grid on
* the accent so the page reads as one thing. */
.card .icon {
background-color: var(--sl-color-accent-low) !important;
color: var(--sl-color-accent-high) !important;
}
/* Tool names appear constantly in the prose; give inline code enough
* contrast to scan without turning every paragraph into a rash. */
:not(pre) > code {
border: 1px solid var(--sl-color-gray-5);
border-radius: 0.25rem;
padding: 0.1rem 0.35rem;
font-size: 0.9em;
}
/* Reference tables are dense and mostly two or three narrow columns. */
.sl-markdown-content table {
display: table;
width: 100%;
font-size: 0.95em;
}
.sl-markdown-content th {
text-align: left;
font-weight: 600;
}
/* The splash hero reads better slightly narrower than the default. */
.hero > .stack {
max-width: 46rem;
}

5
docs-site/tsconfig.json Normal file
View File

@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}

View File

@ -27,7 +27,8 @@ dependencies = [
] ]
[project.urls] [project.urls]
Repository = "https://git.supported.systems/MCP/mcqemu" Documentation = "https://mcqemu.warehack.ing"
Repository = "https://git.supported.systems/warehack.ing/mcqemu"
[project.scripts] [project.scripts]
mcqemu = "mcqemu.server:main" mcqemu = "mcqemu.server:main"