commit 72a53a64ed2bed30f6becc6e7a99bb1fdd9fedc8 Author: Ben Date: Sat Dec 20 20:40:49 2025 +0000 Initial commit: Komodo MCP server - KomodoClient for Komodo Core API - MCP tools: list_servers, list_deployments, list_stacks, get_container_status - Raw API pass-through: komodo_api_call - API documentation resource: komodo://api-reference - Docker multi-stage build with uv diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b32dfc4 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Komodo Core Connection +KOMODO_URL=https://komodo.example.com +KOMODO_API_KEY=your-api-key +KOMODO_API_SECRET=your-api-secret + +# MCP Transport Security (optional) +MCP_ALLOWED_HOSTS=localhost:*,127.0.0.1:* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fb63d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Environment files +.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..190ff33 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Stage 1: Builder for dependencies +FROM python:3.11-slim-bullseye AS builder + +WORKDIR /app + +# Install uv +RUN pip install uv + +# Copy only dependency files first to leverage Docker cache +COPY pyproject.toml ./ + +# Install dependencies with uv +RUN uv pip install --system . + +# Stage 2: Final image +FROM python:3.11-slim-bullseye + +WORKDIR /app + +# Copy installed dependencies from builder +COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/ +COPY --from=builder /usr/local/bin/ /usr/local/bin/ + +# Copy application code +COPY server.py ./ + +# Expose the port Uvicorn will listen on +EXPOSE 8000 + +# Run the server +CMD ["python", "server.py"] diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md new file mode 100644 index 0000000..56a3034 --- /dev/null +++ b/IMPLEMENTATION.md @@ -0,0 +1,78 @@ +# Implementation Details + +Technical documentation for the Komodo MCP Server. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ MCP Client (AI Agent) │ +└─────────────────────────┬───────────────────────────────┘ + │ SSE + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Docker Container (komodo-mcp) │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ FastMCP + uvicorn (:8000) │ │ +│ └───────────────────────────────────────────────────┘ │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ KomodoClient │ │ +│ │ (httpx → Komodo Core API) │ │ +│ └─────────────────────────┬─────────────────────────┘ │ +└─────────────────────────────┼───────────────────────────┘ + │ HTTPS + ▼ + ┌──────────────────┐ + │ Komodo Core │ + │ (REST API) │ + └────────┬─────────┘ + │ + ┌─────────────────┼─────────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Periphery│ │ Periphery│ │ Periphery│ + │ Server 1 │ │ Server 2 │ │ Server N │ + └──────────┘ └──────────┘ └──────────┘ +``` + +## Components + +### KomodoClient +- HTTP client using `httpx` +- Handles authentication via `X-Api-Key` and `X-Api-Secret` headers +- Methods: `read()`, `write()`, `execute()` mapping to API endpoints + +### Transport Security +- `TransportSecuritySettings` validates Host headers +- Configurable via `MCP_ALLOWED_HOSTS` + +### Tool Strategy + +**Layer 1: Curated Tools** +- `list_servers()` - Server discovery +- `list_deployments()` - Container listing +- `list_stacks()` - Stack listing +- `get_container_status(deployment)` - Container details + +**Layer 2: Raw Access** +- `komodo_api_call(endpoint, request_type, params)` - Any API operation + +### API Docs Resource +- URI: `komodo://api-reference` +- Provides structured API documentation for AI agents + +## Komodo API + +All requests use: +- **Method**: POST +- **Paths**: `/read`, `/write`, `/execute` +- **Body**: `{"type": "RequestType", "params": {...}}` +- **Auth**: `X-Api-Key` + `X-Api-Secret` headers + +Full docs: https://docs.rs/komodo_client/latest/komodo_client/api/index.html + +## Build & Deploy + +- **Build**: `uv` + multi-stage Docker +- **Registry**: Gitea Container Registry +- **Deploy**: Docker Compose diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3937871 --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +.PHONY: build dev logs stop test-sse clean rebuild + +# Build the Docker image locally +build: + docker compose -f docker-compose.dev.yml build + +# Start the development server +dev: + docker compose -f docker-compose.dev.yml up -d + @echo "Server starting at http://localhost:8001" + @echo "Use 'make logs' to view output" + +# View container logs +logs: + docker compose -f docker-compose.dev.yml logs -f + +# Stop the development server +stop: + docker compose -f docker-compose.dev.yml down + +# Test SSE endpoint (5 second timeout) +test-sse: + @echo "Testing SSE endpoint (5s timeout)..." + @curl -N --max-time 5 http://localhost:8001/sse 2>/dev/null || echo "\n[Timeout - this is expected for SSE]" + +# Test root endpoint +test-root: + @curl -s http://localhost:8001/ | head -20 + +# Full rebuild (no cache) +rebuild: + docker compose -f docker-compose.dev.yml build --no-cache + +# Clean up containers and images +clean: + docker compose -f docker-compose.dev.yml down --rmi local -v diff --git a/README.md b/README.md new file mode 100644 index 0000000..051fd99 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# Komodo MCP Server + +A Model Context Protocol (MCP) server for [Komodo](https://komo.do/) Docker management. + +## Features + +- **Curated Tools**: High-level tools for common operations +- **Raw API Access**: Pass-through tool for 100% API coverage +- **API Docs Resource**: Built-in reference for AI agents + +## Tools + +| Tool | Description | +|------|-------------| +| `list_servers` | Lists all periphery servers | +| `list_deployments` | Lists deployments (containers) | +| `list_stacks` | Lists stacks (docker-compose) | +| `get_container_status` | Gets container details | +| `komodo_api_call` | Raw API pass-through | + +## Resources + +| Resource | Description | +|----------|-------------| +| `komodo://api-reference` | API documentation | + +## Configuration + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `KOMODO_URL` | Yes | Komodo Core URL | +| `KOMODO_API_KEY` | Yes | API key | +| `KOMODO_API_SECRET` | Yes | API secret | +| `MCP_ALLOWED_HOSTS` | No | Allowed Host headers | + +### Deploy with Docker Compose + +```yaml +services: + komodo-mcp: + image: gitea.ext.ben.io/b3nw/komodo-mcp:latest + environment: + - KOMODO_URL=https://komodo.example.com + - KOMODO_API_KEY=your-key + - KOMODO_API_SECRET=your-secret + - MCP_ALLOWED_HOSTS=komodo-mcp.example.io,localhost:* + ports: + - "8000:8000" +``` + +### Connect MCP Client + +SSE endpoint: `https://your-host/sse` + +## Local Development + +```bash +# Setup +cp .env.example .env +# Edit .env with your credentials + +# Build and run +make build +make dev + +# Test +make logs +make test-sse +make stop +``` + +## Tech Stack + +- Python 3.11+ / FastMCP / httpx +- SSE transport / uvicorn +- Docker with multi-stage build diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..6c16cc5 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,9 @@ +services: + komodo-mcp: + build: . + container_name: komodo-mcp-dev + env_file: + - .env + ports: + - "8001:8000" + restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7e20f6b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + komodo-mcp: + image: gitea.ext.ben.io/b3nw/komodo-mcp:latest + container_name: komodo-mcp + environment: + - KOMODO_URL=${KOMODO_URL} + - KOMODO_API_KEY=${KOMODO_API_KEY} + - KOMODO_API_SECRET=${KOMODO_API_SECRET} + - MCP_ALLOWED_HOSTS=komodo-mcp.example.io,localhost:*,127.0.0.1:* + ports: + - "8000:8000" + restart: unless-stopped diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..24d72ec --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "komodo-mcp" +version = "0.1.0" +description = "MCP Server for Komodo Docker Management" +dependencies = [ + "mcp", + "httpx", + "uvicorn", + "fastapi", + "python-dotenv", +] +requires-python = ">=3.11" + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[tool.uv] +python-version = "3.11" diff --git a/server.py b/server.py new file mode 100644 index 0000000..bf279c4 --- /dev/null +++ b/server.py @@ -0,0 +1,230 @@ +import os +import logging +import httpx +from mcp.server.fastmcp import FastMCP +from mcp.server.transport_security import TransportSecuritySettings +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# --- Configuration --- +KOMODO_URL = os.getenv("KOMODO_URL", "").rstrip("/") +KOMODO_API_KEY = os.getenv("KOMODO_API_KEY", "") +KOMODO_API_SECRET = os.getenv("KOMODO_API_SECRET", "") +MCP_ALLOWED_HOSTS = os.getenv("MCP_ALLOWED_HOSTS", "localhost:*,127.0.0.1:*") + + +class KomodoClient: + """HTTP client for Komodo Core API.""" + + def __init__(self, url: str, api_key: str, api_secret: str): + self.url = url + self.headers = { + "Content-Type": "application/json", + "X-Api-Key": api_key, + "X-Api-Secret": api_secret, + } + self._client = httpx.Client(timeout=30.0) + + def _request(self, endpoint: str, request_type: str, params: dict = None) -> dict: + """Make a request to Komodo API. + + Args: + endpoint: API endpoint (read, write, execute) + request_type: Request type name (e.g., 'ListServers', 'GetDeployment') + params: Request parameters + """ + url = f"{self.url}/{endpoint}" + body = {"type": request_type, "params": params or {}} + + try: + response = self._client.post(url, json=body, headers=self.headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + return {"error": f"HTTP {e.response.status_code}: {e.response.text}"} + except Exception as e: + return {"error": str(e)} + + def read(self, request_type: str, params: dict = None) -> dict: + """Make a read request.""" + return self._request("read", request_type, params) + + def write(self, request_type: str, params: dict = None) -> dict: + """Make a write request.""" + return self._request("write", request_type, params) + + def execute(self, request_type: str, params: dict = None) -> dict: + """Make an execute request.""" + return self._request("execute", request_type, params) + + +# --- Initialize Client --- +def _get_client() -> tuple[KomodoClient | None, dict | None]: + """Get Komodo client or return error dict.""" + if not KOMODO_URL: + return None, {"error": "KOMODO_URL not configured"} + if not KOMODO_API_KEY or not KOMODO_API_SECRET: + return None, {"error": "KOMODO_API_KEY and KOMODO_API_SECRET required"} + return KomodoClient(KOMODO_URL, KOMODO_API_KEY, KOMODO_API_SECRET), None + + +# --- MCP Server --- +transport_security = TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=[h.strip() for h in MCP_ALLOWED_HOSTS.split(",")], + allowed_origins=[], +) +mcp = FastMCP("Komodo MCP", transport_security=transport_security) + + +# --- API Documentation Resource --- +API_DOCS = """# Komodo Core API Reference + +## Endpoints +All requests use POST method with JSON body: `{"type": "RequestType", "params": {...}}` + +### /read - Get Data +| Request Type | Description | Params | +|-------------|-------------|--------| +| `ListServers` | List all servers | `{}` | +| `GetServer` | Get server details | `{"server": "name_or_id"}` | +| `ListDeployments` | List deployments | `{}` | +| `GetDeployment` | Get deployment details | `{"deployment": "name_or_id"}` | +| `GetDeploymentContainer` | Get container info | `{"deployment": "name_or_id"}` | +| `GetDeploymentLog` | Get container logs | `{"deployment": "name_or_id", "tail": 100}` | +| `ListStacks` | List stacks | `{}` | +| `GetStack` | Get stack details | `{"stack": "name_or_id"}` | +| `GetDockerContainersSummary` | Container summary for server | `{"server": "name_or_id"}` | +| `ListBuilds` | List builds | `{}` | +| `ListRepos` | List repositories | `{}` | +| `ListProcedures` | List procedures | `{}` | +| `GetCoreInfo` | Get Komodo Core info | `{}` | + +### /execute - Run Actions +| Request Type | Description | Params | +|-------------|-------------|--------| +| `Deploy` | Deploy a deployment | `{"deployment": "name_or_id"}` | +| `StartContainer` | Start container | `{"deployment": "name_or_id"}` | +| `StopContainer` | Stop container | `{"deployment": "name_or_id"}` | +| `PauseContainer` | Pause container | `{"deployment": "name_or_id"}` | +| `RestartContainer` | Restart container | `{"deployment": "name_or_id"}` | +| `DeployStack` | Deploy stack | `{"stack": "name_or_id"}` | +| `StartStack` | Start stack | `{"stack": "name_or_id"}` | +| `StopStack` | Stop stack | `{"stack": "name_or_id"}` | +| `RestartStack` | Restart stack | `{"stack": "name_or_id"}` | +| `RunBuild` | Run a build | `{"build": "name_or_id"}` | +| `RunProcedure` | Run a procedure | `{"procedure": "name_or_id"}` | + +### /write - Modify Resources +| Request Type | Description | Params | +|-------------|-------------|--------| +| `CreateDeployment` | Create deployment | `{"name": "...", "config": {...}}` | +| `UpdateDeployment` | Update deployment | `{"id": "...", "config": {...}}` | +| `DeleteDeployment` | Delete deployment | `{"id": "..."}` | +| `CreateStack` | Create stack | `{"name": "...", "config": {...}}` | +| `UpdateStack` | Update stack | `{"id": "...", "config": {...}}` | +| `DeleteStack` | Delete stack | `{"id": "..."}` | + +## Full Documentation +https://docs.rs/komodo_client/latest/komodo_client/api/index.html +""" + + +@mcp.resource("komodo://api-reference") +def get_api_reference() -> str: + """Komodo API reference documentation.""" + return API_DOCS + + +# --- MCP Tools --- +@mcp.tool() +def list_servers() -> dict: + """Lists all servers (periphery nodes) connected to Komodo. + + Returns server names, IDs, status, and system information. + """ + client, error = _get_client() + if error: + return error + return client.read("ListServers") + + +@mcp.tool() +def list_deployments() -> dict: + """Lists all deployments (single container applications). + + Deployments are Docker containers managed by Komodo. + """ + client, error = _get_client() + if error: + return error + return client.read("ListDeployments") + + +@mcp.tool() +def list_stacks() -> dict: + """Lists all stacks (docker-compose applications). + + Stacks are multi-container applications defined by docker-compose files. + """ + client, error = _get_client() + if error: + return error + return client.read("ListStacks") + + +@mcp.tool() +def get_container_status(deployment: str) -> dict: + """Gets the status of a specific container. + + Args: + deployment: Deployment name or ID + + Returns container state, image, ports, and resource usage. + """ + client, error = _get_client() + if error: + return error + return client.read("GetDeploymentContainer", {"deployment": deployment}) + + +@mcp.tool() +def komodo_api_call( + endpoint: str, + request_type: str, + params: dict = None +) -> dict: + """Execute a raw Komodo API call. + + Use the komodo://api-reference resource for available request types. + + Args: + endpoint: API endpoint - 'read', 'write', or 'execute' + request_type: Request type name (e.g., 'GetServer', 'Deploy', 'ListBuilds') + params: Request parameters as a dictionary + + Examples: + - Get server: endpoint='read', request_type='GetServer', params={'server': 'my-server'} + - Deploy: endpoint='execute', request_type='Deploy', params={'deployment': 'my-app'} + - List builds: endpoint='read', request_type='ListBuilds', params={} + """ + client, error = _get_client() + if error: + return error + + if endpoint not in ("read", "write", "execute"): + return {"error": f"Invalid endpoint '{endpoint}'. Use 'read', 'write', or 'execute'."} + + method = getattr(client, endpoint) + return method(request_type, params or {}) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(mcp.sse_app, host="0.0.0.0", port=8000)