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

An MCP server that manages VMware vSphere/ESXi across **multiple named
environments** (vCenters and standalone hosts) with a real guardrail layer:
declarative policy, two-phase confirmation for destructive operations, an
append-only audit trail, and optional human-in-the-loop approval via a small
web dashboard.

Read-only introspection is cheap and always on. Destructive capability is
opt-in (`ENABLE_DESTRUCTIVE=true`), policy-gated per target, and never
executes on the first call.

## Quick start

```bash
python3 -m venv .venv && .venv/bin/pip install -e .
# optional web UI extras:
.venv/bin/pip install -e ".[web]"

cp targets.yaml.example targets.yaml   # declare your environments
cp policy.yaml.example policy.yaml     # declare what is allowed where

# credentials per target, matching each credential_env_prefix in targets.yaml
export VC_PROD1_USER='administrator@vsphere.local'
export VC_PROD1_PASSWORD='...'
export VC_DEV1_USER='...' VC_DEV1_PASSWORD='...'

.venv/bin/vmware-mcp        # stdio MCP server
```

Claude Desktop / MCP client config:

```json
{
  "mcpServers": {
    "vmware": {
      "command": "/path/to/.venv/bin/vmware-mcp",
      "env": {
        "TARGETS_FILE": "/path/to/targets.yaml",
        "POLICY_FILE": "/path/to/policy.yaml",
        "AUDIT_LOG_PATH": "/path/to/audit.jsonl",
        "ENABLE_DESTRUCTIVE": "true",
        "VC_PROD1_USER": "administrator@vsphere.local",
        "VC_PROD1_PASSWORD": "..."
      }
    }
  }
}
```

## Configuration

### Environment variables

| Variable | Default | Meaning |
|---|---|---|
| `ENABLE_DESTRUCTIVE` | `false` | Register destructive tools at all. Off = they don't exist on the tool surface. |
| `DRY_RUN` | `false` | Full policy + confirmation flow, but stop short of the pyvmomi mutation. Overridable per target in `targets.yaml` (`dry_run:` on a target wins in either direction). |
| `TARGETS_FILE` | `./targets.yaml` | Environment declarations. |
| `POLICY_FILE` | `./policy.yaml` | Policy rules; hot-reloaded on change. |
| `AUDIT_LOG_PATH` | `./audit.jsonl` | Append-only JSONL audit trail. |
| `CONFIRMATION_TTL_SECONDS` | `120` | How long a destructive proposal stays confirmable. `confirmation_ttl_seconds` in policy.yaml overrides this (read at startup). |
| `{PREFIX}_USER`, `{PREFIX}_PASSWORD` | — | Per-target credentials; `PREFIX` is each target's `credential_env_prefix`. Read lazily at connect time and never logged. |
| `WEB_UI_ENABLED` | `false` | Start the dashboard alongside the MCP server. |
| `WEB_UI_HOST` / `WEB_UI_PORT` | `127.0.0.1` / `8787` | Localhost-bound by default; exposing it further (TLS, VPN, reverse proxy) is on you. |
| `WEB_UI_TOKEN` | — | Shared secret, **required** when the UI is enabled. The server refuses to start an unauthenticated approval UI. |

### targets.yaml

See `targets.yaml.example`. Each entry: `name` (what every tool call
references), `host`, `credential_env_prefix`, optional `port`, `insecure`
(skip TLS verification, default false), `tags` (used by policy rules),
`standalone_host`, and optional per-target `dry_run`.

## Multi-target model

Every tool call is scoped by a `target` argument — a name from `targets.yaml`,
never an implicit connection.

- **Read tools** accept a single name, a list, or `"all"`. Multi-target reads
  fan out concurrently and return **partial results**: each target reports
  `{"ok": ...}` or `{"error": {...}}`, so one dead vCenter never fails the
  whole call. Example: `list_vms(target="all", name="web-1")` finds a VM name
  across every environment.
- **Mutating and destructive tools** take exactly one explicit target.
  `target: "all"` on those is denied outright (fail closed), and the denial —
  like every policy decision — is audited.
- **Connection pool**: one lazy session per target, opened on first use,
  reconnected transparently when vSphere expires it. Each target gets its own
  small thread pool, so a hung or unreachable vCenter cannot starve calls to
  healthy ones. Fan-out applies a per-target timeout.
- **Linked Mode**: ELM replicates roles/tags/licenses, but each vCenter still
  serves only its own inventory over the SOAP API, so the pool always keeps
  one session per target. What it does detect is two target entries resolving
  to the *same* vCenter instance (DNS alias, ELM member listed twice):
  `list_targets` flags them via `shares_instance_with` so you can drop the
  duplicate. Cross-vCenter migration/clone beyond what vSphere provides
  natively is out of scope.

Object naming: VMs (and hosts/datastores/etc.) are referenced by exact name or
moid (`vm-123`). An ambiguous name is an error listing the candidate moids —
the server never guesses.

## The guardrail layer

1. **Capability gating** — without `ENABLE_DESTRUCTIVE=true`, destructive
   tools (and `confirm_action` etc.) are not registered at all.
2. **Policy engine** — `policy.yaml`, hot-reloaded, evaluated per call.
   Precedence: `protected` (absolute deny, override ignored) → `rules`
   (ordered, first match wins) → `targets` (per-target overrides) →
   `defaults`. A target with no policy entry inherits the global defaults,
   which ship strict (`destructive: deny`). Any engine error — including a
   policy file that stops parsing after an edit — **fails closed**: every
   mutate/destructive call is denied with the parse error until it's fixed.
3. **Two-phase commit** — a destructive tool call never executes. It returns a
   *proposal*: what will happen, the affected objects, and prominently **which
   target** (name + host), plus a token. `confirm_action(token)` within the
   TTL executes it; `cancel_action(token)` kills it; expiry is automatic.
   This is the main defense against "confirmed the right VM, wrong vCenter" —
   always re-read the proposal's target before confirming.
4. **Human approval (optional)** — set `require_web_approval: true` for a
   target in policy.yaml and `confirm_action` over MCP is refused until a
   human clicks Confirm in the web UI's pending queue. The web Confirm both
   approves and executes; the agent can no longer confirm its own proposal.
   **This is off by default.** With it off, proposals still appear in the
   pending queue, but only for visibility: the agent can confirm one over MCP
   a second after proposing it, and the queue entry will flip straight to
   `executed` or `failed` under a human who was still reading it. The queue
   labels which kind each order is. If you want the queue to be a real gate,
   you have to turn this on.
5. **Auto-snapshot-before-destroy** — on by default for revert-type actions,
   per target (`auto_snapshot_before_destroy`). The snapshot's name/id and
   target are in the result. It deliberately does **not** apply to `delete_vm`
   or `delete_all_snapshots`: `Destroy_Task` takes the snapshot with it, so it
   protects nothing and only adds a way for the delete to fail. If you want a
   real undo window before a delete, power off and wait instead. When a bulk
   proposal's snapshots partly fail, nothing destructive runs and the error
   names every VM that failed plus every snapshot already taken.
6. **Limits** — per-target `max_bulk_objects` (bulk calls affecting more
   objects are denied before proposal) and `max_calls_per_minute` per target
   and category (a burst against one vCenter doesn't starve the others).
7. **Dry run** — `DRY_RUN=true` (or per-target `dry_run:`) runs resolution,
   policy, proposal, and confirmation for real, then reports what *would*
   have happened instead of calling pyvmomi. Point it at prod policy files
   safely.

### The confirmation flow, concretely

```text
> delete_vm(target="dev-vc1", vm="web-2")
{ "state": "pending", "target": "dev-vc1",
  "summary": "PERMANENTLY DELETE VM 'web-2' (vm-2) from target 'dev-vc1' — ...",
  "affected": [{"type": "vm", "name": "web-2", "moid": "vm-2", ...}],
  "token": "kJx0...", "expires_in_seconds": 120,
  "confirm_with": "confirm_action(token=\"kJx0...\") within 120s",
  "note": "NO CHANGES MADE YET. This proposal targets environment 'dev-vc1'
           (vcenter-dev1.example.com). Verify the target and affected objects
           before confirming." }

> confirm_action(token="kJx0...")
{ "target": "dev-vc1", "deleted_vm": "web-2", "moid": "vm-2",
  "safety_snapshots": [{"name": "mcp-safety-1755...", "vm": "web-2",
                        "target": "dev-vc1"}] }
```

Errors are distinct on purpose: `confirmation_expired` (get a fresh proposal),
`confirmation_invalid` (wrong/used/cancelled token), `task_failed` (vSphere
executed and failed, message included), `unreachable` (target down),
`policy_denied`, `rate_limited`. Each needs different remediation.

## Extending the policy file

Add a rule (ordered — first match wins):

```yaml
rules:
  - name: interns-cannot-touch-databases
    match:
      target_tags: [env:staging]        # any listed tag matches (OR)
      tools: [destructive]              # category or concrete tool names
      vm_patterns: ["db-*", "pg-*"]     # fnmatch against affected VM names
    action: deny
    unless_override: true               # override=true in the request flips it
```

Match fields (`target_names`, `target_tags`, `tools`, `vm_patterns`,
`folders`, `resource_pools`) are ANDed; absent fields match anything. Add a
per-target block under `targets:` to change category defaults, limits, or
flags for one environment. Add `protected:` entries for objects nothing may
destroy regardless of override. Save the file — it reloads on the next call;
a broken edit fails closed rather than falling back.

## Audit log

Append-only JSONL at `AUDIT_LOG_PATH`, one line per tool call and per
confirmation-lifecycle event. Args are secret-redacted before serialization.
Phases: `call` (reads), `proposed`, `executed`, `denied`, `failed`,
`cancelled`, `dry_run`.

Sample line (wrapped for readability — the file is one JSON object per line):

```json
{"ts":"2026-08-20T14:02:11+0000","ts_epoch":1755698531.204,
 "target":"prod-vc1","tool":"delete_vm","phase":"denied",
 "identity":"claude-desktop/1.5.0",
 "args":{"target":"prod-vc1","vm":"web-1","override":false},
 "decision":{"allowed":false,"reason":"denied by rule 'protect-prod' (pass override=true to proceed)","rule":"protect-prod"}}
```

Incident review is grep-shaped:

```bash
tail -f audit.jsonl
grep '"target":"prod-vc1"' audit.jsonl | grep '"phase":"executed"'
```

## Web UI (optional)

```bash
export WEB_UI_ENABLED=true WEB_UI_TOKEN=$(openssl rand -hex 16)
.venv/bin/vmware-mcp     # dashboard on http://127.0.0.1:8787
```

One static page (Tailwind CDN, vanilla JS, no build step) served by the same
process, reading the same pool/policy/audit modules as the MCP tools:

- **Pending** — the work-order queue. Every destructive proposal appears here
  with its target, affected objects, requester, and countdown. Orders on a
  target with `require_web_approval: true` are tagged "blocked until approved
  here" and cannot execute any other way. The rest are tagged "not gated" and
  are informational: the MCP client can confirm them itself at any moment, so
  do not read the queue as a veto you have not configured.
- **Targets** — per-target connectivity, version, last error, duplicate
  instance detection.
- **Inventory** — read-only VM/host/cluster/datastore browser with fan-out
  and per-target error display.
- **Audit** — filterable tail of the JSONL log.

Every request requires `WEB_UI_TOKEN` (header `X-Auth-Token`, `Bearer`, or
`?token=`). The UI never creates or edits anything — actions stay on the MCP
path; only confirm/deny of an existing proposal is writable.

## Tool surface

Read (always registered): `list_targets`, `list_vms`, `get_vm_details`,
`get_vm_performance`, `list_hosts`, `get_host_details`, `list_clusters`,
`get_cluster_details`, `list_datastores`, `get_datastore_details`,
`get_events`, `get_tasks`, `get_customization_status`, `list_snapshots`.

`list_vms` rows carry `connection_state` and `created_at`, and both are
filterable. `connection_state` matters more than it looks: an `orphaned`,
`inaccessible` or `invalid` VM keeps reporting its last-known power state and
last-known guest IPs indefinitely, while vCenter quietly disables
`CreateSnapshot_Task` and `Destroy_Task` on it. Every one of those disabled
calls fails with the same subject-less fault, "The operation is not allowed in
the current state", so a VM that looks like a clean powered-off delete
candidate can be an unusable ghost record. Use `unregister_vm` on those.

`get_events` and `get_tasks` take an explicit window (`start`/`end` as
ISO-8601), with `minutes` only as a fallback lookback from now. Both scroll a
vSphere HistoryCollector rather than calling the single-shot `QueryEvents`,
which caps server-side and silently returns only the most recent slice of a
busy window. Filters: `event_type`/`task_id`, `user`, `state`, and
`entity_name` (a regex matched against the entity names carried on each
record). `entity_name` is how you query a VM that has already been deleted and
can no longer be resolved. It scans the window rather than filtering
server-side, and if it hits `max_scan` before reaching the start of the window
it raises rather than handing back a partial list that reads like a complete
one.

`get_customization_status` folds the `guest.customizationInfo` block and the
`Customization*` event family into one answer with `finished`/`succeeded`
booleans. A failure lands in one or the other depending on how far it got, so
reading either alone gives a wrong answer.

Mutating (policy-gated, one target): `power_on_vm`, `power_off_vm`
(guest-graceful with hard fallback), `suspend_vm`, `reset_vm`,
`create_snapshot`, `reconfigure_vm` (cpu/mem/add-disk/add-nic only — no raw
spec injection), `mount_iso`, `unmount_iso`, `migrate_vm` (within-target
vMotion/storage vMotion), `clone_vm` (same target).

Destructive (only with `ENABLE_DESTRUCTIVE=true`; all two-phase):
`delete_snapshot`, `revert_snapshot`, `delete_vm`, `unregister_vm`,
`remove_disk`, `bulk_vm_action` (delete_vm / delete_all_snapshots /
revert_to_current_snapshot over a name regex, size-limited), plus
`list_pending_actions`, `confirm_action`, `cancel_action`.

`unregister_vm` drops the vCenter record and leaves the files on the
datastore. It exists because `delete_vm` cannot touch an orphaned or invalid
VM at all: vCenter disables `Destroy_Task` on those, and `UnregisterVM` stays
enabled precisely because it is the only way to clear one. `delete_vm` now
refuses such a VM up front with the reason and a pointer here, instead of
relaying vSphere's subject-less fault.

## Project layout

```text
vmware_mcp/
  settings.py       env-driven global settings
  config.py         pydantic schemas + loaders for targets.yaml / policy.yaml
  errors.py         error taxonomy; the one place pyvmomi faults are translated
  pool.py           TargetPool: sessions, reconnect, per-target isolation, fan-out
  policy.py         pure policy engine + fail-closed shell + rate limiter
  confirm.py        two-phase confirmation store (TTL, single-use, web approval)
  audit.py          append-only JSONL with secret redaction
  vimops/
    inventory.py    sync reads: listing, resolution, events, perf
    actions.py      sync mutations + the single wait_for_task polling loop
  server.py         composition root, tool surface, capability gating
  webui.py          FastAPI app sharing the same App modules
  static/index.html the dashboard (Tailwind CDN, vanilla JS)
```

## Testing

```bash
.venv/bin/pip install -e ".[dev]"
.venv/bin/python -m pytest            # 112 tests, no vCenter required
```

- `test_policy.py` — pure engine: defaults vs per-target overrides, tag rules,
  override semantics, protected objects, bulk limits, fail-closed reload.
- `test_pool.py` — two fake targets: lazy connect, session-expiry reconnect,
  a slow/hung target not blocking the other, fan-out partial results.
- `test_confirm.py` / `test_tools.py` — TTL, single-use tokens, the full
  propose→confirm path, dry-run, capability gating via real MCP registration.
- `test_vimops.py` — mocked vim tree: filters, task waiting, device edits,
  event/task history paging (including the `latestPage` that
  `ResetCollector` skips), the connection-state guard, and customization
  status.
- `test_webui.py` — token gating and the human-approval handshake.
- `test_integration_lab.py` — read-only tests against real lab vCenters
  (ideally two, to exercise fan-out); skipped unless `LAB_TARGETS_FILE` and
  the lab credentials are exported. See the module docstring.

## Scope notes

- One credential per target, one policy file for all targets — no multi-tenant
  auth layer.
- No cross-vCenter migration/clone orchestration beyond what vSphere offers
  natively.
- No scheduler: request/response only.
- Inventory listing batches all properties through the vSphere
  PropertyCollector — a constant number of round trips per call regardless of
  VM count — with a lazy per-object fallback for targets (or test fakes)
  without one. Single-object detail reads stay lazy.
- vSphere *tags* (the REST-API kind) aren't available over pyvmomi's SOAP
  interface, so VM filtering is by name/regex/folder/pool/power-state.