fix: accept both str and dict params in komodo_api_call
All checks were successful
Build and Push Komodo MCP Docker Image / build (push) Successful in 8s

Resolves issue #1 - allows params parameter to accept either JSON string
or dict type, fixing type validation errors when calling the function.
This commit is contained in:
Ben
2025-12-26 20:17:00 +00:00
parent 46f5f67e3e
commit 9b1b563a35

View File

@@ -1,6 +1,7 @@
import os import os
import json import json
import logging import logging
from typing import Any
import httpx import httpx
from fastmcp import FastMCP from fastmcp import FastMCP
from starlette.applications import Starlette from starlette.applications import Starlette
@@ -23,7 +24,7 @@ KOMODO_API_SECRET = os.getenv("KOMODO_API_SECRET", "")
class KomodoClient: class KomodoClient:
"""HTTP client for Komodo Core API.""" """HTTP client for Komodo Core API."""
def __init__(self, url: str, api_key: str, api_secret: str): def __init__(self, url: str, api_key: str, api_secret: str):
self.url = url self.url = url
self.headers = { self.headers = {
@@ -32,10 +33,10 @@ class KomodoClient:
"X-Api-Secret": api_secret, "X-Api-Secret": api_secret,
} }
self._client = httpx.Client(timeout=30.0) self._client = httpx.Client(timeout=30.0)
def _request(self, endpoint: str, request_type: str, params: dict = None) -> dict: def _request(self, endpoint: str, request_type: str, params: dict = None) -> dict:
"""Make a request to Komodo API. """Make a request to Komodo API.
Args: Args:
endpoint: API endpoint (read, write, execute) endpoint: API endpoint (read, write, execute)
request_type: Request type name (e.g., 'ListServers', 'GetDeployment') request_type: Request type name (e.g., 'ListServers', 'GetDeployment')
@@ -43,7 +44,7 @@ class KomodoClient:
""" """
url = f"{self.url}/{endpoint}" url = f"{self.url}/{endpoint}"
body = {"type": request_type, "params": params or {}} body = {"type": request_type, "params": params or {}}
try: try:
response = self._client.post(url, json=body, headers=self.headers) response = self._client.post(url, json=body, headers=self.headers)
response.raise_for_status() response.raise_for_status()
@@ -52,15 +53,15 @@ class KomodoClient:
return {"error": f"HTTP {e.response.status_code}: {e.response.text}"} return {"error": f"HTTP {e.response.status_code}: {e.response.text}"}
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
def read(self, request_type: str, params: dict = None) -> dict: def read(self, request_type: str, params: dict = None) -> dict:
"""Make a read request.""" """Make a read request."""
return self._request("read", request_type, params) return self._request("read", request_type, params)
def write(self, request_type: str, params: dict = None) -> dict: def write(self, request_type: str, params: dict = None) -> dict:
"""Make a write request.""" """Make a write request."""
return self._request("write", request_type, params) return self._request("write", request_type, params)
def execute(self, request_type: str, params: dict = None) -> dict: def execute(self, request_type: str, params: dict = None) -> dict:
"""Make an execute request.""" """Make an execute request."""
return self._request("execute", request_type, params) return self._request("execute", request_type, params)
@@ -145,10 +146,11 @@ def get_api_reference() -> str:
# Note: Return str (JSON) instead of dict to work around FastMCP/Gemini CLI # Note: Return str (JSON) instead of dict to work around FastMCP/Gemini CLI
# schema compatibility issue with additionalProperties # schema compatibility issue with additionalProperties
@mcp.tool() @mcp.tool()
def list_servers() -> str: 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 as JSON. Returns server names, IDs, status, and system information as JSON.
""" """
client, error = _get_client() client, error = _get_client()
@@ -160,7 +162,7 @@ def list_servers() -> str:
@mcp.tool() @mcp.tool()
def list_deployments() -> str: def list_deployments() -> str:
"""Lists all deployments (single container applications). """Lists all deployments (single container applications).
Deployments are Docker containers managed by Komodo. Returns JSON. Deployments are Docker containers managed by Komodo. Returns JSON.
""" """
client, error = _get_client() client, error = _get_client()
@@ -172,7 +174,7 @@ def list_deployments() -> str:
@mcp.tool() @mcp.tool()
def list_stacks() -> str: 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. Returns JSON. Stacks are multi-container applications defined by docker-compose files. Returns JSON.
""" """
client, error = _get_client() client, error = _get_client()
@@ -184,10 +186,10 @@ def list_stacks() -> str:
@mcp.tool() @mcp.tool()
def get_container_status(deployment: str) -> str: 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 as JSON. Returns container state, image, ports, and resource usage as JSON.
""" """
client, error = _get_client() client, error = _get_client()
@@ -198,19 +200,17 @@ def get_container_status(deployment: str) -> str:
@mcp.tool() @mcp.tool()
def komodo_api_call( def komodo_api_call(
endpoint: str, endpoint: str, request_type: str, params: str | dict[str, Any] = "{}"
request_type: str,
params: str = "{}"
) -> str: ) -> 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.
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 JSON string (default: "{}") params: Request parameters as a JSON string or dict (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"}'
@@ -219,15 +219,22 @@ def komodo_api_call(
client, error = _get_client() client, error = _get_client()
if error: if error:
return json.dumps(error) return json.dumps(error)
if endpoint not in ("read", "write", "execute"): if endpoint not in ("read", "write", "execute"):
return json.dumps({"error": f"Invalid endpoint '{endpoint}'. Use 'read', 'write', or 'execute'."}) return json.dumps(
{
"error": f"Invalid endpoint '{endpoint}'. Use 'read', 'write', or 'execute'."
}
)
try: try:
parsed_params = json.loads(params) if params else {} if isinstance(params, str):
parsed_params = json.loads(params) if params else {}
else:
parsed_params = params or {}
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
return json.dumps({"error": f"Invalid JSON in params: {e}"}) return json.dumps({"error": f"Invalid JSON in params: {e}"})
method = getattr(client, endpoint) method = getattr(client, endpoint)
return json.dumps(method(request_type, parsed_params)) return json.dumps(method(request_type, parsed_params))
@@ -237,7 +244,9 @@ async def health(request):
"""Health check endpoint for Docker.""" """Health check endpoint for Docker."""
client, error = _get_client() client, error = _get_client()
if error: if error:
return JSONResponse({"status": "degraded", "error": error["error"]}, status_code=503) return JSONResponse(
{"status": "degraded", "error": error["error"]}, status_code=503
)
return JSONResponse({"status": "ok"}) return JSONResponse({"status": "ok"})
@@ -245,14 +254,14 @@ async def health(request):
def create_app(): def create_app():
"""Create the ASGI application with health check and MCP routes.""" """Create the ASGI application with health check and MCP routes."""
mcp_app = mcp.http_app() mcp_app = mcp.http_app()
# Wrapper app: /health is standalone, everything else goes to MCP # Wrapper app: /health is standalone, everything else goes to MCP
# IMPORTANT: Must pass mcp_app.lifespan for task group initialization # IMPORTANT: Must pass mcp_app.lifespan for task group initialization
routes = [ routes = [
Route("/health", health, methods=["GET"]), Route("/health", health, methods=["GET"]),
Mount("/", app=mcp_app), # MCP handles /mcp endpoint Mount("/", app=mcp_app), # MCP handles /mcp endpoint
] ]
return Starlette(routes=routes, lifespan=mcp_app.lifespan) return Starlette(routes=routes, lifespan=mcp_app.lifespan)
@@ -262,4 +271,5 @@ app = create_app()
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000) uvicorn.run(app, host="0.0.0.0", port=8000)