refactor: clean up codebase and rename core server
- Remove test files and demos (test_*.py, create_nginx_stack.py) - Remove build artifacts (egg-info directory) - Rename merged_mcp_server.py to portainer_core_server.py for consistency - Update documentation to reflect new naming - Add comprehensive docstrings to all Python files - Maintain all essential functionality This cleanup improves code organization while preserving all production servers: - portainer_core_server.py (formerly merged_mcp_server.py) - portainer_docker_server.py - portainer_edge_server.py - portainer_environments_server.py - portainer_gitops_server.py - portainer_kubernetes_server.py - portainer_stacks_server.py 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
7a1abbe243
commit
d5f8ae5794
@ -1,6 +1,6 @@
|
||||
# Portainer Unified MCP Server
|
||||
# Portainer Core 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.
|
||||
This MCP server provides core Portainer functionality by combining user management and teams/RBAC features into a single unified server. It offers comprehensive user, team, and role-based access control management for Portainer Business Edition.
|
||||
|
||||
## Features
|
||||
|
||||
@ -54,7 +54,7 @@ cd /Users/adelorenzo/repos/portainer-mcp
|
||||
"mcpServers": {
|
||||
"portainer": {
|
||||
"command": "/path/to/.venv/bin/python",
|
||||
"args": ["/path/to/merged_mcp_server.py"],
|
||||
"args": ["/path/to/portainer_core_server.py"],
|
||||
"env": {
|
||||
"PORTAINER_URL": "https://your-portainer-instance.com",
|
||||
"PORTAINER_API_KEY": "your-api-key",
|
@ -1,131 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create nginx02 stack from Git repository"""
|
||||
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
|
||||
# Configuration
|
||||
PORTAINER_URL = "https://partner.portainer.live"
|
||||
PORTAINER_API_KEY = "ptr_uMqreULEo44qvuszgG8oZWdjkDx3K9HBXSmjd+F/vDE="
|
||||
|
||||
# Stack configuration
|
||||
STACK_NAME = "nginx02"
|
||||
ENVIRONMENT_ID = 6 # docker03
|
||||
REPOSITORY_URL = "https://git.oe74.net/adelorenzo/portainer-yaml"
|
||||
REPOSITORY_REF = "main" # or master
|
||||
COMPOSE_PATH = "nginx-cmpose.yaml"
|
||||
GIT_USERNAME = "adelorenzo"
|
||||
GIT_PASSWORD = "dimi2014"
|
||||
|
||||
def create_stack_from_git():
|
||||
"""Create a stack from Git repository"""
|
||||
|
||||
# Build request data
|
||||
data = {
|
||||
"Name": STACK_NAME,
|
||||
"EndpointId": ENVIRONMENT_ID,
|
||||
"GitConfig": {
|
||||
"URL": REPOSITORY_URL,
|
||||
"ReferenceName": REPOSITORY_REF,
|
||||
"ComposeFilePathInRepository": COMPOSE_PATH,
|
||||
"Authentication": {
|
||||
"Username": GIT_USERNAME,
|
||||
"Password": GIT_PASSWORD
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Headers
|
||||
headers = {
|
||||
"X-API-Key": PORTAINER_API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# API endpoint
|
||||
url = f"{PORTAINER_URL}/api/stacks"
|
||||
|
||||
print(f"Creating stack '{STACK_NAME}' from Git repository...")
|
||||
print(f"Repository: {REPOSITORY_URL}")
|
||||
print(f"Compose file: {COMPOSE_PATH}")
|
||||
print(f"Environment ID: {ENVIRONMENT_ID}")
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=data, headers=headers)
|
||||
|
||||
if response.status_code == 200 or response.status_code == 201:
|
||||
result = response.json()
|
||||
print(f"\n✅ Stack created successfully!")
|
||||
print(f"Stack ID: {result['Id']}")
|
||||
print(f"Stack Name: {result['Name']}")
|
||||
return result
|
||||
else:
|
||||
print(f"\n❌ Error creating stack: {response.status_code}")
|
||||
print(f"Response: {response.text}")
|
||||
|
||||
# Try to parse error message
|
||||
try:
|
||||
error_data = response.json()
|
||||
if "message" in error_data:
|
||||
print(f"Error message: {error_data['message']}")
|
||||
elif "details" in error_data:
|
||||
print(f"Error details: {error_data['details']}")
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Exception occurred: {str(e)}")
|
||||
return None
|
||||
|
||||
def list_existing_stacks():
|
||||
"""List existing stacks to check for references"""
|
||||
|
||||
headers = {
|
||||
"X-API-Key": PORTAINER_API_KEY
|
||||
}
|
||||
|
||||
url = f"{PORTAINER_URL}/api/stacks"
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers)
|
||||
if response.status_code == 200:
|
||||
stacks = response.json()
|
||||
print("\n📚 Existing stacks:")
|
||||
for stack in stacks:
|
||||
if stack.get("EndpointId") == ENVIRONMENT_ID:
|
||||
print(f" - {stack['Name']} (ID: {stack['Id']})")
|
||||
if stack.get("GitConfig"):
|
||||
print(f" Git: {stack['GitConfig']['URL']}")
|
||||
print(f" Path: {stack['GitConfig'].get('ComposeFilePathInRepository', 'N/A')}")
|
||||
return stacks
|
||||
else:
|
||||
print(f"Error listing stacks: {response.status_code}")
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"Exception listing stacks: {str(e)}")
|
||||
return []
|
||||
|
||||
if __name__ == "__main__":
|
||||
# First, list existing stacks
|
||||
print("Checking existing stacks on environment docker03...")
|
||||
existing_stacks = list_existing_stacks()
|
||||
|
||||
# Check if stack already exists
|
||||
stack_exists = any(s['Name'] == STACK_NAME and s.get('EndpointId') == ENVIRONMENT_ID for s in existing_stacks)
|
||||
|
||||
if stack_exists:
|
||||
print(f"\n⚠️ Stack '{STACK_NAME}' already exists on this environment!")
|
||||
response = input("Do you want to continue anyway? (y/n): ")
|
||||
if response.lower() != 'y':
|
||||
print("Aborting...")
|
||||
sys.exit(0)
|
||||
|
||||
# Create the stack
|
||||
print("\n" + "="*50)
|
||||
result = create_stack_from_git()
|
||||
|
||||
if result:
|
||||
print("\n🎉 Stack deployment completed!")
|
||||
else:
|
||||
print("\n😞 Stack deployment failed!")
|
@ -1,5 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merged MCP server for Portainer Core + Teams functionality."""
|
||||
"""
|
||||
Portainer Core MCP Server
|
||||
|
||||
This module provides the core MCP server for Portainer Business Edition,
|
||||
combining essential user management and teams/RBAC functionality into a
|
||||
single unified server.
|
||||
|
||||
The server implements comprehensive management capabilities including:
|
||||
- User authentication and session management
|
||||
- User CRUD operations with role assignments
|
||||
- Teams creation and membership management
|
||||
- Role-based access control (RBAC) configuration
|
||||
- Resource access controls for fine-grained permissions
|
||||
- System settings management
|
||||
|
||||
This core server serves as the foundation for Portainer management,
|
||||
providing the essential identity and access management features that
|
||||
other specialized servers (Docker, Kubernetes, Edge, etc.) depend on.
|
||||
|
||||
Complexity: O(1) for all operations (simple HTTP requests)
|
||||
Dependencies: httpx for async HTTP, mcp for server protocol
|
||||
Call Flow: MCP client -> handle_call_tool() -> make_request() -> Portainer API
|
||||
|
||||
Environment Variables:
|
||||
PORTAINER_URL: Base URL of Portainer instance
|
||||
PORTAINER_API_KEY: API key for authentication
|
||||
MCP_MODE: Set to "true" to suppress logging
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
@ -16,7 +43,7 @@ from mcp.server import NotificationOptions, Server
|
||||
from mcp.server.models import InitializationOptions
|
||||
|
||||
# Create server
|
||||
server = Server("portainer-unified")
|
||||
server = Server("portainer-core")
|
||||
|
||||
# Store for our state
|
||||
portainer_url = os.getenv("PORTAINER_URL", "https://partner.portainer.live")
|
||||
@ -590,7 +617,7 @@ async def run():
|
||||
read_stream,
|
||||
write_stream,
|
||||
InitializationOptions(
|
||||
server_name="portainer-unified",
|
||||
server_name="portainer-core",
|
||||
server_version="1.0.0",
|
||||
capabilities=server.get_capabilities(
|
||||
notification_options=NotificationOptions(
|
@ -1203,6 +1203,7 @@ async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[
|
||||
return [types.TextContent(type="text", text="Error: environment_id and service_id are required")]
|
||||
|
||||
# Get current service spec
|
||||
# Docker Swarm requires the current version for optimistic locking
|
||||
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']}")]
|
||||
@ -1279,6 +1280,7 @@ async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[
|
||||
if result["status_code"] == 200 and result["data"]:
|
||||
stacks = result["data"]
|
||||
# Filter stacks for this environment
|
||||
# Stacks API returns all stacks, need to filter by 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"
|
||||
@ -1354,7 +1356,7 @@ async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[
|
||||
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 if this is a Swarm cluster
|
||||
swarm = info.get('Swarm', {})
|
||||
if swarm.get('LocalNodeState') == 'active':
|
||||
output += f"\nSwarm Status:\n"
|
||||
|
@ -4,6 +4,36 @@ Portainer Edge MCP Server
|
||||
|
||||
Provides edge computing functionality through Portainer's API.
|
||||
Manages edge environments, edge stacks, edge groups, and edge jobs.
|
||||
|
||||
This module implements comprehensive edge computing management including:
|
||||
- Edge environment registration and monitoring
|
||||
- Edge agent deployment and configuration
|
||||
- Edge groups for organizing edge devices
|
||||
- Edge stacks for distributed application deployment
|
||||
- Edge jobs for remote command execution
|
||||
- Edge environment status tracking and diagnostics
|
||||
|
||||
The server supports various edge computing scenarios:
|
||||
- IoT device management at scale
|
||||
- Distributed application deployment to edge locations
|
||||
- Remote configuration and updates
|
||||
- Centralized monitoring of edge infrastructure
|
||||
- Batch operations across edge device groups
|
||||
|
||||
Complexity: O(n) for list operations where n is number of edge environments
|
||||
Dependencies: aiohttp for async HTTP, mcp for server protocol
|
||||
Call Flow: MCP client -> handle_call_tool() -> make_request() -> Portainer API
|
||||
|
||||
Environment Variables:
|
||||
PORTAINER_URL: Base URL of Portainer instance (required)
|
||||
PORTAINER_API_KEY: API key for authentication (required)
|
||||
MCP_MODE: Set to "true" to suppress logging (default: true)
|
||||
|
||||
Edge Concepts:
|
||||
- Edge Environment: Remote Docker/K8s instance with edge agent
|
||||
- Edge Group: Logical grouping of edge environments
|
||||
- Edge Stack: Application deployed to multiple edge environments
|
||||
- Edge Job: Script/command executed on edge environments
|
||||
"""
|
||||
|
||||
import os
|
||||
@ -47,7 +77,36 @@ async def make_request(
|
||||
data: Optional[Any] = None,
|
||||
headers: Optional[dict] = None
|
||||
) -> dict:
|
||||
"""Make an authenticated request to Portainer API."""
|
||||
"""
|
||||
Make an authenticated request to Portainer API.
|
||||
|
||||
Centralized HTTP request handler for all Portainer Edge API interactions.
|
||||
Handles authentication, error responses, and timeout management.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, PUT, DELETE)
|
||||
endpoint: API endpoint path (e.g., /api/edge_groups)
|
||||
json_data: Optional JSON payload for request body
|
||||
params: Optional query parameters
|
||||
data: Optional form data or raw body content
|
||||
headers: Optional additional headers to merge with defaults
|
||||
|
||||
Returns:
|
||||
Dict containing response data or error information
|
||||
Error responses have 'error' key with descriptive message
|
||||
|
||||
Complexity: O(1) - Single HTTP request
|
||||
|
||||
Call Flow:
|
||||
- Called by: All tool handler functions
|
||||
- Calls: aiohttp for async HTTP operations
|
||||
|
||||
Error Handling:
|
||||
- HTTP 4xx/5xx errors return structured error dict
|
||||
- Timeout errors (30s) return timeout error
|
||||
- Network errors return connection error
|
||||
- Parses Portainer error details from response
|
||||
"""
|
||||
url = f"{PORTAINER_URL}{endpoint}"
|
||||
|
||||
default_headers = {
|
||||
@ -95,7 +154,29 @@ async def make_request(
|
||||
return {"error": f"Request failed: {str(e)}"}
|
||||
|
||||
def format_edge_status(status: int) -> str:
|
||||
"""Format edge environment status with emoji."""
|
||||
"""
|
||||
Format edge environment status with emoji.
|
||||
|
||||
Converts numeric edge status codes to user-friendly strings
|
||||
with visual indicators for quick status recognition.
|
||||
|
||||
Args:
|
||||
status: Edge environment status code from API
|
||||
|
||||
Returns:
|
||||
Formatted status string with emoji indicator
|
||||
|
||||
Complexity: O(1) - Simple lookup
|
||||
|
||||
Call Flow:
|
||||
- Called by: Edge environment listing functions
|
||||
- Calls: None (pure function)
|
||||
|
||||
Status Codes:
|
||||
- 1: Connected (🔴 green) - Agent is online
|
||||
- 2: Disconnected (🔴 red) - Agent is offline
|
||||
- Other: Unknown (❔) - Undefined status
|
||||
"""
|
||||
if status == 1:
|
||||
return "🟢 Connected"
|
||||
elif status == 2:
|
||||
|
@ -1,5 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MCP server for Portainer Environments management."""
|
||||
"""
|
||||
MCP server for Portainer Environments management.
|
||||
|
||||
This module provides comprehensive environment and endpoint management functionality
|
||||
for Portainer Business Edition. It handles Docker, Kubernetes, and Edge environments
|
||||
with support for groups, tags, and team associations.
|
||||
|
||||
The server implements tools for:
|
||||
- Environment CRUD operations (Docker, Kubernetes, Edge)
|
||||
- Environment status monitoring and health checks
|
||||
- Environment groups for logical organization
|
||||
- Tag management for categorization
|
||||
- Team access control associations
|
||||
- Edge agent deployment script generation
|
||||
|
||||
Complexity: O(1) for all operations (simple HTTP requests)
|
||||
Dependencies: httpx for async HTTP, mcp for server protocol, enum for type constants
|
||||
Call Flow: MCP client -> handle_call_tool() -> make_request() -> Portainer API
|
||||
|
||||
Environment Variables:
|
||||
PORTAINER_URL: Base URL of Portainer instance
|
||||
PORTAINER_API_KEY: API key for authentication
|
||||
MCP_MODE: Set to "true" to suppress logging
|
||||
|
||||
API Compatibility:
|
||||
Supports both new (/environments) and legacy (/endpoints) API endpoints
|
||||
for backward compatibility with older Portainer versions.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
@ -26,6 +53,19 @@ api_key = os.getenv("PORTAINER_API_KEY", "")
|
||||
|
||||
# Environment types enum
|
||||
class EnvironmentType(Enum):
|
||||
"""
|
||||
Enumeration of supported Portainer environment types.
|
||||
|
||||
Maps environment type names to their numeric API values.
|
||||
Used when creating new environments to specify the type.
|
||||
|
||||
Values:
|
||||
DOCKER: Standard Docker environment
|
||||
DOCKER_SWARM: Docker Swarm cluster
|
||||
KUBERNETES: Kubernetes cluster
|
||||
ACI: Azure Container Instances
|
||||
EDGE_AGENT: Edge computing environment
|
||||
"""
|
||||
DOCKER = 1
|
||||
DOCKER_SWARM = 2
|
||||
KUBERNETES = 3
|
||||
@ -35,7 +75,29 @@ class EnvironmentType(Enum):
|
||||
|
||||
@server.list_tools()
|
||||
async def handle_list_tools() -> list[types.Tool]:
|
||||
"""List available tools."""
|
||||
"""
|
||||
List available tools.
|
||||
|
||||
Returns a comprehensive list of environment management tools.
|
||||
Each tool includes its name, description, and JSON schema for input validation.
|
||||
|
||||
Returns:
|
||||
list[types.Tool]: List of available tools with their schemas
|
||||
|
||||
Complexity: O(1) - Returns static list
|
||||
|
||||
Call Flow:
|
||||
- Called by: MCP protocol during initialization
|
||||
- Calls: None (static return)
|
||||
|
||||
Tool Categories:
|
||||
- Basic Operations: list, get, create, update, delete environments
|
||||
- Status: get_environment_status for health monitoring
|
||||
- Groups: CRUD operations for environment groups
|
||||
- Tags: Create and manage environment tags
|
||||
- Edge: Generate Edge agent deployment scripts
|
||||
- Access: Team association management
|
||||
"""
|
||||
return [
|
||||
# Basic environment operations
|
||||
types.Tool(
|
||||
@ -363,7 +425,31 @@ async def handle_list_tools() -> list[types.Tool]:
|
||||
|
||||
|
||||
async def make_request(method: str, endpoint: str, json_data: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
"""Make HTTP request to Portainer API."""
|
||||
"""
|
||||
Make HTTP request to Portainer API.
|
||||
|
||||
Centralized HTTP request handler that manages all API communication with Portainer.
|
||||
Handles authentication headers, timeout configuration, and safe JSON parsing.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, PUT, DELETE)
|
||||
endpoint: API endpoint path (without /api prefix)
|
||||
json_data: Optional JSON payload for POST/PUT requests
|
||||
|
||||
Returns:
|
||||
Dict containing status_code, data (parsed JSON), and raw text
|
||||
|
||||
Complexity: O(1) - Single HTTP request
|
||||
|
||||
Call Flow:
|
||||
- Called by: handle_call_tool() for all API operations
|
||||
- Calls: httpx for async HTTP requests
|
||||
|
||||
Error Handling:
|
||||
- 30 second timeout for long-running operations
|
||||
- Safe JSON parsing with content-type checking
|
||||
- SSL verification disabled for self-signed certificates
|
||||
"""
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
|
||||
@ -381,6 +467,7 @@ async def make_request(method: str, endpoint: str, json_data: Optional[Dict] = N
|
||||
raise ValueError(f"Unsupported method: {method}")
|
||||
|
||||
# Parse JSON response safely
|
||||
# Check content-type header to avoid parsing non-JSON responses
|
||||
try:
|
||||
data = response.json() if response.text and response.headers.get("content-type", "").startswith("application/json") else None
|
||||
except Exception:
|
||||
@ -390,7 +477,24 @@ async def make_request(method: str, endpoint: str, json_data: Optional[Dict] = N
|
||||
|
||||
|
||||
def format_environment_type(env_type: int) -> str:
|
||||
"""Convert environment type ID to readable string."""
|
||||
"""
|
||||
Convert environment type ID to readable string.
|
||||
|
||||
Maps numeric environment type values from the API to human-readable names.
|
||||
Includes support for local environment types not in the enum.
|
||||
|
||||
Args:
|
||||
env_type: Numeric environment type from API
|
||||
|
||||
Returns:
|
||||
Human-readable environment type name
|
||||
|
||||
Complexity: O(1) - Dictionary lookup
|
||||
|
||||
Call Flow:
|
||||
- Called by: list_environments, get_environment in handle_call_tool()
|
||||
- Calls: None (pure function)
|
||||
"""
|
||||
type_map = {
|
||||
1: "Docker",
|
||||
2: "Docker Swarm",
|
||||
@ -404,7 +508,23 @@ def format_environment_type(env_type: int) -> str:
|
||||
|
||||
|
||||
def format_environment_status(status: int) -> str:
|
||||
"""Convert environment status to readable string."""
|
||||
"""
|
||||
Convert environment status to readable string.
|
||||
|
||||
Maps numeric status values to human-readable status descriptions.
|
||||
|
||||
Args:
|
||||
status: Numeric status value from API
|
||||
|
||||
Returns:
|
||||
Human-readable status ("up", "down", or "unknown")
|
||||
|
||||
Complexity: O(1) - Dictionary lookup
|
||||
|
||||
Call Flow:
|
||||
- Called by: list_environments, get_environment in handle_call_tool()
|
||||
- Calls: None (pure function)
|
||||
"""
|
||||
status_map = {
|
||||
1: "up",
|
||||
2: "down"
|
||||
@ -414,7 +534,32 @@ def format_environment_status(status: int) -> str:
|
||||
|
||||
@server.call_tool()
|
||||
async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]:
|
||||
"""Handle tool calls."""
|
||||
"""
|
||||
Handle tool calls.
|
||||
|
||||
Main request dispatcher that processes all incoming tool requests from MCP clients.
|
||||
Routes requests to appropriate Portainer API endpoints and formats responses.
|
||||
Handles both new (/environments) and legacy (/endpoints) API endpoints.
|
||||
|
||||
Args:
|
||||
name: The name of the tool to execute
|
||||
arguments: Optional dictionary of arguments for the tool
|
||||
|
||||
Returns:
|
||||
list[types.TextContent]: Response messages as text content
|
||||
|
||||
Complexity: O(1) - Each tool performs single API request
|
||||
|
||||
Call Flow:
|
||||
- Called by: MCP protocol when client invokes a tool
|
||||
- Calls: make_request() for API operations, format_* functions for display
|
||||
|
||||
Error Handling:
|
||||
- Validates required arguments before API calls
|
||||
- Handles both new and legacy API endpoints with fallback
|
||||
- Specific error messages for timeouts and connection failures
|
||||
- Detailed traceback for debugging unexpected errors
|
||||
"""
|
||||
import httpx
|
||||
|
||||
try:
|
||||
@ -424,15 +569,18 @@ async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[
|
||||
start = arguments.get("start", 0) if arguments else 0
|
||||
|
||||
# Try both /environments (new) and /endpoints (old) endpoints
|
||||
# This dual-endpoint approach ensures compatibility across Portainer versions
|
||||
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
|
||||
# Fall back to fetching all and paginating manually
|
||||
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
|
||||
# Legacy API returns array, new API might return paginated object
|
||||
if isinstance(environments, list):
|
||||
total = len(environments)
|
||||
environments = environments[start:start + limit]
|
||||
@ -441,6 +589,7 @@ async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[
|
||||
output = f"Found environments:\n"
|
||||
|
||||
for env in environments[:10]: # Limit output to first 10 to avoid huge responses
|
||||
# Format each environment with essential details
|
||||
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')}"
|
||||
@ -516,7 +665,8 @@ async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[
|
||||
if arguments.get("tags"):
|
||||
env_data["TagIds"] = arguments["tags"]
|
||||
|
||||
# TLS configuration
|
||||
# TLS configuration for secure connections
|
||||
# Required when connecting to Docker daemon over TCP with TLS
|
||||
if arguments.get("tls") or arguments.get("tls_skip_verify"):
|
||||
env_data["TLSConfig"] = {
|
||||
"TLS": arguments.get("tls", False),
|
||||
@ -574,7 +724,8 @@ async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[
|
||||
if not env_id:
|
||||
return [types.TextContent(type="text", text="Error: environment_id is required")]
|
||||
|
||||
# Get current environment first
|
||||
# Get current environment first to preserve unchanged fields
|
||||
# Portainer requires complete environment object for updates
|
||||
result = await make_request("GET", f"/environments/{env_id}")
|
||||
if result["status_code"] == 404:
|
||||
result = await make_request("GET", f"/endpoints/{env_id}")
|
||||
@ -628,6 +779,7 @@ async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[
|
||||
return [types.TextContent(type="text", text="Error: environment_id is required")]
|
||||
|
||||
# Try to get docker info through Portainer proxy
|
||||
# This endpoint proxies requests to the actual Docker/Kubernetes API
|
||||
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")
|
||||
@ -642,7 +794,7 @@ async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[
|
||||
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"
|
||||
output += f"- Memory: {info.get('MemTotal', 0) / (1024**3):.2f} GB\n" # Convert bytes to GB
|
||||
return [types.TextContent(type="text", text=output)]
|
||||
else:
|
||||
return [types.TextContent(type="text", text="Environment is down or inaccessible")]
|
||||
@ -800,7 +952,7 @@ async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[
|
||||
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"\nDeployment command:\n" # Provide ready-to-use Docker command
|
||||
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"
|
||||
@ -831,7 +983,26 @@ async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[
|
||||
|
||||
|
||||
async def run():
|
||||
"""Run the MCP server."""
|
||||
"""
|
||||
Run the MCP server.
|
||||
|
||||
Sets up the stdio transport and runs the server with configured capabilities.
|
||||
This is the main async loop that handles all MCP protocol communication.
|
||||
|
||||
The server runs indefinitely until interrupted, processing tool requests
|
||||
and responses through stdin/stdout streams.
|
||||
|
||||
Complexity: O(1) - Server initialization
|
||||
|
||||
Call Flow:
|
||||
- Called by: main() via asyncio.run()
|
||||
- Calls: server.run() with stdio streams
|
||||
|
||||
Configuration:
|
||||
- Uses stdio transport for Claude Desktop compatibility
|
||||
- Disables all change notifications (tools are static)
|
||||
- Server version 1.0.0 indicates stable implementation
|
||||
"""
|
||||
# Use stdio transport
|
||||
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
|
||||
await server.run(
|
||||
@ -853,7 +1024,18 @@ async def run():
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
"""
|
||||
Main entry point.
|
||||
|
||||
Synchronous wrapper that starts the async server loop.
|
||||
This is the script's entry point when run directly.
|
||||
|
||||
Complexity: O(1)
|
||||
|
||||
Call Flow:
|
||||
- Called by: __main__ block or external scripts
|
||||
- Calls: asyncio.run(run())
|
||||
"""
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
|
@ -4,6 +4,29 @@ Portainer GitOps MCP Server
|
||||
|
||||
Provides GitOps automation and configuration functionality through Portainer's API.
|
||||
Manages automatic deployments, webhooks, and Git-based stack updates.
|
||||
|
||||
This module implements a comprehensive GitOps solution for Portainer, enabling:
|
||||
- Webhook-based automatic deployments triggered by Git events
|
||||
- Polling-based deployments for environments without webhook access
|
||||
- Stack management with Git repository integration
|
||||
- Configuration updates and monitoring of GitOps status
|
||||
|
||||
The server supports both webhook and polling modes, with configurable
|
||||
intervals and authentication mechanisms for various Git providers.
|
||||
|
||||
Complexity: O(n) for list operations where n is number of stacks
|
||||
Dependencies: aiohttp for async HTTP, mcp for server protocol
|
||||
Call Flow: MCP client -> handle_call_tool() -> make_request() -> Portainer API
|
||||
|
||||
Environment Variables:
|
||||
PORTAINER_URL: Base URL of Portainer instance (required)
|
||||
PORTAINER_API_KEY: API key for authentication (required)
|
||||
MCP_MODE: Set to "true" to suppress logging (default: true)
|
||||
|
||||
GitOps Modes:
|
||||
- Webhook: Real-time updates via Git provider webhooks
|
||||
- Polling: Periodic checks for repository changes (5-30 min intervals)
|
||||
- Manual: Explicit user-triggered updates only
|
||||
"""
|
||||
|
||||
import os
|
||||
@ -110,7 +133,32 @@ server = Server("portainer-gitops")
|
||||
|
||||
@server.list_tools()
|
||||
async def handle_list_tools() -> list[types.Tool]:
|
||||
"""List all available tools."""
|
||||
"""
|
||||
List all available tools.
|
||||
|
||||
Returns the complete list of GitOps management tools provided by this
|
||||
MCP server. Each tool includes its schema for input validation.
|
||||
|
||||
Returns:
|
||||
list[types.Tool]: List of available tools with their schemas
|
||||
|
||||
Complexity: O(1) - Returns static list
|
||||
|
||||
Call Flow:
|
||||
- Called by: MCP protocol during initialization
|
||||
- Calls: None (static return)
|
||||
|
||||
Available Tools:
|
||||
- list_gitops_stacks: View all stacks with GitOps configurations
|
||||
- get_stack_gitops_config: Get detailed GitOps config for a stack
|
||||
- enable_stack_gitops: Enable automatic updates (webhook/polling)
|
||||
- disable_stack_gitops: Disable automatic updates
|
||||
- update_stack_from_git: Manually trigger Git update
|
||||
- configure_git_authentication: Set up Git credentials
|
||||
- test_git_repository: Validate Git repository access
|
||||
- get_webhook_url: Get webhook URL for Git providers
|
||||
- get_polling_status: Check polling job status
|
||||
"""
|
||||
return [
|
||||
types.Tool(
|
||||
name="list_gitops_stacks",
|
||||
@ -396,7 +444,38 @@ async def handle_call_tool(
|
||||
name: str,
|
||||
arguments: dict | None
|
||||
) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
|
||||
"""Handle tool execution."""
|
||||
"""
|
||||
Handle tool execution.
|
||||
|
||||
Main request dispatcher that processes all incoming tool requests from MCP
|
||||
clients. Routes requests to appropriate Portainer GitOps API endpoints and
|
||||
formats responses.
|
||||
|
||||
Args:
|
||||
name: The name of the tool to execute
|
||||
arguments: Optional dictionary of arguments for the tool
|
||||
|
||||
Returns:
|
||||
list[types.TextContent]: Response messages as text content
|
||||
|
||||
Complexity: O(n) for list operations where n is number of stacks
|
||||
|
||||
Call Flow:
|
||||
- Called by: MCP protocol when client invokes a tool
|
||||
- Calls: make_request() for API operations, format_gitops_status()
|
||||
|
||||
Error Handling:
|
||||
- API errors return descriptive error messages
|
||||
- Missing required arguments return validation errors
|
||||
- Network/timeout errors are caught and reported
|
||||
- All exceptions caught to prevent server crashes
|
||||
|
||||
GitOps Operations:
|
||||
- Stack listing filters by GitOps enablement
|
||||
- Configuration updates preserve existing settings
|
||||
- Webhook URLs are environment-specific
|
||||
- Polling intervals support various time units (s/m/h)
|
||||
"""
|
||||
|
||||
if not arguments:
|
||||
arguments = {}
|
||||
@ -434,6 +513,7 @@ async def handle_call_tool(
|
||||
output += f" Repository: {stack['GitConfig']['URL']}\n"
|
||||
output += f" Branch: {stack['GitConfig']['ReferenceName']}\n"
|
||||
|
||||
# Show polling-specific configuration
|
||||
if method == "polling":
|
||||
output += f" Interval: {auto_update.get('Interval', '5m')}\n"
|
||||
if auto_update.get("ForceUpdate"):
|
||||
@ -644,6 +724,7 @@ async def handle_call_tool(
|
||||
output += f" Repository: {edge_stack['GitConfig']['URL']}\n"
|
||||
output += f" Branch: {edge_stack['GitConfig']['ReferenceName']}\n"
|
||||
|
||||
# Show polling-specific configuration
|
||||
if method == "polling":
|
||||
output += f" Interval: {auto_update.get('Interval', '5m')}\n"
|
||||
|
||||
|
@ -741,7 +741,35 @@ async def handle_list_tools() -> list[types.Tool]:
|
||||
|
||||
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."""
|
||||
"""
|
||||
Make HTTP request to Portainer Kubernetes API.
|
||||
|
||||
Centralized HTTP request handler that manages all API communication with
|
||||
Portainer's Kubernetes proxy endpoints. Handles authentication and returns
|
||||
structured response data.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, PUT, DELETE, PATCH)
|
||||
endpoint: API endpoint path (e.g., /endpoints/{id}/kubernetes/...)
|
||||
json_data: Optional JSON payload for POST/PUT/PATCH requests
|
||||
params: Optional query parameters for the request
|
||||
text_response: If True, return raw text instead of parsing JSON
|
||||
|
||||
Returns:
|
||||
Dict containing status_code, data (parsed JSON or text), and raw text
|
||||
|
||||
Complexity: O(1) - Single HTTP request
|
||||
|
||||
Call Flow:
|
||||
- Called by: handle_call_tool() for all Kubernetes API operations
|
||||
- Calls: httpx for async HTTP requests
|
||||
|
||||
Error Handling:
|
||||
- Returns status code for caller to handle
|
||||
- Preserves raw response text for debugging
|
||||
- SSL verification disabled for self-signed certificates
|
||||
- Handles both JSON and text responses
|
||||
"""
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
|
||||
@ -1311,6 +1339,7 @@ async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[
|
||||
for port in ports:
|
||||
port_str = f"{port.get('port')}"
|
||||
if port.get("targetPort") and str(port.get("targetPort")) != str(port.get("port")):
|
||||
# Display target port mapping using arrow notation
|
||||
port_str += f"→{port.get('targetPort')}"
|
||||
if port.get("nodePort"):
|
||||
port_str += f" (NodePort: {port.get('nodePort')})"
|
||||
|
@ -4,6 +4,34 @@ Portainer Stacks MCP Server
|
||||
|
||||
Provides stack deployment and management functionality through Portainer's API.
|
||||
Supports Docker Compose stacks and Kubernetes manifests.
|
||||
|
||||
This module implements comprehensive stack management capabilities including:
|
||||
- Stack creation from strings, files, Git repositories, or templates
|
||||
- Stack lifecycle management (start, stop, update, delete)
|
||||
- Support for both Docker Swarm and Kubernetes environments
|
||||
- Environment variable and secret management
|
||||
- Stack migration between environments
|
||||
- Template-based deployments with variable substitution
|
||||
|
||||
The server handles various deployment scenarios:
|
||||
- Docker Compose stacks for Swarm mode
|
||||
- Kubernetes manifests for K8s clusters
|
||||
- Git-based deployments with authentication
|
||||
- App templates from Portainer's registry
|
||||
|
||||
Complexity: O(n) for list operations where n is number of stacks
|
||||
Dependencies: aiohttp for async HTTP, mcp for server protocol
|
||||
Call Flow: MCP client -> handle_call_tool() -> make_request() -> Portainer API
|
||||
|
||||
Environment Variables:
|
||||
PORTAINER_URL: Base URL of Portainer instance (required)
|
||||
PORTAINER_API_KEY: API key for authentication (required)
|
||||
MCP_MODE: Set to "true" to suppress logging (default: true)
|
||||
|
||||
Stack Types:
|
||||
- Swarm: Docker Compose format for Swarm orchestration
|
||||
- Kubernetes: YAML manifests for K8s deployments
|
||||
- Standalone: Single-node Docker Compose (converts to Swarm)
|
||||
"""
|
||||
|
||||
import os
|
||||
@ -47,7 +75,36 @@ async def make_request(
|
||||
data: Optional[Any] = None,
|
||||
headers: Optional[dict] = None
|
||||
) -> dict:
|
||||
"""Make an authenticated request to Portainer API."""
|
||||
"""
|
||||
Make an authenticated request to Portainer API.
|
||||
|
||||
Centralized HTTP request handler for all Portainer Stack API interactions.
|
||||
Handles authentication, error responses, and timeout management.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, PUT, DELETE)
|
||||
endpoint: API endpoint path (e.g., /api/stacks)
|
||||
json_data: Optional JSON payload for request body
|
||||
params: Optional query parameters
|
||||
data: Optional form data or raw body content
|
||||
headers: Optional additional headers to merge with defaults
|
||||
|
||||
Returns:
|
||||
Dict containing response data or error information
|
||||
Error responses have 'error' key with descriptive message
|
||||
|
||||
Complexity: O(1) - Single HTTP request
|
||||
|
||||
Call Flow:
|
||||
- Called by: All tool handler functions
|
||||
- Calls: aiohttp for async HTTP operations
|
||||
|
||||
Error Handling:
|
||||
- HTTP 4xx/5xx errors return structured error dict
|
||||
- Timeout errors (30s) return timeout error
|
||||
- Network errors return connection error
|
||||
- Parses Portainer error details from response
|
||||
"""
|
||||
url = f"{PORTAINER_URL}{endpoint}"
|
||||
|
||||
default_headers = {
|
||||
@ -95,7 +152,29 @@ async def make_request(
|
||||
return {"error": f"Request failed: {str(e)}"}
|
||||
|
||||
def format_stack_status(stack: dict) -> str:
|
||||
"""Format stack status with emoji."""
|
||||
"""
|
||||
Format stack status with emoji.
|
||||
|
||||
Converts numeric stack status codes to user-friendly strings
|
||||
with visual indicators for quick status recognition.
|
||||
|
||||
Args:
|
||||
stack: Stack object from Portainer API
|
||||
|
||||
Returns:
|
||||
Formatted status string with emoji
|
||||
|
||||
Complexity: O(1) - Simple lookup
|
||||
|
||||
Call Flow:
|
||||
- Called by: Stack listing and detail display functions
|
||||
- Calls: None (pure function)
|
||||
|
||||
Status Codes:
|
||||
- 1: Active (running) - ✅
|
||||
- 2: Inactive (stopped) - 🛑
|
||||
- Other: Unknown - ❓
|
||||
"""
|
||||
status = stack.get("Status", 0)
|
||||
if status == 1:
|
||||
return "✅ Active"
|
||||
@ -105,7 +184,30 @@ def format_stack_status(stack: dict) -> str:
|
||||
return "❓ Unknown"
|
||||
|
||||
def format_stack_type(stack_type: int) -> str:
|
||||
"""Format stack type."""
|
||||
"""
|
||||
Format stack type.
|
||||
|
||||
Converts numeric stack type codes to readable strings identifying
|
||||
the orchestration platform.
|
||||
|
||||
Args:
|
||||
stack_type: Numeric type code from Portainer API
|
||||
|
||||
Returns:
|
||||
Stack type as string (Swarm, Compose, Kubernetes, or Unknown)
|
||||
|
||||
Complexity: O(1) - Simple lookup
|
||||
|
||||
Call Flow:
|
||||
- Called by: Stack listing and detail display functions
|
||||
- Calls: None (pure function)
|
||||
|
||||
Type Codes:
|
||||
- 1: Swarm (Docker Swarm mode)
|
||||
- 2: Compose (Standalone Docker Compose)
|
||||
- 3: Kubernetes (K8s manifests)
|
||||
- Other: Unknown type
|
||||
"""
|
||||
if stack_type == 1:
|
||||
return "Swarm"
|
||||
elif stack_type == 2:
|
||||
@ -120,7 +222,33 @@ server = Server("portainer-stacks")
|
||||
|
||||
@server.list_tools()
|
||||
async def handle_list_tools() -> list[types.Tool]:
|
||||
"""List all available tools."""
|
||||
"""
|
||||
List all available tools.
|
||||
|
||||
Returns the complete list of stack management tools provided by this
|
||||
MCP server. Each tool includes its schema for input validation.
|
||||
|
||||
Returns:
|
||||
list[types.Tool]: List of available tools with their schemas
|
||||
|
||||
Complexity: O(1) - Returns static list
|
||||
|
||||
Call Flow:
|
||||
- Called by: MCP protocol during initialization
|
||||
- Calls: None (static return)
|
||||
|
||||
Available Tools:
|
||||
- list_stacks: View all stacks across environments
|
||||
- get_stack_details: Get detailed info about a specific stack
|
||||
- create_stack_from_string: Deploy from Compose/K8s string
|
||||
- create_stack_from_repository: Deploy from Git repository
|
||||
- create_stack_from_file: Deploy from uploaded file
|
||||
- update_stack: Modify existing stack configuration
|
||||
- delete_stack: Remove a stack and its resources
|
||||
- start_stack: Start a stopped stack
|
||||
- stop_stack: Stop a running stack
|
||||
- get_stack_file: Retrieve stack definition file
|
||||
"""
|
||||
return [
|
||||
types.Tool(
|
||||
name="list_stacks",
|
||||
|
14
run_mcp.py
14
run_mcp.py
@ -1,14 +0,0 @@
|
||||
#!/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()
|
@ -23,6 +23,17 @@ Architecture:
|
||||
- Configuration loading from environment variables
|
||||
- Async server initialization and startup
|
||||
- Graceful error handling and shutdown
|
||||
|
||||
Complexity: O(1) - Simple startup sequence
|
||||
Dependencies: portainer_core.server for main server logic
|
||||
Call Flow: __main__ -> setup_environment() -> asyncio.run(main())
|
||||
|
||||
Server Lifecycle:
|
||||
1. Environment validation and setup
|
||||
2. Async event loop initialization
|
||||
3. MCP server startup on stdio
|
||||
4. Process requests until interrupted
|
||||
5. Graceful shutdown on exit
|
||||
"""
|
||||
|
||||
import os
|
||||
@ -99,6 +110,8 @@ if __name__ == "__main__":
|
||||
|
||||
try:
|
||||
# Start the async MCP server - this blocks until shutdown
|
||||
# The main() function from portainer_core.server handles all MCP protocol
|
||||
# communication via stdio streams (stdin/stdout)
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
# Handle graceful shutdown on Ctrl+C
|
||||
|
@ -1,241 +0,0 @@
|
||||
#!/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()
|
@ -1,258 +0,0 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: portainer-core-mcp
|
||||
Version: 0.1.0
|
||||
Summary: Portainer Core MCP Server - Authentication and User Management
|
||||
Author-email: Portainer MCP Team <support@portainer.io>
|
||||
License: MIT
|
||||
Project-URL: Homepage, https://github.com/portainer/portainer-mcp-core
|
||||
Project-URL: Documentation, https://github.com/portainer/portainer-mcp-core#readme
|
||||
Project-URL: Repository, https://github.com/portainer/portainer-mcp-core
|
||||
Project-URL: Issues, https://github.com/portainer/portainer-mcp-core/issues
|
||||
Classifier: Development Status :: 3 - Alpha
|
||||
Classifier: Intended Audience :: Developers
|
||||
Classifier: License :: OSI Approved :: MIT License
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Programming Language :: Python :: 3.8
|
||||
Classifier: Programming Language :: Python :: 3.9
|
||||
Classifier: Programming Language :: Python :: 3.10
|
||||
Classifier: Programming Language :: Python :: 3.11
|
||||
Classifier: Programming Language :: Python :: 3.12
|
||||
Requires-Python: >=3.8
|
||||
Description-Content-Type: text/markdown
|
||||
Requires-Dist: mcp>=1.0.0
|
||||
Requires-Dist: httpx>=0.25.0
|
||||
Requires-Dist: pydantic>=2.0.0
|
||||
Requires-Dist: pydantic-settings>=2.0.0
|
||||
Requires-Dist: structlog>=23.0.0
|
||||
Requires-Dist: PyJWT>=2.8.0
|
||||
Requires-Dist: python-dotenv>=1.0.0
|
||||
Requires-Dist: tenacity>=8.0.0
|
||||
Provides-Extra: dev
|
||||
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
||||
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
||||
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
||||
Requires-Dist: pytest-mock>=3.10.0; extra == "dev"
|
||||
Requires-Dist: httpx-mock>=0.10.0; extra == "dev"
|
||||
Requires-Dist: black>=23.0.0; extra == "dev"
|
||||
Requires-Dist: isort>=5.12.0; extra == "dev"
|
||||
Requires-Dist: flake8>=6.0.0; extra == "dev"
|
||||
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
||||
Requires-Dist: pre-commit>=3.0.0; extra == "dev"
|
||||
|
||||
# Portainer Core MCP Server
|
||||
|
||||
A Model Context Protocol (MCP) server that provides authentication and user management functionality for Portainer Business Edition.
|
||||
|
||||
## Features
|
||||
|
||||
- **Authentication**: JWT token-based authentication with Portainer API
|
||||
- **User Management**: Complete CRUD operations for users
|
||||
- **Settings Management**: Portainer instance configuration
|
||||
- **Health Monitoring**: Server and service health checks
|
||||
- **Fault Tolerance**: Circuit breaker pattern with automatic recovery
|
||||
- **Structured Logging**: JSON-formatted logs with correlation IDs
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.8+
|
||||
- Portainer Business Edition instance
|
||||
- Valid Portainer API key
|
||||
|
||||
## Installation
|
||||
|
||||
### Using pip
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### Using uv (recommended)
|
||||
|
||||
```bash
|
||||
uv pip install -e .
|
||||
```
|
||||
|
||||
### Using uvx (run without installing)
|
||||
|
||||
```bash
|
||||
# No installation needed - runs directly
|
||||
uvx --from . portainer-core-mcp
|
||||
```
|
||||
|
||||
### Using npm/npx
|
||||
|
||||
```bash
|
||||
npm install -g portainer-core-mcp
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Create a `.env` file or set environment variables:
|
||||
|
||||
```bash
|
||||
# Required
|
||||
PORTAINER_URL=https://your-portainer-instance.com
|
||||
PORTAINER_API_KEY=your-api-key-here
|
||||
|
||||
# Optional
|
||||
HTTP_TIMEOUT=30
|
||||
MAX_RETRIES=3
|
||||
LOG_LEVEL=INFO
|
||||
DEBUG=false
|
||||
```
|
||||
|
||||
### Generate API Key
|
||||
|
||||
1. Log in to your Portainer instance
|
||||
2. Go to **User Settings** > **API Tokens**
|
||||
3. Click **Add API Token**
|
||||
4. Copy the generated token
|
||||
|
||||
## Usage
|
||||
|
||||
### Start the Server
|
||||
|
||||
#### Using Python
|
||||
|
||||
```bash
|
||||
python run_server.py
|
||||
```
|
||||
|
||||
#### Using uv
|
||||
|
||||
```bash
|
||||
uv run python run_server.py
|
||||
```
|
||||
|
||||
#### Using uvx
|
||||
|
||||
```bash
|
||||
uvx --from . portainer-core-mcp
|
||||
```
|
||||
|
||||
#### Using npm/npx
|
||||
|
||||
```bash
|
||||
npx portainer-core-mcp
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
|
||||
```bash
|
||||
# Copy example environment file
|
||||
cp .env.example .env
|
||||
|
||||
# Edit configuration
|
||||
nano .env
|
||||
|
||||
# Start server (choose your preferred method)
|
||||
python run_server.py
|
||||
# OR
|
||||
uvx --from . portainer-core-mcp
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
The MCP server provides the following tools:
|
||||
|
||||
### Authentication
|
||||
- `authenticate` - Login with username/password
|
||||
- `generate_token` - Generate API tokens
|
||||
- `get_current_user` - Get current user info
|
||||
|
||||
### User Management
|
||||
- `list_users` - List all users
|
||||
- `create_user` - Create new user
|
||||
- `update_user` - Update user details
|
||||
- `delete_user` - Delete user
|
||||
|
||||
### Settings
|
||||
- `get_settings` - Get Portainer settings
|
||||
- `update_settings` - Update configuration
|
||||
|
||||
### Health
|
||||
- `health_check` - Server health status
|
||||
|
||||
## Available Resources
|
||||
|
||||
- `portainer://users` - User management data
|
||||
- `portainer://settings` - Configuration settings
|
||||
- `portainer://health` - Server health status
|
||||
|
||||
## Development
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/yourusername/portainer-core-mcp.git
|
||||
cd portainer-core-mcp
|
||||
|
||||
# Install development dependencies
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Install pre-commit hooks
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=src/portainer_core --cov-report=html
|
||||
|
||||
# Run only unit tests
|
||||
pytest -m unit
|
||||
|
||||
# Run only integration tests
|
||||
pytest -m integration
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
black src tests
|
||||
isort src tests
|
||||
|
||||
# Lint code
|
||||
flake8 src tests
|
||||
|
||||
# Type checking
|
||||
mypy src
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The server follows a layered architecture:
|
||||
|
||||
- **MCP Server Layer**: Handles MCP protocol communication
|
||||
- **Service Layer**: Abstracts Portainer API interactions
|
||||
- **Models Layer**: Defines data structures and validation
|
||||
- **Utils Layer**: Provides utility functions and helpers
|
||||
|
||||
## Security
|
||||
|
||||
- All API communications use HTTPS
|
||||
- JWT tokens are handled securely and never logged
|
||||
- Input validation on all parameters
|
||||
- Rate limiting to prevent abuse
|
||||
- Circuit breaker pattern for fault tolerance
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests for new functionality
|
||||
5. Ensure all tests pass
|
||||
6. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details.
|
@ -1,25 +0,0 @@
|
||||
README.md
|
||||
pyproject.toml
|
||||
src/portainer_core/__init__.py
|
||||
src/portainer_core/config.py
|
||||
src/portainer_core/server.py
|
||||
src/portainer_core/models/__init__.py
|
||||
src/portainer_core/models/auth.py
|
||||
src/portainer_core/models/settings.py
|
||||
src/portainer_core/models/users.py
|
||||
src/portainer_core/services/__init__.py
|
||||
src/portainer_core/services/auth.py
|
||||
src/portainer_core/services/base.py
|
||||
src/portainer_core/services/settings.py
|
||||
src/portainer_core/services/users.py
|
||||
src/portainer_core/utils/__init__.py
|
||||
src/portainer_core/utils/errors.py
|
||||
src/portainer_core/utils/logging.py
|
||||
src/portainer_core/utils/tokens.py
|
||||
src/portainer_core_mcp.egg-info/PKG-INFO
|
||||
src/portainer_core_mcp.egg-info/SOURCES.txt
|
||||
src/portainer_core_mcp.egg-info/dependency_links.txt
|
||||
src/portainer_core_mcp.egg-info/entry_points.txt
|
||||
src/portainer_core_mcp.egg-info/requires.txt
|
||||
src/portainer_core_mcp.egg-info/top_level.txt
|
||||
tests/test_basic.py
|
@ -1 +0,0 @@
|
||||
|
@ -1,2 +0,0 @@
|
||||
[console_scripts]
|
||||
portainer-core-mcp = portainer_core.server:main_sync
|
@ -1,20 +0,0 @@
|
||||
mcp>=1.0.0
|
||||
httpx>=0.25.0
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
structlog>=23.0.0
|
||||
PyJWT>=2.8.0
|
||||
python-dotenv>=1.0.0
|
||||
tenacity>=8.0.0
|
||||
|
||||
[dev]
|
||||
pytest>=7.0.0
|
||||
pytest-asyncio>=0.21.0
|
||||
pytest-cov>=4.0.0
|
||||
pytest-mock>=3.10.0
|
||||
httpx-mock>=0.10.0
|
||||
black>=23.0.0
|
||||
isort>=5.12.0
|
||||
flake8>=6.0.0
|
||||
mypy>=1.0.0
|
||||
pre-commit>=3.0.0
|
@ -1 +0,0 @@
|
||||
portainer_core
|
@ -1,44 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Test Portainer Edge API endpoints
|
||||
|
||||
echo "🚀 Testing Portainer Edge API"
|
||||
echo "URL: $PORTAINER_URL"
|
||||
echo "API Key: ***${PORTAINER_API_KEY: -4}"
|
||||
echo ""
|
||||
|
||||
# Test edge environments
|
||||
echo "🌐 Testing Edge Environments..."
|
||||
curl -s -H "X-API-Key: $PORTAINER_API_KEY" \
|
||||
"$PORTAINER_URL/api/endpoints?types=4" | jq '.[] | select(.Type == 4) | {name: .Name, id: .Id, status: .Status}' 2>/dev/null || echo "No edge environments found or jq not installed"
|
||||
|
||||
echo ""
|
||||
|
||||
# Test edge groups
|
||||
echo "👥 Testing Edge Groups..."
|
||||
curl -s -H "X-API-Key: $PORTAINER_API_KEY" \
|
||||
"$PORTAINER_URL/api/edge_groups" | jq '.[0:3] | .[] | {name: .Name, id: .Id, dynamic: .Dynamic}' 2>/dev/null || echo "No edge groups found or jq not installed"
|
||||
|
||||
echo ""
|
||||
|
||||
# Test edge stacks
|
||||
echo "📚 Testing Edge Stacks..."
|
||||
curl -s -H "X-API-Key: $PORTAINER_API_KEY" \
|
||||
"$PORTAINER_URL/api/edge_stacks" | jq '.[0:3] | .[] | {name: .Name, id: .Id, groups: .EdgeGroups}' 2>/dev/null || echo "No edge stacks found or jq not installed"
|
||||
|
||||
echo ""
|
||||
|
||||
# Test edge jobs
|
||||
echo "💼 Testing Edge Jobs..."
|
||||
curl -s -H "X-API-Key: $PORTAINER_API_KEY" \
|
||||
"$PORTAINER_URL/api/edge_jobs" | jq '.[0:3] | .[] | {name: .Name, id: .Id, recurring: .Recurring}' 2>/dev/null || echo "No edge jobs found or jq not installed"
|
||||
|
||||
echo ""
|
||||
|
||||
# Test edge settings
|
||||
echo "⚙️ Testing Edge Settings..."
|
||||
curl -s -H "X-API-Key: $PORTAINER_API_KEY" \
|
||||
"$PORTAINER_URL/api/settings" | jq '.Edge | {checkin: .CheckinInterval, command: .CommandInterval, ping: .PingInterval}' 2>/dev/null || echo "Failed to get edge settings or jq not installed"
|
||||
|
||||
echo ""
|
||||
echo "✅ Edge API test completed!"
|
@ -1,163 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for Portainer Edge MCP Server
|
||||
Tests edge environments, stacks, groups, and jobs functionality
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import os
|
||||
import json
|
||||
|
||||
PORTAINER_URL = os.getenv("PORTAINER_URL", "").rstrip("/")
|
||||
PORTAINER_API_KEY = os.getenv("PORTAINER_API_KEY", "")
|
||||
|
||||
async def make_request(method: str, endpoint: str, json_data=None, params=None):
|
||||
"""Make a request to Portainer API"""
|
||||
url = f"{PORTAINER_URL}{endpoint}"
|
||||
headers = {"X-API-Key": PORTAINER_API_KEY}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
json=json_data,
|
||||
params=params,
|
||||
headers=headers
|
||||
) as response:
|
||||
text = await response.text()
|
||||
if response.status >= 400:
|
||||
print(f"❌ Error {response.status}: {text}")
|
||||
return None
|
||||
return json.loads(text) if text else {}
|
||||
|
||||
async def test_edge_environments():
|
||||
"""Test edge environment operations"""
|
||||
print("\n🌐 Testing Edge Environments...")
|
||||
|
||||
# List all endpoints/environments
|
||||
endpoints = await make_request("GET", "/api/endpoints")
|
||||
if endpoints:
|
||||
edge_envs = [e for e in endpoints if e.get("Type") == 4] # Type 4 is Edge
|
||||
print(f"✅ Found {len(edge_envs)} edge environments")
|
||||
|
||||
if edge_envs:
|
||||
# Get details of first edge environment
|
||||
env = edge_envs[0]
|
||||
print(f" • {env['Name']} (ID: {env['Id']})")
|
||||
print(f" Status: {env.get('Status', 'Unknown')}")
|
||||
print(f" Edge ID: {env.get('EdgeID', 'N/A')}")
|
||||
|
||||
# Get edge status
|
||||
status = await make_request("GET", f"/api/endpoints/{env['Id']}/edge/status")
|
||||
if status:
|
||||
print(f" Check-in: {status.get('CheckinTime', 'Never')}")
|
||||
else:
|
||||
print("❌ Failed to list environments")
|
||||
|
||||
async def test_edge_groups():
|
||||
"""Test edge group operations"""
|
||||
print("\n👥 Testing Edge Groups...")
|
||||
|
||||
# List edge groups
|
||||
groups = await make_request("GET", "/api/edge_groups")
|
||||
if groups:
|
||||
print(f"✅ Found {len(groups)} edge groups")
|
||||
for group in groups[:3]: # Show first 3
|
||||
print(f" • {group['Name']} (ID: {group['Id']})")
|
||||
print(f" Dynamic: {'Yes' if group.get('Dynamic') else 'No'}")
|
||||
print(f" Endpoints: {len(group.get('Endpoints', []))}")
|
||||
else:
|
||||
print("❌ Failed to list edge groups")
|
||||
|
||||
# Try to create a test edge group
|
||||
print("\n📝 Creating test edge group...")
|
||||
test_group_data = {
|
||||
"Name": "test-edge-group",
|
||||
"Dynamic": False,
|
||||
"TagIds": []
|
||||
}
|
||||
|
||||
new_group = await make_request("POST", "/api/edge_groups", json_data=test_group_data)
|
||||
if new_group:
|
||||
print(f"✅ Created edge group: {new_group['Name']} (ID: {new_group['Id']})")
|
||||
|
||||
# Clean up - delete the test group
|
||||
await make_request("DELETE", f"/api/edge_groups/{new_group['Id']}")
|
||||
print("🗑️ Cleaned up test edge group")
|
||||
else:
|
||||
print("❌ Failed to create edge group")
|
||||
|
||||
async def test_edge_stacks():
|
||||
"""Test edge stack operations"""
|
||||
print("\n📚 Testing Edge Stacks...")
|
||||
|
||||
# List edge stacks
|
||||
stacks = await make_request("GET", "/api/edge_stacks")
|
||||
if stacks:
|
||||
print(f"✅ Found {len(stacks)} edge stacks")
|
||||
for stack in stacks[:3]: # Show first 3
|
||||
print(f" • {stack['Name']} (ID: {stack['Id']})")
|
||||
print(f" Type: {stack.get('StackType', 'Unknown')}")
|
||||
print(f" Groups: {len(stack.get('EdgeGroups', []))}")
|
||||
|
||||
# Check if it has GitOps
|
||||
if stack.get("GitConfig") and stack.get("AutoUpdate"):
|
||||
print(f" GitOps: Enabled ({stack['AutoUpdate'].get('Interval', 'N/A')})")
|
||||
else:
|
||||
print("❌ Failed to list edge stacks")
|
||||
|
||||
async def test_edge_jobs():
|
||||
"""Test edge job operations"""
|
||||
print("\n💼 Testing Edge Jobs...")
|
||||
|
||||
# List edge jobs
|
||||
jobs = await make_request("GET", "/api/edge_jobs")
|
||||
if jobs:
|
||||
print(f"✅ Found {len(jobs)} edge jobs")
|
||||
for job in jobs[:3]: # Show first 3
|
||||
print(f" • {job['Name']} (ID: {job['Id']})")
|
||||
print(f" Recurring: {'Yes' if job.get('Recurring') else 'No'}")
|
||||
if job.get('CronExpression'):
|
||||
print(f" Schedule: {job['CronExpression']}")
|
||||
print(f" Target Groups: {len(job.get('EdgeGroups', []))}")
|
||||
else:
|
||||
print("❌ Failed to list edge jobs")
|
||||
|
||||
async def test_edge_settings():
|
||||
"""Test edge settings"""
|
||||
print("\n⚙️ Testing Edge Settings...")
|
||||
|
||||
# Get settings
|
||||
settings = await make_request("GET", "/api/settings")
|
||||
if settings and settings.get("Edge"):
|
||||
edge_settings = settings["Edge"]
|
||||
print("✅ Edge Settings:")
|
||||
print(f" • Check-in Interval: {edge_settings.get('CheckinInterval', 'N/A')} seconds")
|
||||
print(f" • Command Interval: {edge_settings.get('CommandInterval', 'N/A')} seconds")
|
||||
print(f" • Ping Interval: {edge_settings.get('PingInterval', 'N/A')} seconds")
|
||||
print(f" • Tunnel Server: {edge_settings.get('TunnelServerAddress', 'Not configured')}")
|
||||
else:
|
||||
print("❌ Failed to get edge settings")
|
||||
|
||||
async def main():
|
||||
"""Run all edge tests"""
|
||||
print("🚀 Portainer Edge API Tests")
|
||||
print(f"URL: {PORTAINER_URL}")
|
||||
print(f"API Key: {'***' + PORTAINER_API_KEY[-4:] if PORTAINER_API_KEY else 'Not set'}")
|
||||
|
||||
if not PORTAINER_URL or not PORTAINER_API_KEY:
|
||||
print("\n❌ Please set PORTAINER_URL and PORTAINER_API_KEY environment variables")
|
||||
return
|
||||
|
||||
# Run tests
|
||||
await test_edge_environments()
|
||||
await test_edge_groups()
|
||||
await test_edge_stacks()
|
||||
await test_edge_jobs()
|
||||
await test_edge_settings()
|
||||
|
||||
print("\n✅ Edge API tests completed!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
@ -1,164 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test creating a stack with GitOps enabled"""
|
||||
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import json
|
||||
import sys
|
||||
|
||||
# Configuration
|
||||
PORTAINER_URL = "https://partner.portainer.live"
|
||||
PORTAINER_API_KEY = "ptr_uMqreULEo44qvuszgG8oZWdjkDx3K9HBXSmjd+F/vDE="
|
||||
|
||||
# Stack configuration
|
||||
STACK_NAME = "nginx03-gitops"
|
||||
ENVIRONMENT_ID = 6 # docker03
|
||||
REPOSITORY_URL = "https://git.oe74.net/adelorenzo/portainer-yaml"
|
||||
REPOSITORY_REF = "main"
|
||||
COMPOSE_PATH = "docker-compose.yml"
|
||||
|
||||
async def create_stack_with_gitops():
|
||||
"""Create a stack with GitOps enabled from the start"""
|
||||
|
||||
# Build request data
|
||||
data = {
|
||||
"name": STACK_NAME,
|
||||
"repositoryURL": REPOSITORY_URL,
|
||||
"repositoryReferenceName": REPOSITORY_REF,
|
||||
"composeFilePathInRepository": COMPOSE_PATH,
|
||||
"repositoryAuthentication": False,
|
||||
"autoUpdate": {
|
||||
"interval": "5m",
|
||||
"forcePullImage": True,
|
||||
"forceUpdate": False
|
||||
}
|
||||
}
|
||||
|
||||
# Headers
|
||||
headers = {
|
||||
"X-API-Key": PORTAINER_API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# API endpoint
|
||||
endpoint = f"{PORTAINER_URL}/api/stacks/create/standalone/repository?endpointId={ENVIRONMENT_ID}"
|
||||
|
||||
print(f"Creating stack '{STACK_NAME}' with GitOps enabled...")
|
||||
print(f"Repository: {REPOSITORY_URL}")
|
||||
print(f"Compose file: {COMPOSE_PATH}")
|
||||
print(f"Environment ID: {ENVIRONMENT_ID}")
|
||||
print(f"GitOps: Enabled (polling every 5m)")
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(endpoint, json=data, headers=headers) as response:
|
||||
response_text = await response.text()
|
||||
|
||||
if response.status in [200, 201]:
|
||||
result = json.loads(response_text)
|
||||
print(f"\n✅ Stack created successfully!")
|
||||
print(f"Stack ID: {result['Id']}")
|
||||
print(f"Stack Name: {result['Name']}")
|
||||
|
||||
# Check GitOps status
|
||||
if result.get("AutoUpdate"):
|
||||
print(f"\n🔄 GitOps Status:")
|
||||
auto_update = result["AutoUpdate"]
|
||||
print(f" Enabled: Yes")
|
||||
print(f" Interval: {auto_update.get('Interval', 'N/A')}")
|
||||
print(f" Force Pull Image: {auto_update.get('ForcePullImage', False)}")
|
||||
print(f" Force Update: {auto_update.get('ForceUpdate', False)}")
|
||||
else:
|
||||
print(f"\n⚠️ GitOps is not enabled on the created stack")
|
||||
|
||||
return result
|
||||
else:
|
||||
print(f"\n❌ Error creating stack: {response.status}")
|
||||
print(f"Response: {response_text}")
|
||||
|
||||
# Try to parse error message
|
||||
try:
|
||||
error_data = json.loads(response_text)
|
||||
if "message" in error_data:
|
||||
print(f"Error message: {error_data['message']}")
|
||||
elif "details" in error_data:
|
||||
print(f"Error details: {error_data['details']}")
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Exception occurred: {str(e)}")
|
||||
return None
|
||||
|
||||
async def check_stack_gitops(stack_id):
|
||||
"""Check if GitOps is enabled on a stack"""
|
||||
|
||||
headers = {
|
||||
"X-API-Key": PORTAINER_API_KEY
|
||||
}
|
||||
|
||||
endpoint = f"{PORTAINER_URL}/api/stacks/{stack_id}"
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(endpoint, headers=headers) as response:
|
||||
if response.status == 200:
|
||||
result = await response.json()
|
||||
print(f"\n📚 Stack Details:")
|
||||
print(f"Name: {result['Name']}")
|
||||
print(f"ID: {result['Id']}")
|
||||
|
||||
if result.get("GitConfig"):
|
||||
print(f"\n🔗 Git Configuration:")
|
||||
git = result["GitConfig"]
|
||||
print(f" Repository: {git['URL']}")
|
||||
print(f" Reference: {git['ReferenceName']}")
|
||||
print(f" Path: {git.get('ConfigFilePath', 'N/A')}")
|
||||
|
||||
if result.get("AutoUpdate"):
|
||||
print(f"\n🔄 GitOps Configuration:")
|
||||
auto = result["AutoUpdate"]
|
||||
print(f" Enabled: Yes")
|
||||
print(f" Interval: {auto.get('Interval', 'N/A')}")
|
||||
print(f" Force Pull Image: {auto.get('ForcePullImage', False)}")
|
||||
print(f" Force Update: {auto.get('ForceUpdate', False)}")
|
||||
if auto.get("Webhook"):
|
||||
print(f" Webhook: Enabled")
|
||||
else:
|
||||
print(f"\n❌ GitOps: Disabled")
|
||||
|
||||
return result
|
||||
else:
|
||||
print(f"Error getting stack details: {response.status}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Exception getting stack details: {str(e)}")
|
||||
return None
|
||||
|
||||
async def main():
|
||||
print("=" * 60)
|
||||
print("Testing Portainer Stack Creation with GitOps")
|
||||
print("=" * 60)
|
||||
|
||||
# Create the stack with GitOps
|
||||
result = await create_stack_with_gitops()
|
||||
|
||||
if result:
|
||||
print("\n" + "=" * 60)
|
||||
print("Verifying GitOps configuration...")
|
||||
print("=" * 60)
|
||||
|
||||
# Wait a moment for the stack to be fully created
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Check the stack details
|
||||
await check_stack_gitops(result["Id"])
|
||||
|
||||
print("\n🎉 Test completed!")
|
||||
else:
|
||||
print("\n😞 Test failed!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
56
test_uvx.py
56
test_uvx.py
@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to verify uvx functionality.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
def test_uvx():
|
||||
"""Test that uvx can run the application."""
|
||||
print("🧪 Testing uvx functionality...")
|
||||
|
||||
# Set test environment variables
|
||||
env = os.environ.copy()
|
||||
env['PORTAINER_URL'] = 'https://demo.portainer.io'
|
||||
env['PORTAINER_API_KEY'] = 'demo-key'
|
||||
|
||||
try:
|
||||
# Test uvx --help first
|
||||
result = subprocess.run([
|
||||
'uvx', '--help'
|
||||
], capture_output=True, text=True, timeout=10)
|
||||
|
||||
if result.returncode != 0:
|
||||
print("❌ uvx not available")
|
||||
return False
|
||||
|
||||
print("✅ uvx is available")
|
||||
|
||||
# Test that our package can be found
|
||||
print("📦 Testing package discovery...")
|
||||
result = subprocess.run([
|
||||
'uvx', '--from', '.', 'portainer-core-mcp', '--help'
|
||||
], capture_output=True, text=True, timeout=10, env=env)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"❌ Package test failed: {result.stderr}")
|
||||
return False
|
||||
|
||||
print("✅ Package can be discovered by uvx")
|
||||
return True
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print("❌ uvx test timed out")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
print("❌ uvx not found - install with: pip install uv")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ uvx test failed: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = test_uvx()
|
||||
sys.exit(0 if success else 1)
|
Loading…
Reference in New Issue
Block a user