feat: add /health endpoint bypassing transport security
All checks were successful
Build and Push Proxmox MCP Docker Image / build (push) Successful in 32s

- Migrate from SSE to HTTP transport using fastmcp>=2.0
- Add /health endpoint for Docker health checks and load balancers
- Remove MCP_ALLOWED_HOSTS (no longer needed with http_app approach)
- Add lifespan handler for proper task group initialization
- Install curl in Docker image for health checks
- Update Makefile with test-health and test-mcp targets
- Update documentation to reflect new endpoint structure

Fixes: Health check fails with 421 Misdirected Request when
MCP_ALLOWED_HOSTS doesn't include localhost
This commit is contained in:
Ben
2025-12-20 23:17:55 +00:00
parent 4b576d40ad
commit 18a8c2e59f
6 changed files with 73 additions and 33 deletions

View File

@@ -19,6 +19,10 @@ FROM python:3.11-slim-bullseye
WORKDIR /app 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 # Copy installed dependencies from builder
# We copy the entire site-packages directory # We copy the entire site-packages directory
COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/ COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/

View File

@@ -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 the Docker image locally
build: build:
@@ -18,14 +18,15 @@ logs:
stop: stop:
docker compose -f docker-compose.dev.yml down docker compose -f docker-compose.dev.yml down
# Test SSE endpoint (5 second timeout) # Test health endpoint
test-sse: test-health:
@echo "Testing SSE endpoint (5s timeout)..." @echo "Testing health endpoint..."
@curl -N --max-time 5 http://localhost:8001/sse 2>/dev/null || echo "\n[Timeout - this is expected for SSE]" @curl -s http://localhost:8001/health | jq .
# Test root endpoint # Test MCP endpoint (should return 406 for GET)
test-root: test-mcp:
@curl -s http://localhost:8001/ | head -20 @echo "Testing MCP endpoint..."
@curl -s -o /dev/null -w "%{http_code}" http://localhost:8001/mcp
# Full rebuild (no cache) # Full rebuild (no cache)
rebuild: rebuild:

View File

@@ -52,24 +52,35 @@ All tools accept a `cluster` parameter. If only one cluster is configured, it's
services: services:
proxmox-mcp: proxmox-mcp:
image: gitea.ext.ben.io/b3nw/proxmox-mcp-custom:latest image: gitea.ext.ben.io/b3nw/proxmox-mcp-custom:latest
environment:
- MCP_ALLOWED_HOSTS=proxmox-mcp.example.io,localhost:*
volumes: volumes:
- ./clusters.json:/app/clusters.json:ro - ./clusters.json:/app/clusters.json:ro
ports: ports:
- "8000:8000" - "8000:8000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped restart: unless-stopped
``` ```
### 3. Connect MCP Client ### 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 ## Environment Variables
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `MCP_ALLOWED_HOSTS` | `localhost:*,127.0.0.1:*` | Allowed Host headers |
| `CLUSTERS_CONFIG_PATH` | `/app/clusters.json` | Path to clusters config | | `CLUSTERS_CONFIG_PATH` | `/app/clusters.json` | Path to clusters config |
## Local Development ## Local Development
@@ -85,12 +96,12 @@ make dev
# Test # Test
make logs make logs
make test-sse curl http://localhost:8001/health
make stop make stop
``` ```
## Tech Stack ## Tech Stack
- Python 3.11+ / FastMCP / proxmoxer - Python 3.11+ / FastMCP 2.0+ / proxmoxer
- SSE transport / uvicorn - HTTP transport / uvicorn
- Docker with multi-stage build - Docker with multi-stage build

View File

@@ -2,13 +2,15 @@ services:
proxmox-mcp: proxmox-mcp:
image: gitea.ext.ben.io/b3nw/proxmox-mcp-custom:latest image: gitea.ext.ben.io/b3nw/proxmox-mcp-custom:latest
container_name: proxmox-mcp 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: volumes:
# Mount your clusters.json configuration file # Mount your clusters.json configuration file
- ./clusters.json:/app/clusters.json:ro - ./clusters.json:/app/clusters.json:ro
ports: ports:
- "8000:8000" - "8000:8000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
restart: unless-stopped restart: unless-stopped

View File

@@ -3,11 +3,10 @@ name = "proxmox-mcp-custom"
version = "0.1.0" version = "0.1.0"
description = "Custom Proxmox MCP Server" description = "Custom Proxmox MCP Server"
dependencies = [ dependencies = [
"mcp", "fastmcp>=2.0",
"proxmoxer", "proxmoxer",
"requests", # Required for proxmoxer https backend "requests", # Required for proxmoxer https backend
"uvicorn", "uvicorn",
"fastapi",
"python-dotenv", # For local development "python-dotenv", # For local development
] ]
requires-python = ">=3.11" requires-python = ">=3.11"

View File

@@ -2,8 +2,10 @@ import os
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
from mcp.server.fastmcp import FastMCP from fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route, Mount
from proxmoxer import ProxmoxAPI from proxmoxer import ProxmoxAPI
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -14,9 +16,6 @@ load_dotenv()
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# --- MCP Transport Security ---
MCP_ALLOWED_HOSTS = os.getenv("MCP_ALLOWED_HOSTS", "localhost:*,127.0.0.1:*")
# --- Cluster Configuration --- # --- Cluster Configuration ---
CLUSTERS_CONFIG_PATH = os.getenv("CLUSTERS_CONFIG_PATH", "/app/clusters.json") CLUSTERS_CONFIG_PATH = os.getenv("CLUSTERS_CONFIG_PATH", "/app/clusters.json")
@@ -102,12 +101,7 @@ class ClusterManager:
cluster_manager = ClusterManager(CLUSTERS_CONFIG_PATH) cluster_manager = ClusterManager(CLUSTERS_CONFIG_PATH)
# --- FastMCP Server --- # --- FastMCP Server ---
transport_security = TransportSecuritySettings( mcp = FastMCP("Proxmox MCP")
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)
def _get_cluster_or_error(cluster: str = None) -> tuple[ProxmoxAPI | None, dict | None]: 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)} 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__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(mcp.sse_app, host="0.0.0.0", port=8000) # Run the wrapper app (includes /health and /mcp endpoints)
uvicorn.run(app, host="0.0.0.0", port=8000)