mcvsphere/test_client.py
Ryan Malloy 9e39c1c678 Refactor to modular mixin architecture with 74 tools
Major refactoring from monolithic server.py to modular MCPMixin pattern:

Architecture:
- src/esxi_mcp_server/ package with proper src-layout
- FastMCP MCPMixin pattern for tool organization
- Separate mixins for each functional area
- Shared VMwareConnection class with lazy datastore/network lookup

New Mixins Added:
- DiskManagementMixin: add_disk, remove_disk, extend_disk, list_disks,
  attach_iso, detach_iso
- NICManagementMixin: add_nic, remove_nic, change_nic_network,
  connect_nic, set_nic_mac, list_nics
- HostManagementMixin: get_host_info, enter/exit_maintenance_mode,
  list_services, start/stop_service, set_service_policy,
  get/configure_ntp, reboot_host, shutdown_host, get_host_hardware,
  get_host_networking
- OVFManagementMixin: deploy_ovf, export_vm_ovf, list_ovf_networks
- ResourcesMixin: Added move_datastore_file, copy_datastore_file

Streaming Support:
- Generator-based streaming for datastore downloads
- Memory-efficient large file handling with save_to parameter
- Chunked uploads from disk

Testing:
- test_client.py: MCP SDK-based test client
- Validates all 74 tools against real ESXi host

Build System:
- pyproject.toml with uv, ruff configuration
- Docker dev/prod modes with hot-reload
- Updated Makefile for uv-based workflow
2025-12-26 05:53:51 -07:00

215 lines
8.8 KiB
Python

#!/usr/bin/env python3
"""Simple MCP client to test the ESXi MCP server."""
import asyncio
import json
import os
from pathlib import Path
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
def load_env_file(path: str = ".env") -> dict[str, str]:
"""Load environment variables from a .env file."""
env = {}
env_path = Path(path)
if env_path.exists():
with open(env_path) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, _, value = line.partition("=")
env[key.strip()] = value.strip()
return env
async def main():
"""Test the ESXi MCP server."""
print("🔌 Connecting to ESXi MCP server...")
# Load from .env file
dotenv = load_env_file()
server_params = StdioServerParameters(
command="uv",
args=["run", "esxi-mcp-server"],
env={
**os.environ,
"VCENTER_HOST": dotenv.get("VCENTER_HOST", os.environ.get("VCENTER_HOST", "")),
"VCENTER_USER": dotenv.get("VCENTER_USER", os.environ.get("VCENTER_USER", "")),
"VCENTER_PASSWORD": dotenv.get("VCENTER_PASSWORD", os.environ.get("VCENTER_PASSWORD", "")),
"VCENTER_INSECURE": dotenv.get("VCENTER_INSECURE", os.environ.get("VCENTER_INSECURE", "true")),
"MCP_TRANSPORT": "stdio", # Force stdio transport
}
)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
# Initialize
await session.initialize()
print("✓ Connected!\n")
# List available tools
print("=== Available Tools ===")
tools_result = await session.list_tools()
print(f"Total: {len(tools_result.tools)} tools")
# Group by category (based on tool name patterns)
categories = {
"VM Lifecycle": [],
"Power Ops": [],
"Snapshots": [],
"Guest Ops": [],
"Monitoring": [],
"Datastore": [],
"Disk Mgmt": [],
"NIC Mgmt": [],
"Host Mgmt": [],
"OVF/OVA": [],
"Other": [],
}
for tool in tools_result.tools:
name = tool.name
if name in ["create_vm", "clone_vm", "delete_vm", "reconfigure_vm", "get_vm_info"]:
categories["VM Lifecycle"].append(name)
elif "power" in name or name in ["reset_vm", "suspend_vm"]:
categories["Power Ops"].append(name)
elif "snapshot" in name:
categories["Snapshots"].append(name)
elif "guest" in name or "execute" in name:
categories["Guest Ops"].append(name)
elif "metrics" in name or "events" in name or "alarms" in name:
categories["Monitoring"].append(name)
elif "datastore" in name or "browse" in name or "upload" in name or "download" in name:
categories["Datastore"].append(name)
elif "disk" in name or "iso" in name:
categories["Disk Mgmt"].append(name)
elif "nic" in name or "mac" in name:
categories["NIC Mgmt"].append(name)
elif "host" in name or "maintenance" in name or "service" in name or "ntp" in name:
categories["Host Mgmt"].append(name)
elif "ovf" in name:
categories["OVF/OVA"].append(name)
else:
categories["Other"].append(name)
for cat, tools in categories.items():
if tools:
print(f" {cat}: {len(tools)} tools")
print()
# List available resources
print("=== Available Resources ===")
resources_result = await session.list_resources()
for r in resources_result.resources:
print(f"{r.uri} ({r.name})")
print()
# Test 1: Browse datastore
print("=== Test 1: Browse Datastore ===")
result = await session.call_tool("browse_datastore", {
"datastore": "c1_ds-02",
"path": "iso"
})
files = json.loads(result.content[0].text)
print(f"Found {len(files)} files in iso/")
for f in files[:5]:
print(f" {f['name']}: {f['size_human']}")
print()
# Test 2: Read VMs resource
print("=== Test 2: Read Resource (esxi://vms) ===")
try:
result = await session.read_resource("esxi://vms")
vms = json.loads(result.contents[0].text)
print(f"Found {len(vms)} VMs:")
for vm in vms[:5]:
print(f"{vm['name']}: {vm['power_state']}")
except Exception as e:
print(f"Resource read error: {e}")
print()
# Test 3: Get Host Info (NEW!)
print("=== Test 3: Get Host Info (NEW!) ===")
try:
result = await session.call_tool("get_host_info", {})
host_info = json.loads(result.content[0].text)
print(f" Host: {host_info.get('name', 'N/A')}")
prod = host_info.get('product', {})
print(f" ESXi: {prod.get('version', 'N/A')} (build {prod.get('build', 'N/A')})")
hw = host_info.get('hardware', {})
print(f" CPU: {hw.get('cpu_cores', 'N/A')} cores @ {hw.get('cpu_mhz', 'N/A')} MHz")
print(f" RAM: {hw.get('memory_gb', 'N/A')} GB")
status = host_info.get('status', {})
print(f" Maintenance: {status.get('maintenance_mode', 'N/A')}")
except Exception as e:
print(f"Error: {e}")
print()
# Test 4: List Services (NEW!)
print("=== Test 4: List Services (NEW!) ===")
try:
result = await session.call_tool("list_services", {})
services = json.loads(result.content[0].text)
running = [s for s in services if s.get('running')]
print(f"Found {len(services)} services ({len(running)} running):")
for s in running[:8]:
print(f"{s['key']}: {s['label']}")
except Exception as e:
print(f"Error: {e}")
print()
# Test 5: Get NTP Config (NEW!)
print("=== Test 5: Get NTP Config (NEW!) ===")
try:
result = await session.call_tool("get_ntp_config", {})
ntp = json.loads(result.content[0].text)
print(f" NTP Servers: {ntp.get('ntp_servers', [])}")
print(f" Service Running: {ntp.get('service_running', 'N/A')}")
print(f" Timezone: {ntp.get('timezone', 'N/A')}")
except Exception as e:
print(f"Error: {e}")
print()
# Test 6: List VM Disks (if there are VMs)
print("=== Test 6: List VM Disks (NEW!) ===")
try:
# First get a VM name
result = await session.read_resource("esxi://vms")
vms = json.loads(result.contents[0].text)
if vms:
vm_name = vms[0]['name']
print(f" Checking disks on: {vm_name}")
result = await session.call_tool("list_disks", {"vm_name": vm_name})
disks = json.loads(result.content[0].text)
for d in disks:
print(f"{d['label']}: {d.get('size_gb', 'N/A')} GB")
else:
print(" No VMs found to check")
except Exception as e:
print(f"Error: {e}")
print()
# Test 7: List VM NICs (NEW!)
print("=== Test 7: List VM NICs (NEW!) ===")
try:
if vms:
vm_name = vms[0]['name']
print(f" Checking NICs on: {vm_name}")
result = await session.call_tool("list_nics", {"vm_name": vm_name})
nics = json.loads(result.content[0].text)
for n in nics:
print(f"{n['label']}: {n.get('type', 'N/A')} - {n.get('network', 'N/A')}")
print(f" MAC: {n.get('mac_address', 'N/A')}, Connected: {n.get('connected', 'N/A')}")
except Exception as e:
print(f"Error: {e}")
print()
print("✅ All tests completed!")
if __name__ == "__main__":
asyncio.run(main())