vmware-mcp
Manages VMware vSphere/vCenter and standalone ESXi hosts across multiple named environments. Provides read-only introspection of inventory (VMs, hosts, datastores, etc.) fanned out across targets, plus policy-gated destructive operations such as VM deletion, snapshot management, and revert actions. Includes a guardrail layer with declarative policy, two-phase proposal/confirmation of destructive calls, optional human approval via a web dashboard, dry-run mode, rate/bulk limits, auto-snapshot-before-destroy, and an append-only audit trail.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@vmware-mcplist all VMs across my vCenter environments"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 serverClaude 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 |
|
| Register destructive tools at all. Off = they don't exist on the tool surface. |
|
| Full policy + confirmation flow, but stop short of the pyvmomi mutation. Overridable per target in |
|
| Environment declarations. |
|
| Policy rules; hot-reloaded on change. |
|
| Append-only JSONL audit trail. |
|
| How long a destructive proposal stays confirmable. |
| — | Per-target credentials; |
|
| Start the dashboard alongside the MCP server. |
|
| Localhost-bound by default; exposing it further (TLS, VPN, reverse proxy) is on you. |
| — | 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_targetsflags them viashares_instance_withso 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
Capability gating — without
ENABLE_DESTRUCTIVE=true, destructive tools (andconfirm_actionetc.) are not registered at all.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.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.Human approval (optional) — set
require_web_approval: truefor a target in policy.yaml andconfirm_actionover 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 toexecutedorfailedunder 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.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 todelete_vmordelete_all_snapshots:Destroy_Tasktakes 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.Limits — per-target
max_bulk_objects(bulk calls affecting more objects are denied before proposal) andmax_calls_per_minuteper target and category (a burst against one vCenter doesn't starve the others).Dry run —
DRY_RUN=true(or per-targetdry_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 itMatch 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:8787One 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: trueare 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 requiredtest_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 thelatestPagethatResetCollectorskips), 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 unlessLAB_TARGETS_FILEand 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.
This server cannot be deployed
Maintenance
Related MCP Connectors
- ZopDev MCPOAuthdev.zop
Cloud cost, inventory and governance on AWS/Azure/GCP. Read-only by default, optional scoped writes
Governed MCP gateway: one endpoint for your tools, with credential custody and audit log.
Scoped, audited SSH exec, sessions, and SFTP on your saved servers without exposing credentials
Provides capabilities that let LLM agents perform a range of infrastructure management tasks.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables 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.6MIT
- FlicenseNot gradedqualityDmaintenanceEnables 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.-
- AlicenseNot gradedqualityDmaintenanceEnables 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 npm3MIT
- AlicenseAqualityCmaintenanceProvides tools to list, create, power on/off, and delete VMs on vCenter or ESXi hosts using natural language.5Apache 2.0