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