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 42s

- 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 46f5f67e3e
9 changed files with 112 additions and 53 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"

105
server.py
View File

@@ -1,8 +1,11 @@
import os import os
import json
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 +19,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 +77,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 ---
@@ -143,63 +142,66 @@ def get_api_reference() -> str:
# --- MCP Tools --- # --- MCP Tools ---
# Note: Return str (JSON) instead of dict to work around FastMCP/Gemini CLI
# schema compatibility issue with additionalProperties
@mcp.tool() @mcp.tool()
def list_servers() -> dict: def list_servers() -> str:
"""Lists all servers (periphery nodes) connected to Komodo. """Lists all servers (periphery nodes) connected to Komodo.
Returns server names, IDs, status, and system information. Returns server names, IDs, status, and system information as JSON.
""" """
client, error = _get_client() client, error = _get_client()
if error: if error:
return error return json.dumps(error)
return client.read("ListServers") return json.dumps(client.read("ListServers"))
@mcp.tool() @mcp.tool()
def list_deployments() -> dict: def list_deployments() -> str:
"""Lists all deployments (single container applications). """Lists all deployments (single container applications).
Deployments are Docker containers managed by Komodo. Deployments are Docker containers managed by Komodo. Returns JSON.
""" """
client, error = _get_client() client, error = _get_client()
if error: if error:
return error return json.dumps(error)
return client.read("ListDeployments") return json.dumps(client.read("ListDeployments"))
@mcp.tool() @mcp.tool()
def list_stacks() -> dict: def list_stacks() -> str:
"""Lists all stacks (docker-compose applications). """Lists all stacks (docker-compose applications).
Stacks are multi-container applications defined by docker-compose files. Stacks are multi-container applications defined by docker-compose files. Returns JSON.
""" """
client, error = _get_client() client, error = _get_client()
if error: if error:
return error return json.dumps(error)
return client.read("ListStacks") return json.dumps(client.read("ListStacks"))
@mcp.tool() @mcp.tool()
def get_container_status(deployment: str) -> dict: def get_container_status(deployment: str) -> str:
"""Gets the status of a specific container. """Gets the status of a specific container.
Args: Args:
deployment: Deployment name or ID deployment: Deployment name or ID
Returns container state, image, ports, and resource usage. Returns container state, image, ports, and resource usage as JSON.
""" """
client, error = _get_client() client, error = _get_client()
if error: if error:
return error return json.dumps(error)
return client.read("GetDeploymentContainer", {"deployment": deployment}) return json.dumps(client.read("GetDeploymentContainer", {"deployment": deployment}))
@mcp.tool() @mcp.tool()
def komodo_api_call( def komodo_api_call(
endpoint: str, endpoint: str,
request_type: str, request_type: str,
params: dict = None params: str = "{}"
) -> dict: ) -> str:
"""Execute a raw Komodo API call. """Execute a raw Komodo API call.
Use the komodo://api-reference resource for available request types. Use the komodo://api-reference resource for available request types.
@@ -207,24 +209,57 @@ def komodo_api_call(
Args: Args:
endpoint: API endpoint - 'read', 'write', or 'execute' endpoint: API endpoint - 'read', 'write', or 'execute'
request_type: Request type name (e.g., 'GetServer', 'Deploy', 'ListBuilds') request_type: Request type name (e.g., 'GetServer', 'Deploy', 'ListBuilds')
params: Request parameters as a dictionary params: Request parameters as a JSON string (default: "{}")
Examples: Examples:
- Get server: endpoint='read', request_type='GetServer', params={'server': 'my-server'} - Get server: endpoint='read', request_type='GetServer', params='{"server": "my-server"}'
- Deploy: endpoint='execute', request_type='Deploy', params={'deployment': 'my-app'} - Deploy: endpoint='execute', request_type='Deploy', params='{"deployment": "my-app"}'
- List builds: endpoint='read', request_type='ListBuilds', params={} - List builds: endpoint='read', request_type='ListBuilds', params='{}'
""" """
client, error = _get_client() client, error = _get_client()
if error: if error:
return error return json.dumps(error)
if endpoint not in ("read", "write", "execute"): if endpoint not in ("read", "write", "execute"):
return {"error": f"Invalid endpoint '{endpoint}'. Use 'read', 'write', or 'execute'."} return json.dumps({"error": f"Invalid endpoint '{endpoint}'. Use 'read', 'write', or 'execute'."})
try:
parsed_params = json.loads(params) if params else {}
except json.JSONDecodeError as e:
return json.dumps({"error": f"Invalid JSON in params: {e}"})
method = getattr(client, endpoint) method = getattr(client, endpoint)
return method(request_type, params or {}) return json.dumps(method(request_type, parsed_params))
# --- 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__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(mcp.sse_app, host="0.0.0.0", port=8000) uvicorn.run(app, host="0.0.0.0", port=8000)