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
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:
105
server.py
105
server.py
@@ -1,8 +1,11 @@
|
||||
import os
|
||||
import json
|
||||
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 +19,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 +77,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 ---
|
||||
@@ -143,63 +142,66 @@ def get_api_reference() -> str:
|
||||
|
||||
|
||||
# --- MCP Tools ---
|
||||
# Note: Return str (JSON) instead of dict to work around FastMCP/Gemini CLI
|
||||
# schema compatibility issue with additionalProperties
|
||||
|
||||
@mcp.tool()
|
||||
def list_servers() -> dict:
|
||||
def list_servers() -> str:
|
||||
"""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()
|
||||
if error:
|
||||
return error
|
||||
return client.read("ListServers")
|
||||
return json.dumps(error)
|
||||
return json.dumps(client.read("ListServers"))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_deployments() -> dict:
|
||||
def list_deployments() -> str:
|
||||
"""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()
|
||||
if error:
|
||||
return error
|
||||
return client.read("ListDeployments")
|
||||
return json.dumps(error)
|
||||
return json.dumps(client.read("ListDeployments"))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_stacks() -> dict:
|
||||
def list_stacks() -> str:
|
||||
"""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()
|
||||
if error:
|
||||
return error
|
||||
return client.read("ListStacks")
|
||||
return json.dumps(error)
|
||||
return json.dumps(client.read("ListStacks"))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_container_status(deployment: str) -> dict:
|
||||
def get_container_status(deployment: str) -> str:
|
||||
"""Gets the status of a specific container.
|
||||
|
||||
Args:
|
||||
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()
|
||||
if error:
|
||||
return error
|
||||
return client.read("GetDeploymentContainer", {"deployment": deployment})
|
||||
return json.dumps(error)
|
||||
return json.dumps(client.read("GetDeploymentContainer", {"deployment": deployment}))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def komodo_api_call(
|
||||
endpoint: str,
|
||||
request_type: str,
|
||||
params: dict = None
|
||||
) -> dict:
|
||||
params: str = "{}"
|
||||
) -> str:
|
||||
"""Execute a raw Komodo API call.
|
||||
|
||||
Use the komodo://api-reference resource for available request types.
|
||||
@@ -207,24 +209,57 @@ def komodo_api_call(
|
||||
Args:
|
||||
endpoint: API endpoint - 'read', 'write', or 'execute'
|
||||
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:
|
||||
- Get server: endpoint='read', request_type='GetServer', params={'server': 'my-server'}
|
||||
- Deploy: endpoint='execute', request_type='Deploy', params={'deployment': 'my-app'}
|
||||
- List builds: endpoint='read', request_type='ListBuilds', params={}
|
||||
- Get server: endpoint='read', request_type='GetServer', params='{"server": "my-server"}'
|
||||
- Deploy: endpoint='execute', request_type='Deploy', params='{"deployment": "my-app"}'
|
||||
- List builds: endpoint='read', request_type='ListBuilds', params='{}'
|
||||
"""
|
||||
client, error = _get_client()
|
||||
if error:
|
||||
return error
|
||||
return json.dumps(error)
|
||||
|
||||
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)
|
||||
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__":
|
||||
import uvicorn
|
||||
uvicorn.run(mcp.sse_app, host="0.0.0.0", port=8000)
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
||||
Reference in New Issue
Block a user