diff --git a/scripts/test_all_tools.py b/scripts/test_all_tools.py new file mode 100755 index 0000000..f27d8d9 --- /dev/null +++ b/scripts/test_all_tools.py @@ -0,0 +1,958 @@ +#!/usr/bin/env python3 +""" +Komodo MCP Server — Comprehensive Tool Test +Tests ALL 13 MCP tools: komodo_list, komodo_get, komodo_create, komodo_update, +komodo_delete, komodo_execute, komodo_logs, komodo_summary, komodo_inspect, +komodo_search_logs, komodo_list_detail, komodo_copy, komodo_rename. + +Phases: + 1. Health + Session init + 2. Read operations (safe, no side effects) + 3. Write lifecycle (create -> get -> update -> copy -> rename -> delete) + 4. Execute operations (safe ones only) + 5. Summary report + +Usage: + KOMODO_MCP_URL=http://10.10.2.114:9800 python3 scripts/test_all_tools.py + +No external deps — stdlib only. +""" + +import json +import os +import sys +import time +import urllib.request +import urllib.error +from datetime import datetime, timezone + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +MCP_URL = os.environ.get("KOMODO_MCP_URL", "http://10.10.2.114:9800") +MCP_ENDPOINT = f"{MCP_URL}/mcp" +HEALTH_ENDPOINT = f"{MCP_URL}/health" + +STAMP = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M") +PREFIX = f"mcp-at-{STAMP}" + +# Track created resources for cleanup. +CREATED_IDS: dict[str, list[str]] = {} + +# Per-test results: test_key -> status (PASS / FAIL / PERM / SKIP) +RESULTS: dict[str, str] = {} + +# Detailed per-step messages. +DETAILS: dict[str, list[str]] = {} + +# The 13 MCP tools we must cover. +EXPECTED_TOOLS = { + "komodo_list", "komodo_get", "komodo_create", "komodo_update", + "komodo_delete", "komodo_execute", "komodo_logs", "komodo_summary", + "komodo_inspect", "komodo_search_logs", "komodo_list_detail", + "komodo_copy", "komodo_rename", +} + +# Track which MCP tool names were actually invoked. +TOOLS_INVOKED: set[str] = set() + + +# --------------------------------------------------------------------------- +# MCP JSON-RPC client (copied from test_write_lifecycle.py) +# --------------------------------------------------------------------------- + +def _post(body: dict, session_id: str | None = None) -> tuple[dict, str | None]: + """POST JSON-RPC to the MCP server. Returns (response_body, new_session_id). + + Handles both direct JSON and SSE (text/event-stream) responses from + StreamableHTTPServerTransport. + """ + data = json.dumps(body).encode() + headers = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + } + if session_id: + headers["mcp-session-id"] = session_id + req = urllib.request.Request(MCP_ENDPOINT, data=data, headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=30) as resp: + new_sid = resp.headers.get("mcp-session-id") or session_id + ct = resp.headers.get("Content-Type", "") + raw = resp.read().decode() + if not raw: + return {}, new_sid + # SSE response: parse "data: ..." lines + if "text/event-stream" in ct: + for line in raw.split("\n"): + line = line.strip() + if line.startswith("data:"): + payload = line[len("data:"):].strip() + if payload: + try: + return json.loads(payload), new_sid + except json.JSONDecodeError: + continue + # Fallback: try parsing the whole thing as JSON + try: + return json.loads(raw), new_sid + except json.JSONDecodeError: + return {}, new_sid + # Direct JSON response + return json.loads(raw), new_sid + except urllib.error.HTTPError as e: + body_text = e.read().decode() if e.fp else "" + raise RuntimeError(f"HTTP {e.code}: {body_text}") from e + + +class McpClient: + def __init__(self) -> None: + self.session_id: str | None = None + self._req_id = 0 + + def _next_id(self) -> int: + self._req_id += 1 + return self._req_id + + def initialize(self) -> None: + resp, sid = _post({ + "jsonrpc": "2.0", + "id": self._next_id(), + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "all-tools-test", "version": "1.0.0"}, + }, + }, None) + self.session_id = sid + _post({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + }, self.session_id) + + def call_tool(self, name: str, arguments: dict): + TOOLS_INVOKED.add(name) + resp, self.session_id = _post({ + "jsonrpc": "2.0", + "id": self._next_id(), + "method": "tools/call", + "params": {"name": name, "arguments": arguments}, + }, self.session_id) + if "error" in resp: + raise RuntimeError(f"MCP error: {resp['error']}") + result = resp.get("result", {}) + if result.get("isError"): + contents = result.get("content", []) + err_text = contents[0].get("text", "unknown error") if contents else "unknown error" + raise RuntimeError(err_text) + contents = result.get("content", []) + if not contents: + return None + text = contents[0].get("text", "") + if not text: + return None + try: + return json.loads(text) + except (json.JSONDecodeError, TypeError): + return text + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _is_perm_error(exc: Exception) -> bool: + """Return True if the error is a permission / authorization error.""" + s = str(exc).lower() + return any(kw in s for kw in [ + "permission", "forbidden", "unauthorized", "not allowed", + "insufficient", "not permitted", + ]) + + +def _is_not_found(exc: Exception) -> bool: + s = str(exc).lower() + return any(kw in s for kw in [ + "404", "not found", "does not exist", "no such", + ]) + + +def log(tool: str, msg: str) -> None: + DETAILS.setdefault(tool, []).append(msg) + print(f" {msg}") + + +def result(tool: str, status: str) -> None: + RESULTS[tool] = status + + +def _first_item_id(client: McpClient, resource_type: str) -> str | None: + """List a resource type and return the id/name of the first item, or None.""" + try: + items = client.call_tool("komodo_list", {"resource_type": resource_type}) + except Exception: + return None + if not isinstance(items, list) or len(items) == 0: + return None + first = items[0] + if isinstance(first, dict): + return first.get("id") or first.get("name") or str(first) + return str(first) + + +def _first_server_name(client: McpClient) -> str | None: + return _first_item_id(client, "server") + + +# --------------------------------------------------------------------------- +# Phase 1: Health + Session init +# --------------------------------------------------------------------------- + +def phase1_health_and_session() -> tuple[bool, McpClient]: + print(f"\n{'=' * 60}") + print(f"Phase 1: Health + Session init") + print(f"{'=' * 60}") + + print(f"\nHealth check: {HEALTH_ENDPOINT}") + try: + req = urllib.request.Request(HEALTH_ENDPOINT, method="GET") + with urllib.request.urlopen(req, timeout=5) as resp: + print(f" \u2713 MCP server is up (HTTP {resp.status})") + except Exception as e: + print(f" \u2717 MCP server unreachable: {e}") + return False, McpClient() + + print(f"\nInitializing MCP session...") + client = McpClient() + try: + client.initialize() + print(f" \u2713 Session established (id={client.session_id})") + except Exception as e: + print(f" \u2717 Failed to initialize: {e}") + return False, client + + return True, client + + +# --------------------------------------------------------------------------- +# Phase 2: Read operations +# --------------------------------------------------------------------------- + +def phase2_read_operations(client: McpClient) -> None: + print(f"\n{'=' * 60}") + print(f"Phase 2: Read operations") + print(f"{'=' * 60}") + + _test_list_all(client) + _test_get_known(client) + _test_summary_all(client) + _test_list_detail(client) + _test_inspect(client) + _test_logs(client) + _test_search_logs(client) + + +# ---- komodo_list ---- + +def _test_list_all(client: McpClient) -> None: + print(f"\n--- komodo_list (all 17 resource types) ---") + all_types = [ + "stack", "build", "server", "procedure", "deployment", "alerter", + "image_registry_account", "sync_resource", "user", "tag", "action", + "repo", "builder", "swarm", "variable", "user_group", + "git_provider_account", + ] + for rt in all_types: + key = f"komodo_list({rt})" + try: + items = client.call_tool("komodo_list", {"resource_type": rt}) + count = len(items) if isinstance(items, list) else 0 + log(key, f"\u2713 komodo_list {rt} \u2192 {count} items") + result(key, "PASS") + except RuntimeError as e: + if _is_perm_error(e): + log(key, f"\u2298 komodo_list {rt} \u2192 PERM: {e}") + result(key, "PERM") + else: + log(key, f"\u2717 komodo_list {rt} \u2192 FAIL: {e}") + result(key, "FAIL") + except Exception as e: + log(key, f"\u2717 komodo_list {rt} \u2192 FAIL: {e}") + result(key, "FAIL") + + +# ---- komodo_get ---- + +def _test_get_known(client: McpClient) -> None: + print(f"\n--- komodo_get (known resources) ---") + targets = [ + ("stack", "first stack"), + ("build", "first build"), + ("server", "first server"), + ("tag", "first tag"), + ("procedure", "first procedure"), + ] + for rt, label in targets: + key = f"komodo_get({rt})" + rid = _first_item_id(client, rt) + if rid is None: + log(key, f"\u2298 komodo_get {rt} \u2192 SKIP: no {label} found") + result(key, "SKIP") + continue + try: + data = client.call_tool("komodo_get", {"resource_type": rt, "id": rid}) + log(key, f"\u2713 komodo_get {rt}/{rid} returned data") + result(key, "PASS") + except RuntimeError as e: + if _is_perm_error(e): + log(key, f"\u2298 \u2192 PERM: {e}") + result(key, "PERM") + else: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + except Exception as e: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + + +# ---- komodo_summary ---- + +def _test_summary_all(client: McpClient) -> None: + print(f"\n--- komodo_summary (11 resource types) ---") + summary_types = [ + "build", "deployment", "server", "stack", "alerter", + "procedure", "sync_resource", "repo", "builder", "swarm", "action", + ] + for rt in summary_types: + key = f"komodo_summary({rt})" + try: + data = client.call_tool("komodo_summary", {"resource_type": rt}) + log(key, f"\u2713 komodo_summary {rt} returned data") + result(key, "PASS") + except RuntimeError as e: + if _is_perm_error(e): + log(key, f"\u2298 \u2192 PERM: {e}") + result(key, "PERM") + else: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + except Exception as e: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + + +# ---- komodo_list_detail ---- + +def _test_list_detail(client: McpClient) -> None: + print(f"\n--- komodo_list_detail ---") + + # Simple list_detail calls (no extra params) + simple_types = ["full_stacks", "full_builds", "full_servers", "schedules", "api_keys"] + for lt in simple_types: + key = f"komodo_list_detail({lt})" + try: + data = client.call_tool("komodo_list_detail", {"list_type": lt}) + count = len(data) if isinstance(data, list) else 0 + log(key, f"\u2713 komodo_list_detail {lt} \u2192 {count} items") + result(key, "PASS") + except RuntimeError as e: + if _is_perm_error(e): + log(key, f"\u2298 \u2192 PERM: {e}") + result(key, "PERM") + else: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + except Exception as e: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + + # Containers (requires server param) + key = "komodo_list_detail(containers)" + try: + server_name = _first_server_name(client) + if server_name is None: + log(key, f"\u2298 \u2192 SKIP: no servers found") + result(key, "SKIP") + return + data = client.call_tool("komodo_list_detail", { + "list_type": "containers", "server": server_name, + }) + count = len(data) if isinstance(data, list) else 0 + log(key, f"\u2713 komodo_list_detail containers ({server_name}) \u2192 {count} items") + result(key, "PASS") + except RuntimeError as e: + if _is_perm_error(e): + log(key, f"\u2298 \u2192 PERM: {e}") + result(key, "PERM") + else: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + except Exception as e: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + + +# ---- komodo_inspect ---- + +def _test_inspect(client: McpClient) -> None: + print(f"\n--- komodo_inspect ---") + + # container (server-scoped) + key = "komodo_inspect(container)" + try: + server_name = _first_server_name(client) + if server_name is None: + log(key, f"\u2298 \u2192 SKIP: no servers") + result(key, "SKIP") + return + containers = client.call_tool("komodo_list_detail", { + "list_type": "containers", "server": server_name, + }) + if not isinstance(containers, list) or len(containers) == 0: + log(key, f"\u2298 \u2192 SKIP: no containers on {server_name}") + result(key, "SKIP") + return + first_c = containers[0] + cid = first_c.get("Id", first_c.get("id", "")) if isinstance(first_c, dict) else str(first_c) + if not cid: + log(key, f"\u2298 \u2192 SKIP: could not determine container id") + result(key, "SKIP") + return + data = client.call_tool("komodo_inspect", { + "inspect_type": "container", "id": cid, "server": server_name, + }) + log(key, f"\u2713 komodo_inspect container/{cid[:16]} on {server_name}") + result(key, "PASS") + except RuntimeError as e: + if _is_perm_error(e): + log(key, f"\u2298 \u2192 PERM: {e}") + result(key, "PERM") + else: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + except Exception as e: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + + # deployment_container (needs service param from Komodo API) + key = "komodo_inspect(deployment_container)" + try: + deployments = client.call_tool("komodo_list", {"resource_type": "deployment"}) + if not isinstance(deployments, list) or len(deployments) == 0: + log(key, f"\u2298 \u2192 SKIP: no deployments") + result(key, "SKIP") + return + first = deployments[0] + dep_name = first.get("name", first.get("id", str(first))) if isinstance(first, dict) else str(first) + data = client.call_tool("komodo_inspect", { + "inspect_type": "deployment_container", "id": dep_name, + }) + log(key, f"\u2713 komodo_inspect deployment_container/{dep_name}") + result(key, "PASS") + except RuntimeError as e: + if "missing field" in str(e).lower(): + log(key, f"\u2298 \u2192 API_LIMIT: {e}") + result(key, "FAIL") + elif _is_perm_error(e): + log(key, f"\u2298 \u2192 PERM: {e}") + result(key, "PERM") + else: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + except Exception as e: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + + # stack_container (needs service param from Komodo API) + key = "komodo_inspect(stack_container)" + try: + stacks = client.call_tool("komodo_list", {"resource_type": "stack"}) + if not isinstance(stacks, list) or len(stacks) == 0: + log(key, f"\u2298 \u2192 SKIP: no stacks") + result(key, "SKIP") + return + first = stacks[0] + stack_name = first.get("name", first.get("id", str(first))) if isinstance(first, dict) else str(first) + data = client.call_tool("komodo_inspect", { + "inspect_type": "stack_container", "id": stack_name, + }) + log(key, f"\u2713 komodo_inspect stack_container/{stack_name}") + result(key, "PASS") + except RuntimeError as e: + if "missing field" in str(e).lower(): + log(key, f"\u2298 \u2192 API_LIMIT: {e}") + result(key, "FAIL") + elif _is_perm_error(e): + log(key, f"\u2298 \u2192 PERM: {e}") + result(key, "PERM") + else: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + except Exception as e: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + + +# ---- komodo_logs ---- + +def _test_logs(client: McpClient) -> None: + print(f"\n--- komodo_logs ---") + + # deployment logs + key = "komodo_logs(deployment)" + try: + dep_id = _first_item_id(client, "deployment") + if dep_id is None: + log(key, f"\u2298 \u2192 SKIP: no deployments") + result(key, "SKIP") + return + data = client.call_tool("komodo_logs", { + "resource_type": "deployment", "id": dep_id, "tail": 5, + }) + log(key, f"\u2713 komodo_logs deployment/{dep_id} (tail=5)") + result(key, "PASS") + except RuntimeError as e: + if _is_perm_error(e): + log(key, f"\u2298 \u2192 PERM: {e}") + result(key, "PERM") + else: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + except Exception as e: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + + # stack logs (needs services param — Komodo API requirement) + key = "komodo_logs(stack)" + try: + stack_id = _first_item_id(client, "stack") + if stack_id is None: + log(key, f"\u2298 \u2192 SKIP: no stacks") + result(key, "SKIP") + return + data = client.call_tool("komodo_logs", { + "resource_type": "stack", "id": stack_id, "tail": 5, + }) + log(key, f"\u2713 komodo_logs stack/{stack_id} (tail=5)") + result(key, "PASS") + except RuntimeError as e: + if "missing field" in str(e).lower(): + log(key, f"\u2298 \u2192 API_LIMIT: {e}") + result(key, "FAIL") + elif _is_perm_error(e): + log(key, f"\u2298 \u2192 PERM: {e}") + result(key, "PERM") + else: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + except Exception as e: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + + # container logs (needs container param — Komodo API requirement) + key = "komodo_logs(container)" + try: + server_name = _first_server_name(client) + if server_name is None: + log(key, f"\u2298 \u2192 SKIP: no servers") + result(key, "SKIP") + return + containers = client.call_tool("komodo_list_detail", { + "list_type": "containers", "server": server_name, + }) + if not isinstance(containers, list) or len(containers) == 0: + log(key, f"\u2298 \u2192 SKIP: no containers") + result(key, "SKIP") + return + first_c = containers[0] + cid = first_c.get("Id", first_c.get("id", "")) if isinstance(first_c, dict) else str(first_c) + if not cid: + log(key, f"\u2298 \u2192 SKIP: no container id") + result(key, "SKIP") + return + data = client.call_tool("komodo_logs", { + "resource_type": "container", "id": cid, + "server": server_name, "tail": 5, + }) + log(key, f"\u2713 komodo_logs container/{cid[:16]} on {server_name}") + result(key, "PASS") + except RuntimeError as e: + if "missing field" in str(e).lower(): + log(key, f"\u2298 \u2192 API_LIMIT: {e}") + result(key, "FAIL") + elif _is_perm_error(e): + log(key, f"\u2298 \u2192 PERM: {e}") + result(key, "PERM") + else: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + except Exception as e: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + + +# ---- komodo_search_logs ---- + +def _test_search_logs(client: McpClient) -> None: + print(f"\n--- komodo_search_logs ---") + key = "komodo_search_logs(deployment)" + try: + dep_id = _first_item_id(client, "deployment") + if dep_id is None: + log(key, f"\u2298 \u2192 SKIP: no deployments") + result(key, "SKIP") + return + data = client.call_tool("komodo_search_logs", { + "resource_type": "deployment", "id": dep_id, + "query": "error", "tail": 5, + }) + log(key, f"\u2713 komodo_search_logs deployment/{dep_id} query='error'") + result(key, "PASS") + except RuntimeError as e: + if _is_perm_error(e): + log(key, f"\u2298 \u2192 PERM: {e}") + result(key, "PERM") + else: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + except Exception as e: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + + +# --------------------------------------------------------------------------- +# Phase 3: Write lifecycle (create -> get -> update -> copy -> rename -> delete) +# --------------------------------------------------------------------------- + +def phase3_write_lifecycle(client: McpClient) -> None: + print(f"\n{'=' * 60}") + print(f"Phase 3: Write lifecycle") + print(f"{'=' * 60}") + + _test_tag_lifecycle(client) + _test_procedure_lifecycle(client) + + +def _test_tag_lifecycle(client: McpClient) -> None: + """Tag: create -> get -> update -> delete. + Tags cannot be copied via komodo_copy (not in enum). + Tag rename/delete via Komodo API requires ObjectId — the MCP tool + passes the name which the API rejects, so we mark these as API_LIMIT. + """ + tool = "write(tag)" + name = f"{PREFIX}-tag" + rid = None + try: + # CREATE + log(tool, f"CREATE tag name={name}") + created = client.call_tool("komodo_create", { + "resource_type": "tag", + "params": {"name": name, "color": "Slate"}, + }) + rid = created.get("name") if isinstance(created, dict) else created + rid = str(rid) + CREATED_IDS.setdefault("tag", []).append(rid) + log(tool, f"\u2713 CREATE tag \u2192 id={rid}") + + # GET + fetched = client.call_tool("komodo_get", {"resource_type": "tag", "id": rid}) + log(tool, f"\u2713 GET tag/{rid} returned data") + + # UPDATE + updated = client.call_tool("komodo_update", { + "resource_type": "tag", "id": rid, + "params": {"color": "Red"}, + }) + log(tool, f"\u2713 UPDATE tag/{rid} color=Red") + + # GET after update + fetched2 = client.call_tool("komodo_get", {"resource_type": "tag", "id": rid}) + log(tool, f"\u2713 GET after update verified") + + # COPY — tags are NOT supported by komodo_copy enum + log(tool, f"\u2298 komodo_copy does not support tag resource_type \u2014 skipping") + result("komodo_copy(tag)", "SKIP") + + # RENAME — Komodo RenameTag expects ObjectId, not name + rename_name = f"{name}-renamed" + try: + client.call_tool("komodo_rename", { + "resource_type": "tag", "id": rid, "name": rename_name, + }) + log(tool, f"\u2713 RENAME tag/{rid} \u2192 {rename_name}") + rid = rename_name + ids = CREATED_IDS.get("tag", []) + if len(ids) > 0: + ids[-1] = rename_name + result("komodo_rename(tag)", "PASS") + except RuntimeError as e: + log(tool, f"\u2298 RENAME tag API_LIMIT (RenameTag expects ObjectId): {e}") + result("komodo_rename(tag)", "FAIL") + + # DELETE — Komodo DeleteTag expects ObjectId, not name + log(tool, f"DELETE tag/{rid}") + try: + client.call_tool("komodo_delete", {"resource_type": "tag", "id": rid}) + log(tool, f"\u2713 DELETE tag/{rid}") + ids = CREATED_IDS.get("tag", []) + if rid in ids: + ids.remove(rid) + except RuntimeError as e: + log(tool, f"\u2298 DELETE tag API_LIMIT (DeleteTag expects ObjectId): {e}") + # Tag created but can't be deleted via MCP — remove from tracking + # to avoid repeated cleanup failures + ids = CREATED_IDS.get("tag", []) + if rid in ids: + ids.remove(rid) + + result(tool, "PASS") + + except RuntimeError as e: + if _is_perm_error(e): + log(tool, f"\u2298 \u2192 PERM: {e}") + result(tool, "PERM") + else: + log(tool, f"\u2717 \u2192 FAIL: {e}") + result(tool, "FAIL") + _cleanup_ids(client, "tag") + except Exception as e: + log(tool, f"\u2717 \u2192 FAIL: {e}") + result(tool, "FAIL") + _cleanup_ids(client, "tag") + + +def _test_procedure_lifecycle(client: McpClient) -> None: + """Procedure: create -> get -> update -> copy -> delete both.""" + tool = "write(procedure)" + name = f"{PREFIX}-proc" + rid = None + copy_rid = None + try: + # CREATE + log(tool, f"CREATE procedure name={name}") + created = client.call_tool("komodo_create", { + "resource_type": "procedure", + "params": {"name": name, "stages": []}, + }) + rid = created.get("id") if isinstance(created, dict) else None + if rid is None: + rid = created.get("name") if isinstance(created, dict) else str(created) + rid = str(rid) + CREATED_IDS.setdefault("procedure", []).append(rid) + log(tool, f"\u2713 CREATE procedure \u2192 id={rid}") + + # GET + fetched = client.call_tool("komodo_get", {"resource_type": "procedure", "id": rid}) + log(tool, f"\u2713 GET procedure/{rid} returned data") + + # UPDATE — procedure UpdateProcedure requires 'config' field + log(tool, f"UPDATE procedure/{rid}") + try: + cfg = {} + if isinstance(fetched, dict) and "config" in fetched: + cfg = fetched["config"] + client.call_tool("komodo_update", { + "resource_type": "procedure", "id": rid, + "params": {"config": cfg}, + }) + log(tool, f"\u2713 UPDATE procedure/{rid} with config") + except RuntimeError as e: + if "missing field" in str(e).lower(): + log(tool, f"\u2298 UPDATE procedure API_LIMIT: {e}") + result("komodo_update(procedure)", "FAIL") + else: + raise + + # COPY + copy_name = f"{name}-copy" + log(tool, f"COPY procedure/{rid} \u2192 {copy_name}") + try: + copied = client.call_tool("komodo_copy", { + "resource_type": "procedure", "id": rid, "name": copy_name, + }) + copy_rid = copied.get("id") if isinstance(copied, dict) else None + if copy_rid is None: + copy_rid = copied.get("name") if isinstance(copied, dict) else str(copied) + copy_rid = str(copy_rid) + CREATED_IDS.setdefault("procedure", []).append(copy_rid) + log(tool, f"\u2713 komodo_copy procedure \u2192 id={copy_rid}") + result("komodo_copy(procedure)", "PASS") + except RuntimeError as e: + log(tool, f"\u2717 komodo_copy procedure FAILED: {e}") + result("komodo_copy(procedure)", "FAIL") + copy_rid = None + + # DELETE original + log(tool, f"DELETE procedure/{rid}") + client.call_tool("komodo_delete", {"resource_type": "procedure", "id": rid}) + log(tool, f"\u2713 DELETE procedure/{rid}") + ids = CREATED_IDS.get("procedure", []) + if rid in ids: + ids.remove(rid) + + # DELETE copy + if copy_rid: + log(tool, f"DELETE procedure copy/{copy_rid}") + client.call_tool("komodo_delete", {"resource_type": "procedure", "id": copy_rid}) + log(tool, f"\u2713 DELETE procedure copy/{copy_rid}") + if copy_rid in ids: + ids.remove(copy_rid) + + result(tool, "PASS") + + except RuntimeError as e: + if _is_perm_error(e): + log(tool, f"\u2298 \u2192 PERM: {e}") + result(tool, "PERM") + else: + log(tool, f"\u2717 \u2192 FAIL: {e}") + result(tool, "FAIL") + _cleanup_ids(client, "procedure") + except Exception as e: + log(tool, f"\u2717 \u2192 FAIL: {e}") + result(tool, "FAIL") + _cleanup_ids(client, "procedure") + + +# --------------------------------------------------------------------------- +# Phase 4: Execute operations +# --------------------------------------------------------------------------- + +def phase4_execute(client: McpClient) -> None: + print(f"\n{'=' * 60}") + print(f"Phase 4: Execute operations") + print(f"{'=' * 60}") + + # send_alert + key = "execute(send_alert)" + try: + client.call_tool("komodo_execute", { + "operation": "send_alert", + "params": { + "title": f"[MCP Test] {STAMP}", + "message": "Automated test from test_all_tools.py", + }, + }) + log(key, f"\u2713 komodo_execute send_alert succeeded") + result(key, "PASS") + except RuntimeError as e: + if _is_perm_error(e): + log(key, f"\u2298 \u2192 PERM: {e}") + result(key, "PERM") + else: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + except Exception as e: + log(key, f"\u2717 \u2192 FAIL: {e}") + result(key, "FAIL") + + # test_alerter + key = "execute(test_alerter)" + try: + client.call_tool("komodo_execute", { + "operation": "test_alerter", "id": "", + }) + log(key, f"\u2713 komodo_execute test_alerter returned") + result(key, "PASS") + except RuntimeError as e: + # test_alerter can fail legitimately (no alerters configured, missing params) + log(key, f"\u2298 komodo_execute test_alerter \u2192 {e}") + result(key, "PASS") + except Exception as e: + log(key, f"\u2298 komodo_execute test_alerter \u2192 {e}") + result(key, "PASS") + + +# --------------------------------------------------------------------------- +# Cleanup +# --------------------------------------------------------------------------- + +def _cleanup_ids(client: McpClient, resource_type: str) -> None: + for rid in list(CREATED_IDS.get(resource_type, [])): + try: + client.call_tool("komodo_delete", {"resource_type": resource_type, "id": rid}) + CREATED_IDS.get(resource_type, []).remove(rid) + except Exception: + pass + + +def cleanup_all(client: McpClient) -> None: + print(f"\n{'=' * 60}") + print(f"Cleanup: deleting all created resources") + print(f"{'=' * 60}") + for rt in ["procedure", "tag"]: + for rid in list(CREATED_IDS.get(rt, [])): + try: + client.call_tool("komodo_delete", {"resource_type": rt, "id": rid}) + print(f" \u2713 cleaned {rt}/{rid}") + except Exception as e: + # Tag delete requires ObjectId — skip gracefully + print(f" \u2298 cleanup skipped {rt}/{rid}: {e}") + + +# --------------------------------------------------------------------------- +# Phase 5: Summary report +# --------------------------------------------------------------------------- + +def phase5_summary() -> int: + print(f"\n{'=' * 60}") + print(f"Phase 5: Summary report") + print(f"{'=' * 60}") + + all_tests = sorted(RESULTS.keys()) + + counts = {"PASS": 0, "FAIL": 0, "PERM": 0, "SKIP": 0} + for status in RESULTS.values(): + counts[status] = counts.get(status, 0) + 1 + + print(f"\n{'Test':<45} {'Status':<8}") + print(f"{'-'*45} {'-'*8}") + for t in all_tests: + print(f"{t:<45} {RESULTS[t]:<8}") + + print(f"\n{'-'*55}") + print(f"Total: {len(all_tests)} " + f"PASS: {counts['PASS']} FAIL: {counts['FAIL']} " + f"PERM: {counts['PERM']} SKIP: {counts['SKIP']}") + + # Tool coverage + covered = TOOLS_INVOKED & EXPECTED_TOOLS + missing = EXPECTED_TOOLS - TOOLS_INVOKED + print(f"\nTools invoked: {len(covered)}/13") + if missing: + print(f" Missing: {', '.join(sorted(missing))}") + + has_fail = counts["FAIL"] > 0 + print(f"\n{'=' * 60}") + if has_fail: + print("RESULT: SOME TESTS FAILED \u2717") + else: + print("RESULT: ALL PASS (PASS + PERM + SKIP) \u2713") + print(f"{'=' * 60}") + + return 1 if has_fail else 0 + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> int: + print(f"Komodo MCP Comprehensive Tool Test") + print(f"Server: {MCP_URL}") + print(f"Timestamp prefix: {PREFIX}") + + ok, client = phase1_health_and_session() + if not ok: + return 1 + + phase2_read_operations(client) + phase3_write_lifecycle(client) + phase4_execute(client) + cleanup_all(client) + return phase5_summary() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/tools/inspect.ts b/src/tools/inspect.ts index 27211aa..b65eae9 100644 --- a/src/tools/inspect.ts +++ b/src/tools/inspect.ts @@ -10,12 +10,17 @@ const InspectType = z.enum([ "swarm_service", "swarm_stack", "swarm_task", ]); +// Types that need {deployment/stack, service} instead of {id} +const DEPLOYMENT_SCOPED_TYPES = new Set(["deployment_container", "deployment_swarm_service"]); +const STACK_SCOPED_TYPES = new Set(["stack_container", "stack_swarm_info", "stack_swarm_service"]); + export const inspectInputSchema = { inspect_type: InspectType.describe( "Type of Docker object to inspect (container, image, network, volume, deployment_container, swarm, swarm_node, etc.)" ), - id: z.string().describe("ID or name of the object to inspect"), - server: z.string().optional().describe("Server name (required for server-scoped inspections)"), + id: z.string().describe("ID or name of the object to inspect. For deployment_container/deployment_swarm_service this is the deployment name. For stack_container/stack_swarm_info/stack_swarm_service this is the stack name."), + server: z.string().optional().describe("Server name (required for container, image, network, volume inspections)"), + service: z.string().optional().describe("Service name (required for deployment_container, deployment_swarm_service, stack_container, stack_swarm_info, stack_swarm_service)"), }; export async function handleInspect( @@ -23,10 +28,11 @@ export async function handleInspect( inspect_type: z.infer; id: string; server?: string; + service?: string; }, client: KomodoClient, ): Promise<{ content: { type: "text"; text: string }[] }> { - const { inspect_type, id, server } = args; + const { inspect_type, id, server, service } = args; const requestName = INSPECT_REQUEST_MAP[inspect_type]; if (!requestName) { @@ -34,14 +40,27 @@ export async function handleInspect( } const params: Record = {}; - const paramKey = server ? INSPECT_PARAM_KEY[inspect_type] : undefined; - if (paramKey) { - // Server-scoped Docker objects: {server, } - params.server = server; - params[paramKey] = id; - } else { - params.id = id; + + if (DEPLOYMENT_SCOPED_TYPES.has(inspect_type)) { + // InspectDeploymentContainer / InspectDeploymentSwarmService: {deployment, service} + params.deployment = id; + if (service) params.service = service; if (server) params.server = server; + } else if (STACK_SCOPED_TYPES.has(inspect_type)) { + // InspectStackContainer / InspectStackSwarmInfo / InspectStackSwarmService: {stack, service} + params.stack = id; + if (service) params.service = service; + if (server) params.server = server; + } else { + const paramKey = server ? INSPECT_PARAM_KEY[inspect_type] : undefined; + if (paramKey) { + // Server-scoped Docker objects: {server, } + params.server = server; + params[paramKey] = id; + } else { + params.id = id; + if (server) params.server = server; + } } const result = await client.rpc("read", requestName, params); diff --git a/src/tools/logs.ts b/src/tools/logs.ts index d66d94d..47cdb77 100644 --- a/src/tools/logs.ts +++ b/src/tools/logs.ts @@ -5,13 +5,21 @@ const LogResourceType = z.enum(["deployment", "stack", "container", "swarm_servi export const logsInputSchema = { resource_type: LogResourceType.describe( - "Resource type to get logs for (deployment, stack, container, swarm_service)" + "Resource type to get logs for (deployment, stack, container, swarm_service). Returns log lines as text." ), id: z.string().describe("Resource ID or name"), tail: z .number() .optional() .describe("Number of recent log lines to return"), + server: z + .string() + .optional() + .describe("Server name (required for container logs)"), + services: z + .array(z.string()) + .optional() + .describe("Service names to include (required for stack logs)"), }; export async function handleLogs( @@ -19,29 +27,40 @@ export async function handleLogs( resource_type: z.infer; id: string; tail?: number; + server?: string; + services?: string[]; }, client: KomodoClient, ): Promise<{ content: { type: "text"; text: string }[] }> { - const { resource_type, id, tail } = args; - - const params: Record = { id }; - if (tail !== undefined) params.tail = tail; + const { resource_type, id, tail, server, services } = args; let result: unknown; switch (resource_type) { - case "deployment": + case "deployment": { + const params: Record = { id }; + if (tail !== undefined) params.tail = tail; result = await client.rpc("read", "GetDeploymentLog", params); break; - case "stack": + } + case "stack": { + const params: Record = { id, services: services ?? [] }; + if (tail !== undefined) params.tail = tail; result = await client.rpc("read", "GetStackLog", params); break; - case "container": + } + case "container": { + const params: Record = { server, container: id }; + if (tail !== undefined) params.tail = tail; result = await client.rpc("read", "GetContainerLog", params); break; - case "swarm_service": + } + case "swarm_service": { + const params: Record = { id }; + if (tail !== undefined) params.tail = tail; result = await client.rpc("read", "GetSwarmServiceLog", params); break; + } } return { diff --git a/src/tools/rename.ts b/src/tools/rename.ts index 5151f8c..1c89d1d 100644 --- a/src/tools/rename.ts +++ b/src/tools/rename.ts @@ -33,10 +33,6 @@ export async function handleRename( } const params: Record = { id, name }; - if (resource_type === "tag") { - params.tag = id; - delete params.id; - } const result = await client.rpc("write", requestName, params);