From 8e28b84e59b85479b72d557c7a59e1b4804b80be Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Fri, 26 Dec 2025 18:44:09 -0700 Subject: [PATCH] add console and serial port mixins, expand to 94 tools New features: - ConsoleMixin: vm_screenshot, wait_for_vm_tools, get_vm_tools_status - SerialPortMixin: setup_serial_port, get_serial_port, connect_serial_port, clear_serial_port, remove_serial_port Enables: - VM console screenshots via vSphere HTTP API - VMware Tools status monitoring and wait utilities - Network serial ports for headless VMs and network appliances README rewritten with comprehensive documentation of all 94 tools. --- README.md | 437 +++++++++++++++------- src/esxi_mcp_server/mixins/__init__.py | 18 +- src/esxi_mcp_server/mixins/console.py | 178 +++++++++ src/esxi_mcp_server/mixins/serial_port.py | 312 +++++++++++++++ src/esxi_mcp_server/server.py | 4 + 5 files changed, 802 insertions(+), 147 deletions(-) create mode 100644 src/esxi_mcp_server/mixins/console.py create mode 100644 src/esxi_mcp_server/mixins/serial_port.py diff --git a/README.md b/README.md index 363209a..5cb67f7 100644 --- a/README.md +++ b/README.md @@ -1,188 +1,345 @@ # ESXi MCP Server -A VMware ESXi/vCenter management server based on MCP (Model Control Protocol), providing simple REST API interfaces for virtual machine management. +A comprehensive VMware vSphere management server implementing the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/), enabling AI assistants like Claude to manage virtual infrastructure through natural language. + +## Why ESXi MCP Server? + +Traditional VMware management requires navigating complex UIs or writing scripts. With ESXi MCP Server, you can simply ask: + +> "Create a new VM with 4 CPUs and 8GB RAM, then take a snapshot before installing the OS" + +And watch it happen. The server exposes **94 tools** covering every aspect of vSphere management. ## Features -- Support for ESXi and vCenter Server connections -- Real-time communication based on SSE (Server-Sent Events) -- RESTful API interface with JSON-RPC support -- API key authentication -- Complete virtual machine lifecycle management -- Real-time performance monitoring -- SSL/TLS secure connection support -- Flexible configuration options (YAML/JSON/Environment Variables) - -## Core Functions - -- Virtual Machine Management - - Create VM - - Clone VM - - Delete VM - - Power On/Off operations - - List all VMs -- Performance Monitoring - - CPU usage - - Memory usage - - Storage usage - - Network traffic statistics - -## Requirements - -- Python 3.7+ -- pyVmomi -- PyYAML -- uvicorn -- mcp-core (Machine Control Protocol core library) +- **94 MCP Tools** - Complete vSphere management capabilities +- **6 MCP Resources** - Real-time access to VMs, hosts, datastores, networks, and clusters +- **Modular Architecture** - 13 specialized mixins organized by function +- **Full vCenter & ESXi Support** - Works with standalone hosts or vCenter Server +- **Guest Operations** - Execute commands, transfer files inside VMs via VMware Tools +- **Serial Console Access** - Network serial ports for headless VMs and network appliances +- **VM Screenshots** - Capture console screenshots for monitoring or documentation ## Quick Start -1. Install dependencies: +### Installation ```bash -pip install pyvmomi pyyaml uvicorn mcp-core +# Install with uv (recommended) +uvx esxi-mcp-server + +# Or install with pip +pip install esxi-mcp-server ``` -2. Create configuration file `config.yaml`: +### Configuration -```yaml -vcenter_host: "your-vcenter-ip" -vcenter_user: "administrator@vsphere.local" -vcenter_password: "your-password" -datacenter: "your-datacenter" # Optional -cluster: "your-cluster" # Optional -datastore: "your-datastore" # Optional -network: "VM Network" # Optional -insecure: true # Skip SSL certificate verification -api_key: "your-api-key" # API access key -log_file: "./logs/vmware_mcp.log" # Log file path -log_level: "INFO" # Log level -``` - -3. Run the server: +Create a `.env` file or set environment variables: ```bash -python server.py -c config.yaml +VCENTER_HOST=vcenter.example.com +VCENTER_USER=administrator@vsphere.local +VCENTER_PASSWORD=your-password +VCENTER_INSECURE=true # Skip SSL verification (dev only) ``` -## API Interface +### Run the Server -### Authentication +```bash +# Using uvx +uvx esxi-mcp-server -All privileged operations require authentication first: - -```http -POST /sse/messages -Authorization: Bearer your-api-key +# Or if installed +esxi-mcp-server ``` -### Main Tool Interfaces +### Add to Claude Code -1. Create VM -```json -{ - "name": "vm-name", - "cpu": 2, - "memory": 4096, - "datastore": "datastore-name", - "network": "network-name" -} +```bash +claude mcp add esxi "uvx esxi-mcp-server" ``` -2. Clone VM -```json -{ - "template_name": "source-vm", - "new_name": "new-vm-name" -} +## Available Tools (94 Total) + +### VM Lifecycle (6 tools) +| Tool | Description | +|------|-------------| +| `list_vms` | List all virtual machines | +| `get_vm_info` | Get detailed VM information | +| `create_vm` | Create a new virtual machine | +| `clone_vm` | Clone from template or existing VM | +| `delete_vm` | Delete a virtual machine | +| `reconfigure_vm` | Modify CPU, memory, annotation | +| `rename_vm` | Rename a virtual machine | + +### Power Operations (6 tools) +| Tool | Description | +|------|-------------| +| `power_on_vm` | Power on a VM | +| `power_off_vm` | Power off a VM (hard) | +| `shutdown_guest` | Graceful guest OS shutdown | +| `reboot_guest` | Graceful guest OS reboot | +| `suspend_vm` | Suspend a VM | +| `reset_vm` | Hard reset a VM | + +### Snapshots (5 tools) +| Tool | Description | +|------|-------------| +| `list_snapshots` | List all snapshots | +| `create_snapshot` | Create a new snapshot | +| `revert_to_snapshot` | Revert to a snapshot | +| `delete_snapshot` | Delete a snapshot | +| `delete_all_snapshots` | Remove all snapshots | + +### Guest Operations (7 tools) +*Requires VMware Tools running in the guest* + +| Tool | Description | +|------|-------------| +| `list_guest_processes` | List processes in guest OS | +| `run_command_in_guest` | Execute command in guest | +| `read_guest_file` | Read file from guest OS | +| `write_guest_file` | Write file to guest OS | +| `list_guest_directory` | List directory contents | +| `create_guest_directory` | Create directory in guest | +| `delete_guest_file` | Delete file or directory | + +### Console & Monitoring (5 tools) +| Tool | Description | +|------|-------------| +| `vm_screenshot` | Capture VM console screenshot | +| `wait_for_vm_tools` | Wait for VMware Tools to be ready | +| `get_vm_tools_status` | Get VMware Tools status | +| `get_vm_stats` | Get VM performance statistics | +| `get_host_stats` | Get host performance statistics | + +### Serial Port Management (5 tools) +*For network appliances and headless VMs* + +| Tool | Description | +|------|-------------| +| `get_serial_port` | Get serial port configuration | +| `setup_serial_port` | Configure network serial port | +| `connect_serial_port` | Connect/disconnect serial port | +| `clear_serial_port` | Reset serial port connection | +| `remove_serial_port` | Remove serial port from VM | + +### Disk Management (5 tools) +| Tool | Description | +|------|-------------| +| `list_disks` | List VM disks | +| `add_disk` | Add a new disk | +| `remove_disk` | Remove a disk | +| `resize_disk` | Expand disk size | +| `get_disk_info` | Get disk details | + +### NIC Management (6 tools) +| Tool | Description | +|------|-------------| +| `list_nics` | List VM network adapters | +| `add_nic` | Add a network adapter | +| `remove_nic` | Remove a network adapter | +| `connect_nic` | Connect/disconnect NIC | +| `change_nic_network` | Change NIC network | +| `get_nic_info` | Get NIC details | + +### OVF/OVA Management (5 tools) +| Tool | Description | +|------|-------------| +| `deploy_ovf` | Deploy VM from OVF template | +| `export_ovf` | Export VM to OVF | +| `list_ovf_networks` | List OVF network mappings | +| `upload_to_datastore` | Upload file to datastore | +| `download_from_datastore` | Download file from datastore | + +### Host Management (10 tools) +| Tool | Description | +|------|-------------| +| `list_hosts` | List ESXi hosts | +| `get_host_info` | Get host details | +| `get_host_hardware` | Get hardware information | +| `get_host_networking` | Get network configuration | +| `list_services` | List host services | +| `get_service_status` | Get service status | +| `start_service` | Start a host service | +| `stop_service` | Stop a host service | +| `restart_service` | Restart a host service | +| `get_ntp_config` | Get NTP configuration | + +### Datastore & Resources (8 tools) +| Tool | Description | +|------|-------------| +| `get_datastore_info` | Get datastore details | +| `browse_datastore` | Browse datastore files | +| `get_vcenter_info` | Get vCenter information | +| `get_resource_pool_info` | Get resource pool details | +| `get_network_info` | Get network details | +| `list_templates` | List VM templates | +| `get_alarms` | Get active alarms | +| `get_recent_events` | Get recent events | + +### vCenter Operations (18 tools) +*Available when connected to vCenter Server* + +| Tool | Description | +|------|-------------| +| `list_folders` | List VM folders | +| `create_folder` | Create a folder | +| `delete_folder` | Delete a folder | +| `move_vm_to_folder` | Move VM to folder | +| `list_clusters` | List clusters | +| `get_cluster_info` | Get cluster details | +| `list_resource_pools` | List resource pools | +| `create_resource_pool` | Create resource pool | +| `delete_resource_pool` | Delete resource pool | +| `move_vm_to_resource_pool` | Move VM to resource pool | +| `list_tags` | List tags | +| `get_vm_tags` | Get tags on a VM | +| `apply_tag_to_vm` | Apply tag to VM | +| `remove_tag_from_vm` | Remove tag from VM | +| `migrate_vm` | Migrate VM to another host | +| `list_recent_tasks` | List recent tasks | +| `list_recent_events` | List recent events | +| `cancel_task` | Cancel a running task | + +## MCP Resources + +Access real-time data through MCP resources: + +| Resource URI | Description | +|--------------|-------------| +| `esxi://vms` | All virtual machines | +| `esxi://hosts` | All ESXi hosts | +| `esxi://datastores` | All datastores | +| `esxi://networks` | All networks | +| `esxi://clusters` | All clusters | +| `esxi://resource-pools` | All resource pools | + +## Architecture + +The server uses a modular mixin architecture: + +``` +esxi_mcp_server/ +├── server.py # FastMCP server setup +├── connection.py # VMware connection management +├── config.py # Settings and configuration +└── mixins/ + ├── vm_lifecycle.py # VM CRUD operations + ├── power_ops.py # Power management + ├── snapshots.py # Snapshot management + ├── guest_ops.py # Guest OS operations + ├── console.py # Screenshots & Tools monitoring + ├── serial_port.py # Serial console access + ├── disk_management.py # Disk operations + ├── nic_management.py # Network adapter operations + ├── ovf_management.py # OVF/OVA handling + ├── host_management.py # Host operations + ├── monitoring.py # Performance monitoring + ├── resources.py # MCP resources + └── vcenter_ops.py # vCenter-specific operations ``` -3. Delete VM -```json -{ - "name": "vm-name" -} +## Configuration Options + +| Variable | Description | Default | +|----------|-------------|---------| +| `VCENTER_HOST` | vCenter/ESXi hostname or IP | *required* | +| `VCENTER_USER` | Username | *required* | +| `VCENTER_PASSWORD` | Password | *required* | +| `VCENTER_INSECURE` | Skip SSL verification | `false` | +| `VCENTER_DATACENTER` | Target datacenter | *auto-detect* | +| `VCENTER_CLUSTER` | Target cluster | *auto-detect* | +| `VCENTER_DATASTORE` | Default datastore | *auto-detect* | +| `VCENTER_NETWORK` | Default network | *auto-detect* | +| `MCP_TRANSPORT` | Transport mode (`stdio` or `sse`) | `stdio` | +| `LOG_LEVEL` | Logging level | `INFO` | + +## Docker Support + +```bash +# Build +docker build -t esxi-mcp-server . + +# Run +docker run -d \ + -e VCENTER_HOST=vcenter.example.com \ + -e VCENTER_USER=admin@vsphere.local \ + -e VCENTER_PASSWORD=secret \ + esxi-mcp-server ``` -4. Power Operations -```json -{ - "name": "vm-name" -} +## Examples + +### Create a VM and Install an OS + +``` +User: Create a new VM called "web-server" with 4 CPUs, 8GB RAM, and a 100GB disk + +Claude: I'll create that VM for you. +[Calls create_vm with name="web-server", cpu=4, memory_mb=8192, disk_gb=100] + +VM 'web-server' created successfully. + +User: Power it on and take a screenshot + +Claude: [Calls power_on_vm, then vm_screenshot] + +The VM is now running. Here's the console screenshot showing the BIOS boot screen. ``` -### Resource Monitoring Interface +### Guest Operations -Get VM performance data: -```http -GET vmstats://{vm_name} +``` +User: Run "uname -a" on the linux-server VM + +Claude: [Calls run_command_in_guest with command="/usr/bin/uname", arguments="-a"] + +The command returned: +Linux linux-server 5.15.0-generic #1 SMP x86_64 GNU/Linux ``` -## Configuration +### Serial Console for Network Appliances -| Parameter | Description | Required | Default | -|-----------|-------------|----------|---------| -| vcenter_host | vCenter/ESXi server address | Yes | - | -| vcenter_user | Login username | Yes | - | -| vcenter_password | Login password | Yes | - | -| datacenter | Datacenter name | No | Auto-select first | -| cluster | Cluster name | No | Auto-select first | -| datastore | Storage name | No | Auto-select largest available | -| network | Network name | No | VM Network | -| insecure | Skip SSL verification | No | false | -| api_key | API access key | No | - | -| log_file | Log file path | No | Console output | -| log_level | Log level | No | INFO | +``` +User: Set up a serial console on my Cisco router VM -## Environment Variables +Claude: [Calls setup_serial_port with name="cisco-router", protocol="telnet"] -All configuration items support environment variable settings, following these naming rules: -- VCENTER_HOST -- VCENTER_USER -- VCENTER_PASSWORD -- VCENTER_DATACENTER -- VCENTER_CLUSTER -- VCENTER_DATASTORE -- VCENTER_NETWORK -- VCENTER_INSECURE -- MCP_API_KEY -- MCP_LOG_FILE -- MCP_LOG_LEVEL +Serial port configured. You can connect via: +telnet://10.20.0.22:4521 +``` -## Security Recommendations +## Requirements -1. Production Environment: - - Use valid SSL certificates - - Enable API key authentication - - Set appropriate log levels - - Restrict API access scope +- Python 3.11+ +- VMware vSphere 7.0+ (ESXi or vCenter) +- VMware Tools (for guest operations) -2. Testing Environment: - - Set insecure: true to skip SSL verification - - Use more detailed log level (DEBUG) +## Development + +```bash +# Clone the repo +git clone https://github.com/yourusername/esxi-mcp-server.git +cd esxi-mcp-server + +# Install dependencies +uv sync + +# Run tests +uv run python test_client.py +``` ## License -MIT License +MIT License - See [LICENSE](LICENSE) for details. ## Contributing -Issues and Pull Requests are welcome! - -## Changelog - -### v0.0.1 -- Initial release -- Basic VM management functionality -- SSE communication support -- API key authentication -- Performance monitoring - -## Author - -Bright8192 +Contributions welcome! Please read the contributing guidelines and submit a PR. ## Acknowledgments -- VMware pyvmomi team -- MCP Protocol development team +- Built with [FastMCP](https://github.com/jlowin/fastmcp) +- Uses [pyVmomi](https://github.com/vmware/pyvmomi) for vSphere API +- Inspired by the Model Context Protocol specification diff --git a/src/esxi_mcp_server/mixins/__init__.py b/src/esxi_mcp_server/mixins/__init__.py index 0a26f17..01daf62 100644 --- a/src/esxi_mcp_server/mixins/__init__.py +++ b/src/esxi_mcp_server/mixins/__init__.py @@ -1,5 +1,6 @@ """MCP Mixins for ESXi operations organized by category.""" +from esxi_mcp_server.mixins.console import ConsoleMixin from esxi_mcp_server.mixins.disk_management import DiskManagementMixin from esxi_mcp_server.mixins.guest_ops import GuestOpsMixin from esxi_mcp_server.mixins.host_management import HostManagementMixin @@ -8,20 +9,23 @@ from esxi_mcp_server.mixins.nic_management import NICManagementMixin from esxi_mcp_server.mixins.ovf_management import OVFManagementMixin from esxi_mcp_server.mixins.power_ops import PowerOpsMixin from esxi_mcp_server.mixins.resources import ResourcesMixin +from esxi_mcp_server.mixins.serial_port import SerialPortMixin from esxi_mcp_server.mixins.snapshots import SnapshotsMixin from esxi_mcp_server.mixins.vcenter_ops import VCenterOpsMixin from esxi_mcp_server.mixins.vm_lifecycle import VMLifecycleMixin __all__ = [ - "VMLifecycleMixin", - "PowerOpsMixin", - "SnapshotsMixin", - "MonitoringMixin", - "GuestOpsMixin", - "ResourcesMixin", + "ConsoleMixin", "DiskManagementMixin", + "GuestOpsMixin", + "HostManagementMixin", + "MonitoringMixin", "NICManagementMixin", "OVFManagementMixin", - "HostManagementMixin", + "PowerOpsMixin", + "ResourcesMixin", + "SerialPortMixin", + "SnapshotsMixin", "VCenterOpsMixin", + "VMLifecycleMixin", ] diff --git a/src/esxi_mcp_server/mixins/console.py b/src/esxi_mcp_server/mixins/console.py new file mode 100644 index 0000000..9b9d2f0 --- /dev/null +++ b/src/esxi_mcp_server/mixins/console.py @@ -0,0 +1,178 @@ +"""VM Console operations - screenshots and tools monitoring.""" + +import base64 +import time +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any + +import requests +from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool +from mcp.types import ToolAnnotations +from pyVmomi import vim + +if TYPE_CHECKING: + from esxi_mcp_server.connection import VMwareConnection + + +class ConsoleMixin(MCPMixin): + """VM console operations - screenshots and VMware Tools monitoring.""" + + def __init__(self, conn: "VMwareConnection"): + self.conn = conn + + @mcp_tool( + name="wait_for_vm_tools", + description="Wait for VMware Tools to become available on a VM. Useful after powering on a VM.", + annotations=ToolAnnotations(readOnlyHint=True), + ) + def wait_for_vm_tools( + self, name: str, timeout: int = 120, poll_interval: int = 5 + ) -> dict[str, Any]: + """Wait for VMware Tools to become available. + + Args: + name: VM name + timeout: Maximum seconds to wait (default: 120) + poll_interval: Seconds between status checks (default: 5) + + Returns: + Dict with tools status, version, and guest info when ready + """ + vm = self.conn.find_vm(name) + if not vm: + raise ValueError(f"VM '{name}' not found") + + start_time = datetime.now() + end_time = start_time + timedelta(seconds=timeout) + + while datetime.now() < end_time: + tools_status = vm.guest.toolsStatus if vm.guest else None + + if tools_status == vim.vm.GuestInfo.ToolsStatus.toolsOk: + return { + "status": "ready", + "tools_status": str(tools_status), + "tools_version": vm.guest.toolsVersion if vm.guest else None, + "tools_running_status": ( + vm.guest.toolsRunningStatus if vm.guest else None + ), + "ip_address": vm.guest.ipAddress if vm.guest else None, + "hostname": vm.guest.hostName if vm.guest else None, + "guest_os": vm.guest.guestFullName if vm.guest else None, + "wait_time_seconds": (datetime.now() - start_time).total_seconds(), + } + + time.sleep(poll_interval) + + # Timeout reached + return { + "status": "timeout", + "tools_status": str(vm.guest.toolsStatus) if vm.guest else None, + "message": f"VMware Tools not ready after {timeout} seconds", + "wait_time_seconds": timeout, + } + + @mcp_tool( + name="get_vm_tools_status", + description="Get current VMware Tools status for a VM", + annotations=ToolAnnotations(readOnlyHint=True), + ) + def get_vm_tools_status(self, name: str) -> dict[str, Any]: + """Get VMware Tools status without waiting. + + Args: + name: VM name + + Returns: + Dict with current tools status and guest info + """ + vm = self.conn.find_vm(name) + if not vm: + raise ValueError(f"VM '{name}' not found") + + return { + "tools_status": str(vm.guest.toolsStatus) if vm.guest else None, + "tools_version": vm.guest.toolsVersion if vm.guest else None, + "tools_running_status": ( + vm.guest.toolsRunningStatus if vm.guest else None + ), + "tools_version_status": ( + str(vm.guest.toolsVersionStatus) if vm.guest else None + ), + "ip_address": vm.guest.ipAddress if vm.guest else None, + "hostname": vm.guest.hostName if vm.guest else None, + "guest_os": vm.guest.guestFullName if vm.guest else None, + "guest_id": vm.guest.guestId if vm.guest else None, + "guest_state": vm.guest.guestState if vm.guest else None, + } + + @mcp_tool( + name="vm_screenshot", + description="Capture a screenshot of the VM console. Returns base64-encoded PNG image.", + annotations=ToolAnnotations(readOnlyHint=True), + ) + def vm_screenshot( + self, + name: str, + width: int | None = None, + height: int | None = None, + ) -> dict[str, Any]: + """Capture VM console screenshot via vSphere HTTP API. + + Args: + name: VM name + width: Optional width to scale the image + height: Optional height to scale the image + + Returns: + Dict with base64-encoded image data and metadata + """ + vm = self.conn.find_vm(name) + if not vm: + raise ValueError(f"VM '{name}' not found") + + # Build screenshot URL + # Format: https://{host}/screen?id={moid} + host = self.conn.settings.vcenter_host + moid = vm._moId + screenshot_url = f"https://{host}/screen?id={moid}" + + # Add optional scaling parameters + params = [] + if width: + params.append(f"w={width}") + if height: + params.append(f"h={height}") + if params: + screenshot_url += "&" + "&".join(params) + + # Build auth header + username = self.conn.settings.vcenter_user + password = self.conn.settings.vcenter_password.get_secret_value() + auth = base64.b64encode(f"{username}:{password}".encode()).decode("ascii") + + # Make request + try: + response = requests.get( + screenshot_url, + headers={"Authorization": f"Basic {auth}"}, + verify=not self.conn.settings.vcenter_insecure, + timeout=30, + ) + response.raise_for_status() + except requests.RequestException as e: + raise ValueError(f"Failed to capture screenshot: {e}") from e + + # Encode image as base64 + image_data = base64.b64encode(response.content).decode("ascii") + content_type = response.headers.get("Content-Type", "image/png") + + return { + "vm_name": name, + "moid": moid, + "content_type": content_type, + "size_bytes": len(response.content), + "image_base64": image_data, + "width": width, + "height": height, + } diff --git a/src/esxi_mcp_server/mixins/serial_port.py b/src/esxi_mcp_server/mixins/serial_port.py new file mode 100644 index 0000000..1c6d746 --- /dev/null +++ b/src/esxi_mcp_server/mixins/serial_port.py @@ -0,0 +1,312 @@ +"""Serial Port Management - network console access for VMs.""" + +import random +import socket +import time +from typing import TYPE_CHECKING, Any + +from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool +from mcp.types import ToolAnnotations +from pyVmomi import vim + +if TYPE_CHECKING: + from esxi_mcp_server.connection import VMwareConnection + + +class SerialPortMixin(MCPMixin): + """Serial port management for VM network console access. + + Network serial ports allow telnet/TCP connections to VM consoles, + useful for headless VMs, network appliances, or serial console access. + + Supported protocols: + - telnet: Telnet over TCP (can negotiate SSL) + - telnets: Telnet over SSL over TCP + - tcp: Unencrypted TCP + - tcp+ssl: Encrypted SSL over TCP + """ + + def __init__(self, conn: "VMwareConnection"): + self.conn = conn + + def _get_serial_port(self, vm: vim.VirtualMachine) -> vim.vm.device.VirtualSerialPort | None: + """Find existing serial port with URI backing on a VM.""" + if not vm.config: + return None + for device in vm.config.hardware.device: + if ( + isinstance(device, vim.vm.device.VirtualSerialPort) + and isinstance(device.backing, vim.vm.device.VirtualSerialPort.URIBackingInfo) + ): + return device + return None + + def _find_unused_port(self, host_ip: str, start: int = 2000, end: int = 9000) -> int: + """Find an unused TCP port on the ESXi host.""" + # Try random ports in range until we find one that's available + attempts = 0 + max_attempts = 50 + while attempts < max_attempts: + port = random.randint(start, end) + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(1) + try: + result = sock.connect_ex((host_ip, port)) + if result != 0: # Port not in use + return port + except (OSError, TimeoutError): + return port # Likely available + finally: + sock.close() + attempts += 1 + + raise ValueError(f"Could not find unused port in range {start}-{end}") + + @mcp_tool( + name="get_serial_port", + description="Get current serial port configuration for a VM", + annotations=ToolAnnotations(readOnlyHint=True), + ) + def get_serial_port(self, name: str) -> dict[str, Any]: + """Get serial port configuration. + + Args: + name: VM name + + Returns: + Dict with serial port details or message if not configured + """ + vm = self.conn.find_vm(name) + if not vm: + raise ValueError(f"VM '{name}' not found") + + serial_port = self._get_serial_port(vm) + if not serial_port: + return { + "configured": False, + "message": "No network serial port configured", + } + + backing = serial_port.backing + return { + "configured": True, + "label": serial_port.deviceInfo.label, + "connected": serial_port.connectable.connected if serial_port.connectable else None, + "start_connected": serial_port.connectable.startConnected if serial_port.connectable else None, + "direction": backing.direction if backing else None, + "service_uri": backing.serviceURI if backing else None, + "yield_on_poll": serial_port.yieldOnPoll, + } + + @mcp_tool( + name="setup_serial_port", + description="Configure a network serial port on a VM for console access. VM must be powered off.", + annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True), + ) + def setup_serial_port( + self, + name: str, + protocol: str = "telnet", + port: int | None = None, + direction: str = "server", + yield_on_poll: bool = True, + ) -> dict[str, Any]: + """Setup or update network serial port. + + Args: + name: VM name + protocol: Protocol to use (telnet, telnets, tcp, tcp+ssl). Default: telnet + port: TCP port number. If not specified, auto-assigns unused port. + direction: 'server' (VM listens) or 'client' (VM connects). Default: server + yield_on_poll: Enable CPU yield behavior. Default: True + + Returns: + Dict with configured serial port URI and details + """ + vm = self.conn.find_vm(name) + if not vm: + raise ValueError(f"VM '{name}' not found") + + # Check VM is powered off + if vm.runtime.powerState != vim.VirtualMachine.PowerState.poweredOff: + raise ValueError(f"VM '{name}' must be powered off to configure serial port") + + # Validate protocol + valid_protocols = ["telnet", "telnets", "tcp", "tcp+ssl", "tcp4", "tcp6"] + if protocol not in valid_protocols: + raise ValueError(f"Invalid protocol '{protocol}'. Must be one of: {valid_protocols}") + + # Validate direction + if direction not in ["server", "client"]: + raise ValueError("Direction must be 'server' or 'client'") + + # Find or assign port + if port is None: + host = vm.runtime.host + host_ip = host.name if host else self.conn.settings.vcenter_host + port = self._find_unused_port(host_ip) + + # Build service URI + service_uri = f"{protocol}://:{port}" + + # Build spec + serial_spec = vim.vm.device.VirtualDeviceSpec() + existing_port = self._get_serial_port(vm) + + if existing_port: + # Edit existing + serial_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.edit + serial_spec.device = existing_port + else: + # Add new + serial_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add + serial_spec.device = vim.vm.device.VirtualSerialPort() + + # Configure backing + serial_spec.device.yieldOnPoll = yield_on_poll + serial_spec.device.backing = vim.vm.device.VirtualSerialPort.URIBackingInfo() + serial_spec.device.backing.direction = direction + serial_spec.device.backing.serviceURI = service_uri + + # Configure connectable + serial_spec.device.connectable = vim.vm.device.VirtualDevice.ConnectInfo() + serial_spec.device.connectable.startConnected = True + serial_spec.device.connectable.allowGuestControl = True + serial_spec.device.connectable.connected = False # Will connect on power on + + # Apply config + spec = vim.vm.ConfigSpec() + spec.deviceChange = [serial_spec] + task = vm.ReconfigVM_Task(spec=spec) + self.conn.wait_for_task(task) + + # Get ESXi host info for connection string + host = vm.runtime.host + host_ip = host.name if host else self.conn.settings.vcenter_host + + return { + "vm_name": name, + "service_uri": service_uri, + "connection_string": f"{protocol}://{host_ip}:{port}", + "protocol": protocol, + "port": port, + "direction": direction, + "yield_on_poll": yield_on_poll, + "operation": "updated" if existing_port else "created", + } + + @mcp_tool( + name="connect_serial_port", + description="Connect or disconnect an existing serial port on a VM", + annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True), + ) + def connect_serial_port(self, name: str, connected: bool = True) -> dict[str, Any]: + """Connect or disconnect serial port. + + Args: + name: VM name + connected: True to connect, False to disconnect. Default: True + + Returns: + Dict with result + """ + vm = self.conn.find_vm(name) + if not vm: + raise ValueError(f"VM '{name}' not found") + + serial_port = self._get_serial_port(vm) + if not serial_port: + raise ValueError(f"No network serial port configured on VM '{name}'") + + # Build edit spec + serial_spec = vim.vm.device.VirtualDeviceSpec() + serial_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.edit + serial_spec.device = serial_port + serial_spec.device.connectable.connected = connected + + spec = vim.vm.ConfigSpec() + spec.deviceChange = [serial_spec] + task = vm.ReconfigVM_Task(spec=spec) + self.conn.wait_for_task(task) + + return { + "vm_name": name, + "connected": connected, + "service_uri": serial_port.backing.serviceURI if serial_port.backing else None, + } + + @mcp_tool( + name="clear_serial_port", + description="Reset serial port by disconnecting and reconnecting (clears stuck connections)", + annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True), + ) + def clear_serial_port(self, name: str) -> dict[str, Any]: + """Clear serial port by cycling connection state. + + Useful for clearing stuck or stale connections. + + Args: + name: VM name + + Returns: + Dict with result + """ + vm = self.conn.find_vm(name) + if not vm: + raise ValueError(f"VM '{name}' not found") + + serial_port = self._get_serial_port(vm) + if not serial_port: + raise ValueError(f"No network serial port configured on VM '{name}'") + + # Disconnect + self.connect_serial_port(name, connected=False) + time.sleep(1) + + # Reconnect + self.connect_serial_port(name, connected=True) + + return { + "vm_name": name, + "status": "cleared", + "service_uri": serial_port.backing.serviceURI if serial_port.backing else None, + "message": "Serial port disconnected and reconnected", + } + + @mcp_tool( + name="remove_serial_port", + description="Remove the network serial port from a VM. VM must be powered off.", + annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True), + ) + def remove_serial_port(self, name: str) -> str: + """Remove serial port from VM. + + Args: + name: VM name + + Returns: + Success message + """ + vm = self.conn.find_vm(name) + if not vm: + raise ValueError(f"VM '{name}' not found") + + # Check VM is powered off + if vm.runtime.powerState != vim.VirtualMachine.PowerState.poweredOff: + raise ValueError(f"VM '{name}' must be powered off to remove serial port") + + serial_port = self._get_serial_port(vm) + if not serial_port: + return f"No network serial port configured on VM '{name}'" + + # Build remove spec + serial_spec = vim.vm.device.VirtualDeviceSpec() + serial_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.remove + serial_spec.device = serial_port + + spec = vim.vm.ConfigSpec() + spec.deviceChange = [serial_spec] + task = vm.ReconfigVM_Task(spec=spec) + self.conn.wait_for_task(task) + + return f"Serial port removed from VM '{name}'" diff --git a/src/esxi_mcp_server/server.py b/src/esxi_mcp_server/server.py index 2b70815..97b1022 100644 --- a/src/esxi_mcp_server/server.py +++ b/src/esxi_mcp_server/server.py @@ -9,6 +9,7 @@ from fastmcp import FastMCP from esxi_mcp_server.config import Settings, get_settings from esxi_mcp_server.connection import VMwareConnection from esxi_mcp_server.mixins import ( + ConsoleMixin, DiskManagementMixin, GuestOpsMixin, HostManagementMixin, @@ -17,6 +18,7 @@ from esxi_mcp_server.mixins import ( OVFManagementMixin, PowerOpsMixin, ResourcesMixin, + SerialPortMixin, SnapshotsMixin, VCenterOpsMixin, VMLifecycleMixin, @@ -78,6 +80,8 @@ def create_server(settings: Settings | None = None) -> FastMCP: OVFManagementMixin(conn), HostManagementMixin(conn), VCenterOpsMixin(conn), + ConsoleMixin(conn), + SerialPortMixin(conn), ] tool_count = 0