Initial commit
This commit is contained in:
61
.gitignore
vendored
Normal file
61
.gitignore
vendored
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual Environment
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.project
|
||||||
|
.pydevproject
|
||||||
|
.settings/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# UV
|
||||||
|
.uv/
|
||||||
|
|
||||||
|
# Local configuration
|
||||||
|
config/config.json
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Kevin
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
398
README.md
Normal file
398
README.md
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
# Proxmox MCP Server
|
||||||
|
|
||||||
|
A Python-based Model Context Protocol (MCP) server for interacting with Proxmox hypervisors, providing a clean interface for managing nodes, VMs, and containers.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Built with the official MCP SDK
|
||||||
|
- Secure token-based authentication with Proxmox
|
||||||
|
- Tools for managing nodes, VMs, and containers
|
||||||
|
- VM console command execution
|
||||||
|
- Configurable logging system
|
||||||
|
- Type-safe implementation with Pydantic
|
||||||
|
- Full integration with Claude Desktop
|
||||||
|
|
||||||
|
## Installation for Cline Users
|
||||||
|
|
||||||
|
If you're using this MCP server with Cline, follow these steps for installation:
|
||||||
|
|
||||||
|
1. Create a directory for your MCP servers (if you haven't already):
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/Documents/Cline/MCP
|
||||||
|
cd ~/Documents/Cline/MCP
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Clone and install the package:
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/yourusername/proxmox-mcp.git
|
||||||
|
|
||||||
|
# Install in development mode with dependencies
|
||||||
|
pip install -e "proxmox-mcp[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Create your configuration:
|
||||||
|
```bash
|
||||||
|
# Create config directory
|
||||||
|
mkdir proxmox-config
|
||||||
|
cd proxmox-config
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `config.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"proxmox": {
|
||||||
|
"host": "your-proxmox-host",
|
||||||
|
"port": 8006,
|
||||||
|
"verify_ssl": true,
|
||||||
|
"service": "PVE"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"user": "your-username@pve",
|
||||||
|
"token_name": "your-token-name",
|
||||||
|
"token_value": "your-token-value"
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"level": "INFO",
|
||||||
|
"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
"file": "proxmox_mcp.log"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Install in Claude Desktop:
|
||||||
|
```bash
|
||||||
|
cd ~/Documents/Cline/MCP
|
||||||
|
mcp install proxmox-mcp/src/proxmox_mcp/server.py \
|
||||||
|
--name "Proxmox Manager" \
|
||||||
|
-v PROXMOX_MCP_CONFIG=./proxmox-config/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
5. The server will now be available in Cline with these tools:
|
||||||
|
- `get_nodes`: List all nodes in the cluster
|
||||||
|
- `get_node_status`: Get detailed status of a node
|
||||||
|
- `get_vms`: List all VMs
|
||||||
|
- `get_containers`: List all LXC containers
|
||||||
|
- `get_storage`: List available storage
|
||||||
|
- `get_cluster_status`: Get cluster status
|
||||||
|
- `execute_vm_command`: Run commands in VM consoles
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
1. Install UV:
|
||||||
|
```bash
|
||||||
|
pip install uv
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/proxmox-mcp.git
|
||||||
|
cd proxmox-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Create and activate virtual environment:
|
||||||
|
```bash
|
||||||
|
# Create venv
|
||||||
|
uv venv
|
||||||
|
|
||||||
|
# Activate venv (Windows)
|
||||||
|
.venv\Scripts\activate
|
||||||
|
# OR Activate venv (Unix/MacOS)
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Install dependencies:
|
||||||
|
```bash
|
||||||
|
# Install with development dependencies
|
||||||
|
uv pip install -e ".[dev]"
|
||||||
|
# OR install only runtime dependencies
|
||||||
|
uv pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Proxmox API Token Setup
|
||||||
|
1. Log into your Proxmox web interface
|
||||||
|
2. Navigate to Datacenter -> Permissions -> API Tokens
|
||||||
|
3. Create a new API token:
|
||||||
|
- Select a user (e.g., root@pam)
|
||||||
|
- Enter a token ID (e.g., "mcp-token")
|
||||||
|
- Uncheck "Privilege Separation" if you want full access
|
||||||
|
- Save and copy both the token ID and secret
|
||||||
|
|
||||||
|
### Server Configuration
|
||||||
|
Configure the server using either a JSON file or environment variables:
|
||||||
|
|
||||||
|
#### Using JSON Configuration
|
||||||
|
1. Copy the example configuration:
|
||||||
|
```bash
|
||||||
|
cp config/config.example.json config/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Edit `config/config.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"proxmox": {
|
||||||
|
"host": "your-proxmox-host",
|
||||||
|
"port": 8006,
|
||||||
|
"verify_ssl": true,
|
||||||
|
"service": "PVE"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"user": "username@pve",
|
||||||
|
"token_name": "your-token-name",
|
||||||
|
"token_value": "your-token-value"
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"level": "INFO",
|
||||||
|
"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
"file": "proxmox_mcp.log"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Using Environment Variables
|
||||||
|
Set the following environment variables:
|
||||||
|
```bash
|
||||||
|
# Required
|
||||||
|
PROXMOX_HOST=your-host
|
||||||
|
PROXMOX_USER=username@pve
|
||||||
|
PROXMOX_TOKEN_NAME=your-token-name
|
||||||
|
PROXMOX_TOKEN_VALUE=your-token-value
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
PROXMOX_PORT=8006 # Default: 8006
|
||||||
|
PROXMOX_VERIFY_SSL=true # Default: true
|
||||||
|
PROXMOX_SERVICE=PVE # Default: PVE
|
||||||
|
LOG_LEVEL=INFO # Default: INFO
|
||||||
|
LOG_FORMAT=%(asctime)s... # Default: standard format
|
||||||
|
LOG_FILE=proxmox_mcp.log # Default: None (stdout)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Tools
|
||||||
|
|
||||||
|
The server provides the following MCP tools for interacting with Proxmox:
|
||||||
|
|
||||||
|
### get_nodes
|
||||||
|
Lists all nodes in the Proxmox cluster.
|
||||||
|
|
||||||
|
- Parameters: None
|
||||||
|
- Example Response:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "pve1",
|
||||||
|
"status": "online"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "pve2",
|
||||||
|
"status": "online"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### get_node_status
|
||||||
|
Get detailed status of a specific node.
|
||||||
|
|
||||||
|
- Parameters:
|
||||||
|
- `node` (string, required): Name of the node
|
||||||
|
- Example Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "running",
|
||||||
|
"uptime": 1234567,
|
||||||
|
"cpu": 0.12,
|
||||||
|
"memory": {
|
||||||
|
"total": 16777216,
|
||||||
|
"used": 8388608,
|
||||||
|
"free": 8388608
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### get_vms
|
||||||
|
List all VMs across the cluster.
|
||||||
|
|
||||||
|
- Parameters: None
|
||||||
|
- Example Response:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"vmid": "100",
|
||||||
|
"name": "web-server",
|
||||||
|
"status": "running",
|
||||||
|
"node": "pve1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vmid": "101",
|
||||||
|
"name": "database",
|
||||||
|
"status": "stopped",
|
||||||
|
"node": "pve2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### get_containers
|
||||||
|
List all LXC containers.
|
||||||
|
|
||||||
|
- Parameters: None
|
||||||
|
- Example Response:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"vmid": "200",
|
||||||
|
"name": "docker-host",
|
||||||
|
"status": "running",
|
||||||
|
"node": "pve1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vmid": "201",
|
||||||
|
"name": "nginx-proxy",
|
||||||
|
"status": "running",
|
||||||
|
"node": "pve1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### get_storage
|
||||||
|
List available storage.
|
||||||
|
|
||||||
|
- Parameters: None
|
||||||
|
- Example Response:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"storage": "local",
|
||||||
|
"type": "dir"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"storage": "ceph-pool",
|
||||||
|
"type": "rbd"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### get_cluster_status
|
||||||
|
Get overall cluster status.
|
||||||
|
|
||||||
|
- Parameters: None
|
||||||
|
- Example Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"quorate": true,
|
||||||
|
"nodes": 2,
|
||||||
|
"version": "7.4-15",
|
||||||
|
"cluster_name": "proxmox-cluster"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### execute_vm_command
|
||||||
|
Execute a command in a VM's console using QEMU Guest Agent.
|
||||||
|
|
||||||
|
- Parameters:
|
||||||
|
- `node` (string, required): Name of the node where VM is running
|
||||||
|
- `vmid` (string, required): ID of the VM
|
||||||
|
- `command` (string, required): Command to execute
|
||||||
|
- Example Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"output": "command output here",
|
||||||
|
"error": "",
|
||||||
|
"exit_code": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Requirements:
|
||||||
|
- VM must be running
|
||||||
|
- QEMU Guest Agent must be installed and running in the VM
|
||||||
|
- Command execution permissions must be enabled in the Guest Agent
|
||||||
|
- Error Handling:
|
||||||
|
- Returns error if VM is not running
|
||||||
|
- Returns error if VM is not found
|
||||||
|
- Returns error if command execution fails
|
||||||
|
- Includes command output even if command returns non-zero exit code
|
||||||
|
|
||||||
|
## Running the Server
|
||||||
|
|
||||||
|
### Development Mode
|
||||||
|
For testing and development, use the MCP development server:
|
||||||
|
```bash
|
||||||
|
mcp dev proxmox_mcp/server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Claude Desktop Integration
|
||||||
|
To install the server in Claude Desktop:
|
||||||
|
```bash
|
||||||
|
# Basic installation
|
||||||
|
mcp install proxmox_mcp/server.py
|
||||||
|
|
||||||
|
# Installation with custom name and environment variables
|
||||||
|
mcp install proxmox_mcp/server.py \
|
||||||
|
--name "Proxmox Manager" \
|
||||||
|
-v PROXMOX_HOST=your-host \
|
||||||
|
-v PROXMOX_USER=username@pve \
|
||||||
|
-v PROXMOX_TOKEN_NAME=your-token \
|
||||||
|
-v PROXMOX_TOKEN_VALUE=your-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direct Execution
|
||||||
|
Run the server directly:
|
||||||
|
```bash
|
||||||
|
python -m proxmox_mcp.server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The server implements comprehensive error handling:
|
||||||
|
|
||||||
|
- Authentication Errors: When token authentication fails
|
||||||
|
- Connection Errors: When unable to connect to Proxmox
|
||||||
|
- Validation Errors: When tool parameters are invalid
|
||||||
|
- API Errors: When Proxmox API calls fail
|
||||||
|
|
||||||
|
All errors are properly logged and returned with descriptive messages.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Logging can be configured through the config file or environment variables:
|
||||||
|
|
||||||
|
- Log Levels: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||||
|
- Output: File or stdout
|
||||||
|
- Format: Customizable format string
|
||||||
|
|
||||||
|
Example log output:
|
||||||
|
```
|
||||||
|
2025-02-18 19:15:23,456 - proxmox-mcp - INFO - Server started
|
||||||
|
2025-02-18 19:15:24,789 - proxmox-mcp - INFO - Connected to Proxmox host
|
||||||
|
2025-02-18 19:15:25,123 - proxmox-mcp - DEBUG - Tool called: get_nodes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
- Run tests: `pytest`
|
||||||
|
- Format code: `black .`
|
||||||
|
- Type checking: `mypy .`
|
||||||
|
- Lint: `ruff .`
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
proxmox-mcp/
|
||||||
|
├── src/
|
||||||
|
│ └── proxmox_mcp/
|
||||||
|
│ ├── server.py # Main MCP server implementation
|
||||||
|
│ ├── tools/ # Tool implementations
|
||||||
|
│ │ └── vm_console.py # VM console operations
|
||||||
|
│ └── utils/ # Utilities (auth, logging)
|
||||||
|
├── tests/ # Test suite
|
||||||
|
├── config/
|
||||||
|
│ └── config.example.json # Configuration template
|
||||||
|
├── pyproject.toml # Project metadata and dependencies
|
||||||
|
├── requirements.in # Core dependencies
|
||||||
|
├── requirements-dev.in # Development dependencies
|
||||||
|
└── LICENSE # MIT License
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
18
config/config.example.json
Normal file
18
config/config.example.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"proxmox": {
|
||||||
|
"host": "your-proxmox-host",
|
||||||
|
"port": 8006,
|
||||||
|
"verify_ssl": true,
|
||||||
|
"service": "PVE"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"user": "username@pve",
|
||||||
|
"token_name": "your-token-name",
|
||||||
|
"token_value": "your-token-value"
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"level": "INFO",
|
||||||
|
"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
"file": "proxmox_mcp.log"
|
||||||
|
}
|
||||||
|
}
|
||||||
80
pyproject.toml
Normal file
80
pyproject.toml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "proxmox-mcp"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Model Context Protocol server for interacting with Proxmox hypervisors"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
authors = [
|
||||||
|
{name = "Kevin", email = "kevin@example.com"}
|
||||||
|
]
|
||||||
|
readme = "README.md"
|
||||||
|
license = "MIT"
|
||||||
|
keywords = ["proxmox", "mcp", "virtualization", "cline", "qemu", "lxc"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
|
"Topic :: System :: Systems Administration",
|
||||||
|
"Topic :: System :: Virtualization",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"modelcontextprotocol-sdk>=1.0.0,<2.0.0",
|
||||||
|
"proxmoxer>=2.0.1,<3.0.0",
|
||||||
|
"requests>=2.31.0,<3.0.0",
|
||||||
|
"pydantic>=2.0.0,<3.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.0.0,<8.0.0",
|
||||||
|
"black>=23.0.0,<24.0.0",
|
||||||
|
"mypy>=1.0.0,<2.0.0",
|
||||||
|
"pytest-asyncio>=0.21.0,<0.22.0",
|
||||||
|
"ruff>=0.1.0,<0.2.0",
|
||||||
|
"types-requests>=2.31.0,<3.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/yourusername/proxmox-mcp"
|
||||||
|
Documentation = "https://github.com/yourusername/proxmox-mcp#readme"
|
||||||
|
Repository = "https://github.com/yourusername/proxmox-mcp.git"
|
||||||
|
Issues = "https://github.com/yourusername/proxmox-mcp/issues"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
proxmox-mcp = "proxmox_mcp.server:main"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "strict"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
addopts = "-v"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.9"
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
check_untyped_defs = true
|
||||||
|
disallow_untyped_decorators = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
warn_no_return = true
|
||||||
|
warn_unreachable = true
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
select = ["E", "F", "B", "I"]
|
||||||
|
ignore = []
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py39"
|
||||||
14
requirements-dev.in
Normal file
14
requirements-dev.in
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Development dependencies
|
||||||
|
-r requirements.in
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest>=7.0.0,<8.0.0
|
||||||
|
pytest-asyncio>=0.21.0,<0.22.0
|
||||||
|
|
||||||
|
# Code quality
|
||||||
|
black>=23.0.0,<24.0.0
|
||||||
|
mypy>=1.0.0,<2.0.0
|
||||||
|
ruff>=0.1.0,<0.2.0
|
||||||
|
|
||||||
|
# Type stubs
|
||||||
|
types-requests>=2.31.0,<3.0.0
|
||||||
5
requirements.in
Normal file
5
requirements.in
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Core dependencies
|
||||||
|
modelcontextprotocol-sdk>=1.0.0,<2.0.0
|
||||||
|
proxmoxer>=2.0.1,<3.0.0
|
||||||
|
requests>=2.31.0,<3.0.0
|
||||||
|
pydantic>=2.0.0,<3.0.0
|
||||||
65
setup.py
Normal file
65
setup.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
Setup script for the Proxmox MCP server.
|
||||||
|
This file is maintained for compatibility with older tools.
|
||||||
|
For modern Python packaging, see pyproject.toml.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from setuptools import setup, find_packages
|
||||||
|
|
||||||
|
# Metadata and dependencies are primarily managed in pyproject.toml
|
||||||
|
# This file exists for compatibility with tools that don't support pyproject.toml
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name="proxmox-mcp",
|
||||||
|
version="0.1.0",
|
||||||
|
packages=find_packages(where="src"),
|
||||||
|
package_dir={"": "src"},
|
||||||
|
python_requires=">=3.9",
|
||||||
|
install_requires=[
|
||||||
|
"modelcontextprotocol-sdk>=1.0.0,<2.0.0",
|
||||||
|
"proxmoxer>=2.0.1,<3.0.0",
|
||||||
|
"requests>=2.31.0,<3.0.0",
|
||||||
|
"pydantic>=2.0.0,<3.0.0",
|
||||||
|
],
|
||||||
|
extras_require={
|
||||||
|
"dev": [
|
||||||
|
"pytest>=7.0.0,<8.0.0",
|
||||||
|
"black>=23.0.0,<24.0.0",
|
||||||
|
"mypy>=1.0.0,<2.0.0",
|
||||||
|
"pytest-asyncio>=0.21.0,<0.22.0",
|
||||||
|
"ruff>=0.1.0,<0.2.0",
|
||||||
|
"types-requests>=2.31.0,<3.0.0",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
entry_points={
|
||||||
|
"console_scripts": [
|
||||||
|
"proxmox-mcp=proxmox_mcp.server:main",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
author="Kevin",
|
||||||
|
author_email="kevin@example.com",
|
||||||
|
description="A Model Context Protocol server for interacting with Proxmox hypervisors",
|
||||||
|
long_description=open("README.md").read(),
|
||||||
|
long_description_content_type="text/markdown",
|
||||||
|
license="MIT",
|
||||||
|
keywords=["proxmox", "mcp", "virtualization", "cline", "qemu", "lxc"],
|
||||||
|
classifiers=[
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
|
"Topic :: System :: Systems Administration",
|
||||||
|
"Topic :: System :: Virtualization",
|
||||||
|
],
|
||||||
|
project_urls={
|
||||||
|
"Homepage": "https://github.com/yourusername/proxmox-mcp",
|
||||||
|
"Documentation": "https://github.com/yourusername/proxmox-mcp#readme",
|
||||||
|
"Repository": "https://github.com/yourusername/proxmox-mcp.git",
|
||||||
|
"Issues": "https://github.com/yourusername/proxmox-mcp/issues",
|
||||||
|
},
|
||||||
|
)
|
||||||
8
src/proxmox_mcp/__init__.py
Normal file
8
src/proxmox_mcp/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Proxmox MCP Server - A Model Context Protocol server for interacting with Proxmox hypervisors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .server import ProxmoxMCPServer
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
__all__ = ["ProxmoxMCPServer"]
|
||||||
223
src/proxmox_mcp/server.py
Normal file
223
src/proxmox_mcp/server.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from mcp.server.fastmcp.tools import Tool
|
||||||
|
from mcp.server.fastmcp.tools.base import Tool as BaseTool
|
||||||
|
from mcp.types import CallToolResult as Response, TextContent as Content
|
||||||
|
from proxmoxer import ProxmoxAPI
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .tools.vm_console import VMConsoleManager
|
||||||
|
|
||||||
|
class ProxmoxConfig(BaseModel):
|
||||||
|
host: str
|
||||||
|
port: int = 8006
|
||||||
|
verify_ssl: bool = True
|
||||||
|
service: str = "PVE"
|
||||||
|
|
||||||
|
class AuthConfig(BaseModel):
|
||||||
|
user: str
|
||||||
|
token_name: str
|
||||||
|
token_value: str
|
||||||
|
|
||||||
|
class LoggingConfig(BaseModel):
|
||||||
|
level: str = "INFO"
|
||||||
|
format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
file: Optional[str] = None
|
||||||
|
|
||||||
|
class Config(BaseModel):
|
||||||
|
proxmox: ProxmoxConfig
|
||||||
|
auth: AuthConfig
|
||||||
|
logging: LoggingConfig
|
||||||
|
|
||||||
|
class ProxmoxMCPServer:
|
||||||
|
def __init__(self, config_path: Optional[str] = None):
|
||||||
|
self.config = self._load_config(config_path)
|
||||||
|
self._setup_logging()
|
||||||
|
self.proxmox = self._setup_proxmox()
|
||||||
|
self.vm_console = VMConsoleManager(self.proxmox)
|
||||||
|
self.mcp = FastMCP("ProxmoxMCP")
|
||||||
|
self._setup_tools()
|
||||||
|
|
||||||
|
def _load_config(self, config_path: Optional[str]) -> Config:
|
||||||
|
"""Load configuration from file or environment variables."""
|
||||||
|
if config_path:
|
||||||
|
with open(config_path) as f:
|
||||||
|
config_data = json.load(f)
|
||||||
|
else:
|
||||||
|
# Load from environment variables
|
||||||
|
config_data = {
|
||||||
|
"proxmox": {
|
||||||
|
"host": os.getenv("PROXMOX_HOST", ""),
|
||||||
|
"port": int(os.getenv("PROXMOX_PORT", "8006")),
|
||||||
|
"verify_ssl": os.getenv("PROXMOX_VERIFY_SSL", "true").lower() == "true",
|
||||||
|
"service": os.getenv("PROXMOX_SERVICE", "PVE"),
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"user": os.getenv("PROXMOX_USER", ""),
|
||||||
|
"token_name": os.getenv("PROXMOX_TOKEN_NAME", ""),
|
||||||
|
"token_value": os.getenv("PROXMOX_TOKEN_VALUE", ""),
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"level": os.getenv("LOG_LEVEL", "INFO"),
|
||||||
|
"format": os.getenv("LOG_FORMAT", "%(asctime)s - %(name)s - %(levelname)s - %(message)s"),
|
||||||
|
"file": os.getenv("LOG_FILE"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return Config(**config_data)
|
||||||
|
|
||||||
|
def _setup_logging(self) -> None:
|
||||||
|
"""Configure logging based on settings."""
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, self.config.logging.level.upper()),
|
||||||
|
format=self.config.logging.format,
|
||||||
|
filename=self.config.logging.file,
|
||||||
|
)
|
||||||
|
self.logger = logging.getLogger("proxmox-mcp")
|
||||||
|
|
||||||
|
def _setup_proxmox(self) -> ProxmoxAPI:
|
||||||
|
"""Initialize Proxmox API connection."""
|
||||||
|
try:
|
||||||
|
return ProxmoxAPI(
|
||||||
|
host=self.config.proxmox.host,
|
||||||
|
port=self.config.proxmox.port,
|
||||||
|
user=self.config.auth.user,
|
||||||
|
token_name=self.config.auth.token_name,
|
||||||
|
token_value=self.config.auth.token_value,
|
||||||
|
verify_ssl=self.config.proxmox.verify_ssl,
|
||||||
|
service=self.config.proxmox.service,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to connect to Proxmox: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _setup_tools(self) -> None:
|
||||||
|
"""Register MCP tools."""
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def get_nodes() -> List[Content]:
|
||||||
|
"""List all nodes in the Proxmox cluster."""
|
||||||
|
try:
|
||||||
|
result = self.proxmox.nodes.get()
|
||||||
|
nodes = [{"node": node["node"], "status": node["status"]} for node in result]
|
||||||
|
return [Content(type="text", text=json.dumps(nodes))]
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to get nodes: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def get_node_status(node: str) -> List[Content]:
|
||||||
|
"""Get detailed status of a specific node.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
node: Name of the node to get status for
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = self.proxmox.nodes(node).status.get()
|
||||||
|
return [Content(type="text", text=json.dumps(result))]
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to get node status: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def get_vms() -> List[Content]:
|
||||||
|
"""List all VMs across the cluster."""
|
||||||
|
try:
|
||||||
|
result = []
|
||||||
|
for node in self.proxmox.nodes.get():
|
||||||
|
vms = self.proxmox.nodes(node["node"]).qemu.get()
|
||||||
|
result.extend([{
|
||||||
|
"vmid": vm["vmid"],
|
||||||
|
"name": vm["name"],
|
||||||
|
"status": vm["status"],
|
||||||
|
"node": node["node"]
|
||||||
|
} for vm in vms])
|
||||||
|
return [Content(type="text", text=json.dumps(result))]
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to get VMs: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def get_containers() -> List[Content]:
|
||||||
|
"""List all LXC containers."""
|
||||||
|
try:
|
||||||
|
result = []
|
||||||
|
for node in self.proxmox.nodes.get():
|
||||||
|
containers = self.proxmox.nodes(node["node"]).lxc.get()
|
||||||
|
result.extend([{
|
||||||
|
"vmid": container["vmid"],
|
||||||
|
"name": container["name"],
|
||||||
|
"status": container["status"],
|
||||||
|
"node": node["node"]
|
||||||
|
} for container in containers])
|
||||||
|
return [Content(type="text", text=json.dumps(result))]
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to get containers: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def get_storage() -> List[Content]:
|
||||||
|
"""List available storage."""
|
||||||
|
try:
|
||||||
|
result = self.proxmox.storage.get()
|
||||||
|
storage = [{"storage": storage["storage"], "type": storage["type"]} for storage in result]
|
||||||
|
return [Content(type="text", text=json.dumps(storage))]
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to get storage: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def get_cluster_status() -> List[Content]:
|
||||||
|
"""Get overall cluster status."""
|
||||||
|
try:
|
||||||
|
result = self.proxmox.cluster.status.get()
|
||||||
|
return [Content(type="text", text=json.dumps(result))]
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to get cluster status: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
async def execute_vm_command(node: str, vmid: str, command: str) -> List[Content]:
|
||||||
|
"""Execute a command in a VM's console.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
node: Name of the node where VM is running
|
||||||
|
vmid: ID of the VM
|
||||||
|
command: Command to execute
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await self.vm_console.execute_command(node, vmid, command)
|
||||||
|
return [Content(type="text", text=json.dumps(result))]
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to execute VM command: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
"""Start the MCP server."""
|
||||||
|
try:
|
||||||
|
await self.mcp.run()
|
||||||
|
self.logger.info("Proxmox MCP server running")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Server error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Entry point for the MCP server."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
config_path = os.getenv("PROXMOX_MCP_CONFIG")
|
||||||
|
server = ProxmoxMCPServer(config_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(server.run())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
5
src/proxmox_mcp/tools/__init__.py
Normal file
5
src/proxmox_mcp/tools/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
MCP tools for interacting with Proxmox hypervisors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__ = []
|
||||||
63
src/proxmox_mcp/tools/vm_console.py
Normal file
63
src/proxmox_mcp/tools/vm_console.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Module for managing VM console operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
class VMConsoleManager:
|
||||||
|
"""Manager class for VM console operations."""
|
||||||
|
|
||||||
|
def __init__(self, proxmox_api):
|
||||||
|
"""Initialize the VM console manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
proxmox_api: Initialized ProxmoxAPI instance
|
||||||
|
"""
|
||||||
|
self.proxmox = proxmox_api
|
||||||
|
self.logger = logging.getLogger("proxmox-mcp.vm-console")
|
||||||
|
|
||||||
|
async def execute_command(self, node: str, vmid: str, command: str) -> Dict[str, Any]:
|
||||||
|
"""Execute a command in a VM's console.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
node: Name of the node where VM is running
|
||||||
|
vmid: ID of the VM
|
||||||
|
command: Command to execute
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing command output and status
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If VM is not found or not running
|
||||||
|
RuntimeError: If command execution fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Verify VM exists and is running
|
||||||
|
vm_status = self.proxmox.nodes(node).qemu(vmid).status.current.get()
|
||||||
|
if vm_status["status"] != "running":
|
||||||
|
self.logger.error(f"Failed to execute command on VM {vmid}: VM is not running")
|
||||||
|
raise ValueError(f"VM {vmid} on node {node} is not running")
|
||||||
|
|
||||||
|
# Get VM's console
|
||||||
|
console = self.proxmox.nodes(node).qemu(vmid).agent.exec.post(
|
||||||
|
command=command
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.debug(f"Executed command '{command}' on VM {vmid} (node: {node})")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"output": console.get("out", ""),
|
||||||
|
"error": console.get("err", ""),
|
||||||
|
"exit_code": console.get("exitcode", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
# Re-raise ValueError for VM not running
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to execute command on VM {vmid}: {str(e)}")
|
||||||
|
if "not found" in str(e).lower():
|
||||||
|
raise ValueError(f"VM {vmid} not found on node {node}")
|
||||||
|
raise RuntimeError(f"Failed to execute command: {str(e)}")
|
||||||
5
src/proxmox_mcp/utils/__init__.py
Normal file
5
src/proxmox_mcp/utils/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Utility functions and helpers for the Proxmox MCP server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__ = []
|
||||||
86
src/proxmox_mcp/utils/auth.py
Normal file
86
src/proxmox_mcp/utils/auth.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
Authentication utilities for the Proxmox MCP server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Dict, Optional, Tuple
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class ProxmoxAuth(BaseModel):
|
||||||
|
"""Proxmox authentication configuration."""
|
||||||
|
user: str
|
||||||
|
token_name: str
|
||||||
|
token_value: str
|
||||||
|
|
||||||
|
def load_auth_from_env() -> ProxmoxAuth:
|
||||||
|
"""
|
||||||
|
Load Proxmox authentication details from environment variables.
|
||||||
|
|
||||||
|
Environment Variables:
|
||||||
|
PROXMOX_USER: Username with realm (e.g., 'root@pam' or 'user@pve')
|
||||||
|
PROXMOX_TOKEN_NAME: API token name
|
||||||
|
PROXMOX_TOKEN_VALUE: API token value
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProxmoxAuth: Authentication configuration
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If required environment variables are missing
|
||||||
|
"""
|
||||||
|
user = os.getenv("PROXMOX_USER")
|
||||||
|
token_name = os.getenv("PROXMOX_TOKEN_NAME")
|
||||||
|
token_value = os.getenv("PROXMOX_TOKEN_VALUE")
|
||||||
|
|
||||||
|
if not all([user, token_name, token_value]):
|
||||||
|
missing = []
|
||||||
|
if not user:
|
||||||
|
missing.append("PROXMOX_USER")
|
||||||
|
if not token_name:
|
||||||
|
missing.append("PROXMOX_TOKEN_NAME")
|
||||||
|
if not token_value:
|
||||||
|
missing.append("PROXMOX_TOKEN_VALUE")
|
||||||
|
raise ValueError(f"Missing required environment variables: {', '.join(missing)}")
|
||||||
|
|
||||||
|
return ProxmoxAuth(
|
||||||
|
user=user,
|
||||||
|
token_name=token_name,
|
||||||
|
token_value=token_value,
|
||||||
|
)
|
||||||
|
|
||||||
|
def parse_user(user: str) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Parse a Proxmox user string into username and realm.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User string in format 'username@realm'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple[str, str]: (username, realm)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If user string is not in correct format
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
username, realm = user.split("@")
|
||||||
|
return username, realm
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
"Invalid user format. Expected 'username@realm' (e.g., 'root@pam' or 'user@pve')"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_auth_dict(auth: ProxmoxAuth) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Convert ProxmoxAuth model to dictionary for Proxmoxer API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
auth: ProxmoxAuth configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, str]: Authentication dictionary for Proxmoxer
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"user": auth.user,
|
||||||
|
"token_name": auth.token_name,
|
||||||
|
"token_value": auth.token_value,
|
||||||
|
}
|
||||||
51
src/proxmox_mcp/utils/logging.py
Normal file
51
src/proxmox_mcp/utils/logging.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""
|
||||||
|
Logging configuration for the Proxmox MCP server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
def setup_logging(
|
||||||
|
level: str = "INFO",
|
||||||
|
format_str: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
log_file: Optional[str] = None,
|
||||||
|
) -> logging.Logger:
|
||||||
|
"""
|
||||||
|
Configure logging for the Proxmox MCP server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
level: The logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||||
|
format_str: The format string for log messages
|
||||||
|
log_file: Optional file path to write logs to
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
logging.Logger: Configured logger instance
|
||||||
|
"""
|
||||||
|
# Create logger
|
||||||
|
logger = logging.getLogger("proxmox-mcp")
|
||||||
|
logger.setLevel(getattr(logging, level.upper()))
|
||||||
|
|
||||||
|
# Create handlers
|
||||||
|
handlers = []
|
||||||
|
|
||||||
|
# Console handler
|
||||||
|
console_handler = logging.StreamHandler(sys.stderr)
|
||||||
|
console_handler.setLevel(getattr(logging, level.upper()))
|
||||||
|
handlers.append(console_handler)
|
||||||
|
|
||||||
|
# File handler if log_file is specified
|
||||||
|
if log_file:
|
||||||
|
file_handler = logging.FileHandler(log_file)
|
||||||
|
file_handler.setLevel(getattr(logging, level.upper()))
|
||||||
|
handlers.append(file_handler)
|
||||||
|
|
||||||
|
# Create formatter
|
||||||
|
formatter = logging.Formatter(format_str)
|
||||||
|
|
||||||
|
# Add formatter to handlers and handlers to logger
|
||||||
|
for handler in handlers:
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
|
||||||
|
return logger
|
||||||
3
tests/__init__.py
Normal file
3
tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Test suite for the Proxmox MCP server.
|
||||||
|
"""
|
||||||
224
tests/test_server.py
Normal file
224
tests/test_server.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
"""
|
||||||
|
Tests for the Proxmox MCP server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from mcp.server.fastmcp.exceptions import ToolError
|
||||||
|
from proxmox_mcp.server import ProxmoxMCPServer
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_env_vars():
|
||||||
|
"""Fixture to set up test environment variables."""
|
||||||
|
env_vars = {
|
||||||
|
"PROXMOX_HOST": "test.proxmox.com",
|
||||||
|
"PROXMOX_USER": "test@pve",
|
||||||
|
"PROXMOX_TOKEN_NAME": "test_token",
|
||||||
|
"PROXMOX_TOKEN_VALUE": "test_value",
|
||||||
|
"LOG_LEVEL": "DEBUG"
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env_vars):
|
||||||
|
yield env_vars
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_proxmox():
|
||||||
|
"""Fixture to mock ProxmoxAPI."""
|
||||||
|
with patch("proxmox_mcp.server.ProxmoxAPI") as mock:
|
||||||
|
mock.return_value.nodes.get.return_value = [
|
||||||
|
{"node": "node1", "status": "online"},
|
||||||
|
{"node": "node2", "status": "online"}
|
||||||
|
]
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server(mock_env_vars, mock_proxmox):
|
||||||
|
"""Fixture to create a ProxmoxMCPServer instance."""
|
||||||
|
return ProxmoxMCPServer()
|
||||||
|
|
||||||
|
def test_server_initialization(server, mock_proxmox):
|
||||||
|
"""Test server initialization with environment variables."""
|
||||||
|
assert server.config.proxmox.host == "test.proxmox.com"
|
||||||
|
assert server.config.auth.user == "test@pve"
|
||||||
|
assert server.config.auth.token_name == "test_token"
|
||||||
|
assert server.config.auth.token_value == "test_value"
|
||||||
|
assert server.config.logging.level == "DEBUG"
|
||||||
|
|
||||||
|
mock_proxmox.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_tools(server):
|
||||||
|
"""Test listing available tools."""
|
||||||
|
tools = await server.mcp.list_tools()
|
||||||
|
|
||||||
|
assert len(tools) > 0
|
||||||
|
tool_names = [tool.name for tool in tools]
|
||||||
|
assert "get_nodes" in tool_names
|
||||||
|
assert "get_vms" in tool_names
|
||||||
|
assert "get_containers" in tool_names
|
||||||
|
assert "execute_vm_command" in tool_names
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_nodes(server, mock_proxmox):
|
||||||
|
"""Test get_nodes tool."""
|
||||||
|
mock_proxmox.return_value.nodes.get.return_value = [
|
||||||
|
{"node": "node1", "status": "online"},
|
||||||
|
{"node": "node2", "status": "online"}
|
||||||
|
]
|
||||||
|
response = await server.mcp.call_tool("get_nodes", {})
|
||||||
|
result = json.loads(response[0].text)
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0]["node"] == "node1"
|
||||||
|
assert result[1]["node"] == "node2"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_node_status_missing_parameter(server):
|
||||||
|
"""Test get_node_status tool with missing parameter."""
|
||||||
|
with pytest.raises(ToolError, match="Field required"):
|
||||||
|
await server.mcp.call_tool("get_node_status", {})
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_node_status(server, mock_proxmox):
|
||||||
|
"""Test get_node_status tool with valid parameter."""
|
||||||
|
mock_proxmox.return_value.nodes.return_value.status.get.return_value = {
|
||||||
|
"status": "running",
|
||||||
|
"uptime": 123456
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await server.mcp.call_tool("get_node_status", {"node": "node1"})
|
||||||
|
result = json.loads(response[0].text)
|
||||||
|
assert result["status"] == "running"
|
||||||
|
assert result["uptime"] == 123456
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_vms(server, mock_proxmox):
|
||||||
|
"""Test get_vms tool."""
|
||||||
|
mock_proxmox.return_value.nodes.get.return_value = [{"node": "node1", "status": "online"}]
|
||||||
|
mock_proxmox.return_value.nodes.return_value.qemu.get.return_value = [
|
||||||
|
{"vmid": "100", "name": "vm1", "status": "running"},
|
||||||
|
{"vmid": "101", "name": "vm2", "status": "stopped"}
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await server.mcp.call_tool("get_vms", {})
|
||||||
|
result = json.loads(response[0].text)
|
||||||
|
assert len(result) > 0
|
||||||
|
assert result[0]["name"] == "vm1"
|
||||||
|
assert result[1]["name"] == "vm2"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_containers(server, mock_proxmox):
|
||||||
|
"""Test get_containers tool."""
|
||||||
|
mock_proxmox.return_value.nodes.get.return_value = [{"node": "node1", "status": "online"}]
|
||||||
|
mock_proxmox.return_value.nodes.return_value.lxc.get.return_value = [
|
||||||
|
{"vmid": "200", "name": "container1", "status": "running"},
|
||||||
|
{"vmid": "201", "name": "container2", "status": "stopped"}
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await server.mcp.call_tool("get_containers", {})
|
||||||
|
result = json.loads(response[0].text)
|
||||||
|
assert len(result) > 0
|
||||||
|
assert result[0]["name"] == "container1"
|
||||||
|
assert result[1]["name"] == "container2"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_storage(server, mock_proxmox):
|
||||||
|
"""Test get_storage tool."""
|
||||||
|
mock_proxmox.return_value.storage.get.return_value = [
|
||||||
|
{"storage": "local", "type": "dir"},
|
||||||
|
{"storage": "ceph", "type": "rbd"}
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await server.mcp.call_tool("get_storage", {})
|
||||||
|
result = json.loads(response[0].text)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0]["storage"] == "local"
|
||||||
|
assert result[1]["storage"] == "ceph"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_cluster_status(server, mock_proxmox):
|
||||||
|
"""Test get_cluster_status tool."""
|
||||||
|
mock_proxmox.return_value.cluster.status.get.return_value = {
|
||||||
|
"quorate": True,
|
||||||
|
"nodes": 2
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await server.mcp.call_tool("get_cluster_status", {})
|
||||||
|
result = json.loads(response[0].text)
|
||||||
|
assert result["quorate"] is True
|
||||||
|
assert result["nodes"] == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_vm_command_success(server, mock_proxmox):
|
||||||
|
"""Test successful VM command execution."""
|
||||||
|
# Mock VM status check
|
||||||
|
mock_proxmox.return_value.nodes.return_value.qemu.return_value.status.current.get.return_value = {
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
|
# Mock command execution
|
||||||
|
mock_proxmox.return_value.nodes.return_value.qemu.return_value.agent.exec.post.return_value = {
|
||||||
|
"out": "command output",
|
||||||
|
"err": "",
|
||||||
|
"exitcode": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await server.mcp.call_tool("execute_vm_command", {
|
||||||
|
"node": "node1",
|
||||||
|
"vmid": "100",
|
||||||
|
"command": "ls -l"
|
||||||
|
})
|
||||||
|
result = json.loads(response[0].text)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["output"] == "command output"
|
||||||
|
assert result["error"] == ""
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_vm_command_missing_parameters(server):
|
||||||
|
"""Test VM command execution with missing parameters."""
|
||||||
|
with pytest.raises(ToolError):
|
||||||
|
await server.mcp.call_tool("execute_vm_command", {})
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_vm_command_vm_not_running(server, mock_proxmox):
|
||||||
|
"""Test VM command execution when VM is not running."""
|
||||||
|
mock_proxmox.return_value.nodes.return_value.qemu.return_value.status.current.get.return_value = {
|
||||||
|
"status": "stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ToolError, match="not running"):
|
||||||
|
await server.mcp.call_tool("execute_vm_command", {
|
||||||
|
"node": "node1",
|
||||||
|
"vmid": "100",
|
||||||
|
"command": "ls -l"
|
||||||
|
})
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_vm_command_with_error(server, mock_proxmox):
|
||||||
|
"""Test VM command execution with command error."""
|
||||||
|
# Mock VM status check
|
||||||
|
mock_proxmox.return_value.nodes.return_value.qemu.return_value.status.current.get.return_value = {
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
|
# Mock command execution with error
|
||||||
|
mock_proxmox.return_value.nodes.return_value.qemu.return_value.agent.exec.post.return_value = {
|
||||||
|
"out": "",
|
||||||
|
"err": "command not found",
|
||||||
|
"exitcode": 1
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await server.mcp.call_tool("execute_vm_command", {
|
||||||
|
"node": "node1",
|
||||||
|
"vmid": "100",
|
||||||
|
"command": "invalid-command"
|
||||||
|
})
|
||||||
|
result = json.loads(response[0].text)
|
||||||
|
|
||||||
|
assert result["success"] is True # API call succeeded
|
||||||
|
assert result["output"] == ""
|
||||||
|
assert result["error"] == "command not found"
|
||||||
|
assert result["exit_code"] == 1
|
||||||
88
tests/test_vm_console.py
Normal file
88
tests/test_vm_console.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
Tests for VM console operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from proxmox_mcp.tools.vm_console import VMConsoleManager
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_proxmox():
|
||||||
|
"""Fixture to create a mock ProxmoxAPI instance."""
|
||||||
|
mock = Mock()
|
||||||
|
# Setup chained mock calls
|
||||||
|
mock.nodes.return_value.qemu.return_value.status.current.get.return_value = {
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
|
mock.nodes.return_value.qemu.return_value.agent.exec.post.return_value = {
|
||||||
|
"out": "command output",
|
||||||
|
"err": "",
|
||||||
|
"exitcode": 0
|
||||||
|
}
|
||||||
|
return mock
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def vm_console(mock_proxmox):
|
||||||
|
"""Fixture to create a VMConsoleManager instance."""
|
||||||
|
return VMConsoleManager(mock_proxmox)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_command_success(vm_console, mock_proxmox):
|
||||||
|
"""Test successful command execution."""
|
||||||
|
result = await vm_console.execute_command("node1", "100", "ls -l")
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["output"] == "command output"
|
||||||
|
assert result["error"] == ""
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
|
||||||
|
# Verify correct API calls
|
||||||
|
mock_proxmox.nodes.return_value.qemu.assert_called_with("100")
|
||||||
|
mock_proxmox.nodes.return_value.qemu.return_value.agent.exec.post.assert_called_with(
|
||||||
|
command="ls -l"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_command_vm_not_running(vm_console, mock_proxmox):
|
||||||
|
"""Test command execution on stopped VM."""
|
||||||
|
mock_proxmox.nodes.return_value.qemu.return_value.status.current.get.return_value = {
|
||||||
|
"status": "stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="not running"):
|
||||||
|
await vm_console.execute_command("node1", "100", "ls -l")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_command_vm_not_found(vm_console, mock_proxmox):
|
||||||
|
"""Test command execution on non-existent VM."""
|
||||||
|
mock_proxmox.nodes.return_value.qemu.return_value.status.current.get.side_effect = \
|
||||||
|
Exception("VM not found")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="not found"):
|
||||||
|
await vm_console.execute_command("node1", "100", "ls -l")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_command_failure(vm_console, mock_proxmox):
|
||||||
|
"""Test command execution failure."""
|
||||||
|
mock_proxmox.nodes.return_value.qemu.return_value.agent.exec.post.side_effect = \
|
||||||
|
Exception("Command failed")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="Failed to execute command"):
|
||||||
|
await vm_console.execute_command("node1", "100", "ls -l")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_command_with_error_output(vm_console, mock_proxmox):
|
||||||
|
"""Test command execution with error output."""
|
||||||
|
mock_proxmox.nodes.return_value.qemu.return_value.agent.exec.post.return_value = {
|
||||||
|
"out": "",
|
||||||
|
"err": "command error",
|
||||||
|
"exitcode": 1
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await vm_console.execute_command("node1", "100", "invalid-command")
|
||||||
|
|
||||||
|
assert result["success"] is True # Success refers to API call, not command
|
||||||
|
assert result["output"] == ""
|
||||||
|
assert result["error"] == "command error"
|
||||||
|
assert result["exit_code"] == 1
|
||||||
Reference in New Issue
Block a user