Skip to main content
Glama

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

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:

{
  "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": "..."
      }
    }
  }
}

Related MCP server: vSphere MCP Server

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 enginepolicy.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 runDRY_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

> 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):

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):

{"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:

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

Web UI (optional)

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

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

.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.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables secure management of VMware vCenter 8.0+ environments through controlled operations including VM lifecycle management, snapshots, and resource discovery with built-in RBAC authorization, audit logging, and rate limiting.
    6
    MIT
  • F
    license
    Not graded
    quality
    D
    maintenance
    Enables AI agents to manage VMware vSphere virtual infrastructure through comprehensive operations including VM power control, snapshot management, resource monitoring, performance analytics, and bulk operations with built-in safety confirmations for destructive actions.
    -
  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables AI agents to manage VMware vSphere infrastructure through 55 typed tools built on the govc CLI. It supports comprehensive operations including VM lifecycle management, snapshot control, datastore navigation, and networking configuration.
    10 npm
    3
    MIT