Initial commit

This commit is contained in:
Kevin Bond
2025-02-18 20:32:52 -07:00
commit cd77f754ee
18 changed files with 1418 additions and 0 deletions

61
.gitignore vendored Normal file
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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",
},
)

View 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
View 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()

View File

@@ -0,0 +1,5 @@
"""
MCP tools for interacting with Proxmox hypervisors.
"""
__all__ = []

View 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)}")

View File

@@ -0,0 +1,5 @@
"""
Utility functions and helpers for the Proxmox MCP server.
"""
__all__ = []

View 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,
}

View 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
View File

@@ -0,0 +1,3 @@
"""
Test suite for the Proxmox MCP server.
"""

224
tests/test_server.py Normal file
View 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
View 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