diff --git a/Dockerfile b/Dockerfile index de348a7..a87de4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,10 @@ FROM python:3.11-slim-bullseye WORKDIR /app +# Install curl for health checks +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + # Copy installed dependencies from builder # We copy the entire site-packages directory COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/ diff --git a/Makefile b/Makefile index 64d8376..af41eae 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build dev logs stop test-sse clean +.PHONY: build dev logs stop test-health clean # Build the Docker image locally build: @@ -18,14 +18,15 @@ logs: 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 health endpoint +test-health: + @echo "Testing health endpoint..." + @curl -s http://localhost:8001/health | jq . -# Test root endpoint -test-root: - @curl -s http://localhost:8001/ | head -20 +# Test MCP endpoint (should return 406 for GET) +test-mcp: + @echo "Testing MCP endpoint..." + @curl -s -o /dev/null -w "%{http_code}" http://localhost:8001/mcp # Full rebuild (no cache) rebuild: diff --git a/README.md b/README.md index e046ec5..f3bcfc1 100644 --- a/README.md +++ b/README.md @@ -52,24 +52,35 @@ All tools accept a `cluster` parameter. If only one cluster is configured, it's services: proxmox-mcp: image: gitea.ext.ben.io/b3nw/proxmox-mcp-custom:latest - environment: - - MCP_ALLOWED_HOSTS=proxmox-mcp.example.io,localhost:* volumes: - ./clusters.json:/app/clusters.json:ro ports: - "8000:8000" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 restart: unless-stopped ``` ### 3. Connect MCP Client -SSE endpoint: `https://your-host/sse` +MCP endpoint: `https://your-host/mcp` + +## Endpoints + +| Endpoint | Description | +|----------|-------------| +| `/mcp` | MCP HTTP endpoint | +| `/health` | Health check endpoint for Docker/load balancers | + +The `/health` endpoint returns `{"status": "ok"}` (or `{"status": "degraded"}` if no clusters are configured) and is designed for Docker health checks. ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| -| `MCP_ALLOWED_HOSTS` | `localhost:*,127.0.0.1:*` | Allowed Host headers | | `CLUSTERS_CONFIG_PATH` | `/app/clusters.json` | Path to clusters config | ## Local Development @@ -85,12 +96,12 @@ make dev # Test make logs -make test-sse +curl http://localhost:8001/health make stop ``` ## Tech Stack -- Python 3.11+ / FastMCP / proxmoxer -- SSE transport / uvicorn +- Python 3.11+ / FastMCP 2.0+ / proxmoxer +- HTTP transport / uvicorn - Docker with multi-stage build diff --git a/docker-compose.yml b/docker-compose.yml index d6b00b8..867a6c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,13 +2,15 @@ services: proxmox-mcp: image: gitea.ext.ben.io/b3nw/proxmox-mcp-custom:latest container_name: proxmox-mcp - environment: - # --- MCP Transport Security --- - # Allowed Host headers (comma-separated, supports :* for wildcard ports) - - MCP_ALLOWED_HOSTS=proxmox-mcp.example.io,localhost:*,127.0.0.1:* volumes: # Mount your clusters.json configuration file - ./clusters.json:/app/clusters.json:ro ports: - "8000:8000" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s restart: unless-stopped diff --git a/pyproject.toml b/pyproject.toml index 6347992..54816da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,11 +3,10 @@ name = "proxmox-mcp-custom" version = "0.1.0" description = "Custom Proxmox MCP Server" dependencies = [ - "mcp", + "fastmcp>=2.0", "proxmoxer", "requests", # Required for proxmoxer https backend "uvicorn", - "fastapi", "python-dotenv", # For local development ] requires-python = ">=3.11" diff --git a/server.py b/server.py index ed0f529..c169b48 100644 --- a/server.py +++ b/server.py @@ -2,8 +2,10 @@ import os import json import logging from pathlib import Path -from mcp.server.fastmcp import FastMCP -from mcp.server.transport_security import TransportSecuritySettings +from fastmcp import FastMCP +from starlette.applications import Starlette +from starlette.responses import JSONResponse +from starlette.routing import Route, Mount from proxmoxer import ProxmoxAPI from dotenv import load_dotenv @@ -14,9 +16,6 @@ load_dotenv() logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -# --- MCP Transport Security --- -MCP_ALLOWED_HOSTS = os.getenv("MCP_ALLOWED_HOSTS", "localhost:*,127.0.0.1:*") - # --- Cluster Configuration --- CLUSTERS_CONFIG_PATH = os.getenv("CLUSTERS_CONFIG_PATH", "/app/clusters.json") @@ -102,12 +101,7 @@ class ClusterManager: cluster_manager = ClusterManager(CLUSTERS_CONFIG_PATH) # --- FastMCP Server --- -transport_security = TransportSecuritySettings( - enable_dns_rebinding_protection=True, - allowed_hosts=[h.strip() for h in MCP_ALLOWED_HOSTS.split(",")], - allowed_origins=[], -) -mcp = FastMCP("Proxmox MCP", transport_security=transport_security) +mcp = FastMCP("Proxmox MCP") def _get_cluster_or_error(cluster: str = None) -> tuple[ProxmoxAPI | None, dict | None]: @@ -229,6 +223,35 @@ def proxmox_api_call( return {"error": str(e)} +# --- Health Check Endpoint --- +async def health(request): + """Health check endpoint - bypasses transport security.""" + # Optionally verify cluster connectivity + if not cluster_manager.clusters: + return JSONResponse({"status": "degraded", "error": "No clusters configured"}, status_code=503) + return JSONResponse({"status": "ok"}) + + +# --- ASGI Application --- +def create_app(): + """Create the ASGI application with health check and MCP routes.""" + mcp_app = mcp.http_app() + + # Wrapper app: /health is standalone, everything else goes to MCP + # IMPORTANT: Must pass mcp_app.lifespan for task group initialization + routes = [ + Route("/health", health, methods=["GET"]), + Mount("/", app=mcp_app), # MCP handles /mcp endpoint + ] + + return Starlette(routes=routes, lifespan=mcp_app.lifespan) + + +# Create the app instance for uvicorn +app = create_app() + + if __name__ == "__main__": import uvicorn - uvicorn.run(mcp.sse_app, host="0.0.0.0", port=8000) \ No newline at end of file + # Run the wrapper app (includes /health and /mcp endpoints) + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file