feat: add /health endpoint and switch to fastmcp 2.0
All checks were successful
Build and Push Komodo MCP Docker Image / build (push) Successful in 18s

- Switch from mcp package to fastmcp>=2.0 for Streamable HTTP support
  - Fixes 405 Method Not Allowed for MCP clients like Gemini CLI
  - MCP endpoint now at /mcp (POST) instead of /sse (GET)
  - Removes MCP_ALLOWED_HOSTS (handled at reverse proxy level)

- Add /health endpoint for Docker health checks
  - Returns {"status": "ok"} (200) or {"status": "degraded"} (503)
  - Enables health checks without transport security issues

- Add curl to Docker image for health checks
- Add healthcheck config to docker-compose files
- Add test-health and test-mcp Makefile targets
- Update documentation
This commit is contained in:
Ben
2025-12-20 22:30:56 +00:00
parent 0ecf6880a1
commit 98dc6b7f50
9 changed files with 80 additions and 28 deletions

View File

@@ -2,6 +2,3 @@
KOMODO_URL=https://komodo.example.com KOMODO_URL=https://komodo.example.com
KOMODO_API_KEY=your-api-key KOMODO_API_KEY=your-api-key
KOMODO_API_SECRET=your-api-secret KOMODO_API_SECRET=your-api-secret
# MCP Transport Security (optional)
MCP_ALLOWED_HOSTS=localhost:*,127.0.0.1:*

View File

@@ -15,6 +15,10 @@ RUN uv pip install --system .
# Stage 2: Final image # Stage 2: Final image
FROM python:3.11-slim-bullseye FROM python:3.11-slim-bullseye
# Install curl for health checks
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
# Copy installed dependencies from builder # Copy installed dependencies from builder

View File

@@ -8,7 +8,7 @@ Technical documentation for the Komodo MCP Server.
┌─────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────┐
│ MCP Client (AI Agent) │ │ MCP Client (AI Agent) │
└─────────────────────────┬───────────────────────────────┘ └─────────────────────────┬───────────────────────────────┘
SSE HTTP (POST /mcp)
┌─────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────┐
│ Docker Container (komodo-mcp) │ │ Docker Container (komodo-mcp) │

View File

@@ -1,4 +1,4 @@
.PHONY: build dev logs stop test-sse clean rebuild .PHONY: build dev logs stop test-mcp test-health clean rebuild
# Build the Docker image locally # Build the Docker image locally
build: build:
@@ -18,15 +18,22 @@ 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 MCP endpoint (POST to /mcp)
test-sse: test-mcp:
@echo "Testing SSE endpoint (5s timeout)..." @echo "Testing MCP endpoint..."
@curl -N --max-time 5 http://localhost:8001/sse 2>/dev/null || echo "\n[Timeout - this is expected for SSE]" @curl -s -X POST http://localhost:8001/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc": "2.0", "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test", "version": "1.0"}}, "id": 1}' | head -200
# Test root endpoint # Test root endpoint
test-root: test-root:
@curl -s http://localhost:8001/ | head -20 @curl -s http://localhost:8001/ | head -20
# Test health endpoint (bypasses MCP_ALLOWED_HOSTS)
test-health:
@echo "Testing health endpoint..."
@curl -s http://localhost:8001/health
# Full rebuild (no cache) # Full rebuild (no cache)
rebuild: rebuild:
docker compose -f docker-compose.dev.yml build --no-cache docker compose -f docker-compose.dev.yml build --no-cache

View File

@@ -33,7 +33,6 @@ A Model Context Protocol (MCP) server for [Komodo](https://komo.do/) Docker mana
| `KOMODO_URL` | Yes | Komodo Core URL | | `KOMODO_URL` | Yes | Komodo Core URL |
| `KOMODO_API_KEY` | Yes | API key | | `KOMODO_API_KEY` | Yes | API key |
| `KOMODO_API_SECRET` | Yes | API secret | | `KOMODO_API_SECRET` | Yes | API secret |
| `MCP_ALLOWED_HOSTS` | No | Allowed Host headers |
### Deploy with Docker Compose ### Deploy with Docker Compose
@@ -45,14 +44,21 @@ services:
- KOMODO_URL=https://komodo.example.com - KOMODO_URL=https://komodo.example.com
- KOMODO_API_KEY=your-key - KOMODO_API_KEY=your-key
- KOMODO_API_SECRET=your-secret - KOMODO_API_SECRET=your-secret
- MCP_ALLOWED_HOSTS=komodo-mcp.example.io,localhost:*
ports: ports:
- "8000:8000" - "8000:8000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
``` ```
### Connect MCP Client ### Endpoints
SSE endpoint: `https://your-host/sse` | Endpoint | Method | Description |
|----------|--------|-------------|
| `/mcp` | POST | MCP protocol endpoint |
| `/health` | GET | Health check for Docker |
## Local Development ## Local Development
@@ -67,12 +73,12 @@ make dev
# Test # Test
make logs make logs
make test-sse make test-mcp
make stop make stop
``` ```
## Tech Stack ## Tech Stack
- Python 3.11+ / FastMCP / httpx - Python 3.11+ / FastMCP / httpx
- SSE transport / uvicorn - Streamable HTTP transport / uvicorn
- Docker with multi-stage build - Docker with multi-stage build

View File

@@ -6,4 +6,10 @@ services:
- .env - .env
ports: ports:
- "8001:8000" - "8001:8000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
restart: unless-stopped restart: unless-stopped

View File

@@ -6,7 +6,12 @@ services:
- KOMODO_URL=${KOMODO_URL} - KOMODO_URL=${KOMODO_URL}
- KOMODO_API_KEY=${KOMODO_API_KEY} - KOMODO_API_KEY=${KOMODO_API_KEY}
- KOMODO_API_SECRET=${KOMODO_API_SECRET} - KOMODO_API_SECRET=${KOMODO_API_SECRET}
- MCP_ALLOWED_HOSTS=komodo-mcp.example.io,localhost:*,127.0.0.1:*
ports: ports:
- "8000:8000" - "8000:8000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
restart: unless-stopped restart: unless-stopped

View File

@@ -3,10 +3,9 @@ name = "komodo-mcp"
version = "0.1.0" version = "0.1.0"
description = "MCP Server for Komodo Docker Management" description = "MCP Server for Komodo Docker Management"
dependencies = [ dependencies = [
"mcp", "fastmcp>=2.0",
"httpx", "httpx",
"uvicorn", "uvicorn",
"fastapi",
"python-dotenv", "python-dotenv",
] ]
requires-python = ">=3.11" requires-python = ">=3.11"

View File

@@ -1,8 +1,10 @@
import os import os
import logging import logging
import httpx import httpx
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 dotenv import load_dotenv from dotenv import load_dotenv
# Load environment variables # Load environment variables
@@ -16,7 +18,6 @@ logger = logging.getLogger(__name__)
KOMODO_URL = os.getenv("KOMODO_URL", "").rstrip("/") KOMODO_URL = os.getenv("KOMODO_URL", "").rstrip("/")
KOMODO_API_KEY = os.getenv("KOMODO_API_KEY", "") KOMODO_API_KEY = os.getenv("KOMODO_API_KEY", "")
KOMODO_API_SECRET = os.getenv("KOMODO_API_SECRET", "") KOMODO_API_SECRET = os.getenv("KOMODO_API_SECRET", "")
MCP_ALLOWED_HOSTS = os.getenv("MCP_ALLOWED_HOSTS", "localhost:*,127.0.0.1:*")
class KomodoClient: class KomodoClient:
@@ -75,12 +76,9 @@ def _get_client() -> tuple[KomodoClient | None, dict | None]:
# --- MCP Server --- # --- MCP Server ---
transport_security = TransportSecuritySettings( # Note: Host validation is handled at the reverse proxy/ingress level
enable_dns_rebinding_protection=True, # The /health endpoint bypasses this for Docker health checks
allowed_hosts=[h.strip() for h in MCP_ALLOWED_HOSTS.split(",")], mcp = FastMCP("Komodo MCP")
allowed_origins=[],
)
mcp = FastMCP("Komodo MCP", transport_security=transport_security)
# --- API Documentation Resource --- # --- API Documentation Resource ---
@@ -225,6 +223,36 @@ def komodo_api_call(
return method(request_type, params or {}) return method(request_type, params or {})
# --- Health Check Endpoint ---
# This bypasses MCP_ALLOWED_HOSTS for Docker health checks
async def health(request):
"""Health check endpoint - bypasses transport security."""
# Optionally verify Komodo connectivity
client, error = _get_client()
if error:
return JSONResponse({"status": "degraded", "error": error["error"]}, 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 bypasses security, everything else goes to MCP
routes = [
Route("/health", health, methods=["GET"]),
Mount("/", app=mcp_app), # MCP handles /mcp endpoint
]
return Starlette(routes=routes)
# 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)