From cd77f754ee5de1ed4d4e49918a15acbc4c3c16b5 Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Tue, 18 Feb 2025 20:32:52 -0700 Subject: [PATCH] Initial commit --- .gitignore | 61 +++++ LICENSE | 21 ++ README.md | 398 ++++++++++++++++++++++++++++ config/config.example.json | 18 ++ pyproject.toml | 80 ++++++ requirements-dev.in | 14 + requirements.in | 5 + setup.py | 65 +++++ src/proxmox_mcp/__init__.py | 8 + src/proxmox_mcp/server.py | 223 ++++++++++++++++ src/proxmox_mcp/tools/__init__.py | 5 + src/proxmox_mcp/tools/vm_console.py | 63 +++++ src/proxmox_mcp/utils/__init__.py | 5 + src/proxmox_mcp/utils/auth.py | 86 ++++++ src/proxmox_mcp/utils/logging.py | 51 ++++ tests/__init__.py | 3 + tests/test_server.py | 224 ++++++++++++++++ tests/test_vm_console.py | 88 ++++++ 18 files changed, 1418 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config/config.example.json create mode 100644 pyproject.toml create mode 100644 requirements-dev.in create mode 100644 requirements.in create mode 100644 setup.py create mode 100644 src/proxmox_mcp/__init__.py create mode 100644 src/proxmox_mcp/server.py create mode 100644 src/proxmox_mcp/tools/__init__.py create mode 100644 src/proxmox_mcp/tools/vm_console.py create mode 100644 src/proxmox_mcp/utils/__init__.py create mode 100644 src/proxmox_mcp/utils/auth.py create mode 100644 src/proxmox_mcp/utils/logging.py create mode 100644 tests/__init__.py create mode 100644 tests/test_server.py create mode 100644 tests/test_vm_console.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8f7bb5 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..abf4128 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f9391c --- /dev/null +++ b/README.md @@ -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 diff --git a/config/config.example.json b/config/config.example.json new file mode 100644 index 0000000..8dac6c5 --- /dev/null +++ b/config/config.example.json @@ -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" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a1e7527 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/requirements-dev.in b/requirements-dev.in new file mode 100644 index 0000000..f8447bb --- /dev/null +++ b/requirements-dev.in @@ -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 diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..971b7bc --- /dev/null +++ b/requirements.in @@ -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 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5d623b1 --- /dev/null +++ b/setup.py @@ -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", + }, +) diff --git a/src/proxmox_mcp/__init__.py b/src/proxmox_mcp/__init__.py new file mode 100644 index 0000000..650a51c --- /dev/null +++ b/src/proxmox_mcp/__init__.py @@ -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"] diff --git a/src/proxmox_mcp/server.py b/src/proxmox_mcp/server.py new file mode 100644 index 0000000..5910161 --- /dev/null +++ b/src/proxmox_mcp/server.py @@ -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() diff --git a/src/proxmox_mcp/tools/__init__.py b/src/proxmox_mcp/tools/__init__.py new file mode 100644 index 0000000..f2aa189 --- /dev/null +++ b/src/proxmox_mcp/tools/__init__.py @@ -0,0 +1,5 @@ +""" +MCP tools for interacting with Proxmox hypervisors. +""" + +__all__ = [] diff --git a/src/proxmox_mcp/tools/vm_console.py b/src/proxmox_mcp/tools/vm_console.py new file mode 100644 index 0000000..96f91f2 --- /dev/null +++ b/src/proxmox_mcp/tools/vm_console.py @@ -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)}") diff --git a/src/proxmox_mcp/utils/__init__.py b/src/proxmox_mcp/utils/__init__.py new file mode 100644 index 0000000..0aa43d6 --- /dev/null +++ b/src/proxmox_mcp/utils/__init__.py @@ -0,0 +1,5 @@ +""" +Utility functions and helpers for the Proxmox MCP server. +""" + +__all__ = [] diff --git a/src/proxmox_mcp/utils/auth.py b/src/proxmox_mcp/utils/auth.py new file mode 100644 index 0000000..23572a1 --- /dev/null +++ b/src/proxmox_mcp/utils/auth.py @@ -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, + } diff --git a/src/proxmox_mcp/utils/logging.py b/src/proxmox_mcp/utils/logging.py new file mode 100644 index 0000000..3b37d74 --- /dev/null +++ b/src/proxmox_mcp/utils/logging.py @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e965720 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +""" +Test suite for the Proxmox MCP server. +""" diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..a8aae29 --- /dev/null +++ b/tests/test_server.py @@ -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 diff --git a/tests/test_vm_console.py b/tests/test_vm_console.py new file mode 100644 index 0000000..aa77f23 --- /dev/null +++ b/tests/test_vm_console.py @@ -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