From bf80ae577e21b8127bdece7e81c10c08ff12628f 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 Streamable HTTP transport - Switch from legacy SSE transport (sse_app) to Streamable HTTP (http_app) - Fixes 405 Method Not Allowed for MCP clients like Gemini CLI - MCP endpoint now at /mcp (POST) instead of /sse (GET) - Add /health endpoint that bypasses MCP_ALLOWED_HOSTS validation - Returns {"status": "ok"} (200) or {"status": "degraded"} (503) - Enables Docker health checks without exposing to external hosts - 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 --- Dockerfile | 4 ++++ IMPLEMENTATION.md | 2 +- Makefile | 17 ++++++++++++----- README.md | 18 +++++++++++++----- docker-compose.dev.yml | 6 ++++++ docker-compose.yml | 6 ++++++ server.py | 35 ++++++++++++++++++++++++++++++++++- 7 files changed, 76 insertions(+), 12 deletions(-) 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..0012575 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,22 @@ 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:* + - MCP_ALLOWED_HOSTS=komodo-mcp.example.io 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 (bypasses `MCP_ALLOWED_HOSTS`) | ## Local Development @@ -67,12 +75,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..c11e63a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,4 +9,10 @@ services: - 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/server.py b/server.py index bf279c4..8e2f694 100644 --- a/server.py +++ b/server.py @@ -3,6 +3,9 @@ import logging import httpx from mcp.server.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 # Load environment variables @@ -225,6 +228,36 @@ def komodo_api_call( 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__": 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)