# Directory Structure ``` ├── .gitignore ├── docker-compose.yml ├── Dockerfile ├── LICENSE ├── proxmox-config │ └── config.example.json ├── pyproject.toml ├── README.md ├── requirements-dev.in ├── requirements.in ├── setup.py ├── src │ └── proxmox_mcp │ ├── __init__.py │ ├── config │ │ ├── __init__.py │ │ ├── loader.py │ │ └── models.py │ ├── core │ │ ├── __init__.py │ │ ├── logging.py │ │ └── proxmox.py │ ├── formatting │ │ ├── __init__.py │ │ ├── colors.py │ │ ├── components.py │ │ ├── formatters.py │ │ ├── templates.py │ │ └── theme.py │ ├── server.py │ ├── tools │ │ ├── __init__.py │ │ ├── base.py │ │ ├── cluster.py │ │ ├── console │ │ │ ├── __init__.py │ │ │ └── manager.py │ │ ├── containers.py │ │ ├── definitions.py │ │ ├── node.py │ │ ├── storage.py │ │ └── vm.py │ └── utils │ ├── __init__.py │ ├── auth.py │ └── logging.py ├── start_openapi.sh ├── start_server.sh ├── test_scripts │ ├── README.md │ ├── test_common.py │ ├── test_create_vm.py │ ├── test_openapi.py │ ├── test_vm_power.py │ └── test_vm_start.py └── tests ├── __init__.py ├── test_server.py └── test_vm_console.py ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Python __pycache__/ *.py[cod] *$py.class *.so .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # Virtual Environment .env .venv env/ venv/ ENV/ # IDE .idea/ .vscode/ *.swp *.swo .project .pydevproject .settings/ # Logs *.log logs/ # Test coverage .coverage htmlcov/ .tox/ .nox/ .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # UV .uv/ # Local configuration config/config.json proxmox-config/config.json **/config.json ``` -------------------------------------------------------------------------------- /test_scripts/README.md: -------------------------------------------------------------------------------- ```markdown # ProxmoxMCP Test Scripts This folder contains various test scripts and demo programs for the ProxmoxMCP project. ## 📁 File Description ### 🔧 VM Management Tests - **`test_vm_power.py`** - VM power management functionality test - Test VM start, stop, restart and other operations - Check VM status and available operations - **`test_vm_start.py`** - VM startup functionality specific test - Dedicated test for VM startup functionality - Suitable for single VM startup testing - **`test_create_vm.py`** - VM creation functionality test - Test complete workflow of creating new virtual machines - Verify 1 CPU core + 2GB RAM + 10GB storage configuration ### 🌐 API Tests - **`test_openapi.py`** - OpenAPI service comprehensive test - Test all API endpoints - Include VM creation, power management and other functionalities - Verify integration with Open WebUI ## 🚀 Usage ### Environment Setup ```bash # Activate virtual environment source ../.venv/bin/activate # Set configuration path (if needed) export PROXMOX_MCP_CONFIG=../proxmox-config/config.json ``` ### Running Tests #### 1. Test VM Power Management ```bash python test_vm_power.py ``` #### 2. Test VM Creation ```bash python test_create_vm.py ``` #### 3. Test OpenAPI Service ```bash python test_openapi.py ``` #### 4. Test VM Startup ```bash python test_vm_start.py ``` ## 📋 Test Coverage ### ✅ Tested Features - [x] VM list retrieval - [x] VM status query - [x] VM power management (start/stop/restart/shutdown) - [x] VM creation (support custom CPU/memory/storage) - [x] Storage type auto-detection - [x] Disk format intelligent selection - [x] OpenAPI service integration - [x] Error handling verification ### 🎯 Test Scenarios - **Basic functionality**: Connection, authentication, basic operations - **VM lifecycle**: Create, start, stop, delete - **Storage compatibility**: LVM, filesystem storage - **API integration**: REST API calls and responses - **Error recovery**: Exception handling ## 🔗 Related Documentation - **Main project documentation**: [../README.md](../README.md) - **VM creation guide**: [../VM_CREATION_GUIDE.md](../VM_CREATION_GUIDE.md) - **OpenAPI deployment**: [../OPENAPI_DEPLOYMENT.md](../OPENAPI_DEPLOYMENT.md) - **Quick deployment**: [../QUICK_DEPLOY_8811.md](../QUICK_DEPLOY_8811.md) ## 📊 Test Results Examples ### Success Cases ``` ✅ VM 995: Created successfully (local-lvm, raw) ✅ VM 996: Created successfully (vm-storage, raw) ✅ VM 998: Created successfully (local-lvm, raw) ✅ VM 999: Created successfully (local-lvm, raw) ``` ### API Endpoint Verification ``` ✅ get_nodes: 200 - 134 chars ✅ get_vms: 200 - 1843 chars ✅ create_vm: 200 - VM created successfully ✅ start_vm: 200 - VM started successfully ``` ## 🛠️ Troubleshooting If tests fail, please check: 1. **Configuration file**: Whether `../proxmox-config/config.json` is correct 2. **Network connection**: Whether Proxmox server is reachable 3. **Authentication info**: Whether API token is valid 4. **Service status**: Whether OpenAPI service is running on port 8811 ## 📝 Contributing Guidelines When adding new tests, please: 1. Use descriptive filenames (e.g., `test_function_name.py`) 2. Include detailed docstrings 3. Add appropriate error handling 4. Update this README file --- **Last Updated**: December 2024 **Maintainer**: ProxmoxMCP Development Team ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # ProxmoxMCP-Plus - Enhanced Proxmox MCP Server An enhanced Python-based Model Context Protocol (MCP) server for interacting with Proxmox virtualization platforms. This project is built upon **[canvrno/ProxmoxMCP](https://github.com/canvrno/ProxmoxMCP)** with numerous new features and improvements, providing complete OpenAPI integration and more powerful virtualization management capabilities. ## Acknowledgments This project is built upon the excellent open-source project [ProxmoxMCP](https://github.com/canvrno/ProxmoxMCP) by [@canvrno](https://github.com/canvrno). Thanks to the original author for providing the foundational framework and creative inspiration! ## 🆕 New Features and Improvements ### Major enhancements compared to the original version: - ✨ **Complete VM Lifecycle Management** - Brand new `create_vm` tool - Support for creating virtual machines with custom configurations - New `delete_vm` tool - Safe VM deletion (with force deletion option) - Enhanced intelligent storage type detection (LVM/file-based) - 🔧 **Extended Power Management Features** - `start_vm` - Start virtual machines - `stop_vm` - Force stop virtual machines - `shutdown_vm` - Graceful shutdown - `reset_vm` - Restart virtual machines - 🐳 **New Container Support** - `get_containers` - List all LXC containers and their status - `start_container` - Start LXC container - `stop_container` - Stop LXC container - `restart_container` - Restart LXC container (forcefully/gracefully) - `update_container_resources` - Adjust container CPU, memory, swap, or extend disk - 📊 **Enhanced Monitoring and Display** - Improved storage pool status monitoring - More detailed cluster health status checks - Rich output formatting and themes - 🌐 **Complete OpenAPI Integration** - 11 complete REST API endpoints - Production-ready Docker deployment - Perfect Open WebUI integration - Natural language VM creation support - 🛡️ **Production-grade Security and Stability** - Enhanced error handling mechanisms - Comprehensive parameter validation - Production-level logging - Complete unit test coverage ## Built With - [Cline](https://github.com/cline/cline) - Autonomous coding agent - Go faster with Cline - [Proxmoxer](https://github.com/proxmoxer/proxmoxer) - Python wrapper for Proxmox API - [MCP SDK](https://github.com/modelcontextprotocol/sdk) - Model Context Protocol SDK - [Pydantic](https://docs.pydantic.dev/) - Data validation using Python type annotations ## Features - 🤖 Full integration with Cline and Open WebUI - 🛠️ Built with the official MCP SDK - 🔒 Secure token-based authentication with Proxmox - 🖥️ Complete VM lifecycle management (create, start, stop, reset, shutdown, delete) - 💻 VM console command execution - 🐳 LXC container management support - 🗃️ Intelligent storage type detection (LVM/file-based) - 📝 Configurable logging system - ✅ Type-safe implementation with Pydantic - 🎨 Rich output formatting with customizable themes - 🌐 OpenAPI REST endpoints for integration - 📡 11 fully functional API endpoints ## Installation ### Prerequisites - UV package manager (recommended) - Python 3.9 or higher - Git - Access to a Proxmox server with API token credentials Before starting, ensure you have: - [ ] Proxmox server hostname or IP - [ ] Proxmox API token (see [API Token Setup](#proxmox-api-token-setup)) - [ ] UV installed (`pip install uv`) ### Option 1: Quick Install (Recommended) 1. Clone and set up environment: ```bash # Clone repository git clone https://github.com/RekklesNA/ProxmoxMCP-Plus.git cd ProxmoxMCP-Plus # Create and activate virtual environment uv venv source .venv/bin/activate # Linux/macOS # OR .\.venv\Scripts\Activate.ps1 # Windows ``` 2. Install dependencies: ```bash # Install with development dependencies uv pip install -e ".[dev]" ``` 3. Create configuration: ```bash # Create config directory and copy template mkdir -p proxmox-config cp proxmox-config/config.example.json proxmox-config/config.json ``` 4. Edit `proxmox-config/config.json`: ```json { "proxmox": { "host": "PROXMOX_HOST", # Required: Your Proxmox server address "port": 8006, # Optional: Default is 8006 "verify_ssl": false, # Optional: Set false for self-signed certs "service": "PVE" # Optional: Default is PVE }, "auth": { "user": "USER@pve", # Required: Your Proxmox username "token_name": "TOKEN_NAME", # Required: API token ID "token_value": "TOKEN_VALUE" # Required: API token value }, "logging": { "level": "INFO", # Optional: DEBUG for more detail "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", "file": "proxmox_mcp.log" # Optional: Log to file } } ``` ### Verifying Installation 1. Check Python environment: ```bash python -c "import proxmox_mcp; print('Installation OK')" ``` 2. Run the tests: ```bash pytest ``` 3. Verify configuration: ```bash # Linux/macOS PROXMOX_MCP_CONFIG="proxmox-config/config.json" python -m proxmox_mcp.server # Windows (PowerShell) $env:PROXMOX_MCP_CONFIG="proxmox-config\config.json"; python -m proxmox_mcp.server ``` ## Configuration ### Proxmox API Token Setup 1. Log into your Proxmox web interface 2. Navigate to Datacenter -> Permissions -> API Tokens 3. Create a new API token: - Select a user (e.g., root@pam) - Enter a token ID (e.g., "mcp-token") - Uncheck "Privilege Separation" if you want full access - Save and copy both the token ID and secret ## Running the Server ### Development Mode For testing and development: ```bash # Activate virtual environment first source .venv/bin/activate # Linux/macOS # OR .\.venv\Scripts\Activate.ps1 # Windows # Run the server python -m proxmox_mcp.server ``` ### OpenAPI Deployment (Production Ready) Deploy ProxmoxMCP Plus as standard OpenAPI REST endpoints for integration with Open WebUI and other applications. #### Quick OpenAPI Start ```bash # Install mcpo (MCP-to-OpenAPI proxy) pip install mcpo # Start OpenAPI service on port 8811 ./start_openapi.sh ``` #### Docker Deployment ```bash # Build and run with Docker docker build -t proxmox-mcp-api . docker run -d --name proxmox-mcp-api -p 8811:8811 \ -v $(pwd)/proxmox-config:/app/proxmox-config proxmox-mcp-api # Or use Docker Compose docker-compose up -d ``` #### Access OpenAPI Service Once deployed, access your service at: - **📖 API Documentation**: http://your-server:8811/docs - **🔧 OpenAPI Specification**: http://your-server:8811/openapi.json - **❤️ Health Check**: http://your-server:8811/health ### Cline Desktop Integration For Cline users, add this configuration to your MCP settings file (typically at `~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json`): ```json { "mcpServers": { "ProxmoxMCP-Plus": { "command": "/absolute/path/to/ProxmoxMCP-Plus/.venv/bin/python", "args": ["-m", "proxmox_mcp.server"], "cwd": "/absolute/path/to/ProxmoxMCP-Plus", "env": { "PYTHONPATH": "/absolute/path/to/ProxmoxMCP-Plus/src", "PROXMOX_MCP_CONFIG": "/absolute/path/to/ProxmoxMCP-Plus/proxmox-config/config.json", "PROXMOX_HOST": "your-proxmox-host", "PROXMOX_USER": "username@pve", "PROXMOX_TOKEN_NAME": "token-name", "PROXMOX_TOKEN_VALUE": "token-value", "PROXMOX_PORT": "8006", "PROXMOX_VERIFY_SSL": "false", "PROXMOX_SERVICE": "PVE", "LOG_LEVEL": "DEBUG" }, "disabled": false, "autoApprove": [] } } } ``` ## Available Tools & API Endpoints The server provides 11 comprehensive MCP tools and corresponding REST API endpoints: ### VM Management Tools #### create_vm Create a new virtual machine with specified resources. **Parameters:** - `node` (string, required): Name of the node - `vmid` (string, required): ID for the new VM - `name` (string, required): Name for the VM - `cpus` (integer, required): Number of CPU cores (1-32) - `memory` (integer, required): Memory in MB (512-131072) - `disk_size` (integer, required): Disk size in GB (5-1000) - `storage` (string, optional): Storage pool name - `ostype` (string, optional): OS type (default: l26) **API Endpoint:** ```http POST /create_vm Content-Type: application/json { "node": "pve", "vmid": "200", "name": "my-vm", "cpus": 1, "memory": 2048, "disk_size": 10 } ``` **Example Response:** ``` 🎉 VM 200 created successfully! 📋 VM Configuration: • Name: my-vm • Node: pve • VM ID: 200 • CPU Cores: 1 • Memory: 2048 MB (2.0 GB) • Disk: 10 GB (local-lvm, raw format) • Storage Type: lvmthin • Network: virtio (bridge=vmbr0) • QEMU Agent: Enabled 🔧 Task ID: UPID:pve:001AB729:0442E853:682FF380:qmcreate:200:root@pam!mcp ``` #### VM Power Management 🆕 **start_vm**: Start a virtual machine ```http POST /start_vm {"node": "pve", "vmid": "200"} ``` **stop_vm**: Force stop a virtual machine ```http POST /stop_vm {"node": "pve", "vmid": "200"} ``` **shutdown_vm**: Gracefully shutdown a virtual machine ```http POST /shutdown_vm {"node": "pve", "vmid": "200"} ``` **reset_vm**: Reset (restart) a virtual machine ```http POST /reset_vm {"node": "pve", "vmid": "200"} ``` **delete_vm** 🆕: Completely delete a virtual machine ```http POST /delete_vm {"node": "pve", "vmid": "200", "force": false} ``` ### 🆕 Container Management Tools #### get_containers 🆕 List all LXC containers across the cluster. **API Endpoint:** `POST /get_containers` **Example Response:** ``` 🐳 Containers 🐳 nginx-server (ID: 200) • Status: RUNNING • Node: pve • CPU Cores: 2 • Memory: 1.5 GB / 2.0 GB (75.0%) ``` ### Monitoring Tools #### get_nodes Lists all nodes in the Proxmox cluster. **API Endpoint:** `POST /get_nodes` **Example Response:** ``` 🖥️ Proxmox Nodes 🖥️ pve-compute-01 • Status: ONLINE • Uptime: ⏳ 156d 12h • CPU Cores: 64 • Memory: 186.5 GB / 512.0 GB (36.4%) ``` #### get_node_status Get detailed status of a specific node. **Parameters:** - `node` (string, required): Name of the node **API Endpoint:** `POST /get_node_status` #### get_vms List all VMs across the cluster. **API Endpoint:** `POST /get_vms` #### get_storage List available storage pools. **API Endpoint:** `POST /get_storage` #### get_cluster_status Get overall cluster status and health. **API Endpoint:** `POST /get_cluster_status` #### execute_vm_command Execute a command in a VM's console using QEMU Guest Agent. **Parameters:** - `node` (string, required): Name of the node where VM is running - `vmid` (string, required): ID of the VM - `command` (string, required): Command to execute **API Endpoint:** `POST /execute_vm_command` **Requirements:** - VM must be running - QEMU Guest Agent must be installed and running in the VM ## Open WebUI Integration ### Configure Open WebUI 1. Access your Open WebUI instance 2. Navigate to **Settings** → **Connections** → **OpenAPI** 3. Add new API configuration: ```json { "name": "Proxmox MCP API Plus", "base_url": "http://your-server:8811", "api_key": "", "description": "Enhanced Proxmox Virtualization Management API" } ``` ### Natural Language VM Creation Users can now request VMs using natural language: - **"Can you create a VM with 1 cpu core and 2 GB ram with 10GB of storage disk"** - **"Create a new VM for testing with minimal resources"** - **"I need a development server with 4 cores and 8GB RAM"** The AI assistant will automatically call the appropriate APIs and provide detailed feedback. ## Storage Type Support ### Intelligent Storage Detection ProxmoxMCP Plus automatically detects storage types and selects appropriate disk formats: #### LVM Storage (local-lvm, vm-storage) - ✅ Format: `raw` - ✅ High performance - ⚠️ No cloud-init image support #### File-based Storage (local, NFS, CIFS) - ✅ Format: `qcow2` - ✅ Cloud-init support - ✅ Flexible snapshot capabilities ## Project Structure ``` ProxmoxMCP-Plus/ ├── 📁 src/ # Source code │ └── proxmox_mcp/ │ ├── server.py # Main MCP server implementation │ ├── config/ # Configuration handling │ ├── core/ # Core functionality │ ├── formatting/ # Output formatting and themes │ ├── tools/ # Tool implementations │ │ ├── vm.py # VM management (create/power) 🆕 │ │ ├── container.py # Container management 🆕 │ │ └── console/ # VM console operations │ └── utils/ # Utilities (auth, logging) │ ├── 📁 tests/ # Unit test suite ├── 📁 test_scripts/ # Integration tests & demos │ ├── README.md # Test documentation │ ├── test_vm_power.py # VM power management tests 🆕 │ ├── test_vm_start.py # VM startup tests │ ├── test_create_vm.py # VM creation tests 🆕 │ └── test_openapi.py # OpenAPI service tests │ ├── 📁 proxmox-config/ # Configuration files │ └── config.json # Server configuration │ ├── 📄 Configuration Files │ ├── pyproject.toml # Project metadata │ ├── docker-compose.yml # Docker orchestration │ ├── Dockerfile # Docker image definition │ └── requirements.in # Dependencies │ ├── 📄 Scripts │ ├── start_server.sh # MCP server launcher │ └── start_openapi.sh # OpenAPI service launcher │ └── 📄 Documentation ├── README.md # This file ├── VM_CREATION_GUIDE.md # VM creation guide ├── OPENAPI_DEPLOYMENT.md # OpenAPI deployment └── LICENSE # MIT License ``` ## Testing ### Run Unit Tests ```bash pytest ``` ### Run Integration Tests ```bash cd test_scripts # Test VM power management python test_vm_power.py # Test VM creation python test_create_vm.py # Test OpenAPI service python test_openapi.py ``` ### API Testing with curl ```bash # Test node listing curl -X POST "http://your-server:8811/get_nodes" \ -H "Content-Type: application/json" \ -d "{}" # Test VM creation curl -X POST "http://your-server:8811/create_vm" \ -H "Content-Type: application/json" \ -d '{ "node": "pve", "vmid": "300", "name": "test-vm", "cpus": 1, "memory": 2048, "disk_size": 10 }' ``` ## Production Security ### API Key Authentication Set up secure API access: ```bash export PROXMOX_API_KEY="your-secure-api-key" export PROXMOX_MCP_CONFIG="/app/proxmox-config/config.json" ``` ### Nginx Reverse Proxy Example nginx configuration: ```nginx server { listen 80; server_name your-domain.com; location / { proxy_pass http://localhost:8811; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } } ``` ## Troubleshooting ### Common Issues 1. **Port already in use** ```bash netstat -tlnp | grep 8811 # Change port if needed mcpo --port 8812 -- ./start_server.sh ``` 2. **Configuration errors** ```bash # Verify config file cat proxmox-config/config.json ``` 3. **Connection issues** ```bash # Test Proxmox connectivity curl -k https://your-proxmox:8006/api2/json/version ``` ### View Logs ```bash # View service logs tail -f proxmox_mcp.log # Docker logs docker logs proxmox-mcp-api -f ``` ## Deployment Status ### ✅ Feature Completion: 100% - [x] VM Creation (user requirement: 1 CPU + 2GB RAM + 10GB storage) 🆕 - [x] VM Power Management (start VPN-Server ID:101) 🆕 - [x] VM Deletion Feature 🆕 - [x] Container Management (LXC) 🆕 - [x] Storage Compatibility (LVM/file-based) - [x] OpenAPI Integration (port 8811) - [x] Open WebUI Integration - [x] Error Handling & Validation - [x] Complete Documentation & Testing ### Production Ready! **ProxmoxMCP Plus is now fully ready for production use!** When users say **"Can you create a VM with 1 cpu core and 2 GB ram with 10GB of storage disk"**, the AI assistant can: 1. 📞 Call the `create_vm` API 2. 🔧 Automatically select appropriate storage and format 3. 🎯 Create VMs that match requirements 4. 📊 Return detailed configuration information 5. 💡 Provide next-step recommendations ## Development After activating your virtual environment: - Run tests: `pytest` - Format code: `black .` - Type checking: `mypy .` - Lint: `ruff .` ## License MIT License ## Special Thanks - Thanks to [@canvrno](https://github.com/canvrno) for the excellent foundational project [ProxmoxMCP](https://github.com/canvrno/ProxmoxMCP) - Thanks to the Proxmox community for providing the powerful virtualization platform - Thanks to all contributors and users for their support --- **Ready to Deploy!** 🎉 Your enhanced Proxmox MCP service with OpenAPI integration is ready for production use. ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/config/__init__.py: -------------------------------------------------------------------------------- ```python ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/core/__init__.py: -------------------------------------------------------------------------------- ```python ``` -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- ```python """ Test suite for the Proxmox MCP server. """ ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/tools/__init__.py: -------------------------------------------------------------------------------- ```python """ MCP tools for interacting with Proxmox hypervisors. """ __all__ = [] ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/utils/__init__.py: -------------------------------------------------------------------------------- ```python """ Utility functions and helpers for the Proxmox MCP server. """ __all__ = [] ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/tools/console/__init__.py: -------------------------------------------------------------------------------- ```python """ Console management package for Proxmox MCP. """ from .manager import VMConsoleManager __all__ = ['VMConsoleManager'] ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/__init__.py: -------------------------------------------------------------------------------- ```python """ Proxmox MCP Server - A Model Context Protocol server for interacting with Proxmox hypervisors. """ from .server import ProxmoxMCPServer __version__ = "0.1.0" __all__ = ["ProxmoxMCPServer"] ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/formatting/__init__.py: -------------------------------------------------------------------------------- ```python """ Proxmox MCP formatting package for styled output. """ from .theme import ProxmoxTheme from .colors import ProxmoxColors from .formatters import ProxmoxFormatters from .templates import ProxmoxTemplates from .components import ProxmoxComponents __all__ = [ 'ProxmoxTheme', 'ProxmoxColors', 'ProxmoxFormatters', 'ProxmoxTemplates', 'ProxmoxComponents' ] ``` -------------------------------------------------------------------------------- /proxmox-config/config.example.json: -------------------------------------------------------------------------------- ```json { "proxmox": { "host": "your-proxmox-host-ip", "port": 8006, "verify_ssl": false, "service": "PVE" }, "auth": { "user": "username@pve", "token_name": "your-token-name", "token_value": "your-token-value" }, "logging": { "level": "DEBUG", "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", "file": "proxmox_mcp.log" } } ``` -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- ```yaml version: '3.8' services: proxmox-mcp-api: build: . ports: - "8811:8811" volumes: - ./proxmox-config:/app/proxmox-config:ro environment: - PROXMOX_MCP_CONFIG=/app/proxmox-config/config.json - API_HOST=0.0.0.0 - API_PORT=8811 restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8811/health"] interval: 30s timeout: 10s retries: 3 networks: - proxmox-network networks: proxmox-network: driver: bridge ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile # Use Python 3.11 slim image as base FROM python:3.11-slim # Set working directory WORKDIR /app # Install system dependencies RUN apt-get update && apt-get install -y \ git \ curl \ && rm -rf /var/lib/apt/lists/* # Install Python dependencies RUN pip install mcpo uv # Copy project files COPY . . # Create virtual environment and install dependencies RUN uv venv && \ . .venv/bin/activate && \ uv pip install -e ".[dev]" # Expose port EXPOSE 8811 # Set environment variables ENV PROXMOX_MCP_CONFIG="/app/proxmox-config/config.json" ENV API_HOST="0.0.0.0" ENV API_PORT="8811" # Startup command CMD ["mcpo", "--host", "0.0.0.0", "--port", "8811", "--", \ "/bin/bash", "-c", "cd /app && source .venv/bin/activate && python -m proxmox_mcp.server"] ``` -------------------------------------------------------------------------------- /start_server.sh: -------------------------------------------------------------------------------- ```bash #!/bin/bash """ Proxmox MCP server startup script """ echo "🚀 Starting Proxmox MCP server..." echo "" # Check virtual environment if [ ! -d ".venv" ]; then echo "❌ Virtual environment does not exist, please run installation steps first" exit 1 fi # Activate virtual environment source .venv/bin/activate # Set environment variables export PROXMOX_MCP_CONFIG="proxmox-config/config.json" # Check configuration file if [ ! -f "$PROXMOX_MCP_CONFIG" ]; then echo "❌ Configuration file does not exist: $PROXMOX_MCP_CONFIG" echo "Please ensure the configuration file is properly set up" exit 1 fi echo "✅ Configuration file: $PROXMOX_MCP_CONFIG" echo "✅ Virtual environment activated" echo "" echo "🔍 Starting server..." echo "Press Ctrl+C to stop the server" echo "" # Start server python -m proxmox_mcp.server ``` -------------------------------------------------------------------------------- /test_scripts/test_vm_start.py: -------------------------------------------------------------------------------- ```python #!/usr/bin/env python3 """ Test VM startup functionality """ import os import sys def test_start_vm_101(): """Test starting VM 101 (VPN-Server)""" # Set configuration os.environ['PROXMOX_MCP_CONFIG'] = 'proxmox-config/config.json' try: from proxmox_mcp.config.loader import load_config from proxmox_mcp.core.proxmox import ProxmoxManager from proxmox_mcp.tools.vm import VMTools config = load_config('proxmox-config/config.json') manager = ProxmoxManager(config.proxmox, config.auth) api = manager.get_api() vm_tools = VMTools(api) print("🚀 Test starting VPN-Server (VM 101)") print("=" * 50) # Start VM 101 result = vm_tools.start_vm(node="pve", vmid="101") for content in result: print(content.text) return True except Exception as e: print(f"❌ Start failed: {e}") return False if __name__ == "__main__": print("🔍 Test VM startup functionality") print("=" * 50) success = test_start_vm_101() if success: print("\n✅ Test completed") else: print("\n❌ Test failed") sys.exit(1) ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/utils/logging.py: -------------------------------------------------------------------------------- ```python """ Logging configuration for the Proxmox MCP server. """ import logging import sys from typing import Optional def setup_logging( level: str = "INFO", format_str: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s", log_file: Optional[str] = None, ) -> logging.Logger: """ Configure logging for the Proxmox MCP server. Args: level: The logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) format_str: The format string for log messages log_file: Optional file path to write logs to Returns: logging.Logger: Configured logger instance """ # Create logger logger = logging.getLogger("proxmox-mcp") logger.setLevel(getattr(logging, level.upper())) # Create handlers handlers = [] # Console handler console_handler = logging.StreamHandler(sys.stderr) console_handler.setLevel(getattr(logging, level.upper())) handlers.append(console_handler) # File handler if log_file is specified if log_file: file_handler = logging.FileHandler(log_file) file_handler.setLevel(getattr(logging, level.upper())) handlers.append(file_handler) # Create formatter formatter = logging.Formatter(format_str) # Add formatter to handlers and handlers to logger for handler in handlers: handler.setFormatter(formatter) logger.addHandler(handler) return logger ``` -------------------------------------------------------------------------------- /start_openapi.sh: -------------------------------------------------------------------------------- ```bash #!/bin/bash # Proxmox MCP OpenAPI startup script # Expose MCP server as OpenAPI REST endpoints through mcpo proxy # Configurable deployment address # Get host and port from environment variables or use defaults HOST=${OPENAPI_HOST:-"localhost"} PORT=${OPENAPI_PORT:-"8811"} echo "🛰️ Starting Proxmox MCP OpenAPI server..." echo "" # Check if mcpo is installed if ! command -v mcpo &> /dev/null; then echo "❌ mcpo not installed, installing..." pip install mcpo fi # Check virtual environment if [ ! -d ".venv" ]; then echo "❌ Virtual environment does not exist, please run installation steps first" exit 1 fi # Check configuration file if [ ! -f "proxmox-config/config.json" ]; then echo "❌ Configuration file does not exist: proxmox-config/config.json" echo "Please ensure the configuration file is properly set up" exit 1 fi echo "✅ Configuration file: proxmox-config/config.json" echo "✅ mcpo proxy ready" echo "" echo "🚀 Starting OpenAPI proxy server..." echo "🌐 Service address: http://${HOST}:${PORT}" echo "📖 API documentation: http://${HOST}:${PORT}/docs" echo "🔧 OpenAPI specification: http://${HOST}:${PORT}/openapi.json" echo "❤️ Health check: http://${HOST}:${PORT}/health" echo "" echo "Press Ctrl+C to stop the server" echo "" echo "💡 To use a different host/port, set environment variables:" echo " export OPENAPI_HOST=your-host" echo " export OPENAPI_PORT=your-port" echo "" # Set environment variables export PROXMOX_MCP_CONFIG="$(pwd)/proxmox-config/config.json" # Start mcpo proxy server, bind to all interfaces on specified port mcpo --host 0.0.0.0 --port ${PORT} -- bash -c "cd $(pwd) && source .venv/bin/activate && python -m proxmox_mcp.server" ``` -------------------------------------------------------------------------------- /test_scripts/test_common.py: -------------------------------------------------------------------------------- ```python #!/usr/bin/env python3 """ Common configuration helper for test scripts """ import os import sys from pathlib import Path def setup_test_environment(): """Set up test environment configuration paths""" # Get current script directory current_dir = Path(__file__).parent # Calculate project root directory project_root = current_dir.parent # Set configuration file path config_path = project_root / "proxmox-config" / "config.json" # Set source code path src_path = project_root / "src" # Ensure paths exist if not config_path.exists(): raise FileNotFoundError(f"Configuration file does not exist: {config_path}") if not src_path.exists(): raise FileNotFoundError(f"Source code directory does not exist: {src_path}") # Set environment variables os.environ['PROXMOX_MCP_CONFIG'] = str(config_path) # Add source code path to Python path if str(src_path) not in sys.path: sys.path.insert(0, str(src_path)) return str(config_path) def get_test_tools(): """Get test tools classes""" # Ensure environment is set up config_path = setup_test_environment() try: from proxmox_mcp.config.loader import load_config from proxmox_mcp.core.proxmox import ProxmoxManager from proxmox_mcp.tools.vm import VMTools config = load_config(config_path) manager = ProxmoxManager(config.proxmox, config.auth) api = manager.get_api() vm_tools = VMTools(api) return { 'config': config, 'manager': manager, 'api': api, 'vm_tools': vm_tools } except Exception as e: print(f"❌ Failed to initialize test tools: {e}") raise def print_test_header(title): """Print test title""" print(f"🔍 {title}") print("=" * len(f"🔍 {title}")) def print_test_result(success, message=""): """Print test result""" if success: print(f"\n✅ Test completed {message}") else: print(f"\n❌ Test failed {message}") sys.exit(1) ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml [build-system] requires = ["setuptools>=61.0.0", "wheel"] build-backend = "setuptools.build_meta" [project] name = "proxmox-mcp" version = "0.1.0" description = "A Model Context Protocol server for interacting with Proxmox hypervisors" requires-python = ">=3.11" authors = [ {name = "Kevin", email = "[email protected]"} ] readme = "README.md" license = {text = "MIT"} keywords = ["proxmox", "mcp", "virtualization", "cline", "qemu", "lxc"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Systems Administration", ] dependencies = [ "mcp @ git+https://github.com/modelcontextprotocol/python-sdk.git", "proxmoxer>=2.0.1,<3.0.0", "requests>=2.31.0,<3.0.0", "pydantic>=2.0.0,<3.0.0", "fastapi>=0.115.0", "uvicorn[standard]>=0.30.0", "mcpo>=0.0.17", ] [project.optional-dependencies] dev = [ "pytest>=7.0.0,<8.0.0", "black>=23.0.0,<24.0.0", "mypy>=1.0.0,<2.0.0", "pytest-asyncio>=0.21.0,<0.22.0", "ruff>=0.1.0,<0.2.0", "types-requests>=2.31.0,<3.0.0", ] [project.urls] Homepage = "https://github.com/yourusername/proxmox-mcp" Documentation = "https://github.com/yourusername/proxmox-mcp#readme" Repository = "https://github.com/yourusername/proxmox-mcp.git" Issues = "https://github.com/yourusername/proxmox-mcp/issues" [project.scripts] proxmox-mcp = "proxmox_mcp.server:main" [tool.pytest.ini_options] asyncio_mode = "strict" testpaths = ["tests"] python_files = ["test_*.py"] addopts = "-v" [tool.mypy] python_version = "3.9" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true disallow_incomplete_defs = true check_untyped_defs = true disallow_untyped_decorators = true no_implicit_optional = true warn_redundant_casts = true warn_unused_ignores = true warn_no_return = true warn_unreachable = true [tool.ruff] select = ["E", "F", "B", "I"] ignore = [] line-length = 100 target-version = "py39" ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/utils/auth.py: -------------------------------------------------------------------------------- ```python """ Authentication utilities for the Proxmox MCP server. """ import os from typing import Dict, Optional, Tuple from pydantic import BaseModel class ProxmoxAuth(BaseModel): """Proxmox authentication configuration.""" user: str token_name: str token_value: str def load_auth_from_env() -> ProxmoxAuth: """ Load Proxmox authentication details from environment variables. Environment Variables: PROXMOX_USER: Username with realm (e.g., 'root@pam' or 'user@pve') PROXMOX_TOKEN_NAME: API token name PROXMOX_TOKEN_VALUE: API token value Returns: ProxmoxAuth: Authentication configuration Raises: ValueError: If required environment variables are missing """ user = os.getenv("PROXMOX_USER") token_name = os.getenv("PROXMOX_TOKEN_NAME") token_value = os.getenv("PROXMOX_TOKEN_VALUE") if not all([user, token_name, token_value]): missing = [] if not user: missing.append("PROXMOX_USER") if not token_name: missing.append("PROXMOX_TOKEN_NAME") if not token_value: missing.append("PROXMOX_TOKEN_VALUE") raise ValueError(f"Missing required environment variables: {', '.join(missing)}") return ProxmoxAuth( user=user, token_name=token_name, token_value=token_value, ) def parse_user(user: str) -> Tuple[str, str]: """ Parse a Proxmox user string into username and realm. Args: user: User string in format 'username@realm' Returns: Tuple[str, str]: (username, realm) Raises: ValueError: If user string is not in correct format """ try: username, realm = user.split("@") return username, realm except ValueError: raise ValueError( "Invalid user format. Expected 'username@realm' (e.g., 'root@pam' or 'user@pve')" ) def get_auth_dict(auth: ProxmoxAuth) -> Dict[str, str]: """ Convert ProxmoxAuth model to dictionary for Proxmoxer API. Args: auth: ProxmoxAuth configuration Returns: Dict[str, str]: Authentication dictionary for Proxmoxer """ return { "user": auth.user, "token_name": auth.token_name, "token_value": auth.token_value, } ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/config/loader.py: -------------------------------------------------------------------------------- ```python """ Configuration loading utilities for the Proxmox MCP server. This module handles loading and validation of server configuration: - JSON configuration file loading - Environment variable handling - Configuration validation using Pydantic models - Error handling for invalid configurations The module ensures that all required configuration is present and valid before the server starts operation. """ import json import os from typing import Optional from .models import Config def load_config(config_path: Optional[str] = None) -> Config: """Load and validate configuration from JSON file. Performs the following steps: 1. Verifies config path is provided 2. Loads JSON configuration file 3. Validates required fields are present 4. Converts to typed Config object using Pydantic Configuration must include: - Proxmox connection settings (host, port, etc.) - Authentication credentials (user, token) - Logging configuration Args: config_path: Path to the JSON configuration file If not provided, raises ValueError Returns: Config object containing validated configuration: { "proxmox": { "host": "proxmox-host", "port": 8006, ... }, "auth": { "user": "username", "token_name": "token-name", ... }, "logging": { "level": "INFO", ... } } Raises: ValueError: If: - Config path is not provided - JSON is invalid - Required fields are missing - Field values are invalid """ if not config_path: raise ValueError("PROXMOX_MCP_CONFIG environment variable must be set") try: with open(config_path) as f: config_data = json.load(f) if not config_data.get('proxmox', {}).get('host'): raise ValueError("Proxmox host cannot be empty") return Config(**config_data) except json.JSONDecodeError as e: raise ValueError(f"Invalid JSON in config file: {e}") except Exception as e: raise ValueError(f"Failed to load config: {e}") ``` -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- ```python """ Setup script for the Proxmox MCP server. This file is maintained for compatibility with older tools. For modern Python packaging, see pyproject.toml. """ from setuptools import setup, find_packages # Metadata and dependencies are primarily managed in pyproject.toml # This file exists for compatibility with tools that don't support pyproject.toml setup( name="proxmox-mcp", version="0.1.0", packages=find_packages(where="src"), package_dir={"": "src"}, python_requires=">=3.9", install_requires=[ "mcp @ git+https://github.com/modelcontextprotocol/python-sdk.git", "proxmoxer>=2.0.1,<3.0.0", "requests>=2.31.0,<3.0.0", "pydantic>=2.0.0,<3.0.0", ], extras_require={ "dev": [ "pytest>=7.0.0,<8.0.0", "black>=23.0.0,<24.0.0", "mypy>=1.0.0,<2.0.0", "pytest-asyncio>=0.21.0,<0.22.0", "ruff>=0.1.0,<0.2.0", "types-requests>=2.31.0,<3.0.0", ], }, entry_points={ "console_scripts": [ "proxmox-mcp=proxmox_mcp.server:main", ], }, author="Kevin", author_email="[email protected]", description="A Model Context Protocol server for interacting with Proxmox hypervisors", long_description=open("README.md").read(), long_description_content_type="text/markdown", license="MIT", keywords=["proxmox", "mcp", "virtualization", "cline", "qemu", "lxc"], classifiers=[ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Systems Administration", "Topic :: System :: Virtualization", ], project_urls={ "Homepage": "https://github.com/yourusername/proxmox-mcp", "Documentation": "https://github.com/yourusername/proxmox-mcp#readme", "Repository": "https://github.com/yourusername/proxmox-mcp.git", "Issues": "https://github.com/yourusername/proxmox-mcp/issues", }, ) ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/formatting/theme.py: -------------------------------------------------------------------------------- ```python """ Theme configuration for Proxmox MCP output styling. """ class ProxmoxTheme: """Theme configuration for Proxmox MCP output.""" # Feature flags USE_EMOJI = True USE_COLORS = True # Status indicators with emojis STATUS = { 'online': '🟢', 'offline': '🔴', 'running': '▶️', 'stopped': '⏹️', 'unknown': '❓', 'pending': '⏳', 'error': '❌', 'warning': '⚠️', } # Resource type indicators RESOURCES = { 'node': '🖥️', 'vm': '🗃️', 'container': '📦', 'storage': '💾', 'cpu': '⚡', 'memory': '🧠', 'network': '🌐', 'disk': '💿', 'backup': '📼', 'snapshot': '📸', 'template': '📋', 'pool': '🏊', } # Action and operation indicators ACTIONS = { 'success': '✅', 'error': '❌', 'warning': '⚠️', 'info': 'ℹ️', 'command': '🔧', 'start': '▶️', 'stop': '⏹️', 'restart': '🔄', 'delete': '🗑️', 'edit': '✏️', 'create': '➕', 'migrate': '➡️', 'clone': '📑', 'lock': '🔒', 'unlock': '🔓', } # Section and grouping indicators SECTIONS = { 'header': '📌', 'details': '📝', 'statistics': '📊', 'configuration': '⚙️', 'logs': '📜', 'tasks': '📋', 'users': '👥', 'permissions': '🔑', } # Measurement and metric indicators METRICS = { 'percentage': '%', 'temperature': '🌡️', 'uptime': '⏳', 'bandwidth': '📶', 'latency': '⚡', } @classmethod def get_status_emoji(cls, status: str) -> str: """Get emoji for a status value with fallback.""" status = status.lower() return cls.STATUS.get(status, cls.STATUS['unknown']) @classmethod def get_resource_emoji(cls, resource: str) -> str: """Get emoji for a resource type with fallback.""" resource = resource.lower() return cls.RESOURCES.get(resource, '📦') @classmethod def get_action_emoji(cls, action: str) -> str: """Get emoji for an action with fallback.""" action = action.lower() return cls.ACTIONS.get(action, cls.ACTIONS['info']) @classmethod def get_section_emoji(cls, section: str) -> str: """Get emoji for a section type with fallback.""" section = section.lower() return cls.SECTIONS.get(section, cls.SECTIONS['details']) ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/core/logging.py: -------------------------------------------------------------------------------- ```python """ Logging configuration for the Proxmox MCP server. This module handles logging setup and configuration: - File and console logging handlers - Log level management - Format customization - Handler lifecycle management The logging system supports: - Configurable log levels - File-based logging with path resolution - Console logging for errors - Custom format strings - Multiple handler management """ import logging import os from typing import Optional from ..config.models import LoggingConfig def setup_logging(config: LoggingConfig) -> logging.Logger: """Configure and initialize logging system. Sets up a comprehensive logging system with: - File logging (if configured): * Handles relative/absolute paths * Uses configured log level * Applies custom format - Console logging: * Always enabled for errors * Ensures critical issues are visible - Handler Management: * Removes existing handlers * Configures new handlers * Sets up formatters Args: config: Logging configuration containing: - Log level (e.g., "INFO", "DEBUG") - Format string - Optional log file path Returns: Configured logger instance for "proxmox-mcp" with appropriate handlers and formatting Example config: { "level": "INFO", "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", "file": "/path/to/log/file.log" # Optional } """ # Convert relative path to absolute log_file = config.file if log_file and not os.path.isabs(log_file): log_file = os.path.join(os.getcwd(), log_file) # Create handlers handlers = [] if log_file: file_handler = logging.FileHandler(log_file) file_handler.setLevel(getattr(logging, config.level.upper())) handlers.append(file_handler) # Console handler for errors only console_handler = logging.StreamHandler() console_handler.setLevel(logging.ERROR) handlers.append(console_handler) # Configure formatters formatter = logging.Formatter(config.format) for handler in handlers: handler.setFormatter(formatter) # Configure root logger root_logger = logging.getLogger() root_logger.setLevel(getattr(logging, config.level.upper())) # Remove any existing handlers for handler in root_logger.handlers[:]: root_logger.removeHandler(handler) # Add new handlers for handler in handlers: root_logger.addHandler(handler) # Create and return server logger logger = logging.getLogger("proxmox-mcp") return logger ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/tools/cluster.py: -------------------------------------------------------------------------------- ```python """ Cluster-related tools for Proxmox MCP. This module provides tools for monitoring and managing Proxmox clusters: - Retrieving overall cluster health status - Monitoring quorum status and node count - Tracking cluster resources and configuration - Checking cluster-wide service availability The tools provide essential information for maintaining cluster health and ensuring proper operation. """ from typing import List from mcp.types import TextContent as Content from .base import ProxmoxTool from .definitions import GET_CLUSTER_STATUS_DESC class ClusterTools(ProxmoxTool): """Tools for managing Proxmox cluster. Provides functionality for: - Monitoring cluster health and status - Tracking quorum and node membership - Managing cluster-wide resources - Verifying cluster configuration Essential for maintaining cluster health and ensuring proper operation of the Proxmox environment. """ def get_cluster_status(self) -> List[Content]: """Get overall Proxmox cluster health and configuration status. Retrieves comprehensive cluster information including: - Cluster name and identity - Quorum status (essential for cluster operations) - Active node count and health - Resource distribution and status This information is critical for: - Ensuring cluster stability - Monitoring node membership - Verifying resource availability - Detecting potential issues Returns: List of Content objects containing formatted cluster status: { "name": "cluster-name", "quorum": true/false, "nodes": count, "resources": [ { "type": "resource-type", "status": "status" } ] } Raises: RuntimeError: If cluster status query fails due to: - Network connectivity issues - Authentication problems - API endpoint failures """ try: result = self.proxmox.cluster.status.get() first_item = result[0] if result and len(result) > 0 else {} status = { "name": first_item.get("name") if first_item else None, "quorum": first_item.get("quorate") if first_item else None, "nodes": len([node for node in result if node.get("type") == "node"]) if result else 0, "resources": [res for res in result if res.get("type") == "resource"] if result else [] } return self._format_response(status, "cluster") except Exception as e: self._handle_error("get cluster status", e) ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/config/models.py: -------------------------------------------------------------------------------- ```python """ Configuration models for the Proxmox MCP server. This module defines Pydantic models for configuration validation: - Proxmox connection settings - Authentication credentials - Logging configuration - Tool-specific parameter models The models provide: - Type validation - Default values - Field descriptions - Required vs optional field handling """ from typing import Optional, Annotated from pydantic import BaseModel, Field class NodeStatus(BaseModel): """Model for node status query parameters. Validates and documents the required parameters for querying a specific node's status in the cluster. """ node: Annotated[str, Field(description="Name/ID of node to query (e.g. 'pve1', 'proxmox-node2')")] class VMCommand(BaseModel): """Model for VM command execution parameters. Validates and documents the required parameters for executing commands within a VM via QEMU guest agent. """ node: Annotated[str, Field(description="Host node name (e.g. 'pve1', 'proxmox-node2')")] vmid: Annotated[str, Field(description="VM ID number (e.g. '100', '101')")] command: Annotated[str, Field(description="Shell command to run (e.g. 'uname -a', 'systemctl status nginx')")] class ProxmoxConfig(BaseModel): """Model for Proxmox connection configuration. Defines the required and optional parameters for establishing a connection to the Proxmox API server. Provides sensible defaults for optional parameters. """ host: str # Required: Proxmox host address port: int = 8006 # Optional: API port (default: 8006) verify_ssl: bool = True # Optional: SSL verification (default: True) service: str = "PVE" # Optional: Service type (default: PVE) class AuthConfig(BaseModel): """Model for Proxmox authentication configuration. Defines the required parameters for API authentication using token-based authentication. All fields are required to ensure secure API access. """ user: str # Required: Username (e.g., 'root@pam') token_name: str # Required: API token name token_value: str # Required: API token secret class LoggingConfig(BaseModel): """Model for logging configuration. Defines logging parameters with sensible defaults. Supports both file and console logging with customizable format and log levels. """ level: str = "INFO" # Optional: Log level (default: INFO) format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" # Optional: Log format file: Optional[str] = None # Optional: Log file path (default: None for console logging) class Config(BaseModel): """Root configuration model. Combines all configuration models into a single validated configuration object. All sections are required to ensure proper server operation. """ proxmox: ProxmoxConfig # Required: Proxmox connection settings auth: AuthConfig # Required: Authentication credentials logging: LoggingConfig # Required: Logging configuration ``` -------------------------------------------------------------------------------- /tests/test_vm_console.py: -------------------------------------------------------------------------------- ```python """ Tests for VM console operations. """ import pytest from unittest.mock import Mock, patch from proxmox_mcp.tools.console import VMConsoleManager @pytest.fixture def mock_proxmox(): """Fixture to create a mock ProxmoxAPI instance.""" mock = Mock() # Setup chained mock calls mock.nodes.return_value.qemu.return_value.status.current.get.return_value = { "status": "running" } mock.nodes.return_value.qemu.return_value.agent.exec.post.return_value = { "out": "command output", "err": "", "exitcode": 0 } return mock @pytest.fixture def vm_console(mock_proxmox): """Fixture to create a VMConsoleManager instance.""" return VMConsoleManager(mock_proxmox) @pytest.mark.asyncio async def test_execute_command_success(vm_console, mock_proxmox): """Test successful command execution.""" result = await vm_console.execute_command("node1", "100", "ls -l") assert result["success"] is True assert result["output"] == "command output" assert result["error"] == "" assert result["exit_code"] == 0 # Verify correct API calls mock_proxmox.nodes.return_value.qemu.assert_called_with("100") mock_proxmox.nodes.return_value.qemu.return_value.agent.exec.post.assert_called_with( command="ls -l" ) @pytest.mark.asyncio async def test_execute_command_vm_not_running(vm_console, mock_proxmox): """Test command execution on stopped VM.""" mock_proxmox.nodes.return_value.qemu.return_value.status.current.get.return_value = { "status": "stopped" } with pytest.raises(ValueError, match="not running"): await vm_console.execute_command("node1", "100", "ls -l") @pytest.mark.asyncio async def test_execute_command_vm_not_found(vm_console, mock_proxmox): """Test command execution on non-existent VM.""" mock_proxmox.nodes.return_value.qemu.return_value.status.current.get.side_effect = \ Exception("VM not found") with pytest.raises(ValueError, match="not found"): await vm_console.execute_command("node1", "100", "ls -l") @pytest.mark.asyncio async def test_execute_command_failure(vm_console, mock_proxmox): """Test command execution failure.""" mock_proxmox.nodes.return_value.qemu.return_value.agent.exec.post.side_effect = \ Exception("Command failed") with pytest.raises(RuntimeError, match="Failed to execute command"): await vm_console.execute_command("node1", "100", "ls -l") @pytest.mark.asyncio async def test_execute_command_with_error_output(vm_console, mock_proxmox): """Test command execution with error output.""" mock_proxmox.nodes.return_value.qemu.return_value.agent.exec.post.return_value = { "out": "", "err": "command error", "exitcode": 1 } result = await vm_console.execute_command("node1", "100", "invalid-command") assert result["success"] is True # Success refers to API call, not command assert result["output"] == "" assert result["error"] == "command error" assert result["exit_code"] == 1 ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/formatting/colors.py: -------------------------------------------------------------------------------- ```python """ Color utilities for Proxmox MCP output styling. """ from typing import Optional from .theme import ProxmoxTheme class ProxmoxColors: """ANSI color definitions and utilities for terminal output.""" # Foreground colors BLACK = '\033[30m' RED = '\033[31m' GREEN = '\033[32m' YELLOW = '\033[33m' BLUE = '\033[34m' MAGENTA = '\033[35m' CYAN = '\033[36m' WHITE = '\033[37m' # Background colors BG_BLACK = '\033[40m' BG_RED = '\033[41m' BG_GREEN = '\033[42m' BG_YELLOW = '\033[43m' BG_BLUE = '\033[44m' BG_MAGENTA = '\033[45m' BG_CYAN = '\033[46m' BG_WHITE = '\033[47m' # Styles BOLD = '\033[1m' DIM = '\033[2m' ITALIC = '\033[3m' UNDERLINE = '\033[4m' BLINK = '\033[5m' REVERSE = '\033[7m' HIDDEN = '\033[8m' STRIKE = '\033[9m' # Reset RESET = '\033[0m' @classmethod def colorize(cls, text: str, color: str, style: Optional[str] = None) -> str: """Add color and optional style to text with theme awareness. Args: text: Text to colorize color: ANSI color code style: Optional ANSI style code Returns: Formatted text string """ if not ProxmoxTheme.USE_COLORS: return text if style: return f"{style}{color}{text}{cls.RESET}" return f"{color}{text}{cls.RESET}" @classmethod def status_color(cls, status: str) -> str: """Get appropriate color for a status value. Args: status: Status string to get color for Returns: ANSI color code """ status = status.lower() if status in ['online', 'running', 'success']: return cls.GREEN elif status in ['offline', 'stopped', 'error']: return cls.RED elif status in ['pending', 'warning']: return cls.YELLOW return cls.BLUE @classmethod def resource_color(cls, resource_type: str) -> str: """Get appropriate color for a resource type. Args: resource_type: Resource type to get color for Returns: ANSI color code """ resource_type = resource_type.lower() if resource_type in ['node', 'vm', 'container']: return cls.CYAN elif resource_type in ['cpu', 'memory', 'network']: return cls.YELLOW elif resource_type in ['storage', 'disk']: return cls.MAGENTA return cls.BLUE @classmethod def metric_color(cls, value: float, warning: float = 80.0, critical: float = 90.0) -> str: """Get appropriate color for a metric value based on thresholds. Args: value: Metric value (typically percentage) warning: Warning threshold critical: Critical threshold Returns: ANSI color code """ if value >= critical: return cls.RED elif value >= warning: return cls.YELLOW return cls.GREEN ``` -------------------------------------------------------------------------------- /test_scripts/test_create_vm.py: -------------------------------------------------------------------------------- ```python #!/usr/bin/env python3 """ Test VM creation functionality """ import os import sys def test_create_vm(): """Test creating VM - 1 CPU, 2GB RAM, 10GB storage""" # Set configuration os.environ['PROXMOX_MCP_CONFIG'] = 'proxmox-config/config.json' try: from proxmox_mcp.config.loader import load_config from proxmox_mcp.core.proxmox import ProxmoxManager from proxmox_mcp.tools.vm import VMTools config = load_config('proxmox-config/config.json') manager = ProxmoxManager(config.proxmox, config.auth) api = manager.get_api() vm_tools = VMTools(api) print("🎉 Test creating new VM - user requested configuration") print("=" * 60) print("Configuration:") print(" • CPU: 1 core") print(" • RAM: 2 GB (2048 MB)") print(" • Storage: 10 GB") print(" • VM ID: 999 (test purpose)") print(" • Name: test-vm-demo") print() # Find an available VM ID vmid = "999" # Check if VM ID already exists try: existing_vm = api.nodes("pve").qemu(vmid).config.get() print(f"⚠️ VM {vmid} already exists, will try VM ID 998") vmid = "998" existing_vm = api.nodes("pve").qemu(vmid).config.get() print(f"⚠️ VM {vmid} also exists, will try VM ID 997") vmid = "997" except: print(f"✅ VM ID {vmid} is available") # Create VM result = vm_tools.create_vm( node="pve", vmid=vmid, name="test-vm-demo", cpus=1, memory=2048, # 2GB in MB disk_size=10 # 10GB ) for content in result: print(content.text) return True except Exception as e: print(f"❌ Creation failed: {e}") return False def test_list_vms(): """Test listing VMs to confirm successful creation""" os.environ['PROXMOX_MCP_CONFIG'] = 'proxmox-config/config.json' try: from proxmox_mcp.config.loader import load_config from proxmox_mcp.core.proxmox import ProxmoxManager from proxmox_mcp.tools.vm import VMTools config = load_config('proxmox-config/config.json') manager = ProxmoxManager(config.proxmox, config.auth) api = manager.get_api() vm_tools = VMTools(api) print("\n🔍 List all VMs to confirm creation results:") print("=" * 40) result = vm_tools.get_vms() for content in result: # Only show newly created VM information lines = content.text.split('\n') for line in lines: if 'test-vm-demo' in line or 'VM 99' in line: print(line) return True except Exception as e: print(f"❌ List query failed: {e}") return False if __name__ == "__main__": print("🔍 Test VM creation functionality") print("=" * 50) success = test_create_vm() if success: print("\n✅ Creation test completed") # Test listing VMs test_list_vms() else: print("\n❌ Creation test failed") sys.exit(1) ``` -------------------------------------------------------------------------------- /test_scripts/test_vm_power.py: -------------------------------------------------------------------------------- ```python #!/usr/bin/env python3 """ Test Proxmox VM power management functionality """ import sys from test_common import setup_test_environment, get_test_tools, print_test_header, print_test_result def test_vm_power_operations(): """Test VM power management operations""" try: # Set up test environment setup_test_environment() tools = get_test_tools() api = tools['api'] nodes = api.nodes.get() # Safely get the first node to avoid index out of range error if not nodes or len(nodes) == 0: print("❌ No Proxmox nodes found") return False node_name = nodes[0]['node'] print(f"Test node: {node_name}") # Get all VMs vms = api.nodes(node_name).qemu.get() print(f"Found {len(vms)} virtual machines:") vm_101_found = False for vm in vms: vmid = vm['vmid'] name = vm['name'] status = vm['status'] print(f" - VM {vmid}: {name} ({status})") if vmid == 101: vm_101_found = True print(f"\nFound VPN-Server (ID: 101), current status: {status}") # Test available status operations vm_api = api.nodes(node_name).qemu(vmid) status_api = vm_api.status print("Test available status operations:") # Try accessing different status endpoints try: # Check if start endpoint exists if hasattr(status_api, 'start'): print(" ✅ Supports start operation") else: print(" ❌ Does not support start operation") if hasattr(status_api, 'stop'): print(" ✅ Supports stop operation") else: print(" ❌ Does not support stop operation") if hasattr(status_api, 'reset'): print(" ✅ Supports reset operation") else: print(" ❌ Does not support reset operation") if hasattr(status_api, 'shutdown'): print(" ✅ Supports shutdown operation") else: print(" ❌ Does not support shutdown operation") # If VM is stopped, try to start if status == 'stopped': print(f"\nVM {vmid} is currently stopped, can try to start") print("Start command would be: api.nodes(node).qemu(101).status.start.post()") elif status == 'running': print(f"\nVM {vmid} is currently running") except Exception as e: print(f" Error while testing operations: {e}") if not vm_101_found: print("\n❌ VM 101 (VPN-Server) not found") except Exception as e: print(f"Test failed: {e}") return False return True if __name__ == "__main__": print_test_header("Test Proxmox VM power management functionality") success = test_vm_power_operations() print_test_result(success) ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/tools/storage.py: -------------------------------------------------------------------------------- ```python """ Storage-related tools for Proxmox MCP. This module provides tools for managing and monitoring Proxmox storage: - Listing all storage pools across the cluster - Retrieving detailed storage information including: * Storage type and content types * Usage statistics and capacity * Availability status * Node assignments The tools implement fallback mechanisms for scenarios where detailed storage information might be temporarily unavailable. """ from typing import List from mcp.types import TextContent as Content from .base import ProxmoxTool from .definitions import GET_STORAGE_DESC class StorageTools(ProxmoxTool): """Tools for managing Proxmox storage. Provides functionality for: - Retrieving cluster-wide storage information - Monitoring storage pool status and health - Tracking storage utilization and capacity - Managing storage content types Implements fallback mechanisms for scenarios where detailed storage information might be temporarily unavailable. """ def get_storage(self) -> List[Content]: """List storage pools across the cluster with detailed status. Retrieves comprehensive information for each storage pool including: - Basic identification (name, type) - Content types supported (VM disks, backups, ISO images, etc.) - Availability status (online/offline) - Usage statistics: * Used space * Total capacity * Available space Implements a fallback mechanism that returns basic information if detailed status retrieval fails for any storage pool. Returns: List of Content objects containing formatted storage information: { "storage": "storage-name", "type": "storage-type", "content": ["content-types"], "status": "online/offline", "used": bytes, "total": bytes, "available": bytes } Raises: RuntimeError: If the cluster-wide storage query fails """ try: result = self.proxmox.storage.get() storage = [] for store in result: # Get detailed storage info including usage try: status = self.proxmox.nodes(store.get("node", "localhost")).storage(store["storage"]).status.get() storage.append({ "storage": store["storage"], "type": store["type"], "content": store.get("content", []), "status": "online" if store.get("enabled", True) else "offline", "used": status.get("used", 0), "total": status.get("total", 0), "available": status.get("avail", 0) }) except Exception: # If detailed status fails, add basic info storage.append({ "storage": store["storage"], "type": store["type"], "content": store.get("content", []), "status": "online" if store.get("enabled", True) else "offline", "used": 0, "total": 0, "available": 0 }) return self._format_response(storage, "storage") except Exception as e: self._handle_error("get storage", e) ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/core/proxmox.py: -------------------------------------------------------------------------------- ```python """ Proxmox API setup and management. This module handles the core Proxmox API integration, providing: - Secure API connection setup and management - Token-based authentication - Connection testing and validation - Error handling for API operations The ProxmoxManager class serves as the central point for all Proxmox API interactions, ensuring consistent connection handling and authentication across the MCP server. """ import logging from typing import Dict, Any from proxmoxer import ProxmoxAPI from ..config.models import ProxmoxConfig, AuthConfig class ProxmoxManager: """Manager class for Proxmox API operations. This class handles: - API connection initialization and management - Configuration validation and merging - Connection testing and health checks - Token-based authentication setup The manager provides a single point of access to the Proxmox API, ensuring proper initialization and error handling for all API operations. """ def __init__(self, proxmox_config: ProxmoxConfig, auth_config: AuthConfig): """Initialize the Proxmox API manager. Args: proxmox_config: Proxmox connection configuration auth_config: Authentication configuration """ self.logger = logging.getLogger("proxmox-mcp.proxmox") self.config = self._create_config(proxmox_config, auth_config) self.api = self._setup_api() def _create_config(self, proxmox_config: ProxmoxConfig, auth_config: AuthConfig) -> Dict[str, Any]: """Create a configuration dictionary for ProxmoxAPI. Merges connection and authentication configurations into a single dictionary suitable for ProxmoxAPI initialization. Handles: - Host and port configuration - SSL verification settings - Token-based authentication details - Service type specification Args: proxmox_config: Proxmox connection configuration (host, port, SSL settings) auth_config: Authentication configuration (user, token details) Returns: Dictionary containing merged configuration ready for API initialization """ return { 'host': proxmox_config.host, 'port': proxmox_config.port, 'user': auth_config.user, 'token_name': auth_config.token_name, 'token_value': auth_config.token_value, 'verify_ssl': proxmox_config.verify_ssl, 'service': proxmox_config.service } def _setup_api(self) -> ProxmoxAPI: """Initialize and test Proxmox API connection. Performs the following steps: 1. Creates ProxmoxAPI instance with configured settings 2. Tests connection by making a version check request 3. Validates authentication and permissions 4. Logs connection status and any issues Returns: Initialized and tested ProxmoxAPI instance Raises: RuntimeError: If connection fails due to: - Invalid host/port - Authentication failure - Network connectivity issues - SSL certificate validation errors """ try: self.logger.info(f"Connecting to Proxmox host: {self.config['host']}") api = ProxmoxAPI(**self.config) # Test connection api.version.get() self.logger.info("Successfully connected to Proxmox API") return api except Exception as e: self.logger.error(f"Failed to connect to Proxmox: {e}") raise RuntimeError(f"Failed to connect to Proxmox: {e}") def get_api(self) -> ProxmoxAPI: """Get the initialized Proxmox API instance. Provides access to the configured and tested ProxmoxAPI instance for making API calls. The instance maintains connection state and handles authentication automatically. Returns: ProxmoxAPI instance ready for making API calls """ return self.api ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/tools/base.py: -------------------------------------------------------------------------------- ```python """ Base classes and utilities for Proxmox MCP tools. This module provides the foundation for all Proxmox MCP tools, including: - Base tool class with common functionality - Response formatting utilities - Error handling mechanisms - Logging setup All tool implementations inherit from the ProxmoxTool base class to ensure consistent behavior and error handling across the MCP server. """ import logging from typing import Any, Dict, List, Optional, Union from mcp.types import TextContent as Content from proxmoxer import ProxmoxAPI from ..formatting import ProxmoxTemplates class ProxmoxTool: """Base class for Proxmox MCP tools. This class provides common functionality used by all Proxmox tool implementations: - Proxmox API access - Standardized logging - Response formatting - Error handling All tool classes should inherit from this base class to ensure consistent behavior and error handling across the MCP server. """ def __init__(self, proxmox_api: ProxmoxAPI): """Initialize the tool. Args: proxmox_api: Initialized ProxmoxAPI instance """ self.proxmox = proxmox_api self.logger = logging.getLogger(f"proxmox-mcp.{self.__class__.__name__.lower()}") def _format_response(self, data: Any, resource_type: Optional[str] = None) -> List[Content]: """Format response data into MCP content using templates. This method handles formatting of various Proxmox resource types into consistent MCP content responses. It uses specialized templates for different resource types (nodes, VMs, storage, etc.) and falls back to JSON formatting for unknown types. Args: data: Raw data from Proxmox API to format resource_type: Type of resource for template selection. Valid types: 'nodes', 'node_status', 'vms', 'storage', 'containers', 'cluster' Returns: List of Content objects formatted according to resource type """ if resource_type == "nodes": formatted = ProxmoxTemplates.node_list(data) elif resource_type == "node_status": # For node_status, data should be a tuple of (node_name, status_dict) if isinstance(data, tuple) and len(data) == 2: formatted = ProxmoxTemplates.node_status(data[0], data[1]) else: formatted = ProxmoxTemplates.node_status("unknown", data) elif resource_type == "vms": formatted = ProxmoxTemplates.vm_list(data) elif resource_type == "storage": formatted = ProxmoxTemplates.storage_list(data) elif resource_type == "containers": formatted = ProxmoxTemplates.container_list(data) elif resource_type == "cluster": formatted = ProxmoxTemplates.cluster_status(data) else: # Fallback to JSON formatting for unknown types import json formatted = json.dumps(data, indent=2) return [Content(type="text", text=formatted)] def _handle_error(self, operation: str, error: Exception) -> None: """Handle and log errors from Proxmox operations. Provides standardized error handling across all tools by: - Logging errors with appropriate context - Categorizing errors into specific exception types - Converting Proxmox-specific errors into standard Python exceptions Args: operation: Description of the operation that failed (e.g., "get node status") error: The exception that occurred during the operation Raises: ValueError: For invalid input, missing resources, or permission issues RuntimeError: For unexpected errors or API failures """ error_msg = str(error) self.logger.error(f"Failed to {operation}: {error_msg}") if "not found" in error_msg.lower(): raise ValueError(f"Resource not found: {error_msg}") if "permission denied" in error_msg.lower(): raise ValueError(f"Permission denied: {error_msg}") if "invalid" in error_msg.lower(): raise ValueError(f"Invalid input: {error_msg}") raise RuntimeError(f"Failed to {operation}: {error_msg}") ``` -------------------------------------------------------------------------------- /test_scripts/test_openapi.py: -------------------------------------------------------------------------------- ```python #!/usr/bin/env python3 """ Test OpenAPI functionality """ import requests import json import os # Get base URL from environment variable or use default localhost BASE_URL = os.getenv('OPENAPI_BASE_URL', 'http://localhost:8811') def test_basic_endpoints(): """Test basic API endpoints""" print("🔍 Test basic API endpoints") print(f"🌐 Using base URL: {BASE_URL}") print("=" * 50) # Test get nodes try: response = requests.post(f"{BASE_URL}/get_nodes") print(f"✅ get_nodes: {response.status_code} - {len(response.text)} chars") except Exception as e: print(f"❌ get_nodes error: {e}") # Test get VM list try: response = requests.post(f"{BASE_URL}/get_vms") print(f"✅ get_vms: {response.status_code} - {len(response.text)} chars") if response.status_code == 200: # Check if our test VMs are included if "test-vm" in response.text: print(" 📋 Test VM found") except Exception as e: print(f"❌ get_vms error: {e}") def test_vm_creation_api(): """Test VM creation API""" print("\n🎉 Test VM creation API - user requested configuration") print("=" * 50) print("Configuration: 1 CPU core, 2GB RAM, 10GB storage") # VM creation parameters create_data = { "node": "pve", "vmid": "996", # Use new VM ID "name": "user-requested-vm", "cpus": 1, "memory": 2048, # 2GB in MB "disk_size": 10 # 10GB } try: response = requests.post( f"{BASE_URL}/create_vm", json=create_data, headers={"Content-Type": "application/json"} ) print(f"📡 API response status: {response.status_code}") if response.status_code == 200: result = response.json() print("✅ VM creation successful!") print(f"📄 Response content: {json.dumps(result, indent=2, ensure_ascii=False)}") else: print(f"❌ VM creation failed: {response.text}") except requests.exceptions.ConnectionError: print("❌ Cannot connect to API server - please ensure OpenAPI service is running") except Exception as e: print(f"❌ API call error: {e}") def test_vm_power_api(): """Test VM power management API""" print("\n🚀 Test VM power management API") print("=" * 50) # Test starting VM 101 (VPN-Server) start_data = { "node": "pve", "vmid": "101" } try: response = requests.post( f"{BASE_URL}/start_vm", json=start_data, headers={"Content-Type": "application/json"} ) print(f"📡 Start VM 101 response: {response.status_code}") if response.status_code == 200: result = response.json() print("✅ VM start command successful!") print(f"📄 Response: {json.dumps(result, indent=2, ensure_ascii=False)}") else: print(f"❌ VM start failed: {response.text}") except requests.exceptions.ConnectionError: print("❌ Cannot connect to API server") except Exception as e: print(f"❌ API call error: {e}") def list_available_apis(): """List all available API endpoints""" print("\n📋 Available API endpoints") print("=" * 50) try: response = requests.get(f"{BASE_URL}/openapi.json") if response.status_code == 200: openapi_spec = response.json() paths = openapi_spec.get("paths", {}) print(f"Found {len(paths)} API endpoints:") for path, methods in paths.items(): for method, details in methods.items(): summary = details.get("summary", "No summary") print(f" • {method.upper()} {path} - {summary}") else: print(f"❌ Cannot get API specification: {response.status_code}") except Exception as e: print(f"❌ Get API list error: {e}") if __name__ == "__main__": print("🔍 ProxmoxMCP OpenAPI functionality test") print("=" * 60) # List available APIs list_available_apis() # Test basic functionality test_basic_endpoints() # Test VM creation functionality test_vm_creation_api() # Test VM power management test_vm_power_api() print("\n✅ All tests completed") print("\n💡 Usage instructions:") print("When user says 'Can you create a VM with 1 cpu core and 2 GB ram with 10GB of storage disk',") print("the AI assistant can call create_vm API to complete the task!") print(f"\n🔧 To test with different server, set environment variable:") print("export OPENAPI_BASE_URL=http://your-server:8811") ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/formatting/formatters.py: -------------------------------------------------------------------------------- ```python """ Core formatting functions for Proxmox MCP output. """ from typing import List, Union, Dict, Any from .theme import ProxmoxTheme from .colors import ProxmoxColors class ProxmoxFormatters: """Core formatting functions for Proxmox data.""" @staticmethod def format_bytes(bytes_value: int) -> str: """Format bytes with proper units. Args: bytes_value: Number of bytes Returns: Formatted string with appropriate unit """ for unit in ['B', 'KB', 'MB', 'GB', 'TB']: if bytes_value < 1024: return f"{bytes_value:.2f} {unit}" bytes_value /= 1024 return f"{bytes_value:.2f} TB" @staticmethod def format_uptime(seconds: int) -> str: """Format uptime in seconds to human readable format. Args: seconds: Uptime in seconds Returns: Formatted uptime string """ days = seconds // 86400 hours = (seconds % 86400) // 3600 minutes = (seconds % 3600) // 60 parts = [] if days > 0: parts.append(f"{days}d") if hours > 0: parts.append(f"{hours}h") if minutes > 0: parts.append(f"{minutes}m") return f"{ProxmoxTheme.METRICS['uptime']} " + " ".join(parts) if parts else "0m" @staticmethod def format_percentage(value: float, warning: float = 80.0, critical: float = 90.0) -> str: """Format percentage with color based on thresholds. Args: value: Percentage value warning: Warning threshold critical: Critical threshold Returns: Formatted percentage string """ color = ProxmoxColors.metric_color(value, warning, critical) return ProxmoxColors.colorize(f"{value:.1f}%", color) @staticmethod def format_status(status: str) -> str: """Format status with emoji and color. Args: status: Status string Returns: Formatted status string """ status = status.lower() emoji = ProxmoxTheme.get_status_emoji(status) color = ProxmoxColors.status_color(status) return f"{emoji} {ProxmoxColors.colorize(status.upper(), color)}" @staticmethod def format_resource_header(resource_type: str, name: str) -> str: """Format resource header with emoji and styling. Args: resource_type: Type of resource name: Resource name Returns: Formatted header string """ emoji = ProxmoxTheme.get_resource_emoji(resource_type) color = ProxmoxColors.resource_color(resource_type) return f"\n{emoji} {ProxmoxColors.colorize(name, color, ProxmoxColors.BOLD)}" @staticmethod def format_section_header(title: str, section_type: str = 'header') -> str: """Format section header with emoji and border. Args: title: Section title section_type: Type of section for emoji selection Returns: Formatted section header """ emoji = ProxmoxTheme.get_section_emoji(section_type) header = f"{emoji} {title}" border = "═" * len(header) return f"\n{header}\n{border}\n" @staticmethod def format_key_value(key: str, value: str, emoji: str = "") -> str: """Format key-value pair with optional emoji. Args: key: Label/key value: Value to display emoji: Optional emoji prefix Returns: Formatted key-value string """ key_str = ProxmoxColors.colorize(key, ProxmoxColors.CYAN) prefix = f"{emoji} " if emoji else "" return f"{prefix}{key_str}: {value}" @staticmethod def format_command_output(success: bool, command: str, output: str, error: str = None) -> str: """Format command execution output. Args: success: Whether command succeeded command: The command that was executed output: Command output error: Optional error message Returns: Formatted command output string """ result = [ f"{ProxmoxTheme.ACTIONS['command']} Console Command Result", f" • Status: {'SUCCESS' if success else 'FAILED'}", f" • Command: {command}", "", "Output:", output.strip() ] if error: result.extend([ "", "Error:", error.strip() ]) return "\n".join(result) ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/tools/definitions.py: -------------------------------------------------------------------------------- ```python """ Tool descriptions for Proxmox MCP tools. """ # Node tool descriptions GET_NODES_DESC = """List all nodes in the Proxmox cluster with their status, CPU, memory, and role information. Example: {"node": "pve1", "status": "online", "cpu_usage": 0.15, "memory": {"used": "8GB", "total": "32GB"}}""" GET_NODE_STATUS_DESC = """Get detailed status information for a specific Proxmox node. Parameters: node* - Name/ID of node to query (e.g. 'pve1') Example: {"cpu": {"usage": 0.15}, "memory": {"used": "8GB", "total": "32GB"}}""" # VM tool descriptions GET_VMS_DESC = """List all virtual machines across the cluster with their status and resource usage. Example: {"vmid": "100", "name": "ubuntu", "status": "running", "cpu": 2, "memory": 4096}""" CREATE_VM_DESC = """Create a new virtual machine with specified configuration. Parameters: node* - Host node name (e.g. 'pve') vmid* - New VM ID number (e.g. '200', '300') name* - VM name (e.g. 'my-new-vm', 'web-server') cpus* - Number of CPU cores (e.g. 1, 2, 4) memory* - Memory size in MB (e.g. 2048 for 2GB, 4096 for 4GB) disk_size* - Disk size in GB (e.g. 10, 20, 50) storage - Storage name (optional, will auto-detect if not specified) ostype - OS type (optional, default: 'l26' for Linux) Examples: - Create VM with 1 CPU, 2GB RAM, 10GB disk: node='pve', vmid='200', name='test-vm', cpus=1, memory=2048, disk_size=10 - Create VM with 2 CPUs, 4GB RAM, 20GB disk: node='pve', vmid='201', name='web-server', cpus=2, memory=4096, disk_size=20""" EXECUTE_VM_COMMAND_DESC = """Execute commands in a VM via QEMU guest agent. Parameters: node* - Host node name (e.g. 'pve1') vmid* - VM ID number (e.g. '100') command* - Shell command to run (e.g. 'uname -a') Example: {"success": true, "output": "Linux vm1 5.4.0", "exit_code": 0}""" # VM Power Management tool descriptions START_VM_DESC = """Start a virtual machine. Parameters: node* - Host node name (e.g. 'pve') vmid* - VM ID number (e.g. '101') Example: Power on VPN-Server with ID 101 on node pve""" STOP_VM_DESC = """Stop a virtual machine (force stop). Parameters: node* - Host node name (e.g. 'pve') vmid* - VM ID number (e.g. '101') Example: Force stop VPN-Server with ID 101 on node pve""" SHUTDOWN_VM_DESC = """Shutdown a virtual machine gracefully. Parameters: node* - Host node name (e.g. 'pve') vmid* - VM ID number (e.g. '101') Example: Gracefully shutdown VPN-Server with ID 101 on node pve""" RESET_VM_DESC = """Reset (restart) a virtual machine. Parameters: node* - Host node name (e.g. 'pve') vmid* - VM ID number (e.g. '101') Example: Reset VPN-Server with ID 101 on node pve""" DELETE_VM_DESC = """Delete/remove a virtual machine completely. ⚠️ WARNING: This operation permanently deletes the VM and all its data! Parameters: node* - Host node name (e.g. 'pve') vmid* - VM ID number (e.g. '998') force - Force deletion even if VM is running (optional, default: false) This will permanently remove: - VM configuration - All virtual disks - All snapshots - Cannot be undone! Example: Delete test VM with ID 998 on node pve""" # Container tool descriptions GET_CONTAINERS_DESC = """List LXC containers across the cluster (or filter by node). Parameters: - node (optional): Node name to filter (e.g. 'pve1') - include_stats (bool, default true): Include live CPU/memory stats - include_raw (bool, default false): Include raw Proxmox API payloads for debugging - format_style ('pretty'|'json', default 'pretty'): Pretty text or raw JSON list Notes: - Live stats from /nodes/{node}/lxc/{vmid}/status/current. - If maxmem is 0 (unlimited), memory limit falls back to /config.memory (MiB). - If live returns zeros, the most recent RRD sample is used as a fallback. - Fields provided: cores (CPU cores/cpulimit), memory (MiB limit), cpu_pct, mem_bytes, maxmem_bytes, mem_pct, unlimited_memory. """ START_CONTAINER_DESC = """Start one or more LXC containers. selector: '123' | 'pve1:123' | 'pve1/name' | 'name' | comma list Example: start_container selector='pve1:101,pve2/web' """ STOP_CONTAINER_DESC = """Stop LXC containers. graceful=True uses shutdown; otherwise force stop. selector: same grammar as start_container timeout_seconds: 10 (default) """ RESTART_CONTAINER_DESC = """Restart LXC containers (reboot). selector: same grammar as start_container """ UPDATE_CONTAINER_RESOURCES_DESC = """Update resources for one or more LXC containers. selector: same grammar as start_container cores: New CPU core count (optional) memory: New memory limit in MiB (optional) swap: New swap limit in MiB (optional) disk_gb: Additional disk size in GiB to add (optional) disk: Disk identifier to resize (default 'rootfs') """ # Storage tool descriptions GET_STORAGE_DESC = """List storage pools across the cluster with their usage and configuration. Example: {"storage": "local-lvm", "type": "lvm", "used": "500GB", "total": "1TB"}""" # Cluster tool descriptions GET_CLUSTER_STATUS_DESC = """Get overall Proxmox cluster health and configuration status. Example: {"name": "proxmox", "quorum": "ok", "nodes": 3, "ha_status": "active"}""" ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/tools/node.py: -------------------------------------------------------------------------------- ```python """ Node-related tools for Proxmox MCP. This module provides tools for managing and monitoring Proxmox nodes: - Listing all nodes in the cluster with their status - Getting detailed node information including: * CPU usage and configuration * Memory utilization * Uptime statistics * Health status The tools handle both basic and detailed node information retrieval, with fallback mechanisms for partial data availability. """ from typing import List from mcp.types import TextContent as Content from .base import ProxmoxTool from .definitions import GET_NODES_DESC, GET_NODE_STATUS_DESC class NodeTools(ProxmoxTool): """Tools for managing Proxmox nodes. Provides functionality for: - Retrieving cluster-wide node information - Getting detailed status for specific nodes - Monitoring node health and resources - Handling node-specific API operations Implements fallback mechanisms for scenarios where detailed node information might be temporarily unavailable. """ def get_nodes(self) -> List[Content]: """List all nodes in the Proxmox cluster with detailed status. Retrieves comprehensive information for each node including: - Basic status (online/offline) - Uptime statistics - CPU configuration and count - Memory usage and capacity Implements a fallback mechanism that returns basic information if detailed status retrieval fails for any node. Returns: List of Content objects containing formatted node information: { "node": "node_name", "status": "online/offline", "uptime": seconds, "maxcpu": cpu_count, "memory": { "used": bytes, "total": bytes } } Raises: RuntimeError: If the cluster-wide node query fails """ try: result = self.proxmox.nodes.get() nodes = [] # Get detailed info for each node for node in result: node_name = node["node"] try: # Get detailed status for each node status = self.proxmox.nodes(node_name).status.get() nodes.append({ "node": node_name, "status": node["status"], "uptime": status.get("uptime", 0), "maxcpu": status.get("cpuinfo", {}).get("cpus", "N/A"), "memory": { "used": status.get("memory", {}).get("used", 0), "total": status.get("memory", {}).get("total", 0) } }) except Exception: # Fallback to basic info if detailed status fails nodes.append({ "node": node_name, "status": node["status"], "uptime": 0, "maxcpu": "N/A", "memory": { # The nodes.get() API already returns memory usage # in the "mem" field, so use that directly. The # previous implementation subtracted this value # from "maxmem" which actually produced the amount # of *free* memory instead of the used memory. "used": node.get("mem", 0), "total": node.get("maxmem", 0) } }) return self._format_response(nodes, "nodes") except Exception as e: self._handle_error("get nodes", e) def get_node_status(self, node: str) -> List[Content]: """Get detailed status information for a specific node. Retrieves comprehensive status information including: - CPU usage and configuration - Memory utilization details - Uptime and load statistics - Network status - Storage health - Running tasks and services Args: node: Name/ID of node to query (e.g., 'pve1', 'proxmox-node2') Returns: List of Content objects containing detailed node status: { "uptime": seconds, "cpu": { "usage": percentage, "cores": count }, "memory": { "used": bytes, "total": bytes, "free": bytes }, ...additional status fields } Raises: ValueError: If the specified node is not found RuntimeError: If status retrieval fails (node offline, network issues) """ try: result = self.proxmox.nodes(node).status.get() return self._format_response((node, result), "node_status") except Exception as e: self._handle_error(f"get status for node {node}", e) ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/formatting/components.py: -------------------------------------------------------------------------------- ```python """ Reusable UI components for Proxmox MCP output. """ from typing import List, Optional from .colors import ProxmoxColors from .theme import ProxmoxTheme class ProxmoxComponents: """Reusable UI components for formatted output.""" @staticmethod def create_table(headers: List[str], rows: List[List[str]], title: Optional[str] = None) -> str: """Create an ASCII table with optional title. Args: headers: List of column headers rows: List of row data title: Optional table title Returns: Formatted table string """ # Calculate column widths considering multi-line content widths = [len(header) for header in headers] for row in rows: for i, cell in enumerate(row): cell_lines = str(cell).split('\n') max_line_length = max(len(line) for line in cell_lines) widths[i] = max(widths[i], max_line_length) # Create separator line separator = "+" + "+".join("-" * (w + 2) for w in widths) + "+" # Calculate total width for title total_width = sum(widths) + len(widths) + 1 # Build table result = [] # Add title if provided if title: # Center the title title_str = ProxmoxColors.colorize(title, ProxmoxColors.CYAN, ProxmoxColors.BOLD) padding = (total_width - len(title) - 2) // 2 # -2 for the border chars title_separator = "+" + "-" * (total_width - 2) + "+" result.extend([ title_separator, "|" + " " * padding + title_str + " " * (total_width - padding - len(title) - 2) + "|", title_separator ]) # Add headers header = "|" + "|".join(f" {ProxmoxColors.colorize(h, ProxmoxColors.CYAN):<{w}} " for w, h in zip(widths, headers)) + "|" result.extend([separator, header, separator]) # Add rows with multi-line cell support for row in rows: # Split each cell into lines cell_lines = [str(cell).split('\n') for cell in row] max_lines = max(len(lines) for lines in cell_lines) # Pad cells with fewer lines padded_cells = [] for lines in cell_lines: if len(lines) < max_lines: lines.extend([''] * (max_lines - len(lines))) padded_cells.append(lines) # Create row strings for each line for line_idx in range(max_lines): line_parts = [] for col_idx, cell_lines in enumerate(padded_cells): line = cell_lines[line_idx] line_parts.append(f" {line:<{widths[col_idx]}} ") result.append("|" + "|".join(line_parts) + "|") # Add separator after each row except the last if row != rows[-1]: result.append(separator) result.append(separator) return "\n".join(result) @staticmethod def create_progress_bar(value: float, total: float, width: int = 20) -> str: """Create a progress bar with percentage. Args: value: Current value total: Maximum value width: Width of progress bar in characters Returns: Formatted progress bar string """ percentage = min(100, (value / total * 100) if total > 0 else 0) filled = int(width * percentage / 100) color = ProxmoxColors.metric_color(percentage) bar = "█" * filled + "░" * (width - filled) return f"{ProxmoxColors.colorize(bar, color)} {percentage:.1f}%" @staticmethod def create_resource_usage(used: float, total: float, label: str, emoji: str) -> str: """Create a resource usage display with progress bar. Args: used: Used amount total: Total amount label: Resource label emoji: Resource emoji Returns: Formatted resource usage string """ from .formatters import ProxmoxFormatters percentage = (used / total * 100) if total > 0 else 0 progress = ProxmoxComponents.create_progress_bar(used, total) return ( f"{emoji} {label}:\n" f" {progress}\n" f" {ProxmoxFormatters.format_bytes(used)} / {ProxmoxFormatters.format_bytes(total)}" ) @staticmethod def create_key_value_grid(data: dict, columns: int = 2) -> str: """Create a grid of key-value pairs. Args: data: Dictionary of key-value pairs columns: Number of columns in grid Returns: Formatted grid string """ # Calculate max widths for each column items = list(data.items()) rows = [items[i:i + columns] for i in range(0, len(items), columns)] key_widths = [0] * columns val_widths = [0] * columns for row in rows: for i, (key, val) in enumerate(row): key_widths[i] = max(key_widths[i], len(str(key))) val_widths[i] = max(val_widths[i], len(str(val))) # Format rows result = [] for row in rows: formatted_items = [] for i, (key, val) in enumerate(row): key_str = ProxmoxColors.colorize(f"{key}:", ProxmoxColors.CYAN) formatted_items.append(f"{key_str:<{key_widths[i] + 10}} {val:<{val_widths[i]}}") result.append(" ".join(formatted_items)) return "\n".join(result) @staticmethod def create_status_badge(status: str) -> str: """Create a status badge with emoji. Args: status: Status string Returns: Formatted status badge string """ status = status.lower() emoji = ProxmoxTheme.get_status_emoji(status) return f"{emoji} {status.upper()}" ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/tools/console/manager.py: -------------------------------------------------------------------------------- ```python """ Module for managing VM console operations. This module provides functionality for interacting with VM consoles: - Executing commands within VMs via QEMU guest agent - Handling command execution lifecycle - Managing command output and status - Error handling and logging The module implements a robust command execution system with: - VM state verification - Asynchronous command execution - Detailed status tracking - Comprehensive error handling """ import logging from typing import Dict, Any class VMConsoleManager: """Manager class for VM console operations. Provides functionality for: - Executing commands in VM consoles - Managing command execution lifecycle - Handling command output and errors - Monitoring execution status Uses QEMU guest agent for reliable command execution with: - VM state verification before execution - Asynchronous command processing - Detailed output capture - Comprehensive error handling """ def __init__(self, proxmox_api): """Initialize the VM console manager. Args: proxmox_api: Initialized ProxmoxAPI instance """ self.proxmox = proxmox_api self.logger = logging.getLogger("proxmox-mcp.vm-console") async def execute_command(self, node: str, vmid: str, command: str) -> Dict[str, Any]: """Execute a command in a VM's console via QEMU guest agent. Implements a two-phase command execution process: 1. Command Initiation: - Verifies VM exists and is running - Initiates command execution via guest agent - Captures command PID for tracking 2. Result Collection: - Monitors command execution status - Captures command output and errors - Handles completion status Requirements: - VM must be running - QEMU guest agent must be installed and active - Command execution permissions must be enabled Args: node: Name of the node where VM is running (e.g., 'pve1') vmid: ID of the VM to execute command in (e.g., '100') command: Shell command to execute in the VM Returns: Dictionary containing command execution results: { "success": true/false, "output": "command output", "error": "error output if any", "exit_code": command_exit_code } Raises: ValueError: If: - VM is not found - VM is not running - Guest agent is not available RuntimeError: If: - Command execution fails - Unable to get command status - API communication errors occur """ try: # Verify VM exists and is running vm_status = self.proxmox.nodes(node).qemu(vmid).status.current.get() if vm_status["status"] != "running": self.logger.error(f"Failed to execute command on VM {vmid}: VM is not running") raise ValueError(f"VM {vmid} on node {node} is not running") # Get VM's console self.logger.info(f"Executing command on VM {vmid} (node: {node}): {command}") # Get the API endpoint # Use the guest agent exec endpoint endpoint = self.proxmox.nodes(node).qemu(vmid).agent self.logger.debug(f"Using API endpoint: {endpoint}") # Execute the command using two-step process try: # Start command execution self.logger.info("Starting command execution...") try: self.logger.debug(f"Executing command via agent: {command}") exec_result = endpoint("exec").post(command=command) self.logger.debug(f"Raw exec response: {exec_result}") self.logger.info(f"Command started with result: {exec_result}") except Exception as e: self.logger.error(f"Failed to start command: {str(e)}") raise RuntimeError(f"Failed to start command: {str(e)}") if 'pid' not in exec_result: raise RuntimeError("No PID returned from command execution") pid = exec_result['pid'] self.logger.info(f"Waiting for command completion (PID: {pid})...") # Add a small delay to allow command to complete import asyncio await asyncio.sleep(1) # Get command output using exec-status try: self.logger.debug(f"Getting status for PID {pid}...") console = endpoint("exec-status").get(pid=pid) self.logger.debug(f"Raw exec-status response: {console}") if not console: raise RuntimeError("No response from exec-status") except Exception as e: self.logger.error(f"Failed to get command status: {str(e)}") raise RuntimeError(f"Failed to get command status: {str(e)}") self.logger.info(f"Command completed with status: {console}") except Exception as e: self.logger.error(f"API call failed: {str(e)}") raise RuntimeError(f"API call failed: {str(e)}") self.logger.debug(f"Raw API response type: {type(console)}") self.logger.debug(f"Raw API response: {console}") # Handle different response structures if isinstance(console, dict): # Handle exec-status response format output = console.get("out-data", "") error = console.get("err-data", "") exit_code = console.get("exitcode", 0) exited = console.get("exited", 0) if not exited: self.logger.warning("Command may not have completed") else: # Some versions might return data differently self.logger.debug(f"Unexpected response type: {type(console)}") output = str(console) error = "" exit_code = 0 self.logger.debug(f"Processed output: {output}") self.logger.debug(f"Processed error: {error}") self.logger.debug(f"Processed exit code: {exit_code}") self.logger.debug(f"Executed command '{command}' on VM {vmid} (node: {node})") return { "success": True, "output": output, "error": error, "exit_code": exit_code } except ValueError: # Re-raise ValueError for VM not running raise except Exception as e: self.logger.error(f"Failed to execute command on VM {vmid}: {str(e)}") if "not found" in str(e).lower(): raise ValueError(f"VM {vmid} not found on node {node}") raise RuntimeError(f"Failed to execute command: {str(e)}") ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/formatting/templates.py: -------------------------------------------------------------------------------- ```python """ Output templates for Proxmox MCP resource types. """ from typing import Dict, List, Any from .formatters import ProxmoxFormatters from .theme import ProxmoxTheme from .colors import ProxmoxColors from .components import ProxmoxComponents class ProxmoxTemplates: """Output templates for different Proxmox resource types.""" @staticmethod def node_list(nodes: List[Dict[str, Any]]) -> str: """Template for node list output. Args: nodes: List of node data dictionaries Returns: Formatted node list string """ result = [f"{ProxmoxTheme.RESOURCES['node']} Proxmox Nodes"] for node in nodes: # Get node status status = node.get("status", "unknown") # Get memory info memory = node.get("memory", {}) memory_used = memory.get("used", 0) memory_total = memory.get("total", 0) memory_percent = (memory_used / memory_total * 100) if memory_total > 0 else 0 # Format node info result.extend([ "", # Empty line between nodes f"{ProxmoxTheme.RESOURCES['node']} {node['node']}", f" • Status: {status.upper()}", f" • Uptime: {ProxmoxFormatters.format_uptime(node.get('uptime', 0))}", f" • CPU Cores: {node.get('maxcpu', 'N/A')}", f" • Memory: {ProxmoxFormatters.format_bytes(memory_used)} / " f"{ProxmoxFormatters.format_bytes(memory_total)} ({memory_percent:.1f}%)" ]) # Add disk usage if available disk = node.get("disk", {}) if disk: disk_used = disk.get("used", 0) disk_total = disk.get("total", 0) disk_percent = (disk_used / disk_total * 100) if disk_total > 0 else 0 result.append( f" • Disk: {ProxmoxFormatters.format_bytes(disk_used)} / " f"{ProxmoxFormatters.format_bytes(disk_total)} ({disk_percent:.1f}%)" ) return "\n".join(result) @staticmethod def node_status(node: str, status: Dict[str, Any]) -> str: """Template for detailed node status output. Args: node: Node name status: Node status data Returns: Formatted node status string """ memory = status.get("memory", {}) memory_used = memory.get("used", 0) memory_total = memory.get("total", 0) memory_percent = (memory_used / memory_total * 100) if memory_total > 0 else 0 result = [ f"{ProxmoxTheme.RESOURCES['node']} Node: {node}", f" • Status: {status.get('status', 'unknown').upper()}", f" • Uptime: {ProxmoxFormatters.format_uptime(status.get('uptime', 0))}", f" • CPU Cores: {status.get('maxcpu', 'N/A')}", f" • Memory: {ProxmoxFormatters.format_bytes(memory_used)} / " f"{ProxmoxFormatters.format_bytes(memory_total)} ({memory_percent:.1f}%)" ] # Add disk usage if available disk = status.get("disk", {}) if disk: disk_used = disk.get("used", 0) disk_total = disk.get("total", 0) disk_percent = (disk_used / disk_total * 100) if disk_total > 0 else 0 result.append( f" • Disk: {ProxmoxFormatters.format_bytes(disk_used)} / " f"{ProxmoxFormatters.format_bytes(disk_total)} ({disk_percent:.1f}%)" ) return "\n".join(result) @staticmethod def vm_list(vms: List[Dict[str, Any]]) -> str: """Template for VM list output. Args: vms: List of VM data dictionaries Returns: Formatted VM list string """ result = [f"{ProxmoxTheme.RESOURCES['vm']} Virtual Machines"] for vm in vms: memory = vm.get("memory", {}) memory_used = memory.get("used", 0) memory_total = memory.get("total", 0) memory_percent = (memory_used / memory_total * 100) if memory_total > 0 else 0 result.extend([ "", # Empty line between VMs f"{ProxmoxTheme.RESOURCES['vm']} {vm['name']} (ID: {vm['vmid']})", f" • Status: {vm['status'].upper()}", f" • Node: {vm['node']}", f" • CPU Cores: {vm.get('cpus', 'N/A')}", f" • Memory: {ProxmoxFormatters.format_bytes(memory_used)} / " f"{ProxmoxFormatters.format_bytes(memory_total)} ({memory_percent:.1f}%)" ]) return "\n".join(result) @staticmethod def storage_list(storage: List[Dict[str, Any]]) -> str: """Template for storage list output. Args: storage: List of storage data dictionaries Returns: Formatted storage list string """ result = [f"{ProxmoxTheme.RESOURCES['storage']} Storage Pools"] for store in storage: used = store.get("used", 0) total = store.get("total", 0) percent = (used / total * 100) if total > 0 else 0 result.extend([ "", # Empty line between storage pools f"{ProxmoxTheme.RESOURCES['storage']} {store['storage']}", f" • Status: {store.get('status', 'unknown').upper()}", f" • Type: {store['type']}", f" • Usage: {ProxmoxFormatters.format_bytes(used)} / " f"{ProxmoxFormatters.format_bytes(total)} ({percent:.1f}%)" ]) return "\n".join(result) @staticmethod def container_list(containers: List[Dict[str, Any]]) -> str: """Template for container list output. Args: containers: List of container data dictionaries Returns: Formatted container list string """ if not containers: return f"{ProxmoxTheme.RESOURCES['container']} No containers found" result = [f"{ProxmoxTheme.RESOURCES['container']} Containers"] for container in containers: memory = container.get("memory", {}) memory_used = memory.get("used", 0) memory_total = memory.get("total", 0) memory_percent = (memory_used / memory_total * 100) if memory_total > 0 else 0 result.extend([ "", # Empty line between containers f"{ProxmoxTheme.RESOURCES['container']} {container['name']} (ID: {container['vmid']})", f" • Status: {container['status'].upper()}", f" • Node: {container['node']}", f" • CPU Cores: {container.get('cpus', 'N/A')}", f" • Memory: {ProxmoxFormatters.format_bytes(memory_used)} / " f"{ProxmoxFormatters.format_bytes(memory_total)} ({memory_percent:.1f}%)" ]) return "\n".join(result) @staticmethod def cluster_status(status: Dict[str, Any]) -> str: """Template for cluster status output. Args: status: Cluster status data Returns: Formatted cluster status string """ result = [f"{ProxmoxTheme.SECTIONS['configuration']} Proxmox Cluster"] # Basic cluster info result.extend([ "", f" • Name: {status.get('name', 'N/A')}", f" • Quorum: {'OK' if status.get('quorum') else 'NOT OK'}", f" • Nodes: {status.get('nodes', 0)}", ]) # Add resource count if available resources = status.get('resources', []) if resources: result.append(f" • Resources: {len(resources)}") return "\n".join(result) ``` -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- ```python """ Tests for the Proxmox MCP server. """ import os import json import pytest from unittest.mock import Mock, patch from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp.exceptions import ToolError from proxmox_mcp.server import ProxmoxMCPServer @pytest.fixture def mock_env_vars(): """Fixture to set up test environment variables.""" env_vars = { "PROXMOX_HOST": "test.proxmox.com", "PROXMOX_USER": "test@pve", "PROXMOX_TOKEN_NAME": "test_token", "PROXMOX_TOKEN_VALUE": "test_value", "LOG_LEVEL": "DEBUG" } with patch.dict(os.environ, env_vars): yield env_vars @pytest.fixture def mock_proxmox(): """Fixture to mock ProxmoxAPI.""" with patch("proxmox_mcp.core.proxmox.ProxmoxAPI") as mock: mock.return_value.nodes.get.return_value = [ {"node": "node1", "status": "online"}, {"node": "node2", "status": "online"} ] yield mock @pytest.fixture def server(mock_env_vars, mock_proxmox): """Fixture to create a ProxmoxMCPServer instance.""" return ProxmoxMCPServer() def test_server_initialization(server, mock_proxmox): """Test server initialization with environment variables.""" assert server.config.proxmox.host == "test.proxmox.com" assert server.config.auth.user == "test@pve" assert server.config.auth.token_name == "test_token" assert server.config.auth.token_value == "test_value" assert server.config.logging.level == "DEBUG" mock_proxmox.assert_called_once() @pytest.mark.asyncio async def test_list_tools(server): """Test listing available tools.""" tools = await server.mcp.list_tools() assert len(tools) > 0 tool_names = [tool.name for tool in tools] assert "get_nodes" in tool_names assert "get_vms" in tool_names assert "get_containers" in tool_names assert "execute_vm_command" in tool_names assert "update_container_resources" in tool_names @pytest.mark.asyncio async def test_get_nodes(server, mock_proxmox): """Test get_nodes tool.""" mock_proxmox.return_value.nodes.get.return_value = [ {"node": "node1", "status": "online"}, {"node": "node2", "status": "online"} ] response = await server.mcp.call_tool("get_nodes", {}) result = json.loads(response[0].text) assert len(result) == 2 assert result[0]["node"] == "node1" assert result[1]["node"] == "node2" @pytest.mark.asyncio async def test_get_node_status_missing_parameter(server): """Test get_node_status tool with missing parameter.""" with pytest.raises(ToolError, match="Field required"): await server.mcp.call_tool("get_node_status", {}) @pytest.mark.asyncio async def test_get_node_status(server, mock_proxmox): """Test get_node_status tool with valid parameter.""" mock_proxmox.return_value.nodes.return_value.status.get.return_value = { "status": "running", "uptime": 123456 } response = await server.mcp.call_tool("get_node_status", {"node": "node1"}) result = json.loads(response[0].text) assert result["status"] == "running" assert result["uptime"] == 123456 @pytest.mark.asyncio async def test_get_vms(server, mock_proxmox): """Test get_vms tool.""" mock_proxmox.return_value.nodes.get.return_value = [{"node": "node1", "status": "online"}] mock_proxmox.return_value.nodes.return_value.qemu.get.return_value = [ {"vmid": "100", "name": "vm1", "status": "running"}, {"vmid": "101", "name": "vm2", "status": "stopped"} ] response = await server.mcp.call_tool("get_vms", {}) result = json.loads(response[0].text) assert len(result) > 0 assert result[0]["name"] == "vm1" assert result[1]["name"] == "vm2" @pytest.mark.asyncio async def test_get_containers(server, mock_proxmox): """Test get_containers tool.""" mock_proxmox.return_value.nodes.get.return_value = [{"node": "node1", "status": "online"}] mock_proxmox.return_value.nodes.return_value.lxc.get.return_value = [ {"vmid": "200", "name": "container1", "status": "running"}, {"vmid": "201", "name": "container2", "status": "stopped"} ] response = await server.mcp.call_tool("get_containers", {}) result = json.loads(response[0].text) assert len(result) > 0 assert result[0]["name"] == "container1" assert result[1]["name"] == "container2" @pytest.mark.asyncio async def test_update_container_resources(server, mock_proxmox): """Test update_container_resources tool.""" mock_proxmox.return_value.nodes.get.return_value = [{"node": "node1", "status": "online"}] mock_proxmox.return_value.nodes.return_value.lxc.get.return_value = [ {"vmid": "200", "name": "container1", "status": "running"} ] ct_api = mock_proxmox.return_value.nodes.return_value.lxc.return_value ct_api.config.put.return_value = {} ct_api.resize.put.return_value = {} response = await server.mcp.call_tool( "update_container_resources", {"selector": "node1:200", "cores": 2, "memory": 512, "swap": 256, "disk_gb": 1}, ) result = json.loads(response[0].text) assert result[0]["ok"] is True ct_api.config.put.assert_called_with(cores=2, memory=512, swap=256) ct_api.resize.put.assert_called_with(disk="rootfs", size="+1G") @pytest.mark.asyncio async def test_get_storage(server, mock_proxmox): """Test get_storage tool.""" mock_proxmox.return_value.storage.get.return_value = [ {"storage": "local", "type": "dir"}, {"storage": "ceph", "type": "rbd"} ] response = await server.mcp.call_tool("get_storage", {}) result = json.loads(response[0].text) assert len(result) == 2 assert result[0]["storage"] == "local" assert result[1]["storage"] == "ceph" @pytest.mark.asyncio async def test_get_cluster_status(server, mock_proxmox): """Test get_cluster_status tool.""" mock_proxmox.return_value.cluster.status.get.return_value = { "quorate": True, "nodes": 2 } response = await server.mcp.call_tool("get_cluster_status", {}) result = json.loads(response[0].text) assert result["quorate"] is True assert result["nodes"] == 2 @pytest.mark.asyncio async def test_execute_vm_command_success(server, mock_proxmox): """Test successful VM command execution.""" # Mock VM status check mock_proxmox.return_value.nodes.return_value.qemu.return_value.status.current.get.return_value = { "status": "running" } # Mock command execution mock_proxmox.return_value.nodes.return_value.qemu.return_value.agent.exec.post.return_value = { "out": "command output", "err": "", "exitcode": 0 } response = await server.mcp.call_tool("execute_vm_command", { "node": "node1", "vmid": "100", "command": "ls -l" }) result = json.loads(response[0].text) assert result["success"] is True assert result["output"] == "command output" assert result["error"] == "" assert result["exit_code"] == 0 @pytest.mark.asyncio async def test_execute_vm_command_missing_parameters(server): """Test VM command execution with missing parameters.""" with pytest.raises(ToolError): await server.mcp.call_tool("execute_vm_command", {}) @pytest.mark.asyncio async def test_execute_vm_command_vm_not_running(server, mock_proxmox): """Test VM command execution when VM is not running.""" mock_proxmox.return_value.nodes.return_value.qemu.return_value.status.current.get.return_value = { "status": "stopped" } with pytest.raises(ToolError, match="not running"): await server.mcp.call_tool("execute_vm_command", { "node": "node1", "vmid": "100", "command": "ls -l" }) @pytest.mark.asyncio async def test_execute_vm_command_with_error(server, mock_proxmox): """Test VM command execution with command error.""" # Mock VM status check mock_proxmox.return_value.nodes.return_value.qemu.return_value.status.current.get.return_value = { "status": "running" } # Mock command execution with error mock_proxmox.return_value.nodes.return_value.qemu.return_value.agent.exec.post.return_value = { "out": "", "err": "command not found", "exitcode": 1 } response = await server.mcp.call_tool("execute_vm_command", { "node": "node1", "vmid": "100", "command": "invalid-command" }) result = json.loads(response[0].text) assert result["success"] is True # API call succeeded assert result["output"] == "" assert result["error"] == "command not found" assert result["exit_code"] == 1 @pytest.mark.asyncio async def test_start_vm(server, mock_proxmox): """Test start_vm tool.""" mock_proxmox.return_value.nodes.return_value.qemu.return_value.status.current.get.return_value = { "status": "stopped" } mock_proxmox.return_value.nodes.return_value.qemu.return_value.status.start.post.return_value = "UPID:taskid" response = await server.mcp.call_tool("start_vm", {"node": "node1", "vmid": "100"}) assert "start initiated successfully" in response[0].text ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/server.py: -------------------------------------------------------------------------------- ```python """ Main server implementation for Proxmox MCP. This module implements the core MCP server for Proxmox integration, providing: - Configuration loading and validation - Logging setup - Proxmox API connection management - MCP tool registration and routing - Signal handling for graceful shutdown The server exposes a set of tools for managing Proxmox resources including: - Node management - VM operations - Storage management - Cluster status monitoring """ import logging import os import sys import signal from typing import Optional, List, Annotated, Literal from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp.tools import Tool from mcp.types import TextContent as Content from pydantic import Field, BaseModel from fastapi import Body from .config.loader import load_config from .core.logging import setup_logging from .core.proxmox import ProxmoxManager from .tools.node import NodeTools from .tools.vm import VMTools from .tools.storage import StorageTools from .tools.cluster import ClusterTools from .tools.containers import ContainerTools from .tools.definitions import ( GET_NODES_DESC, GET_NODE_STATUS_DESC, GET_VMS_DESC, CREATE_VM_DESC, EXECUTE_VM_COMMAND_DESC, START_VM_DESC, STOP_VM_DESC, SHUTDOWN_VM_DESC, RESET_VM_DESC, DELETE_VM_DESC, GET_CONTAINERS_DESC, START_CONTAINER_DESC, STOP_CONTAINER_DESC, RESTART_CONTAINER_DESC, UPDATE_CONTAINER_RESOURCES_DESC, GET_STORAGE_DESC, GET_CLUSTER_STATUS_DESC ) class ProxmoxMCPServer: """Main server class for Proxmox MCP.""" def __init__(self, config_path: Optional[str] = None): """Initialize the server. Args: config_path: Path to configuration file """ self.config = load_config(config_path) self.logger = setup_logging(self.config.logging) # Initialize core components self.proxmox_manager = ProxmoxManager(self.config.proxmox, self.config.auth) self.proxmox = self.proxmox_manager.get_api() # Initialize tools self.node_tools = NodeTools(self.proxmox) self.vm_tools = VMTools(self.proxmox) self.storage_tools = StorageTools(self.proxmox) self.cluster_tools = ClusterTools(self.proxmox) self.container_tools = ContainerTools(self.proxmox) # Initialize MCP server self.mcp = FastMCP("ProxmoxMCP") self._setup_tools() def _setup_tools(self) -> None: """Register MCP tools with the server. Initializes and registers all available tools with the MCP server: - Node management tools (list nodes, get status) - VM operation tools (list VMs, execute commands, power management) - Storage management tools (list storage) - Cluster tools (get cluster status) Each tool is registered with appropriate descriptions and parameter validation using Pydantic models. """ # Node tools @self.mcp.tool(description=GET_NODES_DESC) def get_nodes(): return self.node_tools.get_nodes() @self.mcp.tool(description=GET_NODE_STATUS_DESC) def get_node_status( node: Annotated[str, Field(description="Name/ID of node to query (e.g. 'pve1', 'proxmox-node2')")] ): return self.node_tools.get_node_status(node) # VM tools @self.mcp.tool(description=GET_VMS_DESC) def get_vms(): return self.vm_tools.get_vms() @self.mcp.tool(description=CREATE_VM_DESC) def create_vm( node: Annotated[str, Field(description="Host node name (e.g. 'pve')")], vmid: Annotated[str, Field(description="New VM ID number (e.g. '200', '300')")], name: Annotated[str, Field(description="VM name (e.g. 'my-new-vm', 'web-server')")], cpus: Annotated[int, Field(description="Number of CPU cores (e.g. 1, 2, 4)", ge=1, le=32)], memory: Annotated[int, Field(description="Memory size in MB (e.g. 2048 for 2GB)", ge=512, le=131072)], disk_size: Annotated[int, Field(description="Disk size in GB (e.g. 10, 20, 50)", ge=5, le=1000)], storage: Annotated[Optional[str], Field(description="Storage name (optional, will auto-detect)", default=None)] = None, ostype: Annotated[Optional[str], Field(description="OS type (optional, default: 'l26' for Linux)", default=None)] = None ): return self.vm_tools.create_vm(node, vmid, name, cpus, memory, disk_size, storage, ostype) @self.mcp.tool(description=EXECUTE_VM_COMMAND_DESC) async def execute_vm_command( node: Annotated[str, Field(description="Host node name (e.g. 'pve1', 'proxmox-node2')")], vmid: Annotated[str, Field(description="VM ID number (e.g. '100', '101')")], command: Annotated[str, Field(description="Shell command to run (e.g. 'uname -a', 'systemctl status nginx')")] ): return await self.vm_tools.execute_command(node, vmid, command) # VM Power Management tools @self.mcp.tool(description=START_VM_DESC) def start_vm( node: Annotated[str, Field(description="Host node name (e.g. 'pve')")], vmid: Annotated[str, Field(description="VM ID number (e.g. '101')")] ): return self.vm_tools.start_vm(node, vmid) @self.mcp.tool(description=STOP_VM_DESC) def stop_vm( node: Annotated[str, Field(description="Host node name (e.g. 'pve')")], vmid: Annotated[str, Field(description="VM ID number (e.g. '101')")] ): return self.vm_tools.stop_vm(node, vmid) @self.mcp.tool(description=SHUTDOWN_VM_DESC) def shutdown_vm( node: Annotated[str, Field(description="Host node name (e.g. 'pve')")], vmid: Annotated[str, Field(description="VM ID number (e.g. '101')")] ): return self.vm_tools.shutdown_vm(node, vmid) @self.mcp.tool(description=RESET_VM_DESC) def reset_vm( node: Annotated[str, Field(description="Host node name (e.g. 'pve')")], vmid: Annotated[str, Field(description="VM ID number (e.g. '101')")] ): return self.vm_tools.reset_vm(node, vmid) @self.mcp.tool(description=DELETE_VM_DESC) def delete_vm( node: Annotated[str, Field(description="Host node name (e.g. 'pve')")], vmid: Annotated[str, Field(description="VM ID number (e.g. '998')")], force: Annotated[bool, Field(description="Force deletion even if VM is running", default=False)] = False ): return self.vm_tools.delete_vm(node, vmid, force) # Storage tools @self.mcp.tool(description=GET_STORAGE_DESC) def get_storage(): return self.storage_tools.get_storage() # Cluster tools @self.mcp.tool(description=GET_CLUSTER_STATUS_DESC) def get_cluster_status(): return self.cluster_tools.get_cluster_status() # Containers (LXC) class GetContainersPayload(BaseModel): node: Optional[str] = Field(None, description="Optional node name (e.g. 'pve1')") include_stats: bool = Field(True, description="Include live stats and fallbacks") include_raw: bool = Field(False, description="Include raw status/config") format_style: Literal["pretty", "json"] = Field( "pretty", description="'pretty' or 'json'" ) @self.mcp.tool(description=GET_CONTAINERS_DESC) def get_containers( payload: GetContainersPayload = Body(..., embed=True, description="Container query options") ): return self.container_tools.get_containers( node=payload.node, include_stats=payload.include_stats, include_raw=payload.include_raw, format_style=payload.format_style, ) # Container controls @self.mcp.tool(description=START_CONTAINER_DESC) def start_container( selector: Annotated[str, Field(description="CT selector: '123' | 'pve1:123' | 'pve1/name' | 'name' | comma list")], format_style: Annotated[str, Field(description="'pretty' or 'json'", pattern="^(pretty|json)$")] = "pretty", ): return self.container_tools.start_container(selector=selector, format_style=format_style) @self.mcp.tool(description=STOP_CONTAINER_DESC) def stop_container( selector: Annotated[str, Field(description="CT selector (see start_container)")], graceful: Annotated[bool, Field(description="Graceful shutdown (True) or forced stop (False)", default=True)] = True, timeout_seconds: Annotated[int, Field(description="Timeout for stop/shutdown", ge=1, le=600)] = 10, format_style: Annotated[Literal["pretty","json"], Field(description="Output format")] = "pretty", ): return self.container_tools.stop_container( selector=selector, graceful=graceful, timeout_seconds=timeout_seconds, format_style=format_style ) @self.mcp.tool(description=RESTART_CONTAINER_DESC) def restart_container( selector: Annotated[str, Field(description="CT selector (see start_container)")], timeout_seconds: Annotated[int, Field(description="Timeout for reboot", ge=1, le=600)] = 10, format_style: Annotated[str, Field(description="'pretty' or 'json'", pattern="^(pretty|json)$")] = "pretty", ): return self.container_tools.restart_container( selector=selector, timeout_seconds=timeout_seconds, format_style=format_style ) @self.mcp.tool(description=UPDATE_CONTAINER_RESOURCES_DESC) def update_container_resources( selector: Annotated[str, Field(description="CT selector (see start_container)")], cores: Annotated[Optional[int], Field(description="New CPU core count", ge=1)] = None, memory: Annotated[Optional[int], Field(description="New memory limit in MiB", ge=16)] = None, swap: Annotated[Optional[int], Field(description="New swap limit in MiB", ge=0)] = None, disk_gb: Annotated[Optional[int], Field(description="Additional disk size in GiB", ge=1)] = None, disk: Annotated[str, Field(description="Disk to resize", default="rootfs")] = "rootfs", format_style: Annotated[Literal["pretty","json"], Field(description="Output format")] = "pretty", ): return self.container_tools.update_container_resources( selector=selector, cores=cores, memory=memory, swap=swap, disk_gb=disk_gb, disk=disk, format_style=format_style, ) def start(self) -> None: """Start the MCP server. Initializes the server with: - Signal handlers for graceful shutdown (SIGINT, SIGTERM) - Async runtime for handling concurrent requests - Error handling and logging The server runs until terminated by a signal or fatal error. """ import anyio def signal_handler(signum, frame): self.logger.info("Received signal to shutdown...") sys.exit(0) # Set up signal handlers signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) try: self.logger.info("Starting MCP server...") anyio.run(self.mcp.run_stdio_async) except Exception as e: self.logger.error(f"Server error: {e}") sys.exit(1) if __name__ == "__main__": config_path = os.getenv("PROXMOX_MCP_CONFIG") if not config_path: print("PROXMOX_MCP_CONFIG environment variable must be set") sys.exit(1) try: server = ProxmoxMCPServer(config_path) server.start() except KeyboardInterrupt: print("\nShutting down gracefully...") sys.exit(0) except Exception as e: print(f"Error: {e}") sys.exit(1) ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/tools/vm.py: -------------------------------------------------------------------------------- ```python """ VM-related tools for Proxmox MCP. This module provides tools for managing and interacting with Proxmox VMs: - Listing all VMs across the cluster with their status - Retrieving detailed VM information including: * Resource allocation (CPU, memory) * Runtime status * Node placement - Executing commands within VMs via QEMU guest agent - Handling VM console operations - VM power management (start, stop, shutdown, reset) - VM creation with customizable specifications The tools implement fallback mechanisms for scenarios where detailed VM information might be temporarily unavailable. """ from typing import List, Optional from mcp.types import TextContent as Content from .base import ProxmoxTool from .definitions import GET_VMS_DESC, EXECUTE_VM_COMMAND_DESC from .console.manager import VMConsoleManager class VMTools(ProxmoxTool): """Tools for managing Proxmox VMs. Provides functionality for: - Retrieving cluster-wide VM information - Getting detailed VM status and configuration - Executing commands within VMs - Managing VM console operations - VM power management (start, stop, shutdown, reset) - VM creation with customizable specifications Implements fallback mechanisms for scenarios where detailed VM information might be temporarily unavailable. Integrates with QEMU guest agent for VM command execution. """ def __init__(self, proxmox_api): """Initialize VM tools. Args: proxmox_api: Initialized ProxmoxAPI instance """ super().__init__(proxmox_api) self.console_manager = VMConsoleManager(proxmox_api) def get_vms(self) -> List[Content]: """List all virtual machines across the cluster with detailed status. Retrieves comprehensive information for each VM including: - Basic identification (ID, name) - Runtime status (running, stopped) - Resource allocation and usage: * CPU cores * Memory allocation and usage - Node placement Implements a fallback mechanism that returns basic information if detailed configuration retrieval fails for any VM. Returns: List of Content objects containing formatted VM information: { "vmid": "100", "name": "vm-name", "status": "running/stopped", "node": "node-name", "cpus": core_count, "memory": { "used": bytes, "total": bytes } } Raises: RuntimeError: If the cluster-wide VM query fails """ try: result = [] for node in self.proxmox.nodes.get(): node_name = node["node"] vms = self.proxmox.nodes(node_name).qemu.get() for vm in vms: vmid = vm["vmid"] # Get VM config for CPU cores try: config = self.proxmox.nodes(node_name).qemu(vmid).config.get() result.append({ "vmid": vmid, "name": vm["name"], "status": vm["status"], "node": node_name, "cpus": config.get("cores", "N/A"), "memory": { "used": vm.get("mem", 0), "total": vm.get("maxmem", 0) } }) except Exception: # Fallback if can't get config result.append({ "vmid": vmid, "name": vm["name"], "status": vm["status"], "node": node_name, "cpus": "N/A", "memory": { "used": vm.get("mem", 0), "total": vm.get("maxmem", 0) } }) return self._format_response(result, "vms") except Exception as e: self._handle_error("get VMs", e) def create_vm(self, node: str, vmid: str, name: str, cpus: int, memory: int, disk_size: int, storage: Optional[str] = None, ostype: Optional[str] = None) -> List[Content]: """Create a new virtual machine with specified configuration. Args: node: Host node name (e.g., 'pve') vmid: New VM ID number (e.g., '200') name: VM name (e.g., 'my-new-vm') cpus: Number of CPU cores (e.g., 1, 2, 4) memory: Memory size in MB (e.g., 2048 for 2GB) disk_size: Disk size in GB (e.g., 10, 20, 50) storage: Storage name (e.g., 'local-lvm', 'vm-storage'). If None, will auto-detect ostype: OS type (e.g., 'l26' for Linux, 'win10' for Windows). Default: 'l26' Returns: List of Content objects containing creation result Raises: ValueError: If VM ID already exists or invalid parameters RuntimeError: If VM creation fails """ try: # Check if VM ID already exists try: existing_vm = self.proxmox.nodes(node).qemu(vmid).config.get() raise ValueError(f"VM {vmid} already exists on node {node}") except Exception as e: if "does not exist" not in str(e).lower(): raise e # Get storage information storage_list = self.proxmox.nodes(node).storage.get() storage_info = {} for s in storage_list: storage_info[s["storage"]] = s # Auto-detect storage if not specified if storage is None: # Prefer local-lvm for VM images first for s in storage_list: if s["storage"] == "local-lvm" and "images" in s.get("content", ""): storage = s["storage"] break if storage is None: # Then try vm-storage for s in storage_list: if s["storage"] == "vm-storage" and "images" in s.get("content", ""): storage = s["storage"] break if storage is None: # Fallback to any storage that supports images for s in storage_list: if "images" in s.get("content", ""): storage = s["storage"] break if storage is None: raise ValueError("No suitable storage found for VM images") # Validate storage exists and supports images if storage not in storage_info: raise ValueError(f"Storage '{storage}' not found on node {node}") if "images" not in storage_info[storage].get("content", ""): raise ValueError(f"Storage '{storage}' does not support VM images") # Determine appropriate disk format based on storage type storage_type = storage_info[storage]["type"] if storage_type in ["lvm", "lvmthin"]: # LVM storages use raw format and no cloudinit disk_format = "raw" vm_config_storage = { "scsi0": f"{storage}:{disk_size},format={disk_format}", } elif storage_type in ["dir", "nfs", "cifs"]: # File-based storages can use qcow2 disk_format = "qcow2" vm_config_storage = { "scsi0": f"{storage}:{disk_size},format={disk_format}", "ide2": f"{storage}:cloudinit", } else: # Default to raw for unknown storage types disk_format = "raw" vm_config_storage = { "scsi0": f"{storage}:{disk_size},format={disk_format}", } # Set default OS type if ostype is None: ostype = "l26" # Linux 2.6+ kernel # Prepare VM configuration vm_config = { "vmid": vmid, "name": name, "cores": cpus, "memory": memory, "ostype": ostype, "scsihw": "virtio-scsi-pci", "boot": "order=scsi0", "agent": "1", # Enable QEMU guest agent "vga": "std", "net0": "virtio,bridge=vmbr0", } # Add storage configuration vm_config.update(vm_config_storage) # Create the VM task_result = self.proxmox.nodes(node).qemu.create(**vm_config) cloudinit_note = "" if storage_type in ["lvm", "lvmthin"]: cloudinit_note = "\n ⚠️ Note: LVM storage doesn't support cloud-init image" result_text = f"""🎉 VM {vmid} created successfully! 📋 VM Configuration: • Name: {name} • Node: {node} • VM ID: {vmid} • CPU Cores: {cpus} • Memory: {memory} MB ({memory/1024:.1f} GB) • Disk: {disk_size} GB ({storage}, {disk_format} format) • Storage Type: {storage_type} • OS Type: {ostype} • Network: virtio (bridge=vmbr0) • QEMU Agent: Enabled{cloudinit_note} 🔧 Task ID: {task_result} 💡 Next steps: 1. Upload an ISO to install the operating system 2. Start the VM using start_vm tool 3. Access the console to complete OS installation""" return [Content(type="text", text=result_text)] except ValueError as e: raise e except Exception as e: self._handle_error(f"create VM {vmid}", e) def start_vm(self, node: str, vmid: str) -> List[Content]: """Start a virtual machine. Args: node: Host node name (e.g., 'pve1', 'proxmox-node2') vmid: VM ID number (e.g., '100', '101') Returns: List of Content objects containing operation result Raises: ValueError: If VM is not found RuntimeError: If start operation fails """ try: # Check if VM exists and get current status vm_status = self.proxmox.nodes(node).qemu(vmid).status.current.get() current_status = vm_status.get("status") if current_status == "running": result_text = f"🟢 VM {vmid} is already running" else: # Start the VM task_result = self.proxmox.nodes(node).qemu(vmid).status.start.post() result_text = f"🚀 VM {vmid} start initiated successfully\nTask ID: {task_result}" return [Content(type="text", text=result_text)] except Exception as e: if "does not exist" in str(e).lower() or "not found" in str(e).lower(): raise ValueError(f"VM {vmid} not found on node {node}") self._handle_error(f"start VM {vmid}", e) def stop_vm(self, node: str, vmid: str) -> List[Content]: """Stop a virtual machine (force stop). Args: node: Host node name (e.g., 'pve1', 'proxmox-node2') vmid: VM ID number (e.g., '100', '101') Returns: List of Content objects containing operation result Raises: ValueError: If VM is not found RuntimeError: If stop operation fails """ try: # Check if VM exists and get current status vm_status = self.proxmox.nodes(node).qemu(vmid).status.current.get() current_status = vm_status.get("status") if current_status == "stopped": result_text = f"🔴 VM {vmid} is already stopped" else: # Stop the VM task_result = self.proxmox.nodes(node).qemu(vmid).status.stop.post() result_text = f"🛑 VM {vmid} stop initiated successfully\nTask ID: {task_result}" return [Content(type="text", text=result_text)] except Exception as e: if "does not exist" in str(e).lower() or "not found" in str(e).lower(): raise ValueError(f"VM {vmid} not found on node {node}") self._handle_error(f"stop VM {vmid}", e) def shutdown_vm(self, node: str, vmid: str) -> List[Content]: """Shutdown a virtual machine gracefully. Args: node: Host node name (e.g., 'pve1', 'proxmox-node2') vmid: VM ID number (e.g., '100', '101') Returns: List of Content objects containing operation result Raises: ValueError: If VM is not found RuntimeError: If shutdown operation fails """ try: # Check if VM exists and get current status vm_status = self.proxmox.nodes(node).qemu(vmid).status.current.get() current_status = vm_status.get("status") if current_status == "stopped": result_text = f"🔴 VM {vmid} is already stopped" else: # Shutdown the VM gracefully task_result = self.proxmox.nodes(node).qemu(vmid).status.shutdown.post() result_text = f"💤 VM {vmid} graceful shutdown initiated\nTask ID: {task_result}" return [Content(type="text", text=result_text)] except Exception as e: if "does not exist" in str(e).lower() or "not found" in str(e).lower(): raise ValueError(f"VM {vmid} not found on node {node}") self._handle_error(f"shutdown VM {vmid}", e) def reset_vm(self, node: str, vmid: str) -> List[Content]: """Reset (restart) a virtual machine. Args: node: Host node name (e.g., 'pve1', 'proxmox-node2') vmid: VM ID number (e.g., '100', '101') Returns: List of Content objects containing operation result Raises: ValueError: If VM is not found RuntimeError: If reset operation fails """ try: # Check if VM exists and get current status vm_status = self.proxmox.nodes(node).qemu(vmid).status.current.get() current_status = vm_status.get("status") if current_status == "stopped": result_text = f"⚠️ Cannot reset VM {vmid}: VM is currently stopped\nUse start_vm to start it first" else: # Reset the VM task_result = self.proxmox.nodes(node).qemu(vmid).status.reset.post() result_text = f"🔄 VM {vmid} reset initiated successfully\nTask ID: {task_result}" return [Content(type="text", text=result_text)] except Exception as e: if "does not exist" in str(e).lower() or "not found" in str(e).lower(): raise ValueError(f"VM {vmid} not found on node {node}") self._handle_error(f"reset VM {vmid}", e) async def execute_command(self, node: str, vmid: str, command: str) -> List[Content]: """Execute a command in a VM via QEMU guest agent. Uses the QEMU guest agent to execute commands within a running VM. Requires: - VM must be running - QEMU guest agent must be installed and running in the VM - Command execution permissions must be enabled Args: node: Host node name (e.g., 'pve1', 'proxmox-node2') vmid: VM ID number (e.g., '100', '101') command: Shell command to run (e.g., 'uname -a', 'systemctl status nginx') Returns: List of Content objects containing formatted command output: { "success": true/false, "output": "command output", "error": "error message if any" } Raises: ValueError: If VM is not found, not running, or guest agent is not available RuntimeError: If command execution fails due to permissions or other issues """ try: result = await self.console_manager.execute_command(node, vmid, command) # Use the command output formatter from ProxmoxFormatters from ..formatting import ProxmoxFormatters formatted = ProxmoxFormatters.format_command_output( success=result["success"], command=command, output=result["output"], error=result.get("error") ) return [Content(type="text", text=formatted)] except Exception as e: self._handle_error(f"execute command on VM {vmid}", e) def delete_vm(self, node: str, vmid: str, force: bool = False) -> List[Content]: """Delete/remove a virtual machine completely. This will permanently delete the VM and all its associated data including: - VM configuration - Virtual disks - Snapshots WARNING: This operation cannot be undone! Args: node: Host node name (e.g., 'pve1', 'proxmox-node2') vmid: VM ID number (e.g., '100', '101') force: Force deletion even if VM is running (will stop first) Returns: List of Content objects containing deletion result Raises: ValueError: If VM is not found or is running and force=False RuntimeError: If deletion fails """ try: # Check if VM exists and get current status try: vm_status = self.proxmox.nodes(node).qemu(vmid).status.current.get() current_status = vm_status.get("status") vm_name = vm_status.get("name", f"VM-{vmid}") except Exception as e: if "does not exist" in str(e).lower() or "not found" in str(e).lower(): raise ValueError(f"VM {vmid} not found on node {node}") raise e # Check if VM is running if current_status == "running": if not force: raise ValueError(f"VM {vmid} ({vm_name}) is currently running. " f"Please stop it first or use force=True to stop and delete.") else: # Force stop the VM first self.proxmox.nodes(node).qemu(vmid).status.stop.post() result_text = f"🛑 Stopping VM {vmid} ({vm_name}) before deletion...\n" else: result_text = f"🗑️ Deleting VM {vmid} ({vm_name})...\n" # Delete the VM task_result = self.proxmox.nodes(node).qemu(vmid).delete() result_text += f"""🗑️ VM {vmid} ({vm_name}) deletion initiated successfully! ⚠️ WARNING: This operation will permanently remove: • VM configuration • All virtual disks • All snapshots • Cannot be undone! 🔧 Task ID: {task_result} ✅ VM {vmid} ({vm_name}) is being deleted from node {node}""" return [Content(type="text", text=result_text)] except ValueError as e: raise e except Exception as e: self._handle_error(f"delete VM {vmid}", e) ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/tools/containers.py: -------------------------------------------------------------------------------- ```python from typing import List, Dict, Optional, Tuple, Any, Union import json from mcp.types import TextContent as Content from .base import ProxmoxTool def _b2h(n: Union[int, float, str]) -> str: """bytes -> human (binary units).""" try: n = float(n) except Exception: return "0.00 B" units = ("B", "KiB", "MiB", "GiB", "TiB", "PiB") i = 0 while n >= 1024.0 and i < len(units) - 1: n /= 1024.0 i += 1 return f"{n:.2f} {units[i]}" # The rest of the helpers were preserved from your original file; no changes needed def _get(d: Any, key: str, default: Any = None) -> Any: """dict.get with None guard.""" if isinstance(d, dict): return d.get(key, default) return default def _as_dict(maybe: Any) -> Dict: """Return dict; unwrap {'data': dict}; else {}.""" if isinstance(maybe, dict): data = maybe.get("data") if isinstance(data, dict): return data return maybe return {} def _as_list(maybe: Any) -> List: """Return list; unwrap {'data': list}; else [].""" if isinstance(maybe, list): return maybe if isinstance(maybe, dict): data = maybe.get("data") if isinstance(data, list): return data return [] class ContainerTools(ProxmoxTool): """ LXC container tools for Proxmox MCP. - Lists containers cluster-wide (or by node) - Live stats via /status/current - Limit fallback via /config (memory MiB, cores/cpulimit) - RRD fallback when live returns zeros - Pretty output rendered here; JSON path is raw & sanitized """ # ---------- error / output ---------- def _json_fmt(self, data: Any) -> List[Content]: """Return raw JSON string (never touch project formatters).""" return [Content(type="text", text=json.dumps(data, indent=2, sort_keys=True))] def _err(self, action: str, e: Exception) -> List[Content]: if hasattr(self, "handle_error"): return self.handle_error(e, action) # type: ignore[attr-defined] if hasattr(self, "_handle_error"): return self._handle_error(action, e) # type: ignore[attr-defined] return [Content(type="text", text=json.dumps({"error": str(e), "action": action}))] # ---------- helpers ---------- def _list_ct_pairs(self, node: Optional[str]) -> List[Tuple[str, Dict]]: """Yield (node_name, ct_dict). Coerce odd shapes into dicts with vmid.""" out: List[Tuple[str, Dict]] = [] if node: raw = self.proxmox.nodes(node).lxc.get() for it in _as_list(raw): if isinstance(it, dict): out.append((node, it)) else: try: vmid = int(it) out.append((node, {"vmid": vmid})) except Exception: continue else: nodes = _as_list(self.proxmox.nodes.get()) for n in nodes: nname = _get(n, "node") if not nname: continue raw = self.proxmox.nodes(nname).lxc.get() for it in _as_list(raw): if isinstance(it, dict): out.append((nname, it)) else: try: vmid = int(it) out.append((nname, {"vmid": vmid})) except Exception: continue return out def _rrd_last(self, node: str, vmid: int) -> Tuple[Optional[float], Optional[int], Optional[int]]: """Return (cpu_pct, mem_bytes, maxmem_bytes) from the most recent RRD sample.""" try: rrd = _as_list(self.proxmox.nodes(node).lxc(vmid).rrddata.get(timeframe="hour", ds="cpu,mem,maxmem")) if not rrd or not isinstance(rrd[-1], dict): return None, None, None last = rrd[-1] # Proxmox RRD cpu is fraction already (0..1). Convert to percent. cpu_pct = float(_get(last, "cpu", 0.0) or 0.0) * 100.0 mem_bytes = int(_get(last, "mem", 0) or 0) maxmem_bytes = int(_get(last, "maxmem", 0) or 0) return cpu_pct, mem_bytes, maxmem_bytes except Exception: return None, None, None def _status_and_config(self, node: str, vmid: int) -> Tuple[Dict, Dict]: """Return (status_current_dict, config_dict).""" raw_status: Dict = {} raw_config: Dict = {} try: raw_status = _as_dict(self.proxmox.nodes(node).lxc(vmid).status.current.get()) except Exception: raw_status = {} try: raw_config = _as_dict(self.proxmox.nodes(node).lxc(vmid).config.get()) except Exception: raw_config = {} return raw_status, raw_config def _render_pretty(self, rows: List[Dict]) -> List[Content]: lines: List[str] = ["📦 Containers", ""] for r in rows: name = r.get("name") or f"ct-{r.get('vmid')}" vmid = r.get("vmid") status = (r.get("status") or "").upper() node = r.get("node") or "?" cores = r.get("cores") cpu_pct = r.get("cpu_pct", 0.0) mem_bytes = int(r.get("mem_bytes") or 0) maxmem_bytes = int(r.get("maxmem_bytes") or 0) mem_pct = r.get("mem_pct") unlimited = bool(r.get("unlimited_memory", False)) lines.append(f"📦 {name} (ID: {vmid})") lines.append(f" • Status: {status}") lines.append(f" • Node: {node}") lines.append(f" • CPU: {cpu_pct:.1f}%") lines.append(f" • CPU Cores: {cores if cores is not None else 'N/A'}") if unlimited: lines.append(f" • Memory: {_b2h(mem_bytes)} (unlimited)") else: if maxmem_bytes > 0: pct_str = f" ({mem_pct:.1f}%)" if isinstance(mem_pct, (int, float)) else "" lines.append(f" • Memory: {_b2h(mem_bytes)} / {_b2h(maxmem_bytes)}{pct_str}") else: lines.append(f" • Memory: {_b2h(mem_bytes)} / 0.00 B") lines.append("") return [Content(type="text", text="\n".join(lines).rstrip())] # ---------- tool ---------- def get_containers( self, node: Optional[str] = None, include_stats: bool = True, include_raw: bool = False, format_style: str = "pretty", ) -> List[Content]: """ List containers cluster-wide or by node. - `include_stats=True` fetches live CPU/mem from /status/current - RRD fallback is used if live returns zeros - `format_style='json'` returns raw JSON list (sanitized) - `format_style='pretty'` renders a human-friendly table """ try: pairs = self._list_ct_pairs(node) rows: List[Dict] = [] for nname, ct in pairs: vmid_val = _get(ct, "vmid") vmid_int: Optional[int] = None try: if vmid_val is not None: vmid_int = int(vmid_val) except Exception: vmid_int = None rec: Dict = { "vmid": str(vmid_val) if vmid_val is not None else None, "name": _get(ct, "name") or _get(ct, "hostname") or (f"ct-{vmid_val}" if vmid_val is not None else "ct-?"), "node": nname, "status": _get(ct, "status"), } if include_stats and vmid_int is not None: raw_status, raw_config = self._status_and_config(nname, vmid_int) cpu_frac = float(_get(raw_status, "cpu", 0.0) or 0.0) cpu_pct = round(cpu_frac * 100.0, 2) mem_bytes = int(_get(raw_status, "mem", 0) or 0) maxmem_bytes = int(_get(raw_status, "maxmem", 0) or 0) memory_mib = 0 cores: Optional[Union[int, float]] = None unlimited_memory = False try: cfg_mem = _get(raw_config, "memory") if cfg_mem is None: cfg_mem = _get(raw_config, "ram") if cfg_mem is None: cfg_mem = _get(raw_config, "maxmem") if cfg_mem is None: cfg_mem = _get(raw_config, "memoryMiB") if cfg_mem is not None: try: memory_mib = int(cfg_mem) except Exception: memory_mib = 0 else: memory_mib = 0 unlimited_memory = bool(_get(raw_config, "swap", 0) == 0 and memory_mib == 0) cfg_cores = _get(raw_config, "cores") cfg_cpulimit = _get(raw_config, "cpulimit") if cfg_cores is not None: cores = int(cfg_cores) elif cfg_cpulimit is not None and float(cfg_cpulimit) > 0: cores = float(cfg_cpulimit) except Exception: cores = None # --- NEW: fallbacks for stopped / missing maxmem --- status_str = str(_get(raw_status, "status") or _get(ct, "status") or "").lower() if status_str == "stopped": try: mem_bytes = 0 except Exception: mem_bytes = 0 if (not maxmem_bytes or int(maxmem_bytes) == 0) and memory_mib and int(memory_mib) > 0: try: maxmem_bytes = int(memory_mib) * 1024 * 1024 except Exception: maxmem_bytes = 0 # RRD fallback if zeros if (mem_bytes == 0) or (maxmem_bytes == 0) or (cpu_pct == 0.0): rrd_cpu, rrd_mem, rrd_maxmem = self._rrd_last(nname, vmid_int) if cpu_pct == 0.0 and rrd_cpu is not None: cpu_pct = rrd_cpu if mem_bytes == 0 and rrd_mem is not None: mem_bytes = rrd_mem if maxmem_bytes == 0 and rrd_maxmem: maxmem_bytes = rrd_maxmem if memory_mib == 0: try: memory_mib = int(round(maxmem_bytes / (1024 * 1024))) except Exception: memory_mib = 0 rec.update({ "cores": cores, "memory": memory_mib, "cpu_pct": cpu_pct, "mem_bytes": mem_bytes, "maxmem_bytes": maxmem_bytes, "mem_pct": ( round((mem_bytes / maxmem_bytes * 100.0), 2) if (maxmem_bytes and maxmem_bytes > 0) else None ), "unlimited_memory": unlimited_memory, }) # For PRETTY only: allow raw blobs to be attached if requested. if include_raw and format_style != "json": rec["raw_status"] = raw_status rec["raw_config"] = raw_config rows.append(rec) if format_style == "json": # JSON path must be immune to any formatter assumptions; no raw payloads. return self._json_fmt(rows) return self._render_pretty(rows) except Exception as e: return self._err("Failed to list containers", e) # ---------- target resolution for control ops ---------- def _resolve_targets(self, selector: str) -> List[Tuple[str, int, str]]: """ Turn a selector string into a list of (node, vmid, label). Supports: - '123' (vmid across cluster) - 'pve1:123' (node:vmid) - 'pve1/name' (node/name) - 'name' (by name/hostname across the cluster) - comma-separated list of any of the above """ if not selector: return [] tokens = [t.strip() for t in selector.split(",") if t.strip()] inventory: List[Tuple[str, Dict[str, Any]]] = self._list_ct_pairs(node=None) resolved: List[Tuple[str, int, str]] = [] for tok in tokens: if ":" in tok and "/" not in tok: node, vmid_s = tok.split(":", 1) try: vmid = int(vmid_s) except Exception: continue for n, ct in inventory: if n == node and int(_get(ct, "vmid", -1)) == vmid: label = _get(ct, "name") or _get(ct, "hostname") or f"ct-{vmid}" resolved.append((node, vmid, label)) break continue if "/" in tok and ":" not in tok: node, name = tok.split("/", 1) name = name.strip() for n, ct in inventory: if n == node and (_get(ct, "name") == name or _get(ct, "hostname") == name): vmid = int(_get(ct, "vmid", -1)) if vmid >= 0: resolved.append((node, vmid, name)) continue if tok.isdigit(): vmid = int(tok) for n, ct in inventory: if int(_get(ct, "vmid", -1)) == vmid: label = _get(ct, "name") or _get(ct, "hostname") or f"ct-{vmid}" resolved.append((n, vmid, label)) continue name = tok for n, ct in inventory: if _get(ct, "name") == name or _get(ct, "hostname") == name: vmid = int(_get(ct, "vmid", -1)) if vmid >= 0: resolved.append((n, vmid, name)) uniq = {} for n, v, lbl in resolved: uniq[(n, v)] = lbl return [(n, v, uniq[(n, v)]) for (n, v) in uniq.keys()] def _render_action_result(self, title: str, results: List[Dict[str, Any]]) -> List[Content]: """Pretty-print an action result; JSON stays raw.""" lines = [f"📦 {title}", ""] for r in results: status = "✅ OK" if r.get("ok") else "❌ FAIL" node = r.get("node") vmid = r.get("vmid") name = r.get("name") or f"ct-{vmid}" msg = r.get("message") or r.get("error") or "" lines.append(f"{status} {name} (ID: {vmid}, node: {node}) {('- ' + str(msg)) if msg else ''}") return [Content(type="text", text="\n".join(lines).rstrip())] # ---------- container control tools ---------- def start_container(self, selector: str, format_style: str = "pretty") -> List[Content]: """ Start LXC containers matching `selector`. selector examples: '123', 'pve1:123', 'pve1/name', 'name', 'pve1:101,pve2/web' """ try: targets = self._resolve_targets(selector) if not targets: return self._err("No containers matched the selector", ValueError(selector)) results: List[Dict[str, Any]] = [] for node, vmid, label in targets: try: resp = self.proxmox.nodes(node).lxc(vmid).status.start.post() results.append({"ok": True, "node": node, "vmid": vmid, "name": label, "message": resp}) except Exception as e: results.append({"ok": False, "node": node, "vmid": vmid, "name": label, "error": str(e)}) if format_style == "json": return self._json_fmt(results) return self._render_action_result("Start Containers", results) except Exception as e: return self._err("Failed to start container(s)", e) def stop_container(self, selector: str, graceful: bool = True, timeout_seconds: int = 10, format_style: str = "pretty") -> List[Content]: """ Stop LXC containers. graceful=True → POST .../status/shutdown (graceful stop) graceful=False → POST .../status/stop (force stop) """ try: targets = self._resolve_targets(selector) if not targets: return self._err("No containers matched the selector", ValueError(selector)) results: List[Dict[str, Any]] = [] for node, vmid, label in targets: try: if graceful: resp = self.proxmox.nodes(node).lxc(vmid).status.shutdown.post(timeout=timeout_seconds) else: resp = self.proxmox.nodes(node).lxc(vmid).status.stop.post() results.append({"ok": True, "node": node, "vmid": vmid, "name": label, "message": resp}) except Exception as e: results.append({"ok": False, "node": node, "vmid": vmid, "name": label, "error": str(e)}) if format_style == "json": return self._json_fmt(results) return self._render_action_result("Stop Containers", results) except Exception as e: return self._err("Failed to stop container(s)", e) def restart_container(self, selector: str, timeout_seconds: int = 10, format_style: str = "pretty") -> List[Content]: """ Restart LXC containers via POST .../status/reboot. """ try: targets = self._resolve_targets(selector) if not targets: return self._err("No containers matched the selector", ValueError(selector)) results: List[Dict[str, Any]] = [] for node, vmid, label in targets: try: resp = self.proxmox.nodes(node).lxc(vmid).status.reboot.post() results.append({"ok": True, "node": node, "vmid": vmid, "name": label, "message": resp}) except Exception as e: results.append({"ok": False, "node": node, "vmid": vmid, "name": label, "error": str(e)}) if format_style == "json": return self._json_fmt(results) return self._render_action_result("Restart Containers", results) except Exception as e: return self._err("Failed to restart container(s)", e) def update_container_resources( self, selector: str, cores: Optional[int] = None, memory: Optional[int] = None, swap: Optional[int] = None, disk_gb: Optional[int] = None, disk: str = "rootfs", format_style: str = "pretty", ) -> List[Content]: """Update container CPU/memory/swap limits and/or extend disk size. Parameters: selector: Container selector (same grammar as start_container) cores: New CPU core count memory: New memory limit in MiB swap: New swap limit in MiB disk_gb: Additional disk size to add in GiB disk: Disk identifier to resize (default 'rootfs') format_style: Output format ('pretty' or 'json') """ try: targets = self._resolve_targets(selector) if not targets: return self._err("No containers matched the selector", ValueError(selector)) results: List[Dict[str, Any]] = [] for node, vmid, label in targets: rec: Dict[str, Any] = {"ok": True, "node": node, "vmid": vmid, "name": label} changes: List[str] = [] try: update_params: Dict[str, Any] = {} if cores is not None: update_params["cores"] = cores changes.append(f"cores={cores}") if memory is not None: update_params["memory"] = memory changes.append(f"memory={memory}MiB") if swap is not None: update_params["swap"] = swap changes.append(f"swap={swap}MiB") if update_params: self.proxmox.nodes(node).lxc(vmid).config.put(**update_params) if disk_gb is not None: size_str = f"+{disk_gb}G" # Use PUT for disk resize - some Proxmox versions reject POST self.proxmox.nodes(node).lxc(vmid).resize.put(disk=disk, size=size_str) changes.append(f"{disk}+={disk_gb}G") rec["message"] = ", ".join(changes) if changes else "no changes" except Exception as e: rec["ok"] = False rec["error"] = str(e) results.append(rec) if format_style == "json": return self._json_fmt(results) return self._render_action_result("Update Container Resources", results) except Exception as e: return self._err("Failed to update container(s)", e) ```