From 84ca8aee99b2cb863434426cad74a97d9fa5537f Mon Sep 17 00:00:00 2001 From: Adolfo Delorenzo Date: Fri, 18 Jul 2025 07:33:27 -0600 Subject: [PATCH] first commit --- .env.example | 36 ++ CLAUDE.md | 181 ++++++ README.md | 134 +++++ pyproject.toml | 141 +++++ run_server.py | 115 ++++ src/portainer_core/__init__.py | 57 ++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 430 bytes .../__pycache__/config.cpython-312.pyc | Bin 0 -> 7659 bytes .../__pycache__/server.cpython-312.pyc | Bin 0 -> 25905 bytes src/portainer_core/config.py | 229 ++++++++ src/portainer_core/models/__init__.py | 5 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 308 bytes .../models/__pycache__/auth.cpython-312.pyc | Bin 0 -> 2911 bytes .../__pycache__/settings.cpython-312.pyc | Bin 0 -> 8766 bytes .../models/__pycache__/users.cpython-312.pyc | Bin 0 -> 8020 bytes src/portainer_core/models/auth.py | 50 ++ src/portainer_core/models/settings.py | 152 +++++ src/portainer_core/models/users.py | 147 +++++ src/portainer_core/server.py | 549 ++++++++++++++++++ src/portainer_core/services/__init__.py | 5 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 314 bytes .../services/__pycache__/auth.cpython-312.pyc | Bin 0 -> 9278 bytes .../services/__pycache__/base.cpython-312.pyc | Bin 0 -> 14882 bytes .../__pycache__/settings.cpython-312.pyc | Bin 0 -> 10883 bytes .../__pycache__/users.cpython-312.pyc | Bin 0 -> 12073 bytes src/portainer_core/services/auth.py | 230 ++++++++ src/portainer_core/services/base.py | 333 +++++++++++ src/portainer_core/services/settings.py | 251 ++++++++ src/portainer_core/services/users.py | 270 +++++++++ src/portainer_core/utils/__init__.py | 5 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 295 bytes .../utils/__pycache__/errors.cpython-312.pyc | Bin 0 -> 8264 bytes .../utils/__pycache__/logging.cpython-312.pyc | Bin 0 -> 7416 bytes .../utils/__pycache__/tokens.cpython-312.pyc | Bin 0 -> 7519 bytes src/portainer_core/utils/errors.py | 174 ++++++ src/portainer_core/utils/logging.py | 164 ++++++ src/portainer_core/utils/tokens.py | 231 ++++++++ src/portainer_core_mcp.egg-info/PKG-INFO | 175 ++++++ src/portainer_core_mcp.egg-info/SOURCES.txt | 18 + .../dependency_links.txt | 1 + .../entry_points.txt | 2 + src/portainer_core_mcp.egg-info/requires.txt | 20 + src/portainer_core_mcp.egg-info/top_level.txt | 1 + .../test_basic.cpython-312-pytest-7.4.3.pyc | Bin 0 -> 30846 bytes tests/test_basic.py | 184 ++++++ 45 files changed, 3860 insertions(+) create mode 100644 .env.example create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 run_server.py create mode 100644 src/portainer_core/__init__.py create mode 100644 src/portainer_core/__pycache__/__init__.cpython-312.pyc create mode 100644 src/portainer_core/__pycache__/config.cpython-312.pyc create mode 100644 src/portainer_core/__pycache__/server.cpython-312.pyc create mode 100644 src/portainer_core/config.py create mode 100644 src/portainer_core/models/__init__.py create mode 100644 src/portainer_core/models/__pycache__/__init__.cpython-312.pyc create mode 100644 src/portainer_core/models/__pycache__/auth.cpython-312.pyc create mode 100644 src/portainer_core/models/__pycache__/settings.cpython-312.pyc create mode 100644 src/portainer_core/models/__pycache__/users.cpython-312.pyc create mode 100644 src/portainer_core/models/auth.py create mode 100644 src/portainer_core/models/settings.py create mode 100644 src/portainer_core/models/users.py create mode 100644 src/portainer_core/server.py create mode 100644 src/portainer_core/services/__init__.py create mode 100644 src/portainer_core/services/__pycache__/__init__.cpython-312.pyc create mode 100644 src/portainer_core/services/__pycache__/auth.cpython-312.pyc create mode 100644 src/portainer_core/services/__pycache__/base.cpython-312.pyc create mode 100644 src/portainer_core/services/__pycache__/settings.cpython-312.pyc create mode 100644 src/portainer_core/services/__pycache__/users.cpython-312.pyc create mode 100644 src/portainer_core/services/auth.py create mode 100644 src/portainer_core/services/base.py create mode 100644 src/portainer_core/services/settings.py create mode 100644 src/portainer_core/services/users.py create mode 100644 src/portainer_core/utils/__init__.py create mode 100644 src/portainer_core/utils/__pycache__/__init__.cpython-312.pyc create mode 100644 src/portainer_core/utils/__pycache__/errors.cpython-312.pyc create mode 100644 src/portainer_core/utils/__pycache__/logging.cpython-312.pyc create mode 100644 src/portainer_core/utils/__pycache__/tokens.cpython-312.pyc create mode 100644 src/portainer_core/utils/errors.py create mode 100644 src/portainer_core/utils/logging.py create mode 100644 src/portainer_core/utils/tokens.py create mode 100644 src/portainer_core_mcp.egg-info/PKG-INFO create mode 100644 src/portainer_core_mcp.egg-info/SOURCES.txt create mode 100644 src/portainer_core_mcp.egg-info/dependency_links.txt create mode 100644 src/portainer_core_mcp.egg-info/entry_points.txt create mode 100644 src/portainer_core_mcp.egg-info/requires.txt create mode 100644 src/portainer_core_mcp.egg-info/top_level.txt create mode 100644 tests/__pycache__/test_basic.cpython-312-pytest-7.4.3.pyc create mode 100644 tests/test_basic.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ab1ea6f --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +# Portainer Core MCP Server Configuration + +# Portainer connection settings +PORTAINER_URL=https://your-portainer-instance.com + +# Authentication settings (choose one method) +# Method 1: API Key authentication (recommended) +PORTAINER_API_KEY=your-api-key-here + +# Method 2: Username/Password authentication +# PORTAINER_USERNAME=admin +# PORTAINER_PASSWORD=your-password-here + +# HTTP client settings +HTTP_TIMEOUT=30 +MAX_RETRIES=3 +RETRY_DELAY=1.0 + +# Circuit breaker settings +CIRCUIT_BREAKER_FAILURE_THRESHOLD=5 +CIRCUIT_BREAKER_RECOVERY_TIMEOUT=60 + +# Token management settings +TOKEN_CACHE_TTL=3600 +TOKEN_REFRESH_THRESHOLD=300 + +# Logging settings +LOG_LEVEL=INFO +LOG_FORMAT=json + +# Development settings +DEBUG=false + +# Server settings +SERVER_HOST=localhost +SERVER_PORT=8000 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d9efcc3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,181 @@ +# Portainer Core MCP Server + +This MCP server provides authentication and core management functionality for Portainer Business Edition. + +## Server Purpose + +**Primary Functions:** +- User authentication and session management +- API token generation and validation +- User profile and settings management +- Basic system information retrieval + +**Part of Portainer MCP Suite:** +- `portainer-core` - Authentication and user management (this server) +- `portainer-teams` - Teams and RBAC management +- `portainer-environments` - Environment and endpoint management +- `portainer-docker` - Docker container operations +- `portainer-kubernetes` - Kubernetes cluster management +- `portainer-stacks` - Stack deployment and management +- `portainer-edge` - Edge computing and device management + +## Authentication Flow + +**Required for all operations:** +1. Initialize admin user (first time setup) +2. Authenticate to get JWT token +3. Use token in `X-API-Key` header for all requests + +**Token Management:** +- Tokens expire based on server configuration +- Generate new tokens before expiration +- Store tokens securely in environment variables + +## Base Configuration + +**Environment Variables:** +```bash +PORTAINER_URL=https://your-portainer-instance.com +PORTAINER_API_KEY=your-api-token +``` + +**API Base URLs:** +- Business Edition: `{PORTAINER_URL}/api` +- All endpoints require authentication except initial admin setup + +## Common Response Patterns + +**Success Response Structure:** +```json +{ + "Id": 1, + "Username": "admin", + "Role": 1, + "CreationDate": 1631852794 +} +``` + +**Error Response Structure:** +```json +{ + "message": "Invalid credentials", + "details": "Authentication failed" +} +``` + +## Core Endpoint Categories + +**Authentication Endpoints:** +- `POST /api/users/admin/init` - Initialize admin user +- `POST /api/auth` - Authenticate user +- `POST /api/users/{id}/tokens` - Generate API token +- `DELETE /api/auth` - Logout/invalidate session + +**User Management:** +- `GET /api/users` - List users +- `POST /api/users` - Create user +- `GET /api/users/{id}` - Get user details +- `PUT /api/users/{id}` - Update user +- `DELETE /api/users/{id}` - Delete user + +**Settings:** +- `GET /api/settings` - Get Portainer settings +- `PUT /api/settings` - Update settings +- `GET /api/settings/public` - Get public settings + +## Error Handling + +**Common HTTP Status Codes:** +- `200` - Success +- `201` - Created +- `400` - Bad Request (invalid parameters) +- `401` - Unauthorized (invalid/missing token) +- `403` - Forbidden (insufficient permissions) +- `404` - Not Found +- `409` - Conflict (resource already exists) +- `500` - Internal Server Error + +**Retry Logic:** +- Implement exponential backoff for 5xx errors +- Refresh token on 401 responses +- Maximum 3 retry attempts + +## Security Considerations + +**Best Practices:** +- Always use HTTPS in production +- Rotate API tokens regularly +- Implement proper token storage +- Log authentication events +- Rate limit API calls + +**RBAC Integration:** +- Check user permissions before operations +- Respect environment-level access controls +- Honor team-based restrictions + +## Development Workflow + +**Testing Authentication:** +```bash +# Test admin initialization +curl -X POST "${PORTAINER_URL}/api/users/admin/init" \ + -H "Content-Type: application/json" \ + -d '{"Username":"admin","Password":"yourpassword"}' + +# Test login +curl -X POST "${PORTAINER_URL}/api/auth" \ + -H "Content-Type: application/json" \ + -d '{"Username":"admin","Password":"yourpassword"}' +``` + +**Local Development:** +- Use Docker Compose with Portainer for testing +- Mock authentication for unit tests +- Validate all endpoints with proper error handling + +## Integration Notes + +**With Other MCP Servers:** +- Share authentication state across Portainer MCP servers +- Use consistent error handling patterns +- Maintain session context for user operations + +**Rate Limits:** +- Portainer has built-in rate limiting +- Implement client-side throttling +- Monitor for 429 responses + +## Troubleshooting + +**Common Issues:** +- **401 Unauthorized**: Check token validity and format +- **403 Forbidden**: Verify user permissions and RBAC settings +- **Connection Refused**: Confirm Portainer URL and network access +- **SSL Errors**: Validate certificate configuration + +**Debug Commands:** +```bash +# Verify Portainer connectivity +curl -I "${PORTAINER_URL}/api/status" + +# Check current user context +curl -H "X-API-Key: ${PORTAINER_API_KEY}" \ + "${PORTAINER_URL}/api/users/me" +``` + +**Logging:** +- Enable verbose logging for authentication flows +- Log all API calls with timestamps +- Mask sensitive data in logs (passwords, tokens) + +## Version Compatibility + +**Supported Versions:** +- Portainer Business Edition 2.30.x+ +- API Version: 2.31.3 + +**Breaking Changes:** +- Monitor Portainer release notes for API changes +- Test against new versions before upgrading +- Maintain backward compatibility where possible diff --git a/README.md b/README.md new file mode 100644 index 0000000..0565a42 --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# Portainer Core MCP Server + +A Model Context Protocol (MCP) server for Portainer Business Edition authentication and user management. + +## Features + +- **Authentication & Session Management**: JWT token handling and user authentication +- **User Management**: Create, read, update, and delete users +- **Settings Management**: Retrieve and update Portainer settings +- **Secure Token Handling**: Automatic token refresh and secure storage +- **Error Handling**: Comprehensive error handling with retry logic +- **Circuit Breaker**: Fault tolerance for external API calls + +## Installation + +```bash +pip install portainer-core-mcp +``` + +## Configuration + +Set the following environment variables: + +```bash +PORTAINER_URL=https://your-portainer-instance.com +PORTAINER_API_KEY=your-api-token # Optional, for API key authentication +PORTAINER_USERNAME=admin # For username/password authentication +PORTAINER_PASSWORD=your-password # For username/password authentication +``` + +## Usage + +### As MCP Server + +```bash +portainer-core-mcp +``` + +### Programmatic Usage + +```python +from portainer_core.server import PortainerCoreMCPServer + +server = PortainerCoreMCPServer() +# Use server instance +``` + +## Available MCP Tools + +- `authenticate` - Login with username/password +- `generate_token` - Generate API token +- `get_current_user` - Get authenticated user info +- `list_users` - List all users +- `create_user` - Create new user +- `update_user` - Update user details +- `delete_user` - Delete user +- `get_settings` - Get Portainer settings +- `update_settings` - Update settings + +## Development + +### Setup + +```bash +# Clone the repository +git clone https://github.com/yourusername/portainer-core-mcp.git +cd portainer-core-mcp + +# Install development dependencies +pip install -e ".[dev]" + +# Install pre-commit hooks +pre-commit install +``` + +### Testing + +```bash +# Run all tests +pytest + +# Run with coverage +pytest --cov=src/portainer_core --cov-report=html + +# Run only unit tests +pytest -m unit + +# Run only integration tests +pytest -m integration +``` + +### Code Quality + +```bash +# Format code +black src tests +isort src tests + +# Lint code +flake8 src tests + +# Type checking +mypy src +``` + +## Architecture + +The server follows a layered architecture: + +- **MCP Server Layer**: Handles MCP protocol communication +- **Service Layer**: Abstracts Portainer API interactions +- **Models Layer**: Defines data structures and validation +- **Utils Layer**: Provides utility functions and helpers + +## Security + +- All API communications use HTTPS +- JWT tokens are handled securely and never logged +- Input validation on all parameters +- Rate limiting to prevent abuse +- Circuit breaker pattern for fault tolerance + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests for new functionality +5. Ensure all tests pass +6. Submit a pull request + +## License + +MIT License - see LICENSE file for details. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9bfdbf0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,141 @@ +[build-system] +requires = ["setuptools>=64", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "portainer-core-mcp" +version = "0.1.0" +description = "Portainer Core MCP Server - Authentication and User Management" +authors = [ + {name = "Your Name", email = "your.email@example.com"} +] +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.8" +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "mcp>=1.0.0", + "httpx>=0.25.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", + "structlog>=23.0.0", + "PyJWT>=2.8.0", + "python-dotenv>=1.0.0", + "tenacity>=8.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "pytest-cov>=4.0.0", + "pytest-mock>=3.10.0", + "httpx-mock>=0.10.0", + "black>=23.0.0", + "isort>=5.12.0", + "flake8>=6.0.0", + "mypy>=1.0.0", + "pre-commit>=3.0.0", +] + +[project.urls] +Homepage = "https://github.com/yourusername/portainer-core-mcp" +Documentation = "https://github.com/yourusername/portainer-core-mcp#readme" +Repository = "https://github.com/yourusername/portainer-core-mcp" +Issues = "https://github.com/yourusername/portainer-core-mcp/issues" + +[project.scripts] +portainer-core-mcp = "portainer_core.server:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-dir] +"" = "src" + +[tool.black] +line-length = 88 +target-version = ['py38'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + +[tool.isort] +profile = "black" +multi_line_output = 3 +line_length = 88 +known_first_party = ["portainer_core"] + +[tool.mypy] +python_version = "3.8" +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 +strict_equality = true + +[tool.pytest.ini_options] +minversion = "7.0" +addopts = "-ra -q --strict-markers --strict-config" +testpaths = [ + "tests", +] +python_files = [ + "test_*.py", + "*_test.py", +] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", + "unit: marks tests as unit tests", +] + +[tool.coverage.run] +source = ["src"] +omit = [ + "*/tests/*", + "*/test_*", + "*/conftest.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "if self.debug:", + "if settings.DEBUG", + "raise AssertionError", + "raise NotImplementedError", + "if 0:", + "if __name__ == .__main__.:", + "class .*\\bProtocol\\):", + "@(abc\\.)?abstractmethod", +] \ No newline at end of file diff --git a/run_server.py b/run_server.py new file mode 100644 index 0000000..5e45f06 --- /dev/null +++ b/run_server.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +""" +Portainer Core MCP Server - Entry Point Script. + +This script serves as the main entry point for the Portainer Core MCP Server, +providing authentication and user management functionality for Portainer Business Edition. +It handles environment setup, configuration validation, and server startup. + +Usage: + python run_server.py + +Environment Variables: + PORTAINER_URL: The base URL of your Portainer instance (required) + PORTAINER_API_KEY: API key for authentication (option 1) + PORTAINER_USERNAME: Username for authentication (option 2) + PORTAINER_PASSWORD: Password for authentication (option 2) + +Example: + export PORTAINER_URL=https://portainer.example.com + export PORTAINER_API_KEY=your-api-key-here + python run_server.py + +Architecture: + - Environment setup and validation + - Configuration loading from environment variables + - Async server initialization and startup + - Graceful error handling and shutdown +""" + +import os +import sys +import asyncio +from portainer_core.server import main + + +def setup_environment(): + """ + Set up environment variables with fallback values for demo/testing. + + This function ensures the required environment variables are set for the server + to start properly. It provides fallback values for demo purposes when specific + configuration is not provided. + + Environment Variables Handled: + PORTAINER_URL: Sets default demo URL if not provided + PORTAINER_API_KEY: Sets placeholder if no authentication is configured + PORTAINER_USERNAME/PORTAINER_PASSWORD: Alternative authentication method + + Raises: + None: This function doesn't raise exceptions but prints warnings for + missing configuration. + + Side Effects: + - Modifies os.environ with default values + - Prints configuration messages to stdout + - Ensures minimum viable configuration for server startup + + Note: + In production environments, always provide proper authentication + credentials rather than relying on demo/placeholder values. + """ + # Configure Portainer URL with fallback to demo instance + if not os.environ.get('PORTAINER_URL'): + os.environ['PORTAINER_URL'] = 'https://demo.portainer.io' + print("šŸ”§ Using demo Portainer URL: https://demo.portainer.io") + + # Configure authentication - requires either API key or username/password + has_api_key = os.environ.get('PORTAINER_API_KEY') + has_credentials = (os.environ.get('PORTAINER_USERNAME') and + os.environ.get('PORTAINER_PASSWORD')) + + if not has_api_key and not has_credentials: + print("āš ļø No authentication configured. Set PORTAINER_API_KEY or PORTAINER_USERNAME/PORTAINER_PASSWORD") + print(" For demo purposes, using placeholder API key") + os.environ['PORTAINER_API_KEY'] = 'demo-api-key' + +if __name__ == "__main__": + """ + Main execution block for the Portainer Core MCP Server. + + This block handles the complete server lifecycle: + 1. Environment setup and configuration validation + 2. Async server initialization + 3. Graceful error handling and shutdown + 4. User-friendly status messages + + Exit Codes: + 0: Successful shutdown (user interrupt) + 1: Server failure or configuration error + + Flow: + 1. Print startup messages and configuration requirements + 2. Setup environment variables with fallbacks + 3. Start async server using asyncio.run() + 4. Handle interrupts and exceptions gracefully + """ + print("šŸš€ Starting Portainer Core MCP Server...") + print(" Configuration will be loaded from environment variables") + print(" Set PORTAINER_URL and PORTAINER_API_KEY (or username/password) before running") + print("") + + # Initialize environment with fallback values for demo/testing + setup_environment() + + try: + # Start the async MCP server - this blocks until shutdown + asyncio.run(main()) + except KeyboardInterrupt: + # Handle graceful shutdown on Ctrl+C + print("\nšŸ‘‹ Server stopped by user") + sys.exit(0) + except Exception as e: + # Handle any startup or runtime errors + print(f"\nāŒ Server failed: {e}") + sys.exit(1) \ No newline at end of file diff --git a/src/portainer_core/__init__.py b/src/portainer_core/__init__.py new file mode 100644 index 0000000..ef411f0 --- /dev/null +++ b/src/portainer_core/__init__.py @@ -0,0 +1,57 @@ +""" +Portainer Core MCP Server - Main Package + +This package provides Model Context Protocol (MCP) server functionality for Portainer +Business Edition, focusing on authentication and user management operations. + +The package implements a complete MCP server that integrates with Portainer's REST API +to provide secure, reliable access to user management, authentication, and settings +configuration through the MCP protocol. + +Key Features: + - Authentication service with JWT token management + - User management with full CRUD operations + - Settings management for Portainer configuration + - Comprehensive error handling and logging + - Circuit breaker pattern for fault tolerance + - Health monitoring and service status reporting + +Package Structure: + - server: Main MCP server implementation + - config: Configuration management with environment variable support + - models: Pydantic data models for API requests/responses + - services: Business logic services (auth, users, settings) + - utils: Utility modules (errors, logging, tokens) + +Usage: + from portainer_core.server import create_server + + # Create and run the MCP server + server = create_server() + await server.run() + +Dependencies: + - mcp: Model Context Protocol implementation + - pydantic: Data validation and serialization + - httpx: HTTP client for API requests + - structlog: Structured logging + +Version: 0.1.0 +License: MIT +Compatibility: Python 3.8+ +""" + +__version__ = "0.1.0" +__author__ = "Portainer MCP Team" +__email__ = "support@portainer.io" +__license__ = "MIT" +__description__ = "MCP server for Portainer Business Edition authentication and user management" + +# Package metadata for introspection +__all__ = [ + "__version__", + "__author__", + "__email__", + "__license__", + "__description__", +] \ No newline at end of file diff --git a/src/portainer_core/__pycache__/__init__.cpython-312.pyc b/src/portainer_core/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..05d0b4ca2ad4dc2efff344cde8028502601c9643 GIT binary patch literal 430 zcmYLFyH3L}6ixb2NlIZs6%vaFmJTFkM+gxyQdI&8k>$#YU1Aa2k)2dDQ~3c_zJYJy z2db`2Y(?FexT&am2m2h~bC2!w+3WcZ)sbmyDlxdT1#%2s5L$e7%A zmAvZD5C&XQkzMf@D)Wp7iI$^5YuN!{IssUA0r0^VZISo@^;Oy+_5e1M03Xh}j^j59 zQ!RN&8PD*$s?wq1xwc`xjp0ltd1y_tJAnk1A%IY#00{Ega_>TFR%ZOtc<5Q@Y)^1m eHC@;JX|&ze>cBbd&%2A>@!VhRpT2sggZ)4KG>BpV literal 0 HcmV?d00001 diff --git a/src/portainer_core/__pycache__/config.cpython-312.pyc b/src/portainer_core/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..de87e17bcbe3531d30c02992adc3925966c5c46f GIT binary patch literal 7659 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 Bl$8Jg literal 0 HcmV?d00001 diff --git a/src/portainer_core/config.py b/src/portainer_core/config.py new file mode 100644 index 0000000..6346c48 --- /dev/null +++ b/src/portainer_core/config.py @@ -0,0 +1,229 @@ +""" +Configuration management for Portainer Core MCP Server. + +This module handles environment variable validation and configuration settings. +""" + +import os +from typing import Optional +from urllib.parse import urlparse + +from pydantic import BaseModel, Field, field_validator, ConfigDict +from pydantic_settings import BaseSettings + + +class PortainerConfig(BaseSettings): + """Configuration settings for Portainer Core MCP Server.""" + + # Portainer connection settings + portainer_url: str = Field( + ..., + description="Base URL of the Portainer instance" + ) + + # Authentication settings (either API key or username/password) + portainer_api_key: Optional[str] = Field( + default=None, + description="Portainer API key for authentication" + ) + + portainer_username: Optional[str] = Field( + default=None, + description="Portainer username for authentication" + ) + + portainer_password: Optional[str] = Field( + default=None, + description="Portainer password for authentication" + ) + + # HTTP client settings + http_timeout: int = Field( + default=30, + description="HTTP request timeout in seconds" + ) + + max_retries: int = Field( + default=3, + description="Maximum number of retry attempts" + ) + + retry_delay: float = Field( + default=1.0, + description="Base delay between retries in seconds" + ) + + # Circuit breaker settings + circuit_breaker_failure_threshold: int = Field( + default=5, + description="Number of failures before circuit breaker opens" + ) + + circuit_breaker_recovery_timeout: int = Field( + default=60, + description="Time in seconds before attempting recovery" + ) + + # Token management settings + token_cache_ttl: int = Field( + default=3600, + description="Token cache TTL in seconds" + ) + + token_refresh_threshold: int = Field( + default=300, + description="Refresh token when expiring within this many seconds" + ) + + # Logging settings + log_level: str = Field( + default="INFO", + description="Logging level" + ) + + log_format: str = Field( + default="json", + description="Logging format (json or text)" + ) + + # Development settings + debug: bool = Field( + default=False, + description="Enable debug mode" + ) + + model_config = ConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False + ) + + @field_validator("portainer_url") + @classmethod + def validate_portainer_url(cls, v): + """Validate Portainer URL format.""" + if not v: + raise ValueError("Portainer URL is required") + + try: + parsed = urlparse(v) + if not parsed.scheme or not parsed.netloc: + raise ValueError("Invalid URL format") + + if parsed.scheme not in ["http", "https"]: + raise ValueError("URL must use http or https scheme") + + # Remove trailing slash for consistency + return v.rstrip("/") + except Exception as e: + raise ValueError(f"Invalid Portainer URL: {e}") + + @field_validator("log_level") + @classmethod + def validate_log_level(cls, v): + """Validate logging level.""" + valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + if v.upper() not in valid_levels: + raise ValueError(f"Log level must be one of {valid_levels}") + return v.upper() + + @field_validator("log_format") + @classmethod + def validate_log_format(cls, v): + """Validate logging format.""" + valid_formats = ["json", "text"] + if v.lower() not in valid_formats: + raise ValueError(f"Log format must be one of {valid_formats}") + return v.lower() + + def validate_auth_config(self) -> None: + """Validate authentication configuration.""" + has_api_key = self.portainer_api_key is not None + has_credentials = ( + self.portainer_username is not None + and self.portainer_password is not None + ) + + if not has_api_key and not has_credentials: + raise ValueError( + "Either PORTAINER_API_KEY or both PORTAINER_USERNAME and " + "PORTAINER_PASSWORD must be provided" + ) + + if has_api_key and has_credentials: + # Prefer API key if both are provided + self.portainer_username = None + self.portainer_password = None + + @property + def api_base_url(self) -> str: + """Get the base API URL.""" + return f"{self.portainer_url}/api" + + @property + def use_api_key_auth(self) -> bool: + """Check if API key authentication should be used.""" + return self.portainer_api_key is not None + + @property + def use_credentials_auth(self) -> bool: + """Check if username/password authentication should be used.""" + return ( + self.portainer_username is not None + and self.portainer_password is not None + ) + + +class ServerConfig(BaseModel): + """Server-specific configuration settings.""" + + host: str = Field( + default="localhost", + description="Server host" + ) + + port: int = Field( + default=8000, + description="Server port" + ) + + server_name: str = Field( + default="portainer-core-mcp", + description="Server name for identification" + ) + + version: str = Field( + default="0.1.0", + description="Server version" + ) + + +def get_config() -> PortainerConfig: + """Get validated configuration instance.""" + config = PortainerConfig() + config.validate_auth_config() + return config + + +def get_server_config() -> ServerConfig: + """Get server configuration instance.""" + return ServerConfig() + + +# Global configuration instances - lazy initialization +_config = None +_server_config = None + +def get_global_config() -> PortainerConfig: + """Get global configuration instance with lazy initialization.""" + global _config + if _config is None: + _config = get_config() + return _config + +def get_global_server_config() -> ServerConfig: + """Get global server configuration instance with lazy initialization.""" + global _server_config + if _server_config is None: + _server_config = get_server_config() + return _server_config \ No newline at end of file diff --git a/src/portainer_core/models/__init__.py b/src/portainer_core/models/__init__.py new file mode 100644 index 0000000..fb6da54 --- /dev/null +++ b/src/portainer_core/models/__init__.py @@ -0,0 +1,5 @@ +""" +Data models for Portainer Core MCP Server. + +This module contains Pydantic models for request/response data structures. +""" \ No newline at end of file diff --git a/src/portainer_core/models/__pycache__/__init__.cpython-312.pyc b/src/portainer_core/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..49660390132de7d24271ed8170cd265947bb41d5 GIT binary patch literal 308 zcmYk2J5B^K42F|s1gl7UhqPNTxd5UWDkRWIP*D(#5~GQxD%Rq9&9^67+hDYQW4nU)eX05|fa-{aR{J?ne+)^*1JdbWqDpA~D%y}z?eUe? i&bzF29jEQ`Y|nX)$27W}pUQVWk2vRFi;7oRQThYGPF>po literal 0 HcmV?d00001 diff --git a/src/portainer_core/models/__pycache__/auth.cpython-312.pyc b/src/portainer_core/models/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..314ccadf69c7d65bb1b088d0246776a8e8fa0d8f GIT binary patch literal 2911 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 D#>Ni; literal 0 HcmV?d00001 diff --git a/src/portainer_core/models/__pycache__/users.cpython-312.pyc b/src/portainer_core/models/__pycache__/users.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0734c662fe787dd2c6f5608fe3583ee21b760419 GIT binary patch literal 8020 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 None: + """Set up MCP server handlers.""" + + @self.server.list_resources() + async def handle_list_resources() -> List[Resource]: + """List available resources.""" + return [ + Resource( + uri="portainer://users", + name="Users", + description="Portainer users and their details", + mimeType="application/json", + ), + Resource( + uri="portainer://settings", + name="Settings", + description="Portainer instance settings", + mimeType="application/json", + ), + Resource( + uri="portainer://health", + name="Health", + description="Server health status", + mimeType="application/json", + ), + ] + + @self.server.read_resource() + async def handle_read_resource(uri: str) -> str: + """Read a specific resource.""" + with LogContext(): + logger.info("Reading resource", uri=uri) + + try: + if uri == "portainer://health": + return await self._get_health_status() + elif uri == "portainer://users": + return await self._get_users_resource() + elif uri == "portainer://settings": + return await self._get_settings_resource() + else: + raise PortainerError(f"Unknown resource: {uri}") + + except Exception as e: + logger.error("Error reading resource", uri=uri, error=str(e)) + raise + + @self.server.list_tools() + async def handle_list_tools() -> List[Tool]: + """List available tools.""" + return [ + Tool( + name="authenticate", + description="Authenticate with Portainer using username/password", + inputSchema={ + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "Username for authentication" + }, + "password": { + "type": "string", + "description": "Password for authentication" + } + }, + "required": ["username", "password"] + } + ), + Tool( + name="generate_token", + description="Generate API token for a user", + inputSchema={ + "type": "object", + "properties": { + "user_id": { + "type": "integer", + "description": "User ID to generate token for" + }, + "description": { + "type": "string", + "description": "Token description" + } + }, + "required": ["user_id"] + } + ), + Tool( + name="get_current_user", + description="Get current authenticated user information", + inputSchema={ + "type": "object", + "properties": {}, + "additionalProperties": False + } + ), + Tool( + name="list_users", + description="List all users", + inputSchema={ + "type": "object", + "properties": {}, + "additionalProperties": False + } + ), + Tool( + name="create_user", + description="Create a new user", + inputSchema={ + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "Username for the new user" + }, + "password": { + "type": "string", + "description": "Password for the new user" + }, + "role": { + "type": "integer", + "description": "Role ID for the user (1=Admin, 2=User)" + } + }, + "required": ["username", "password", "role"] + } + ), + Tool( + name="update_user", + description="Update user information", + inputSchema={ + "type": "object", + "properties": { + "user_id": { + "type": "integer", + "description": "User ID to update" + }, + "username": { + "type": "string", + "description": "New username" + }, + "password": { + "type": "string", + "description": "New password" + }, + "role": { + "type": "integer", + "description": "New role ID" + } + }, + "required": ["user_id"] + } + ), + Tool( + name="delete_user", + description="Delete a user", + inputSchema={ + "type": "object", + "properties": { + "user_id": { + "type": "integer", + "description": "User ID to delete" + } + }, + "required": ["user_id"] + } + ), + Tool( + name="get_settings", + description="Get Portainer settings", + inputSchema={ + "type": "object", + "properties": {}, + "additionalProperties": False + } + ), + Tool( + name="update_settings", + description="Update Portainer settings", + inputSchema={ + "type": "object", + "properties": { + "settings": { + "type": "object", + "description": "Settings to update" + } + }, + "required": ["settings"] + } + ), + Tool( + name="health_check", + description="Check server health status", + inputSchema={ + "type": "object", + "properties": {}, + "additionalProperties": False + } + ), + ] + + @self.server.call_tool() + async def handle_call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]: + """Handle tool calls.""" + correlation_id = set_correlation_id() + + with LogContext(correlation_id): + logger.info("Tool called", tool_name=name, arguments=arguments) + + try: + if name == "health_check": + result = await self._handle_health_check() + elif name == "authenticate": + result = await self._handle_authenticate(arguments) + elif name == "generate_token": + result = await self._handle_generate_token(arguments) + elif name == "get_current_user": + result = await self._handle_get_current_user(arguments) + elif name == "list_users": + result = await self._handle_list_users(arguments) + elif name == "create_user": + result = await self._handle_create_user(arguments) + elif name == "update_user": + result = await self._handle_update_user(arguments) + elif name == "delete_user": + result = await self._handle_delete_user(arguments) + elif name == "get_settings": + result = await self._handle_get_settings(arguments) + elif name == "update_settings": + result = await self._handle_update_settings(arguments) + else: + raise PortainerError(f"Unknown tool: {name}") + + logger.info("Tool executed successfully", tool_name=name) + return [TextContent(type="text", text=result)] + + except Exception as e: + logger.error("Tool execution failed", tool_name=name, error=str(e)) + error_message = f"Error executing {name}: {str(e)}" + return [TextContent(type="text", text=error_message)] + + async def _get_health_status(self) -> str: + """Get server health status.""" + try: + config = get_global_config() + server_config = get_global_server_config() + + # Initialize services if not already done + await self._ensure_services_initialized() + + # Check service health + service_statuses = {} + + if self.auth_service: + service_statuses["auth"] = "healthy" if await self.auth_service.health_check() else "unhealthy" + else: + service_statuses["auth"] = "not_initialized" + + if self.user_service: + service_statuses["users"] = "healthy" if await self.user_service.health_check() else "unhealthy" + else: + service_statuses["users"] = "not_initialized" + + if self.settings_service: + service_statuses["settings"] = "healthy" if await self.settings_service.health_check() else "unhealthy" + else: + service_statuses["settings"] = "not_initialized" + + # Overall status + overall_status = "healthy" if all(s == "healthy" for s in service_statuses.values()) else "degraded" + + status = { + "status": overall_status, + "server": server_config.server_name, + "version": server_config.version, + "portainer_url": config.portainer_url, + "services": service_statuses + } + + return str(status) + except Exception as e: + logger.error("Health check failed", error=str(e)) + return f"Health check failed: {str(e)}" + + async def _ensure_services_initialized(self) -> None: + """Ensure all services are initialized.""" + if self.auth_service is None: + self.auth_service = AuthService() + await self.auth_service.initialize() + + if self.user_service is None: + self.user_service = UserService() + await self.user_service.initialize() + + if self.settings_service is None: + self.settings_service = SettingsService() + await self.settings_service.initialize() + + async def _get_users_resource(self) -> str: + """Get users resource.""" + try: + await self._ensure_services_initialized() + users = await self.user_service.list_users() + return str(users) + except Exception as e: + logger.error("Failed to get users resource", error=str(e)) + return f"Failed to get users: {str(e)}" + + async def _get_settings_resource(self) -> str: + """Get settings resource.""" + try: + await self._ensure_services_initialized() + settings = await self.settings_service.get_settings() + return str(settings) + except Exception as e: + logger.error("Failed to get settings resource", error=str(e)) + return f"Failed to get settings: {str(e)}" + + async def _handle_health_check(self) -> str: + """Handle health check tool call.""" + return await self._get_health_status() + + async def _handle_authenticate(self, arguments: Dict[str, Any]) -> str: + """Handle authentication tool call.""" + try: + await self._ensure_services_initialized() + username = arguments.get("username") + password = arguments.get("password") + + result = await self.auth_service.login(username, password) + logger.info("Authentication successful", username=username) + return str(result) + except Exception as e: + logger.error("Authentication failed", error=str(e)) + return f"Authentication failed: {str(e)}" + + async def _handle_generate_token(self, arguments: Dict[str, Any]) -> str: + """Handle token generation tool call.""" + try: + await self._ensure_services_initialized() + user_id = arguments.get("user_id") + description = arguments.get("description", "MCP Server Token") + + result = await self.auth_service.generate_api_token(user_id, description) + logger.info("Token generation successful", user_id=user_id) + return str(result) + except Exception as e: + logger.error("Token generation failed", error=str(e)) + return f"Token generation failed: {str(e)}" + + async def _handle_get_current_user(self, arguments: Dict[str, Any]) -> str: + """Handle get current user tool call.""" + try: + await self._ensure_services_initialized() + result = await self.auth_service.get_current_user() + return str(result) + except Exception as e: + logger.error("Get current user failed", error=str(e)) + return f"Get current user failed: {str(e)}" + + async def _handle_list_users(self, arguments: Dict[str, Any]) -> str: + """Handle list users tool call.""" + try: + await self._ensure_services_initialized() + result = await self.user_service.list_users() + return str(result) + except Exception as e: + logger.error("List users failed", error=str(e)) + return f"List users failed: {str(e)}" + + async def _handle_create_user(self, arguments: Dict[str, Any]) -> str: + """Handle create user tool call.""" + try: + await self._ensure_services_initialized() + username = arguments.get("username") + password = arguments.get("password") + role = arguments.get("role") + + result = await self.user_service.create_user(username, password, role) + logger.info("User creation successful", username=username) + return str(result) + except Exception as e: + logger.error("User creation failed", error=str(e)) + return f"User creation failed: {str(e)}" + + async def _handle_update_user(self, arguments: Dict[str, Any]) -> str: + """Handle update user tool call.""" + try: + await self._ensure_services_initialized() + user_id = arguments.get("user_id") + username = arguments.get("username") + password = arguments.get("password") + role = arguments.get("role") + + result = await self.user_service.update_user(user_id, username, password, role) + logger.info("User update successful", user_id=user_id) + return str(result) + except Exception as e: + logger.error("User update failed", error=str(e)) + return f"User update failed: {str(e)}" + + async def _handle_delete_user(self, arguments: Dict[str, Any]) -> str: + """Handle delete user tool call.""" + try: + await self._ensure_services_initialized() + user_id = arguments.get("user_id") + + result = await self.user_service.delete_user(user_id) + logger.info("User deletion successful", user_id=user_id) + return f"User {user_id} deleted successfully" + except Exception as e: + logger.error("User deletion failed", error=str(e)) + return f"User deletion failed: {str(e)}" + + async def _handle_get_settings(self, arguments: Dict[str, Any]) -> str: + """Handle get settings tool call.""" + try: + await self._ensure_services_initialized() + result = await self.settings_service.get_settings() + return str(result) + except Exception as e: + logger.error("Get settings failed", error=str(e)) + return f"Get settings failed: {str(e)}" + + async def _handle_update_settings(self, arguments: Dict[str, Any]) -> str: + """Handle update settings tool call.""" + try: + await self._ensure_services_initialized() + settings = arguments.get("settings") + + result = await self.settings_service.update_settings(settings) + logger.info("Settings update successful") + return str(result) + except Exception as e: + logger.error("Settings update failed", error=str(e)) + return f"Settings update failed: {str(e)}" + + async def run(self) -> None: + """Run the MCP server.""" + logger.info("Starting Portainer Core MCP Server") + + try: + server_config = get_global_server_config() + + # Initialize services + await self._ensure_services_initialized() + + async with stdio_server() as (read_stream, write_stream): + await self.server.run( + read_stream, + write_stream, + InitializationOptions( + server_name=server_config.server_name, + server_version=server_config.version, + ) + ) + except Exception as e: + logger.error("Server failed to start", error=str(e)) + raise + finally: + # Clean up services + await self._cleanup_services() + + async def _cleanup_services(self) -> None: + """Clean up service resources.""" + if self.auth_service: + await self.auth_service.cleanup() + if self.user_service: + await self.user_service.cleanup() + if self.settings_service: + await self.settings_service.cleanup() + + +def create_server() -> PortainerCoreMCPServer: + """Create and return a configured MCP server instance.""" + return PortainerCoreMCPServer() + + +async def main() -> None: + """Main entry point for the MCP server.""" + try: + server = create_server() + await server.run() + except KeyboardInterrupt: + logger.info("Server stopped by user") + except Exception as e: + logger.error("Server failed", error=str(e)) + raise + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/src/portainer_core/services/__init__.py b/src/portainer_core/services/__init__.py new file mode 100644 index 0000000..9091dcc --- /dev/null +++ b/src/portainer_core/services/__init__.py @@ -0,0 +1,5 @@ +""" +Service layer for Portainer Core MCP Server. + +This module contains service classes for interacting with the Portainer API. +""" \ No newline at end of file diff --git a/src/portainer_core/services/__pycache__/__init__.cpython-312.pyc b/src/portainer_core/services/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3e5d91a22c56cb820ec89ae00d383ab8800aee57 GIT binary patch literal 314 zcmYjNK}rNM5KPuVm%#oZ*F8A-fCw@tL1jQsdI%xYI)RyF=#I(^p8SMw@GbseuYN$> znUMeEXCO;KIc+iE3P!c8@`&uPAAvIC!&?J_MdtYOz|qydUVLL+v^kd?D1T;1}eXxdh4`ee7}0KNkfrut@Y9?5JOl literal 0 HcmV?d00001 diff --git a/src/portainer_core/services/__pycache__/auth.cpython-312.pyc b/src/portainer_core/services/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..12ae6851c1be714d415beaf28a18b611a63b9b76 GIT binary patch literal 9278 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 Dxy+7$ literal 0 HcmV?d00001 diff --git a/src/portainer_core/services/__pycache__/base.cpython-312.pyc b/src/portainer_core/services/__pycache__/base.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3858a722d9c8bf6003f562eadb14ffe91ecdcd06 GIT binary patch literal 14882 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 QQ%2f(x#vR)zXbjN1Knu)2mk;8 literal 0 HcmV?d00001 diff --git a/src/portainer_core/services/__pycache__/settings.cpython-312.pyc b/src/portainer_core/services/__pycache__/settings.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fd72d24d68a9f6d1292173e6c1886b121488c0a0 GIT binary patch literal 10883 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

B z3^-E}%=%n(a(%Fs{DcOV^6z#)EZa;WGrN>xyw%+&aM`uN=03u`PXtK3FK|$7Pne$j z5@7adBa5Ps#A?LBXTayI>3P!97$PyUlAy5)@O!KTSaKRN43>~;*|lf_n<0(floC_S z^jq9;TVTm*NSP94rdTC|C2t8VnQ$lhET)**LK!Ua6ON0HGFb9wuA8v5M~=&B8AuW8 z*;fT4AP3Nx**5BnnXomw29;4+j17S$2o=GND;6=qteAK1*?Va3;9dqTx{;*u9<4jc zQYk*B?1RrRXvGmtioM?#;fO`FM>Nc)B6={}smN}Mz`=39OzCte$UJUIYs;n`+m9m!%fHEIxmM1)hhfjSdgihYzJh+yMst;+ny)KBf0 zMn{k~RW!cfR{;~VafA?~L7meW2SQ?$oezCkUSmLx(W=pm4e}^^;Hxm$$y9VRc}XWS zt!0C-#h@mONu>?d=+hR5mOfG(u*j{saCYN>9uG+P>2<%a#$*m+9fnG{nsGukpKp~m ze_>-#nWZIm7aOrz_#>#Q&O%^vnG;rcGPumuCvr`j=bPK^G;f-1-Za;|1&9oS6^~%W zBUoWjZinrdI>We3KY~>iNEl`p?}lDV&8{b>p7`k4@!W}%bH`3G3Um4kDNJW>$d*96VT-V@S01Uq4%h_#r7O#-Iqcrz#yGhlw;5Ym}|^PasnGMxyNI%#Lg50bV{2nrKsnC zSUUEBk>s+IU|3?J6JioLhCL;0#6%!4vuyfU1eSO+KQq;RKX^RTawH)p8Hf;MR2;O({yS7#{8irQ?rW34Y`ivZ%|>i`T? zXfVKt$fKON$~M&1QeCezyt84;NHff#WjI>wBz}%9-GIRKxSX?a0+x-w_;{as6;QrZ{fEP zeDR?x^PW6{M&Pllt;fYFnMbuu#p^-E@uRT^RD2^S|Kg8Vt>-$XDPZ-#oCbP>rob4d z{g;^p)}zzDVLAE&vU2cMM5F}V2|?RHb6c=0BIvSI$JDf(u-W5nHOkm@A8O82M5a!e zfPNOjKVym=E3T_+=4vvR+U#*!`Mrs5dd}c$)NS6qi z30pNsZ7|PJ@jjdNaHIGdW+Uy6ybJ5#Sn{r;y6LC%!0cygN}vGm{pi99N8nC`HErt^hzB zhA8Z8H4+XX{Rb<#e(&SWW((2L1d$ zbU-ZYqrJ;%)(@P3Fqu9jup!e4dXbI^+J6}yI5QVaH8D42^NIuL47)Q5H$sQy zNIX3}W`hH^Dr)S+54T`;*FUH+L-S^;)&&^K$P0$p{Ux@m?gNi`7fhh-j_T0Xe*}_a5STJ`0P5+xW`(=y*Ma)W-LH1f1y}!H^i8^){Ma3Ak;0Fo;12gKt3q z7sIp*j!`)t4~N^SvJ`SvTywjbsjmc36a&00s@{j-vq|zR{%iMmhtN@Q)d>Cc%IYiQ zvn#hO5KI)-HVKF5m1u$B|JC>c`!8&(7mm z>YJ&Zdivil&i3?Uzo%$nV?b!SnqDCAS6J67oTfMX3IzYBHf#SgI~G{Fu+uLzUD@*~ zfj_0yn2Y}QIwcH=yfZyE0>^RMOtTNhW*YH1@6+`}?Rp+AG%Ls=@?Lfdu6B(=hN^u? z_6^xqfFCaJ9Zkc%rY?4+Ph~f2v^zHFo5}mMLcFqyz2a3??E|%8j18{^gB29V*2Ev! JK=7Zn{{LQi5!e6# literal 0 HcmV?d00001 diff --git a/src/portainer_core/services/auth.py b/src/portainer_core/services/auth.py new file mode 100644 index 0000000..425b260 --- /dev/null +++ b/src/portainer_core/services/auth.py @@ -0,0 +1,230 @@ +""" +Authentication service for Portainer Core MCP Server. + +This module provides authentication functionality including login, token management, +and session handling for Portainer Business Edition. +""" + +import asyncio +from typing import Dict, Any, Optional +from datetime import datetime, timedelta + +from ..services.base import BaseService +from ..models.auth import LoginRequest, LoginResponse, TokenRequest, TokenResponse +from ..utils.errors import ( + PortainerAuthenticationError, + PortainerValidationError, + PortainerTokenExpiredError, +) +from ..utils.tokens import decode_jwt_token, is_token_expired +from ..utils.logging import get_logger + + +class AuthService(BaseService): + """Service for authentication operations.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.logger = get_logger(__name__) + self._cached_token = None + self._token_expires_at = None + + async def login(self, username: str, password: str) -> LoginResponse: + """ + Authenticate user with username and password. + + Args: + username: User's username + password: User's password + + Returns: + LoginResponse containing authentication token + + Raises: + PortainerAuthenticationError: If authentication fails + """ + request_data = LoginRequest(username=username, password=password) + + try: + response = await self._make_request( + method="POST", + endpoint="/auth", + json_data=request_data.model_dump(), + require_auth=False + ) + + # Store token for future use + self._cached_token = response.get("jwt") + if self._cached_token: + # Calculate expiration time (tokens typically last 8 hours) + self._token_expires_at = datetime.utcnow() + timedelta(hours=8) + + return LoginResponse(**response) + + except Exception as e: + self.logger.error("Authentication failed", username=username, error=str(e)) + raise PortainerAuthenticationError(f"Authentication failed: {str(e)}") + + async def logout(self) -> bool: + """ + Logout the current user session. + + Returns: + True if logout successful + """ + try: + await self._make_request( + method="DELETE", + endpoint="/auth" + ) + + # Clear cached token + self._cached_token = None + self._token_expires_at = None + + return True + + except Exception as e: + self.logger.error("Logout failed", error=str(e)) + return False + + async def generate_api_token(self, user_id: int, description: str = "API Token") -> TokenResponse: + """ + Generate an API token for a user. + + Args: + user_id: ID of the user + description: Token description + + Returns: + TokenResponse containing the new API token + """ + request_data = TokenRequest(description=description) + + try: + response = await self._make_request( + method="POST", + endpoint=f"/users/{user_id}/tokens", + json_data=request_data.model_dump() + ) + + return TokenResponse(**response) + + except Exception as e: + self.logger.error("Token generation failed", user_id=user_id, error=str(e)) + raise PortainerValidationError(f"Token generation failed: {str(e)}") + + async def validate_token(self, token: str) -> bool: + """ + Validate an authentication token. + + Args: + token: JWT token to validate + + Returns: + True if token is valid + """ + try: + if is_token_expired(token): + return False + + # Try to use the token for a simple API call + headers = {"Authorization": f"Bearer {token}"} + await self._make_request( + method="GET", + endpoint="/status", + headers=headers, + require_auth=False + ) + + return True + + except Exception: + return False + + async def refresh_token_if_needed(self) -> Optional[str]: + """ + Refresh the cached token if it's expired or about to expire. + + Returns: + New token if refreshed, None if no refresh needed + """ + if not self._cached_token or not self._token_expires_at: + return None + + # Check if token expires within 1 hour + expires_soon = datetime.utcnow() + timedelta(hours=1) + if self._token_expires_at > expires_soon: + return None + + # Re-authenticate if using credentials + if self.config.use_credentials_auth: + try: + response = await self.login( + self.config.portainer_username, + self.config.portainer_password + ) + return response.jwt + + except Exception as e: + self.logger.error("Token refresh failed", error=str(e)) + raise PortainerTokenExpiredError("Failed to refresh expired token") + + return None + + async def get_current_user(self) -> Dict[str, Any]: + """ + Get information about the currently authenticated user. + + Returns: + User information dictionary + """ + try: + response = await self._make_request( + method="GET", + endpoint="/users/me" + ) + + return response + + except Exception as e: + self.logger.error("Failed to get current user", error=str(e)) + raise PortainerAuthenticationError(f"Failed to get current user: {str(e)}") + + def get_cached_token(self) -> Optional[str]: + """ + Get the currently cached authentication token. + + Returns: + Cached token if available and not expired + """ + if not self._cached_token or not self._token_expires_at: + return None + + if datetime.utcnow() >= self._token_expires_at: + # Token expired, clear cache + self._cached_token = None + self._token_expires_at = None + return None + + return self._cached_token + + async def health_check(self) -> bool: + """ + Check if the authentication service is healthy. + + Returns: + True if service is healthy + """ + try: + # Try to access the status endpoint + await self._make_request( + method="GET", + endpoint="/status", + require_auth=False + ) + return True + + except Exception as e: + self.logger.error("Auth service health check failed", error=str(e)) + return False \ No newline at end of file diff --git a/src/portainer_core/services/base.py b/src/portainer_core/services/base.py new file mode 100644 index 0000000..e3864e9 --- /dev/null +++ b/src/portainer_core/services/base.py @@ -0,0 +1,333 @@ +""" +Base service class for Portainer API interactions. + +This module provides a base service class with HTTP client, retry logic, +circuit breaker pattern, and error handling. +""" + +import asyncio +import time +from abc import ABC, abstractmethod +from enum import Enum +from typing import Any, Dict, Optional, Union + +import httpx +from tenacity import ( + retry, + stop_after_attempt, + wait_exponential, + retry_if_exception_type, + before_sleep_log, +) + +from ..config import get_global_config +from ..utils.errors import ( + PortainerError, + PortainerNetworkError, + PortainerTimeoutError, + PortainerCircuitBreakerError, + map_http_error, + is_retryable_error, + should_refresh_token, +) +from ..utils.logging import get_logger + +logger = get_logger(__name__) + + +class CircuitBreakerState(Enum): + """Circuit breaker states.""" + CLOSED = "closed" + OPEN = "open" + HALF_OPEN = "half_open" + + +class CircuitBreaker: + """Circuit breaker implementation for fault tolerance.""" + + def __init__( + self, + failure_threshold: int = 5, + recovery_timeout: int = 60, + name: str = "default" + ): + self.failure_threshold = failure_threshold + self.recovery_timeout = recovery_timeout + self.name = name + self.failure_count = 0 + self.last_failure_time = None + self.state = CircuitBreakerState.CLOSED + + def can_execute(self) -> bool: + """Check if the circuit breaker allows execution.""" + if self.state == CircuitBreakerState.CLOSED: + return True + + if self.state == CircuitBreakerState.OPEN: + if self.last_failure_time and \ + time.time() - self.last_failure_time > self.recovery_timeout: + self.state = CircuitBreakerState.HALF_OPEN + logger.info( + "Circuit breaker transitioning to half-open", + name=self.name, + failure_count=self.failure_count + ) + return True + return False + + # HALF_OPEN state + return True + + def record_success(self) -> None: + """Record a successful operation.""" + self.failure_count = 0 + self.last_failure_time = None + + if self.state == CircuitBreakerState.HALF_OPEN: + self.state = CircuitBreakerState.CLOSED + logger.info( + "Circuit breaker closed after successful operation", + name=self.name + ) + + def record_failure(self) -> None: + """Record a failed operation.""" + self.failure_count += 1 + self.last_failure_time = time.time() + + if self.failure_count >= self.failure_threshold: + self.state = CircuitBreakerState.OPEN + logger.warning( + "Circuit breaker opened due to failures", + name=self.name, + failure_count=self.failure_count + ) + + +class BaseService(ABC): + """Base class for Portainer API services.""" + + def __init__(self, name: str): + self.name = name + self.client = None + self.config = get_global_config() + self.circuit_breaker = CircuitBreaker( + failure_threshold=self.config.circuit_breaker_failure_threshold, + recovery_timeout=self.config.circuit_breaker_recovery_timeout, + name=name + ) + self.logger = get_logger(f"{__name__}.{name}") + self._auth_token = None + self._token_expiry = None + + async def __aenter__(self): + """Async context manager entry.""" + await self.initialize() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit.""" + await self.cleanup() + + async def initialize(self) -> None: + """Initialize the service and HTTP client.""" + if self.client is None: + self.client = httpx.AsyncClient( + timeout=httpx.Timeout(self.config.http_timeout), + limits=httpx.Limits(max_keepalive_connections=5, max_connections=10), + verify=True, # Always verify SSL certificates + ) + self.logger.info("HTTP client initialized", service=self.name) + + async def cleanup(self) -> None: + """Clean up resources.""" + if self.client: + await self.client.aclose() + self.client = None + self.logger.info("HTTP client closed", service=self.name) + + def _get_base_headers(self) -> Dict[str, str]: + """Get base headers for API requests.""" + headers = { + "Content-Type": "application/json", + "User-Agent": f"portainer-core-mcp/{self.config.portainer_url}", + } + + if self._auth_token: + headers["Authorization"] = f"Bearer {self._auth_token}" + elif self.config.portainer_api_key: + headers["X-API-Key"] = self.config.portainer_api_key + + return headers + + def _build_url(self, endpoint: str) -> str: + """Build full URL for API endpoint.""" + base_url = self.config.api_base_url + if endpoint.startswith("/"): + endpoint = endpoint[1:] + return f"{base_url}/{endpoint}" + + async def _execute_request( + self, + method: str, + endpoint: str, + data: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + retry_on_auth_failure: bool = True, + ) -> httpx.Response: + """Execute HTTP request with circuit breaker and error handling.""" + + # Check circuit breaker + if not self.circuit_breaker.can_execute(): + raise PortainerCircuitBreakerError( + f"Circuit breaker is open for {self.name} service" + ) + + # Ensure client is initialized + if self.client is None: + await self.initialize() + + # Build request + url = self._build_url(endpoint) + request_headers = self._get_base_headers() + if headers: + request_headers.update(headers) + + self.logger.debug( + "Making HTTP request", + method=method, + url=url, + params=params, + service=self.name + ) + + try: + response = await self.client.request( + method=method, + url=url, + json=data, + params=params, + headers=request_headers, + ) + + # Check for authentication errors + if response.status_code == 401 and retry_on_auth_failure: + self.logger.warning("Authentication failed, attempting token refresh") + if await self._refresh_token(): + # Retry once with new token + return await self._execute_request( + method, endpoint, data, params, headers, retry_on_auth_failure=False + ) + + # Handle HTTP errors + if response.status_code >= 400: + error_message = f"HTTP {response.status_code}" + try: + error_data = response.json() + if isinstance(error_data, dict): + error_message = error_data.get("message", error_message) + except: + error_message = response.text or error_message + + error = map_http_error(response.status_code, error_message) + self.circuit_breaker.record_failure() + raise error + + # Record success + self.circuit_breaker.record_success() + + self.logger.debug( + "HTTP request successful", + method=method, + url=url, + status_code=response.status_code, + service=self.name + ) + + return response + + except httpx.TimeoutException as e: + self.circuit_breaker.record_failure() + raise PortainerTimeoutError(f"Request timeout: {str(e)}") + except httpx.NetworkError as e: + self.circuit_breaker.record_failure() + raise PortainerNetworkError(f"Network error: {str(e)}") + except PortainerError: + # Re-raise Portainer errors as-is + raise + except Exception as e: + self.circuit_breaker.record_failure() + raise PortainerError(f"Unexpected error: {str(e)}") + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=10), + retry=retry_if_exception_type(( + PortainerNetworkError, + PortainerTimeoutError, + httpx.TimeoutException, + httpx.NetworkError, + )), + before_sleep=before_sleep_log(logger, "WARNING"), + ) + async def _make_request( + self, + method: str, + endpoint: str, + data: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> httpx.Response: + """Make HTTP request with retry logic.""" + return await self._execute_request(method, endpoint, data, params, headers) + + async def get( + self, + endpoint: str, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> httpx.Response: + """Make GET request.""" + return await self._make_request("GET", endpoint, params=params, headers=headers) + + async def post( + self, + endpoint: str, + data: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> httpx.Response: + """Make POST request.""" + return await self._make_request("POST", endpoint, data=data, params=params, headers=headers) + + async def put( + self, + endpoint: str, + data: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> httpx.Response: + """Make PUT request.""" + return await self._make_request("PUT", endpoint, data=data, params=params, headers=headers) + + async def delete( + self, + endpoint: str, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> httpx.Response: + """Make DELETE request.""" + return await self._make_request("DELETE", endpoint, params=params, headers=headers) + + async def _refresh_token(self) -> bool: + """Refresh authentication token if needed.""" + # TODO: Implement token refresh logic + # This will be implemented in the auth service + self.logger.warning("Token refresh not implemented yet") + return False + + @abstractmethod + async def health_check(self) -> bool: + """Check if the service is healthy.""" + pass \ No newline at end of file diff --git a/src/portainer_core/services/settings.py b/src/portainer_core/services/settings.py new file mode 100644 index 0000000..39c5959 --- /dev/null +++ b/src/portainer_core/services/settings.py @@ -0,0 +1,251 @@ +""" +Settings management service for Portainer Core MCP Server. + +This module provides settings management functionality for Portainer Business Edition +configuration and system settings. +""" + +from typing import Dict, Any, Optional + +from ..services.base import BaseService +from ..models.settings import SettingsResponse, UpdateSettingsRequest +from ..utils.errors import ( + PortainerValidationError, + PortainerAuthorizationError, +) +from ..utils.logging import get_logger + + +class SettingsService(BaseService): + """Service for settings management operations.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.logger = get_logger(__name__) + + async def get_settings(self) -> SettingsResponse: + """ + Get current Portainer settings. + + Returns: + SettingsResponse object with current settings + """ + try: + response = await self._make_request( + method="GET", + endpoint="/settings" + ) + + return SettingsResponse(**response) + + except Exception as e: + self.logger.error("Failed to get settings", error=str(e)) + raise PortainerValidationError(f"Failed to get settings: {str(e)}") + + async def get_public_settings(self) -> Dict[str, Any]: + """ + Get public settings (accessible without authentication). + + Returns: + Dictionary of public settings + """ + try: + response = await self._make_request( + method="GET", + endpoint="/settings/public", + require_auth=False + ) + + return response + + except Exception as e: + self.logger.error("Failed to get public settings", error=str(e)) + raise PortainerValidationError(f"Failed to get public settings: {str(e)}") + + async def update_settings(self, settings_data: UpdateSettingsRequest) -> SettingsResponse: + """ + Update Portainer settings. + + Args: + settings_data: Settings update data + + Returns: + SettingsResponse object with updated settings + """ + try: + response = await self._make_request( + method="PUT", + endpoint="/settings", + json_data=settings_data.model_dump(exclude_unset=True) + ) + + self.logger.info("Settings updated successfully") + return SettingsResponse(**response) + + except Exception as e: + self.logger.error("Failed to update settings", error=str(e)) + raise PortainerAuthorizationError(f"Failed to update settings: {str(e)}") + + async def get_ldap_settings(self) -> Dict[str, Any]: + """ + Get LDAP authentication settings. + + Returns: + Dictionary of LDAP settings + """ + try: + response = await self._make_request( + method="GET", + endpoint="/settings/authentication/ldap" + ) + + return response + + except Exception as e: + self.logger.error("Failed to get LDAP settings", error=str(e)) + raise PortainerValidationError(f"Failed to get LDAP settings: {str(e)}") + + async def update_ldap_settings(self, ldap_data: Dict[str, Any]) -> bool: + """ + Update LDAP authentication settings. + + Args: + ldap_data: LDAP configuration data + + Returns: + True if update successful + """ + try: + await self._make_request( + method="PUT", + endpoint="/settings/authentication/ldap", + json_data=ldap_data + ) + + self.logger.info("LDAP settings updated successfully") + return True + + except Exception as e: + self.logger.error("Failed to update LDAP settings", error=str(e)) + raise PortainerAuthorizationError(f"Failed to update LDAP settings: {str(e)}") + + async def test_ldap_connectivity(self) -> Dict[str, Any]: + """ + Test LDAP server connectivity. + + Returns: + Dictionary with connectivity test results + """ + try: + response = await self._make_request( + method="POST", + endpoint="/settings/authentication/ldap/test" + ) + + return response + + except Exception as e: + self.logger.error("LDAP connectivity test failed", error=str(e)) + raise PortainerValidationError(f"LDAP connectivity test failed: {str(e)}") + + async def get_edge_settings(self) -> Dict[str, Any]: + """ + Get Edge computing settings. + + Returns: + Dictionary of Edge settings + """ + try: + response = await self._make_request( + method="GET", + endpoint="/settings/edge" + ) + + return response + + except Exception as e: + self.logger.error("Failed to get Edge settings", error=str(e)) + raise PortainerValidationError(f"Failed to get Edge settings: {str(e)}") + + async def update_edge_settings(self, edge_data: Dict[str, Any]) -> bool: + """ + Update Edge computing settings. + + Args: + edge_data: Edge configuration data + + Returns: + True if update successful + """ + try: + await self._make_request( + method="PUT", + endpoint="/settings/edge", + json_data=edge_data + ) + + self.logger.info("Edge settings updated successfully") + return True + + except Exception as e: + self.logger.error("Failed to update Edge settings", error=str(e)) + raise PortainerAuthorizationError(f"Failed to update Edge settings: {str(e)}") + + async def get_ssl_settings(self) -> Dict[str, Any]: + """ + Get SSL/TLS settings. + + Returns: + Dictionary of SSL settings + """ + try: + response = await self._make_request( + method="GET", + endpoint="/settings/ssl" + ) + + return response + + except Exception as e: + self.logger.error("Failed to get SSL settings", error=str(e)) + raise PortainerValidationError(f"Failed to get SSL settings: {str(e)}") + + async def update_ssl_settings(self, ssl_data: Dict[str, Any]) -> bool: + """ + Update SSL/TLS settings. + + Args: + ssl_data: SSL configuration data + + Returns: + True if update successful + """ + try: + await self._make_request( + method="PUT", + endpoint="/settings/ssl", + json_data=ssl_data + ) + + self.logger.info("SSL settings updated successfully") + return True + + except Exception as e: + self.logger.error("Failed to update SSL settings", error=str(e)) + raise PortainerAuthorizationError(f"Failed to update SSL settings: {str(e)}") + + async def health_check(self) -> bool: + """ + Check if the settings service is healthy. + + Returns: + True if service is healthy + """ + try: + # Try to get public settings (no auth required) + await self.get_public_settings() + return True + + except Exception as e: + self.logger.error("Settings service health check failed", error=str(e)) + return False \ No newline at end of file diff --git a/src/portainer_core/services/users.py b/src/portainer_core/services/users.py new file mode 100644 index 0000000..3012447 --- /dev/null +++ b/src/portainer_core/services/users.py @@ -0,0 +1,270 @@ +""" +User management service for Portainer Core MCP Server. + +This module provides user management functionality including creating, updating, +and managing users in Portainer Business Edition. +""" + +from typing import Dict, List, Any, Optional + +from ..services.base import BaseService +from ..models.users import ( + CreateUserRequest, + UpdateUserRequest, + UserResponse, + ChangePasswordRequest +) +from ..utils.errors import ( + PortainerValidationError, + PortainerNotFoundError, + PortainerAuthorizationError, +) +from ..utils.logging import get_logger + + +class UserService(BaseService): + """Service for user management operations.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.logger = get_logger(__name__) + + async def list_users(self) -> List[UserResponse]: + """ + List all users in the Portainer instance. + + Returns: + List of UserResponse objects + """ + try: + response = await self._make_request( + method="GET", + endpoint="/users" + ) + + return [UserResponse(**user) for user in response] + + except Exception as e: + self.logger.error("Failed to list users", error=str(e)) + raise PortainerValidationError(f"Failed to list users: {str(e)}") + + async def get_user(self, user_id: int) -> UserResponse: + """ + Get details of a specific user. + + Args: + user_id: ID of the user to retrieve + + Returns: + UserResponse object + + Raises: + PortainerNotFoundError: If user doesn't exist + """ + try: + response = await self._make_request( + method="GET", + endpoint=f"/users/{user_id}" + ) + + return UserResponse(**response) + + except Exception as e: + self.logger.error("Failed to get user", user_id=user_id, error=str(e)) + if "404" in str(e): + raise PortainerNotFoundError(f"User {user_id} not found") + raise PortainerValidationError(f"Failed to get user: {str(e)}") + + async def create_user(self, user_data: CreateUserRequest) -> UserResponse: + """ + Create a new user. + + Args: + user_data: User creation data + + Returns: + UserResponse object for the created user + """ + try: + response = await self._make_request( + method="POST", + endpoint="/users", + json_data=user_data.model_dump() + ) + + self.logger.info("User created successfully", username=user_data.username) + return UserResponse(**response) + + except Exception as e: + self.logger.error("Failed to create user", username=user_data.username, error=str(e)) + raise PortainerValidationError(f"Failed to create user: {str(e)}") + + async def update_user(self, user_id: int, user_data: UpdateUserRequest) -> UserResponse: + """ + Update an existing user. + + Args: + user_id: ID of the user to update + user_data: User update data + + Returns: + UserResponse object for the updated user + """ + try: + response = await self._make_request( + method="PUT", + endpoint=f"/users/{user_id}", + json_data=user_data.model_dump(exclude_unset=True) + ) + + self.logger.info("User updated successfully", user_id=user_id) + return UserResponse(**response) + + except Exception as e: + self.logger.error("Failed to update user", user_id=user_id, error=str(e)) + if "404" in str(e): + raise PortainerNotFoundError(f"User {user_id} not found") + raise PortainerValidationError(f"Failed to update user: {str(e)}") + + async def delete_user(self, user_id: int) -> bool: + """ + Delete a user. + + Args: + user_id: ID of the user to delete + + Returns: + True if deletion successful + """ + try: + await self._make_request( + method="DELETE", + endpoint=f"/users/{user_id}" + ) + + self.logger.info("User deleted successfully", user_id=user_id) + return True + + except Exception as e: + self.logger.error("Failed to delete user", user_id=user_id, error=str(e)) + if "404" in str(e): + raise PortainerNotFoundError(f"User {user_id} not found") + raise PortainerValidationError(f"Failed to delete user: {str(e)}") + + async def change_password(self, user_id: int, password_data: ChangePasswordRequest) -> bool: + """ + Change a user's password. + + Args: + user_id: ID of the user + password_data: Password change data + + Returns: + True if password change successful + """ + try: + await self._make_request( + method="PUT", + endpoint=f"/users/{user_id}/passwd", + json_data=password_data.model_dump() + ) + + self.logger.info("Password changed successfully", user_id=user_id) + return True + + except Exception as e: + self.logger.error("Failed to change password", user_id=user_id, error=str(e)) + if "404" in str(e): + raise PortainerNotFoundError(f"User {user_id} not found") + raise PortainerAuthorizationError(f"Failed to change password: {str(e)}") + + async def get_user_memberships(self, user_id: int) -> List[Dict[str, Any]]: + """ + Get team memberships for a user. + + Args: + user_id: ID of the user + + Returns: + List of team membership data + """ + try: + response = await self._make_request( + method="GET", + endpoint=f"/users/{user_id}/memberships" + ) + + return response + + except Exception as e: + self.logger.error("Failed to get user memberships", user_id=user_id, error=str(e)) + if "404" in str(e): + raise PortainerNotFoundError(f"User {user_id} not found") + raise PortainerValidationError(f"Failed to get user memberships: {str(e)}") + + async def check_admin_user_exists(self) -> bool: + """ + Check if an admin user exists in the system. + + Returns: + True if admin user exists + """ + try: + response = await self._make_request( + method="GET", + endpoint="/users/admin/check", + require_auth=False + ) + + return response.get("exists", False) + + except Exception as e: + self.logger.error("Failed to check admin user", error=str(e)) + return False + + async def initialize_admin_user(self, username: str, password: str) -> UserResponse: + """ + Initialize the admin user (first-time setup). + + Args: + username: Admin username + password: Admin password + + Returns: + UserResponse for the created admin user + """ + try: + data = { + "Username": username, + "Password": password + } + + response = await self._make_request( + method="POST", + endpoint="/users/admin/init", + json_data=data, + require_auth=False + ) + + self.logger.info("Admin user initialized", username=username) + return UserResponse(**response) + + except Exception as e: + self.logger.error("Failed to initialize admin user", username=username, error=str(e)) + raise PortainerValidationError(f"Failed to initialize admin user: {str(e)}") + + async def health_check(self) -> bool: + """ + Check if the user service is healthy. + + Returns: + True if service is healthy + """ + try: + # Try to list users (requires authentication) + await self.list_users() + return True + + except Exception as e: + self.logger.error("User service health check failed", error=str(e)) + return False \ No newline at end of file diff --git a/src/portainer_core/utils/__init__.py b/src/portainer_core/utils/__init__.py new file mode 100644 index 0000000..fdbf972 --- /dev/null +++ b/src/portainer_core/utils/__init__.py @@ -0,0 +1,5 @@ +""" +Utility modules for Portainer Core MCP Server. + +This module contains utility functions and helper classes. +""" \ No newline at end of file diff --git a/src/portainer_core/utils/__pycache__/__init__.cpython-312.pyc b/src/portainer_core/utils/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4eb41dc1d1e953fd46c775bd16a0f78728c32e74 GIT binary patch literal 295 zcmYLFF;2uV5R8uqPL}QuZm%Hzz=f5 z#2zVi)&q$a3B+By7Y{(MKn>@Qy^RZ5X!L~_#khzZMz52_jC9_J9$d&8+I2BPZ20|_ z9j}x!sFixJP9@UMt?Y3C*%@%z%FieX0!A6m5v~UvWF-BMD4m(ISVpOo^)@Nhgz326 a{oQawmsz=79Lqz#E*N9qtD4nR=k*u%oLM9Q literal 0 HcmV?d00001 diff --git a/src/portainer_core/utils/__pycache__/errors.cpython-312.pyc b/src/portainer_core/utils/__pycache__/errors.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ea68d330019076bdbabbb75097b83243c6198c81 GIT binary patch literal 8264 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 Y*&V#(4+n*&l~A6*WmVb53#a~n0S=A+UjP6A literal 0 HcmV?d00001 diff --git a/src/portainer_core/utils/__pycache__/logging.cpython-312.pyc b/src/portainer_core/utils/__pycache__/logging.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..61961f8e2f25c1a2c3dd7314fd60d134f6c38fc1 GIT binary patch literal 7416 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 literal 0 HcmV?d00001 diff --git a/src/portainer_core/utils/__pycache__/tokens.cpython-312.pyc b/src/portainer_core/utils/__pycache__/tokens.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c92e5cc72f56e6d08bef7b1cd35a2d8567db7b73 GIT binary patch literal 7519 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 str: + if self.status_code: + return f"[{self.status_code}] {self.message}" + return self.message + + +class PortainerAuthenticationError(PortainerError): + """Authentication-related errors.""" + + def __init__(self, message: str = "Authentication failed", **kwargs): + super().__init__(message, status_code=401, **kwargs) + + +class PortainerAuthorizationError(PortainerError): + """Authorization-related errors.""" + + def __init__(self, message: str = "Insufficient permissions", **kwargs): + super().__init__(message, status_code=403, **kwargs) + + +class PortainerNotFoundError(PortainerError): + """Resource not found errors.""" + + def __init__(self, message: str = "Resource not found", **kwargs): + super().__init__(message, status_code=404, **kwargs) + + +class PortainerValidationError(PortainerError): + """Request validation errors.""" + + def __init__(self, message: str = "Invalid request data", **kwargs): + super().__init__(message, status_code=400, **kwargs) + + +class PortainerConflictError(PortainerError): + """Resource conflict errors.""" + + def __init__(self, message: str = "Resource conflict", **kwargs): + super().__init__(message, status_code=409, **kwargs) + + +class PortainerServerError(PortainerError): + """Server-side errors.""" + + def __init__(self, message: str = "Internal server error", **kwargs): + super().__init__(message, status_code=500, **kwargs) + + +class PortainerNetworkError(PortainerError): + """Network-related errors.""" + + def __init__(self, message: str = "Network error", **kwargs): + super().__init__(message, **kwargs) + + +class PortainerTimeoutError(PortainerError): + """Request timeout errors.""" + + def __init__(self, message: str = "Request timeout", **kwargs): + super().__init__(message, **kwargs) + + +class PortainerRateLimitError(PortainerError): + """Rate limiting errors.""" + + def __init__(self, message: str = "Rate limit exceeded", **kwargs): + super().__init__(message, status_code=429, **kwargs) + + +class PortainerCircuitBreakerError(PortainerError): + """Circuit breaker errors.""" + + def __init__(self, message: str = "Circuit breaker is open", **kwargs): + super().__init__(message, **kwargs) + + +class PortainerTokenExpiredError(PortainerAuthenticationError): + """Token expiration errors.""" + + def __init__(self, message: str = "Token has expired", **kwargs): + super().__init__(message, **kwargs) + + +class PortainerConfigurationError(PortainerError): + """Configuration-related errors.""" + + def __init__(self, message: str = "Configuration error", **kwargs): + super().__init__(message, **kwargs) + + +def map_http_error(status_code: int, message: str, details: Optional[Dict[str, Any]] = None) -> PortainerError: + """Map HTTP status codes to appropriate exception types.""" + error_map = { + 400: PortainerValidationError, + 401: PortainerAuthenticationError, + 403: PortainerAuthorizationError, + 404: PortainerNotFoundError, + 409: PortainerConflictError, + 429: PortainerRateLimitError, + 500: PortainerServerError, + 502: PortainerServerError, + 503: PortainerServerError, + 504: PortainerTimeoutError, + } + + error_class = error_map.get(status_code, PortainerError) + + # For known error classes, pass only message and details to avoid status_code conflicts + if error_class in error_map.values(): + return error_class(message, details=details) + else: + # For generic PortainerError, include status_code + return error_class(message, status_code=status_code, details=details) + + +def is_retryable_error(error: Exception) -> bool: + """Check if an error is retryable.""" + if isinstance(error, PortainerError): + # Don't retry authentication, authorization, validation, or not found errors + if isinstance(error, ( + PortainerAuthenticationError, + PortainerAuthorizationError, + PortainerValidationError, + PortainerNotFoundError, + PortainerConflictError + )): + return False + + # Don't retry client errors (4xx) except for rate limiting + if error.status_code and 400 <= error.status_code < 500: + return isinstance(error, PortainerRateLimitError) + + # Retry server errors (5xx) and network errors + return True + + # Retry network-related exceptions + if isinstance(error, (ConnectionError, TimeoutError)): + return True + + return False + + +def should_refresh_token(error: Exception) -> bool: + """Check if an error indicates token refresh is needed.""" + if isinstance(error, PortainerAuthenticationError): + return True + + if isinstance(error, PortainerError) and error.status_code == 401: + return True + + return False \ No newline at end of file diff --git a/src/portainer_core/utils/logging.py b/src/portainer_core/utils/logging.py new file mode 100644 index 0000000..b0545ab --- /dev/null +++ b/src/portainer_core/utils/logging.py @@ -0,0 +1,164 @@ +""" +Logging utilities for Portainer Core MCP Server. + +This module provides structured logging configuration and utilities. +""" + +import logging +import sys +import uuid +from contextvars import ContextVar +from typing import Any, Dict, Optional + +import structlog +from structlog.typing import EventDict + +from ..config import get_global_config + +# Context variable for correlation IDs +correlation_id_var: ContextVar[str] = ContextVar("correlation_id", default="") + + +def add_correlation_id(logger: Any, method_name: str, event_dict: EventDict) -> EventDict: + """Add correlation ID to log entries.""" + correlation_id = correlation_id_var.get() + if correlation_id: + event_dict["correlation_id"] = correlation_id + return event_dict + + +def add_service_info(logger: Any, method_name: str, event_dict: EventDict) -> EventDict: + """Add service information to log entries.""" + event_dict["service"] = "portainer-core-mcp" + event_dict["version"] = "0.1.0" + return event_dict + + +def mask_sensitive_data(logger: Any, method_name: str, event_dict: EventDict) -> EventDict: + """Mask sensitive data in log entries.""" + sensitive_keys = { + "password", "token", "api_key", "secret", "auth", "authorization", + "x-api-key", "bearer", "jwt", "credential", "credentials" + } + + def mask_dict(data: Dict[str, Any]) -> Dict[str, Any]: + """Recursively mask sensitive data in dictionaries.""" + masked = {} + for key, value in data.items(): + key_lower = key.lower() + if any(sensitive in key_lower for sensitive in sensitive_keys): + masked[key] = "***MASKED***" + elif isinstance(value, dict): + masked[key] = mask_dict(value) + elif isinstance(value, list): + masked[key] = [ + mask_dict(item) if isinstance(item, dict) else item + for item in value + ] + else: + masked[key] = value + return masked + + # Mask sensitive data in the event dict + for key, value in event_dict.items(): + if isinstance(value, dict): + event_dict[key] = mask_dict(value) + elif isinstance(value, str): + key_lower = key.lower() + if any(sensitive in key_lower for sensitive in sensitive_keys): + event_dict[key] = "***MASKED***" + + return event_dict + + +def configure_logging() -> None: + """Configure structured logging.""" + try: + config = get_global_config() + log_level = config.log_level + log_format = config.log_format + except Exception: + # Fallback to defaults if config is not available + log_level = "INFO" + log_format = "json" + + processors = [ + structlog.contextvars.merge_contextvars, + add_correlation_id, + add_service_info, + mask_sensitive_data, + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + ] + + if log_format == "json": + processors.append(structlog.processors.JSONRenderer()) + else: + processors.append(structlog.dev.ConsoleRenderer()) + + structlog.configure( + processors=processors, + wrapper_class=structlog.make_filtering_bound_logger( + getattr(logging, log_level) + ), + logger_factory=structlog.PrintLoggerFactory(), + cache_logger_on_first_use=True, + ) + + # Configure standard library logging + logging.basicConfig( + format="%(message)s", + stream=sys.stdout, + level=getattr(logging, log_level), + ) + + +def get_logger(name: Optional[str] = None) -> structlog.BoundLogger: + """Get a configured logger instance.""" + return structlog.get_logger(name) + + +def set_correlation_id(correlation_id: Optional[str] = None) -> str: + """Set correlation ID for request tracing.""" + if correlation_id is None: + correlation_id = str(uuid.uuid4()) + + correlation_id_var.set(correlation_id) + return correlation_id + + +def get_correlation_id() -> str: + """Get current correlation ID.""" + return correlation_id_var.get() + + +def clear_correlation_id() -> None: + """Clear correlation ID.""" + correlation_id_var.set("") + + +class LogContext: + """Context manager for setting correlation ID.""" + + def __init__(self, correlation_id: Optional[str] = None): + self.correlation_id = correlation_id + self.previous_id = None + + def __enter__(self) -> str: + self.previous_id = get_correlation_id() + return set_correlation_id(self.correlation_id) + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + if self.previous_id: + correlation_id_var.set(self.previous_id) + else: + clear_correlation_id() + + +# Initialize logging configuration +configure_logging() + +# Create module logger +logger = get_logger(__name__) \ No newline at end of file diff --git a/src/portainer_core/utils/tokens.py b/src/portainer_core/utils/tokens.py new file mode 100644 index 0000000..2a31401 --- /dev/null +++ b/src/portainer_core/utils/tokens.py @@ -0,0 +1,231 @@ +""" +JWT token utilities for Portainer Core MCP Server. + +This module provides utilities for handling JWT tokens including validation, +expiration checking, and token management. +""" + +import json +import base64 +from datetime import datetime, timezone +from typing import Dict, Any, Optional + +from ..utils.logging import get_logger + + +logger = get_logger(__name__) + + +def decode_jwt_token(token: str) -> Optional[Dict[str, Any]]: + """ + Decode a JWT token without verification. + + Note: This function only decodes the token payload for inspection. + It does not verify the token signature. + + Args: + token: JWT token to decode + + Returns: + Decoded token payload or None if invalid + """ + try: + # Remove Bearer prefix if present + if token.startswith("Bearer "): + token = token[7:] + + # JWT tokens have 3 parts separated by dots + parts = token.split(".") + if len(parts) != 3: + logger.warning("Invalid JWT format: token does not have 3 parts") + return None + + # Decode the payload (second part) + payload = parts[1] + + # Add padding if needed (JWT base64 encoding may not include padding) + missing_padding = len(payload) % 4 + if missing_padding: + payload += "=" * (4 - missing_padding) + + # Decode from base64 + decoded_bytes = base64.urlsafe_b64decode(payload) + decoded_payload = json.loads(decoded_bytes.decode("utf-8")) + + return decoded_payload + + except Exception as e: + logger.error("Failed to decode JWT token", error=str(e)) + return None + + +def is_token_expired(token: str) -> bool: + """ + Check if a JWT token is expired. + + Args: + token: JWT token to check + + Returns: + True if token is expired or invalid + """ + try: + payload = decode_jwt_token(token) + if not payload: + return True + + # Check expiration time (exp claim) + exp = payload.get("exp") + if not exp: + # No expiration claim, assume token is valid + logger.warning("JWT token has no expiration claim") + return False + + # Convert exp (timestamp) to datetime + exp_datetime = datetime.fromtimestamp(exp, tz=timezone.utc) + current_datetime = datetime.now(tz=timezone.utc) + + is_expired = current_datetime >= exp_datetime + + if is_expired: + logger.info("JWT token is expired", + exp=exp_datetime.isoformat(), + now=current_datetime.isoformat()) + + return is_expired + + except Exception as e: + logger.error("Failed to check token expiration", error=str(e)) + return True + + +def get_token_claims(token: str) -> Dict[str, Any]: + """ + Get all claims from a JWT token. + + Args: + token: JWT token to parse + + Returns: + Dictionary of token claims + """ + payload = decode_jwt_token(token) + return payload or {} + + +def get_token_expiration(token: str) -> Optional[datetime]: + """ + Get the expiration time of a JWT token. + + Args: + token: JWT token to check + + Returns: + Expiration datetime or None if not available + """ + try: + payload = decode_jwt_token(token) + if not payload: + return None + + exp = payload.get("exp") + if not exp: + return None + + return datetime.fromtimestamp(exp, tz=timezone.utc) + + except Exception as e: + logger.error("Failed to get token expiration", error=str(e)) + return None + + +def get_token_subject(token: str) -> Optional[str]: + """ + Get the subject (user ID) from a JWT token. + + Args: + token: JWT token to parse + + Returns: + Subject claim value or None + """ + payload = decode_jwt_token(token) + if payload: + return payload.get("sub") + return None + + +def get_token_issuer(token: str) -> Optional[str]: + """ + Get the issuer from a JWT token. + + Args: + token: JWT token to parse + + Returns: + Issuer claim value or None + """ + payload = decode_jwt_token(token) + if payload: + return payload.get("iss") + return None + + +def is_token_valid_for_duration(token: str, min_duration_seconds: int = 300) -> bool: + """ + Check if a token is valid for at least the specified duration. + + Args: + token: JWT token to check + min_duration_seconds: Minimum remaining validity in seconds (default: 5 minutes) + + Returns: + True if token is valid for at least the specified duration + """ + try: + exp_time = get_token_expiration(token) + if not exp_time: + # No expiration time, assume it's valid + return True + + current_time = datetime.now(tz=timezone.utc) + remaining_seconds = (exp_time - current_time).total_seconds() + + return remaining_seconds >= min_duration_seconds + + except Exception as e: + logger.error("Failed to check token validity duration", error=str(e)) + return False + + +def format_token_info(token: str) -> str: + """ + Format token information for logging/debugging. + + Args: + token: JWT token to format + + Returns: + Formatted string with token information + """ + try: + payload = decode_jwt_token(token) + if not payload: + return "Invalid token" + + exp_time = get_token_expiration(token) + subject = get_token_subject(token) + issuer = get_token_issuer(token) + + info_parts = [] + if subject: + info_parts.append(f"sub: {subject}") + if issuer: + info_parts.append(f"iss: {issuer}") + if exp_time: + info_parts.append(f"exp: {exp_time.isoformat()}") + + return " | ".join(info_parts) if info_parts else "No standard claims found" + + except Exception as e: + return f"Error formatting token info: {str(e)}" \ No newline at end of file diff --git a/src/portainer_core_mcp.egg-info/PKG-INFO b/src/portainer_core_mcp.egg-info/PKG-INFO new file mode 100644 index 0000000..de5c578 --- /dev/null +++ b/src/portainer_core_mcp.egg-info/PKG-INFO @@ -0,0 +1,175 @@ +Metadata-Version: 2.4 +Name: portainer-core-mcp +Version: 0.1.0 +Summary: Portainer Core MCP Server - Authentication and User Management +Author-email: Your Name +License: MIT +Project-URL: Homepage, https://github.com/yourusername/portainer-core-mcp +Project-URL: Documentation, https://github.com/yourusername/portainer-core-mcp#readme +Project-URL: Repository, https://github.com/yourusername/portainer-core-mcp +Project-URL: Issues, https://github.com/yourusername/portainer-core-mcp/issues +Classifier: Development Status :: 3 - Alpha +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: MIT License +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Requires-Python: >=3.8 +Description-Content-Type: text/markdown +Requires-Dist: mcp>=1.0.0 +Requires-Dist: httpx>=0.25.0 +Requires-Dist: pydantic>=2.0.0 +Requires-Dist: pydantic-settings>=2.0.0 +Requires-Dist: structlog>=23.0.0 +Requires-Dist: PyJWT>=2.8.0 +Requires-Dist: python-dotenv>=1.0.0 +Requires-Dist: tenacity>=8.0.0 +Provides-Extra: dev +Requires-Dist: pytest>=7.0.0; extra == "dev" +Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev" +Requires-Dist: pytest-cov>=4.0.0; extra == "dev" +Requires-Dist: pytest-mock>=3.10.0; extra == "dev" +Requires-Dist: httpx-mock>=0.10.0; extra == "dev" +Requires-Dist: black>=23.0.0; extra == "dev" +Requires-Dist: isort>=5.12.0; extra == "dev" +Requires-Dist: flake8>=6.0.0; extra == "dev" +Requires-Dist: mypy>=1.0.0; extra == "dev" +Requires-Dist: pre-commit>=3.0.0; extra == "dev" + +# Portainer Core MCP Server + +A Model Context Protocol (MCP) server for Portainer Business Edition authentication and user management. + +## Features + +- **Authentication & Session Management**: JWT token handling and user authentication +- **User Management**: Create, read, update, and delete users +- **Settings Management**: Retrieve and update Portainer settings +- **Secure Token Handling**: Automatic token refresh and secure storage +- **Error Handling**: Comprehensive error handling with retry logic +- **Circuit Breaker**: Fault tolerance for external API calls + +## Installation + +```bash +pip install portainer-core-mcp +``` + +## Configuration + +Set the following environment variables: + +```bash +PORTAINER_URL=https://your-portainer-instance.com +PORTAINER_API_KEY=your-api-token # Optional, for API key authentication +PORTAINER_USERNAME=admin # For username/password authentication +PORTAINER_PASSWORD=your-password # For username/password authentication +``` + +## Usage + +### As MCP Server + +```bash +portainer-core-mcp +``` + +### Programmatic Usage + +```python +from portainer_core.server import PortainerCoreMCPServer + +server = PortainerCoreMCPServer() +# Use server instance +``` + +## Available MCP Tools + +- `authenticate` - Login with username/password +- `generate_token` - Generate API token +- `get_current_user` - Get authenticated user info +- `list_users` - List all users +- `create_user` - Create new user +- `update_user` - Update user details +- `delete_user` - Delete user +- `get_settings` - Get Portainer settings +- `update_settings` - Update settings + +## Development + +### Setup + +```bash +# Clone the repository +git clone https://github.com/yourusername/portainer-core-mcp.git +cd portainer-core-mcp + +# Install development dependencies +pip install -e ".[dev]" + +# Install pre-commit hooks +pre-commit install +``` + +### Testing + +```bash +# Run all tests +pytest + +# Run with coverage +pytest --cov=src/portainer_core --cov-report=html + +# Run only unit tests +pytest -m unit + +# Run only integration tests +pytest -m integration +``` + +### Code Quality + +```bash +# Format code +black src tests +isort src tests + +# Lint code +flake8 src tests + +# Type checking +mypy src +``` + +## Architecture + +The server follows a layered architecture: + +- **MCP Server Layer**: Handles MCP protocol communication +- **Service Layer**: Abstracts Portainer API interactions +- **Models Layer**: Defines data structures and validation +- **Utils Layer**: Provides utility functions and helpers + +## Security + +- All API communications use HTTPS +- JWT tokens are handled securely and never logged +- Input validation on all parameters +- Rate limiting to prevent abuse +- Circuit breaker pattern for fault tolerance + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests for new functionality +5. Ensure all tests pass +6. Submit a pull request + +## License + +MIT License - see LICENSE file for details. diff --git a/src/portainer_core_mcp.egg-info/SOURCES.txt b/src/portainer_core_mcp.egg-info/SOURCES.txt new file mode 100644 index 0000000..dcad2f4 --- /dev/null +++ b/src/portainer_core_mcp.egg-info/SOURCES.txt @@ -0,0 +1,18 @@ +README.md +pyproject.toml +src/portainer_core/__init__.py +src/portainer_core/config.py +src/portainer_core/server.py +src/portainer_core/models/__init__.py +src/portainer_core/services/__init__.py +src/portainer_core/services/base.py +src/portainer_core/utils/__init__.py +src/portainer_core/utils/errors.py +src/portainer_core/utils/logging.py +src/portainer_core_mcp.egg-info/PKG-INFO +src/portainer_core_mcp.egg-info/SOURCES.txt +src/portainer_core_mcp.egg-info/dependency_links.txt +src/portainer_core_mcp.egg-info/entry_points.txt +src/portainer_core_mcp.egg-info/requires.txt +src/portainer_core_mcp.egg-info/top_level.txt +tests/test_basic.py \ No newline at end of file diff --git a/src/portainer_core_mcp.egg-info/dependency_links.txt b/src/portainer_core_mcp.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/portainer_core_mcp.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/portainer_core_mcp.egg-info/entry_points.txt b/src/portainer_core_mcp.egg-info/entry_points.txt new file mode 100644 index 0000000..1d8eacc --- /dev/null +++ b/src/portainer_core_mcp.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +portainer-core-mcp = portainer_core.server:main diff --git a/src/portainer_core_mcp.egg-info/requires.txt b/src/portainer_core_mcp.egg-info/requires.txt new file mode 100644 index 0000000..312cdcb --- /dev/null +++ b/src/portainer_core_mcp.egg-info/requires.txt @@ -0,0 +1,20 @@ +mcp>=1.0.0 +httpx>=0.25.0 +pydantic>=2.0.0 +pydantic-settings>=2.0.0 +structlog>=23.0.0 +PyJWT>=2.8.0 +python-dotenv>=1.0.0 +tenacity>=8.0.0 + +[dev] +pytest>=7.0.0 +pytest-asyncio>=0.21.0 +pytest-cov>=4.0.0 +pytest-mock>=3.10.0 +httpx-mock>=0.10.0 +black>=23.0.0 +isort>=5.12.0 +flake8>=6.0.0 +mypy>=1.0.0 +pre-commit>=3.0.0 diff --git a/src/portainer_core_mcp.egg-info/top_level.txt b/src/portainer_core_mcp.egg-info/top_level.txt new file mode 100644 index 0000000..661bb10 --- /dev/null +++ b/src/portainer_core_mcp.egg-info/top_level.txt @@ -0,0 +1 @@ +portainer_core diff --git a/tests/__pycache__/test_basic.cpython-312-pytest-7.4.3.pyc b/tests/__pycache__/test_basic.cpython-312-pytest-7.4.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bdac776d8213468db636393a32de313d75277bd0 GIT binary patch literal 30846 zcmeHQdvF`adA|d2z~M!Lq$Eq0WkZtnfF*&HsFyvFTUrlWu}w1;?ZiyeD}+0Vpa6o| zJIW>kq^j#qls41YopcBb_i(YV)IZT*8kiUmg@Ew1phn&hRQde?tHZ=9dM^t1V)nh& z^j;k9CG2}^=)EM|>#^@WpsiJV&ukY20;On?sXhD!olot-)8hLg`z{J|fw@2?Rhf-F zmD3CPw4v#So<3D3>9H~~a)oJ)qz{*gmVW;5So(xU&T1qZi;Yhf^mM7L&K9+FzC4ZT z^z=zr0Y*6uDGR6O(#E8g)-_{xCY_sBG4ZLw=~!K!B#i=;I>wNOxMFD}$19HKc!N^zvV)QvAyKR3XhNQI#;yslfYI-PN!$z|%~v;#bZmug6(IU+pS?jgx|3 zRqZ}a+$q=c-{PT5DjlCw;0`NZsZ7xnd?4;&bz>mNk@gu&%kGtkansdSV1b!qKKV~}{ z9Z5PmdRDk`*WB3TaDiU|H+t`Y8>tm;+#NU682;lk5MYhiW?{FWi?0HbdIjbqyz^S{ z72#De(^uKyGf$q)6$>hztkbU+jLG!K*zxokZ7zFz?WAGM=uZp|B3{XAujNWJMJ<~z zmnsRovnw}K*aZpa17>pUg%`(1j*lLFQF-ab=gdCmD-@44ljd%^;*jvIoh&+dVm5*N8#O zH~Y8HZR&s!fpa?)~m;tJL3^ISF zP3An&RD@EwqQ10%Og}+{VusX0-Y^4Y-Hd3{XA7h}O>j;(S6MuRI>56n8YUV1u z<86xhh#Atg;wgey#f)P;8JBmvzwC8?8D?KtHTJu|9MmIFN}3(F4-QV2OWI&g)rx?( zrz_<_c%iaBXxDXDDL+H|yH2S>@pg7*&fH9!l66DH88QkE8Qv2r`^KTY8<1(XgKP-S z^e+PpJtzD}vj3YCU!S-T_)hGd*tHD@u5BE=AqBU@{#yw4#D6RcYqnfoz4iRDYl;4P zV%tZFZC4VRg~ZW%m(GR6p?a6jg~XnEH_oexJqw8g^=_P369*OtpcxmjEpH&Xtt0Aw z@43gHe=PWqk4euDNBk9quY$_Z4Mjl>T?lePecx3DiGrc9XZnrYXH_BMnhd+6D#*5~ z5LIJcQWfI&KviJ;NUZR~)dM~|EkmrfjgadhPQqwafS zBPULL{)HEhkUmIE5T;T{PFCa9IwWA>N7HiUi5A+9jja`4y#{@&@ovXbs~@8?KF~&NKkDeD2T{rBsd7j79?AdY(oMzR)J)YJceWk5VL=2<=yvq zmB=p4kwr2H#I16W{aDVnR%(Dt|EWB_JPpE?HH{Dc8-FcgQ|8*Hoi`#OR3t*6NW|{B zBGDbl4!D0I@kG5#=R#t*-i`BWVt663zut}WYGVK51Hh3*WXlaCOedKi6UT;wmxiUW z&ot^JsLWF5ML%%*?sbv^IMqG~y^kfr(M%^XZ0Ciao5JW}xdlO$J_vJZhGl^n9VLh7 z;O+)+(?#$f&$0CTT*NC6ztPX+q4)k=#FCS8CvvhpYoyU|;<&Q1R31`cIgXyPd{N7h zO8O`$_u!jzq@-oeN`vd}WZ9SmQ;`IAkNo6lCfZc&m| zMN%d}hM7kKpwo>8Ws&e$HIQ_s&T- zMcvgM5}YVA zBJGemh#0}@iYeL^t;eE$wz8>7=X0ew2>JCd0`YdY4Ub*hxTC?a)xVbav7=Y}j(s=v z$d%Ma>|IYo?*dEBbu`yt?|K?~*D>f_{2uFF?>#iKGk7r+A4y9Wdu6;%OCy=+#VrxM z-YJdjYwTUN3pfqK?e3*$hb~~Bo(9Mp-HnYdUW9D)n6f1+zk@QiiQUGQv{U^~YJSCg zD|PWY<@-2;9mg%Wc%76jox$MZjkHPYxOiKa>9}~C*=-Pa(!=#cgrT0JohGyl?478j z>1f1JXn+ha7N$?9^7^Z(?(rR@I4YNgT$|v~ zN9Zty;j8c$QRs_+xu3pBWp|TN-8{<(R84;a$dW;R&^yR2>vb0`){AkJ3ywke7B%P| z9yk&T{#{5ql3W?~z!*VT8TLG~V_C!g5jyPA9|~?Djn2a00MZRIo2Q{wY(U%NyYf&| zApjmQI`Bc+-P=M{Mn@SwYKa5XU+-=%E6jwus7}@0CPJf+3JrIGmCa4Wggen^q2u`r z25@UN&i=Mwc&47{&gm5lEOaX*PQwVk^dy7@ z(phWPfZ59^XnJEvoUK*#G>v-4{;1_R*+D{CL)*Xm%$XtNp z7m9gYA!;EfY|amnyYS08SNS$h=T8&_0~IKG@H!HA4j)9dGzZ|BkScXtqt&fzx3IUlpDP(+;H*LZ*;WhhL1nH z7Aaup;_^pFYK0$n$B(%vgu^{Z0UnE~ALG$ybpFiVrgLeU&Yu*z&5M;Ggl@w;cnvO` z;I-}e)9#F(al8}zF&quRqAAfr=0gOBBSxt0Mly_Ke?tJo3cyd-a;P`M#K%SNsL1jw`zwW@&+>`1Go$kl6kYlmE$ zS>B?+czXO)AXmV4_Zhj$F3IeNfR>M3qc(Dlsqro$*F*=%HL%RNS)y1e>6x2<+WE13 zq_d2 zdxwU;#4NVX>`l-O!KNpiP{=PLIf(=n?@^fCJD(|?xOC5?n~5)f7NnIXr=b%5^U@aI zd5K0HyJk)13Z!kM`%h8c911@1Z-H2|)n5)=NgcxZYOsFq&joo~y-VjMd0UH_;qN^& z1Ggj4C3&``09wI1eC!$RwN+elcjxvDw@tvup5fR8mSsH%bzKHab{uV3jdV!{%Z>~N ztu$g_U=cl>w+0mlcRCzo)jaw%gA_Yt^iDU}N86b3}gG=eLw4TkL*`+ZmMxpa(CEHy6@k@`hu>RLT zaAw(gIhDm(rEBtjLEc-x_h*YK<$tM(obE~>l*F2dy9-od0#HLd1f&+I2Bt#L0G^8W40~HmXXFpabCVIM;oEH=q2dH_%a9&|~2_ zsy0N2Nyka)XZEsFg1~YEUB9%pDY{LWwu!V&I*AJll`9AlSPKc_-x5EvGoiRj@-d25 zZ9&FM1o4A_GzkAg@IMUv2~7DJ@Pe?gAX*JpqpmgjPK)`3d;sqdFeEY0&mixjyaiRR zB*auy68Mzhn&jK?#GaUWiEf_MqIA^tsr`2`>IPOuoip&OpRQ5Yw=(Kny!(F}b)cHB zs;osokbY)-e2m%0VXKvy83>BX5>%-GNcF%C1%n|Nf+&0oP+289S~g(Qo7rjATw@#C z;odR4MO9ZGBJlzmtpQsx*aa6B>}s-ZTv756jHN3|td`~8eaAWttHLP^wCwNX0jMG& zNHA)w!T$a@v%fz+N+@`2#Epd?<4Yvi_B`yp%~ke$u-o)4GD&m2V<3jTaI`GC-SsR* zQ#v^dkA>CLywc=zSeH%_A%ZJ_l0%pW3t|Y@wsO01;C_Y!(d5k*d$VBB-9yx7PVM3T zCWn3s3TbT>i?fvtUJTgqGewwF&_3sc@5@hq53Jw%yU$)u4I%P7T)zkBNAlq=VoFxw z(aWh}tnf(v9-JS^BVDX8H+o=9FYP`rVplkUWE@EmNg0WOg1PT4LnMTMs|n)o>?sKFjgfVjnKU=~ABg8Dm7po$*~2UATD@E5qps{t4O zk_=SO=rM)i!JeZ5JA+Y)9exIX$8-VM*-7bRCU8>HaF5n$ZJe!!F9c{9M+g>A$kmXA zbtPvhn(L{4*xp9XyafSQ;l4+~8tzgSt*o{g14|!Zff3!#A!~*VU>+DsZ->ECI9)d8 z#mX9#)39>~`VQd^)s{gu#?2&fGqCvp^>|X1xIxaMyArW9ESkWk?o{|?!c6X+$2?TM zMY-qp_w8j;4PB8;*JZxFUaY!;dz`U~Ro3GxS{;#@OPwFT0-5z^fq?qjxBlD5F86Id zA6}3**6;NEMBdm!o%|n|GMjd6*pLdlDOD{x>DsvM7uq5lyLRY4ckPg?L;4K^r)x`g z*G3y-ce*xOk)Ugxr$ z%ckerFfe`mNcz<5G!3wYjfsr8Y$bxjcUM8>@UWkY9m^4eBB#ey1nfCTz77MJ{5ld8 zG@~4t_TxBjObQmiO!FX#?XG|h6kFfKA{&7rz%f+#JC$PQL%!h9nh5CwGBGCTeiMte z1s(Yf{JIwjt;HU^qM;24@pLpbFrkN(3BCa;_!{50LZg}+gkNKVb6DCtKtKSl+wtD< z%j@=^?^%!^tKaGQiTv325w8u^yK!EYhgyi!*P(N}#i>2aMHuGGK?y6O)WUbJ!7DOR z>TD6?AQ%v^at$|=37=cTVOt>i z8-NqWsgaN9TbcvJY8;#}_ZJye{* zDQ}U=;1YP+Is*b?{V#w}iG6w9E|k}I9(Q&{&Me4V>-YY=ByXjnd^8jBRb#(_P5Bm* z-$vrmU|+|3NM3{g`YIr(!EU@63c?zTSXkP93)EN^96*!hA%%S`UwQ3ErW`Ji{A|H^ z3bdj#8ez*|(s_0>eUjbvz!(3JwRa_)ryMU*33OQINwR0Grrsj(M9B7w*cTpK_K#XG z+6N^8JxH)$aDxZQrA7MfFU;#kJxFo>=qX#$@;k6Z#D<#yVx5%8CC}lcq*`$6w6=}9 zVNYQnEq56;wbhTdHMLVhO~Y;8%GR=BuVS|B0xJOo}!=*^h&+3pN{!(uG~qclO)Q}hjg=&H3J z@7T!h!#qEOyi0I2r}kUwX;K|fS5=c1iivg16Tbx;Hcx7axmad(69HeChBb7#Vj2VR zAOsOCI09^f+tWBXm;;U9PheuclHhoAjWbJ2b^I4T;V0k`$sT_0g%d}Q#A;?Bf0Fzz z)_|=qaQQcKaMuYy86Br<0_MfaXYt{kyI1nLY1r6R%g-9HOn~acVD7^r(6fSbFPTG9 z!8)|w;1>gJW(jQg3LYQBdBdfv7c#v|v}D-X>TDqo3m0*HEcqRLBEGdL(dIJ4tUWqb z!sD*DGLsDz%k(hq+kOW5J$xG48kt9h>fAQWBVWZIPz5KyjD&)~y>O*t2!^APJ6icB zrj;|)pjfCuu{xDP-^bcXwgGK0L!|{a{ImTQK6X7|n{&bsZ9#st-lg-B{Adf~!BYUM{6QJrCCS2!G>peq38BTUgHN`w?@`bf zpoIXq`6#MOfLqHT9@zf_fg1#oGyu1V6KUrM$9Ua3*a*N4+?<-M;cw}DHUPJF`+qpV zP3~GvZ8!$Nt#wW9lmOh^K~jEiLW&!m^3V^sVPF{Lp$**bR2umGk&(tg zz#q9<#E;)h8VsyRgNBgB*1hA?EDctzNCPhN9D}B7jNEr=00B~~E8D2?bBSGu!C&}m zT||SwvQUKGSy6Q$=xg~QUP0tkABUoLA{Nbl0TWUjil~?TK9ZA2(5huYd-5H;(TK>w zX8QD9>l!bN9UYC;j%8Bh5AX>#FgAOL29`-xu_{ge5P$w7B#$E5f~4gdVDiV1#0=pB zGFF_sUHY?^vE59I6okc z8>O@X;;du`hW}iB4z{VVjCU!S{HV+mc9d=!W zzcT5XJlPcaF7$dfXRO96MEGofa=sTvtG@{a0mKHM#uTGa^6ZGw>zVn;pF!#N^k>zJ z{0Tm?!msr4uziG*;A8hgn?bf+zsUaJRFKbno z%d~V&j@fgZ`}zl6ve$s!XF=Xk@6y@Q+4!f(ybqB4ITDYv@ol_^1Ve9ZXXCD$p@_8Q zW`B>Axs{GeJLv;xGTdjb1b?wrQ8MdkOs*;OzqLgXk-1J`yFzE#pSHrk)Jn8hNx^`> zv}NBiBi23EtBznL(m>4QESR7X+hj}S{297ssJYq`SH%8S72Ek9yo~~>BEi^5wi5A8 zyh2AJbBD+A>I+C-M?zotb8uyb@ICN-PQfxs*c7j^WlZ@qY!|&J$zMTgw2|pEK&}Tx zQM?fdiJ`@WAc}7Yznc6PA^NYvBR>!hEePWa!qbc45m7uY-Vo@=;xmDb;`SQ?ki}=j zRpPcA0+7Y*7IEhd0m$_+Q3xf@S3VA|yB6v_ckFA={>rm&u6;B1Nof7`pb&bHHvRtr D1r-=N literal 0 HcmV?d00001 diff --git a/tests/test_basic.py b/tests/test_basic.py new file mode 100644 index 0000000..55623b8 --- /dev/null +++ b/tests/test_basic.py @@ -0,0 +1,184 @@ +""" +Basic tests for Portainer Core MCP Server. + +This module contains basic tests to verify the setup and configuration. +""" + +import pytest +import os +from unittest.mock import patch + +from portainer_core.config import PortainerConfig, get_config +from portainer_core.utils.errors import PortainerError, PortainerAuthenticationError +from portainer_core.utils.logging import get_logger, set_correlation_id + + +class TestConfiguration: + """Test configuration management.""" + + def test_config_validation_with_api_key(self): + """Test configuration validation with API key.""" + with patch.dict(os.environ, { + 'PORTAINER_URL': 'https://test.example.com', + 'PORTAINER_API_KEY': 'test-api-key' + }): + config = PortainerConfig() + config.validate_auth_config() + assert config.portainer_url == 'https://test.example.com' + assert config.portainer_api_key == 'test-api-key' + assert config.use_api_key_auth is True + assert config.use_credentials_auth is False + + def test_config_validation_with_credentials(self): + """Test configuration validation with username/password.""" + with patch.dict(os.environ, { + 'PORTAINER_URL': 'https://test.example.com', + 'PORTAINER_USERNAME': 'admin', + 'PORTAINER_PASSWORD': 'password' + }): + config = PortainerConfig() + config.validate_auth_config() + assert config.portainer_url == 'https://test.example.com' + assert config.portainer_username == 'admin' + assert config.portainer_password == 'password' + assert config.use_api_key_auth is False + assert config.use_credentials_auth is True + + def test_config_validation_no_auth(self): + """Test configuration validation without authentication.""" + with patch.dict(os.environ, { + 'PORTAINER_URL': 'https://test.example.com' + }, clear=True): + config = PortainerConfig() + with pytest.raises(ValueError, match="Either PORTAINER_API_KEY or both"): + config.validate_auth_config() + + def test_invalid_url(self): + """Test invalid URL validation.""" + with patch.dict(os.environ, { + 'PORTAINER_URL': 'invalid-url', + 'PORTAINER_API_KEY': 'test-key' + }): + with pytest.raises(ValueError, match="Invalid URL format"): + PortainerConfig() + + def test_url_trailing_slash_removal(self): + """Test URL trailing slash removal.""" + with patch.dict(os.environ, { + 'PORTAINER_URL': 'https://test.example.com/', + 'PORTAINER_API_KEY': 'test-key' + }): + config = PortainerConfig() + assert config.portainer_url == 'https://test.example.com' + + def test_api_base_url(self): + """Test API base URL construction.""" + with patch.dict(os.environ, { + 'PORTAINER_URL': 'https://test.example.com', + 'PORTAINER_API_KEY': 'test-key' + }): + config = PortainerConfig() + assert config.api_base_url == 'https://test.example.com/api' + + +class TestErrors: + """Test error handling utilities.""" + + def test_portainer_error_basic(self): + """Test basic PortainerError.""" + error = PortainerError("Test error") + assert str(error) == "Test error" + assert error.message == "Test error" + assert error.status_code is None + assert error.details == {} + + def test_portainer_error_with_status_code(self): + """Test PortainerError with status code.""" + error = PortainerError("Test error", status_code=400) + assert str(error) == "[400] Test error" + assert error.status_code == 400 + + def test_portainer_authentication_error(self): + """Test PortainerAuthenticationError.""" + error = PortainerAuthenticationError() + assert error.status_code == 401 + assert "Authentication failed" in str(error) + + def test_error_mapping(self): + """Test HTTP error mapping.""" + from portainer_core.utils.errors import map_http_error + + error = map_http_error(404, "Not found") + assert error.__class__.__name__ == "PortainerNotFoundError" + assert error.status_code == 404 + + error = map_http_error(500, "Server error") + assert error.__class__.__name__ == "PortainerServerError" + assert error.status_code == 500 + + +class TestLogging: + """Test logging utilities.""" + + def test_get_logger(self): + """Test logger creation.""" + logger = get_logger("test") + assert logger is not None + + def test_correlation_id(self): + """Test correlation ID functionality.""" + correlation_id = set_correlation_id("test-id") + assert correlation_id == "test-id" + + from portainer_core.utils.logging import get_correlation_id + assert get_correlation_id() == "test-id" + + def test_correlation_id_auto_generation(self): + """Test automatic correlation ID generation.""" + correlation_id = set_correlation_id() + assert correlation_id is not None + assert len(correlation_id) > 0 + + +class TestCircuitBreaker: + """Test circuit breaker functionality.""" + + def test_circuit_breaker_initial_state(self): + """Test circuit breaker initial state.""" + from portainer_core.services.base import CircuitBreaker, CircuitBreakerState + + cb = CircuitBreaker() + assert cb.state == CircuitBreakerState.CLOSED + assert cb.can_execute() is True + assert cb.failure_count == 0 + + def test_circuit_breaker_failure_threshold(self): + """Test circuit breaker failure threshold.""" + from portainer_core.services.base import CircuitBreaker, CircuitBreakerState + + cb = CircuitBreaker(failure_threshold=2) + + # First failure + cb.record_failure() + assert cb.state == CircuitBreakerState.CLOSED + assert cb.can_execute() is True + + # Second failure - should open + cb.record_failure() + assert cb.state == CircuitBreakerState.OPEN + assert cb.can_execute() is False + + def test_circuit_breaker_success_reset(self): + """Test circuit breaker success reset.""" + from portainer_core.services.base import CircuitBreaker, CircuitBreakerState + + cb = CircuitBreaker() + cb.record_failure() + cb.record_success() + + assert cb.failure_count == 0 + assert cb.last_failure_time is None + + +if __name__ == "__main__": + pytest.main([__file__]) \ No newline at end of file