This is page 1 of 2. Use http://codebase.md/rekklesna/proxmoxmcp-plus?lines=true&page={x} to view the full context. # 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: -------------------------------------------------------------------------------- ``` 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | # Virtual Environment 24 | .env 25 | .venv 26 | env/ 27 | venv/ 28 | ENV/ 29 | 30 | # IDE 31 | .idea/ 32 | .vscode/ 33 | *.swp 34 | *.swo 35 | .project 36 | .pydevproject 37 | .settings/ 38 | 39 | # Logs 40 | *.log 41 | logs/ 42 | 43 | # Test coverage 44 | .coverage 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # UV 58 | .uv/ 59 | 60 | 61 | # Local configuration 62 | config/config.json 63 | proxmox-config/config.json 64 | **/config.json 65 | ``` -------------------------------------------------------------------------------- /test_scripts/README.md: -------------------------------------------------------------------------------- ```markdown 1 | # ProxmoxMCP Test Scripts 2 | 3 | This folder contains various test scripts and demo programs for the ProxmoxMCP project. 4 | 5 | ## 📁 File Description 6 | 7 | ### 🔧 VM Management Tests 8 | - **`test_vm_power.py`** - VM power management functionality test 9 | - Test VM start, stop, restart and other operations 10 | - Check VM status and available operations 11 | 12 | - **`test_vm_start.py`** - VM startup functionality specific test 13 | - Dedicated test for VM startup functionality 14 | - Suitable for single VM startup testing 15 | 16 | - **`test_create_vm.py`** - VM creation functionality test 17 | - Test complete workflow of creating new virtual machines 18 | - Verify 1 CPU core + 2GB RAM + 10GB storage configuration 19 | 20 | ### 🌐 API Tests 21 | - **`test_openapi.py`** - OpenAPI service comprehensive test 22 | - Test all API endpoints 23 | - Include VM creation, power management and other functionalities 24 | - Verify integration with Open WebUI 25 | 26 | ## 🚀 Usage 27 | 28 | ### Environment Setup 29 | ```bash 30 | # Activate virtual environment 31 | source ../.venv/bin/activate 32 | 33 | # Set configuration path (if needed) 34 | export PROXMOX_MCP_CONFIG=../proxmox-config/config.json 35 | ``` 36 | 37 | ### Running Tests 38 | 39 | #### 1. Test VM Power Management 40 | ```bash 41 | python test_vm_power.py 42 | ``` 43 | 44 | #### 2. Test VM Creation 45 | ```bash 46 | python test_create_vm.py 47 | ``` 48 | 49 | #### 3. Test OpenAPI Service 50 | ```bash 51 | python test_openapi.py 52 | ``` 53 | 54 | #### 4. Test VM Startup 55 | ```bash 56 | python test_vm_start.py 57 | ``` 58 | 59 | ## 📋 Test Coverage 60 | 61 | ### ✅ Tested Features 62 | - [x] VM list retrieval 63 | - [x] VM status query 64 | - [x] VM power management (start/stop/restart/shutdown) 65 | - [x] VM creation (support custom CPU/memory/storage) 66 | - [x] Storage type auto-detection 67 | - [x] Disk format intelligent selection 68 | - [x] OpenAPI service integration 69 | - [x] Error handling verification 70 | 71 | ### 🎯 Test Scenarios 72 | - **Basic functionality**: Connection, authentication, basic operations 73 | - **VM lifecycle**: Create, start, stop, delete 74 | - **Storage compatibility**: LVM, filesystem storage 75 | - **API integration**: REST API calls and responses 76 | - **Error recovery**: Exception handling 77 | 78 | ## 🔗 Related Documentation 79 | 80 | - **Main project documentation**: [../README.md](../README.md) 81 | - **VM creation guide**: [../VM_CREATION_GUIDE.md](../VM_CREATION_GUIDE.md) 82 | - **OpenAPI deployment**: [../OPENAPI_DEPLOYMENT.md](../OPENAPI_DEPLOYMENT.md) 83 | - **Quick deployment**: [../QUICK_DEPLOY_8811.md](../QUICK_DEPLOY_8811.md) 84 | 85 | ## 📊 Test Results Examples 86 | 87 | ### Success Cases 88 | ``` 89 | ✅ VM 995: Created successfully (local-lvm, raw) 90 | ✅ VM 996: Created successfully (vm-storage, raw) 91 | ✅ VM 998: Created successfully (local-lvm, raw) 92 | ✅ VM 999: Created successfully (local-lvm, raw) 93 | ``` 94 | 95 | ### API Endpoint Verification 96 | ``` 97 | ✅ get_nodes: 200 - 134 chars 98 | ✅ get_vms: 200 - 1843 chars 99 | ✅ create_vm: 200 - VM created successfully 100 | ✅ start_vm: 200 - VM started successfully 101 | ``` 102 | 103 | ## 🛠️ Troubleshooting 104 | 105 | If tests fail, please check: 106 | 107 | 1. **Configuration file**: Whether `../proxmox-config/config.json` is correct 108 | 2. **Network connection**: Whether Proxmox server is reachable 109 | 3. **Authentication info**: Whether API token is valid 110 | 4. **Service status**: Whether OpenAPI service is running on port 8811 111 | 112 | ## 📝 Contributing Guidelines 113 | 114 | When adding new tests, please: 115 | 116 | 1. Use descriptive filenames (e.g., `test_function_name.py`) 117 | 2. Include detailed docstrings 118 | 3. Add appropriate error handling 119 | 4. Update this README file 120 | 121 | --- 122 | 123 | **Last Updated**: December 2024 124 | **Maintainer**: ProxmoxMCP Development Team ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # ProxmoxMCP-Plus - Enhanced Proxmox MCP Server 2 | 3 | 4 | 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. 5 | 6 | ## Acknowledgments 7 | 8 | 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! 9 | 10 | ## 🆕 New Features and Improvements 11 | 12 | ### Major enhancements compared to the original version: 13 | 14 | - ✨ **Complete VM Lifecycle Management** 15 | - Brand new `create_vm` tool - Support for creating virtual machines with custom configurations 16 | - New `delete_vm` tool - Safe VM deletion (with force deletion option) 17 | - Enhanced intelligent storage type detection (LVM/file-based) 18 | 19 | - 🔧 **Extended Power Management Features** 20 | - `start_vm` - Start virtual machines 21 | - `stop_vm` - Force stop virtual machines 22 | - `shutdown_vm` - Graceful shutdown 23 | - `reset_vm` - Restart virtual machines 24 | 25 | - 🐳 **New Container Support** 26 | - `get_containers` - List all LXC containers and their status 27 | - `start_container` - Start LXC container 28 | - `stop_container` - Stop LXC container 29 | - `restart_container` - Restart LXC container (forcefully/gracefully) 30 | - `update_container_resources` - Adjust container CPU, memory, swap, or extend disk 31 | 32 | - 📊 **Enhanced Monitoring and Display** 33 | - Improved storage pool status monitoring 34 | - More detailed cluster health status checks 35 | - Rich output formatting and themes 36 | 37 | - 🌐 **Complete OpenAPI Integration** 38 | - 11 complete REST API endpoints 39 | - Production-ready Docker deployment 40 | - Perfect Open WebUI integration 41 | - Natural language VM creation support 42 | 43 | - 🛡️ **Production-grade Security and Stability** 44 | - Enhanced error handling mechanisms 45 | - Comprehensive parameter validation 46 | - Production-level logging 47 | - Complete unit test coverage 48 | 49 | ## Built With 50 | 51 | - [Cline](https://github.com/cline/cline) - Autonomous coding agent - Go faster with Cline 52 | - [Proxmoxer](https://github.com/proxmoxer/proxmoxer) - Python wrapper for Proxmox API 53 | - [MCP SDK](https://github.com/modelcontextprotocol/sdk) - Model Context Protocol SDK 54 | - [Pydantic](https://docs.pydantic.dev/) - Data validation using Python type annotations 55 | 56 | ## Features 57 | 58 | - 🤖 Full integration with Cline and Open WebUI 59 | - 🛠️ Built with the official MCP SDK 60 | - 🔒 Secure token-based authentication with Proxmox 61 | - 🖥️ Complete VM lifecycle management (create, start, stop, reset, shutdown, delete) 62 | - 💻 VM console command execution 63 | - 🐳 LXC container management support 64 | - 🗃️ Intelligent storage type detection (LVM/file-based) 65 | - 📝 Configurable logging system 66 | - ✅ Type-safe implementation with Pydantic 67 | - 🎨 Rich output formatting with customizable themes 68 | - 🌐 OpenAPI REST endpoints for integration 69 | - 📡 11 fully functional API endpoints 70 | 71 | 72 | ## Installation 73 | 74 | ### Prerequisites 75 | - UV package manager (recommended) 76 | - Python 3.9 or higher 77 | - Git 78 | - Access to a Proxmox server with API token credentials 79 | 80 | Before starting, ensure you have: 81 | - [ ] Proxmox server hostname or IP 82 | - [ ] Proxmox API token (see [API Token Setup](#proxmox-api-token-setup)) 83 | - [ ] UV installed (`pip install uv`) 84 | 85 | ### Option 1: Quick Install (Recommended) 86 | 87 | 1. Clone and set up environment: 88 | ```bash 89 | # Clone repository 90 | git clone https://github.com/RekklesNA/ProxmoxMCP-Plus.git 91 | cd ProxmoxMCP-Plus 92 | 93 | # Create and activate virtual environment 94 | uv venv 95 | source .venv/bin/activate # Linux/macOS 96 | # OR 97 | .\.venv\Scripts\Activate.ps1 # Windows 98 | ``` 99 | 100 | 2. Install dependencies: 101 | ```bash 102 | # Install with development dependencies 103 | uv pip install -e ".[dev]" 104 | ``` 105 | 106 | 3. Create configuration: 107 | ```bash 108 | # Create config directory and copy template 109 | mkdir -p proxmox-config 110 | cp proxmox-config/config.example.json proxmox-config/config.json 111 | ``` 112 | 113 | 4. Edit `proxmox-config/config.json`: 114 | ```json 115 | { 116 | "proxmox": { 117 | "host": "PROXMOX_HOST", # Required: Your Proxmox server address 118 | "port": 8006, # Optional: Default is 8006 119 | "verify_ssl": false, # Optional: Set false for self-signed certs 120 | "service": "PVE" # Optional: Default is PVE 121 | }, 122 | "auth": { 123 | "user": "USER@pve", # Required: Your Proxmox username 124 | "token_name": "TOKEN_NAME", # Required: API token ID 125 | "token_value": "TOKEN_VALUE" # Required: API token value 126 | }, 127 | "logging": { 128 | "level": "INFO", # Optional: DEBUG for more detail 129 | "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", 130 | "file": "proxmox_mcp.log" # Optional: Log to file 131 | } 132 | } 133 | ``` 134 | 135 | ### Verifying Installation 136 | 137 | 1. Check Python environment: 138 | ```bash 139 | python -c "import proxmox_mcp; print('Installation OK')" 140 | ``` 141 | 142 | 2. Run the tests: 143 | ```bash 144 | pytest 145 | ``` 146 | 147 | 3. Verify configuration: 148 | ```bash 149 | # Linux/macOS 150 | PROXMOX_MCP_CONFIG="proxmox-config/config.json" python -m proxmox_mcp.server 151 | 152 | # Windows (PowerShell) 153 | $env:PROXMOX_MCP_CONFIG="proxmox-config\config.json"; python -m proxmox_mcp.server 154 | ``` 155 | 156 | ## Configuration 157 | 158 | ### Proxmox API Token Setup 159 | 1. Log into your Proxmox web interface 160 | 2. Navigate to Datacenter -> Permissions -> API Tokens 161 | 3. Create a new API token: 162 | - Select a user (e.g., root@pam) 163 | - Enter a token ID (e.g., "mcp-token") 164 | - Uncheck "Privilege Separation" if you want full access 165 | - Save and copy both the token ID and secret 166 | 167 | ## Running the Server 168 | 169 | ### Development Mode 170 | For testing and development: 171 | ```bash 172 | # Activate virtual environment first 173 | source .venv/bin/activate # Linux/macOS 174 | # OR 175 | .\.venv\Scripts\Activate.ps1 # Windows 176 | 177 | # Run the server 178 | python -m proxmox_mcp.server 179 | ``` 180 | 181 | ### OpenAPI Deployment (Production Ready) 182 | 183 | Deploy ProxmoxMCP Plus as standard OpenAPI REST endpoints for integration with Open WebUI and other applications. 184 | 185 | #### Quick OpenAPI Start 186 | ```bash 187 | # Install mcpo (MCP-to-OpenAPI proxy) 188 | pip install mcpo 189 | 190 | # Start OpenAPI service on port 8811 191 | ./start_openapi.sh 192 | ``` 193 | 194 | #### Docker Deployment 195 | ```bash 196 | # Build and run with Docker 197 | docker build -t proxmox-mcp-api . 198 | docker run -d --name proxmox-mcp-api -p 8811:8811 \ 199 | -v $(pwd)/proxmox-config:/app/proxmox-config proxmox-mcp-api 200 | 201 | # Or use Docker Compose 202 | docker-compose up -d 203 | ``` 204 | 205 | #### Access OpenAPI Service 206 | Once deployed, access your service at: 207 | - **📖 API Documentation**: http://your-server:8811/docs 208 | - **🔧 OpenAPI Specification**: http://your-server:8811/openapi.json 209 | - **❤️ Health Check**: http://your-server:8811/health 210 | 211 | ### Cline Desktop Integration 212 | 213 | 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`): 214 | 215 | ```json 216 | { 217 | "mcpServers": { 218 | "ProxmoxMCP-Plus": { 219 | "command": "/absolute/path/to/ProxmoxMCP-Plus/.venv/bin/python", 220 | "args": ["-m", "proxmox_mcp.server"], 221 | "cwd": "/absolute/path/to/ProxmoxMCP-Plus", 222 | "env": { 223 | "PYTHONPATH": "/absolute/path/to/ProxmoxMCP-Plus/src", 224 | "PROXMOX_MCP_CONFIG": "/absolute/path/to/ProxmoxMCP-Plus/proxmox-config/config.json", 225 | "PROXMOX_HOST": "your-proxmox-host", 226 | "PROXMOX_USER": "username@pve", 227 | "PROXMOX_TOKEN_NAME": "token-name", 228 | "PROXMOX_TOKEN_VALUE": "token-value", 229 | "PROXMOX_PORT": "8006", 230 | "PROXMOX_VERIFY_SSL": "false", 231 | "PROXMOX_SERVICE": "PVE", 232 | "LOG_LEVEL": "DEBUG" 233 | }, 234 | "disabled": false, 235 | "autoApprove": [] 236 | } 237 | } 238 | } 239 | ``` 240 | 241 | ## Available Tools & API Endpoints 242 | 243 | The server provides 11 comprehensive MCP tools and corresponding REST API endpoints: 244 | 245 | ### VM Management Tools 246 | 247 | #### create_vm 248 | Create a new virtual machine with specified resources. 249 | 250 | **Parameters:** 251 | - `node` (string, required): Name of the node 252 | - `vmid` (string, required): ID for the new VM 253 | - `name` (string, required): Name for the VM 254 | - `cpus` (integer, required): Number of CPU cores (1-32) 255 | - `memory` (integer, required): Memory in MB (512-131072) 256 | - `disk_size` (integer, required): Disk size in GB (5-1000) 257 | - `storage` (string, optional): Storage pool name 258 | - `ostype` (string, optional): OS type (default: l26) 259 | 260 | **API Endpoint:** 261 | ```http 262 | POST /create_vm 263 | Content-Type: application/json 264 | 265 | { 266 | "node": "pve", 267 | "vmid": "200", 268 | "name": "my-vm", 269 | "cpus": 1, 270 | "memory": 2048, 271 | "disk_size": 10 272 | } 273 | ``` 274 | 275 | **Example Response:** 276 | ``` 277 | 🎉 VM 200 created successfully! 278 | 279 | 📋 VM Configuration: 280 | • Name: my-vm 281 | • Node: pve 282 | • VM ID: 200 283 | • CPU Cores: 1 284 | • Memory: 2048 MB (2.0 GB) 285 | • Disk: 10 GB (local-lvm, raw format) 286 | • Storage Type: lvmthin 287 | • Network: virtio (bridge=vmbr0) 288 | • QEMU Agent: Enabled 289 | 290 | 🔧 Task ID: UPID:pve:001AB729:0442E853:682FF380:qmcreate:200:root@pam!mcp 291 | ``` 292 | 293 | #### VM Power Management 🆕 294 | 295 | **start_vm**: Start a virtual machine 296 | ```http 297 | POST /start_vm 298 | {"node": "pve", "vmid": "200"} 299 | ``` 300 | 301 | **stop_vm**: Force stop a virtual machine 302 | ```http 303 | POST /stop_vm 304 | {"node": "pve", "vmid": "200"} 305 | ``` 306 | 307 | **shutdown_vm**: Gracefully shutdown a virtual machine 308 | ```http 309 | POST /shutdown_vm 310 | {"node": "pve", "vmid": "200"} 311 | ``` 312 | 313 | **reset_vm**: Reset (restart) a virtual machine 314 | ```http 315 | POST /reset_vm 316 | {"node": "pve", "vmid": "200"} 317 | ``` 318 | 319 | **delete_vm** 🆕: Completely delete a virtual machine 320 | ```http 321 | POST /delete_vm 322 | {"node": "pve", "vmid": "200", "force": false} 323 | ``` 324 | 325 | ### 🆕 Container Management Tools 326 | 327 | #### get_containers 🆕 328 | List all LXC containers across the cluster. 329 | 330 | **API Endpoint:** `POST /get_containers` 331 | 332 | **Example Response:** 333 | ``` 334 | 🐳 Containers 335 | 336 | 🐳 nginx-server (ID: 200) 337 | • Status: RUNNING 338 | • Node: pve 339 | • CPU Cores: 2 340 | • Memory: 1.5 GB / 2.0 GB (75.0%) 341 | ``` 342 | 343 | ### Monitoring Tools 344 | 345 | #### get_nodes 346 | Lists all nodes in the Proxmox cluster. 347 | 348 | **API Endpoint:** `POST /get_nodes` 349 | 350 | **Example Response:** 351 | ``` 352 | 🖥️ Proxmox Nodes 353 | 354 | 🖥️ pve-compute-01 355 | • Status: ONLINE 356 | • Uptime: ⏳ 156d 12h 357 | • CPU Cores: 64 358 | • Memory: 186.5 GB / 512.0 GB (36.4%) 359 | ``` 360 | 361 | #### get_node_status 362 | Get detailed status of a specific node. 363 | 364 | **Parameters:** 365 | - `node` (string, required): Name of the node 366 | 367 | **API Endpoint:** `POST /get_node_status` 368 | 369 | #### get_vms 370 | List all VMs across the cluster. 371 | 372 | **API Endpoint:** `POST /get_vms` 373 | 374 | #### get_storage 375 | List available storage pools. 376 | 377 | **API Endpoint:** `POST /get_storage` 378 | 379 | #### get_cluster_status 380 | Get overall cluster status and health. 381 | 382 | **API Endpoint:** `POST /get_cluster_status` 383 | 384 | #### execute_vm_command 385 | Execute a command in a VM's console using QEMU Guest Agent. 386 | 387 | **Parameters:** 388 | - `node` (string, required): Name of the node where VM is running 389 | - `vmid` (string, required): ID of the VM 390 | - `command` (string, required): Command to execute 391 | 392 | **API Endpoint:** `POST /execute_vm_command` 393 | 394 | **Requirements:** 395 | - VM must be running 396 | - QEMU Guest Agent must be installed and running in the VM 397 | 398 | ## Open WebUI Integration 399 | 400 | ### Configure Open WebUI 401 | 402 | 1. Access your Open WebUI instance 403 | 2. Navigate to **Settings** → **Connections** → **OpenAPI** 404 | 3. Add new API configuration: 405 | 406 | ```json 407 | { 408 | "name": "Proxmox MCP API Plus", 409 | "base_url": "http://your-server:8811", 410 | "api_key": "", 411 | "description": "Enhanced Proxmox Virtualization Management API" 412 | } 413 | ``` 414 | 415 | ### Natural Language VM Creation 416 | 417 | Users can now request VMs using natural language: 418 | 419 | - **"Can you create a VM with 1 cpu core and 2 GB ram with 10GB of storage disk"** 420 | - **"Create a new VM for testing with minimal resources"** 421 | - **"I need a development server with 4 cores and 8GB RAM"** 422 | 423 | The AI assistant will automatically call the appropriate APIs and provide detailed feedback. 424 | 425 | ## Storage Type Support 426 | 427 | ### Intelligent Storage Detection 428 | 429 | ProxmoxMCP Plus automatically detects storage types and selects appropriate disk formats: 430 | 431 | #### LVM Storage (local-lvm, vm-storage) 432 | - ✅ Format: `raw` 433 | - ✅ High performance 434 | - ⚠️ No cloud-init image support 435 | 436 | #### File-based Storage (local, NFS, CIFS) 437 | - ✅ Format: `qcow2` 438 | - ✅ Cloud-init support 439 | - ✅ Flexible snapshot capabilities 440 | 441 | ## Project Structure 442 | 443 | ``` 444 | ProxmoxMCP-Plus/ 445 | ├── 📁 src/ # Source code 446 | │ └── proxmox_mcp/ 447 | │ ├── server.py # Main MCP server implementation 448 | │ ├── config/ # Configuration handling 449 | │ ├── core/ # Core functionality 450 | │ ├── formatting/ # Output formatting and themes 451 | │ ├── tools/ # Tool implementations 452 | │ │ ├── vm.py # VM management (create/power) 🆕 453 | │ │ ├── container.py # Container management 🆕 454 | │ │ └── console/ # VM console operations 455 | │ └── utils/ # Utilities (auth, logging) 456 | │ 457 | ├── 📁 tests/ # Unit test suite 458 | ├── 📁 test_scripts/ # Integration tests & demos 459 | │ ├── README.md # Test documentation 460 | │ ├── test_vm_power.py # VM power management tests 🆕 461 | │ ├── test_vm_start.py # VM startup tests 462 | │ ├── test_create_vm.py # VM creation tests 🆕 463 | │ └── test_openapi.py # OpenAPI service tests 464 | │ 465 | ├── 📁 proxmox-config/ # Configuration files 466 | │ └── config.json # Server configuration 467 | │ 468 | ├── 📄 Configuration Files 469 | │ ├── pyproject.toml # Project metadata 470 | │ ├── docker-compose.yml # Docker orchestration 471 | │ ├── Dockerfile # Docker image definition 472 | │ └── requirements.in # Dependencies 473 | │ 474 | ├── 📄 Scripts 475 | │ ├── start_server.sh # MCP server launcher 476 | │ └── start_openapi.sh # OpenAPI service launcher 477 | │ 478 | └── 📄 Documentation 479 | ├── README.md # This file 480 | ├── VM_CREATION_GUIDE.md # VM creation guide 481 | ├── OPENAPI_DEPLOYMENT.md # OpenAPI deployment 482 | └── LICENSE # MIT License 483 | ``` 484 | 485 | ## Testing 486 | 487 | ### Run Unit Tests 488 | ```bash 489 | pytest 490 | ``` 491 | 492 | ### Run Integration Tests 493 | ```bash 494 | cd test_scripts 495 | 496 | # Test VM power management 497 | python test_vm_power.py 498 | 499 | # Test VM creation 500 | python test_create_vm.py 501 | 502 | # Test OpenAPI service 503 | python test_openapi.py 504 | ``` 505 | 506 | ### API Testing with curl 507 | ```bash 508 | # Test node listing 509 | curl -X POST "http://your-server:8811/get_nodes" \ 510 | -H "Content-Type: application/json" \ 511 | -d "{}" 512 | 513 | # Test VM creation 514 | curl -X POST "http://your-server:8811/create_vm" \ 515 | -H "Content-Type: application/json" \ 516 | -d '{ 517 | "node": "pve", 518 | "vmid": "300", 519 | "name": "test-vm", 520 | "cpus": 1, 521 | "memory": 2048, 522 | "disk_size": 10 523 | }' 524 | ``` 525 | 526 | ## Production Security 527 | 528 | ### API Key Authentication 529 | Set up secure API access: 530 | 531 | ```bash 532 | export PROXMOX_API_KEY="your-secure-api-key" 533 | export PROXMOX_MCP_CONFIG="/app/proxmox-config/config.json" 534 | ``` 535 | 536 | ### Nginx Reverse Proxy 537 | Example nginx configuration: 538 | 539 | ```nginx 540 | server { 541 | listen 80; 542 | server_name your-domain.com; 543 | 544 | location / { 545 | proxy_pass http://localhost:8811; 546 | proxy_set_header Host $host; 547 | proxy_set_header X-Real-IP $remote_addr; 548 | } 549 | } 550 | ``` 551 | 552 | ## Troubleshooting 553 | 554 | ### Common Issues 555 | 556 | 1. **Port already in use** 557 | ```bash 558 | netstat -tlnp | grep 8811 559 | # Change port if needed 560 | mcpo --port 8812 -- ./start_server.sh 561 | ``` 562 | 563 | 2. **Configuration errors** 564 | ```bash 565 | # Verify config file 566 | cat proxmox-config/config.json 567 | ``` 568 | 569 | 3. **Connection issues** 570 | ```bash 571 | # Test Proxmox connectivity 572 | curl -k https://your-proxmox:8006/api2/json/version 573 | ``` 574 | 575 | ### View Logs 576 | ```bash 577 | # View service logs 578 | tail -f proxmox_mcp.log 579 | 580 | # Docker logs 581 | docker logs proxmox-mcp-api -f 582 | ``` 583 | 584 | ## Deployment Status 585 | 586 | ### ✅ Feature Completion: 100% 587 | 588 | - [x] VM Creation (user requirement: 1 CPU + 2GB RAM + 10GB storage) 🆕 589 | - [x] VM Power Management (start VPN-Server ID:101) 🆕 590 | - [x] VM Deletion Feature 🆕 591 | - [x] Container Management (LXC) 🆕 592 | - [x] Storage Compatibility (LVM/file-based) 593 | - [x] OpenAPI Integration (port 8811) 594 | - [x] Open WebUI Integration 595 | - [x] Error Handling & Validation 596 | - [x] Complete Documentation & Testing 597 | 598 | ### Production Ready! 599 | 600 | **ProxmoxMCP Plus is now fully ready for production use!** 601 | 602 | 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: 603 | 604 | 1. 📞 Call the `create_vm` API 605 | 2. 🔧 Automatically select appropriate storage and format 606 | 3. 🎯 Create VMs that match requirements 607 | 4. 📊 Return detailed configuration information 608 | 5. 💡 Provide next-step recommendations 609 | 610 | ## Development 611 | 612 | After activating your virtual environment: 613 | 614 | - Run tests: `pytest` 615 | - Format code: `black .` 616 | - Type checking: `mypy .` 617 | - Lint: `ruff .` 618 | 619 | ## License 620 | 621 | MIT License 622 | 623 | ## Special Thanks 624 | 625 | - Thanks to [@canvrno](https://github.com/canvrno) for the excellent foundational project [ProxmoxMCP](https://github.com/canvrno/ProxmoxMCP) 626 | - Thanks to the Proxmox community for providing the powerful virtualization platform 627 | - Thanks to all contributors and users for their support 628 | 629 | --- 630 | 631 | **Ready to Deploy!** 🎉 Your enhanced Proxmox MCP service with OpenAPI integration is ready for production use. 632 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/config/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/core/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Test suite for the Proxmox MCP server. 3 | """ 4 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/tools/__init__.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | MCP tools for interacting with Proxmox hypervisors. 3 | """ 4 | 5 | __all__ = [] 6 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/utils/__init__.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Utility functions and helpers for the Proxmox MCP server. 3 | """ 4 | 5 | __all__ = [] 6 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/tools/console/__init__.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Console management package for Proxmox MCP. 3 | """ 4 | from .manager import VMConsoleManager 5 | 6 | __all__ = ['VMConsoleManager'] 7 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/__init__.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Proxmox MCP Server - A Model Context Protocol server for interacting with Proxmox hypervisors. 3 | """ 4 | 5 | from .server import ProxmoxMCPServer 6 | 7 | __version__ = "0.1.0" 8 | __all__ = ["ProxmoxMCPServer"] 9 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/formatting/__init__.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Proxmox MCP formatting package for styled output. 3 | """ 4 | 5 | from .theme import ProxmoxTheme 6 | from .colors import ProxmoxColors 7 | from .formatters import ProxmoxFormatters 8 | from .templates import ProxmoxTemplates 9 | from .components import ProxmoxComponents 10 | 11 | __all__ = [ 12 | 'ProxmoxTheme', 13 | 'ProxmoxColors', 14 | 'ProxmoxFormatters', 15 | 'ProxmoxTemplates', 16 | 'ProxmoxComponents' 17 | ] 18 | ``` -------------------------------------------------------------------------------- /proxmox-config/config.example.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "proxmox": { 3 | "host": "your-proxmox-host-ip", 4 | "port": 8006, 5 | "verify_ssl": false, 6 | "service": "PVE" 7 | }, 8 | "auth": { 9 | "user": "username@pve", 10 | "token_name": "your-token-name", 11 | "token_value": "your-token-value" 12 | }, 13 | "logging": { 14 | "level": "DEBUG", 15 | "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", 16 | "file": "proxmox_mcp.log" 17 | } 18 | } 19 | ``` -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- ```yaml 1 | version: '3.8' 2 | 3 | services: 4 | proxmox-mcp-api: 5 | build: . 6 | ports: 7 | - "8811:8811" 8 | volumes: 9 | - ./proxmox-config:/app/proxmox-config:ro 10 | environment: 11 | - PROXMOX_MCP_CONFIG=/app/proxmox-config/config.json 12 | - API_HOST=0.0.0.0 13 | - API_PORT=8811 14 | restart: unless-stopped 15 | healthcheck: 16 | test: ["CMD", "curl", "-f", "http://localhost:8811/health"] 17 | interval: 30s 18 | timeout: 10s 19 | retries: 3 20 | networks: 21 | - proxmox-network 22 | 23 | networks: 24 | proxmox-network: 25 | driver: bridge ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | # Use Python 3.11 slim image as base 2 | FROM python:3.11-slim 3 | 4 | # Set working directory 5 | WORKDIR /app 6 | 7 | # Install system dependencies 8 | RUN apt-get update && apt-get install -y \ 9 | git \ 10 | curl \ 11 | && rm -rf /var/lib/apt/lists/* 12 | 13 | # Install Python dependencies 14 | RUN pip install mcpo uv 15 | 16 | # Copy project files 17 | COPY . . 18 | 19 | # Create virtual environment and install dependencies 20 | RUN uv venv && \ 21 | . .venv/bin/activate && \ 22 | uv pip install -e ".[dev]" 23 | 24 | # Expose port 25 | EXPOSE 8811 26 | 27 | # Set environment variables 28 | ENV PROXMOX_MCP_CONFIG="/app/proxmox-config/config.json" 29 | ENV API_HOST="0.0.0.0" 30 | ENV API_PORT="8811" 31 | 32 | # Startup command 33 | CMD ["mcpo", "--host", "0.0.0.0", "--port", "8811", "--", \ 34 | "/bin/bash", "-c", "cd /app && source .venv/bin/activate && python -m proxmox_mcp.server"] ``` -------------------------------------------------------------------------------- /start_server.sh: -------------------------------------------------------------------------------- ```bash 1 | #!/bin/bash 2 | """ 3 | Proxmox MCP server startup script 4 | """ 5 | 6 | echo "🚀 Starting Proxmox MCP server..." 7 | echo "" 8 | 9 | # Check virtual environment 10 | if [ ! -d ".venv" ]; then 11 | echo "❌ Virtual environment does not exist, please run installation steps first" 12 | exit 1 13 | fi 14 | 15 | # Activate virtual environment 16 | source .venv/bin/activate 17 | 18 | # Set environment variables 19 | export PROXMOX_MCP_CONFIG="proxmox-config/config.json" 20 | 21 | # Check configuration file 22 | if [ ! -f "$PROXMOX_MCP_CONFIG" ]; then 23 | echo "❌ Configuration file does not exist: $PROXMOX_MCP_CONFIG" 24 | echo "Please ensure the configuration file is properly set up" 25 | exit 1 26 | fi 27 | 28 | echo "✅ Configuration file: $PROXMOX_MCP_CONFIG" 29 | echo "✅ Virtual environment activated" 30 | echo "" 31 | echo "🔍 Starting server..." 32 | echo "Press Ctrl+C to stop the server" 33 | echo "" 34 | 35 | # Start server 36 | python -m proxmox_mcp.server 37 | ``` -------------------------------------------------------------------------------- /test_scripts/test_vm_start.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | """ 3 | Test VM startup functionality 4 | """ 5 | import os 6 | import sys 7 | 8 | def test_start_vm_101(): 9 | """Test starting VM 101 (VPN-Server)""" 10 | 11 | # Set configuration 12 | os.environ['PROXMOX_MCP_CONFIG'] = 'proxmox-config/config.json' 13 | 14 | try: 15 | from proxmox_mcp.config.loader import load_config 16 | from proxmox_mcp.core.proxmox import ProxmoxManager 17 | from proxmox_mcp.tools.vm import VMTools 18 | 19 | config = load_config('proxmox-config/config.json') 20 | manager = ProxmoxManager(config.proxmox, config.auth) 21 | api = manager.get_api() 22 | 23 | vm_tools = VMTools(api) 24 | 25 | print("🚀 Test starting VPN-Server (VM 101)") 26 | print("=" * 50) 27 | 28 | # Start VM 101 29 | result = vm_tools.start_vm(node="pve", vmid="101") 30 | 31 | for content in result: 32 | print(content.text) 33 | 34 | return True 35 | 36 | except Exception as e: 37 | print(f"❌ Start failed: {e}") 38 | return False 39 | 40 | if __name__ == "__main__": 41 | print("🔍 Test VM startup functionality") 42 | print("=" * 50) 43 | 44 | success = test_start_vm_101() 45 | 46 | if success: 47 | print("\n✅ Test completed") 48 | else: 49 | print("\n❌ Test failed") 50 | sys.exit(1) ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/utils/logging.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Logging configuration for the Proxmox MCP server. 3 | """ 4 | 5 | import logging 6 | import sys 7 | from typing import Optional 8 | 9 | def setup_logging( 10 | level: str = "INFO", 11 | format_str: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s", 12 | log_file: Optional[str] = None, 13 | ) -> logging.Logger: 14 | """ 15 | Configure logging for the Proxmox MCP server. 16 | 17 | Args: 18 | level: The logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) 19 | format_str: The format string for log messages 20 | log_file: Optional file path to write logs to 21 | 22 | Returns: 23 | logging.Logger: Configured logger instance 24 | """ 25 | # Create logger 26 | logger = logging.getLogger("proxmox-mcp") 27 | logger.setLevel(getattr(logging, level.upper())) 28 | 29 | # Create handlers 30 | handlers = [] 31 | 32 | # Console handler 33 | console_handler = logging.StreamHandler(sys.stderr) 34 | console_handler.setLevel(getattr(logging, level.upper())) 35 | handlers.append(console_handler) 36 | 37 | # File handler if log_file is specified 38 | if log_file: 39 | file_handler = logging.FileHandler(log_file) 40 | file_handler.setLevel(getattr(logging, level.upper())) 41 | handlers.append(file_handler) 42 | 43 | # Create formatter 44 | formatter = logging.Formatter(format_str) 45 | 46 | # Add formatter to handlers and handlers to logger 47 | for handler in handlers: 48 | handler.setFormatter(formatter) 49 | logger.addHandler(handler) 50 | 51 | return logger 52 | ``` -------------------------------------------------------------------------------- /start_openapi.sh: -------------------------------------------------------------------------------- ```bash 1 | #!/bin/bash 2 | # Proxmox MCP OpenAPI startup script 3 | # Expose MCP server as OpenAPI REST endpoints through mcpo proxy 4 | # Configurable deployment address 5 | 6 | # Get host and port from environment variables or use defaults 7 | HOST=${OPENAPI_HOST:-"localhost"} 8 | PORT=${OPENAPI_PORT:-"8811"} 9 | 10 | echo "🛰️ Starting Proxmox MCP OpenAPI server..." 11 | echo "" 12 | 13 | # Check if mcpo is installed 14 | if ! command -v mcpo &> /dev/null; then 15 | echo "❌ mcpo not installed, installing..." 16 | pip install mcpo 17 | fi 18 | 19 | # Check virtual environment 20 | if [ ! -d ".venv" ]; then 21 | echo "❌ Virtual environment does not exist, please run installation steps first" 22 | exit 1 23 | fi 24 | 25 | # Check configuration file 26 | if [ ! -f "proxmox-config/config.json" ]; then 27 | echo "❌ Configuration file does not exist: proxmox-config/config.json" 28 | echo "Please ensure the configuration file is properly set up" 29 | exit 1 30 | fi 31 | 32 | echo "✅ Configuration file: proxmox-config/config.json" 33 | echo "✅ mcpo proxy ready" 34 | echo "" 35 | echo "🚀 Starting OpenAPI proxy server..." 36 | echo "🌐 Service address: http://${HOST}:${PORT}" 37 | echo "📖 API documentation: http://${HOST}:${PORT}/docs" 38 | echo "🔧 OpenAPI specification: http://${HOST}:${PORT}/openapi.json" 39 | echo "❤️ Health check: http://${HOST}:${PORT}/health" 40 | echo "" 41 | echo "Press Ctrl+C to stop the server" 42 | echo "" 43 | echo "💡 To use a different host/port, set environment variables:" 44 | echo " export OPENAPI_HOST=your-host" 45 | echo " export OPENAPI_PORT=your-port" 46 | echo "" 47 | 48 | # Set environment variables 49 | export PROXMOX_MCP_CONFIG="$(pwd)/proxmox-config/config.json" 50 | 51 | # Start mcpo proxy server, bind to all interfaces on specified port 52 | 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 1 | #!/usr/bin/env python3 2 | """ 3 | Common configuration helper for test scripts 4 | """ 5 | import os 6 | import sys 7 | from pathlib import Path 8 | 9 | def setup_test_environment(): 10 | """Set up test environment configuration paths""" 11 | 12 | # Get current script directory 13 | current_dir = Path(__file__).parent 14 | 15 | # Calculate project root directory 16 | project_root = current_dir.parent 17 | 18 | # Set configuration file path 19 | config_path = project_root / "proxmox-config" / "config.json" 20 | 21 | # Set source code path 22 | src_path = project_root / "src" 23 | 24 | # Ensure paths exist 25 | if not config_path.exists(): 26 | raise FileNotFoundError(f"Configuration file does not exist: {config_path}") 27 | 28 | if not src_path.exists(): 29 | raise FileNotFoundError(f"Source code directory does not exist: {src_path}") 30 | 31 | # Set environment variables 32 | os.environ['PROXMOX_MCP_CONFIG'] = str(config_path) 33 | 34 | # Add source code path to Python path 35 | if str(src_path) not in sys.path: 36 | sys.path.insert(0, str(src_path)) 37 | 38 | return str(config_path) 39 | 40 | def get_test_tools(): 41 | """Get test tools classes""" 42 | 43 | # Ensure environment is set up 44 | config_path = setup_test_environment() 45 | 46 | try: 47 | from proxmox_mcp.config.loader import load_config 48 | from proxmox_mcp.core.proxmox import ProxmoxManager 49 | from proxmox_mcp.tools.vm import VMTools 50 | 51 | config = load_config(config_path) 52 | manager = ProxmoxManager(config.proxmox, config.auth) 53 | api = manager.get_api() 54 | 55 | vm_tools = VMTools(api) 56 | 57 | return { 58 | 'config': config, 59 | 'manager': manager, 60 | 'api': api, 61 | 'vm_tools': vm_tools 62 | } 63 | 64 | except Exception as e: 65 | print(f"❌ Failed to initialize test tools: {e}") 66 | raise 67 | 68 | def print_test_header(title): 69 | """Print test title""" 70 | print(f"🔍 {title}") 71 | print("=" * len(f"🔍 {title}")) 72 | 73 | def print_test_result(success, message=""): 74 | """Print test result""" 75 | if success: 76 | print(f"\n✅ Test completed {message}") 77 | else: 78 | print(f"\n❌ Test failed {message}") 79 | sys.exit(1) ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [build-system] 2 | requires = ["setuptools>=61.0.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "proxmox-mcp" 7 | version = "0.1.0" 8 | description = "A Model Context Protocol server for interacting with Proxmox hypervisors" 9 | requires-python = ">=3.11" 10 | authors = [ 11 | {name = "Kevin", email = "[email protected]"} 12 | ] 13 | readme = "README.md" 14 | license = {text = "MIT"} 15 | keywords = ["proxmox", "mcp", "virtualization", "cline", "qemu", "lxc"] 16 | classifiers = [ 17 | "Development Status :: 3 - Alpha", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: MIT License", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | "Topic :: Software Development :: Libraries :: Python Modules", 26 | "Topic :: System :: Systems Administration", 27 | ] 28 | 29 | dependencies = [ 30 | "mcp @ git+https://github.com/modelcontextprotocol/python-sdk.git", 31 | "proxmoxer>=2.0.1,<3.0.0", 32 | "requests>=2.31.0,<3.0.0", 33 | "pydantic>=2.0.0,<3.0.0", 34 | "fastapi>=0.115.0", 35 | "uvicorn[standard]>=0.30.0", 36 | "mcpo>=0.0.17", 37 | ] 38 | 39 | [project.optional-dependencies] 40 | dev = [ 41 | "pytest>=7.0.0,<8.0.0", 42 | "black>=23.0.0,<24.0.0", 43 | "mypy>=1.0.0,<2.0.0", 44 | "pytest-asyncio>=0.21.0,<0.22.0", 45 | "ruff>=0.1.0,<0.2.0", 46 | "types-requests>=2.31.0,<3.0.0", 47 | ] 48 | 49 | [project.urls] 50 | Homepage = "https://github.com/yourusername/proxmox-mcp" 51 | Documentation = "https://github.com/yourusername/proxmox-mcp#readme" 52 | Repository = "https://github.com/yourusername/proxmox-mcp.git" 53 | Issues = "https://github.com/yourusername/proxmox-mcp/issues" 54 | 55 | [project.scripts] 56 | proxmox-mcp = "proxmox_mcp.server:main" 57 | 58 | [tool.pytest.ini_options] 59 | asyncio_mode = "strict" 60 | testpaths = ["tests"] 61 | python_files = ["test_*.py"] 62 | addopts = "-v" 63 | 64 | [tool.mypy] 65 | python_version = "3.9" 66 | warn_return_any = true 67 | warn_unused_configs = true 68 | disallow_untyped_defs = true 69 | disallow_incomplete_defs = true 70 | check_untyped_defs = true 71 | disallow_untyped_decorators = true 72 | no_implicit_optional = true 73 | warn_redundant_casts = true 74 | warn_unused_ignores = true 75 | warn_no_return = true 76 | warn_unreachable = true 77 | 78 | [tool.ruff] 79 | select = ["E", "F", "B", "I"] 80 | ignore = [] 81 | line-length = 100 82 | target-version = "py39" 83 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/utils/auth.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Authentication utilities for the Proxmox MCP server. 3 | """ 4 | 5 | import os 6 | from typing import Dict, Optional, Tuple 7 | 8 | from pydantic import BaseModel 9 | 10 | class ProxmoxAuth(BaseModel): 11 | """Proxmox authentication configuration.""" 12 | user: str 13 | token_name: str 14 | token_value: str 15 | 16 | def load_auth_from_env() -> ProxmoxAuth: 17 | """ 18 | Load Proxmox authentication details from environment variables. 19 | 20 | Environment Variables: 21 | PROXMOX_USER: Username with realm (e.g., 'root@pam' or 'user@pve') 22 | PROXMOX_TOKEN_NAME: API token name 23 | PROXMOX_TOKEN_VALUE: API token value 24 | 25 | Returns: 26 | ProxmoxAuth: Authentication configuration 27 | 28 | Raises: 29 | ValueError: If required environment variables are missing 30 | """ 31 | user = os.getenv("PROXMOX_USER") 32 | token_name = os.getenv("PROXMOX_TOKEN_NAME") 33 | token_value = os.getenv("PROXMOX_TOKEN_VALUE") 34 | 35 | if not all([user, token_name, token_value]): 36 | missing = [] 37 | if not user: 38 | missing.append("PROXMOX_USER") 39 | if not token_name: 40 | missing.append("PROXMOX_TOKEN_NAME") 41 | if not token_value: 42 | missing.append("PROXMOX_TOKEN_VALUE") 43 | raise ValueError(f"Missing required environment variables: {', '.join(missing)}") 44 | 45 | return ProxmoxAuth( 46 | user=user, 47 | token_name=token_name, 48 | token_value=token_value, 49 | ) 50 | 51 | def parse_user(user: str) -> Tuple[str, str]: 52 | """ 53 | Parse a Proxmox user string into username and realm. 54 | 55 | Args: 56 | user: User string in format 'username@realm' 57 | 58 | Returns: 59 | Tuple[str, str]: (username, realm) 60 | 61 | Raises: 62 | ValueError: If user string is not in correct format 63 | """ 64 | try: 65 | username, realm = user.split("@") 66 | return username, realm 67 | except ValueError: 68 | raise ValueError( 69 | "Invalid user format. Expected 'username@realm' (e.g., 'root@pam' or 'user@pve')" 70 | ) 71 | 72 | def get_auth_dict(auth: ProxmoxAuth) -> Dict[str, str]: 73 | """ 74 | Convert ProxmoxAuth model to dictionary for Proxmoxer API. 75 | 76 | Args: 77 | auth: ProxmoxAuth configuration 78 | 79 | Returns: 80 | Dict[str, str]: Authentication dictionary for Proxmoxer 81 | """ 82 | return { 83 | "user": auth.user, 84 | "token_name": auth.token_name, 85 | "token_value": auth.token_value, 86 | } 87 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/config/loader.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Configuration loading utilities for the Proxmox MCP server. 3 | 4 | This module handles loading and validation of server configuration: 5 | - JSON configuration file loading 6 | - Environment variable handling 7 | - Configuration validation using Pydantic models 8 | - Error handling for invalid configurations 9 | 10 | The module ensures that all required configuration is present 11 | and valid before the server starts operation. 12 | """ 13 | import json 14 | import os 15 | from typing import Optional 16 | from .models import Config 17 | 18 | def load_config(config_path: Optional[str] = None) -> Config: 19 | """Load and validate configuration from JSON file. 20 | 21 | Performs the following steps: 22 | 1. Verifies config path is provided 23 | 2. Loads JSON configuration file 24 | 3. Validates required fields are present 25 | 4. Converts to typed Config object using Pydantic 26 | 27 | Configuration must include: 28 | - Proxmox connection settings (host, port, etc.) 29 | - Authentication credentials (user, token) 30 | - Logging configuration 31 | 32 | Args: 33 | config_path: Path to the JSON configuration file 34 | If not provided, raises ValueError 35 | 36 | Returns: 37 | Config object containing validated configuration: 38 | { 39 | "proxmox": { 40 | "host": "proxmox-host", 41 | "port": 8006, 42 | ... 43 | }, 44 | "auth": { 45 | "user": "username", 46 | "token_name": "token-name", 47 | ... 48 | }, 49 | "logging": { 50 | "level": "INFO", 51 | ... 52 | } 53 | } 54 | 55 | Raises: 56 | ValueError: If: 57 | - Config path is not provided 58 | - JSON is invalid 59 | - Required fields are missing 60 | - Field values are invalid 61 | """ 62 | if not config_path: 63 | raise ValueError("PROXMOX_MCP_CONFIG environment variable must be set") 64 | 65 | try: 66 | with open(config_path) as f: 67 | config_data = json.load(f) 68 | if not config_data.get('proxmox', {}).get('host'): 69 | raise ValueError("Proxmox host cannot be empty") 70 | return Config(**config_data) 71 | except json.JSONDecodeError as e: 72 | raise ValueError(f"Invalid JSON in config file: {e}") 73 | except Exception as e: 74 | raise ValueError(f"Failed to load config: {e}") 75 | ``` -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Setup script for the Proxmox MCP server. 3 | This file is maintained for compatibility with older tools. 4 | For modern Python packaging, see pyproject.toml. 5 | """ 6 | 7 | from setuptools import setup, find_packages 8 | 9 | # Metadata and dependencies are primarily managed in pyproject.toml 10 | # This file exists for compatibility with tools that don't support pyproject.toml 11 | 12 | setup( 13 | name="proxmox-mcp", 14 | version="0.1.0", 15 | packages=find_packages(where="src"), 16 | package_dir={"": "src"}, 17 | python_requires=">=3.9", 18 | install_requires=[ 19 | "mcp @ git+https://github.com/modelcontextprotocol/python-sdk.git", 20 | "proxmoxer>=2.0.1,<3.0.0", 21 | "requests>=2.31.0,<3.0.0", 22 | "pydantic>=2.0.0,<3.0.0", 23 | ], 24 | extras_require={ 25 | "dev": [ 26 | "pytest>=7.0.0,<8.0.0", 27 | "black>=23.0.0,<24.0.0", 28 | "mypy>=1.0.0,<2.0.0", 29 | "pytest-asyncio>=0.21.0,<0.22.0", 30 | "ruff>=0.1.0,<0.2.0", 31 | "types-requests>=2.31.0,<3.0.0", 32 | ], 33 | }, 34 | entry_points={ 35 | "console_scripts": [ 36 | "proxmox-mcp=proxmox_mcp.server:main", 37 | ], 38 | }, 39 | author="Kevin", 40 | author_email="[email protected]", 41 | description="A Model Context Protocol server for interacting with Proxmox hypervisors", 42 | long_description=open("README.md").read(), 43 | long_description_content_type="text/markdown", 44 | license="MIT", 45 | keywords=["proxmox", "mcp", "virtualization", "cline", "qemu", "lxc"], 46 | classifiers=[ 47 | "Development Status :: 3 - Alpha", 48 | "Intended Audience :: Developers", 49 | "License :: OSI Approved :: MIT License", 50 | "Programming Language :: Python :: 3", 51 | "Programming Language :: Python :: 3.9", 52 | "Programming Language :: Python :: 3.10", 53 | "Programming Language :: Python :: 3.11", 54 | "Programming Language :: Python :: 3.12", 55 | "Topic :: Software Development :: Libraries :: Python Modules", 56 | "Topic :: System :: Systems Administration", 57 | "Topic :: System :: Virtualization", 58 | ], 59 | project_urls={ 60 | "Homepage": "https://github.com/yourusername/proxmox-mcp", 61 | "Documentation": "https://github.com/yourusername/proxmox-mcp#readme", 62 | "Repository": "https://github.com/yourusername/proxmox-mcp.git", 63 | "Issues": "https://github.com/yourusername/proxmox-mcp/issues", 64 | }, 65 | ) 66 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/formatting/theme.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Theme configuration for Proxmox MCP output styling. 3 | """ 4 | 5 | class ProxmoxTheme: 6 | """Theme configuration for Proxmox MCP output.""" 7 | 8 | # Feature flags 9 | USE_EMOJI = True 10 | USE_COLORS = True 11 | 12 | # Status indicators with emojis 13 | STATUS = { 14 | 'online': '🟢', 15 | 'offline': '🔴', 16 | 'running': '▶️', 17 | 'stopped': '⏹️', 18 | 'unknown': '❓', 19 | 'pending': '⏳', 20 | 'error': '❌', 21 | 'warning': '⚠️', 22 | } 23 | 24 | # Resource type indicators 25 | RESOURCES = { 26 | 'node': '🖥️', 27 | 'vm': '🗃️', 28 | 'container': '📦', 29 | 'storage': '💾', 30 | 'cpu': '⚡', 31 | 'memory': '🧠', 32 | 'network': '🌐', 33 | 'disk': '💿', 34 | 'backup': '📼', 35 | 'snapshot': '📸', 36 | 'template': '📋', 37 | 'pool': '🏊', 38 | } 39 | 40 | # Action and operation indicators 41 | ACTIONS = { 42 | 'success': '✅', 43 | 'error': '❌', 44 | 'warning': '⚠️', 45 | 'info': 'ℹ️', 46 | 'command': '🔧', 47 | 'start': '▶️', 48 | 'stop': '⏹️', 49 | 'restart': '🔄', 50 | 'delete': '🗑️', 51 | 'edit': '✏️', 52 | 'create': '➕', 53 | 'migrate': '➡️', 54 | 'clone': '📑', 55 | 'lock': '🔒', 56 | 'unlock': '🔓', 57 | } 58 | 59 | # Section and grouping indicators 60 | SECTIONS = { 61 | 'header': '📌', 62 | 'details': '📝', 63 | 'statistics': '📊', 64 | 'configuration': '⚙️', 65 | 'logs': '📜', 66 | 'tasks': '📋', 67 | 'users': '👥', 68 | 'permissions': '🔑', 69 | } 70 | 71 | # Measurement and metric indicators 72 | METRICS = { 73 | 'percentage': '%', 74 | 'temperature': '🌡️', 75 | 'uptime': '⏳', 76 | 'bandwidth': '📶', 77 | 'latency': '⚡', 78 | } 79 | 80 | @classmethod 81 | def get_status_emoji(cls, status: str) -> str: 82 | """Get emoji for a status value with fallback.""" 83 | status = status.lower() 84 | return cls.STATUS.get(status, cls.STATUS['unknown']) 85 | 86 | @classmethod 87 | def get_resource_emoji(cls, resource: str) -> str: 88 | """Get emoji for a resource type with fallback.""" 89 | resource = resource.lower() 90 | return cls.RESOURCES.get(resource, '📦') 91 | 92 | @classmethod 93 | def get_action_emoji(cls, action: str) -> str: 94 | """Get emoji for an action with fallback.""" 95 | action = action.lower() 96 | return cls.ACTIONS.get(action, cls.ACTIONS['info']) 97 | 98 | @classmethod 99 | def get_section_emoji(cls, section: str) -> str: 100 | """Get emoji for a section type with fallback.""" 101 | section = section.lower() 102 | return cls.SECTIONS.get(section, cls.SECTIONS['details']) 103 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/core/logging.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Logging configuration for the Proxmox MCP server. 3 | 4 | This module handles logging setup and configuration: 5 | - File and console logging handlers 6 | - Log level management 7 | - Format customization 8 | - Handler lifecycle management 9 | 10 | The logging system supports: 11 | - Configurable log levels 12 | - File-based logging with path resolution 13 | - Console logging for errors 14 | - Custom format strings 15 | - Multiple handler management 16 | """ 17 | import logging 18 | import os 19 | from typing import Optional 20 | from ..config.models import LoggingConfig 21 | 22 | def setup_logging(config: LoggingConfig) -> logging.Logger: 23 | """Configure and initialize logging system. 24 | 25 | Sets up a comprehensive logging system with: 26 | - File logging (if configured): 27 | * Handles relative/absolute paths 28 | * Uses configured log level 29 | * Applies custom format 30 | 31 | - Console logging: 32 | * Always enabled for errors 33 | * Ensures critical issues are visible 34 | 35 | - Handler Management: 36 | * Removes existing handlers 37 | * Configures new handlers 38 | * Sets up formatters 39 | 40 | Args: 41 | config: Logging configuration containing: 42 | - Log level (e.g., "INFO", "DEBUG") 43 | - Format string 44 | - Optional log file path 45 | 46 | Returns: 47 | Configured logger instance for "proxmox-mcp" 48 | with appropriate handlers and formatting 49 | 50 | Example config: 51 | { 52 | "level": "INFO", 53 | "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", 54 | "file": "/path/to/log/file.log" # Optional 55 | } 56 | """ 57 | # Convert relative path to absolute 58 | log_file = config.file 59 | if log_file and not os.path.isabs(log_file): 60 | log_file = os.path.join(os.getcwd(), log_file) 61 | 62 | # Create handlers 63 | handlers = [] 64 | 65 | if log_file: 66 | file_handler = logging.FileHandler(log_file) 67 | file_handler.setLevel(getattr(logging, config.level.upper())) 68 | handlers.append(file_handler) 69 | 70 | # Console handler for errors only 71 | console_handler = logging.StreamHandler() 72 | console_handler.setLevel(logging.ERROR) 73 | handlers.append(console_handler) 74 | 75 | # Configure formatters 76 | formatter = logging.Formatter(config.format) 77 | for handler in handlers: 78 | handler.setFormatter(formatter) 79 | 80 | # Configure root logger 81 | root_logger = logging.getLogger() 82 | root_logger.setLevel(getattr(logging, config.level.upper())) 83 | 84 | # Remove any existing handlers 85 | for handler in root_logger.handlers[:]: 86 | root_logger.removeHandler(handler) 87 | 88 | # Add new handlers 89 | for handler in handlers: 90 | root_logger.addHandler(handler) 91 | 92 | # Create and return server logger 93 | logger = logging.getLogger("proxmox-mcp") 94 | return logger 95 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/tools/cluster.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Cluster-related tools for Proxmox MCP. 3 | 4 | This module provides tools for monitoring and managing Proxmox clusters: 5 | - Retrieving overall cluster health status 6 | - Monitoring quorum status and node count 7 | - Tracking cluster resources and configuration 8 | - Checking cluster-wide service availability 9 | 10 | The tools provide essential information for maintaining 11 | cluster health and ensuring proper operation. 12 | """ 13 | from typing import List 14 | from mcp.types import TextContent as Content 15 | from .base import ProxmoxTool 16 | from .definitions import GET_CLUSTER_STATUS_DESC 17 | 18 | class ClusterTools(ProxmoxTool): 19 | """Tools for managing Proxmox cluster. 20 | 21 | Provides functionality for: 22 | - Monitoring cluster health and status 23 | - Tracking quorum and node membership 24 | - Managing cluster-wide resources 25 | - Verifying cluster configuration 26 | 27 | Essential for maintaining cluster health and ensuring 28 | proper operation of the Proxmox environment. 29 | """ 30 | 31 | def get_cluster_status(self) -> List[Content]: 32 | """Get overall Proxmox cluster health and configuration status. 33 | 34 | Retrieves comprehensive cluster information including: 35 | - Cluster name and identity 36 | - Quorum status (essential for cluster operations) 37 | - Active node count and health 38 | - Resource distribution and status 39 | 40 | This information is critical for: 41 | - Ensuring cluster stability 42 | - Monitoring node membership 43 | - Verifying resource availability 44 | - Detecting potential issues 45 | 46 | Returns: 47 | List of Content objects containing formatted cluster status: 48 | { 49 | "name": "cluster-name", 50 | "quorum": true/false, 51 | "nodes": count, 52 | "resources": [ 53 | { 54 | "type": "resource-type", 55 | "status": "status" 56 | } 57 | ] 58 | } 59 | 60 | Raises: 61 | RuntimeError: If cluster status query fails due to: 62 | - Network connectivity issues 63 | - Authentication problems 64 | - API endpoint failures 65 | """ 66 | try: 67 | result = self.proxmox.cluster.status.get() 68 | 69 | first_item = result[0] if result and len(result) > 0 else {} 70 | status = { 71 | "name": first_item.get("name") if first_item else None, 72 | "quorum": first_item.get("quorate") if first_item else None, 73 | "nodes": len([node for node in result if node.get("type") == "node"]) if result else 0, 74 | "resources": [res for res in result if res.get("type") == "resource"] if result else [] 75 | } 76 | return self._format_response(status, "cluster") 77 | except Exception as e: 78 | self._handle_error("get cluster status", e) 79 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/config/models.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Configuration models for the Proxmox MCP server. 3 | 4 | This module defines Pydantic models for configuration validation: 5 | - Proxmox connection settings 6 | - Authentication credentials 7 | - Logging configuration 8 | - Tool-specific parameter models 9 | 10 | The models provide: 11 | - Type validation 12 | - Default values 13 | - Field descriptions 14 | - Required vs optional field handling 15 | """ 16 | from typing import Optional, Annotated 17 | from pydantic import BaseModel, Field 18 | 19 | class NodeStatus(BaseModel): 20 | """Model for node status query parameters. 21 | 22 | Validates and documents the required parameters for 23 | querying a specific node's status in the cluster. 24 | """ 25 | node: Annotated[str, Field(description="Name/ID of node to query (e.g. 'pve1', 'proxmox-node2')")] 26 | 27 | class VMCommand(BaseModel): 28 | """Model for VM command execution parameters. 29 | 30 | Validates and documents the required parameters for 31 | executing commands within a VM via QEMU guest agent. 32 | """ 33 | node: Annotated[str, Field(description="Host node name (e.g. 'pve1', 'proxmox-node2')")] 34 | vmid: Annotated[str, Field(description="VM ID number (e.g. '100', '101')")] 35 | command: Annotated[str, Field(description="Shell command to run (e.g. 'uname -a', 'systemctl status nginx')")] 36 | 37 | class ProxmoxConfig(BaseModel): 38 | """Model for Proxmox connection configuration. 39 | 40 | Defines the required and optional parameters for 41 | establishing a connection to the Proxmox API server. 42 | Provides sensible defaults for optional parameters. 43 | """ 44 | host: str # Required: Proxmox host address 45 | port: int = 8006 # Optional: API port (default: 8006) 46 | verify_ssl: bool = True # Optional: SSL verification (default: True) 47 | service: str = "PVE" # Optional: Service type (default: PVE) 48 | 49 | class AuthConfig(BaseModel): 50 | """Model for Proxmox authentication configuration. 51 | 52 | Defines the required parameters for API authentication 53 | using token-based authentication. All fields are required 54 | to ensure secure API access. 55 | """ 56 | user: str # Required: Username (e.g., 'root@pam') 57 | token_name: str # Required: API token name 58 | token_value: str # Required: API token secret 59 | 60 | class LoggingConfig(BaseModel): 61 | """Model for logging configuration. 62 | 63 | Defines logging parameters with sensible defaults. 64 | Supports both file and console logging with 65 | customizable format and log levels. 66 | """ 67 | level: str = "INFO" # Optional: Log level (default: INFO) 68 | format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" # Optional: Log format 69 | file: Optional[str] = None # Optional: Log file path (default: None for console logging) 70 | 71 | class Config(BaseModel): 72 | """Root configuration model. 73 | 74 | Combines all configuration models into a single validated 75 | configuration object. All sections are required to ensure 76 | proper server operation. 77 | """ 78 | proxmox: ProxmoxConfig # Required: Proxmox connection settings 79 | auth: AuthConfig # Required: Authentication credentials 80 | logging: LoggingConfig # Required: Logging configuration 81 | ``` -------------------------------------------------------------------------------- /tests/test_vm_console.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tests for VM console operations. 3 | """ 4 | 5 | import pytest 6 | from unittest.mock import Mock, patch 7 | 8 | from proxmox_mcp.tools.console import VMConsoleManager 9 | 10 | @pytest.fixture 11 | def mock_proxmox(): 12 | """Fixture to create a mock ProxmoxAPI instance.""" 13 | mock = Mock() 14 | # Setup chained mock calls 15 | mock.nodes.return_value.qemu.return_value.status.current.get.return_value = { 16 | "status": "running" 17 | } 18 | mock.nodes.return_value.qemu.return_value.agent.exec.post.return_value = { 19 | "out": "command output", 20 | "err": "", 21 | "exitcode": 0 22 | } 23 | return mock 24 | 25 | @pytest.fixture 26 | def vm_console(mock_proxmox): 27 | """Fixture to create a VMConsoleManager instance.""" 28 | return VMConsoleManager(mock_proxmox) 29 | 30 | @pytest.mark.asyncio 31 | async def test_execute_command_success(vm_console, mock_proxmox): 32 | """Test successful command execution.""" 33 | result = await vm_console.execute_command("node1", "100", "ls -l") 34 | 35 | assert result["success"] is True 36 | assert result["output"] == "command output" 37 | assert result["error"] == "" 38 | assert result["exit_code"] == 0 39 | 40 | # Verify correct API calls 41 | mock_proxmox.nodes.return_value.qemu.assert_called_with("100") 42 | mock_proxmox.nodes.return_value.qemu.return_value.agent.exec.post.assert_called_with( 43 | command="ls -l" 44 | ) 45 | 46 | @pytest.mark.asyncio 47 | async def test_execute_command_vm_not_running(vm_console, mock_proxmox): 48 | """Test command execution on stopped VM.""" 49 | mock_proxmox.nodes.return_value.qemu.return_value.status.current.get.return_value = { 50 | "status": "stopped" 51 | } 52 | 53 | with pytest.raises(ValueError, match="not running"): 54 | await vm_console.execute_command("node1", "100", "ls -l") 55 | 56 | @pytest.mark.asyncio 57 | async def test_execute_command_vm_not_found(vm_console, mock_proxmox): 58 | """Test command execution on non-existent VM.""" 59 | mock_proxmox.nodes.return_value.qemu.return_value.status.current.get.side_effect = \ 60 | Exception("VM not found") 61 | 62 | with pytest.raises(ValueError, match="not found"): 63 | await vm_console.execute_command("node1", "100", "ls -l") 64 | 65 | @pytest.mark.asyncio 66 | async def test_execute_command_failure(vm_console, mock_proxmox): 67 | """Test command execution failure.""" 68 | mock_proxmox.nodes.return_value.qemu.return_value.agent.exec.post.side_effect = \ 69 | Exception("Command failed") 70 | 71 | with pytest.raises(RuntimeError, match="Failed to execute command"): 72 | await vm_console.execute_command("node1", "100", "ls -l") 73 | 74 | @pytest.mark.asyncio 75 | async def test_execute_command_with_error_output(vm_console, mock_proxmox): 76 | """Test command execution with error output.""" 77 | mock_proxmox.nodes.return_value.qemu.return_value.agent.exec.post.return_value = { 78 | "out": "", 79 | "err": "command error", 80 | "exitcode": 1 81 | } 82 | 83 | result = await vm_console.execute_command("node1", "100", "invalid-command") 84 | 85 | assert result["success"] is True # Success refers to API call, not command 86 | assert result["output"] == "" 87 | assert result["error"] == "command error" 88 | assert result["exit_code"] == 1 89 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/formatting/colors.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Color utilities for Proxmox MCP output styling. 3 | """ 4 | from typing import Optional 5 | from .theme import ProxmoxTheme 6 | 7 | class ProxmoxColors: 8 | """ANSI color definitions and utilities for terminal output.""" 9 | 10 | # Foreground colors 11 | BLACK = '\033[30m' 12 | RED = '\033[31m' 13 | GREEN = '\033[32m' 14 | YELLOW = '\033[33m' 15 | BLUE = '\033[34m' 16 | MAGENTA = '\033[35m' 17 | CYAN = '\033[36m' 18 | WHITE = '\033[37m' 19 | 20 | # Background colors 21 | BG_BLACK = '\033[40m' 22 | BG_RED = '\033[41m' 23 | BG_GREEN = '\033[42m' 24 | BG_YELLOW = '\033[43m' 25 | BG_BLUE = '\033[44m' 26 | BG_MAGENTA = '\033[45m' 27 | BG_CYAN = '\033[46m' 28 | BG_WHITE = '\033[47m' 29 | 30 | # Styles 31 | BOLD = '\033[1m' 32 | DIM = '\033[2m' 33 | ITALIC = '\033[3m' 34 | UNDERLINE = '\033[4m' 35 | BLINK = '\033[5m' 36 | REVERSE = '\033[7m' 37 | HIDDEN = '\033[8m' 38 | STRIKE = '\033[9m' 39 | 40 | # Reset 41 | RESET = '\033[0m' 42 | 43 | @classmethod 44 | def colorize(cls, text: str, color: str, style: Optional[str] = None) -> str: 45 | """Add color and optional style to text with theme awareness. 46 | 47 | Args: 48 | text: Text to colorize 49 | color: ANSI color code 50 | style: Optional ANSI style code 51 | 52 | Returns: 53 | Formatted text string 54 | """ 55 | if not ProxmoxTheme.USE_COLORS: 56 | return text 57 | 58 | if style: 59 | return f"{style}{color}{text}{cls.RESET}" 60 | return f"{color}{text}{cls.RESET}" 61 | 62 | @classmethod 63 | def status_color(cls, status: str) -> str: 64 | """Get appropriate color for a status value. 65 | 66 | Args: 67 | status: Status string to get color for 68 | 69 | Returns: 70 | ANSI color code 71 | """ 72 | status = status.lower() 73 | if status in ['online', 'running', 'success']: 74 | return cls.GREEN 75 | elif status in ['offline', 'stopped', 'error']: 76 | return cls.RED 77 | elif status in ['pending', 'warning']: 78 | return cls.YELLOW 79 | return cls.BLUE 80 | 81 | @classmethod 82 | def resource_color(cls, resource_type: str) -> str: 83 | """Get appropriate color for a resource type. 84 | 85 | Args: 86 | resource_type: Resource type to get color for 87 | 88 | Returns: 89 | ANSI color code 90 | """ 91 | resource_type = resource_type.lower() 92 | if resource_type in ['node', 'vm', 'container']: 93 | return cls.CYAN 94 | elif resource_type in ['cpu', 'memory', 'network']: 95 | return cls.YELLOW 96 | elif resource_type in ['storage', 'disk']: 97 | return cls.MAGENTA 98 | return cls.BLUE 99 | 100 | @classmethod 101 | def metric_color(cls, value: float, warning: float = 80.0, critical: float = 90.0) -> str: 102 | """Get appropriate color for a metric value based on thresholds. 103 | 104 | Args: 105 | value: Metric value (typically percentage) 106 | warning: Warning threshold 107 | critical: Critical threshold 108 | 109 | Returns: 110 | ANSI color code 111 | """ 112 | if value >= critical: 113 | return cls.RED 114 | elif value >= warning: 115 | return cls.YELLOW 116 | return cls.GREEN 117 | ``` -------------------------------------------------------------------------------- /test_scripts/test_create_vm.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | """ 3 | Test VM creation functionality 4 | """ 5 | import os 6 | import sys 7 | 8 | def test_create_vm(): 9 | """Test creating VM - 1 CPU, 2GB RAM, 10GB storage""" 10 | 11 | # Set configuration 12 | os.environ['PROXMOX_MCP_CONFIG'] = 'proxmox-config/config.json' 13 | 14 | try: 15 | from proxmox_mcp.config.loader import load_config 16 | from proxmox_mcp.core.proxmox import ProxmoxManager 17 | from proxmox_mcp.tools.vm import VMTools 18 | 19 | config = load_config('proxmox-config/config.json') 20 | manager = ProxmoxManager(config.proxmox, config.auth) 21 | api = manager.get_api() 22 | 23 | vm_tools = VMTools(api) 24 | 25 | print("🎉 Test creating new VM - user requested configuration") 26 | print("=" * 60) 27 | print("Configuration:") 28 | print(" • CPU: 1 core") 29 | print(" • RAM: 2 GB (2048 MB)") 30 | print(" • Storage: 10 GB") 31 | print(" • VM ID: 999 (test purpose)") 32 | print(" • Name: test-vm-demo") 33 | print() 34 | 35 | # Find an available VM ID 36 | vmid = "999" 37 | 38 | # Check if VM ID already exists 39 | try: 40 | existing_vm = api.nodes("pve").qemu(vmid).config.get() 41 | print(f"⚠️ VM {vmid} already exists, will try VM ID 998") 42 | vmid = "998" 43 | existing_vm = api.nodes("pve").qemu(vmid).config.get() 44 | print(f"⚠️ VM {vmid} also exists, will try VM ID 997") 45 | vmid = "997" 46 | except: 47 | print(f"✅ VM ID {vmid} is available") 48 | 49 | # Create VM 50 | result = vm_tools.create_vm( 51 | node="pve", 52 | vmid=vmid, 53 | name="test-vm-demo", 54 | cpus=1, 55 | memory=2048, # 2GB in MB 56 | disk_size=10 # 10GB 57 | ) 58 | 59 | for content in result: 60 | print(content.text) 61 | 62 | return True 63 | 64 | except Exception as e: 65 | print(f"❌ Creation failed: {e}") 66 | return False 67 | 68 | def test_list_vms(): 69 | """Test listing VMs to confirm successful creation""" 70 | 71 | os.environ['PROXMOX_MCP_CONFIG'] = 'proxmox-config/config.json' 72 | 73 | try: 74 | from proxmox_mcp.config.loader import load_config 75 | from proxmox_mcp.core.proxmox import ProxmoxManager 76 | from proxmox_mcp.tools.vm import VMTools 77 | 78 | config = load_config('proxmox-config/config.json') 79 | manager = ProxmoxManager(config.proxmox, config.auth) 80 | api = manager.get_api() 81 | 82 | vm_tools = VMTools(api) 83 | 84 | print("\n🔍 List all VMs to confirm creation results:") 85 | print("=" * 40) 86 | 87 | result = vm_tools.get_vms() 88 | for content in result: 89 | # Only show newly created VM information 90 | lines = content.text.split('\n') 91 | for line in lines: 92 | if 'test-vm-demo' in line or 'VM 99' in line: 93 | print(line) 94 | 95 | return True 96 | 97 | except Exception as e: 98 | print(f"❌ List query failed: {e}") 99 | return False 100 | 101 | if __name__ == "__main__": 102 | print("🔍 Test VM creation functionality") 103 | print("=" * 50) 104 | 105 | success = test_create_vm() 106 | 107 | if success: 108 | print("\n✅ Creation test completed") 109 | # Test listing VMs 110 | test_list_vms() 111 | else: 112 | print("\n❌ Creation test failed") 113 | sys.exit(1) ``` -------------------------------------------------------------------------------- /test_scripts/test_vm_power.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | """ 3 | Test Proxmox VM power management functionality 4 | """ 5 | import sys 6 | from test_common import setup_test_environment, get_test_tools, print_test_header, print_test_result 7 | 8 | def test_vm_power_operations(): 9 | """Test VM power management operations""" 10 | 11 | try: 12 | # Set up test environment 13 | setup_test_environment() 14 | tools = get_test_tools() 15 | 16 | api = tools['api'] 17 | nodes = api.nodes.get() 18 | 19 | # Safely get the first node to avoid index out of range error 20 | if not nodes or len(nodes) == 0: 21 | print("❌ No Proxmox nodes found") 22 | return False 23 | 24 | node_name = nodes[0]['node'] 25 | 26 | print(f"Test node: {node_name}") 27 | 28 | # Get all VMs 29 | vms = api.nodes(node_name).qemu.get() 30 | print(f"Found {len(vms)} virtual machines:") 31 | 32 | vm_101_found = False 33 | for vm in vms: 34 | vmid = vm['vmid'] 35 | name = vm['name'] 36 | status = vm['status'] 37 | print(f" - VM {vmid}: {name} ({status})") 38 | 39 | if vmid == 101: 40 | vm_101_found = True 41 | print(f"\nFound VPN-Server (ID: 101), current status: {status}") 42 | 43 | # Test available status operations 44 | vm_api = api.nodes(node_name).qemu(vmid) 45 | status_api = vm_api.status 46 | 47 | print("Test available status operations:") 48 | 49 | # Try accessing different status endpoints 50 | try: 51 | # Check if start endpoint exists 52 | if hasattr(status_api, 'start'): 53 | print(" ✅ Supports start operation") 54 | else: 55 | print(" ❌ Does not support start operation") 56 | 57 | if hasattr(status_api, 'stop'): 58 | print(" ✅ Supports stop operation") 59 | else: 60 | print(" ❌ Does not support stop operation") 61 | 62 | if hasattr(status_api, 'reset'): 63 | print(" ✅ Supports reset operation") 64 | else: 65 | print(" ❌ Does not support reset operation") 66 | 67 | if hasattr(status_api, 'shutdown'): 68 | print(" ✅ Supports shutdown operation") 69 | else: 70 | print(" ❌ Does not support shutdown operation") 71 | 72 | # If VM is stopped, try to start 73 | if status == 'stopped': 74 | print(f"\nVM {vmid} is currently stopped, can try to start") 75 | print("Start command would be: api.nodes(node).qemu(101).status.start.post()") 76 | 77 | elif status == 'running': 78 | print(f"\nVM {vmid} is currently running") 79 | 80 | except Exception as e: 81 | print(f" Error while testing operations: {e}") 82 | 83 | if not vm_101_found: 84 | print("\n❌ VM 101 (VPN-Server) not found") 85 | 86 | except Exception as e: 87 | print(f"Test failed: {e}") 88 | return False 89 | 90 | return True 91 | 92 | if __name__ == "__main__": 93 | print_test_header("Test Proxmox VM power management functionality") 94 | 95 | success = test_vm_power_operations() 96 | print_test_result(success) ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/tools/storage.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Storage-related tools for Proxmox MCP. 3 | 4 | This module provides tools for managing and monitoring Proxmox storage: 5 | - Listing all storage pools across the cluster 6 | - Retrieving detailed storage information including: 7 | * Storage type and content types 8 | * Usage statistics and capacity 9 | * Availability status 10 | * Node assignments 11 | 12 | The tools implement fallback mechanisms for scenarios where 13 | detailed storage information might be temporarily unavailable. 14 | """ 15 | from typing import List 16 | from mcp.types import TextContent as Content 17 | from .base import ProxmoxTool 18 | from .definitions import GET_STORAGE_DESC 19 | 20 | class StorageTools(ProxmoxTool): 21 | """Tools for managing Proxmox storage. 22 | 23 | Provides functionality for: 24 | - Retrieving cluster-wide storage information 25 | - Monitoring storage pool status and health 26 | - Tracking storage utilization and capacity 27 | - Managing storage content types 28 | 29 | Implements fallback mechanisms for scenarios where detailed 30 | storage information might be temporarily unavailable. 31 | """ 32 | 33 | def get_storage(self) -> List[Content]: 34 | """List storage pools across the cluster with detailed status. 35 | 36 | Retrieves comprehensive information for each storage pool including: 37 | - Basic identification (name, type) 38 | - Content types supported (VM disks, backups, ISO images, etc.) 39 | - Availability status (online/offline) 40 | - Usage statistics: 41 | * Used space 42 | * Total capacity 43 | * Available space 44 | 45 | Implements a fallback mechanism that returns basic information 46 | if detailed status retrieval fails for any storage pool. 47 | 48 | Returns: 49 | List of Content objects containing formatted storage information: 50 | { 51 | "storage": "storage-name", 52 | "type": "storage-type", 53 | "content": ["content-types"], 54 | "status": "online/offline", 55 | "used": bytes, 56 | "total": bytes, 57 | "available": bytes 58 | } 59 | 60 | Raises: 61 | RuntimeError: If the cluster-wide storage query fails 62 | """ 63 | try: 64 | result = self.proxmox.storage.get() 65 | storage = [] 66 | 67 | for store in result: 68 | # Get detailed storage info including usage 69 | try: 70 | status = self.proxmox.nodes(store.get("node", "localhost")).storage(store["storage"]).status.get() 71 | storage.append({ 72 | "storage": store["storage"], 73 | "type": store["type"], 74 | "content": store.get("content", []), 75 | "status": "online" if store.get("enabled", True) else "offline", 76 | "used": status.get("used", 0), 77 | "total": status.get("total", 0), 78 | "available": status.get("avail", 0) 79 | }) 80 | except Exception: 81 | # If detailed status fails, add basic info 82 | storage.append({ 83 | "storage": store["storage"], 84 | "type": store["type"], 85 | "content": store.get("content", []), 86 | "status": "online" if store.get("enabled", True) else "offline", 87 | "used": 0, 88 | "total": 0, 89 | "available": 0 90 | }) 91 | 92 | return self._format_response(storage, "storage") 93 | except Exception as e: 94 | self._handle_error("get storage", e) 95 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/core/proxmox.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Proxmox API setup and management. 3 | 4 | This module handles the core Proxmox API integration, providing: 5 | - Secure API connection setup and management 6 | - Token-based authentication 7 | - Connection testing and validation 8 | - Error handling for API operations 9 | 10 | The ProxmoxManager class serves as the central point for all Proxmox API 11 | interactions, ensuring consistent connection handling and authentication 12 | across the MCP server. 13 | """ 14 | import logging 15 | from typing import Dict, Any 16 | from proxmoxer import ProxmoxAPI 17 | from ..config.models import ProxmoxConfig, AuthConfig 18 | 19 | class ProxmoxManager: 20 | """Manager class for Proxmox API operations. 21 | 22 | This class handles: 23 | - API connection initialization and management 24 | - Configuration validation and merging 25 | - Connection testing and health checks 26 | - Token-based authentication setup 27 | 28 | The manager provides a single point of access to the Proxmox API, 29 | ensuring proper initialization and error handling for all API operations. 30 | """ 31 | 32 | def __init__(self, proxmox_config: ProxmoxConfig, auth_config: AuthConfig): 33 | """Initialize the Proxmox API manager. 34 | 35 | Args: 36 | proxmox_config: Proxmox connection configuration 37 | auth_config: Authentication configuration 38 | """ 39 | self.logger = logging.getLogger("proxmox-mcp.proxmox") 40 | self.config = self._create_config(proxmox_config, auth_config) 41 | self.api = self._setup_api() 42 | 43 | def _create_config(self, proxmox_config: ProxmoxConfig, auth_config: AuthConfig) -> Dict[str, Any]: 44 | """Create a configuration dictionary for ProxmoxAPI. 45 | 46 | Merges connection and authentication configurations into a single 47 | dictionary suitable for ProxmoxAPI initialization. Handles: 48 | - Host and port configuration 49 | - SSL verification settings 50 | - Token-based authentication details 51 | - Service type specification 52 | 53 | Args: 54 | proxmox_config: Proxmox connection configuration (host, port, SSL settings) 55 | auth_config: Authentication configuration (user, token details) 56 | 57 | Returns: 58 | Dictionary containing merged configuration ready for API initialization 59 | """ 60 | return { 61 | 'host': proxmox_config.host, 62 | 'port': proxmox_config.port, 63 | 'user': auth_config.user, 64 | 'token_name': auth_config.token_name, 65 | 'token_value': auth_config.token_value, 66 | 'verify_ssl': proxmox_config.verify_ssl, 67 | 'service': proxmox_config.service 68 | } 69 | 70 | def _setup_api(self) -> ProxmoxAPI: 71 | """Initialize and test Proxmox API connection. 72 | 73 | Performs the following steps: 74 | 1. Creates ProxmoxAPI instance with configured settings 75 | 2. Tests connection by making a version check request 76 | 3. Validates authentication and permissions 77 | 4. Logs connection status and any issues 78 | 79 | Returns: 80 | Initialized and tested ProxmoxAPI instance 81 | 82 | Raises: 83 | RuntimeError: If connection fails due to: 84 | - Invalid host/port 85 | - Authentication failure 86 | - Network connectivity issues 87 | - SSL certificate validation errors 88 | """ 89 | try: 90 | self.logger.info(f"Connecting to Proxmox host: {self.config['host']}") 91 | api = ProxmoxAPI(**self.config) 92 | 93 | # Test connection 94 | api.version.get() 95 | self.logger.info("Successfully connected to Proxmox API") 96 | 97 | return api 98 | except Exception as e: 99 | self.logger.error(f"Failed to connect to Proxmox: {e}") 100 | raise RuntimeError(f"Failed to connect to Proxmox: {e}") 101 | 102 | def get_api(self) -> ProxmoxAPI: 103 | """Get the initialized Proxmox API instance. 104 | 105 | Provides access to the configured and tested ProxmoxAPI instance 106 | for making API calls. The instance maintains connection state and 107 | handles authentication automatically. 108 | 109 | Returns: 110 | ProxmoxAPI instance ready for making API calls 111 | """ 112 | return self.api 113 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/tools/base.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Base classes and utilities for Proxmox MCP tools. 3 | 4 | This module provides the foundation for all Proxmox MCP tools, including: 5 | - Base tool class with common functionality 6 | - Response formatting utilities 7 | - Error handling mechanisms 8 | - Logging setup 9 | 10 | All tool implementations inherit from the ProxmoxTool base class to ensure 11 | consistent behavior and error handling across the MCP server. 12 | """ 13 | import logging 14 | from typing import Any, Dict, List, Optional, Union 15 | from mcp.types import TextContent as Content 16 | from proxmoxer import ProxmoxAPI 17 | from ..formatting import ProxmoxTemplates 18 | 19 | class ProxmoxTool: 20 | """Base class for Proxmox MCP tools. 21 | 22 | This class provides common functionality used by all Proxmox tool implementations: 23 | - Proxmox API access 24 | - Standardized logging 25 | - Response formatting 26 | - Error handling 27 | 28 | All tool classes should inherit from this base class to ensure consistent 29 | behavior and error handling across the MCP server. 30 | """ 31 | 32 | def __init__(self, proxmox_api: ProxmoxAPI): 33 | """Initialize the tool. 34 | 35 | Args: 36 | proxmox_api: Initialized ProxmoxAPI instance 37 | """ 38 | self.proxmox = proxmox_api 39 | self.logger = logging.getLogger(f"proxmox-mcp.{self.__class__.__name__.lower()}") 40 | 41 | def _format_response(self, data: Any, resource_type: Optional[str] = None) -> List[Content]: 42 | """Format response data into MCP content using templates. 43 | 44 | This method handles formatting of various Proxmox resource types into 45 | consistent MCP content responses. It uses specialized templates for 46 | different resource types (nodes, VMs, storage, etc.) and falls back 47 | to JSON formatting for unknown types. 48 | 49 | Args: 50 | data: Raw data from Proxmox API to format 51 | resource_type: Type of resource for template selection. Valid types: 52 | 'nodes', 'node_status', 'vms', 'storage', 'containers', 'cluster' 53 | 54 | Returns: 55 | List of Content objects formatted according to resource type 56 | """ 57 | if resource_type == "nodes": 58 | formatted = ProxmoxTemplates.node_list(data) 59 | elif resource_type == "node_status": 60 | # For node_status, data should be a tuple of (node_name, status_dict) 61 | if isinstance(data, tuple) and len(data) == 2: 62 | formatted = ProxmoxTemplates.node_status(data[0], data[1]) 63 | else: 64 | formatted = ProxmoxTemplates.node_status("unknown", data) 65 | elif resource_type == "vms": 66 | formatted = ProxmoxTemplates.vm_list(data) 67 | elif resource_type == "storage": 68 | formatted = ProxmoxTemplates.storage_list(data) 69 | elif resource_type == "containers": 70 | formatted = ProxmoxTemplates.container_list(data) 71 | elif resource_type == "cluster": 72 | formatted = ProxmoxTemplates.cluster_status(data) 73 | else: 74 | # Fallback to JSON formatting for unknown types 75 | import json 76 | formatted = json.dumps(data, indent=2) 77 | 78 | return [Content(type="text", text=formatted)] 79 | 80 | def _handle_error(self, operation: str, error: Exception) -> None: 81 | """Handle and log errors from Proxmox operations. 82 | 83 | Provides standardized error handling across all tools by: 84 | - Logging errors with appropriate context 85 | - Categorizing errors into specific exception types 86 | - Converting Proxmox-specific errors into standard Python exceptions 87 | 88 | Args: 89 | operation: Description of the operation that failed (e.g., "get node status") 90 | error: The exception that occurred during the operation 91 | 92 | Raises: 93 | ValueError: For invalid input, missing resources, or permission issues 94 | RuntimeError: For unexpected errors or API failures 95 | """ 96 | error_msg = str(error) 97 | self.logger.error(f"Failed to {operation}: {error_msg}") 98 | 99 | if "not found" in error_msg.lower(): 100 | raise ValueError(f"Resource not found: {error_msg}") 101 | if "permission denied" in error_msg.lower(): 102 | raise ValueError(f"Permission denied: {error_msg}") 103 | if "invalid" in error_msg.lower(): 104 | raise ValueError(f"Invalid input: {error_msg}") 105 | 106 | raise RuntimeError(f"Failed to {operation}: {error_msg}") 107 | ``` -------------------------------------------------------------------------------- /test_scripts/test_openapi.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | """ 3 | Test OpenAPI functionality 4 | """ 5 | import requests 6 | import json 7 | import os 8 | 9 | # Get base URL from environment variable or use default localhost 10 | BASE_URL = os.getenv('OPENAPI_BASE_URL', 'http://localhost:8811') 11 | 12 | def test_basic_endpoints(): 13 | """Test basic API endpoints""" 14 | 15 | print("🔍 Test basic API endpoints") 16 | print(f"🌐 Using base URL: {BASE_URL}") 17 | print("=" * 50) 18 | 19 | # Test get nodes 20 | try: 21 | response = requests.post(f"{BASE_URL}/get_nodes") 22 | print(f"✅ get_nodes: {response.status_code} - {len(response.text)} chars") 23 | except Exception as e: 24 | print(f"❌ get_nodes error: {e}") 25 | 26 | # Test get VM list 27 | try: 28 | response = requests.post(f"{BASE_URL}/get_vms") 29 | print(f"✅ get_vms: {response.status_code} - {len(response.text)} chars") 30 | if response.status_code == 200: 31 | # Check if our test VMs are included 32 | if "test-vm" in response.text: 33 | print(" 📋 Test VM found") 34 | except Exception as e: 35 | print(f"❌ get_vms error: {e}") 36 | 37 | def test_vm_creation_api(): 38 | """Test VM creation API""" 39 | 40 | print("\n🎉 Test VM creation API - user requested configuration") 41 | print("=" * 50) 42 | print("Configuration: 1 CPU core, 2GB RAM, 10GB storage") 43 | 44 | # VM creation parameters 45 | create_data = { 46 | "node": "pve", 47 | "vmid": "996", # Use new VM ID 48 | "name": "user-requested-vm", 49 | "cpus": 1, 50 | "memory": 2048, # 2GB in MB 51 | "disk_size": 10 # 10GB 52 | } 53 | 54 | try: 55 | response = requests.post( 56 | f"{BASE_URL}/create_vm", 57 | json=create_data, 58 | headers={"Content-Type": "application/json"} 59 | ) 60 | 61 | print(f"📡 API response status: {response.status_code}") 62 | 63 | if response.status_code == 200: 64 | result = response.json() 65 | print("✅ VM creation successful!") 66 | print(f"📄 Response content: {json.dumps(result, indent=2, ensure_ascii=False)}") 67 | else: 68 | print(f"❌ VM creation failed: {response.text}") 69 | 70 | except requests.exceptions.ConnectionError: 71 | print("❌ Cannot connect to API server - please ensure OpenAPI service is running") 72 | except Exception as e: 73 | print(f"❌ API call error: {e}") 74 | 75 | def test_vm_power_api(): 76 | """Test VM power management API""" 77 | 78 | print("\n🚀 Test VM power management API") 79 | print("=" * 50) 80 | 81 | # Test starting VM 101 (VPN-Server) 82 | start_data = { 83 | "node": "pve", 84 | "vmid": "101" 85 | } 86 | 87 | try: 88 | response = requests.post( 89 | f"{BASE_URL}/start_vm", 90 | json=start_data, 91 | headers={"Content-Type": "application/json"} 92 | ) 93 | 94 | print(f"📡 Start VM 101 response: {response.status_code}") 95 | 96 | if response.status_code == 200: 97 | result = response.json() 98 | print("✅ VM start command successful!") 99 | print(f"📄 Response: {json.dumps(result, indent=2, ensure_ascii=False)}") 100 | else: 101 | print(f"❌ VM start failed: {response.text}") 102 | 103 | except requests.exceptions.ConnectionError: 104 | print("❌ Cannot connect to API server") 105 | except Exception as e: 106 | print(f"❌ API call error: {e}") 107 | 108 | def list_available_apis(): 109 | """List all available API endpoints""" 110 | 111 | print("\n📋 Available API endpoints") 112 | print("=" * 50) 113 | 114 | try: 115 | response = requests.get(f"{BASE_URL}/openapi.json") 116 | if response.status_code == 200: 117 | openapi_spec = response.json() 118 | paths = openapi_spec.get("paths", {}) 119 | 120 | print(f"Found {len(paths)} API endpoints:") 121 | for path, methods in paths.items(): 122 | for method, details in methods.items(): 123 | summary = details.get("summary", "No summary") 124 | print(f" • {method.upper()} {path} - {summary}") 125 | else: 126 | print(f"❌ Cannot get API specification: {response.status_code}") 127 | 128 | except Exception as e: 129 | print(f"❌ Get API list error: {e}") 130 | 131 | if __name__ == "__main__": 132 | print("🔍 ProxmoxMCP OpenAPI functionality test") 133 | print("=" * 60) 134 | 135 | # List available APIs 136 | list_available_apis() 137 | 138 | # Test basic functionality 139 | test_basic_endpoints() 140 | 141 | # Test VM creation functionality 142 | test_vm_creation_api() 143 | 144 | # Test VM power management 145 | test_vm_power_api() 146 | 147 | print("\n✅ All tests completed") 148 | print("\n💡 Usage instructions:") 149 | print("When user says 'Can you create a VM with 1 cpu core and 2 GB ram with 10GB of storage disk',") 150 | print("the AI assistant can call create_vm API to complete the task!") 151 | print(f"\n🔧 To test with different server, set environment variable:") 152 | print("export OPENAPI_BASE_URL=http://your-server:8811") ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/formatting/formatters.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Core formatting functions for Proxmox MCP output. 3 | """ 4 | from typing import List, Union, Dict, Any 5 | from .theme import ProxmoxTheme 6 | from .colors import ProxmoxColors 7 | 8 | class ProxmoxFormatters: 9 | """Core formatting functions for Proxmox data.""" 10 | 11 | @staticmethod 12 | def format_bytes(bytes_value: int) -> str: 13 | """Format bytes with proper units. 14 | 15 | Args: 16 | bytes_value: Number of bytes 17 | 18 | Returns: 19 | Formatted string with appropriate unit 20 | """ 21 | for unit in ['B', 'KB', 'MB', 'GB', 'TB']: 22 | if bytes_value < 1024: 23 | return f"{bytes_value:.2f} {unit}" 24 | bytes_value /= 1024 25 | return f"{bytes_value:.2f} TB" 26 | 27 | @staticmethod 28 | def format_uptime(seconds: int) -> str: 29 | """Format uptime in seconds to human readable format. 30 | 31 | Args: 32 | seconds: Uptime in seconds 33 | 34 | Returns: 35 | Formatted uptime string 36 | """ 37 | days = seconds // 86400 38 | hours = (seconds % 86400) // 3600 39 | minutes = (seconds % 3600) // 60 40 | 41 | parts = [] 42 | if days > 0: 43 | parts.append(f"{days}d") 44 | if hours > 0: 45 | parts.append(f"{hours}h") 46 | if minutes > 0: 47 | parts.append(f"{minutes}m") 48 | 49 | return f"{ProxmoxTheme.METRICS['uptime']} " + " ".join(parts) if parts else "0m" 50 | 51 | @staticmethod 52 | def format_percentage(value: float, warning: float = 80.0, critical: float = 90.0) -> str: 53 | """Format percentage with color based on thresholds. 54 | 55 | Args: 56 | value: Percentage value 57 | warning: Warning threshold 58 | critical: Critical threshold 59 | 60 | Returns: 61 | Formatted percentage string 62 | """ 63 | color = ProxmoxColors.metric_color(value, warning, critical) 64 | return ProxmoxColors.colorize(f"{value:.1f}%", color) 65 | 66 | @staticmethod 67 | def format_status(status: str) -> str: 68 | """Format status with emoji and color. 69 | 70 | Args: 71 | status: Status string 72 | 73 | Returns: 74 | Formatted status string 75 | """ 76 | status = status.lower() 77 | emoji = ProxmoxTheme.get_status_emoji(status) 78 | color = ProxmoxColors.status_color(status) 79 | return f"{emoji} {ProxmoxColors.colorize(status.upper(), color)}" 80 | 81 | @staticmethod 82 | def format_resource_header(resource_type: str, name: str) -> str: 83 | """Format resource header with emoji and styling. 84 | 85 | Args: 86 | resource_type: Type of resource 87 | name: Resource name 88 | 89 | Returns: 90 | Formatted header string 91 | """ 92 | emoji = ProxmoxTheme.get_resource_emoji(resource_type) 93 | color = ProxmoxColors.resource_color(resource_type) 94 | return f"\n{emoji} {ProxmoxColors.colorize(name, color, ProxmoxColors.BOLD)}" 95 | 96 | @staticmethod 97 | def format_section_header(title: str, section_type: str = 'header') -> str: 98 | """Format section header with emoji and border. 99 | 100 | Args: 101 | title: Section title 102 | section_type: Type of section for emoji selection 103 | 104 | Returns: 105 | Formatted section header 106 | """ 107 | emoji = ProxmoxTheme.get_section_emoji(section_type) 108 | header = f"{emoji} {title}" 109 | border = "═" * len(header) 110 | return f"\n{header}\n{border}\n" 111 | 112 | @staticmethod 113 | def format_key_value(key: str, value: str, emoji: str = "") -> str: 114 | """Format key-value pair with optional emoji. 115 | 116 | Args: 117 | key: Label/key 118 | value: Value to display 119 | emoji: Optional emoji prefix 120 | 121 | Returns: 122 | Formatted key-value string 123 | """ 124 | key_str = ProxmoxColors.colorize(key, ProxmoxColors.CYAN) 125 | prefix = f"{emoji} " if emoji else "" 126 | return f"{prefix}{key_str}: {value}" 127 | 128 | @staticmethod 129 | def format_command_output(success: bool, command: str, output: str, error: str = None) -> str: 130 | """Format command execution output. 131 | 132 | Args: 133 | success: Whether command succeeded 134 | command: The command that was executed 135 | output: Command output 136 | error: Optional error message 137 | 138 | Returns: 139 | Formatted command output string 140 | """ 141 | result = [ 142 | f"{ProxmoxTheme.ACTIONS['command']} Console Command Result", 143 | f" • Status: {'SUCCESS' if success else 'FAILED'}", 144 | f" • Command: {command}", 145 | "", 146 | "Output:", 147 | output.strip() 148 | ] 149 | 150 | if error: 151 | result.extend([ 152 | "", 153 | "Error:", 154 | error.strip() 155 | ]) 156 | 157 | return "\n".join(result) 158 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/tools/definitions.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tool descriptions for Proxmox MCP tools. 3 | """ 4 | 5 | # Node tool descriptions 6 | GET_NODES_DESC = """List all nodes in the Proxmox cluster with their status, CPU, memory, and role information. 7 | 8 | Example: 9 | {"node": "pve1", "status": "online", "cpu_usage": 0.15, "memory": {"used": "8GB", "total": "32GB"}}""" 10 | 11 | GET_NODE_STATUS_DESC = """Get detailed status information for a specific Proxmox node. 12 | 13 | Parameters: 14 | node* - Name/ID of node to query (e.g. 'pve1') 15 | 16 | Example: 17 | {"cpu": {"usage": 0.15}, "memory": {"used": "8GB", "total": "32GB"}}""" 18 | 19 | # VM tool descriptions 20 | GET_VMS_DESC = """List all virtual machines across the cluster with their status and resource usage. 21 | 22 | Example: 23 | {"vmid": "100", "name": "ubuntu", "status": "running", "cpu": 2, "memory": 4096}""" 24 | 25 | CREATE_VM_DESC = """Create a new virtual machine with specified configuration. 26 | 27 | Parameters: 28 | node* - Host node name (e.g. 'pve') 29 | vmid* - New VM ID number (e.g. '200', '300') 30 | name* - VM name (e.g. 'my-new-vm', 'web-server') 31 | cpus* - Number of CPU cores (e.g. 1, 2, 4) 32 | memory* - Memory size in MB (e.g. 2048 for 2GB, 4096 for 4GB) 33 | disk_size* - Disk size in GB (e.g. 10, 20, 50) 34 | storage - Storage name (optional, will auto-detect if not specified) 35 | ostype - OS type (optional, default: 'l26' for Linux) 36 | 37 | Examples: 38 | - Create VM with 1 CPU, 2GB RAM, 10GB disk: node='pve', vmid='200', name='test-vm', cpus=1, memory=2048, disk_size=10 39 | - Create VM with 2 CPUs, 4GB RAM, 20GB disk: node='pve', vmid='201', name='web-server', cpus=2, memory=4096, disk_size=20""" 40 | 41 | EXECUTE_VM_COMMAND_DESC = """Execute commands in a VM via QEMU guest agent. 42 | 43 | Parameters: 44 | node* - Host node name (e.g. 'pve1') 45 | vmid* - VM ID number (e.g. '100') 46 | command* - Shell command to run (e.g. 'uname -a') 47 | 48 | Example: 49 | {"success": true, "output": "Linux vm1 5.4.0", "exit_code": 0}""" 50 | 51 | # VM Power Management tool descriptions 52 | START_VM_DESC = """Start a virtual machine. 53 | 54 | Parameters: 55 | node* - Host node name (e.g. 'pve') 56 | vmid* - VM ID number (e.g. '101') 57 | 58 | Example: 59 | Power on VPN-Server with ID 101 on node pve""" 60 | 61 | STOP_VM_DESC = """Stop a virtual machine (force stop). 62 | 63 | Parameters: 64 | node* - Host node name (e.g. 'pve') 65 | vmid* - VM ID number (e.g. '101') 66 | 67 | Example: 68 | Force stop VPN-Server with ID 101 on node pve""" 69 | 70 | SHUTDOWN_VM_DESC = """Shutdown a virtual machine gracefully. 71 | 72 | Parameters: 73 | node* - Host node name (e.g. 'pve') 74 | vmid* - VM ID number (e.g. '101') 75 | 76 | Example: 77 | Gracefully shutdown VPN-Server with ID 101 on node pve""" 78 | 79 | RESET_VM_DESC = """Reset (restart) a virtual machine. 80 | 81 | Parameters: 82 | node* - Host node name (e.g. 'pve') 83 | vmid* - VM ID number (e.g. '101') 84 | 85 | Example: 86 | Reset VPN-Server with ID 101 on node pve""" 87 | 88 | DELETE_VM_DESC = """Delete/remove a virtual machine completely. 89 | 90 | ⚠️ WARNING: This operation permanently deletes the VM and all its data! 91 | 92 | Parameters: 93 | node* - Host node name (e.g. 'pve') 94 | vmid* - VM ID number (e.g. '998') 95 | force - Force deletion even if VM is running (optional, default: false) 96 | 97 | This will permanently remove: 98 | - VM configuration 99 | - All virtual disks 100 | - All snapshots 101 | - Cannot be undone! 102 | 103 | Example: 104 | Delete test VM with ID 998 on node pve""" 105 | 106 | # Container tool descriptions 107 | GET_CONTAINERS_DESC = """List LXC containers across the cluster (or filter by node). 108 | 109 | Parameters: 110 | - node (optional): Node name to filter (e.g. 'pve1') 111 | - include_stats (bool, default true): Include live CPU/memory stats 112 | - include_raw (bool, default false): Include raw Proxmox API payloads for debugging 113 | - format_style ('pretty'|'json', default 'pretty'): Pretty text or raw JSON list 114 | 115 | Notes: 116 | - Live stats from /nodes/{node}/lxc/{vmid}/status/current. 117 | - If maxmem is 0 (unlimited), memory limit falls back to /config.memory (MiB). 118 | - If live returns zeros, the most recent RRD sample is used as a fallback. 119 | - Fields provided: cores (CPU cores/cpulimit), memory (MiB limit), cpu_pct, mem_bytes, maxmem_bytes, mem_pct, unlimited_memory. 120 | """ 121 | 122 | START_CONTAINER_DESC = """Start one or more LXC containers. 123 | selector: '123' | 'pve1:123' | 'pve1/name' | 'name' | comma list 124 | Example: start_container selector='pve1:101,pve2/web' 125 | """ 126 | 127 | STOP_CONTAINER_DESC = """Stop LXC containers. graceful=True uses shutdown; otherwise force stop. 128 | selector: same grammar as start_container 129 | timeout_seconds: 10 (default) 130 | """ 131 | 132 | RESTART_CONTAINER_DESC = """Restart LXC containers (reboot). 133 | selector: same grammar as start_container 134 | """ 135 | 136 | UPDATE_CONTAINER_RESOURCES_DESC = """Update resources for one or more LXC containers. 137 | 138 | selector: same grammar as start_container 139 | cores: New CPU core count (optional) 140 | memory: New memory limit in MiB (optional) 141 | swap: New swap limit in MiB (optional) 142 | disk_gb: Additional disk size in GiB to add (optional) 143 | disk: Disk identifier to resize (default 'rootfs') 144 | """ 145 | 146 | # Storage tool descriptions 147 | GET_STORAGE_DESC = """List storage pools across the cluster with their usage and configuration. 148 | 149 | Example: 150 | {"storage": "local-lvm", "type": "lvm", "used": "500GB", "total": "1TB"}""" 151 | 152 | # Cluster tool descriptions 153 | GET_CLUSTER_STATUS_DESC = """Get overall Proxmox cluster health and configuration status. 154 | 155 | Example: 156 | {"name": "proxmox", "quorum": "ok", "nodes": 3, "ha_status": "active"}""" 157 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/tools/node.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Node-related tools for Proxmox MCP. 3 | 4 | This module provides tools for managing and monitoring Proxmox nodes: 5 | - Listing all nodes in the cluster with their status 6 | - Getting detailed node information including: 7 | * CPU usage and configuration 8 | * Memory utilization 9 | * Uptime statistics 10 | * Health status 11 | 12 | The tools handle both basic and detailed node information retrieval, 13 | with fallback mechanisms for partial data availability. 14 | """ 15 | from typing import List 16 | from mcp.types import TextContent as Content 17 | from .base import ProxmoxTool 18 | from .definitions import GET_NODES_DESC, GET_NODE_STATUS_DESC 19 | 20 | class NodeTools(ProxmoxTool): 21 | """Tools for managing Proxmox nodes. 22 | 23 | Provides functionality for: 24 | - Retrieving cluster-wide node information 25 | - Getting detailed status for specific nodes 26 | - Monitoring node health and resources 27 | - Handling node-specific API operations 28 | 29 | Implements fallback mechanisms for scenarios where detailed 30 | node information might be temporarily unavailable. 31 | """ 32 | 33 | def get_nodes(self) -> List[Content]: 34 | """List all nodes in the Proxmox cluster with detailed status. 35 | 36 | Retrieves comprehensive information for each node including: 37 | - Basic status (online/offline) 38 | - Uptime statistics 39 | - CPU configuration and count 40 | - Memory usage and capacity 41 | 42 | Implements a fallback mechanism that returns basic information 43 | if detailed status retrieval fails for any node. 44 | 45 | Returns: 46 | List of Content objects containing formatted node information: 47 | { 48 | "node": "node_name", 49 | "status": "online/offline", 50 | "uptime": seconds, 51 | "maxcpu": cpu_count, 52 | "memory": { 53 | "used": bytes, 54 | "total": bytes 55 | } 56 | } 57 | 58 | Raises: 59 | RuntimeError: If the cluster-wide node query fails 60 | """ 61 | try: 62 | result = self.proxmox.nodes.get() 63 | nodes = [] 64 | 65 | # Get detailed info for each node 66 | for node in result: 67 | node_name = node["node"] 68 | try: 69 | # Get detailed status for each node 70 | status = self.proxmox.nodes(node_name).status.get() 71 | nodes.append({ 72 | "node": node_name, 73 | "status": node["status"], 74 | "uptime": status.get("uptime", 0), 75 | "maxcpu": status.get("cpuinfo", {}).get("cpus", "N/A"), 76 | "memory": { 77 | "used": status.get("memory", {}).get("used", 0), 78 | "total": status.get("memory", {}).get("total", 0) 79 | } 80 | }) 81 | except Exception: 82 | # Fallback to basic info if detailed status fails 83 | nodes.append({ 84 | "node": node_name, 85 | "status": node["status"], 86 | "uptime": 0, 87 | "maxcpu": "N/A", 88 | "memory": { 89 | # The nodes.get() API already returns memory usage 90 | # in the "mem" field, so use that directly. The 91 | # previous implementation subtracted this value 92 | # from "maxmem" which actually produced the amount 93 | # of *free* memory instead of the used memory. 94 | "used": node.get("mem", 0), 95 | "total": node.get("maxmem", 0) 96 | } 97 | }) 98 | return self._format_response(nodes, "nodes") 99 | except Exception as e: 100 | self._handle_error("get nodes", e) 101 | 102 | def get_node_status(self, node: str) -> List[Content]: 103 | """Get detailed status information for a specific node. 104 | 105 | Retrieves comprehensive status information including: 106 | - CPU usage and configuration 107 | - Memory utilization details 108 | - Uptime and load statistics 109 | - Network status 110 | - Storage health 111 | - Running tasks and services 112 | 113 | Args: 114 | node: Name/ID of node to query (e.g., 'pve1', 'proxmox-node2') 115 | 116 | Returns: 117 | List of Content objects containing detailed node status: 118 | { 119 | "uptime": seconds, 120 | "cpu": { 121 | "usage": percentage, 122 | "cores": count 123 | }, 124 | "memory": { 125 | "used": bytes, 126 | "total": bytes, 127 | "free": bytes 128 | }, 129 | ...additional status fields 130 | } 131 | 132 | Raises: 133 | ValueError: If the specified node is not found 134 | RuntimeError: If status retrieval fails (node offline, network issues) 135 | """ 136 | try: 137 | result = self.proxmox.nodes(node).status.get() 138 | return self._format_response((node, result), "node_status") 139 | except Exception as e: 140 | self._handle_error(f"get status for node {node}", e) 141 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/formatting/components.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Reusable UI components for Proxmox MCP output. 3 | """ 4 | from typing import List, Optional 5 | from .colors import ProxmoxColors 6 | from .theme import ProxmoxTheme 7 | 8 | class ProxmoxComponents: 9 | """Reusable UI components for formatted output.""" 10 | 11 | @staticmethod 12 | def create_table(headers: List[str], rows: List[List[str]], title: Optional[str] = None) -> str: 13 | """Create an ASCII table with optional title. 14 | 15 | Args: 16 | headers: List of column headers 17 | rows: List of row data 18 | title: Optional table title 19 | 20 | Returns: 21 | Formatted table string 22 | """ 23 | # Calculate column widths considering multi-line content 24 | widths = [len(header) for header in headers] 25 | for row in rows: 26 | for i, cell in enumerate(row): 27 | cell_lines = str(cell).split('\n') 28 | max_line_length = max(len(line) for line in cell_lines) 29 | widths[i] = max(widths[i], max_line_length) 30 | 31 | # Create separator line 32 | separator = "+" + "+".join("-" * (w + 2) for w in widths) + "+" 33 | 34 | # Calculate total width for title 35 | total_width = sum(widths) + len(widths) + 1 36 | 37 | # Build table 38 | result = [] 39 | 40 | # Add title if provided 41 | if title: 42 | # Center the title 43 | title_str = ProxmoxColors.colorize(title, ProxmoxColors.CYAN, ProxmoxColors.BOLD) 44 | padding = (total_width - len(title) - 2) // 2 # -2 for the border chars 45 | title_separator = "+" + "-" * (total_width - 2) + "+" 46 | result.extend([ 47 | title_separator, 48 | "|" + " " * padding + title_str + " " * (total_width - padding - len(title) - 2) + "|", 49 | title_separator 50 | ]) 51 | 52 | # Add headers 53 | header = "|" + "|".join(f" {ProxmoxColors.colorize(h, ProxmoxColors.CYAN):<{w}} " for w, h in zip(widths, headers)) + "|" 54 | result.extend([separator, header, separator]) 55 | 56 | # Add rows with multi-line cell support 57 | for row in rows: 58 | # Split each cell into lines 59 | cell_lines = [str(cell).split('\n') for cell in row] 60 | max_lines = max(len(lines) for lines in cell_lines) 61 | 62 | # Pad cells with fewer lines 63 | padded_cells = [] 64 | for lines in cell_lines: 65 | if len(lines) < max_lines: 66 | lines.extend([''] * (max_lines - len(lines))) 67 | padded_cells.append(lines) 68 | 69 | # Create row strings for each line 70 | for line_idx in range(max_lines): 71 | line_parts = [] 72 | for col_idx, cell_lines in enumerate(padded_cells): 73 | line = cell_lines[line_idx] 74 | line_parts.append(f" {line:<{widths[col_idx]}} ") 75 | result.append("|" + "|".join(line_parts) + "|") 76 | 77 | # Add separator after each row except the last 78 | if row != rows[-1]: 79 | result.append(separator) 80 | 81 | result.append(separator) 82 | return "\n".join(result) 83 | 84 | @staticmethod 85 | def create_progress_bar(value: float, total: float, width: int = 20) -> str: 86 | """Create a progress bar with percentage. 87 | 88 | Args: 89 | value: Current value 90 | total: Maximum value 91 | width: Width of progress bar in characters 92 | 93 | Returns: 94 | Formatted progress bar string 95 | """ 96 | percentage = min(100, (value / total * 100) if total > 0 else 0) 97 | filled = int(width * percentage / 100) 98 | color = ProxmoxColors.metric_color(percentage) 99 | 100 | bar = "█" * filled + "░" * (width - filled) 101 | return f"{ProxmoxColors.colorize(bar, color)} {percentage:.1f}%" 102 | 103 | @staticmethod 104 | def create_resource_usage(used: float, total: float, label: str, emoji: str) -> str: 105 | """Create a resource usage display with progress bar. 106 | 107 | Args: 108 | used: Used amount 109 | total: Total amount 110 | label: Resource label 111 | emoji: Resource emoji 112 | 113 | Returns: 114 | Formatted resource usage string 115 | """ 116 | from .formatters import ProxmoxFormatters 117 | percentage = (used / total * 100) if total > 0 else 0 118 | progress = ProxmoxComponents.create_progress_bar(used, total) 119 | 120 | return ( 121 | f"{emoji} {label}:\n" 122 | f" {progress}\n" 123 | f" {ProxmoxFormatters.format_bytes(used)} / {ProxmoxFormatters.format_bytes(total)}" 124 | ) 125 | 126 | @staticmethod 127 | def create_key_value_grid(data: dict, columns: int = 2) -> str: 128 | """Create a grid of key-value pairs. 129 | 130 | Args: 131 | data: Dictionary of key-value pairs 132 | columns: Number of columns in grid 133 | 134 | Returns: 135 | Formatted grid string 136 | """ 137 | # Calculate max widths for each column 138 | items = list(data.items()) 139 | rows = [items[i:i + columns] for i in range(0, len(items), columns)] 140 | 141 | key_widths = [0] * columns 142 | val_widths = [0] * columns 143 | 144 | for row in rows: 145 | for i, (key, val) in enumerate(row): 146 | key_widths[i] = max(key_widths[i], len(str(key))) 147 | val_widths[i] = max(val_widths[i], len(str(val))) 148 | 149 | # Format rows 150 | result = [] 151 | for row in rows: 152 | formatted_items = [] 153 | for i, (key, val) in enumerate(row): 154 | key_str = ProxmoxColors.colorize(f"{key}:", ProxmoxColors.CYAN) 155 | formatted_items.append(f"{key_str:<{key_widths[i] + 10}} {val:<{val_widths[i]}}") 156 | result.append(" ".join(formatted_items)) 157 | 158 | return "\n".join(result) 159 | 160 | @staticmethod 161 | def create_status_badge(status: str) -> str: 162 | """Create a status badge with emoji. 163 | 164 | Args: 165 | status: Status string 166 | 167 | Returns: 168 | Formatted status badge string 169 | """ 170 | status = status.lower() 171 | emoji = ProxmoxTheme.get_status_emoji(status) 172 | return f"{emoji} {status.upper()}" 173 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/tools/console/manager.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Module for managing VM console operations. 3 | 4 | This module provides functionality for interacting with VM consoles: 5 | - Executing commands within VMs via QEMU guest agent 6 | - Handling command execution lifecycle 7 | - Managing command output and status 8 | - Error handling and logging 9 | 10 | The module implements a robust command execution system with: 11 | - VM state verification 12 | - Asynchronous command execution 13 | - Detailed status tracking 14 | - Comprehensive error handling 15 | """ 16 | 17 | import logging 18 | from typing import Dict, Any 19 | 20 | class VMConsoleManager: 21 | """Manager class for VM console operations. 22 | 23 | Provides functionality for: 24 | - Executing commands in VM consoles 25 | - Managing command execution lifecycle 26 | - Handling command output and errors 27 | - Monitoring execution status 28 | 29 | Uses QEMU guest agent for reliable command execution with: 30 | - VM state verification before execution 31 | - Asynchronous command processing 32 | - Detailed output capture 33 | - Comprehensive error handling 34 | """ 35 | 36 | def __init__(self, proxmox_api): 37 | """Initialize the VM console manager. 38 | 39 | Args: 40 | proxmox_api: Initialized ProxmoxAPI instance 41 | """ 42 | self.proxmox = proxmox_api 43 | self.logger = logging.getLogger("proxmox-mcp.vm-console") 44 | 45 | async def execute_command(self, node: str, vmid: str, command: str) -> Dict[str, Any]: 46 | """Execute a command in a VM's console via QEMU guest agent. 47 | 48 | Implements a two-phase command execution process: 49 | 1. Command Initiation: 50 | - Verifies VM exists and is running 51 | - Initiates command execution via guest agent 52 | - Captures command PID for tracking 53 | 54 | 2. Result Collection: 55 | - Monitors command execution status 56 | - Captures command output and errors 57 | - Handles completion status 58 | 59 | Requirements: 60 | - VM must be running 61 | - QEMU guest agent must be installed and active 62 | - Command execution permissions must be enabled 63 | 64 | Args: 65 | node: Name of the node where VM is running (e.g., 'pve1') 66 | vmid: ID of the VM to execute command in (e.g., '100') 67 | command: Shell command to execute in the VM 68 | 69 | Returns: 70 | Dictionary containing command execution results: 71 | { 72 | "success": true/false, 73 | "output": "command output", 74 | "error": "error output if any", 75 | "exit_code": command_exit_code 76 | } 77 | 78 | Raises: 79 | ValueError: If: 80 | - VM is not found 81 | - VM is not running 82 | - Guest agent is not available 83 | RuntimeError: If: 84 | - Command execution fails 85 | - Unable to get command status 86 | - API communication errors occur 87 | """ 88 | try: 89 | # Verify VM exists and is running 90 | vm_status = self.proxmox.nodes(node).qemu(vmid).status.current.get() 91 | if vm_status["status"] != "running": 92 | self.logger.error(f"Failed to execute command on VM {vmid}: VM is not running") 93 | raise ValueError(f"VM {vmid} on node {node} is not running") 94 | 95 | # Get VM's console 96 | self.logger.info(f"Executing command on VM {vmid} (node: {node}): {command}") 97 | 98 | # Get the API endpoint 99 | # Use the guest agent exec endpoint 100 | endpoint = self.proxmox.nodes(node).qemu(vmid).agent 101 | self.logger.debug(f"Using API endpoint: {endpoint}") 102 | 103 | # Execute the command using two-step process 104 | try: 105 | # Start command execution 106 | self.logger.info("Starting command execution...") 107 | try: 108 | self.logger.debug(f"Executing command via agent: {command}") 109 | exec_result = endpoint("exec").post(command=command) 110 | self.logger.debug(f"Raw exec response: {exec_result}") 111 | self.logger.info(f"Command started with result: {exec_result}") 112 | except Exception as e: 113 | self.logger.error(f"Failed to start command: {str(e)}") 114 | raise RuntimeError(f"Failed to start command: {str(e)}") 115 | 116 | if 'pid' not in exec_result: 117 | raise RuntimeError("No PID returned from command execution") 118 | 119 | pid = exec_result['pid'] 120 | self.logger.info(f"Waiting for command completion (PID: {pid})...") 121 | 122 | # Add a small delay to allow command to complete 123 | import asyncio 124 | await asyncio.sleep(1) 125 | 126 | # Get command output using exec-status 127 | try: 128 | self.logger.debug(f"Getting status for PID {pid}...") 129 | console = endpoint("exec-status").get(pid=pid) 130 | self.logger.debug(f"Raw exec-status response: {console}") 131 | if not console: 132 | raise RuntimeError("No response from exec-status") 133 | except Exception as e: 134 | self.logger.error(f"Failed to get command status: {str(e)}") 135 | raise RuntimeError(f"Failed to get command status: {str(e)}") 136 | self.logger.info(f"Command completed with status: {console}") 137 | except Exception as e: 138 | self.logger.error(f"API call failed: {str(e)}") 139 | raise RuntimeError(f"API call failed: {str(e)}") 140 | self.logger.debug(f"Raw API response type: {type(console)}") 141 | self.logger.debug(f"Raw API response: {console}") 142 | 143 | # Handle different response structures 144 | if isinstance(console, dict): 145 | # Handle exec-status response format 146 | output = console.get("out-data", "") 147 | error = console.get("err-data", "") 148 | exit_code = console.get("exitcode", 0) 149 | exited = console.get("exited", 0) 150 | 151 | if not exited: 152 | self.logger.warning("Command may not have completed") 153 | else: 154 | # Some versions might return data differently 155 | self.logger.debug(f"Unexpected response type: {type(console)}") 156 | output = str(console) 157 | error = "" 158 | exit_code = 0 159 | 160 | self.logger.debug(f"Processed output: {output}") 161 | self.logger.debug(f"Processed error: {error}") 162 | self.logger.debug(f"Processed exit code: {exit_code}") 163 | 164 | self.logger.debug(f"Executed command '{command}' on VM {vmid} (node: {node})") 165 | 166 | return { 167 | "success": True, 168 | "output": output, 169 | "error": error, 170 | "exit_code": exit_code 171 | } 172 | 173 | except ValueError: 174 | # Re-raise ValueError for VM not running 175 | raise 176 | except Exception as e: 177 | self.logger.error(f"Failed to execute command on VM {vmid}: {str(e)}") 178 | if "not found" in str(e).lower(): 179 | raise ValueError(f"VM {vmid} not found on node {node}") 180 | raise RuntimeError(f"Failed to execute command: {str(e)}") 181 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/formatting/templates.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Output templates for Proxmox MCP resource types. 3 | """ 4 | from typing import Dict, List, Any 5 | from .formatters import ProxmoxFormatters 6 | from .theme import ProxmoxTheme 7 | from .colors import ProxmoxColors 8 | from .components import ProxmoxComponents 9 | 10 | class ProxmoxTemplates: 11 | """Output templates for different Proxmox resource types.""" 12 | 13 | @staticmethod 14 | def node_list(nodes: List[Dict[str, Any]]) -> str: 15 | """Template for node list output. 16 | 17 | Args: 18 | nodes: List of node data dictionaries 19 | 20 | Returns: 21 | Formatted node list string 22 | """ 23 | result = [f"{ProxmoxTheme.RESOURCES['node']} Proxmox Nodes"] 24 | 25 | for node in nodes: 26 | # Get node status 27 | status = node.get("status", "unknown") 28 | 29 | # Get memory info 30 | memory = node.get("memory", {}) 31 | memory_used = memory.get("used", 0) 32 | memory_total = memory.get("total", 0) 33 | memory_percent = (memory_used / memory_total * 100) if memory_total > 0 else 0 34 | 35 | # Format node info 36 | result.extend([ 37 | "", # Empty line between nodes 38 | f"{ProxmoxTheme.RESOURCES['node']} {node['node']}", 39 | f" • Status: {status.upper()}", 40 | f" • Uptime: {ProxmoxFormatters.format_uptime(node.get('uptime', 0))}", 41 | f" • CPU Cores: {node.get('maxcpu', 'N/A')}", 42 | f" • Memory: {ProxmoxFormatters.format_bytes(memory_used)} / " 43 | f"{ProxmoxFormatters.format_bytes(memory_total)} ({memory_percent:.1f}%)" 44 | ]) 45 | 46 | # Add disk usage if available 47 | disk = node.get("disk", {}) 48 | if disk: 49 | disk_used = disk.get("used", 0) 50 | disk_total = disk.get("total", 0) 51 | disk_percent = (disk_used / disk_total * 100) if disk_total > 0 else 0 52 | result.append( 53 | f" • Disk: {ProxmoxFormatters.format_bytes(disk_used)} / " 54 | f"{ProxmoxFormatters.format_bytes(disk_total)} ({disk_percent:.1f}%)" 55 | ) 56 | 57 | return "\n".join(result) 58 | 59 | @staticmethod 60 | def node_status(node: str, status: Dict[str, Any]) -> str: 61 | """Template for detailed node status output. 62 | 63 | Args: 64 | node: Node name 65 | status: Node status data 66 | 67 | Returns: 68 | Formatted node status string 69 | """ 70 | memory = status.get("memory", {}) 71 | memory_used = memory.get("used", 0) 72 | memory_total = memory.get("total", 0) 73 | memory_percent = (memory_used / memory_total * 100) if memory_total > 0 else 0 74 | 75 | result = [ 76 | f"{ProxmoxTheme.RESOURCES['node']} Node: {node}", 77 | f" • Status: {status.get('status', 'unknown').upper()}", 78 | f" • Uptime: {ProxmoxFormatters.format_uptime(status.get('uptime', 0))}", 79 | f" • CPU Cores: {status.get('maxcpu', 'N/A')}", 80 | f" • Memory: {ProxmoxFormatters.format_bytes(memory_used)} / " 81 | f"{ProxmoxFormatters.format_bytes(memory_total)} ({memory_percent:.1f}%)" 82 | ] 83 | 84 | # Add disk usage if available 85 | disk = status.get("disk", {}) 86 | if disk: 87 | disk_used = disk.get("used", 0) 88 | disk_total = disk.get("total", 0) 89 | disk_percent = (disk_used / disk_total * 100) if disk_total > 0 else 0 90 | result.append( 91 | f" • Disk: {ProxmoxFormatters.format_bytes(disk_used)} / " 92 | f"{ProxmoxFormatters.format_bytes(disk_total)} ({disk_percent:.1f}%)" 93 | ) 94 | 95 | return "\n".join(result) 96 | 97 | @staticmethod 98 | def vm_list(vms: List[Dict[str, Any]]) -> str: 99 | """Template for VM list output. 100 | 101 | Args: 102 | vms: List of VM data dictionaries 103 | 104 | Returns: 105 | Formatted VM list string 106 | """ 107 | result = [f"{ProxmoxTheme.RESOURCES['vm']} Virtual Machines"] 108 | 109 | for vm in vms: 110 | memory = vm.get("memory", {}) 111 | memory_used = memory.get("used", 0) 112 | memory_total = memory.get("total", 0) 113 | memory_percent = (memory_used / memory_total * 100) if memory_total > 0 else 0 114 | 115 | result.extend([ 116 | "", # Empty line between VMs 117 | f"{ProxmoxTheme.RESOURCES['vm']} {vm['name']} (ID: {vm['vmid']})", 118 | f" • Status: {vm['status'].upper()}", 119 | f" • Node: {vm['node']}", 120 | f" • CPU Cores: {vm.get('cpus', 'N/A')}", 121 | f" • Memory: {ProxmoxFormatters.format_bytes(memory_used)} / " 122 | f"{ProxmoxFormatters.format_bytes(memory_total)} ({memory_percent:.1f}%)" 123 | ]) 124 | 125 | return "\n".join(result) 126 | 127 | @staticmethod 128 | def storage_list(storage: List[Dict[str, Any]]) -> str: 129 | """Template for storage list output. 130 | 131 | Args: 132 | storage: List of storage data dictionaries 133 | 134 | Returns: 135 | Formatted storage list string 136 | """ 137 | result = [f"{ProxmoxTheme.RESOURCES['storage']} Storage Pools"] 138 | 139 | for store in storage: 140 | used = store.get("used", 0) 141 | total = store.get("total", 0) 142 | percent = (used / total * 100) if total > 0 else 0 143 | 144 | result.extend([ 145 | "", # Empty line between storage pools 146 | f"{ProxmoxTheme.RESOURCES['storage']} {store['storage']}", 147 | f" • Status: {store.get('status', 'unknown').upper()}", 148 | f" • Type: {store['type']}", 149 | f" • Usage: {ProxmoxFormatters.format_bytes(used)} / " 150 | f"{ProxmoxFormatters.format_bytes(total)} ({percent:.1f}%)" 151 | ]) 152 | 153 | return "\n".join(result) 154 | 155 | @staticmethod 156 | def container_list(containers: List[Dict[str, Any]]) -> str: 157 | """Template for container list output. 158 | 159 | Args: 160 | containers: List of container data dictionaries 161 | 162 | Returns: 163 | Formatted container list string 164 | """ 165 | if not containers: 166 | return f"{ProxmoxTheme.RESOURCES['container']} No containers found" 167 | 168 | result = [f"{ProxmoxTheme.RESOURCES['container']} Containers"] 169 | 170 | for container in containers: 171 | memory = container.get("memory", {}) 172 | memory_used = memory.get("used", 0) 173 | memory_total = memory.get("total", 0) 174 | memory_percent = (memory_used / memory_total * 100) if memory_total > 0 else 0 175 | 176 | result.extend([ 177 | "", # Empty line between containers 178 | f"{ProxmoxTheme.RESOURCES['container']} {container['name']} (ID: {container['vmid']})", 179 | f" • Status: {container['status'].upper()}", 180 | f" • Node: {container['node']}", 181 | f" • CPU Cores: {container.get('cpus', 'N/A')}", 182 | f" • Memory: {ProxmoxFormatters.format_bytes(memory_used)} / " 183 | f"{ProxmoxFormatters.format_bytes(memory_total)} ({memory_percent:.1f}%)" 184 | ]) 185 | 186 | return "\n".join(result) 187 | 188 | @staticmethod 189 | def cluster_status(status: Dict[str, Any]) -> str: 190 | """Template for cluster status output. 191 | 192 | Args: 193 | status: Cluster status data 194 | 195 | Returns: 196 | Formatted cluster status string 197 | """ 198 | result = [f"{ProxmoxTheme.SECTIONS['configuration']} Proxmox Cluster"] 199 | 200 | # Basic cluster info 201 | result.extend([ 202 | "", 203 | f" • Name: {status.get('name', 'N/A')}", 204 | f" • Quorum: {'OK' if status.get('quorum') else 'NOT OK'}", 205 | f" • Nodes: {status.get('nodes', 0)}", 206 | ]) 207 | 208 | # Add resource count if available 209 | resources = status.get('resources', []) 210 | if resources: 211 | result.append(f" • Resources: {len(resources)}") 212 | 213 | return "\n".join(result) 214 | ``` -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tests for the Proxmox MCP server. 3 | """ 4 | 5 | import os 6 | import json 7 | import pytest 8 | from unittest.mock import Mock, patch 9 | 10 | from mcp.server.fastmcp import FastMCP 11 | from mcp.server.fastmcp.exceptions import ToolError 12 | from proxmox_mcp.server import ProxmoxMCPServer 13 | 14 | @pytest.fixture 15 | def mock_env_vars(): 16 | """Fixture to set up test environment variables.""" 17 | env_vars = { 18 | "PROXMOX_HOST": "test.proxmox.com", 19 | "PROXMOX_USER": "test@pve", 20 | "PROXMOX_TOKEN_NAME": "test_token", 21 | "PROXMOX_TOKEN_VALUE": "test_value", 22 | "LOG_LEVEL": "DEBUG" 23 | } 24 | with patch.dict(os.environ, env_vars): 25 | yield env_vars 26 | 27 | @pytest.fixture 28 | def mock_proxmox(): 29 | """Fixture to mock ProxmoxAPI.""" 30 | with patch("proxmox_mcp.core.proxmox.ProxmoxAPI") as mock: 31 | mock.return_value.nodes.get.return_value = [ 32 | {"node": "node1", "status": "online"}, 33 | {"node": "node2", "status": "online"} 34 | ] 35 | yield mock 36 | 37 | @pytest.fixture 38 | def server(mock_env_vars, mock_proxmox): 39 | """Fixture to create a ProxmoxMCPServer instance.""" 40 | return ProxmoxMCPServer() 41 | 42 | def test_server_initialization(server, mock_proxmox): 43 | """Test server initialization with environment variables.""" 44 | assert server.config.proxmox.host == "test.proxmox.com" 45 | assert server.config.auth.user == "test@pve" 46 | assert server.config.auth.token_name == "test_token" 47 | assert server.config.auth.token_value == "test_value" 48 | assert server.config.logging.level == "DEBUG" 49 | 50 | mock_proxmox.assert_called_once() 51 | 52 | @pytest.mark.asyncio 53 | async def test_list_tools(server): 54 | """Test listing available tools.""" 55 | tools = await server.mcp.list_tools() 56 | 57 | assert len(tools) > 0 58 | tool_names = [tool.name for tool in tools] 59 | assert "get_nodes" in tool_names 60 | assert "get_vms" in tool_names 61 | assert "get_containers" in tool_names 62 | assert "execute_vm_command" in tool_names 63 | assert "update_container_resources" in tool_names 64 | 65 | @pytest.mark.asyncio 66 | async def test_get_nodes(server, mock_proxmox): 67 | """Test get_nodes tool.""" 68 | mock_proxmox.return_value.nodes.get.return_value = [ 69 | {"node": "node1", "status": "online"}, 70 | {"node": "node2", "status": "online"} 71 | ] 72 | response = await server.mcp.call_tool("get_nodes", {}) 73 | result = json.loads(response[0].text) 74 | 75 | assert len(result) == 2 76 | assert result[0]["node"] == "node1" 77 | assert result[1]["node"] == "node2" 78 | 79 | @pytest.mark.asyncio 80 | async def test_get_node_status_missing_parameter(server): 81 | """Test get_node_status tool with missing parameter.""" 82 | with pytest.raises(ToolError, match="Field required"): 83 | await server.mcp.call_tool("get_node_status", {}) 84 | 85 | @pytest.mark.asyncio 86 | async def test_get_node_status(server, mock_proxmox): 87 | """Test get_node_status tool with valid parameter.""" 88 | mock_proxmox.return_value.nodes.return_value.status.get.return_value = { 89 | "status": "running", 90 | "uptime": 123456 91 | } 92 | 93 | response = await server.mcp.call_tool("get_node_status", {"node": "node1"}) 94 | result = json.loads(response[0].text) 95 | assert result["status"] == "running" 96 | assert result["uptime"] == 123456 97 | 98 | @pytest.mark.asyncio 99 | async def test_get_vms(server, mock_proxmox): 100 | """Test get_vms tool.""" 101 | mock_proxmox.return_value.nodes.get.return_value = [{"node": "node1", "status": "online"}] 102 | mock_proxmox.return_value.nodes.return_value.qemu.get.return_value = [ 103 | {"vmid": "100", "name": "vm1", "status": "running"}, 104 | {"vmid": "101", "name": "vm2", "status": "stopped"} 105 | ] 106 | 107 | response = await server.mcp.call_tool("get_vms", {}) 108 | result = json.loads(response[0].text) 109 | assert len(result) > 0 110 | assert result[0]["name"] == "vm1" 111 | assert result[1]["name"] == "vm2" 112 | 113 | @pytest.mark.asyncio 114 | async def test_get_containers(server, mock_proxmox): 115 | """Test get_containers tool.""" 116 | mock_proxmox.return_value.nodes.get.return_value = [{"node": "node1", "status": "online"}] 117 | mock_proxmox.return_value.nodes.return_value.lxc.get.return_value = [ 118 | {"vmid": "200", "name": "container1", "status": "running"}, 119 | {"vmid": "201", "name": "container2", "status": "stopped"} 120 | ] 121 | 122 | response = await server.mcp.call_tool("get_containers", {}) 123 | result = json.loads(response[0].text) 124 | assert len(result) > 0 125 | assert result[0]["name"] == "container1" 126 | assert result[1]["name"] == "container2" 127 | 128 | @pytest.mark.asyncio 129 | async def test_update_container_resources(server, mock_proxmox): 130 | """Test update_container_resources tool.""" 131 | mock_proxmox.return_value.nodes.get.return_value = [{"node": "node1", "status": "online"}] 132 | mock_proxmox.return_value.nodes.return_value.lxc.get.return_value = [ 133 | {"vmid": "200", "name": "container1", "status": "running"} 134 | ] 135 | 136 | ct_api = mock_proxmox.return_value.nodes.return_value.lxc.return_value 137 | ct_api.config.put.return_value = {} 138 | ct_api.resize.put.return_value = {} 139 | 140 | response = await server.mcp.call_tool( 141 | "update_container_resources", 142 | {"selector": "node1:200", "cores": 2, "memory": 512, "swap": 256, "disk_gb": 1}, 143 | ) 144 | result = json.loads(response[0].text) 145 | 146 | assert result[0]["ok"] is True 147 | ct_api.config.put.assert_called_with(cores=2, memory=512, swap=256) 148 | ct_api.resize.put.assert_called_with(disk="rootfs", size="+1G") 149 | 150 | @pytest.mark.asyncio 151 | async def test_get_storage(server, mock_proxmox): 152 | """Test get_storage tool.""" 153 | mock_proxmox.return_value.storage.get.return_value = [ 154 | {"storage": "local", "type": "dir"}, 155 | {"storage": "ceph", "type": "rbd"} 156 | ] 157 | 158 | response = await server.mcp.call_tool("get_storage", {}) 159 | result = json.loads(response[0].text) 160 | assert len(result) == 2 161 | assert result[0]["storage"] == "local" 162 | assert result[1]["storage"] == "ceph" 163 | 164 | @pytest.mark.asyncio 165 | async def test_get_cluster_status(server, mock_proxmox): 166 | """Test get_cluster_status tool.""" 167 | mock_proxmox.return_value.cluster.status.get.return_value = { 168 | "quorate": True, 169 | "nodes": 2 170 | } 171 | 172 | response = await server.mcp.call_tool("get_cluster_status", {}) 173 | result = json.loads(response[0].text) 174 | assert result["quorate"] is True 175 | assert result["nodes"] == 2 176 | 177 | @pytest.mark.asyncio 178 | async def test_execute_vm_command_success(server, mock_proxmox): 179 | """Test successful VM command execution.""" 180 | # Mock VM status check 181 | mock_proxmox.return_value.nodes.return_value.qemu.return_value.status.current.get.return_value = { 182 | "status": "running" 183 | } 184 | # Mock command execution 185 | mock_proxmox.return_value.nodes.return_value.qemu.return_value.agent.exec.post.return_value = { 186 | "out": "command output", 187 | "err": "", 188 | "exitcode": 0 189 | } 190 | 191 | response = await server.mcp.call_tool("execute_vm_command", { 192 | "node": "node1", 193 | "vmid": "100", 194 | "command": "ls -l" 195 | }) 196 | result = json.loads(response[0].text) 197 | 198 | assert result["success"] is True 199 | assert result["output"] == "command output" 200 | assert result["error"] == "" 201 | assert result["exit_code"] == 0 202 | 203 | @pytest.mark.asyncio 204 | async def test_execute_vm_command_missing_parameters(server): 205 | """Test VM command execution with missing parameters.""" 206 | with pytest.raises(ToolError): 207 | await server.mcp.call_tool("execute_vm_command", {}) 208 | 209 | @pytest.mark.asyncio 210 | async def test_execute_vm_command_vm_not_running(server, mock_proxmox): 211 | """Test VM command execution when VM is not running.""" 212 | mock_proxmox.return_value.nodes.return_value.qemu.return_value.status.current.get.return_value = { 213 | "status": "stopped" 214 | } 215 | 216 | with pytest.raises(ToolError, match="not running"): 217 | await server.mcp.call_tool("execute_vm_command", { 218 | "node": "node1", 219 | "vmid": "100", 220 | "command": "ls -l" 221 | }) 222 | 223 | @pytest.mark.asyncio 224 | async def test_execute_vm_command_with_error(server, mock_proxmox): 225 | """Test VM command execution with command error.""" 226 | # Mock VM status check 227 | mock_proxmox.return_value.nodes.return_value.qemu.return_value.status.current.get.return_value = { 228 | "status": "running" 229 | } 230 | # Mock command execution with error 231 | mock_proxmox.return_value.nodes.return_value.qemu.return_value.agent.exec.post.return_value = { 232 | "out": "", 233 | "err": "command not found", 234 | "exitcode": 1 235 | } 236 | 237 | response = await server.mcp.call_tool("execute_vm_command", { 238 | "node": "node1", 239 | "vmid": "100", 240 | "command": "invalid-command" 241 | }) 242 | result = json.loads(response[0].text) 243 | 244 | assert result["success"] is True # API call succeeded 245 | assert result["output"] == "" 246 | assert result["error"] == "command not found" 247 | assert result["exit_code"] == 1 248 | 249 | @pytest.mark.asyncio 250 | async def test_start_vm(server, mock_proxmox): 251 | """Test start_vm tool.""" 252 | mock_proxmox.return_value.nodes.return_value.qemu.return_value.status.current.get.return_value = { 253 | "status": "stopped" 254 | } 255 | mock_proxmox.return_value.nodes.return_value.qemu.return_value.status.start.post.return_value = "UPID:taskid" 256 | 257 | response = await server.mcp.call_tool("start_vm", {"node": "node1", "vmid": "100"}) 258 | assert "start initiated successfully" in response[0].text 259 | ``` -------------------------------------------------------------------------------- /src/proxmox_mcp/server.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Main server implementation for Proxmox MCP. 3 | 4 | This module implements the core MCP server for Proxmox integration, providing: 5 | - Configuration loading and validation 6 | - Logging setup 7 | - Proxmox API connection management 8 | - MCP tool registration and routing 9 | - Signal handling for graceful shutdown 10 | 11 | The server exposes a set of tools for managing Proxmox resources including: 12 | - Node management 13 | - VM operations 14 | - Storage management 15 | - Cluster status monitoring 16 | """ 17 | import logging 18 | import os 19 | import sys 20 | import signal 21 | from typing import Optional, List, Annotated, Literal 22 | 23 | from mcp.server.fastmcp import FastMCP 24 | from mcp.server.fastmcp.tools import Tool 25 | from mcp.types import TextContent as Content 26 | from pydantic import Field, BaseModel 27 | from fastapi import Body 28 | 29 | from .config.loader import load_config 30 | from .core.logging import setup_logging 31 | from .core.proxmox import ProxmoxManager 32 | from .tools.node import NodeTools 33 | from .tools.vm import VMTools 34 | from .tools.storage import StorageTools 35 | from .tools.cluster import ClusterTools 36 | from .tools.containers import ContainerTools 37 | from .tools.definitions import ( 38 | GET_NODES_DESC, 39 | GET_NODE_STATUS_DESC, 40 | GET_VMS_DESC, 41 | CREATE_VM_DESC, 42 | EXECUTE_VM_COMMAND_DESC, 43 | START_VM_DESC, 44 | STOP_VM_DESC, 45 | SHUTDOWN_VM_DESC, 46 | RESET_VM_DESC, 47 | DELETE_VM_DESC, 48 | GET_CONTAINERS_DESC, 49 | START_CONTAINER_DESC, 50 | STOP_CONTAINER_DESC, 51 | RESTART_CONTAINER_DESC, 52 | UPDATE_CONTAINER_RESOURCES_DESC, 53 | GET_STORAGE_DESC, 54 | GET_CLUSTER_STATUS_DESC 55 | ) 56 | 57 | class ProxmoxMCPServer: 58 | """Main server class for Proxmox MCP.""" 59 | 60 | def __init__(self, config_path: Optional[str] = None): 61 | """Initialize the server. 62 | 63 | Args: 64 | config_path: Path to configuration file 65 | """ 66 | self.config = load_config(config_path) 67 | self.logger = setup_logging(self.config.logging) 68 | 69 | # Initialize core components 70 | self.proxmox_manager = ProxmoxManager(self.config.proxmox, self.config.auth) 71 | self.proxmox = self.proxmox_manager.get_api() 72 | 73 | # Initialize tools 74 | self.node_tools = NodeTools(self.proxmox) 75 | self.vm_tools = VMTools(self.proxmox) 76 | self.storage_tools = StorageTools(self.proxmox) 77 | self.cluster_tools = ClusterTools(self.proxmox) 78 | self.container_tools = ContainerTools(self.proxmox) 79 | 80 | 81 | # Initialize MCP server 82 | self.mcp = FastMCP("ProxmoxMCP") 83 | self._setup_tools() 84 | 85 | def _setup_tools(self) -> None: 86 | """Register MCP tools with the server. 87 | 88 | Initializes and registers all available tools with the MCP server: 89 | - Node management tools (list nodes, get status) 90 | - VM operation tools (list VMs, execute commands, power management) 91 | - Storage management tools (list storage) 92 | - Cluster tools (get cluster status) 93 | 94 | Each tool is registered with appropriate descriptions and parameter 95 | validation using Pydantic models. 96 | """ 97 | 98 | # Node tools 99 | @self.mcp.tool(description=GET_NODES_DESC) 100 | def get_nodes(): 101 | return self.node_tools.get_nodes() 102 | 103 | @self.mcp.tool(description=GET_NODE_STATUS_DESC) 104 | def get_node_status( 105 | node: Annotated[str, Field(description="Name/ID of node to query (e.g. 'pve1', 'proxmox-node2')")] 106 | ): 107 | return self.node_tools.get_node_status(node) 108 | 109 | # VM tools 110 | @self.mcp.tool(description=GET_VMS_DESC) 111 | def get_vms(): 112 | return self.vm_tools.get_vms() 113 | 114 | @self.mcp.tool(description=CREATE_VM_DESC) 115 | def create_vm( 116 | node: Annotated[str, Field(description="Host node name (e.g. 'pve')")], 117 | vmid: Annotated[str, Field(description="New VM ID number (e.g. '200', '300')")], 118 | name: Annotated[str, Field(description="VM name (e.g. 'my-new-vm', 'web-server')")], 119 | cpus: Annotated[int, Field(description="Number of CPU cores (e.g. 1, 2, 4)", ge=1, le=32)], 120 | memory: Annotated[int, Field(description="Memory size in MB (e.g. 2048 for 2GB)", ge=512, le=131072)], 121 | disk_size: Annotated[int, Field(description="Disk size in GB (e.g. 10, 20, 50)", ge=5, le=1000)], 122 | storage: Annotated[Optional[str], Field(description="Storage name (optional, will auto-detect)", default=None)] = None, 123 | ostype: Annotated[Optional[str], Field(description="OS type (optional, default: 'l26' for Linux)", default=None)] = None 124 | ): 125 | return self.vm_tools.create_vm(node, vmid, name, cpus, memory, disk_size, storage, ostype) 126 | 127 | @self.mcp.tool(description=EXECUTE_VM_COMMAND_DESC) 128 | async def execute_vm_command( 129 | node: Annotated[str, Field(description="Host node name (e.g. 'pve1', 'proxmox-node2')")], 130 | vmid: Annotated[str, Field(description="VM ID number (e.g. '100', '101')")], 131 | command: Annotated[str, Field(description="Shell command to run (e.g. 'uname -a', 'systemctl status nginx')")] 132 | ): 133 | return await self.vm_tools.execute_command(node, vmid, command) 134 | 135 | # VM Power Management tools 136 | @self.mcp.tool(description=START_VM_DESC) 137 | def start_vm( 138 | node: Annotated[str, Field(description="Host node name (e.g. 'pve')")], 139 | vmid: Annotated[str, Field(description="VM ID number (e.g. '101')")] 140 | ): 141 | return self.vm_tools.start_vm(node, vmid) 142 | 143 | @self.mcp.tool(description=STOP_VM_DESC) 144 | def stop_vm( 145 | node: Annotated[str, Field(description="Host node name (e.g. 'pve')")], 146 | vmid: Annotated[str, Field(description="VM ID number (e.g. '101')")] 147 | ): 148 | return self.vm_tools.stop_vm(node, vmid) 149 | 150 | @self.mcp.tool(description=SHUTDOWN_VM_DESC) 151 | def shutdown_vm( 152 | node: Annotated[str, Field(description="Host node name (e.g. 'pve')")], 153 | vmid: Annotated[str, Field(description="VM ID number (e.g. '101')")] 154 | ): 155 | return self.vm_tools.shutdown_vm(node, vmid) 156 | 157 | @self.mcp.tool(description=RESET_VM_DESC) 158 | def reset_vm( 159 | node: Annotated[str, Field(description="Host node name (e.g. 'pve')")], 160 | vmid: Annotated[str, Field(description="VM ID number (e.g. '101')")] 161 | ): 162 | return self.vm_tools.reset_vm(node, vmid) 163 | 164 | @self.mcp.tool(description=DELETE_VM_DESC) 165 | def delete_vm( 166 | node: Annotated[str, Field(description="Host node name (e.g. 'pve')")], 167 | vmid: Annotated[str, Field(description="VM ID number (e.g. '998')")], 168 | force: Annotated[bool, Field(description="Force deletion even if VM is running", default=False)] = False 169 | ): 170 | return self.vm_tools.delete_vm(node, vmid, force) 171 | 172 | # Storage tools 173 | @self.mcp.tool(description=GET_STORAGE_DESC) 174 | def get_storage(): 175 | return self.storage_tools.get_storage() 176 | 177 | # Cluster tools 178 | @self.mcp.tool(description=GET_CLUSTER_STATUS_DESC) 179 | def get_cluster_status(): 180 | return self.cluster_tools.get_cluster_status() 181 | 182 | # Containers (LXC) 183 | class GetContainersPayload(BaseModel): 184 | node: Optional[str] = Field(None, description="Optional node name (e.g. 'pve1')") 185 | include_stats: bool = Field(True, description="Include live stats and fallbacks") 186 | include_raw: bool = Field(False, description="Include raw status/config") 187 | format_style: Literal["pretty", "json"] = Field( 188 | "pretty", description="'pretty' or 'json'" 189 | ) 190 | 191 | @self.mcp.tool(description=GET_CONTAINERS_DESC) 192 | def get_containers( 193 | payload: GetContainersPayload = Body(..., embed=True, description="Container query options") 194 | ): 195 | return self.container_tools.get_containers( 196 | node=payload.node, 197 | include_stats=payload.include_stats, 198 | include_raw=payload.include_raw, 199 | format_style=payload.format_style, 200 | ) 201 | 202 | # Container controls 203 | @self.mcp.tool(description=START_CONTAINER_DESC) 204 | def start_container( 205 | selector: Annotated[str, Field(description="CT selector: '123' | 'pve1:123' | 'pve1/name' | 'name' | comma list")], 206 | format_style: Annotated[str, Field(description="'pretty' or 'json'", pattern="^(pretty|json)$")] = "pretty", 207 | ): 208 | return self.container_tools.start_container(selector=selector, format_style=format_style) 209 | 210 | @self.mcp.tool(description=STOP_CONTAINER_DESC) 211 | def stop_container( 212 | selector: Annotated[str, Field(description="CT selector (see start_container)")], 213 | graceful: Annotated[bool, Field(description="Graceful shutdown (True) or forced stop (False)", default=True)] = True, 214 | timeout_seconds: Annotated[int, Field(description="Timeout for stop/shutdown", ge=1, le=600)] = 10, 215 | format_style: Annotated[Literal["pretty","json"], Field(description="Output format")] = "pretty", 216 | ): 217 | return self.container_tools.stop_container( 218 | selector=selector, graceful=graceful, timeout_seconds=timeout_seconds, format_style=format_style 219 | ) 220 | @self.mcp.tool(description=RESTART_CONTAINER_DESC) 221 | def restart_container( 222 | selector: Annotated[str, Field(description="CT selector (see start_container)")], 223 | timeout_seconds: Annotated[int, Field(description="Timeout for reboot", ge=1, le=600)] = 10, 224 | format_style: Annotated[str, Field(description="'pretty' or 'json'", pattern="^(pretty|json)$")] = "pretty", 225 | ): 226 | return self.container_tools.restart_container( 227 | selector=selector, timeout_seconds=timeout_seconds, format_style=format_style 228 | ) 229 | 230 | @self.mcp.tool(description=UPDATE_CONTAINER_RESOURCES_DESC) 231 | def update_container_resources( 232 | selector: Annotated[str, Field(description="CT selector (see start_container)")], 233 | cores: Annotated[Optional[int], Field(description="New CPU core count", ge=1)] = None, 234 | memory: Annotated[Optional[int], Field(description="New memory limit in MiB", ge=16)] = None, 235 | swap: Annotated[Optional[int], Field(description="New swap limit in MiB", ge=0)] = None, 236 | disk_gb: Annotated[Optional[int], Field(description="Additional disk size in GiB", ge=1)] = None, 237 | disk: Annotated[str, Field(description="Disk to resize", default="rootfs")] = "rootfs", 238 | format_style: Annotated[Literal["pretty","json"], Field(description="Output format")] = "pretty", 239 | ): 240 | return self.container_tools.update_container_resources( 241 | selector=selector, 242 | cores=cores, 243 | memory=memory, 244 | swap=swap, 245 | disk_gb=disk_gb, 246 | disk=disk, 247 | format_style=format_style, 248 | ) 249 | 250 | 251 | def start(self) -> None: 252 | """Start the MCP server. 253 | 254 | Initializes the server with: 255 | - Signal handlers for graceful shutdown (SIGINT, SIGTERM) 256 | - Async runtime for handling concurrent requests 257 | - Error handling and logging 258 | 259 | The server runs until terminated by a signal or fatal error. 260 | """ 261 | import anyio 262 | 263 | def signal_handler(signum, frame): 264 | self.logger.info("Received signal to shutdown...") 265 | sys.exit(0) 266 | 267 | # Set up signal handlers 268 | signal.signal(signal.SIGINT, signal_handler) 269 | signal.signal(signal.SIGTERM, signal_handler) 270 | 271 | try: 272 | self.logger.info("Starting MCP server...") 273 | anyio.run(self.mcp.run_stdio_async) 274 | except Exception as e: 275 | self.logger.error(f"Server error: {e}") 276 | sys.exit(1) 277 | 278 | if __name__ == "__main__": 279 | config_path = os.getenv("PROXMOX_MCP_CONFIG") 280 | if not config_path: 281 | print("PROXMOX_MCP_CONFIG environment variable must be set") 282 | sys.exit(1) 283 | 284 | try: 285 | server = ProxmoxMCPServer(config_path) 286 | server.start() 287 | except KeyboardInterrupt: 288 | print("\nShutting down gracefully...") 289 | sys.exit(0) 290 | except Exception as e: 291 | print(f"Error: {e}") 292 | sys.exit(1) 293 | ```