From e27251b922443a66f3422627020f97a3ee8136fb Mon Sep 17 00:00:00 2001 From: Adolfo Delorenzo Date: Fri, 18 Jul 2025 13:00:05 -0300 Subject: [PATCH] feat: add three new Portainer MCP servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add portainer-environments server for environment/endpoint management - Add portainer-docker server for Docker and Swarm container operations - Add merged portainer server combining core + teams functionality - Fix JSON schema issues and API compatibility - Add comprehensive documentation for each server - Add .gitignore and .env.example for security 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .env.example | 5 +- .gitignore | 56 + README_DOCKER.md | 239 +++ README_ENVIRONMENTS.md | 196 +++ README_MERGED.md | 133 ++ merged_mcp_server.py | 613 +++++++ portainer_docker_server.py | 1431 +++++++++++++++++ portainer_environments_server.py | 861 ++++++++++ requirements.txt | 8 + run_mcp.py | 14 + simple_mcp_server.py | 241 +++ .../__pycache__/__init__.cpython-312.pyc | Bin 1942 -> 0 bytes .../__pycache__/config.cpython-312.pyc | Bin 16355 -> 0 bytes .../__pycache__/server.cpython-312.pyc | Bin 55455 -> 0 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 308 -> 0 bytes .../models/__pycache__/auth.cpython-312.pyc | Bin 2911 -> 0 bytes .../__pycache__/settings.cpython-312.pyc | Bin 8766 -> 0 bytes .../models/__pycache__/users.cpython-312.pyc | Bin 8020 -> 0 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 314 -> 0 bytes .../services/__pycache__/auth.cpython-312.pyc | Bin 9278 -> 0 bytes .../services/__pycache__/base.cpython-312.pyc | Bin 14882 -> 0 bytes .../__pycache__/settings.cpython-312.pyc | Bin 10883 -> 0 bytes .../__pycache__/users.cpython-312.pyc | Bin 12073 -> 0 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 295 -> 0 bytes .../utils/__pycache__/errors.cpython-312.pyc | Bin 8264 -> 0 bytes .../utils/__pycache__/logging.cpython-312.pyc | Bin 7416 -> 0 bytes .../utils/__pycache__/tokens.cpython-312.pyc | Bin 7519 -> 0 bytes .../test_basic.cpython-312-pytest-7.4.3.pyc | Bin 30846 -> 0 bytes 28 files changed, 3796 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 README_DOCKER.md create mode 100644 README_ENVIRONMENTS.md create mode 100644 README_MERGED.md create mode 100755 merged_mcp_server.py create mode 100755 portainer_docker_server.py create mode 100755 portainer_environments_server.py create mode 100644 requirements.txt create mode 100755 run_mcp.py create mode 100755 simple_mcp_server.py delete mode 100644 src/portainer_core/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/portainer_core/__pycache__/config.cpython-312.pyc delete mode 100644 src/portainer_core/__pycache__/server.cpython-312.pyc delete mode 100644 src/portainer_core/models/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/portainer_core/models/__pycache__/auth.cpython-312.pyc delete mode 100644 src/portainer_core/models/__pycache__/settings.cpython-312.pyc delete mode 100644 src/portainer_core/models/__pycache__/users.cpython-312.pyc delete mode 100644 src/portainer_core/services/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/portainer_core/services/__pycache__/auth.cpython-312.pyc delete mode 100644 src/portainer_core/services/__pycache__/base.cpython-312.pyc delete mode 100644 src/portainer_core/services/__pycache__/settings.cpython-312.pyc delete mode 100644 src/portainer_core/services/__pycache__/users.cpython-312.pyc delete mode 100644 src/portainer_core/utils/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/portainer_core/utils/__pycache__/errors.cpython-312.pyc delete mode 100644 src/portainer_core/utils/__pycache__/logging.cpython-312.pyc delete mode 100644 src/portainer_core/utils/__pycache__/tokens.cpython-312.pyc delete mode 100644 tests/__pycache__/test_basic.cpython-312-pytest-7.4.3.pyc diff --git a/.env.example b/.env.example index a15a22c..d838afb 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,7 @@ # - https://portainer.example.com # - https://portainer.company.com:9443 # - http://localhost:9000 -PORTAINER_URL=https://portainer.example.com +PORTAINER_URL=https://your-portainer-instance.com # Portainer API key for authentication (required) # Generate this from Portainer UI: User settings > API tokens @@ -40,3 +40,6 @@ LOG_FORMAT=json # Development settings DEBUG=false +# MCP mode - disables stdout logging +MCP_MODE=true + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d15a04f --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual Environment +.venv/ +venv/ +ENV/ +env/ + +# Environment variables +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# MCP specific +.docker_cleaned +*.pyc \ No newline at end of file diff --git a/README_DOCKER.md b/README_DOCKER.md new file mode 100644 index 0000000..9c72b91 --- /dev/null +++ b/README_DOCKER.md @@ -0,0 +1,239 @@ +# Portainer Docker MCP Server + +This MCP server provides comprehensive Docker and Docker Swarm management functionality through Portainer Business Edition. + +## Features + +### Container Management +- **List and Inspect Containers** + - View all containers with status, ports, and resource usage + - Get detailed container information including networks, mounts, and environment + - Support for both running and stopped containers + +- **Container Lifecycle** + - Create containers with custom configurations + - Start, stop, and restart containers + - Remove containers (with force option) + - View container logs with tail and timestamp options + +### Image Management +- **Docker Images** + - List all images with sizes and tags + - Pull images from registries + - Remove unused images + - Support for multi-tag images + +### Volume Management +- **Docker Volumes** + - List volumes with mount points + - Create named volumes + - Remove volumes + - Support for different volume drivers + +### Network Management +- **Docker Networks** + - List networks with details + - Create custom networks (bridge, overlay, etc.) + - Remove networks + - Configure internal networks + +### Docker Swarm Features +- **Service Management** + - List services with replica status + - Create services with scaling options + - Update service configurations + - Remove services + - View service logs + +- **Stack Deployment** + - Deploy stacks from Docker Compose files + - List stacks across environments + - Remove stacks with all resources + - Support for environment variables in stacks + +### System Information +- **Docker Info** + - System-wide Docker information + - Resource usage statistics + - Swarm cluster status + - Version information + +## Installation + +1. Ensure Python dependencies are installed: +```bash +cd /Users/adelorenzo/repos/portainer-mcp +.venv/bin/pip install mcp httpx +``` + +2. Configure in Claude Desktop (`claude_desktop_config.json`): +```json +{ + "mcpServers": { + "portainer-docker": { + "command": "/path/to/.venv/bin/python", + "args": ["/path/to/portainer_docker_server.py"], + "env": { + "PORTAINER_URL": "https://your-portainer-instance.com", + "PORTAINER_API_KEY": "your-api-key", + "MCP_MODE": "true" + } + } + } +} +``` + +3. Restart Claude Desktop + +## Available Tools + +### Container Operations +- `list_containers` - List all containers in an environment +- `inspect_container` - Get detailed container information +- `create_container` - Create a new container +- `start_container` - Start a stopped container +- `stop_container` - Stop a running container +- `restart_container` - Restart a container +- `remove_container` - Remove a container +- `get_container_logs` - View container logs + +### Image Operations +- `list_images` - List Docker images +- `pull_image` - Pull an image from registry +- `remove_image` - Remove an image + +### Volume Operations +- `list_volumes` - List Docker volumes +- `create_volume` - Create a new volume +- `remove_volume` - Remove a volume + +### Network Operations +- `list_networks` - List Docker networks +- `create_network` - Create a new network +- `remove_network` - Remove a network + +### Swarm Service Operations +- `list_services` - List Swarm services +- `create_service` - Create a new service +- `update_service` - Update service configuration +- `remove_service` - Remove a service +- `get_service_logs` - View service logs + +### Stack Operations +- `list_stacks` - List deployed stacks +- `deploy_stack` - Deploy a new stack +- `remove_stack` - Remove a stack + +### System Operations +- `get_docker_info` - Get Docker system information +- `get_docker_version` - Get Docker version details + +## Usage Examples + +### Container Management +``` +# List all containers in environment 3 +Use "list_containers" with environment_id: 3, all: true + +# Create and start an nginx container +Use "create_container" with: +- environment_id: 3 +- image: "nginx:latest" +- name: "my-nginx" +- ports: {"80/tcp": [{"HostPort": "8080"}]} + +# View container logs +Use "get_container_logs" with: +- environment_id: 3 +- container_id: "my-nginx" +- tail: 50 +- timestamps: true +``` + +### Docker Swarm Services +``` +# Create a replicated service +Use "create_service" with: +- environment_id: 4 +- name: "web-service" +- image: "nginx:alpine" +- replicas: 3 +- ports: [{"target": 80, "published": 8080, "protocol": "tcp"}] + +# Scale a service +Use "update_service" with: +- environment_id: 4 +- service_id: "web-service" +- replicas: 5 +``` + +### Stack Deployment +``` +# Deploy a stack +Use "deploy_stack" with: +- environment_id: 4 +- name: "my-app" +- compose_file: | + version: '3.8' + services: + web: + image: nginx:alpine + ports: + - "8080:80" + redis: + image: redis:alpine +``` + +### Docker Volumes +``` +# Create a volume +Use "create_volume" with: +- environment_id: 3 +- name: "app-data" +- driver: "local" + +# List volumes +Use "list_volumes" with environment_id: 3 +``` + +## Container Configuration + +When creating containers, you can specify: +- **Image**: Docker image name with optional tag +- **Name**: Container name (optional) +- **Command**: Override default command +- **Environment Variables**: Array of KEY=VALUE strings +- **Ports**: Port bindings (host:container mapping) +- **Volumes**: Volume mounts +- **Restart Policy**: no, always, unless-stopped, on-failure + +## Swarm vs Standalone + +This server automatically handles both: +- **Docker Standalone**: Container operations, local volumes, bridge networks +- **Docker Swarm**: Services, stacks, overlay networks, multi-node deployments + +Some operations (like services) only work on Swarm environments. + +## Error Handling + +The server provides clear error messages: +- Environment not found +- Container/service not found +- Swarm-only operations on standalone Docker +- Network connectivity issues +- Permission errors + +## Security Notes + +- API key is required for all operations +- Container operations respect Portainer's RBAC +- Sensitive environment variables are filtered from logs +- Force options available for cleanup operations + +## Limitations + +- Log output is limited to prevent overwhelming responses +- Large container lists are truncated (shows first 20) +- Stack deployment requires compose file as string +- Some Docker features may require direct API access \ No newline at end of file diff --git a/README_ENVIRONMENTS.md b/README_ENVIRONMENTS.md new file mode 100644 index 0000000..79e7078 --- /dev/null +++ b/README_ENVIRONMENTS.md @@ -0,0 +1,196 @@ +# Portainer Environments MCP Server + +This MCP server provides comprehensive environment and endpoint management functionality for Portainer Business Edition. + +## Features + +### Environment Management +- **List and View Environments** + - List all environments with status and type + - Get detailed information about specific environments + - View environment statistics and Docker/Kubernetes info + +- **Create Environments** + - Docker environments (local and remote) + - Kubernetes clusters + - Docker Swarm clusters + - Edge Agent environments + - Support for TLS configuration + +- **Update and Delete** + - Update environment settings and URLs + - Change group assignments + - Delete environments safely + +### Environment Organization +- **Environment Groups** + - Create and manage environment groups + - Organize environments by purpose or location + - Update group names and descriptions + - Delete unused groups + +- **Tags Management** + - Create tags for categorizing environments + - List all available tags + - Delete unused tags + - Assign multiple tags to environments + +### Access Control +- **Team Associations** + - Associate environments with teams + - Set read/write access levels + - Bulk update team permissions + +### Edge Computing +- **Edge Agent Deployment** + - Generate Edge keys for remote deployments + - Get deployment scripts automatically + - Support for Edge environment groups + +## Installation + +1. Ensure Python dependencies are installed: +```bash +cd /Users/adelorenzo/repos/portainer-mcp +.venv/bin/pip install mcp httpx +``` + +2. Configure in Claude Desktop (`claude_desktop_config.json`): +```json +{ + "mcpServers": { + "portainer-environments": { + "command": "/path/to/.venv/bin/python", + "args": ["/path/to/portainer_environments_server.py"], + "env": { + "PORTAINER_URL": "https://your-portainer-instance.com", + "PORTAINER_API_KEY": "your-api-key", + "MCP_MODE": "true" + } + } + } +} +``` + +3. Restart Claude Desktop + +## Available Tools + +### Environment Operations +- `list_environments` - List all environments with pagination support +- `get_environment` - Get detailed information about an environment +- `create_docker_environment` - Create a new Docker environment +- `create_kubernetes_environment` - Create a new Kubernetes environment +- `update_environment` - Update environment settings +- `delete_environment` - Delete an environment +- `get_environment_status` - Get real-time status and statistics + +### Access Control +- `associate_environment` - Associate environments with teams and set permissions + +### Organization +- `list_environment_groups` - List all environment groups +- `create_environment_group` - Create a new environment group +- `update_environment_group` - Update group information +- `delete_environment_group` - Delete an environment group + +### Tags +- `list_tags` - List all available tags +- `create_tag` - Create a new tag +- `delete_tag` - Delete a tag + +### Edge Computing +- `generate_edge_key` - Generate Edge agent deployment script + +## Environment Types + +The server supports these environment types: +- **Docker** - Standard Docker environments +- **Docker Swarm** - Swarm cluster management +- **Kubernetes** - Kubernetes cluster management +- **Azure ACI** - Azure Container Instances +- **Edge Agent** - Remote Edge deployments + +## Example Usage + +### Create a Docker environment: +``` +Use "create_docker_environment" with: +- name: "Production Docker" +- url: "tcp://docker-prod.example.com:2375" +- public_url: "https://docker-prod.example.com" +- tls: true +- tags: ["production", "docker"] +``` + +### Create a Kubernetes environment: +``` +Use "create_kubernetes_environment" with: +- name: "K8s Production" +- url: "https://k8s-api.example.com:6443" +- bearer_token: "your-bearer-token" +- tls_skip_verify: false +``` + +### Deploy an Edge agent: +``` +1. Use "generate_edge_key" with name: "Remote Site 1" +2. Copy the generated deployment command +3. Run the command on the remote Docker host +``` + +### Organize environments: +``` +1. Use "create_environment_group" with name: "Production Servers" +2. Use "create_tag" with name: "critical" +3. Use "update_environment" to assign the group and tags +``` + +## API Compatibility + +This server handles both old and new Portainer API endpoints: +- New API (2.19.x+): `/environments` +- Old API (pre-2.19): `/endpoints` + +The server automatically tries both endpoints for compatibility. + +## Security Notes + +- API key is required for all operations +- HTTPS is recommended (SSL verification disabled for development) +- Team associations respect Portainer's RBAC system +- Edge keys should be kept secure + +## Troubleshooting + +### Common Issues + +**Environment shows as "down":** +- Check network connectivity to the Docker/Kubernetes API +- Verify TLS certificates if using secure connections +- Ensure the API endpoint URL is correct + +**Cannot create environment:** +- Verify the URL format (tcp:// for Docker, https:// for Kubernetes) +- Check if the environment name already exists +- Ensure proper authentication credentials + +**Edge agent not connecting:** +- Verify the Edge key is correct +- Check firewall rules for outbound connections +- Ensure Docker is running on the Edge device + +## Docker URL Formats + +- **Local Docker:** `unix:///var/run/docker.sock` +- **Remote Docker (TCP):** `tcp://hostname:2375` +- **Remote Docker (TLS):** `tcp://hostname:2376` +- **Docker Desktop (Mac):** `unix:///$HOME/.docker/run/docker.sock` +- **Docker Desktop (Windows):** `npipe:////./pipe/docker_engine` + +## Kubernetes Authentication + +For Kubernetes environments, you can use: +- **Bearer Token:** Service account token +- **Client Certificate:** Upload certificate files (in UI) +- **Kubeconfig:** Import kubeconfig file (in UI) \ No newline at end of file diff --git a/README_MERGED.md b/README_MERGED.md new file mode 100644 index 0000000..7120026 --- /dev/null +++ b/README_MERGED.md @@ -0,0 +1,133 @@ +# Portainer Unified MCP Server + +This MCP server combines **portainer-core** and **portainer-teams** functionality into a single unified server, providing comprehensive user, team, and RBAC management for Portainer Business Edition. + +## Features + +### User Management (from portainer-core) +- **Authentication & Session Management** + - Test connection to Portainer + - API token validation +- **User CRUD Operations** + - List all users + - Create new users with role assignment + - Update user passwords and roles + - Delete users + +### Teams Management (from portainer-teams) +- **Team Operations** + - List all teams + - Create new teams with optional leaders + - Delete teams +- **Team Membership** + - Add users to teams + - Remove users from teams + - Bulk membership operations + +### RBAC Management +- **Role Management** + - List available roles with descriptions + - View role priorities and permissions +- **Resource Access Control** + - List all resource controls + - Create resource-specific access controls + - Configure public/private access + - Set user and team permissions + +### Settings Management +- **System Configuration** + - Get current Portainer settings + - Update security settings + - Configure user permissions + +## Installation + +1. Install Python dependencies: +```bash +cd /Users/adelorenzo/repos/portainer-mcp +.venv/bin/pip install mcp httpx +``` + +2. Configure in Claude Desktop (`claude_desktop_config.json`): +```json +{ + "mcpServers": { + "portainer": { + "command": "/path/to/.venv/bin/python", + "args": ["/path/to/merged_mcp_server.py"], + "env": { + "PORTAINER_URL": "https://your-portainer-instance.com", + "PORTAINER_API_KEY": "your-api-key", + "MCP_MODE": "true" + } + } + } +} +``` + +3. Restart Claude Desktop + +## Available Tools + +### User Management +- `test_connection` - Test connection to Portainer +- `get_users` - List all users with their roles +- `create_user` - Create a new user (username, password, role) +- `update_user` - Update user password or role +- `delete_user` - Delete a user by ID + +### Teams Management +- `get_teams` - List all teams +- `create_team` - Create a new team with optional leaders +- `add_team_members` - Add users to a team +- `remove_team_members` - Remove users from a team +- `delete_team` - Delete a team by ID + +### RBAC Management +- `get_roles` - List available roles and their descriptions +- `get_resource_controls` - List all resource access controls +- `create_resource_control` - Create access control for a resource + +### Settings +- `get_settings` - Get current Portainer settings +- `update_settings` - Update Portainer security settings + +## Role Types + +The server supports three role types: +- **Administrator** - Full system access +- **StandardUser** - Regular user access +- **ReadOnlyUser** - Read-only access + +## Example Usage + +### Create a user and add to team: +``` +1. Use "create_user" with username: "john", password: "secure123", role: "StandardUser" +2. Use "get_users" to find John's user ID +3. Use "create_team" with name: "DevOps Team" +4. Use "get_teams" to find the team ID +5. Use "add_team_members" with the team ID and John's user ID +``` + +### Set up resource access control: +``` +1. Use "create_resource_control" with: + - resource_id: "container_id_here" + - resource_type: "container" + - teams: [{"team_id": 1, "access_level": "read"}] + - administrators_only: false +``` + +## Compatibility + +- Supports both old (integer) and new (string) role formats +- Works with Portainer Business Edition 2.30.x+ +- Handles API version differences automatically + +## Security Notes + +- API key is required for all operations +- HTTPS is recommended (SSL verification disabled for development) +- Tokens should be rotated regularly +- All operations respect Portainer's RBAC system \ No newline at end of file diff --git a/merged_mcp_server.py b/merged_mcp_server.py new file mode 100755 index 0000000..455c2c8 --- /dev/null +++ b/merged_mcp_server.py @@ -0,0 +1,613 @@ +#!/usr/bin/env python3 +"""Merged MCP server for Portainer Core + Teams functionality.""" + +import os +import sys +import json +import asyncio +from typing import Any, Dict, List, Optional + +# Suppress all logging to stderr +os.environ["MCP_MODE"] = "true" + +import mcp.server.stdio +import mcp.types as types +from mcp.server import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +# Create server +server = Server("portainer-unified") + +# Store for our state +portainer_url = os.getenv("PORTAINER_URL", "https://partner.portainer.live") +api_key = os.getenv("PORTAINER_API_KEY", "") + + +@server.list_tools() +async def handle_list_tools() -> list[types.Tool]: + """List available tools.""" + return [ + # Core tools + types.Tool( + name="test_connection", + description="Test connection to Portainer", + inputSchema={ + "type": "object", + "properties": {}, + "required": [] + } + ), + + # User Management tools + types.Tool( + name="get_users", + description="Get list of Portainer users", + inputSchema={ + "type": "object", + "properties": {}, + "required": [] + } + ), + types.Tool( + name="create_user", + description="Create a new Portainer 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": "string", + "description": "User role", + "enum": ["Administrator", "StandardUser", "ReadOnlyUser"], + "default": "StandardUser" + } + }, + "required": ["username", "password"] + } + ), + types.Tool( + name="update_user", + description="Update an existing user", + inputSchema={ + "type": "object", + "properties": { + "user_id": { + "type": "integer", + "description": "ID of the user to update" + }, + "password": { + "type": "string", + "description": "New password (optional)" + }, + "role": { + "type": "string", + "description": "New role (optional)", + "enum": ["Administrator", "StandardUser", "ReadOnlyUser"] + } + }, + "required": ["user_id"] + } + ), + types.Tool( + name="delete_user", + description="Delete a user", + inputSchema={ + "type": "object", + "properties": { + "user_id": { + "type": "integer", + "description": "ID of the user to delete" + } + }, + "required": ["user_id"] + } + ), + + # Teams Management tools + types.Tool( + name="get_teams", + description="Get list of teams", + inputSchema={ + "type": "object", + "properties": {}, + "required": [] + } + ), + types.Tool( + name="create_team", + description="Create a new team", + inputSchema={ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the team" + }, + "leaders": { + "type": "array", + "items": {"type": "integer"}, + "description": "Array of user IDs to be team leaders (optional)" + } + }, + "required": ["name"] + } + ), + types.Tool( + name="add_team_members", + description="Add members to a team", + inputSchema={ + "type": "object", + "properties": { + "team_id": { + "type": "integer", + "description": "ID of the team" + }, + "user_ids": { + "type": "array", + "items": {"type": "integer"}, + "description": "Array of user IDs to add to the team" + } + }, + "required": ["team_id", "user_ids"] + } + ), + types.Tool( + name="remove_team_members", + description="Remove members from a team", + inputSchema={ + "type": "object", + "properties": { + "team_id": { + "type": "integer", + "description": "ID of the team" + }, + "user_ids": { + "type": "array", + "items": {"type": "integer"}, + "description": "Array of user IDs to remove from the team" + } + }, + "required": ["team_id", "user_ids"] + } + ), + types.Tool( + name="delete_team", + description="Delete a team", + inputSchema={ + "type": "object", + "properties": { + "team_id": { + "type": "integer", + "description": "ID of the team to delete" + } + }, + "required": ["team_id"] + } + ), + + # RBAC tools + types.Tool( + name="get_roles", + description="Get available roles", + inputSchema={ + "type": "object", + "properties": {}, + "required": [] + } + ), + types.Tool( + name="get_resource_controls", + description="Get resource access controls", + inputSchema={ + "type": "object", + "properties": {}, + "required": [] + } + ), + types.Tool( + name="create_resource_control", + description="Create resource access control", + inputSchema={ + "type": "object", + "properties": { + "resource_id": { + "type": "string", + "description": "ID of the resource" + }, + "resource_type": { + "type": "string", + "description": "Type of resource (container, service, volume, etc.)" + }, + "public": { + "type": "boolean", + "description": "Whether the resource is public", + "default": False + }, + "administrators_only": { + "type": "boolean", + "description": "Whether only administrators can access", + "default": False + }, + "users": { + "type": "array", + "items": { + "type": "object", + "properties": { + "user_id": {"type": "integer"}, + "access_level": {"type": "string", "enum": ["read", "write"]} + } + }, + "description": "User access list" + }, + "teams": { + "type": "array", + "items": { + "type": "object", + "properties": { + "team_id": {"type": "integer"}, + "access_level": {"type": "string", "enum": ["read", "write"]} + } + }, + "description": "Team access list" + } + }, + "required": ["resource_id", "resource_type"] + } + ), + + # Settings tools + types.Tool( + name="get_settings", + description="Get Portainer settings", + inputSchema={ + "type": "object", + "properties": {}, + "required": [] + } + ), + types.Tool( + name="update_settings", + description="Update Portainer settings", + inputSchema={ + "type": "object", + "properties": { + "allow_volume_browser": { + "type": "boolean", + "description": "Allow users to browse volumes" + }, + "allow_bind_mounts": { + "type": "boolean", + "description": "Allow regular users to use bind mounts" + }, + "allow_privileged_mode": { + "type": "boolean", + "description": "Allow regular users to use privileged mode" + }, + "allow_stack_management": { + "type": "boolean", + "description": "Allow regular users to manage stacks" + } + }, + "required": [] + } + ) + ] + + +async def make_request(method: str, endpoint: str, json_data: Optional[Dict] = None) -> Dict[str, Any]: + """Make HTTP request to Portainer API.""" + import httpx + + async with httpx.AsyncClient(verify=False) as client: + headers = {"X-API-Key": api_key} if api_key else {} + + if method == "GET": + response = await client.get(f"{portainer_url}/api{endpoint}", headers=headers) + elif method == "POST": + response = await client.post(f"{portainer_url}/api{endpoint}", headers=headers, json=json_data) + elif method == "PUT": + response = await client.put(f"{portainer_url}/api{endpoint}", headers=headers, json=json_data) + elif method == "DELETE": + response = await client.delete(f"{portainer_url}/api{endpoint}", headers=headers) + else: + raise ValueError(f"Unsupported method: {method}") + + return {"status_code": response.status_code, "data": response.json() if response.text else None, "text": response.text} + + +def convert_role(role): + """Convert between string and integer role representations.""" + if isinstance(role, int): + role_map = {1: "Administrator", 2: "StandardUser", 3: "ReadOnlyUser"} + return role_map.get(role, f"Unknown({role})") + else: + role_map = {"Administrator": 1, "StandardUser": 2, "ReadOnlyUser": 3} + return role_map.get(role, 2) + + +@server.call_tool() +async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]: + """Handle tool calls.""" + + try: + # Core tools + if name == "test_connection": + result = await make_request("GET", "/status") + if result["status_code"] == 200: + return [types.TextContent(type="text", text=f"✓ Connected to Portainer at {portainer_url}")] + else: + return [types.TextContent(type="text", text=f"✗ Failed to connect: HTTP {result['status_code']}")] + + # User Management + elif name == "get_users": + result = await make_request("GET", "/users") + if result["status_code"] == 200: + users = result["data"] + output = f"Found {len(users)} users:\n" + for user in users: + role = convert_role(user.get("Role", "Unknown")) + output += f"- ID: {user.get('Id')}, Username: {user.get('Username')}, Role: {role}\n" + return [types.TextContent(type="text", text=output)] + else: + return [types.TextContent(type="text", text=f"Failed to get users: HTTP {result['status_code']}")] + + elif name == "create_user": + username = arguments.get("username") + password = arguments.get("password") + role = arguments.get("role", "StandardUser") + + if not username or not password: + return [types.TextContent(type="text", text="Error: Username and password are required")] + + role_int = convert_role(role) + + # Try with integer role first + result = await make_request("POST", "/users", { + "Username": username, + "Password": password, + "Role": role_int + }) + + if result["status_code"] == 409: + return [types.TextContent(type="text", text=f"User '{username}' already exists")] + elif result["status_code"] in [200, 201]: + return [types.TextContent(type="text", text=f"✓ User '{username}' created successfully with role {role}")] + else: + # Try with string role + result = await make_request("POST", "/users", { + "Username": username, + "Password": password, + "Role": role + }) + if result["status_code"] in [200, 201]: + return [types.TextContent(type="text", text=f"✓ User '{username}' created successfully with role {role}")] + else: + return [types.TextContent(type="text", text=f"Failed to create user: HTTP {result['status_code']} - {result['text']}")] + + elif name == "update_user": + user_id = arguments.get("user_id") + if not user_id: + return [types.TextContent(type="text", text="Error: user_id is required")] + + update_data = {} + if "password" in arguments: + update_data["Password"] = arguments["password"] + if "role" in arguments: + update_data["Role"] = convert_role(arguments["role"]) + + result = await make_request("PUT", f"/users/{user_id}", update_data) + if result["status_code"] == 200: + return [types.TextContent(type="text", text=f"✓ User {user_id} updated successfully")] + else: + return [types.TextContent(type="text", text=f"Failed to update user: HTTP {result['status_code']}")] + + elif name == "delete_user": + user_id = arguments.get("user_id") + if not user_id: + return [types.TextContent(type="text", text="Error: user_id is required")] + + result = await make_request("DELETE", f"/users/{user_id}") + if result["status_code"] in [200, 204]: + return [types.TextContent(type="text", text=f"✓ User {user_id} deleted successfully")] + else: + return [types.TextContent(type="text", text=f"Failed to delete user: HTTP {result['status_code']}")] + + # Teams Management + elif name == "get_teams": + result = await make_request("GET", "/teams") + if result["status_code"] == 200: + teams = result["data"] + output = f"Found {len(teams)} teams:\n" + for team in teams: + output += f"- ID: {team.get('Id')}, Name: {team.get('Name')}\n" + return [types.TextContent(type="text", text=output)] + else: + return [types.TextContent(type="text", text=f"Failed to get teams: HTTP {result['status_code']}")] + + elif name == "create_team": + team_name = arguments.get("name") + if not team_name: + return [types.TextContent(type="text", text="Error: Team name is required")] + + team_data = { + "Name": team_name, + "Leaders": arguments.get("leaders", []) + } + + result = await make_request("POST", "/teams", team_data) + if result["status_code"] in [200, 201]: + return [types.TextContent(type="text", text=f"✓ Team '{team_name}' created successfully")] + else: + return [types.TextContent(type="text", text=f"Failed to create team: HTTP {result['status_code']} - {result['text']}")] + + elif name == "add_team_members": + team_id = arguments.get("team_id") + user_ids = arguments.get("user_ids", []) + + if not team_id: + return [types.TextContent(type="text", text="Error: team_id is required")] + if not user_ids: + return [types.TextContent(type="text", text="Error: user_ids array is required")] + + result = await make_request("POST", f"/teams/{team_id}/memberships", {"UserIds": user_ids}) + if result["status_code"] in [200, 201, 204]: + return [types.TextContent(type="text", text=f"✓ Added {len(user_ids)} users to team {team_id}")] + else: + return [types.TextContent(type="text", text=f"Failed to add team members: HTTP {result['status_code']}")] + + elif name == "remove_team_members": + team_id = arguments.get("team_id") + user_ids = arguments.get("user_ids", []) + + if not team_id: + return [types.TextContent(type="text", text="Error: team_id is required")] + if not user_ids: + return [types.TextContent(type="text", text="Error: user_ids array is required")] + + result = await make_request("DELETE", f"/teams/{team_id}/memberships", {"UserIds": user_ids}) + if result["status_code"] in [200, 204]: + return [types.TextContent(type="text", text=f"✓ Removed {len(user_ids)} users from team {team_id}")] + else: + return [types.TextContent(type="text", text=f"Failed to remove team members: HTTP {result['status_code']}")] + + elif name == "delete_team": + team_id = arguments.get("team_id") + if not team_id: + return [types.TextContent(type="text", text="Error: team_id is required")] + + result = await make_request("DELETE", f"/teams/{team_id}") + if result["status_code"] in [200, 204]: + return [types.TextContent(type="text", text=f"✓ Team {team_id} deleted successfully")] + else: + return [types.TextContent(type="text", text=f"Failed to delete team: HTTP {result['status_code']}")] + + # RBAC tools + elif name == "get_roles": + result = await make_request("GET", "/roles") + if result["status_code"] == 200: + roles = result["data"] + output = "Available roles:\n" + for role in roles: + output += f"- ID: {role.get('Id')}, Name: {role.get('Name')}, Priority: {role.get('Priority')}\n" + if role.get('Description'): + output += f" Description: {role.get('Description')}\n" + return [types.TextContent(type="text", text=output)] + else: + return [types.TextContent(type="text", text=f"Failed to get roles: HTTP {result['status_code']}")] + + elif name == "get_resource_controls": + result = await make_request("GET", "/resource_controls") + if result["status_code"] == 200: + controls = result["data"] + output = f"Found {len(controls)} resource controls:\n" + for control in controls: + output += f"- ID: {control.get('Id')}, Resource: {control.get('ResourceId')}, " + output += f"Type: {control.get('Type')}, Public: {control.get('Public')}\n" + return [types.TextContent(type="text", text=output)] + else: + return [types.TextContent(type="text", text=f"Failed to get resource controls: HTTP {result['status_code']}")] + + elif name == "create_resource_control": + resource_id = arguments.get("resource_id") + resource_type = arguments.get("resource_type") + + if not resource_id or not resource_type: + return [types.TextContent(type="text", text="Error: resource_id and resource_type are required")] + + control_data = { + "ResourceId": resource_id, + "Type": resource_type, + "Public": arguments.get("public", False), + "AdministratorsOnly": arguments.get("administrators_only", False), + "UserAccesses": arguments.get("users", []), + "TeamAccesses": arguments.get("teams", []) + } + + result = await make_request("POST", "/resource_controls", control_data) + if result["status_code"] in [200, 201]: + return [types.TextContent(type="text", text=f"✓ Resource control created for {resource_id}")] + else: + return [types.TextContent(type="text", text=f"Failed to create resource control: HTTP {result['status_code']}")] + + # Settings tools + elif name == "get_settings": + result = await make_request("GET", "/settings") + if result["status_code"] == 200: + settings = result["data"] + output = "Portainer Settings:\n" + output += f"- Allow Volume Browser: {settings.get('AllowVolumeBrowser', False)}\n" + output += f"- Allow Bind Mounts: {settings.get('AllowBindMountsForRegularUsers', False)}\n" + output += f"- Allow Privileged Mode: {settings.get('AllowPrivilegedModeForRegularUsers', False)}\n" + output += f"- Allow Stack Management: {settings.get('AllowStackManagementForRegularUsers', False)}\n" + output += f"- Authentication Method: {settings.get('AuthenticationMethod', 'Unknown')}\n" + return [types.TextContent(type="text", text=output)] + else: + return [types.TextContent(type="text", text=f"Failed to get settings: HTTP {result['status_code']}")] + + elif name == "update_settings": + # Get current settings first + current = await make_request("GET", "/settings") + if current["status_code"] != 200: + return [types.TextContent(type="text", text=f"Failed to get current settings: HTTP {current['status_code']}")] + + settings_data = current["data"] + + # Update only provided fields + if "allow_volume_browser" in arguments: + settings_data["AllowVolumeBrowser"] = arguments["allow_volume_browser"] + if "allow_bind_mounts" in arguments: + settings_data["AllowBindMountsForRegularUsers"] = arguments["allow_bind_mounts"] + if "allow_privileged_mode" in arguments: + settings_data["AllowPrivilegedModeForRegularUsers"] = arguments["allow_privileged_mode"] + if "allow_stack_management" in arguments: + settings_data["AllowStackManagementForRegularUsers"] = arguments["allow_stack_management"] + + result = await make_request("PUT", "/settings", settings_data) + if result["status_code"] == 200: + return [types.TextContent(type="text", text="✓ Settings updated successfully")] + else: + return [types.TextContent(type="text", text=f"Failed to update settings: HTTP {result['status_code']}")] + + else: + return [types.TextContent(type="text", text=f"Unknown tool: {name}")] + + except Exception as e: + return [types.TextContent(type="text", text=f"Error: {str(e)}")] + + +async def run(): + """Run the MCP server.""" + # Use stdio transport + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="portainer-unified", + server_version="1.0.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions( + prompts_changed=False, + resources_changed=False, + tools_changed=False, + ), + experimental_capabilities={}, + ), + ), + ) + + +def main(): + """Main entry point.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/portainer_docker_server.py b/portainer_docker_server.py new file mode 100755 index 0000000..dc62eb0 --- /dev/null +++ b/portainer_docker_server.py @@ -0,0 +1,1431 @@ +#!/usr/bin/env python3 +"""MCP server for Portainer Docker and Docker Swarm management.""" + +import os +import sys +import json +import asyncio +from typing import Any, Dict, List, Optional +from enum import Enum + +# Suppress all logging to stderr +os.environ["MCP_MODE"] = "true" + +import mcp.server.stdio +import mcp.types as types +from mcp.server import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +# Create server +server = Server("portainer-docker") + +# Store for our state +portainer_url = os.getenv("PORTAINER_URL", "https://partner.portainer.live") +api_key = os.getenv("PORTAINER_API_KEY", "") + + +@server.list_tools() +async def handle_list_tools() -> list[types.Tool]: + """List available tools.""" + return [ + # Container Management + types.Tool( + name="list_containers", + description="List containers in an environment", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + }, + "all": { + "type": "boolean", + "description": "Show all containers (default shows only running)", + "default": False + } + }, + "required": ["environment_id"] + } + ), + types.Tool( + name="inspect_container", + description="Get detailed information about a container", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + }, + "container_id": { + "type": "string", + "description": "Container ID or name" + } + }, + "required": ["environment_id", "container_id"] + } + ), + types.Tool( + name="create_container", + description="Create a new container", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + }, + "image": { + "type": "string", + "description": "Docker image to use" + }, + "name": { + "type": "string", + "description": "Container name (optional)" + }, + "command": { + "type": "array", + "items": {"type": "string"}, + "description": "Command to run (optional)" + }, + "env": { + "type": "array", + "items": {"type": "string"}, + "description": "Environment variables in KEY=VALUE format (optional)" + }, + "ports": { + "type": "object", + "description": "Port bindings (e.g., {'80/tcp': [{'HostPort': '8080'}]})" + }, + "volumes": { + "type": "object", + "description": "Volume bindings (e.g., {'/host/path': {'bind': '/container/path', 'mode': 'rw'}})" + }, + "restart_policy": { + "type": "string", + "enum": ["no", "always", "unless-stopped", "on-failure"], + "default": "no" + } + }, + "required": ["environment_id", "image"] + } + ), + types.Tool( + name="start_container", + description="Start a stopped container", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + }, + "container_id": { + "type": "string", + "description": "Container ID or name" + } + }, + "required": ["environment_id", "container_id"] + } + ), + types.Tool( + name="stop_container", + description="Stop a running container", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + }, + "container_id": { + "type": "string", + "description": "Container ID or name" + } + }, + "required": ["environment_id", "container_id"] + } + ), + types.Tool( + name="restart_container", + description="Restart a container", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + }, + "container_id": { + "type": "string", + "description": "Container ID or name" + } + }, + "required": ["environment_id", "container_id"] + } + ), + types.Tool( + name="remove_container", + description="Remove a container", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + }, + "container_id": { + "type": "string", + "description": "Container ID or name" + }, + "force": { + "type": "boolean", + "description": "Force removal of running container", + "default": False + } + }, + "required": ["environment_id", "container_id"] + } + ), + types.Tool( + name="get_container_logs", + description="Get logs from a container", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + }, + "container_id": { + "type": "string", + "description": "Container ID or name" + }, + "tail": { + "type": "integer", + "description": "Number of lines to show from the end", + "default": 100 + }, + "timestamps": { + "type": "boolean", + "description": "Show timestamps", + "default": False + } + }, + "required": ["environment_id", "container_id"] + } + ), + # Image Management + types.Tool( + name="list_images", + description="List Docker images in an environment", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + } + }, + "required": ["environment_id"] + } + ), + types.Tool( + name="pull_image", + description="Pull a Docker image", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + }, + "image": { + "type": "string", + "description": "Image name with optional tag (e.g., nginx:latest)" + } + }, + "required": ["environment_id", "image"] + } + ), + types.Tool( + name="remove_image", + description="Remove a Docker image", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + }, + "image_id": { + "type": "string", + "description": "Image ID or name:tag" + }, + "force": { + "type": "boolean", + "description": "Force removal", + "default": False + } + }, + "required": ["environment_id", "image_id"] + } + ), + # Volume Management + types.Tool( + name="list_volumes", + description="List Docker volumes", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + } + }, + "required": ["environment_id"] + } + ), + types.Tool( + name="create_volume", + description="Create a Docker volume", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + }, + "name": { + "type": "string", + "description": "Volume name" + }, + "driver": { + "type": "string", + "description": "Volume driver", + "default": "local" + } + }, + "required": ["environment_id", "name"] + } + ), + types.Tool( + name="remove_volume", + description="Remove a Docker volume", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + }, + "volume_name": { + "type": "string", + "description": "Volume name" + } + }, + "required": ["environment_id", "volume_name"] + } + ), + # Network Management + types.Tool( + name="list_networks", + description="List Docker networks", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + } + }, + "required": ["environment_id"] + } + ), + types.Tool( + name="create_network", + description="Create a Docker network", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + }, + "name": { + "type": "string", + "description": "Network name" + }, + "driver": { + "type": "string", + "description": "Network driver", + "default": "bridge" + }, + "internal": { + "type": "boolean", + "description": "Restrict external access", + "default": False + } + }, + "required": ["environment_id", "name"] + } + ), + types.Tool( + name="remove_network", + description="Remove a Docker network", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + }, + "network_id": { + "type": "string", + "description": "Network ID or name" + } + }, + "required": ["environment_id", "network_id"] + } + ), + # Docker Swarm Services + types.Tool( + name="list_services", + description="List Docker Swarm services", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the Swarm environment" + } + }, + "required": ["environment_id"] + } + ), + types.Tool( + name="create_service", + description="Create a Docker Swarm service", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the Swarm environment" + }, + "name": { + "type": "string", + "description": "Service name" + }, + "image": { + "type": "string", + "description": "Docker image" + }, + "replicas": { + "type": "integer", + "description": "Number of replicas", + "default": 1 + }, + "command": { + "type": "array", + "items": {"type": "string"}, + "description": "Command to run (optional)" + }, + "env": { + "type": "array", + "items": {"type": "string"}, + "description": "Environment variables in KEY=VALUE format" + }, + "ports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "target": {"type": "integer"}, + "published": {"type": "integer"}, + "protocol": {"type": "string", "enum": ["tcp", "udp"]} + } + }, + "description": "Published ports" + } + }, + "required": ["environment_id", "name", "image"] + } + ), + types.Tool( + name="update_service", + description="Update a Docker Swarm service", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the Swarm environment" + }, + "service_id": { + "type": "string", + "description": "Service ID or name" + }, + "image": { + "type": "string", + "description": "New image (optional)" + }, + "replicas": { + "type": "integer", + "description": "New replica count (optional)" + }, + "env": { + "type": "array", + "items": {"type": "string"}, + "description": "New environment variables (optional)" + } + }, + "required": ["environment_id", "service_id"] + } + ), + types.Tool( + name="remove_service", + description="Remove a Docker Swarm service", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the Swarm environment" + }, + "service_id": { + "type": "string", + "description": "Service ID or name" + } + }, + "required": ["environment_id", "service_id"] + } + ), + types.Tool( + name="get_service_logs", + description="Get logs from a Docker Swarm service", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the Swarm environment" + }, + "service_id": { + "type": "string", + "description": "Service ID or name" + }, + "tail": { + "type": "integer", + "description": "Number of lines from the end", + "default": 100 + } + }, + "required": ["environment_id", "service_id"] + } + ), + # Stack Management + types.Tool( + name="list_stacks", + description="List Docker stacks", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + } + }, + "required": ["environment_id"] + } + ), + types.Tool( + name="deploy_stack", + description="Deploy a Docker stack from compose file", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + }, + "name": { + "type": "string", + "description": "Stack name" + }, + "compose_file": { + "type": "string", + "description": "Docker Compose file content (YAML)" + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "value": {"type": "string"} + } + }, + "description": "Environment variables for the stack" + } + }, + "required": ["environment_id", "name", "compose_file"] + } + ), + types.Tool( + name="remove_stack", + description="Remove a Docker stack", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + }, + "stack_id": { + "type": "integer", + "description": "Stack ID" + } + }, + "required": ["environment_id", "stack_id"] + } + ), + # System Information + types.Tool( + name="get_docker_info", + description="Get Docker system information", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + } + }, + "required": ["environment_id"] + } + ), + types.Tool( + name="get_docker_version", + description="Get Docker version information", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + } + }, + "required": ["environment_id"] + } + ) + ] + + +async def make_request(method: str, endpoint: str, json_data: Optional[Dict] = None, + params: Optional[Dict] = None, text_response: bool = False) -> Dict[str, Any]: + """Make HTTP request to Portainer API.""" + import httpx + + async with httpx.AsyncClient(verify=False, timeout=30.0) as client: + headers = {"X-API-Key": api_key} if api_key else {} + + # Add JSON content type for POST/PUT requests + if method in ["POST", "PUT"] and json_data is not None: + headers["Content-Type"] = "application/json" + + if method == "GET": + response = await client.get(f"{portainer_url}/api{endpoint}", headers=headers, params=params) + elif method == "POST": + response = await client.post(f"{portainer_url}/api{endpoint}", headers=headers, json=json_data) + elif method == "PUT": + response = await client.put(f"{portainer_url}/api{endpoint}", headers=headers, json=json_data) + elif method == "DELETE": + response = await client.delete(f"{portainer_url}/api{endpoint}", headers=headers) + else: + raise ValueError(f"Unsupported method: {method}") + + # Handle text responses (like logs) + if text_response: + return {"status_code": response.status_code, "data": None, "text": response.text} + + # Parse JSON response safely + try: + data = response.json() if response.text and response.headers.get("content-type", "").startswith("application/json") else None + except Exception: + data = None + + return {"status_code": response.status_code, "data": data, "text": response.text} + + +def format_container_state(state: str) -> str: + """Format container state for display.""" + state_map = { + "running": "🟢 Running", + "paused": "⏸️ Paused", + "exited": "🔴 Stopped", + "dead": "💀 Dead", + "created": "🆕 Created", + "restarting": "🔄 Restarting", + "removing": "🗑️ Removing" + } + return state_map.get(state.lower(), state) + + +def format_bytes(bytes_val: int) -> str: + """Format bytes to human readable format.""" + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if bytes_val < 1024.0: + return f"{bytes_val:.1f} {unit}" + bytes_val /= 1024.0 + return f"{bytes_val:.1f} PB" + + +@server.call_tool() +async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]: + """Handle tool calls.""" + import httpx + + try: + env_id = arguments.get("environment_id") if arguments else None + + # Container Management + if name == "list_containers": + if not env_id: + return [types.TextContent(type="text", text="Error: environment_id is required")] + + all_containers = arguments.get("all", False) + params = {"all": "true"} if all_containers else {"all": "false"} + + result = await make_request("GET", f"/endpoints/{env_id}/docker/containers/json", params=params) + + if result["status_code"] == 200 and result["data"] is not None: + containers = result["data"] + if not containers: + return [types.TextContent(type="text", text="No containers found")] + + output = f"Found {len(containers)} container(s):\n" + for container in containers[:20]: # Limit to 20 containers + # Container names come with leading slash + names = ", ".join([n.lstrip("/") for n in container.get("Names", [])]) + state = container.get("State", "unknown") + status = container.get("Status", "") + image = container.get("Image", "unknown") + container_id = container.get("Id", "")[:12] # Short ID + + output += f"\n- {names} ({container_id})" + output += f"\n Image: {image}" + output += f"\n Status: {format_container_state(state)} - {status}" + + # Show ports if any + ports = container.get("Ports", []) + if ports: + port_info = [] + for port in ports: + if port.get("PublicPort"): + port_info.append(f"{port.get('PublicPort')}→{port.get('PrivatePort')}/{port.get('Type')}") + else: + port_info.append(f"{port.get('PrivatePort')}/{port.get('Type')}") + output += f"\n Ports: {', '.join(port_info)}" + + if len(containers) > 20: + output += f"\n\n... and {len(containers) - 20} more containers" + + return [types.TextContent(type="text", text=output)] + else: + return [types.TextContent(type="text", text=f"Failed to list containers: HTTP {result['status_code']}")] + + elif name == "inspect_container": + container_id = arguments.get("container_id") + if not env_id or not container_id: + return [types.TextContent(type="text", text="Error: environment_id and container_id are required")] + + result = await make_request("GET", f"/endpoints/{env_id}/docker/containers/{container_id}/json") + + if result["status_code"] == 200 and result["data"]: + info = result["data"] + output = f"Container Details:\n" + output += f"- ID: {info.get('Id', '')[:12]}\n" + output += f"- Name: {info.get('Name', '').lstrip('/')}\n" + output += f"- Image: {info.get('Config', {}).get('Image', '')}\n" + output += f"- Status: {format_container_state(info.get('State', {}).get('Status', ''))}\n" + output += f"- Created: {info.get('Created', '')}\n" + + # State details + state = info.get('State', {}) + if state.get('Running'): + output += f"- PID: {state.get('Pid')}\n" + output += f"- Started: {state.get('StartedAt')}\n" + + # Network info + networks = info.get('NetworkSettings', {}).get('Networks', {}) + if networks: + output += "\nNetworks:\n" + for net_name, net_info in networks.items(): + output += f"- {net_name}: {net_info.get('IPAddress', 'N/A')}\n" + + # Mounts + mounts = info.get('Mounts', []) + if mounts: + output += "\nMounts:\n" + for mount in mounts: + output += f"- {mount.get('Source')} → {mount.get('Destination')} ({mount.get('Mode', 'rw')})\n" + + # Environment variables + env_vars = info.get('Config', {}).get('Env', []) + if env_vars: + output += "\nEnvironment Variables:\n" + for env in env_vars[:10]: # Limit to 10 + if '=' in env and not any(secret in env.lower() for secret in ['password', 'secret', 'key', 'token']): + output += f"- {env}\n" + + return [types.TextContent(type="text", text=output)] + else: + return [types.TextContent(type="text", text=f"Failed to inspect container: HTTP {result['status_code']}")] + + elif name == "create_container": + image = arguments.get("image") + if not env_id or not image: + return [types.TextContent(type="text", text="Error: environment_id and image are required")] + + # Build container configuration + config = { + "Image": image, + "Hostname": arguments.get("name", ""), + "AttachStdin": False, + "AttachStdout": True, + "AttachStderr": True, + "Tty": False, + "OpenStdin": False + } + + if arguments.get("command"): + config["Cmd"] = arguments["command"] + + if arguments.get("env"): + config["Env"] = arguments["env"] + + # Host configuration + host_config = { + "RestartPolicy": {"Name": arguments.get("restart_policy", "no")} + } + + if arguments.get("ports"): + config["ExposedPorts"] = {port: {} for port in arguments["ports"].keys()} + host_config["PortBindings"] = arguments["ports"] + + if arguments.get("volumes"): + host_config["Binds"] = [] + for host_path, container_config in arguments["volumes"].items(): + bind = f"{host_path}:{container_config['bind']}" + if container_config.get('mode'): + bind += f":{container_config['mode']}" + host_config["Binds"].append(bind) + + create_data = { + **config, + "HostConfig": host_config + } + + # Create container + params = {"name": arguments.get("name")} if arguments.get("name") else {} + result = await make_request("POST", f"/endpoints/{env_id}/docker/containers/create", + json_data=create_data, params=params) + + if result["status_code"] in [200, 201] and result["data"]: + container_id = result["data"].get("Id", "")[:12] + output = f"✓ Container created: {container_id}" + + # Auto-start the container + start_result = await make_request("POST", f"/endpoints/{env_id}/docker/containers/{container_id}/start") + if start_result["status_code"] in [200, 204]: + output += "\n✓ Container started successfully" + else: + output += f"\n⚠️ Container created but failed to start: HTTP {start_result['status_code']}" + + return [types.TextContent(type="text", text=output)] + else: + error_msg = f"Failed to create container: HTTP {result['status_code']}" + if result.get("text"): + error_msg += f"\n{result['text']}" + return [types.TextContent(type="text", text=error_msg)] + + elif name == "start_container": + container_id = arguments.get("container_id") + if not env_id or not container_id: + return [types.TextContent(type="text", text="Error: environment_id and container_id are required")] + + result = await make_request("POST", f"/endpoints/{env_id}/docker/containers/{container_id}/start") + + if result["status_code"] in [200, 204, 304]: # 304 = already started + return [types.TextContent(type="text", text=f"✓ Container {container_id} started")] + else: + return [types.TextContent(type="text", text=f"Failed to start container: HTTP {result['status_code']}")] + + elif name == "stop_container": + container_id = arguments.get("container_id") + if not env_id or not container_id: + return [types.TextContent(type="text", text="Error: environment_id and container_id are required")] + + result = await make_request("POST", f"/endpoints/{env_id}/docker/containers/{container_id}/stop") + + if result["status_code"] in [200, 204, 304]: # 304 = already stopped + return [types.TextContent(type="text", text=f"✓ Container {container_id} stopped")] + else: + return [types.TextContent(type="text", text=f"Failed to stop container: HTTP {result['status_code']}")] + + elif name == "restart_container": + container_id = arguments.get("container_id") + if not env_id or not container_id: + return [types.TextContent(type="text", text="Error: environment_id and container_id are required")] + + result = await make_request("POST", f"/endpoints/{env_id}/docker/containers/{container_id}/restart") + + if result["status_code"] in [200, 204]: + return [types.TextContent(type="text", text=f"✓ Container {container_id} restarted")] + else: + return [types.TextContent(type="text", text=f"Failed to restart container: HTTP {result['status_code']}")] + + elif name == "remove_container": + container_id = arguments.get("container_id") + if not env_id or not container_id: + return [types.TextContent(type="text", text="Error: environment_id and container_id are required")] + + params = {"force": "true"} if arguments.get("force") else {} + result = await make_request("DELETE", f"/endpoints/{env_id}/docker/containers/{container_id}", params=params) + + if result["status_code"] in [200, 204]: + return [types.TextContent(type="text", text=f"✓ Container {container_id} removed")] + else: + return [types.TextContent(type="text", text=f"Failed to remove container: HTTP {result['status_code']}")] + + elif name == "get_container_logs": + container_id = arguments.get("container_id") + if not env_id or not container_id: + return [types.TextContent(type="text", text="Error: environment_id and container_id are required")] + + params = { + "stdout": "true", + "stderr": "true", + "tail": str(arguments.get("tail", 100)) + } + if arguments.get("timestamps"): + params["timestamps"] = "true" + + result = await make_request("GET", f"/endpoints/{env_id}/docker/containers/{container_id}/logs", + params=params, text_response=True) + + if result["status_code"] == 200: + logs = result.get("text", "") + if logs: + # Docker logs have special encoding, try to clean them up + import re + # Remove ANSI escape codes and Docker log headers + logs = re.sub(r'\x1b\[[0-9;]*m', '', logs) + logs = re.sub(r'[\x00-\x08\x0e-\x1f]', '', logs) + + output = f"Container logs (last {arguments.get('tail', 100)} lines):\n" + output += "-" * 50 + "\n" + output += logs + return [types.TextContent(type="text", text=output)] + else: + return [types.TextContent(type="text", text="No logs available")] + else: + return [types.TextContent(type="text", text=f"Failed to get logs: HTTP {result['status_code']}")] + + # Image Management + elif name == "list_images": + if not env_id: + return [types.TextContent(type="text", text="Error: environment_id is required")] + + result = await make_request("GET", f"/endpoints/{env_id}/docker/images/json") + + if result["status_code"] == 200 and result["data"]: + images = result["data"] + output = f"Found {len(images)} image(s):\n" + + for image in images[:20]: # Limit to 20 + tags = image.get("RepoTags", []) + if tags and tags[0] != ":": + image_name = tags[0] + else: + image_name = image.get("Id", "")[:12] + + size = format_bytes(image.get("Size", 0)) + created = image.get("Created", 0) + + output += f"\n- {image_name}" + output += f"\n ID: {image.get('Id', '')[:12]}" + output += f"\n Size: {size}" + + # Additional tags + if len(tags) > 1: + output += f"\n Other tags: {', '.join(tags[1:])}" + + if len(images) > 20: + output += f"\n\n... and {len(images) - 20} more images" + + return [types.TextContent(type="text", text=output)] + else: + return [types.TextContent(type="text", text=f"Failed to list images: HTTP {result['status_code']}")] + + elif name == "pull_image": + image = arguments.get("image") + if not env_id or not image: + return [types.TextContent(type="text", text="Error: environment_id and image are required")] + + # Parse image name and tag + if ":" in image: + image_name, tag = image.rsplit(":", 1) + else: + image_name = image + tag = "latest" + + params = {"fromImage": image_name, "tag": tag} + result = await make_request("POST", f"/endpoints/{env_id}/docker/images/create", params=params) + + if result["status_code"] == 200: + return [types.TextContent(type="text", text=f"✓ Image {image} pulled successfully")] + else: + return [types.TextContent(type="text", text=f"Failed to pull image: HTTP {result['status_code']}")] + + elif name == "remove_image": + image_id = arguments.get("image_id") + if not env_id or not image_id: + return [types.TextContent(type="text", text="Error: environment_id and image_id are required")] + + params = {"force": "true"} if arguments.get("force") else {} + result = await make_request("DELETE", f"/endpoints/{env_id}/docker/images/{image_id}", params=params) + + if result["status_code"] == 200: + return [types.TextContent(type="text", text=f"✓ Image {image_id} removed")] + else: + return [types.TextContent(type="text", text=f"Failed to remove image: HTTP {result['status_code']}")] + + # Volume Management + elif name == "list_volumes": + if not env_id: + return [types.TextContent(type="text", text="Error: environment_id is required")] + + result = await make_request("GET", f"/endpoints/{env_id}/docker/volumes") + + if result["status_code"] == 200 and result["data"]: + volumes = result["data"].get("Volumes", []) + output = f"Found {len(volumes)} volume(s):\n" + + for volume in volumes[:20]: + output += f"\n- {volume.get('Name')}" + output += f"\n Driver: {volume.get('Driver')}" + output += f"\n Mountpoint: {volume.get('Mountpoint')}" + if volume.get('Options'): + output += f"\n Options: {volume.get('Options')}" + + if len(volumes) > 20: + output += f"\n\n... and {len(volumes) - 20} more volumes" + + return [types.TextContent(type="text", text=output)] + else: + return [types.TextContent(type="text", text=f"Failed to list volumes: HTTP {result['status_code']}")] + + elif name == "create_volume": + volume_name = arguments.get("name") + if not env_id or not volume_name: + return [types.TextContent(type="text", text="Error: environment_id and name are required")] + + volume_data = { + "Name": volume_name, + "Driver": arguments.get("driver", "local") + } + + result = await make_request("POST", f"/endpoints/{env_id}/docker/volumes/create", json_data=volume_data) + + if result["status_code"] in [200, 201]: + return [types.TextContent(type="text", text=f"✓ Volume '{volume_name}' created")] + else: + return [types.TextContent(type="text", text=f"Failed to create volume: HTTP {result['status_code']}")] + + elif name == "remove_volume": + volume_name = arguments.get("volume_name") + if not env_id or not volume_name: + return [types.TextContent(type="text", text="Error: environment_id and volume_name are required")] + + result = await make_request("DELETE", f"/endpoints/{env_id}/docker/volumes/{volume_name}") + + if result["status_code"] in [200, 204]: + return [types.TextContent(type="text", text=f"✓ Volume '{volume_name}' removed")] + else: + return [types.TextContent(type="text", text=f"Failed to remove volume: HTTP {result['status_code']}")] + + # Network Management + elif name == "list_networks": + if not env_id: + return [types.TextContent(type="text", text="Error: environment_id is required")] + + result = await make_request("GET", f"/endpoints/{env_id}/docker/networks") + + if result["status_code"] == 200 and result["data"]: + networks = result["data"] + output = f"Found {len(networks)} network(s):\n" + + for network in networks: + output += f"\n- {network.get('Name')} ({network.get('Id', '')[:12]})" + output += f"\n Driver: {network.get('Driver')}" + output += f"\n Scope: {network.get('Scope')}" + if network.get('Internal'): + output += f"\n Internal: Yes" + if network.get('Containers'): + output += f"\n Containers: {len(network['Containers'])}" + + return [types.TextContent(type="text", text=output)] + else: + return [types.TextContent(type="text", text=f"Failed to list networks: HTTP {result['status_code']}")] + + elif name == "create_network": + network_name = arguments.get("name") + if not env_id or not network_name: + return [types.TextContent(type="text", text="Error: environment_id and name are required")] + + network_data = { + "Name": network_name, + "Driver": arguments.get("driver", "bridge"), + "Internal": arguments.get("internal", False) + } + + result = await make_request("POST", f"/endpoints/{env_id}/docker/networks/create", json_data=network_data) + + if result["status_code"] in [200, 201]: + return [types.TextContent(type="text", text=f"✓ Network '{network_name}' created")] + else: + return [types.TextContent(type="text", text=f"Failed to create network: HTTP {result['status_code']}")] + + elif name == "remove_network": + network_id = arguments.get("network_id") + if not env_id or not network_id: + return [types.TextContent(type="text", text="Error: environment_id and network_id are required")] + + result = await make_request("DELETE", f"/endpoints/{env_id}/docker/networks/{network_id}") + + if result["status_code"] in [200, 204]: + return [types.TextContent(type="text", text=f"✓ Network '{network_id}' removed")] + else: + return [types.TextContent(type="text", text=f"Failed to remove network: HTTP {result['status_code']}")] + + # Docker Swarm Services + elif name == "list_services": + if not env_id: + return [types.TextContent(type="text", text="Error: environment_id is required")] + + result = await make_request("GET", f"/endpoints/{env_id}/docker/services") + + if result["status_code"] == 200 and result["data"]: + services = result["data"] + output = f"Found {len(services)} service(s):\n" + + for service in services[:20]: + spec = service.get("Spec", {}) + mode = spec.get("Mode", {}) + + # Get replica info + if "Replicated" in mode: + replicas = mode["Replicated"].get("Replicas", 0) + mode_str = f"replicated ({replicas} replicas)" + else: + mode_str = "global" + + output += f"\n- {spec.get('Name')} ({service.get('ID', '')[:12]})" + output += f"\n Image: {spec.get('TaskTemplate', {}).get('ContainerSpec', {}).get('Image', 'unknown')}" + output += f"\n Mode: {mode_str}" + + # Get current state + if service.get("UpdateStatus"): + status = service["UpdateStatus"].get("State", "") + if status: + output += f"\n Update Status: {status}" + + if len(services) > 20: + output += f"\n\n... and {len(services) - 20} more services" + + return [types.TextContent(type="text", text=output)] + else: + error_msg = f"Failed to list services: HTTP {result['status_code']}" + if result['status_code'] == 503: + error_msg += "\nNote: This environment might not be a Docker Swarm cluster" + return [types.TextContent(type="text", text=error_msg)] + + elif name == "create_service": + service_name = arguments.get("name") + image = arguments.get("image") + if not env_id or not service_name or not image: + return [types.TextContent(type="text", text="Error: environment_id, name, and image are required")] + + service_spec = { + "Name": service_name, + "TaskTemplate": { + "ContainerSpec": { + "Image": image + } + }, + "Mode": { + "Replicated": { + "Replicas": arguments.get("replicas", 1) + } + } + } + + # Add command if provided + if arguments.get("command"): + service_spec["TaskTemplate"]["ContainerSpec"]["Command"] = arguments["command"] + + # Add environment variables + if arguments.get("env"): + service_spec["TaskTemplate"]["ContainerSpec"]["Env"] = arguments["env"] + + # Add ports + if arguments.get("ports"): + service_spec["EndpointSpec"] = { + "Ports": arguments["ports"] + } + + result = await make_request("POST", f"/endpoints/{env_id}/docker/services/create", json_data=service_spec) + + if result["status_code"] in [200, 201]: + return [types.TextContent(type="text", text=f"✓ Service '{service_name}' created")] + else: + error_msg = f"Failed to create service: HTTP {result['status_code']}" + if result.get("text"): + error_msg += f"\n{result['text']}" + return [types.TextContent(type="text", text=error_msg)] + + elif name == "update_service": + service_id = arguments.get("service_id") + if not env_id or not service_id: + return [types.TextContent(type="text", text="Error: environment_id and service_id are required")] + + # Get current service spec + result = await make_request("GET", f"/endpoints/{env_id}/docker/services/{service_id}") + if result["status_code"] != 200: + return [types.TextContent(type="text", text=f"Failed to get service: HTTP {result['status_code']}")] + + service = result["data"] + spec = service.get("Spec", {}) + version = service.get("Version", {}).get("Index") + + # Update fields if provided + if arguments.get("image"): + spec["TaskTemplate"]["ContainerSpec"]["Image"] = arguments["image"] + + if arguments.get("replicas") is not None: + if "Replicated" in spec.get("Mode", {}): + spec["Mode"]["Replicated"]["Replicas"] = arguments["replicas"] + + if arguments.get("env"): + spec["TaskTemplate"]["ContainerSpec"]["Env"] = arguments["env"] + + # Update service + result = await make_request("POST", f"/endpoints/{env_id}/docker/services/{service_id}/update", + json_data=spec, params={"version": version}) + + if result["status_code"] == 200: + return [types.TextContent(type="text", text=f"✓ Service '{service_id}' updated")] + else: + return [types.TextContent(type="text", text=f"Failed to update service: HTTP {result['status_code']}")] + + elif name == "remove_service": + service_id = arguments.get("service_id") + if not env_id or not service_id: + return [types.TextContent(type="text", text="Error: environment_id and service_id are required")] + + result = await make_request("DELETE", f"/endpoints/{env_id}/docker/services/{service_id}") + + if result["status_code"] in [200, 204]: + return [types.TextContent(type="text", text=f"✓ Service '{service_id}' removed")] + else: + return [types.TextContent(type="text", text=f"Failed to remove service: HTTP {result['status_code']}")] + + elif name == "get_service_logs": + service_id = arguments.get("service_id") + if not env_id or not service_id: + return [types.TextContent(type="text", text="Error: environment_id and service_id are required")] + + params = { + "stdout": "true", + "stderr": "true", + "tail": str(arguments.get("tail", 100)) + } + + result = await make_request("GET", f"/endpoints/{env_id}/docker/services/{service_id}/logs", + params=params, text_response=True) + + if result["status_code"] == 200: + logs = result.get("text", "") + if logs: + output = f"Service logs (last {arguments.get('tail', 100)} lines):\n" + output += "-" * 50 + "\n" + output += logs + return [types.TextContent(type="text", text=output)] + else: + return [types.TextContent(type="text", text="No logs available")] + else: + return [types.TextContent(type="text", text=f"Failed to get logs: HTTP {result['status_code']}")] + + # Stack Management + elif name == "list_stacks": + if not env_id: + return [types.TextContent(type="text", text="Error: environment_id is required")] + + result = await make_request("GET", "/stacks") + + if result["status_code"] == 200 and result["data"]: + stacks = result["data"] + # Filter stacks for this environment + env_stacks = [s for s in stacks if s.get("EndpointId") == env_id] + + output = f"Found {len(env_stacks)} stack(s) in this environment:\n" + + for stack in env_stacks: + output += f"\n- {stack.get('Name')} (ID: {stack.get('Id')})" + output += f"\n Type: {stack.get('Type', 'unknown')}" + output += f"\n Status: {stack.get('Status', 'unknown')}" + if stack.get("GitConfig"): + output += f"\n Git Repository: Yes" + + return [types.TextContent(type="text", text=output)] + else: + return [types.TextContent(type="text", text=f"Failed to list stacks: HTTP {result['status_code']}")] + + elif name == "deploy_stack": + stack_name = arguments.get("name") + compose_file = arguments.get("compose_file") + if not env_id or not stack_name or not compose_file: + return [types.TextContent(type="text", text="Error: environment_id, name, and compose_file are required")] + + stack_data = { + "Name": stack_name, + "StackFileContent": compose_file, + "Env": arguments.get("env", []) + } + + params = { + "type": 2, # Compose stack + "method": "string", # Stack file provided as string + "endpointId": env_id + } + + result = await make_request("POST", "/stacks", json_data=stack_data, params=params) + + if result["status_code"] in [200, 201]: + return [types.TextContent(type="text", text=f"✓ Stack '{stack_name}' deployed successfully")] + else: + error_msg = f"Failed to deploy stack: HTTP {result['status_code']}" + if result.get("text"): + error_msg += f"\n{result['text']}" + return [types.TextContent(type="text", text=error_msg)] + + elif name == "remove_stack": + stack_id = arguments.get("stack_id") + if not env_id or not stack_id: + return [types.TextContent(type="text", text="Error: environment_id and stack_id are required")] + + result = await make_request("DELETE", f"/stacks/{stack_id}") + + if result["status_code"] in [200, 204]: + return [types.TextContent(type="text", text=f"✓ Stack removed successfully")] + else: + return [types.TextContent(type="text", text=f"Failed to remove stack: HTTP {result['status_code']}")] + + # System Information + elif name == "get_docker_info": + if not env_id: + return [types.TextContent(type="text", text="Error: environment_id is required")] + + result = await make_request("GET", f"/endpoints/{env_id}/docker/info") + + if result["status_code"] == 200 and result["data"]: + info = result["data"] + output = "Docker System Information:\n" + output += f"- Docker Version: {info.get('ServerVersion', 'unknown')}\n" + output += f"- API Version: {info.get('ApiVersion', 'unknown')}\n" + output += f"- Operating System: {info.get('OperatingSystem', 'unknown')}\n" + output += f"- Architecture: {info.get('Architecture', 'unknown')}\n" + output += f"- Kernel Version: {info.get('KernelVersion', 'unknown')}\n" + output += f"- Total Memory: {format_bytes(info.get('MemTotal', 0))}\n" + output += f"- CPUs: {info.get('NCPU', 0)}\n" + output += f"- Containers: {info.get('Containers', 0)} ({info.get('ContainersRunning', 0)} running)\n" + output += f"- Images: {info.get('Images', 0)}\n" + + # Swarm info + swarm = info.get('Swarm', {}) + if swarm.get('LocalNodeState') == 'active': + output += f"\nSwarm Status:\n" + output += f"- Node ID: {swarm.get('NodeID', '')[:12]}\n" + output += f"- Is Manager: {swarm.get('ControlAvailable', False)}\n" + if swarm.get('RemoteManagers'): + output += f"- Managers: {len(swarm['RemoteManagers'])}\n" + + return [types.TextContent(type="text", text=output)] + else: + return [types.TextContent(type="text", text=f"Failed to get Docker info: HTTP {result['status_code']}")] + + elif name == "get_docker_version": + if not env_id: + return [types.TextContent(type="text", text="Error: environment_id is required")] + + result = await make_request("GET", f"/endpoints/{env_id}/docker/version") + + if result["status_code"] == 200 and result["data"]: + version = result["data"] + output = "Docker Version Information:\n" + output += f"- Version: {version.get('Version', 'unknown')}\n" + output += f"- API Version: {version.get('ApiVersion', 'unknown')}\n" + output += f"- Git Commit: {version.get('GitCommit', 'unknown')}\n" + output += f"- Go Version: {version.get('GoVersion', 'unknown')}\n" + output += f"- OS/Arch: {version.get('Os', 'unknown')}/{version.get('Arch', 'unknown')}\n" + output += f"- Build Time: {version.get('BuildTime', 'unknown')}\n" + + return [types.TextContent(type="text", text=output)] + else: + return [types.TextContent(type="text", text=f"Failed to get Docker version: HTTP {result['status_code']}")] + + else: + return [types.TextContent(type="text", text=f"Unknown tool: {name}")] + + except httpx.TimeoutException: + return [types.TextContent(type="text", text="Error: Request timed out. The operation may take longer than expected.")] + except httpx.ConnectError: + return [types.TextContent(type="text", text="Error: Could not connect to Portainer server. Please check the URL and network connection.")] + except Exception as e: + import traceback + error_details = f"Error: {str(e)}\nType: {type(e).__name__}" + return [types.TextContent(type="text", text=error_details)] + + +async def run(): + """Run the MCP server.""" + # Use stdio transport + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="portainer-docker", + server_version="1.0.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions( + prompts_changed=False, + resources_changed=False, + tools_changed=False, + ), + experimental_capabilities={}, + ), + ), + ) + + +def main(): + """Main entry point.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/portainer_environments_server.py b/portainer_environments_server.py new file mode 100755 index 0000000..d63c93a --- /dev/null +++ b/portainer_environments_server.py @@ -0,0 +1,861 @@ +#!/usr/bin/env python3 +"""MCP server for Portainer Environments management.""" + +import os +import sys +import json +import asyncio +from typing import Any, Dict, List, Optional +from enum import Enum + +# Suppress all logging to stderr +os.environ["MCP_MODE"] = "true" + +import mcp.server.stdio +import mcp.types as types +from mcp.server import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +# Create server +server = Server("portainer-environments") + +# Store for our state +portainer_url = os.getenv("PORTAINER_URL", "https://partner.portainer.live") +api_key = os.getenv("PORTAINER_API_KEY", "") + + +# Environment types enum +class EnvironmentType(Enum): + DOCKER = 1 + DOCKER_SWARM = 2 + KUBERNETES = 3 + ACI = 4 + EDGE_AGENT = 5 + + +@server.list_tools() +async def handle_list_tools() -> list[types.Tool]: + """List available tools.""" + return [ + # Basic environment operations + types.Tool( + name="list_environments", + description="List all environments/endpoints", + inputSchema={ + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "Number of environments to return (default: all)", + "default": 100 + }, + "start": { + "type": "integer", + "description": "Starting index for pagination", + "default": 0 + } + }, + "required": [] + } + ), + types.Tool( + name="get_environment", + description="Get details of a specific environment", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + } + }, + "required": ["environment_id"] + } + ), + types.Tool( + name="create_docker_environment", + description="Create a new Docker environment", + inputSchema={ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the environment" + }, + "url": { + "type": "string", + "description": "Docker API URL (e.g., tcp://localhost:2375 or unix:///var/run/docker.sock)" + }, + "public_url": { + "type": "string", + "description": "Public URL for accessing the environment (optional)" + }, + "group_id": { + "type": "integer", + "description": "Environment group ID (optional)" + }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "Array of tags for the environment (optional)" + }, + "tls": { + "type": "boolean", + "description": "Enable TLS", + "default": False + }, + "tls_skip_verify": { + "type": "boolean", + "description": "Skip TLS certificate verification", + "default": False + } + }, + "required": ["name", "url"] + } + ), + types.Tool( + name="create_kubernetes_environment", + description="Create a new Kubernetes environment", + inputSchema={ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the environment" + }, + "url": { + "type": "string", + "description": "Kubernetes API server URL" + }, + "bearer_token": { + "type": "string", + "description": "Bearer token for authentication" + }, + "group_id": { + "type": "integer", + "description": "Environment group ID (optional)" + }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "Array of tags for the environment (optional)" + }, + "tls_skip_verify": { + "type": "boolean", + "description": "Skip TLS certificate verification", + "default": False + } + }, + "required": ["name", "url"] + } + ), + types.Tool( + name="update_environment", + description="Update an existing environment", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment to update" + }, + "name": { + "type": "string", + "description": "New name for the environment (optional)" + }, + "url": { + "type": "string", + "description": "New URL for the environment (optional)" + }, + "public_url": { + "type": "string", + "description": "New public URL (optional)" + }, + "group_id": { + "type": "integer", + "description": "New group ID (optional)" + }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "New tags array (optional)" + } + }, + "required": ["environment_id"] + } + ), + types.Tool( + name="delete_environment", + description="Delete an environment", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment to delete" + } + }, + "required": ["environment_id"] + } + ), + # Environment status and management + types.Tool( + name="get_environment_status", + description="Get status and statistics of an environment", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + } + }, + "required": ["environment_id"] + } + ), + types.Tool( + name="associate_environment", + description="Associate/disassociate environment with teams", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "integer", + "description": "ID of the environment" + }, + "teams": { + "type": "array", + "items": { + "type": "object", + "properties": { + "team_id": {"type": "integer"}, + "access_level": {"type": "string", "enum": ["read", "write"]} + } + }, + "description": "Array of team associations" + } + }, + "required": ["environment_id", "teams"] + } + ), + # Environment groups + types.Tool( + name="list_environment_groups", + description="List all environment groups", + inputSchema={ + "type": "object", + "properties": {}, + "required": [] + } + ), + types.Tool( + name="create_environment_group", + description="Create a new environment group", + inputSchema={ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the environment group" + }, + "description": { + "type": "string", + "description": "Description of the group (optional)" + } + }, + "required": ["name"] + } + ), + types.Tool( + name="update_environment_group", + description="Update an environment group", + inputSchema={ + "type": "object", + "properties": { + "group_id": { + "type": "integer", + "description": "ID of the group to update" + }, + "name": { + "type": "string", + "description": "New name for the group (optional)" + }, + "description": { + "type": "string", + "description": "New description (optional)" + } + }, + "required": ["group_id"] + } + ), + types.Tool( + name="delete_environment_group", + description="Delete an environment group", + inputSchema={ + "type": "object", + "properties": { + "group_id": { + "type": "integer", + "description": "ID of the group to delete" + } + }, + "required": ["group_id"] + } + ), + # Tags management + types.Tool( + name="list_tags", + description="List all available tags", + inputSchema={ + "type": "object", + "properties": {}, + "required": [] + } + ), + types.Tool( + name="create_tag", + description="Create a new tag", + inputSchema={ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the tag" + } + }, + "required": ["name"] + } + ), + types.Tool( + name="delete_tag", + description="Delete a tag", + inputSchema={ + "type": "object", + "properties": { + "tag_id": { + "type": "integer", + "description": "ID of the tag to delete" + } + }, + "required": ["tag_id"] + } + ), + # Edge environments + types.Tool( + name="generate_edge_key", + description="Generate Edge agent deployment script", + inputSchema={ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name for the Edge environment" + }, + "group_id": { + "type": "integer", + "description": "Environment group ID (optional)" + } + }, + "required": ["name"] + } + ) + ] + + +async def make_request(method: str, endpoint: str, json_data: Optional[Dict] = None) -> Dict[str, Any]: + """Make HTTP request to Portainer API.""" + import httpx + + async with httpx.AsyncClient(verify=False, timeout=30.0) as client: + headers = {"X-API-Key": api_key} if api_key else {} + + if method == "GET": + response = await client.get(f"{portainer_url}/api{endpoint}", headers=headers) + elif method == "POST": + response = await client.post(f"{portainer_url}/api{endpoint}", headers=headers, json=json_data) + elif method == "PUT": + response = await client.put(f"{portainer_url}/api{endpoint}", headers=headers, json=json_data) + elif method == "DELETE": + response = await client.delete(f"{portainer_url}/api{endpoint}", headers=headers) + else: + raise ValueError(f"Unsupported method: {method}") + + # Parse JSON response safely + try: + data = response.json() if response.text and response.headers.get("content-type", "").startswith("application/json") else None + except Exception: + data = None + + return {"status_code": response.status_code, "data": data, "text": response.text} + + +def format_environment_type(env_type: int) -> str: + """Convert environment type ID to readable string.""" + type_map = { + 1: "Docker", + 2: "Docker Swarm", + 3: "Kubernetes", + 4: "Azure ACI", + 5: "Edge Agent", + 6: "Local Docker", + 7: "Local Kubernetes" + } + return type_map.get(env_type, f"Unknown({env_type})") + + +def format_environment_status(status: int) -> str: + """Convert environment status to readable string.""" + status_map = { + 1: "up", + 2: "down" + } + return status_map.get(status, f"unknown({status})") + + +@server.call_tool() +async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]: + """Handle tool calls.""" + import httpx + + try: + # Basic environment operations + if name == "list_environments": + limit = arguments.get("limit", 100) if arguments else 100 + start = arguments.get("start", 0) if arguments else 0 + + # Try both /environments (new) and /endpoints (old) endpoints + result = await make_request("GET", f"/environments?limit={limit}&start={start}") + if result["status_code"] == 404: + # Legacy endpoint doesn't support pagination params in URL + result = await make_request("GET", "/endpoints") + + if result["status_code"] == 200 and result["data"] is not None: + environments = result["data"] + + # Handle pagination manually for legacy endpoint + if isinstance(environments, list): + total = len(environments) + environments = environments[start:start + limit] + output = f"Found {total} environments (showing {len(environments)}):\n" + else: + output = f"Found environments:\n" + + for env in environments[:10]: # Limit output to first 10 to avoid huge responses + env_type = format_environment_type(env.get("Type", 0)) + status = format_environment_status(env.get("Status", 0)) + output += f"\n- ID: {env.get('Id')}, Name: {env.get('Name')}" + output += f"\n Type: {env_type}, Status: {status}" + output += f"\n URL: {env.get('URL', 'N/A')}" + if env.get("GroupId"): + output += f"\n Group ID: {env.get('GroupId')}" + if env.get("TagIds") or env.get("Tags"): + tags = env.get("Tags", []) + if tags: + tag_names = [t.get('Name', '') for t in tags if isinstance(t, dict)] + if tag_names: + output += f"\n Tags: {', '.join(tag_names)}" + + if len(environments) > 10: + output += f"\n\n... and {len(environments) - 10} more environments" + + return [types.TextContent(type="text", text=output)] + else: + error_msg = f"Failed to list environments: HTTP {result['status_code']}" + if result.get("text"): + error_msg += f" - {result['text'][:200]}" + return [types.TextContent(type="text", text=error_msg)] + + elif name == "get_environment": + env_id = arguments.get("environment_id") + if not env_id: + return [types.TextContent(type="text", text="Error: environment_id is required")] + + # Try both endpoints + result = await make_request("GET", f"/environments/{env_id}") + if result["status_code"] == 404: + result = await make_request("GET", f"/endpoints/{env_id}") + + if result["status_code"] == 200: + env = result["data"] + output = f"Environment Details:\n" + output += f"- ID: {env.get('Id')}\n" + output += f"- Name: {env.get('Name')}\n" + output += f"- Type: {format_environment_type(env.get('Type', 0))}\n" + output += f"- Status: {format_environment_status(env.get('Status', 0))}\n" + output += f"- URL: {env.get('URL', 'N/A')}\n" + output += f"- Public URL: {env.get('PublicURL', 'N/A')}\n" + if env.get("GroupId"): + output += f"- Group ID: {env.get('GroupId')}\n" + if env.get("Tags"): + output += f"- Tags: {', '.join([t.get('Name', '') for t in env.get('Tags', [])])}\n" + if env.get("TLSConfig"): + output += f"- TLS Enabled: {env['TLSConfig'].get('TLS', False)}\n" + output += f"- TLS Skip Verify: {env['TLSConfig'].get('TLSSkipVerify', False)}\n" + return [types.TextContent(type="text", text=output)] + else: + return [types.TextContent(type="text", text=f"Failed to get environment: HTTP {result['status_code']}")] + + elif name == "create_docker_environment": + req_name = arguments.get("name") + url = arguments.get("url") + + if not req_name or not url: + return [types.TextContent(type="text", text="Error: name and url are required")] + + env_data = { + "Name": req_name, + "Type": EnvironmentType.DOCKER.value, + "URL": url, + "EndpointCreationType": 1 # Local environment + } + + if arguments.get("public_url"): + env_data["PublicURL"] = arguments["public_url"] + if arguments.get("group_id"): + env_data["GroupId"] = arguments["group_id"] + if arguments.get("tags"): + env_data["TagIds"] = arguments["tags"] + + # TLS configuration + if arguments.get("tls") or arguments.get("tls_skip_verify"): + env_data["TLSConfig"] = { + "TLS": arguments.get("tls", False), + "TLSSkipVerify": arguments.get("tls_skip_verify", False) + } + + # Try both endpoints + result = await make_request("POST", "/environments", env_data) + if result["status_code"] == 404: + result = await make_request("POST", "/endpoints", env_data) + + if result["status_code"] in [200, 201]: + return [types.TextContent(type="text", text=f"✓ Docker environment '{req_name}' created successfully")] + else: + return [types.TextContent(type="text", text=f"Failed to create Docker environment: HTTP {result['status_code']} - {result['text']}")] + + elif name == "create_kubernetes_environment": + req_name = arguments.get("name") + url = arguments.get("url") + + if not req_name or not url: + return [types.TextContent(type="text", text="Error: name and url are required")] + + env_data = { + "Name": req_name, + "Type": EnvironmentType.KUBERNETES.value, + "URL": url, + "EndpointCreationType": 3 # Kubernetes environment + } + + if arguments.get("bearer_token"): + env_data["Token"] = arguments["bearer_token"] + if arguments.get("group_id"): + env_data["GroupId"] = arguments["group_id"] + if arguments.get("tags"): + env_data["TagIds"] = arguments["tags"] + if arguments.get("tls_skip_verify"): + env_data["TLSConfig"] = { + "TLS": True, + "TLSSkipVerify": arguments["tls_skip_verify"] + } + + # Try both endpoints + result = await make_request("POST", "/environments", env_data) + if result["status_code"] == 404: + result = await make_request("POST", "/endpoints", env_data) + + if result["status_code"] in [200, 201]: + return [types.TextContent(type="text", text=f"✓ Kubernetes environment '{req_name}' created successfully")] + else: + return [types.TextContent(type="text", text=f"Failed to create Kubernetes environment: HTTP {result['status_code']} - {result['text']}")] + + elif name == "update_environment": + env_id = arguments.get("environment_id") + if not env_id: + return [types.TextContent(type="text", text="Error: environment_id is required")] + + # Get current environment first + result = await make_request("GET", f"/environments/{env_id}") + if result["status_code"] == 404: + result = await make_request("GET", f"/endpoints/{env_id}") + + if result["status_code"] != 200: + return [types.TextContent(type="text", text=f"Failed to get current environment: HTTP {result['status_code']}")] + + env_data = result["data"] + + # Update only provided fields + if "name" in arguments: + env_data["Name"] = arguments["name"] + if "url" in arguments: + env_data["URL"] = arguments["url"] + if "public_url" in arguments: + env_data["PublicURL"] = arguments["public_url"] + if "group_id" in arguments: + env_data["GroupId"] = arguments["group_id"] + if "tags" in arguments: + env_data["TagIds"] = arguments["tags"] + + # Try both endpoints + result = await make_request("PUT", f"/environments/{env_id}", env_data) + if result["status_code"] == 404: + result = await make_request("PUT", f"/endpoints/{env_id}", env_data) + + if result["status_code"] == 200: + return [types.TextContent(type="text", text=f"✓ Environment {env_id} updated successfully")] + else: + return [types.TextContent(type="text", text=f"Failed to update environment: HTTP {result['status_code']}")] + + elif name == "delete_environment": + env_id = arguments.get("environment_id") + if not env_id: + return [types.TextContent(type="text", text="Error: environment_id is required")] + + # Try both endpoints + result = await make_request("DELETE", f"/environments/{env_id}") + if result["status_code"] == 404: + result = await make_request("DELETE", f"/endpoints/{env_id}") + + if result["status_code"] in [200, 204]: + return [types.TextContent(type="text", text=f"✓ Environment {env_id} deleted successfully")] + else: + return [types.TextContent(type="text", text=f"Failed to delete environment: HTTP {result['status_code']}")] + + # Environment status and management + elif name == "get_environment_status": + env_id = arguments.get("environment_id") + if not env_id: + return [types.TextContent(type="text", text="Error: environment_id is required")] + + # Try to get docker info through Portainer proxy + result = await make_request("GET", f"/environments/{env_id}/docker/info") + if result["status_code"] == 404: + result = await make_request("GET", f"/endpoints/{env_id}/docker/info") + + if result["status_code"] == 200: + info = result["data"] + output = f"Environment Status:\n" + output += f"- Status: up\n" + output += f"- Docker Version: {info.get('ServerVersion', 'N/A')}\n" + output += f"- Containers: {info.get('Containers', 0)}\n" + output += f"- Running: {info.get('ContainersRunning', 0)}\n" + output += f"- Stopped: {info.get('ContainersStopped', 0)}\n" + output += f"- Images: {info.get('Images', 0)}\n" + output += f"- CPU Count: {info.get('NCPU', 0)}\n" + output += f"- Memory: {info.get('MemTotal', 0) / (1024**3):.2f} GB\n" + return [types.TextContent(type="text", text=output)] + else: + return [types.TextContent(type="text", text="Environment is down or inaccessible")] + + elif name == "associate_environment": + env_id = arguments.get("environment_id") + teams = arguments.get("teams", []) + + if not env_id: + return [types.TextContent(type="text", text="Error: environment_id is required")] + + assoc_data = { + "TeamAccessPolicies": {} + } + + for team in teams: + team_id = team.get("team_id") + access_level = team.get("access_level", "read") + if team_id: + assoc_data["TeamAccessPolicies"][str(team_id)] = {"AccessLevel": access_level} + + # Try both endpoints + result = await make_request("PUT", f"/environments/{env_id}/association", assoc_data) + if result["status_code"] == 404: + result = await make_request("PUT", f"/endpoints/{env_id}/association", assoc_data) + + if result["status_code"] == 200: + return [types.TextContent(type="text", text=f"✓ Environment {env_id} team associations updated")] + else: + return [types.TextContent(type="text", text=f"Failed to update associations: HTTP {result['status_code']}")] + + # Environment groups + elif name == "list_environment_groups": + result = await make_request("GET", "/endpoint_groups") + + if result["status_code"] == 200: + groups = result["data"] + output = f"Found {len(groups)} environment groups:\n" + for group in groups: + output += f"- ID: {group.get('Id')}, Name: {group.get('Name')}\n" + if group.get('Description'): + output += f" Description: {group.get('Description')}\n" + return [types.TextContent(type="text", text=output)] + else: + return [types.TextContent(type="text", text=f"Failed to list groups: HTTP {result['status_code']}")] + + elif name == "create_environment_group": + group_name = arguments.get("name") + if not group_name: + return [types.TextContent(type="text", text="Error: name is required")] + + group_data = { + "Name": group_name, + "Description": arguments.get("description", "") + } + + result = await make_request("POST", "/endpoint_groups", group_data) + + if result["status_code"] in [200, 201]: + return [types.TextContent(type="text", text=f"✓ Environment group '{group_name}' created successfully")] + else: + return [types.TextContent(type="text", text=f"Failed to create group: HTTP {result['status_code']}")] + + elif name == "update_environment_group": + group_id = arguments.get("group_id") + if not group_id: + return [types.TextContent(type="text", text="Error: group_id is required")] + + update_data = {} + if "name" in arguments: + update_data["Name"] = arguments["name"] + if "description" in arguments: + update_data["Description"] = arguments["description"] + + result = await make_request("PUT", f"/endpoint_groups/{group_id}", update_data) + + if result["status_code"] == 200: + return [types.TextContent(type="text", text=f"✓ Environment group {group_id} updated successfully")] + else: + return [types.TextContent(type="text", text=f"Failed to update group: HTTP {result['status_code']}")] + + elif name == "delete_environment_group": + group_id = arguments.get("group_id") + if not group_id: + return [types.TextContent(type="text", text="Error: group_id is required")] + + result = await make_request("DELETE", f"/endpoint_groups/{group_id}") + + if result["status_code"] in [200, 204]: + return [types.TextContent(type="text", text=f"✓ Environment group {group_id} deleted successfully")] + else: + return [types.TextContent(type="text", text=f"Failed to delete group: HTTP {result['status_code']}")] + + # Tags management + elif name == "list_tags": + result = await make_request("GET", "/tags") + + if result["status_code"] == 200: + tags = result["data"] + output = f"Found {len(tags)} tags:\n" + for tag in tags: + output += f"- ID: {tag.get('ID')}, Name: {tag.get('Name')}\n" + return [types.TextContent(type="text", text=output)] + else: + return [types.TextContent(type="text", text=f"Failed to list tags: HTTP {result['status_code']}")] + + elif name == "create_tag": + tag_name = arguments.get("name") + if not tag_name: + return [types.TextContent(type="text", text="Error: name is required")] + + tag_data = {"Name": tag_name} + result = await make_request("POST", "/tags", tag_data) + + if result["status_code"] in [200, 201]: + return [types.TextContent(type="text", text=f"✓ Tag '{tag_name}' created successfully")] + else: + return [types.TextContent(type="text", text=f"Failed to create tag: HTTP {result['status_code']}")] + + elif name == "delete_tag": + tag_id = arguments.get("tag_id") + if not tag_id: + return [types.TextContent(type="text", text="Error: tag_id is required")] + + result = await make_request("DELETE", f"/tags/{tag_id}") + + if result["status_code"] in [200, 204]: + return [types.TextContent(type="text", text=f"✓ Tag {tag_id} deleted successfully")] + else: + return [types.TextContent(type="text", text=f"Failed to delete tag: HTTP {result['status_code']}")] + + # Edge environments + elif name == "generate_edge_key": + edge_name = arguments.get("name") + if not edge_name: + return [types.TextContent(type="text", text="Error: name is required")] + + edge_data = { + "Name": edge_name, + "Type": EnvironmentType.EDGE_AGENT.value, + "EndpointCreationType": 4 # Edge agent + } + + if arguments.get("group_id"): + edge_data["GroupId"] = arguments["group_id"] + + # Try both endpoints + result = await make_request("POST", "/environments", edge_data) + if result["status_code"] == 404: + result = await make_request("POST", "/endpoints", edge_data) + + if result["status_code"] in [200, 201]: + env_id = result["data"].get("Id") + edge_key = result["data"].get("EdgeKey", "") + output = f"✓ Edge environment '{edge_name}' created\n" + output += f"- Environment ID: {env_id}\n" + output += f"- Edge Key: {edge_key}\n" + output += f"\nDeployment command:\n" + output += f"docker run -d --name portainer_edge_agent --restart always \\\n" + output += f" -v /var/run/docker.sock:/var/run/docker.sock \\\n" + output += f" -v /var/lib/docker/volumes:/var/lib/docker/volumes \\\n" + output += f" -v /:/host \\\n" + output += f" -v portainer_agent_data:/data \\\n" + output += f" --env EDGE=1 \\\n" + output += f" --env EDGE_ID={env_id} \\\n" + output += f" --env EDGE_KEY={edge_key} \\\n" + output += f" --env EDGE_INSECURE_POLL=1 \\\n" + output += f" portainer/agent:latest" + return [types.TextContent(type="text", text=output)] + else: + return [types.TextContent(type="text", text=f"Failed to create Edge environment: HTTP {result['status_code']}")] + + else: + return [types.TextContent(type="text", text=f"Unknown tool: {name}")] + + except httpx.TimeoutException: + return [types.TextContent(type="text", text="Error: Request timed out. The Portainer server may be slow or unresponsive.")] + except httpx.ConnectError: + return [types.TextContent(type="text", text="Error: Could not connect to Portainer server. Please check the URL and network connection.")] + except Exception as e: + import traceback + error_details = f"Error: {str(e)}\nType: {type(e).__name__}" + if hasattr(e, "__traceback__"): + error_details += f"\nTraceback: {traceback.format_exc()}" + return [types.TextContent(type="text", text=error_details)] + + +async def run(): + """Run the MCP server.""" + # Use stdio transport + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="portainer-environments", + server_version="1.0.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions( + prompts_changed=False, + resources_changed=False, + tools_changed=False, + ), + experimental_capabilities={}, + ), + ), + ) + + +def main(): + """Main entry point.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fb40685 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +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 \ No newline at end of file diff --git a/run_mcp.py b/run_mcp.py new file mode 100755 index 0000000..c472453 --- /dev/null +++ b/run_mcp.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +"""Direct runner for Portainer MCP server without dev dependencies.""" + +import sys +import os + +# Add src to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +# Import and run the server +from portainer_core.server import main_sync + +if __name__ == "__main__": + main_sync() \ No newline at end of file diff --git a/simple_mcp_server.py b/simple_mcp_server.py new file mode 100755 index 0000000..f2c3114 --- /dev/null +++ b/simple_mcp_server.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Simple MCP server for Portainer that actually works.""" + +import os +import sys +import json +import asyncio +from typing import Any, Dict, List + +# Suppress all logging to stderr +os.environ["MCP_MODE"] = "true" + +import mcp.server.stdio +import mcp.types as types +from mcp.server import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +# Add src to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +# Create server +server = Server("portainer-core") + +# Store for our state +portainer_url = os.getenv("PORTAINER_URL", "https://partner.portainer.live") +api_key = os.getenv("PORTAINER_API_KEY", "") + + +@server.list_tools() +async def handle_list_tools() -> list[types.Tool]: + """List available tools.""" + return [ + types.Tool( + name="test_connection", + description="Test connection to Portainer", + inputSchema={ + "type": "object", + "properties": {}, + "required": [] + } + ), + types.Tool( + name="get_users", + description="Get list of Portainer users", + inputSchema={ + "type": "object", + "properties": {}, + "required": [] + } + ), + types.Tool( + name="create_user", + description="Create a new Portainer 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": "string", + "description": "User role (Administrator, StandardUser, ReadOnlyUser)", + "enum": ["Administrator", "StandardUser", "ReadOnlyUser"], + "default": "StandardUser" + } + }, + "required": ["username", "password"] + } + ) + ] + + +@server.call_tool() +async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]: + """Handle tool calls.""" + import httpx + + if name == "test_connection": + try: + async with httpx.AsyncClient(verify=False) as client: + response = await client.get( + f"{portainer_url}/api/status", + headers={"X-API-Key": api_key} if api_key else {} + ) + if response.status_code == 200: + return [types.TextContent( + type="text", + text=f"✓ Connected to Portainer at {portainer_url}" + )] + else: + return [types.TextContent( + type="text", + text=f"✗ Failed to connect: HTTP {response.status_code}" + )] + except Exception as e: + return [types.TextContent( + type="text", + text=f"✗ Connection error: {str(e)}" + )] + + elif name == "get_users": + try: + async with httpx.AsyncClient(verify=False) as client: + response = await client.get( + f"{portainer_url}/api/users", + headers={"X-API-Key": api_key} + ) + if response.status_code == 200: + users = response.json() + result = f"Found {len(users)} users:\n" + for user in users: + # Handle both old (int) and new (string) role formats + role = user.get("Role", "Unknown") + if isinstance(role, int): + role_map = {1: "Administrator", 2: "StandardUser", 3: "ReadOnlyUser"} + role = role_map.get(role, f"Unknown({role})") + result += f"- {user.get('Username', 'Unknown')} ({role})\n" + return [types.TextContent(type="text", text=result)] + else: + return [types.TextContent( + type="text", + text=f"Failed to get users: HTTP {response.status_code}" + )] + except Exception as e: + return [types.TextContent( + type="text", + text=f"Error getting users: {str(e)}" + )] + + elif name == "create_user": + if not arguments: + return [types.TextContent( + type="text", + text="Error: Missing required arguments" + )] + + username = arguments.get("username") + password = arguments.get("password") + role = arguments.get("role", "StandardUser") + + if not username or not password: + return [types.TextContent( + type="text", + text="Error: Username and password are required" + )] + + # Convert role string to integer for older Portainer versions + role_map = {"Administrator": 1, "StandardUser": 2, "ReadOnlyUser": 3} + role_int = role_map.get(role, 2) + + try: + async with httpx.AsyncClient(verify=False) as client: + # Try with integer role first (older versions) + response = await client.post( + f"{portainer_url}/api/users", + headers={"X-API-Key": api_key}, + json={ + "Username": username, + "Password": password, + "Role": role_int + } + ) + + if response.status_code == 409: + return [types.TextContent( + type="text", + text=f"User '{username}' already exists" + )] + elif response.status_code in [200, 201]: + return [types.TextContent( + type="text", + text=f"✓ User '{username}' created successfully with role {role}" + )] + else: + # Try with string role (newer versions) + response = await client.post( + f"{portainer_url}/api/users", + headers={"X-API-Key": api_key}, + json={ + "Username": username, + "Password": password, + "Role": role + } + ) + if response.status_code in [200, 201]: + return [types.TextContent( + type="text", + text=f"✓ User '{username}' created successfully with role {role}" + )] + else: + return [types.TextContent( + type="text", + text=f"Failed to create user: HTTP {response.status_code} - {response.text}" + )] + except Exception as e: + return [types.TextContent( + type="text", + text=f"Error creating user: {str(e)}" + )] + + else: + return [types.TextContent( + type="text", + text=f"Unknown tool: {name}" + )] + + +async def run(): + """Run the MCP server.""" + # Use stdio transport + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="portainer-core", + server_version="0.2.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions( + prompts_changed=False, + resources_changed=False, + tools_changed=False, + ), + experimental_capabilities={}, + ), + ), + ) + + +def main(): + """Main entry point.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() \ 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 deleted file mode 100644 index 2d95e8260ce09bcb78a4acfb7da14283c8637d7f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1942 zcmZuy-EJF26kgl0-EIhxTEz7!AQ214F5(7R1yP)$77}DNPLX`GOlHU4QPw-l%&c4c zDm(@E-0=WB1uqc!mKP}Wjw`;|UH`<&N?z~GoH^%w=lkZ&Uk3-ihv({a5j|>p-rsJp zdaC8eyHD`(n-_W`FRY9zVRcjuYol7YH`)v9qx!@P8^2dZjd`ksH$=o!YxbcO{QFn+}*!uN-fG?W%2O0}BFP#8K>p@^|JvEq%TfmT*=71I$M zwa65n##zFxR0)fvozqxp+C=rg%?!dDL(f9#jM^~ZZf-&@i0AMpz4Q#^)*-%Sl$`PBzjin?(38 zQbd9CN&?dlVou))W)ar(e8O`~PjA=70g_x42)_Smh-|JzvQ?#W+r>`pa>U~-27@mz zPPR%}+Mk!nZh6|_?-q)3S0A<30=yYH6C|_-;}NjOj#O8JSWPDQUYSeHGim8k3w8x= zQ)aEuNgim-ve*Jm42}~n${o)Ii$T>?CDJOr^13R5v8a^+K`FHD!uX|z>D=m!yE?AQ zSO}q4DBWqln4qObfFQGr7oG$(bSz8)1$#W zWKP=@GRuI*&E}{fIi&wfCX6**u%4>K2vhDZVPgOK=%AkPvRa@ccWTz@Iyg2%E(a|545p>`QXmg@#Pa_b&|2T2ZpGR#S3N@}3cJ^o-7?W|$`q7Oe=1 z$vkBK`k4CpLe9U=lC{vc3`<8}-S%-*B+gzlXrwUdzyYTH6OoD}6bYA`lQHEf_`JI- z-AQX&ve2UwN5c$V3Vf`(4FXEcEVhLZS)0D;(evSOKs=VN3Av`W&Dx5qTyUspZ==19 z{QXx#8wBdnmrS{F5q>fa}8aysew*-E>39p4z_TZhybywSM)tCE0fP2CL2!f>_1Wt8IvtnH69Dtm8VRlLIwulH{b9$ou4?|pQA|K|ON7(V#<`taXIW54=qQ}1KP E|M<0vYXATM diff --git a/src/portainer_core/__pycache__/config.cpython-312.pyc b/src/portainer_core/__pycache__/config.cpython-312.pyc deleted file mode 100644 index 07f54384112c81b0d701aa6cac6c825abe9c5ad8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16355 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 diff --git a/src/portainer_core/__pycache__/server.cpython-312.pyc b/src/portainer_core/__pycache__/server.cpython-312.pyc deleted file mode 100644 index e98579a006e7b9fd055666eecd8d692366654d38..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 55455 zcmeIbd2k$8dMB7!7f?U}Bu;_=K_vf&!}0 zSp`xklZ>{c7*p$Bx3ujs)#LVz#-4*R=^6HP$1Lpbo#COjHQ|X^fS?wLT6wMA>AC*c z2&hS~4tK=v?|WBfRwWMVuspL)5?L>g_q`+E`|kI>KQAgO&~SvGN}v7uztgn;Ko|1x ziNu3;UDK{=QLSH#>QT?A-ml}=JL(zp_It;C{k}1Ozke*y9~jH)&(rB0-)R0=us_Js z{?UT5!u~>*4vdDziu#LKI&ZXiY*qiNvDN*n$4dH3#!CB3$IAN4@GL(X94#NK=&u;7 z?5||k1*2=ms`{(O*7mO*tM0F6_X{WF9b4bOp4|(LZWuHA4VEq%ts8r+ z|1p*>9^E*$sehBMd9+wnZ1Z)rhmQU&9_MRW1FMpFEnVHSp^9Cxs&`|`x*BeuZtTJzOGGrCOwUn=|3rWDl!r`p6YtWNXE?b zG1C|s8y}60#p0<*Y9tXih7zXnOu|gzEj;W>n6c)9f|F-QlEzpfIx!lv-cK5-voSlQ z)Y(YN7&jB=N20N$5t+c_cxq&j!;QqF#srEi7>mRs!>mAKXd*sHg+@k4Qj=LFcTOZx zgJjY;937!|nhX9bwW+t!pYe9YCo{h8k-=2PcYGw7%H%&i&cS5z`(odoh{XqEjd~`~ z#~O%K#nCumpp-PL3x7L_2ccvtI+7URJpsu1Ct}IOggF??_)aDgqnW~!u@_TaiFgVP z$%KxMq2lst_2IEIv1l|Fm0A8Peac2prervl8WpnCxZ# zutGiTjioLm%oo^gG`p|^--dc;gmqNmDOAe3T-6s#rAFezNpWjXj3xG?X5uq!CdM?( zK&{^s)%(3s&nsHLFS;h`$2j#zy|DnsZUE^#+{?pnzPOiP8Tpo9(+Uh6+0%D$G=fQzJ8PYj ztC{6xCpMRPE;aSVR8B-=qnPp-?k}c{XUs$@F_;)N8c>Tyi7gy+oWWaUo*SQfo>ME2 zri`<|7^A==gsOm4cwGh)V`CF>fm50cQ%qM|VNC{MniOWQ5qmKuC%<+QWxWuK+iGCXW9x*( zI6foQCTTQuojBFqDBhE^&EUvF%-~#jM`DptlsJ|EE=!nHQ)*}uXnP`wju;2d#!UfK zA|$=0AZs~bjk6^B=xkSe$+jKfHfZBgU4u~o^4{JG}(j)ym5RmQC-3Y!}7)v znm+hS?U>MG<7N!~pBy0CPBRvHfvVFJnHWuB zppRk*<14e;((e7lsae@eQ5XlzJu^t4n%{p0h*U^Q3^K`Ek*6j$mXR*9Dx5+JGda+C}YLVb*K7 zlrX_G>UdkhVpnEuTrHlb*@6Dtly}|y-v7WX}u*=7xi;hGHw0d@Lm`721BVg z`XU*0g8Cea%Zf41P*F0*MKWwbqdaDEVnjntWyO?C8WZE$9OZ~WD}2B?=A|)YCdQ0d z{QQWSh!gv2oR63zkux9}O$zLYOWqs~J4N@}1*szRJCuT0NZ1?w#bsAoY&h2aU!c zbc|Z%D^MpQBS||sqLHHp7uc07zPH^tIs}yT?THZ+Sl2mdNQ@atLbBk5k|5EcpfTLZ z00`XTY@IRWs3B!lwd{k`V;mkD0&%c?e0l}gP<+0nC?4LD^2JZq+y&B_7U+)T*MLu5jBP9U=y?>eY@ zCO~}v8Ok>jCuW$S*Wm=2$(M|c4rKy-AY_U}MfqitkU0ADla7|NiLqEq1ROB{ zYU{4}?&q(aUuW=NV-x+>{vyBldxftS&WCo+S8blF+A&kLW4^p@uDo%kym7wTn5%A{ zscv2fYQgfq()_`K2PhtWZvqpV@m>e#4zg6g0w7V~Wpzc3g-u2PZ2 z7rbB(Cg>>8oa6(69f`ggk@G;WM8cI|XHkQ&K^mDXAVR`(WF^s$lQXM63L&Zl5pLBQ z(0`Q35idvvX`Y6SD41IC+zFs!sKuzUe5wQSaaqUS$j1d5pjHj2UTtV>H%LK+>g2Y} zXdsMcIhh*!LzZkh`s-1T?QkgqXYCl!8y!iY+|2eRcw$V=I{iuW)>9H|7uj6oE*=l z?O{V<-S(Ci)=TYN1h)xnvXd1=8t%@Vl18-CG+@*siyK%04E{+70#laqOH1nJz3!-k zx;RPW)wOv}ak$;CS%@_m!!c-H!R?`sp;(}6C;}%SPQ_CL)ETM(-r`fZ1+9=&sT(n9;xdPlYsj71=o2B8omF=PM%*x)D_K$0*33`XL{nV7*l2-sApn;ams zNk_ABI5K!v)dmTOWl6#uC>1)$Q83luC#h@<;Rq|p0Zv@j9S({xjI763X5L>8Hxu6J~6C*8x%P6~}Ih2zQXKp$3NTp*FdN2Yx zc3T06rO=sccE?SZnJWhe4SzcwqM_o*>$vDmq-=^E-d0khVanrw#ITf=v3PVmF#-$_ zHsT38qNL#zCfO9ivNdRqFmqETe+*cO5d0_Q_9EryAY&B&Floz7qp*$oYM+$aGxHCW32=0p zF7I=pJB_+o2qf~BW;Wx;Y{Ds1!O_$J^}AvelId>MVy7g6I!=r$QLB0XXaZC{d9c|P z%Ihc~c><^ZqJ0F3Wvf=QcD`!c{90q)=$fx>c^LGUls*ixyUp`!H_jUevhFs`udSOm z_Q<;nHD%?+3tCxG@yAI*cQ1uobaNLH4k?5r0ERH%gB0@j^=yt6MoQ_3{?zNLJ7S&RC9&8um@z23zD%&h z42fG1CDk&ZwtAi$bM{ZN()?&p=o5IipU{KR_=IJO-88 zSY#4%#(9e~pw?OMlZu0i7e;b{&=}9WMEg3-;VcysK?Rl~xj+a4Xap4Ys3AJCA=W(H z++@_-5e*|n^_Hwb5a@8=Qi0ubNn0`ntAHv?_HYDZ0IWw4o*>&yfJ>M(p6q+Nmqbl; z2y7tB73ZSpqdF-hW~t}Y=c9IlW=zCih$k+9Lm+Dm7VDj9o9;&@>ET6Q6&;EN3Hv~RRkjwt#tX~PWE6a+HOG6$Ec@jQtqd4 zLbJ0fjPGj@oq=)f7_`d)lH|}Pg|S((a(IqDaq=MXM&rU+Oid$BgMbO?Bk34TbxNy2 zlEYv*K=#1$n>)mr+J4xAAvk^-$7AD8-iAILl=LSOFq_h(0}o*)#$k=XrK*FFBOE1D zM(F7e$4RnKpN5GPFh!uJaaU=eW4S{bD*B#S3Y;^c8i9;dfjNz)(x8?NQ&_*@jcP^pu#4^2K%|UCE{yvSxwsFc29( zE48t}B>k6JPp1Z)GNl6~{qm7LKs@F|GE>IxvGJ)molGUWDaV?+Z<5X|6FmIlV2tr> z<{`TF0!uY|dC7XrY{WgG2>uS|rpg5$MAcQW(p_ruJ`ywK0P-hP*8h&vN1U1J*0s`g ze}CP3t1GV-zFs)JuK8xyYlZh$w=MWWCDrq5s;Aet-doc;zqxU4^ZuF5`)4;FoZE2l z!$Pfe{p;%%LRxk0w6XKv+MVo$12dZs%x*q3x8cx-tN076*$aE_t=+?3czkB_SgCCYPq%d3+IXvCI{d`!`Xkd-M;3}SG3?pMjbhxWZ(*wv)} zR#lhb{pVGkVb8zSJL?0tH7}C4y#;u5yNGVz-mG^v25xU*_Z!*$opk?Bq29SIa3@6f z@2sNxch;_=bfZq^ZR{KlcGddt?0KxK%73@YkM!MIFUq@X_`4eO?rzRQu0hm99mLHr zj`BZ&6Uyc%r3fDNM*}}3J}-~`<|7Rud)N~#{GszN`Me`q6kb4!z^#g|UFMDIXbmY1qU%J-wd{9QbUpjs@I&vg7Txgsi`T)3 z*0GvDCh~6-zncV{&Fpu}4}D9vq(0gJC}rq@MtXllU-Ip((QV7Lswo-Tbyd_EaHuVLumP}{41j8kzTvA5ZohcY$%u$GN4X9li!u8Zy z2weK%ETRk{C_w&$qZ8y!Nqi|+$wG5C#AreyZW+1>wUdholo3pg#*CkgjpSBto7lR1 zGjnMnb=fyapXsU);q7J05b7S%D@yfoE-0#LINKqJLHJC1Py$Pb@YS*@iUn-?RG(fe z%7GJjDbw>Y^C&t}3kDbNB?Lj;ay6zhgM$pyS1`K3Sqzh7kqc7@zQxJl8Vx&nQDluE zZ>uI;lFOP1vYa|#L+o0i#V*rj0hOh6r&<$cAhON>N2Do+5p zUjkRhH?%4!Pry<|TntxII~DagFu?bSK5W>*AfRbZO^M|umy_9XqmP+6z(s+z0K1Yq zUc+335$`d{g8O-pm6;TwYT8*=4%u&H{1R>m=V3(>PRa&k&C#fB8oOmAwGup9Ajk01 zsB4%u+=OGc&7fixbz}mAK1|F8v!K~#d@g)P7dSa;rjM8SPH6RL3NhucBcJ2qi`3|G zPXHk%$I?4(!&|tbDaK6L?r7wZzOH2)&av=(j%EUhGv}a0hO+?^P}4Oupy?GhQnMr) zPsT_5uek>d19W!+hWnfOvaC$LEX+KPeCAU)fyq3EpG*PC12H&g#*&$QDOBR&ZhDbQ z$rO&n$0t&Jlx2#YCZKff6T&29bUZ^+JxyLV{MdBOj)3YM6agm(|%8r}S2uof1XZI*S=gnF)o9_&_B+qRnP6fhvzg zx%+-(^ckxQ3M^!tE+*Q-CK6R7nJKiHtkNai2m?(}Ja&OKHocbGs6HIgPV155OF15G zIRW2A>KW=bdP0R64Lc5WM8^mrwjLm`ji|f&b{U_EJefk9)hxZ{6f>dm+|1F093T_% zbO~h_J@X`eKoVq9?unUI}orK`Kh+$T+8{E;w{ zLP!juJ?4HoS^DY{yQZ0u==9^N5|1Qav9ZJ;{NE@dSpw~)60J%q=GWHG8>ikk8sIq7 zxMRNM_y>M( zq`m6a1?u!QJlVYdgR-Lf>R+zYHXqhMs4JmzDo{=nD+iGFS#6+dt2P|4gS9RIc0UJO zQ-bctK1bW)E#5kB ze1*Z=GT*R$zNL%TO~Tv$=J5|);O%#TxA2_rl!vSgr#xh4AQz;Ux>~w+=nUs(g32{v zP-(mIin2L83%kVPHit_d%lY@B(yKE|gz|cFvnxbliMXi47U4x%I2^W!>Q@8j9>IQa z$@`if-=eG!HOTE^F|hRW*){`~KY;w);y|S%9IbSF5B4$NCBLIherKDWBb%1*vd^t`{K*Qf zU27sqm>eC z!RijDT7bD@Fev2h#dPafxA6kt2r=KWx`T05)?yBlRfI>f6RM-p?nW$Pmax!>1P;dJ zf#VR$6J^D+^PlywYFW&ri#ba*TL4wp28>lvEFlH=7Qv#Z1|e%vPYpxF0#C}C&vh=j zHV#Kq7B!O*Z~G0~8d9iFSkriuP&Db%(fLrrn=l%naIDR>z3(W6L_Nn1H@|p#N%2vZ zX^XXmb*N=4bHH-sdsfMv76e2F77KqJbsFp)bkC#=ZxiL-ais zwFM~=Y|yc?i4A>ff^BV6POm_J0>uj-P}ZjxmLxu-RQKj&hQ7qru*5zo=c2KmD~5$p zuJ)l%ZCiq+h<6K#keH5a;>&8PAX0GNbNA(K~5Tg|vgXlb{NcdP2 zIKk;VOr_I7I#{e_OwnpaW=RGJTE@aZH(^jYN}a{03)x&Jf(B0nPbNrx!4|BTr@=Xx z-@>WaJVME@(&;3fo~6^*==2<&`swryI(-wTbg{H_5-3x3(;BgFg+|{IA}o3e{TLI zFEBQ`3}FIZE!%|Q!o1znhZJ3XB>e=9HwNIJf+tPpL);Xm-;=twe*0|gjw`*tD5-g) z_uZ0h!V3JxQ#ViEdXCJ%PcQh^mprM@uc>+C^1U_T`TA{h^^ecgKR#REIak;D4+8UR zYu{XdZ*7ZuaBJ;s{js^aW9-p%-Hv-}cd%!jGxeRf_RZEmHCOl44=CGo9Wxsf!0ws) z?px2z);~R0_cVL<=DK@pd07W%>JLs$&ek8Dt2@dc)!tj%%pe|`sXufpI9vbZT-}qO z<&kUkMHO4`t!-t6bjG=1## zW7Ed|cYLoMyI=m)d+X}2Jv+7K{<@Con$8dRX{AjwCEK9&d8|wS`2D(L_*UoY$@c%OWD0N2wc+kTb&e=+D@t~}DH z|6uKrTJMjmdJcMS7wSD*0=GjOD7{VZfpNTv(lChkG{GRgp3+pks(p9ffalissG@bk@kasN-( z6`&q}iZ_vNWalP!ZeizEcHYI#d)T>+o!i-YKRX``9;x>KS>B-|75;YuI@0gt`OvI) z3JLN%6@I!??WH@l{v%uR-gzt!_j?;d=3{KeGK7L@%HppiO;~L;=H{9yayU@1xt`$~ zWWu^hre4eh)ig6n1!Izig<*+CKbzp@0lLvcrvW;B8>b|dahhg5^ZGDJuC%RqTxyoH z?0<>(;Sjss!CnkbL0P3bwGD3y7N{ zUw&*R_}FZ4(}HJvu=+;Vf`*@;bUmaWdLKZLT8LWjZ%k}W8)XX`OZ=?-XM=C=oI3l*>&1=!!4H(<;(bcP>e3-P831gN(Sko>b^c6Yu%~OvksXDDk`11+j_aX zt*(t|m%PZM77$t)&8_bDmro}H^~*G%tA&MGrMlezl%)o2 zrLr_iC1rWm!P@F_|5KKlXDgMZQ%ZFg&3{F^=sj1GRRLVeZ`7?)*M*8j^kVRm@1hT} z1XK(H3kL3zTUXYB2m|Dtm35&13x4Q_Dn=>BF8XCjeHv356+uDtm-zc<_#1#F=<{=ww?8@6OX zN2|%0O)@lY} zKzN_WT|oh;jTJ>ebfw$tiUp(`aj`0i*G_c^0j5LUIKm_M^zrX<9FFOuS&jx_(fe2t z*J1y{Y6WX#R6!%qVN{X#0Q?@XZe6w%K^-s#(5G2%PsByfVd)UWI=Gvw&RHxABr3L? zorO({MY(?kb4n#yD=TDxqd0Zp6jPH@Ol50)n00mnA=FF~QE6T<*iiSe!LDGaM6O~6 zjxIvEAxFxxKC@87bTLb%7>g)6MhnkG`H&su*cqKvB~yn(3DYq=jOB^KrI+dKV0KR! z6jO3TYRdZuUKR>`%<&3%6qM^dblT2-QF^5x7&xI#Fy zkViCQpiKk0S;&hFEcbjmrOO2BVf`r-3vcOMr%>VY3`&L7R*a6!x&bI!prw+bvyyg< zU~ZxUNoitkrW5sR#*3+t2|yD|LPkr@Raf*qWJe;hZ`jVt~R4@n^ZO`KS#FLdkm!CmiFY?-ZS zxYG09x?QvD_FQ=iZUbd&=1O^-CDw|tn+*@VLAMBhv z`1H)dr{@lSbLQYTXAgdBuI*dXZQpup-*hxFz1n!Q^4{u(_qXk5$r&V7#zS3mhn|@^ z^vv9$=VuN*KYM6kZr{N4zJa%nPRGXS?KSsSH@@F=fF);;q>P*Ht=cr-*)!MqwVBSZ z&2>g+I-|3lLv#CwruPrMH90+cf!?mZx4P;5md9CgW_8oUfVQo9CVYCj=h?ZQ=VyAJ zzuz-38$Nx1>**gnH$60dr5A0gL70&Gx$2!W)jQ{F8s=)Y&(v(6udAP{+dWgadwyNh z2Ze#k;wwE5S8L^KUOWH&uS|#5FC5Z}_IwOe|GokJBZ$}Sr`gYeVg2KWhPHYw{KnCx zq0;XifA#qE+UB1OPSyQ*=*sc?p#$?3TW{>V_U-F?t~E@T?R+=1lYGd7yMD#OqP(Q{ z`1M!n59``>y}QkKqXntkHG20~e7D!NBYme*?>^wWvxd@lOZ4tTzPqIzaC^|+-b*>( zZqq6K6?Q(r&WGsyb3NGO@&3FZeE5L(=Xew8Hb36~`2j!Df9mlfor5!&taAl}MB{Nmv>OIavHFeOxTVc?ztYhWEYv|JFV6d zLtDv8zC+0_xgM?LNqv$QD}D!mhw)dyv2tYE&Sl$p0A)&&2v8{JiH(A8-L{g(g^xWd zxEp};7pX8~Isz6auC#%hM@#4L9 z7R%D69VS&YF)G}D&~m8GVh&EBN(uyBRkfkg zz$tdw_|ax-C^R&R!Go{u$O1l5G+&s1xVt|sXLQOX)RRX}o_q$eC@7{Rs+0{%!FBT9 zaOx%7iflrd;8F+hz#7)XV7;hdsKm*$q=Ga0B15rklMt9n++_^|OywfVqhmBgl9Tbl z79M<2Odx0XDxMUwE?NhNNK7b1pP{y^p5xz^cpay~Fl_W9{Ki>W&@jLzQq1j{y=6pU zyn>a)2|+c7K9jmw8OQ8|WGGIUtas$w3!j0-`R1(5qcN-phBS|ss!u`-SR*WUTn_i$ zH*qFO_GNr5(s9y?J&1^~SHf$7>Vd{P3vkM4h+pokO@15S|mT9YflBy%)S7<0wh-lPOS5igE6elst^ZG|A@)JS_2zNswht zC~sduGQD##-fsy$_7Jh&e~8?i3p}E0q1AJR>t+hqO&4x@&)D_{MOV7-hid0bE9Xi# z&XjJPD{Y=BZJsUNJ{Q_P9oqhW;i^Y{+n6adrVAV1+q83b({2J@$AH(*l-AFcw$7Bc z&X(?)3+C7E86kVLcToj{v=6)%uD;aJo-=d&i%fh?MLc%o8EcQcY7bD z@9fsQG~b;)d6Yh=<9t`6vxB)&irg)VxuM7i=crG!4oTi~6|{I?D9-FzRz&25k{60% zstG$R-!)S8eRl``N}%AQ&^hd23OtfzuR&1T^Q5or!A8ykJ#YFIp1imFcB z(TUEEE|sA-y{?B7NVE^m1z9u6B+)Ki;qpqm@m8Km1clQc<^$4AmtAzSv=?5kz4$e{ z%QO-scA=JB8_|J_^j0+(U9>`Mu&DLr2_i%nk%<$cU4h^(Mi(5qhxzLDbJg2ss<+Km z@0qFIGh5v@SKfBz@Ox#IuT6ZveL7SNl2Nqhqxtf-kAJbM$|-;E<#uY4zk3#wzaNm- z%S(D^iT+AiN0D|_?+E(ds6*QdiuR?_l+`MH~4>++DlL=S^mn?Z0)t3_YqVB2+ z<`+bL*aFQy`F*4V9M&t6RR>y*en9tj4Pgfs(aSH0t#n8nxGjM%C8M)wa&mw$9ZanyEcBTiY?WrsK*H zqERn?|G;#}5HxB{hon(^m5#Xm?Txo0cefCYIyzqwzA<>S?)uQRuTPilc{j9&>4^9K z%KR-<+(A!>?!Jt_2M2LdirSS(GD5w|h=iC$JrqKvdCu?g zkgEWm7P_NzaSH}M8mDX0ah>e!g=>`j4bmvak48NqPR|P zTWkh#jh4d?_s2AUXbl$U@eHl9daj~rrlM)KqIqsr^K^dm^s44hlH~03QhBZZJ9>@p zs=rn@PveGTrJ|DFfB0FXcik<9dsIoymppLNp}CcHM7`I=++yxozO2W%bX!4o(Q{Er z;n|u5tnX4%zS}y+C|rvW92dPu2Uee{wTjkSFE)oqp)+X6ifPyecOnbAi*pm=vRm+! zyU~q2tGsBw+Z#5Wf&a3f=?w6jr85Xd3hm|!flrw9lEqy(2cd9t*N6kC947~M+Fi8!cy?As06i4WEST1JJb zx`bDcs$~PDO@!oOVGy|;tNoYBt-!4q4>|!75+OmE$-8RP zF9nqt;tzs7j7wp_jGpBWqOinE$LYWG{5uLfl+_s0t$R3EAWLD0CF5F5H*wkoOJ` zfx-s_Id5^_8_g+CgX)1!i)+TKl^-EU_qNR3iCq= za81r>lxWq?Owc7$?2rdC^^?@?#BjYV;kSLf*w2aZ{}{QXuSr30j|=C##<+Uv^-FK| z&#u{i<;c9_>iwDxb2UvfHBEChtur;PH`}JZK3mf>SJ4A!6?j-2d-d3K6}-By9J?QS zf>>?gp#kH;9x@(OJ^`PMfbhxKqxkLZKl4s?{4_xP_M!QTrW<`XORhhA?Z|Z5?sr4G z8Na>fS4mp`^pf5cf=;M2A3C8{2iQ43*ad}9 zNw~}BB{Kxlhx~ZwuFsG3-Jlm~cc$haekn|i71;T2jIHs5tqE9chK=AG%8Lfw-dGV^QxKu$6AA|iw#JH2BvzCW)>@XYk!x##Iao9w3kqWlWYA_H z6C#ydoDvcLaf2QgOooEGK}I(ST@xv^itDQBUCmh%%yw*n2Lk;Z=DnW`nr*hwWq9 z86owZ6kKYDy7*L#5Op2YY=Zk)ES|Ialuim@@l-_T2>&8TUK%5W836dUDo7(BmSuRR zDl2ebBum6Uo~pqzAVr_x0dZOX z8u=_PtIdVWs@!mO?DerXkIq&$UwLA_dgDI|{~&y=XnOC_+3F{+9J4W6hh}OH-O78X zX13rvasB+Yv(sgJ-wo|$ zj8>asw2Fw)3KFB`2cs20^7eYYGtYN>!)BzxVs#bicl?-iyDGhh|!D8u1 z-_`th=dRyR=>jj(?u^!#?EB&BtFeUdhi&J-Wqm)Cj%rcg5A}wP;qhL^21;1uh2Jba zjz2Qz&1HDVtSr6WICHX`ZWyFj*y3yf1dYfU1dnH2ihw52f)M^B>vJYT{Elr4Wa4XV zat3F&2pI_%1hPd?XcAJ)An?Fv;CdP;?ENH=9qlBbLO@7chf|a#tf)}rig^u3KiWD+ zL2`oq9K1;w(y*}`Enk|xEz-*4<`}QdkP;BW+Ignhf`BI9aQyu(`%l{p2-fPxX4ow zMd2y3i6|Ni8RBAyd&_6jFIW_h6z4RixxdIT(YqGKie{PP6uonPl{(-go6|^JAm~Q$ zEY-n*nVJK$HHYRZ4uP6kS_1AgQgKKcB z6cROa5#(IqvU0RVTAX|J@$7;kr~w8NLGA&AF``jqeK1qpM zl?2uFlddWK$33K~{>pqs!;P|QU%g&=Eq}Uf$Gf2&OoD0^t|6|J=+>8_Q@(B)%H2)r zO3#b<8QMAe0$ImmrgRv-(c^UF7}M?M?n3^K{fsCli)o#emY4})mtD2afn|*8cCE7& zVPi3^vwFiuBNvabQ4slE7p*f>yoi|Y%M!^hwwz0t!NC=NpHg%M2woCG>n7zM1tL(F>p?51Ls^urisU1H)PfXOo`HF2f zx^C*%d#<%jm+g8tw2LuOyOkPxJ*lCqNex{IHS`)JZ@1{3RleKXHzR$gLho9yBNPLr ztJt|Z*j3}bvoYLN>AkyJNBVAwAMf0)^i#UVi?q8M`WJuuc{6wJP4P>Y{Ey1LA+DL= z_Hy_)JBQ`QzXia**_;z?HhjfRG5+l}SN<(;8UD@g>X>(3@UPh3S#Z@@#8O}@QN3Z~ z-|{Z!F(o>Fvy|wCk^jeqf8!xug>ip5{rM@jV-09`oG;!d>1hI34VnlWDn*)h&XW(b z7&V2i$o$C|Hg1Sh(eiEl0k0hF`5+1`3=+SA1m;yR1D*G zVi=o3Y3uyMCuZr}S2KZVGmVy?1tpyBfTAAJdV(yU~w# z?l$--9rhya&M?NmScdTt6mi!DgOtO>d2MW#8x!XR6X&xrRZz-dccQ@W!T$hk+bR5Q z#5$G`h73Dc>hBa`AuX)l2IERz%niCQB7wTi+cBiMTgPL9cuGYA*N7`WINVgv%L zh=;&;gc-+MPu~PzswTE5;HCjYIuNWbTT4hwZWJhj ztuFrm!t>b1o;5nCC)9ZQ!nI+n3gmS2w}I<elNobj(Kx*kx~YBBp?wR5j86$@m7O*| zmDCl#U(XQ#!)FH+CXLVN6xpj|6?Q@vwh5x*5k46QoV};9Rzj)tD)rsT{ zr|L(OUtKK8Hjt&t@^P?CRnG4EVy$22N1%O@4N=vmHmFbhMH2iz8FR(t9=wQ%{`paT z3A}Gb`VQu;DSJX0vdCjo-h{U%+=X}mFgX$AGe_6CdBw;Vb3=!ztTY~!e72lgKBacjZUj)Jw5xrcVz|G|~U?Mj+mr>kbb}6U5Ykx&_&x+I4?jEO$ z99<}a_FT;hT`nIjPWBQmm#O~_WaH~Xs&+(rQ2zQ%LjF1=a^~N4q!VwEXy|E$*IWH1h+_<5J>atYqlrV58q~=1mSR4ZK^>9)m=-=S&ZLO*-P*osR9oJ2LIc5!2kp|j<7wa1PBx(iD5=uSn!}&sNGR#^8lLO*stI1CopAL0n zaX~t33mVyIRe?`Q4WD({AXmWz>cZ+%q76%~CivSxQAJch?H^)wm)r{qH)X~+s61D^=W`pBxOUC9SjV-faID>*MWCa>? z%#+0=>^?dDc(u7Mf2C5kwHd1{oW(&CPEhc9#p*|BNWd+jA4?Xo=Wv@zxx60w!W}&g1c!n#XTd&DK0QSMj7x#NKs3wA*F?P=fU_ zn_z84WZUtxvZ=5Bw2}+}k1rOHEhz-+HWI9Nkzl=@1nXT)ux^E5y^+#z9%<8WZ`Uc^ z%Ferjox8oa4}?3nd&wfO+k0o5AMf1R4)Gdx0WZ>yIB&Go#mo46@GUqa@HlTNT=K&; zQk8yPaT3_3kTf3bxGsLN`HyR?D)}5*eGl7`&?mRy$lY#Zm-e#meCwO2-6anTBH^|L zhT=d(CGbPBu~JH-kEmO#t!FZYyDppSS*sgu?!dgxep%-zHabL9FX6!PGSbdAp${mk zde)KC2Wl&lNe_6h-@xB_{85x{SSogC3@!%j@33sQ58E$MzDw(Itny0yR3hkz$!I*D9(li$hMe z)wK&6uOo{C7?>!xb8{yqcmO_HaZTz&*d-ZCr_G(QEMq7(I5`M=gtcLX#f2&C-A7w} zApRee6E-kpsNfD|{Lvt`3>=L?>1aXCRZ~a}riu^E@M;r-3x+S17=*3_kj5tBhzm+M zm+LPO8-r~%j(rm*Qc-NP;8M+#sGn$}IYF!F;;9kHx>!JJniJzhZqA6+2ecOoi*ap` zUtq*7@ZfRPHLg*ej;i?9%zUF!cP(eJrA!Ak0D$MHRz3TG<)0_Z)A7Fn3{dhd5ObtEu=#w#-5`IEtnJ#H+jRlAwSr zse$RTFAuR4FirtrIi}OfCq6I{MHZ0~b~kg8RWQy#kN?+|f7l2$c^{txY=B_fyx0(R zC&E05(UQ|as*$V;yjx+<%2pi^Sa@Gxw1H~?zra^USsQNHHl3qga}-qiT497Ww+(m$ z!!?0m3MB0aX2zOoP7_Dicp`gV(2AVci-WOonmf+X#Db9XaQ~R{kvLznJ3J9Fv7-vM z_;FN9T`aZ#hBXUNUEmYf(eK=TgAjkpNONIGgwD9Y$hhi)lIAhM@HHw2mPe>(R|tIEr5EU%vsUJX6dE+5ofICgQ}FG33d) zF;Y^ok;hawjm0)bi)bAW!cH`l?Qxg!Vz`;a1labLbJ{yHB?ID{NM1ZCcYxkT)hPsW zh+-=_!V#I!1#E_i-R;;F5A>B~6Zr$=Nmr{@I+-?9rk5DB5AhOX&>nW{T5#3(3STXp zE^oV^zi+`;9^AInPIA9kv+?TX-@ZK4*nMr=&5QGu)mLA5{e`QE*Aw&Q8|KT6_sSdR zH|?0)bYN!Ff%*DfbM^aY>i0kNdn+m*)2*>4Kt+; zv!z?-LR)7-TNmocZu0(`%^xjz@a9Jg^y)`fFJHa<`sKfVSSkX7l`4M^2fG(4`D@yi zt@IP@?N+qyg|_}G`M;p;FI9Gj^&ixB)oA~k_8|G6OOZl&_%1DQ8=)tV zF6!`h1-y6Q66^`wDe+Odq7V=67CI(;a;Hu2F7n;k=chD6hSg}aa|zBK z9l7rMSUM2ss>-_?(z{lL?iS@yx;&5GsR?!?+Qp{2Zol_!zaQzh3%uQ{w6}|NKzn;t zdG}WD+Z*-nE%|S63Q&5h7xx|fB{X}Szid`IG;k5PnSGd#x!-r=L2Y~#-+o&0QGkK* zlYKDQF83c9HU+KAI?RnI)8+o7!4})HodKV<2Y+%~wvDK?!Y7In3S4HRh+=mpNg`Z0 zkY`|cWm~q=O%uw|4*adWvPzE_b&e!&Yl>={5q6cdWG zNRc?Acw0C#6-mBeQ>i%{2-J_dy6-tg6&AP%+IWx?()bYeWAtsvB5_$1LvHQi$V*n_ z732k@!)ybNyi(R(L4GhihwU5UO1^XiUx7Y?>}1(6)f+@U)P<8_SSm8KBJ*Sz=Urpb zS_N-qEQ4sX3)#1JnU(B=FynFRZI#H0V3$SIML0~0|E2e{_DGCLrsi=`NeE@CuSx;NL+xX{Ru z@08noP(|_<^raHx$(*T?_0kF6FgRUs!9b1}tCM>heLQ+po@leRe{4>G?SBINm2Oys z{^n5nD?mWWOUT02CA;`eUiS*uF;7drdDpg?(rvS);ki(FIuxE~n|Ceuf^o0VU=a8Y zT`e=EEwiON=0ZDWLOVcCLv;&zT2b>y?}x(p+aY%6+S*y6-_Sc(`Ti)3)a`2Qnc}-$ zL+Lx%Kc&ofCzwa+5}nRvbbiZYwxI64jcYUc0|Vr^KQNFf7#M&KY+{tsp@D&KV_S3a zByV6Kniw1yFvG}XZlqH;ogSytD|G6hQzxCGbVBE7CQ&#uMkfldWe(Gc7J8es;K?LX zXTCruvWl6cvNDMUGXDvk_S31EPEB;8Fy`#E35n#_K`~#^KJXa+{9ktK4gP`$C-shi z|4DtJCv;Tz@4B|}fkwX%p7k{QcQ2gQ4cy!LK*JAq-ZlJtuMN`0UR zQ+x*)ert}T|R^I8XziEUF;!ho<)S-|1pc1F6 z8X5oiA=!>Ttx=Es8D3)(wF`_dR14f~l7i$IY!N35JOFHDQ%fjUOqP$GdLR@eXxqVw zIE(lV$_qmWUj+vg!WJL9&sE?@u=y0gpG0p4Oa!jXvS2Y~dn|r_#7wYthEN*A0}Gy> zf&{ayIrbj+Lb2JskeLN_VV?D(9wY}$lK25Go&hBs$DwBhma+l21!%A&X{EDptu_-s zvtxO)a-39BZ0o{gBi5r3JIRIw`JQUbcj{#M*y$AQ32O<@r%#_ApM*u;Y6?+PwD*Ac zJWLvjnGIH)F1{-#f2|pvV0#2;tPs^D&fOX0&J3i-0zn;uoQ)2?fIt`MPCgo>qD6jo zj1Jn=)ZqItTC@OyC>Xu0F+hV;F$UamFkwbxCTA3}7XzCKPHdDZ;suL#n*S1$;17ZE zlB8_?j`pxs3q3YlxN+LIkxeGY6r#nSFXQjQM>uhyQ&I+XGz3 zHuB(({m^)#BK&~HvkEqC#ettAVK(cq_Fo{~>7;J$cgXamx-GYhCOPWRBVQM-uzQ+A zU#efyFY8(U-TsOH1C3+fowNY>RJ6nBcmjGq#{4+7mi$|=w>xN)SGFBT7GjXhqZo>E zR5w{$r*MYbDG0F9r7#T505H-RU(h*H6);l8H*Ow>VQA1C8OIiiP{z_W%k%;#m#k*U zjMrhyGqGdb=%lUAN-JqiA=)OGBS%qRId>>MgLzb{T~$V5EtJ|Vrq&6YgtXXq5>pBr8&lLj zgmhIISt47p5fk%Y=Dt{Ez)y>S{E zEnF`VdaqKyCBe&&^I1imk}m4AtL3;hE}vmh#sFqYa=y1sRo4X!W^+0AFl8OrrxAN1 z9E>wQs+Re$aOY16Gm~MJGwKKP{KfB;tpEOoD}njq^6#B{_1tfc&gEC%%dh_Uy~1_B z)O3IId-=s&uh#Ux<%i!ZEXU4<$b*}qS3|E2@MY{*Lih7`&gU1A&dOi>tK-sQqKT-uL^yM(v-`jeo8;YHzLY*z3KW=fUOeU_PZw>N?=&+v=zEUN7zwA5L`! zll>N8HEuKi2tS!T(bbs%?Yb5p=1eu=c$3tJCSiAzh)SklY;e4p4;z!#kY!4&8_hse zvC$+`XJ%I0?k7{xk%UP~&rFbRqmWqAEF_?`8ptH1Zx+*u42I@bI+3fBNgC5k(FBY& z$!1b`Q`O4o0U6xP3VIsiPdV-|iDfj&=4MvW!y>9FloQEjk_Jt-5i?WYvU@a4k|yoL zoGDQ+CWT?1?aFNaA%S9^=!`-W4Q8L4%Me)vJAXgN)CbLfi}#35CI2U!7QDKyf8g=! zz6ZsczUnWvJ@0CJ{-aj=7uwFh&>BDV1a!||X*hkjTGJob|57_VqaFUxxD%Rq9&9^67+hDYQW4nU)eX05|fa-{aR{J?ne+)^*1JdbWqDpA~D%y}z?eUe? i&bzF29jEQ`Y|nX)$27W}pUQVWk2vRFi;7oRQThYGPF>po diff --git a/src/portainer_core/models/__pycache__/auth.cpython-312.pyc b/src/portainer_core/models/__pycache__/auth.cpython-312.pyc deleted file mode 100644 index 314ccadf69c7d65bb1b088d0246776a8e8fa0d8f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2911 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 D#>Ni; diff --git a/src/portainer_core/models/__pycache__/users.cpython-312.pyc b/src/portainer_core/models/__pycache__/users.cpython-312.pyc deleted file mode 100644 index 0734c662fe787dd2c6f5608fe3583ee21b760419..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8020 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 diff --git a/src/portainer_core/services/__pycache__/auth.cpython-312.pyc b/src/portainer_core/services/__pycache__/auth.cpython-312.pyc deleted file mode 100644 index 12ae6851c1be714d415beaf28a18b611a63b9b76..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9278 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 Dxy+7$ diff --git a/src/portainer_core/services/__pycache__/base.cpython-312.pyc b/src/portainer_core/services/__pycache__/base.cpython-312.pyc deleted file mode 100644 index 3858a722d9c8bf6003f562eadb14ffe91ecdcd06..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14882 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 QQ%2f(x#vR)zXbjN1Knu)2mk;8 diff --git a/src/portainer_core/services/__pycache__/settings.cpython-312.pyc b/src/portainer_core/services/__pycache__/settings.cpython-312.pyc deleted file mode 100644 index fd72d24d68a9f6d1292173e6c1886b121488c0a0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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# diff --git a/src/portainer_core/utils/__pycache__/__init__.cpython-312.pyc b/src/portainer_core/utils/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 4eb41dc1d1e953fd46c775bd16a0f78728c32e74..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 diff --git a/src/portainer_core/utils/__pycache__/errors.cpython-312.pyc b/src/portainer_core/utils/__pycache__/errors.cpython-312.pyc deleted file mode 100644 index ea68d330019076bdbabbb75097b83243c6198c81..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8264 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 Y*&V#(4+n*&l~A6*WmVb53#a~n0S=A+UjP6A diff --git a/src/portainer_core/utils/__pycache__/logging.cpython-312.pyc b/src/portainer_core/utils/__pycache__/logging.cpython-312.pyc deleted file mode 100644 index 61961f8e2f25c1a2c3dd7314fd60d134f6c38fc1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7416 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 diff --git a/src/portainer_core/utils/__pycache__/tokens.cpython-312.pyc b/src/portainer_core/utils/__pycache__/tokens.cpython-312.pyc deleted file mode 100644 index c92e5cc72f56e6d08bef7b1cd35a2d8567db7b73..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7519 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 D1r-=N