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
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:
@@ -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:*
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) │
|
||||||
|
|||||||
17
Makefile
17
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 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
|
||||||
|
|||||||
18
README.md
18
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_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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
48
server.py
48
server.py
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user