# Stack Research: vmcluster-mcp

**Project:** vmcluster-mcp
**Researched:** 2026-03-22
**Sources:** PyPI (live), official docs

---

## Recommended Stack

### MCP Server Framework

| Library | Version | Purpose | Why |
|---------|---------|---------|-----|
| `mcp[cli]` | `>=1.26.0` | MCP server + stdio transport | Official Anthropic SDK; FastMCP high-level API generates tool schemas directly from Python type annotations; `mcp.server.stdio.stdio_server()` handles message framing. The `[cli]` extra adds `mcp dev` inspector for local debugging. |

**Confidence: HIGH** — verified from PyPI 2026-03-22; `mcp` 1.26.0 released Jan 2026.

**Key design decision:** Use `FastMCP` (not raw low-level `Server`). Tool functions that return Pydantic `BaseModel` subclasses automatically produce `structuredContent` in the MCP response — this is the mechanism that gives agents typed, parseable outputs rather than free-text. See `mcp.server.fastmcp.FastMCP`.

**Transport:** `stdio` only. This server runs locally on the libvirt host as a subprocess of the agent. Stdio has zero latency overhead, no port management, and no network authentication surface. Do not expose HTTP/SSE transport for v1.

```python
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("vmcluster-mcp")

@mcp.tool()
async def cluster_start(cluster_name: str) -> ClusterStatus:
    """Boot all nodes in the named cluster."""
    ...

if __name__ == "__main__":
    mcp.run()  # defaults to stdio transport
```

---

### VM Management

| Library | Version | Purpose | Why |
|---------|---------|---------|-----|
| `libvirt-python` | `>=12.1.0` | Domain lifecycle, network, snapshots | Official Python bindings to the libvirt C API (wraps `libvirt.so`). Provides `virConnect`, `virDomain`, `virNetwork`, `virDomainSnapshot` objects. External snapshot creation via `virDomain.snapshotCreateXML()` with `VIR_DOMAIN_SNAPSHOT_CREATE_DISK_ONLY` flag. Version tracks libvirt itself — 12.1.0 released Mar 2026. |

**Confidence: HIGH** — verified from PyPI 2026-03-22; `libvirt-python` 12.1.0 released Mar 2026.

**Critical system prerequisite:** `libvirt-python` is NOT pure Python. It compiles against the system libvirt C library. Install system dependencies before `uv sync`:

```bash
# Ubuntu/Debian
sudo apt-get install libvirt-dev python3-dev
```

**External snapshots API pattern:**

```python
import libvirt

conn = libvirt.open("qemu:///system")
dom = conn.lookupByName("my-node")

# External disk-only snapshot (works on running VMs):
snapshot_xml = """
<domainsnapshot>
  <name>post-install</name>
  <disks>
    <disk name='vda' snapshot='external'>
      <source file='/var/lib/vmcluster/snaps/my-node.post-install.qcow2'/>
    </disk>
  </disks>
</domainsnapshot>
"""
flags = libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_DISK_ONLY | \
        libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_ATOMIC
snap = dom.snapshotCreateXML(snapshot_xml, flags)
```

**Note on snapshot revert:** libvirt does not have a single "revert external snapshot" command equivalent to internal snapshots. Revert requires: (1) blockcommit or blockpull to flatten qcow2 chain, or (2) destroy + re-base overlay. The design's `snapshot_revert` will need to implement this explicitly. Plan for this complexity in the snapshot phase.

---

### Data Validation

| Library | Version | Purpose | Why |
|---------|---------|---------|-----|
| `pydantic` | `>=2.12.5` | Tool input/output models, topology schema validation, artifact registry | v2 is a ground-up rewrite with Rust-backed validation core (pydantic-core). FastMCP uses Pydantic v2 for generating JSON schemas from tool signatures. All `BaseModel` subclasses defined in the design doc (`ClusterHandle`, `ClusterStatus`, `NodeStatus`, `ExecResult`, `BuildArtifact`, etc.) are Pydantic v2 models. |

**Confidence: HIGH** — verified from PyPI 2026-03-22; `pydantic` 2.12.5 released Nov 2025. FastMCP 1.26.0 depends on Pydantic v2.

**Pattern:** Return Pydantic models directly from `@mcp.tool()` functions. FastMCP serializes them to `structuredContent` automatically:

```python
from pydantic import BaseModel
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("vmcluster-mcp")

class ExecResult(BaseModel):
    exit_code: int
    stdout: str
    stderr: str
    duration_s: float
    timed_out: bool

@mcp.tool()
async def node_exec(cluster_name: str, node: str, command: str, timeout_s: int = 30) -> ExecResult:
    ...
```

---

### SSH / Remote Execution

| Library | Version | Purpose | Why |
|---------|---------|---------|-----|
| `asyncssh` | `>=2.22.0` | SSH command execution on VM nodes, parallel `node_exec_all` | Pure asyncio implementation — no threads needed. `await conn.run(cmd)` and `asyncio.gather(*[conn.run(cmd) for conn in conns])` make parallel node execution trivial. Paramiko would require `loop.run_in_executor(executor, ...)` wrapping for every call, adding complexity and thread overhead. asyncssh supports ed25519 keys natively. |

**Confidence: HIGH** — verified from PyPI 2026-03-22; `asyncssh` 2.22.0 released Dec 2025.

**asyncssh requires:** `cryptography>=39.0` (handled automatically by uv).

**Pattern for parallel node execution:**

```python
import asyncssh
import asyncio

async def exec_on_nodes(nodes: list[NodeDescriptor], command: str, timeout_s: int) -> dict[str, ExecResult]:
    async def run_one(node: NodeDescriptor) -> tuple[str, ExecResult]:
        async with asyncssh.connect(
            node.ip,
            port=node.ssh_port,
            username=node.ssh_user,
            client_keys=[node.ssh_key_path],
            known_hosts=None,          # cluster IPs are ephemeral, no host verification
        ) as conn:
            result = await asyncio.wait_for(
                conn.run(command, check=False),
                timeout=timeout_s,
            )
        return node.name, ExecResult(
            exit_code=result.exit_status,
            stdout=result.stdout or "",
            stderr=result.stderr or "",
            duration_s=0.0,  # track with time.monotonic()
            timed_out=False,
        )
    results = await asyncio.gather(*[run_one(n) for n in nodes], return_exceptions=False)
    return dict(results)
```

---

### Topology / Config Parsing

| Library | Version | Purpose | Why |
|---------|---------|---------|-----|
| `PyYAML` | `>=6.0.3` | Parse YAML topology files | Standard, battle-tested. The design doc specifies YAML topology files. Always use `yaml.safe_load()` — never `yaml.load()` (arbitrary code execution risk). |

**Confidence: HIGH** — verified from PyPI 2026-03-22; `PyYAML` 6.0.3 released Sep 2025.

**Security note:** `yaml.safe_load()` is mandatory. `yaml.load()` without `Loader=yaml.SafeLoader` is a code injection vector. Topology files come from the local host filesystem, but defense-in-depth still applies.

```python
import yaml

def load_topology(path: str) -> dict:
    with open(path) as f:
        return yaml.safe_load(f)
```

---

### Build Tooling

| Tool | Version | Purpose | Why |
|------|---------|---------|-----|
| `uv` | `>=0.10.12` | Project manager, venv, lockfile, run | Explicitly recommended by the MCP Python SDK docs. 10-100x faster than pip. Single tool replaces pip + pip-tools + virtualenv + pyenv. Universal lockfile (`uv.lock`). `uv run` executes scripts in the managed venv without manual activation. |
| `pyproject.toml` | PEP 518/621 | Project metadata + dependency declaration | Standard. `uv init` generates this. Single source of truth for deps, build config, and tool config (ruff, pytest). |
| `ruff` | `>=0.15.7` | Linting + formatting | Replaces flake8 + black + isort. 100x faster. First-party VS Code extension. Used by Anthropic's own Python SDK. |

**Confidence: HIGH** — all verified from PyPI 2026-03-22.

---

### Testing

| Library | Version | Purpose | Why |
|---------|---------|---------|-----|
| `pytest` | `>=9.0.2` | Test runner | Industry standard. Python 3.10+ required. |
| `pytest-asyncio` | `>=1.3.0` | `async def test_*` support | MCP tools and SSH execution are async; test functions must be async. Use `asyncio_mode = "auto"` in pyproject.toml to avoid per-test `@pytest.mark.asyncio`. |
| `pytest-mock` | `>=3.15.1` | `mocker` fixture, mock patching | `mocker.patch("libvirt.open")` returns a MagicMock; subclass `virConnect` and `virDomain` for realistic libvirt fakes without requiring a live QEMU host. |

**Confidence: HIGH** — all verified from PyPI 2026-03-22.

**libvirt mock pattern:** There is no published `libvirt-mock` package. Use `unittest.mock.MagicMock` or `pytest-mock`'s `mocker.patch` to replace `libvirt.open`. For integration tests that need a real hypervisor, drive a `qemu:///session` connection (user-mode QEMU, no root required).

```python
# Unit test example
def test_cluster_start(mocker):
    mock_conn = mocker.MagicMock()
    mock_dom = mocker.MagicMock()
    mock_dom.state.return_value = [libvirt.VIR_DOMAIN_RUNNING, 0]
    mock_conn.lookupByName.return_value = mock_dom
    mocker.patch("libvirt.open", return_value=mock_conn)

    # ... call cluster_start logic ...
    mock_dom.create.assert_called_once()
```

---

## What NOT to Use

| Choice | Avoid Because |
|--------|---------------|
| **paramiko** | Sync API; requires `loop.run_in_executor()` wrapping for every SSH call in async context. asyncssh is a drop-in async replacement with identical SSH functionality. |
| **Fabric** | High-level wrapper on paramiko; still sync underneath. Adds abstraction without solving the async problem. |
| **subprocess + virsh CLI** | `virsh` commands require parsing free-text output; error handling is fragile; libvirt API errors are strongly typed. Using the C API via libvirt-python is the correct approach. |
| **Pydantic v1** | Still available as `pydantic.v1` shim but has no active development. FastMCP 1.x requires Pydantic v2. Never mix v1 and v2 models in the same server. |
| **SSE or HTTP MCP transport** | This server runs as a local process on the hypervisor host, invoked by an agent. Stdio has zero overhead and no network attack surface. HTTP/SSE transport is for multi-tenant remote servers. |
| **SQLite / Redis for state** | The design spec is explicit: "No critical runtime state in server process." Libvirt IS the state store for domains/networks/snapshots. Use a flat JSON file for the artifact registry (written atomically). |
| **asyncio.ThreadPoolExecutor for SSH** | The workaround for using paramiko in async code. Unnecessary if using asyncssh. |
| **yaml.load() (unsafe)** | `yaml.load()` without `Loader=yaml.SafeLoader` executes arbitrary Python from the YAML file. Always `yaml.safe_load()`. |
| **ruamel.yaml** | More complex API than needed; PyYAML is sufficient for read-only topology parsing. Use ruamel.yaml only if round-trip editing of YAML files with comment preservation is required (it isn't). |
| **httpx / requests for artifact transfer** | Artifacts are local files transferred via SSH/rsync. No HTTP layer needed. Use asyncssh's built-in SCP/SFTP or invoke `rsync` via `node_exec`. |

---

## Dependencies File (pyproject.toml)

```toml
[project]
name = "vmcluster-mcp"
version = "0.1.0"
description = "MCP server for libvirt/QEMU multi-VM cluster orchestration"
requires-python = ">=3.12"
dependencies = [
    "mcp[cli]>=1.26.0",
    "libvirt-python>=12.1.0",
    "pydantic>=2.12.5",
    "asyncssh>=2.22.0",
    "PyYAML>=6.0.3",
]

[project.scripts]
vmcluster-mcp = "vmcluster_mcp.server:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.uv]
dev-dependencies = [
    "pytest>=9.0.2",
    "pytest-asyncio>=1.3.0",
    "pytest-mock>=3.15.1",
    "ruff>=0.15.7",
]

[tool.pytest.ini_options]
asyncio_mode = "auto"

[tool.ruff]
target-version = "py312"
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
```

**System-level prerequisites (not in pyproject.toml):**
```bash
# Ubuntu/Debian — must install before uv sync
sudo apt-get install libvirt-dev python3-dev
```

---

## Sources

- PyPI `mcp` 1.26.0: https://pypi.org/project/mcp/ (verified 2026-03-22)
- PyPI `libvirt-python` 12.1.0: https://pypi.org/project/libvirt-python/ (verified 2026-03-22)
- PyPI `asyncssh` 2.22.0: https://pypi.org/project/asyncssh/ (verified 2026-03-22)
- PyPI `pydantic` 2.12.5: https://pypi.org/project/pydantic/ (verified 2026-03-22)
- PyPI `pytest` 9.0.2: https://pypi.org/project/pytest/ (verified 2026-03-22)
- PyPI `pytest-asyncio` 1.3.0: https://pypi.org/project/pytest-asyncio/ (verified 2026-03-22)
- PyPI `pytest-mock` 3.15.1: https://pypi.org/project/pytest-mock/ (verified 2026-03-22)
- PyPI `uv` 0.10.12: https://pypi.org/project/uv/ (verified 2026-03-22)
- PyPI `PyYAML` 6.0.3: https://pypi.org/project/pyyaml/ (verified 2026-03-22)
- PyPI `ruff` 0.15.7: https://pypi.org/project/ruff/ (verified 2026-03-22)
- libvirt external snapshot API: https://libvirt.org/formatsnapshot.html
- MCP Python SDK docs: https://modelcontextprotocol.io/
