feat: add /health endpoint bypassing transport security
All checks were successful
Build and Push Proxmox MCP Docker Image / build (push) Successful in 32s
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:
@@ -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/
|
||||||
|
|||||||
17
Makefile
17
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 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:
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
47
server.py
47
server.py
@@ -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)
|
||||||
Reference in New Issue
Block a user