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

@@ -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)
# Run the wrapper app (includes /health and /mcp endpoints)
uvicorn.run(app, host="0.0.0.0", port=8000)