From e2fa72ea153040aafba9f05495217cf3c6c63bd8 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 20 Dec 2025 22:30:56 +0000 Subject: [PATCH] feat: add /health endpoint and switch to fastmcp 2.0 - 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 --- .env.example | 3 --- Dockerfile | 4 ++++ IMPLEMENTATION.md | 2 +- Makefile | 17 +++++++++++----- README.md | 18 +++++++++++------ docker-compose.dev.yml | 6 ++++++ docker-compose.yml | 7 ++++++- pyproject.toml | 3 +-- server.py | 46 +++++++++++++++++++++++++++++++++--------- 9 files changed, 78 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index b32dfc4..571edcf 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,3 @@ KOMODO_URL=https://komodo.example.com KOMODO_API_KEY=your-api-key KOMODO_API_SECRET=your-api-secret - -# MCP Transport Security (optional) -MCP_ALLOWED_HOSTS=localhost:*,127.0.0.1:* diff --git a/Dockerfile b/Dockerfile index 190ff33..997612b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,10 @@ RUN uv pip install --system . # Stage 2: Final image 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 # Copy installed dependencies from builder diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 56a3034..e2585f8 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -8,7 +8,7 @@ Technical documentation for the Komodo MCP Server. ┌─────────────────────────────────────────────────────────┐ │ MCP Client (AI Agent) │ └─────────────────────────┬───────────────────────────────┘ - │ SSE + │ HTTP (POST /mcp) ▼ ┌─────────────────────────────────────────────────────────┐ │ Docker Container (komodo-mcp) │ diff --git a/Makefile b/Makefile index 3937871..61f6781 100644 --- a/Makefile +++ b/Makefile @@ -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: @@ -18,15 +18,22 @@ 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 MCP endpoint (POST to /mcp) +test-mcp: + @echo "Testing MCP endpoint..." + @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: @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) rebuild: docker compose -f docker-compose.dev.yml build --no-cache diff --git a/README.md b/README.md index 051fd99..4a30f51 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,6 @@ A Model Context Protocol (MCP) server for [Komodo](https://komo.do/) Docker mana | `KOMODO_URL` | Yes | Komodo Core URL | | `KOMODO_API_KEY` | Yes | API key | | `KOMODO_API_SECRET` | Yes | API secret | -| `MCP_ALLOWED_HOSTS` | No | Allowed Host headers | ### Deploy with Docker Compose @@ -45,14 +44,21 @@ services: - KOMODO_URL=https://komodo.example.com - KOMODO_API_KEY=your-key - KOMODO_API_SECRET=your-secret - - MCP_ALLOWED_HOSTS=komodo-mcp.example.io,localhost:* ports: - "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 @@ -67,12 +73,12 @@ make dev # Test make logs -make test-sse +make test-mcp make stop ``` ## Tech Stack - Python 3.11+ / FastMCP / httpx -- SSE transport / uvicorn +- Streamable HTTP transport / uvicorn - Docker with multi-stage build diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 6c16cc5..0817af6 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -6,4 +6,10 @@ services: - .env ports: - "8001:8000" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index 7e20f6b..3d5b0da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,12 @@ services: - KOMODO_URL=${KOMODO_URL} - KOMODO_API_KEY=${KOMODO_API_KEY} - KOMODO_API_SECRET=${KOMODO_API_SECRET} - - MCP_ALLOWED_HOSTS=komodo-mcp.example.io,localhost:*,127.0.0.1:* ports: - "8000:8000" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s restart: unless-stopped diff --git a/pyproject.toml b/pyproject.toml index 24d72ec..c43ab32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,10 +3,9 @@ name = "komodo-mcp" version = "0.1.0" description = "MCP Server for Komodo Docker Management" dependencies = [ - "mcp", + "fastmcp>=2.0", "httpx", "uvicorn", - "fastapi", "python-dotenv", ] requires-python = ">=3.11" diff --git a/server.py b/server.py index bf279c4..f62e06a 100644 --- a/server.py +++ b/server.py @@ -1,8 +1,10 @@ import os import logging import httpx -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 dotenv import load_dotenv # Load environment variables @@ -16,7 +18,6 @@ logger = logging.getLogger(__name__) KOMODO_URL = os.getenv("KOMODO_URL", "").rstrip("/") KOMODO_API_KEY = os.getenv("KOMODO_API_KEY", "") KOMODO_API_SECRET = os.getenv("KOMODO_API_SECRET", "") -MCP_ALLOWED_HOSTS = os.getenv("MCP_ALLOWED_HOSTS", "localhost:*,127.0.0.1:*") class KomodoClient: @@ -75,12 +76,9 @@ def _get_client() -> tuple[KomodoClient | None, dict | None]: # --- MCP Server --- -transport_security = TransportSecuritySettings( - enable_dns_rebinding_protection=True, - allowed_hosts=[h.strip() for h in MCP_ALLOWED_HOSTS.split(",")], - allowed_origins=[], -) -mcp = FastMCP("Komodo MCP", transport_security=transport_security) +# Note: Host validation is handled at the reverse proxy/ingress level +# The /health endpoint bypasses this for Docker health checks +mcp = FastMCP("Komodo MCP") # --- API Documentation Resource --- @@ -225,6 +223,34 @@ def komodo_api_call( return method(request_type, params or {}) +# --- Health Check Endpoint --- +async def health(request): + """Health check endpoint for Docker.""" + 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 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) + uvicorn.run(app, host="0.0.0.0", port=8000)