Skip to main content
Glama
README.md
# libvirt-mcp

An [MCP](https://modelcontextprotocol.io) server for managing **libvirt/KVM virtual
machines** through the official Python bindings: start/stop VMs, snapshot and restore
them, clone and delete them, provision new VMs from cloud images with
`virt-customize`, run commands inside guests, and share host directories via virtiofs.

## Tools

| Area | Tools |
| --- | --- |
| Inventory | `get_host_info`, `list_vms`, `get_vm_info`, `list_images`, `list_recommended_images`, `download_image` |
| Lifecycle | `start_vm`, `stop_vm`, `reboot_vm`, `pause_vm`, `resume_vm`, `set_vm_autostart` |
| Snapshots | `list_snapshots`, `create_snapshot`, `restore_snapshot`, `delete_snapshot` |
| Create / clone / delete | `create_vm`, `clone_vm`, `delete_vm`, `customize_image` |
| In-guest commands | `exec_in_vm` (qemu guest agent, SSH fallback via `qemu:system/<vm>` proxy then IP, `force_ssh`) |
| File shares | `list_fileshares`, `add_fileshare`, `remove_fileshare` (virtiofs) |

Destructive operations (`delete_vm`, forced stop/reboot, `restore_snapshot`,
`delete_snapshot`) refuse to run without `confirm=true`.

## Prerequisites

Fedora/RHEL:

```bash
sudo dnf install libvirt qemu-kvm virtiofsd python3-libvirt libguestfs-tools-c qemu-img
sudo usermod -aG libvirt,kvm "$USER"   # then re-login
```

The libvirt bindings come from the distro (`python3-libvirt`) — they are ABI-coupled
to the system libvirt. The dedicated SSH key **must exist** (injected into created
VMs, used by the SSH fallback); creation fails fatally without it:

```bash
ssh-keygen -t ed25519 -N '' -f ~/.ssh/vms
```

## Installation

```bash
cd libvirt-mcp
uv venv --system-site-packages --python "$(command -v python3)"
uv sync
uv run python -c "import libvirt, libvirt_qemu, libvirt_mcp; print('ok')"
```

## MCP client configuration

**OpenCode** — the project's `opencode.json` is ready; for another project use:

```json
{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "servers": {
      "libvirt": {
        "type": "local",
        "command": ["uv", "run", "libvirt-mcp"],
        "cwd": "/path/to/libvirt-mcp"
      }
    }
  }
}
```

## Configuration

| Variable | Default | Meaning |
| --- | --- | --- |
| `LIBVIRT_MCP_URI` | `qemu:///system` | libvirt connection URI |
| `LIBVIRT_MCP_SSH_KEY` | `~/.ssh/vms` | Dedicated SSH key (must exist) |
| `LIBVIRT_MCP_IMAGES_DIR` | `~/VirtualMachines` | Where VM disks live / are created |
| `LIBVIRT_MCP_SSH_PORT` | `22` | SSH fallback port |
| `LIBVIRT_MCP_CUSTOMIZE_TIMEOUT` | `1800` | virt-customize timeout (s) |
| `LIBVIRT_MCP_EXEC_TIMEOUT` | `120` | in-guest command timeout (s) |
| `LIBVIRT_MCP_LOG_LEVEL` | `INFO` | stderr log level |

`opencode.json` sets `LIBVIRT_MCP_URI` and `LIBVIRT_MCP_SSH_KEY` explicitly —
edit them there (OpenCode restarts the server on save); `~` is expanded. With a
remote URI (`qemu+ssh://…`) the pure-libvirt tools work, but the disk-image
tooling (`create_vm`, `clone_vm`, `customize_image`, `delete_vm`) needs the
images on a filesystem this server can see.

## Typical usage

```
download_image(image_id="debian-13")
create_vm(name="build", base_image="debian-13-generic-amd64.qcow2",
          vcpus=4, memory_mb=4096, disk_gb=20, packages=["nginx"], update=true)
exec_in_vm(name="build", command="uname -a", ssh_user="root")
create_snapshot(name="build", snapshot_name="clean")
restore_snapshot(name="build", snapshot_name="clean", confirm=true)
stop_vm(name="build", wait=true)
add_fileshare(name="build", host_path="~/src")
exec_in_vm(name="build", command="mkdir -p /mnt/src && mount -t virtiofs src /mnt/src",
           ssh_user="root", force_ssh=true)
```

## Notes

- **`download_image`** always writes inside the images directory
  (`LIBVIRT_MCP_IMAGES_DIR`); the `filename` argument is stripped of any path
  parts, so it cannot escape that directory.
- **`create_vm`** copies the base image, runs `virt-customize` on the copy
  (injects the SSH key for root, sets the root password via a `0600` temp file so
  it never hits the process list, installs packages), defines and starts the
  domain, and cleans up after itself on failure. `qemu-guest-agent` is
  installed/enabled when missing, and `guest-exec` is added to the RPC allowlist
  that RHEL/Rocky 10 use to harden the agent. UEFI-only images (e.g. Fedora UKI,
  RHEL 10) need `firmware="efi"`. **The base must be a pristine cloud image**:
  a disk that already belongs to a VM (`used_by` in `list_images`) carries that
  guest's state — e.g. an fstab entry for a virtiofs share that new VMs don't
  have — and fresh VMs from it hang in an emergency shell with no agent, network
  or sshd.
- **`exec_in_vm`** prefers the guest agent and falls back to SSH. The SSH
  username is a **required** `ssh_user` argument — there is no default, and an
  error is raised if the user cannot connect (auth refused, unknown user,
  unreachable). `create_vm` injects the key for `root`, so `ssh_user="root"`
  works out of the box; for other users, inject their key yourself (e.g. with
  `run_commands` in `create_vm`). Note: the agent path always runs commands as
  root in the guest — the agent protocol has no user concept; use
  `force_ssh=true` with a non-root `ssh_user` to avoid that. On
  SELinux-hardened guests (RHEL/Rocky 10+) agent commands also run in a
  confined context — `force_ssh=true` for privileged operations like mounting.
- **virtiofs shares** rewrite the domain XML (shared memfd memory + filesystem
  device); the VM must be shut off when adding/removing, and snapshots do not
  cover the shared directory.
- **Snapshots**: full disk+memory by default; `disk_only=true` for external
  snapshots. Quiescing applies to `disk_only` only.

## Troubleshooting

- **`libvirt python bindings were not found`** — install `python3-libvirt` and
  create the venv with `--system-site-packages`.
- **`virt-customize` slow/failing** — needs `/dev/kvm` (`kvm` group) and network
  access for `--install`/`--update`.
- **VM with a virtiofs share fails to start** — install `virtiofsd`.
- **UEFI image won't boot** — pass `firmware="efi"` to `create_vm`.
- **Logs** — stderr; set `LIBVIRT_MCP_LOG_LEVEL=DEBUG` for details.

## Development

```bash
uv run python scripts/e2e_demo.py # full workflow against the real hypervisor
```