#!/usr/bin/env python3 """ Komodo MCP Server — Write Lifecycle Test Proves the write path through the MCP server for every creatable aspect: create → get → update (where available) → get → delete → verify-gone. Usage: KOMODO_MCP_URL=http://10.10.2.114:9800 python3 scripts/test_write_lifecycle.py No external deps — stdlib urllib 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-wt-{STAMP}" # All IDs we create — only these may be deleted (allowlist). CREATED_IDS: dict[str, list[str]] = {} # resource_type -> [id, ...] # Per-type results for the final audit table. RESULTS: dict[str, dict] = {} # --------------------------------------------------------------------------- # MCP JSON-RPC client # --------------------------------------------------------------------------- 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": "write-lifecycle-test", "version": "1.0.0"}, }, }, None) self.session_id = sid # Send initialized notification _post({ "jsonrpc": "2.0", "method": "notifications/initialized", }, self.session_id) def call_tool(self, name: str, arguments: dict) -> any: 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", {}) # MCP SDK returns isError:true for tool execution errors 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 def list_(self, resource_type: str) -> list: result = self.call_tool("komodo_list", {"resource_type": resource_type}) return result if isinstance(result, list) else [] def get(self, resource_type: str, rid: str) -> any: return self.call_tool("komodo_get", {"resource_type": resource_type, "id": rid}) def create(self, resource_type: str, params: dict) -> any: return self.call_tool("komodo_create", {"resource_type": resource_type, "params": params}) def update(self, resource_type: str, rid: str, params: dict) -> any: return self.call_tool("komodo_update", {"resource_type": resource_type, "id": rid, "params": params}) def delete(self, resource_type: str, rid: str) -> any: return self.call_tool("komodo_delete", {"resource_type": resource_type, "id": rid}) # --------------------------------------------------------------------------- # Resource type definitions — create_params, update_params, id_field, name_field # --------------------------------------------------------------------------- RESOURCE_DEFS: dict[str, dict] = { "tag": { "create_params": lambda name: {"name": name, "color": "Slate"}, "update_params": lambda name: {"color": "Red"}, "id_field": "name", # tag name IS the id "name_field": "name", "has_update": True, }, "variable": { "create_params": lambda name: {"name": name.upper().replace("-", "_"), "value": "mcp_wt_test_value"}, "update_params": lambda name: {"value": "mcp_wt_updated_value"}, "id_field": "name", "name_field": "name", "has_update": True, # Komodo variable names must match [A-Za-z_][A-Za-z0-9_]* "make_id": lambda name: name.upper().replace("-", "_"), }, "user_group": { "create_params": lambda name: {"name": name}, "update_params": lambda name: {"name": name}, "id_field": "name", "name_field": "name", "has_update": True, }, "procedure": { "create_params": lambda name: {"name": name, "stages": []}, "update_params": lambda name: {"name": name}, "id_field": "id", "name_field": "name", "has_update": True, }, "action": { "create_params": lambda name: {"name": name, "run": {"type": "None"}, "disabled": True}, "update_params": lambda name: {"name": name}, "id_field": "id", "name_field": "name", "has_update": True, }, "repo": { "create_params": lambda name: {"name": name, "repo": "https://example.invalid/dummy.git"}, "update_params": lambda name: {"name": name}, "id_field": "id", "name_field": "name", "has_update": True, }, "builder": { "create_params": lambda name: {"name": name}, "update_params": lambda name: {"name": name}, "id_field": "id", "name_field": "name", "has_update": True, }, "alerter": { "create_params": lambda name: {"name": name, "endpoint": {"type": "Custom", "url": "https://example.invalid/hook", "headers": {}}, "disabled": True}, "update_params": lambda name: {"name": name}, "id_field": "id", "name_field": "name", "has_update": True, }, "sync_resource": { "create_params": lambda name: {"name": name, "resource_sync": {"type": "None"}}, "update_params": lambda name: {"name": name}, "id_field": "id", "name_field": "name", "has_update": True, }, "server": { "create_params": lambda name: {"name": name, "address": "https://example.invalid", "enabled": False}, "update_params": lambda name: {"name": name}, "id_field": "id", "name_field": "name", "has_update": True, }, "stack": { "create_params": lambda name: {"name": name, "project": f"{PREFIX}-proj", "compose_contents": []}, "update_params": lambda name: {"name": name}, "id_field": "id", "name_field": "name", "has_update": True, }, "build": { "create_params": lambda name: {"name": name, "project": f"{PREFIX}-proj", "repo": "https://example.invalid/dummy.git", "builder": {"type": "Disabled"}}, "update_params": lambda name: {"name": name}, "id_field": "id", "name_field": "name", "has_update": True, }, "deployment": { "create_params": lambda name: {"name": name, "project": f"{PREFIX}-proj", "server": "https://example.invalid", "image": "nginx:latest"}, "update_params": lambda name: {"name": name}, "id_field": "id", "name_field": "name", "has_update": True, }, "swarm": { "create_params": lambda name: {"name": name, "project": f"{PREFIX}-proj"}, "update_params": lambda name: {"name": name}, "id_field": "id", "name_field": "name", "has_update": True, }, } # Test tiers TIERS: list[list[str]] = [ ["tag", "variable", "user_group"], ["procedure", "action", "repo", "builder", "alerter", "sync_resource"], ["server", "stack", "build", "deployment", "swarm"], ] # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def make_name(resource_type: str) -> str: return f"{PREFIX}-{resource_type}" def extract_id(resource_type: str, data: any) -> str | None: """Extract the resource ID from a create/get response.""" if data is None: return None # Some GET endpoints return the resource name as a bare string if isinstance(data, str): return data if not isinstance(data, dict): return None rdef = RESOURCE_DEFS[resource_type] # Try id_field first, then common fallbacks for key in [rdef["id_field"], "id", "_id", "name"]: if key in data and data[key]: return str(data[key]) return None def extract_name(resource_type: str, data: any) -> str | None: if data is None: return None if isinstance(data, str): return data if not isinstance(data, dict): return None return data.get(RESOURCE_DEFS[resource_type]["name_field"]) def check(resp, label: str) -> bool: """Assert a condition; print pass/fail.""" if resp: print(f" ✓ {label}") return True else: print(f" ✗ {label}") return False # --------------------------------------------------------------------------- # Single-resource lifecycle test # --------------------------------------------------------------------------- def test_lifecycle(client: McpClient, resource_type: str) -> bool: """ Run create → get → update → get → delete → verify-gone for one type. Returns True on success, False on failure (after cleanup). """ rdef = RESOURCE_DEFS[resource_type] name = make_name(resource_type) rid: str | None = None ok = True result = {"created": False, "updated": False, "deleted": False, "verified_gone": False, "id": None} try: # --- List baseline --- baseline = client.list_(resource_type) baseline_count = len(baseline) print(f" baseline count: {baseline_count}") # --- Create --- create_params = rdef["create_params"](name) print(f" CREATE {resource_type} with {json.dumps(create_params)}") created = client.create(resource_type, create_params) rid = extract_id(resource_type, created) result["id"] = rid if not rid: print(f" ✗ CREATE returned no ID: {created}") ok = False return ok CREATED_IDS.setdefault(resource_type, []).append(rid) result["created"] = True print(f" ✓ CREATE → id={rid}") # --- Get by ID --- fetched = client.get(resource_type, rid) if fetched is None: print(f" ✗ GET by id returned null for {rid}") ok = False return ok # GET may return a bare string (the name) for some types if isinstance(fetched, str): if fetched == rid or fetched == name: print(f" ✓ GET by id returned name: {fetched}") else: print(f" ✗ GET by id returned unexpected string: {fetched}") ok = False return ok elif isinstance(fetched, dict): fetched_name = extract_name(resource_type, fetched) if fetched_name == name or str(fetched.get("id", "")) == rid: print(f" ✓ GET by id matches (name={fetched_name})") else: print(f" ✗ GET by id mismatch: expected name={name}, got {fetched}") ok = False return ok else: print(f" ✓ GET by id returned: {type(fetched).__name__} = {fetched}") # --- Update (if endpoint exists) --- if rdef["has_update"] and rdef["update_params"]: update_params = rdef["update_params"](name) print(f" UPDATE {resource_type} id={rid} with {json.dumps(update_params)}") updated = client.update(resource_type, rid, update_params) result["updated"] = True print(f" ✓ UPDATE succeeded") # --- Get after update --- fetched2 = client.get(resource_type, rid) if fetched2 is not None: # Verify the update was applied (check updated fields) update_ok = True if isinstance(fetched2, dict): for k, v in update_params.items(): if k in fetched2 and fetched2[k] != v: print(f" ✗ GET after update: field {k} expected {v}, got {fetched2[k]}") update_ok = False ok = False # If fetched2 is a string, we can't verify individual fields if update_ok: print(f" ✓ GET after update reflects changes") else: print(f" ✗ GET after update returned null") ok = False else: print(f" ⊘ No update endpoint for {resource_type} — skipping update") # --- Delete --- print(f" DELETE {resource_type} id={rid}") deleted = client.delete(resource_type, rid) result["deleted"] = True print(f" ✓ DELETE succeeded") # --- Verify gone --- time.sleep(0.5) # brief pause for eventual consistency try: still = client.get(resource_type, rid) if still is None: result["verified_gone"] = True print(f" ✓ GET after delete returns null — verified gone") else: print(f" ✗ GET after delete still returns data: {still}") ok = False except RuntimeError as e: # Error response means resource is gone if "404" in str(e) or "not found" in str(e).lower() or "error" in str(e).lower(): result["verified_gone"] = True print(f" ✓ GET after delete errors — verified gone") else: print(f" ✗ GET after delete unexpected error: {e}") ok = False # --- List count back to baseline --- final = client.list_(resource_type) final_count = len(final) if final_count == baseline_count: print(f" ✓ List count back to baseline ({final_count})") else: print(f" ✗ List count mismatch: baseline={baseline_count}, now={final_count}") ok = False except Exception as e: print(f" ✗ EXCEPTION: {e}") ok = False RESULTS[resource_type] = result return ok # --------------------------------------------------------------------------- # Cleanup — reverse-order deletion of any remaining created IDs # --------------------------------------------------------------------------- def cleanup(client: McpClient) -> bool: """Delete all created resources in reverse order. Returns True if all clean.""" all_clean = True # Reverse tier order, then reverse within each tier all_types = [] for tier in reversed(TIERS): all_types.extend(reversed(tier)) for rt in all_types: ids = CREATED_IDS.get(rt, []) for rid in reversed(ids): if RESULTS.get(rt, {}).get("deleted"): continue # already deleted in the test try: print(f" CLEANUP: delete {rt} id={rid}") client.delete(rt, rid) print(f" ✓ cleanup OK for {rt}/{rid}") except Exception as e: print(f" ✗ cleanup FAILED for {rt}/{rid}: {e}") all_clean = False return all_clean # --------------------------------------------------------------------------- # Final sweep # --------------------------------------------------------------------------- def final_sweep(client: McpClient) -> bool: """Verify all created IDs error on get.""" all_gone = True for rt, ids in CREATED_IDS.items(): for rid in ids: if RESULTS.get(rt, {}).get("deleted"): continue try: data = client.get(rt, rid) # null/None means gone; string is sometimes the resource name # (for types like user_group where GET returns bare name) if data is None: print(f" ✓ {rt}/{rid} confirmed gone (null)") elif isinstance(data, str) and data == rid: # Bare string match means it still exists print(f" ✗ {rt}/{rid} still exists (returned name)") all_gone = False elif isinstance(data, dict) and data.get("name") == rid: print(f" ✗ {rt}/{rid} still exists") all_gone = False elif isinstance(data, dict) and data.get("id") == rid: print(f" ✗ {rt}/{rid} still exists") all_gone = False else: # Got something but doesn't match — might be a different # error shape; treat as gone if it looks like an error print(f" ? {rt}/{rid} got unexpected response: {type(data).__name__}") except RuntimeError as e: err_str = str(e).lower() if "404" in err_str or "not found" in err_str or "does not exist" in err_str: print(f" ✓ {rt}/{rid} confirmed gone (error)") else: print(f" ✗ {rt}/{rid} unexpected error: {e}") all_gone = False except Exception as e: print(f" ✗ {rt}/{rid} unexpected: {e}") all_gone = False return all_gone # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main() -> int: print(f"=" * 60) print(f"Komodo MCP Write Lifecycle Test") print(f"MCP server: {MCP_URL}") print(f"Timestamp prefix: {PREFIX}") print(f"=" * 60) # Health check 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" ✓ MCP server is up (HTTP {resp.status})") except Exception as e: print(f" ✗ MCP server unreachable: {e}") return 1 # Initialize MCP session print(f"\nInitializing MCP session...") client = McpClient() try: client.initialize() print(f" ✓ Session established (id={client.session_id})") except Exception as e: print(f" ✗ Failed to initialize: {e}") return 1 # Run tiers all_pass = True tier_fail = False for i, tier in enumerate(TIERS): print(f"\n{'=' * 60}") print(f"TIER {i}: {', '.join(tier)}") print(f"{'=' * 60}") try: for rt in tier: print(f"\n--- {rt} ---") if not test_lifecycle(client, rt): all_pass = False tier_fail = True except Exception as e: print(f"\n ✗ TIER {i} ABORTED: {e}") all_pass = False tier_fail = True if tier_fail: print(f"\n ⚠ Tier {i} had failures — cleaning up and aborting remaining tiers") cleanup(client) break # Final sweep print(f"\n{'=' * 60}") print(f"FINAL SWEEP — all created IDs must error on get") print(f"{'=' * 60}") sweep_ok = final_sweep(client) # Audit table print(f"\n{'=' * 60}") print(f"AUDIT TABLE") print(f"{'=' * 60}") print(f"{'Type':<20} {'Created?':<10} {'ID':<42} {'Updated?':<10} {'Deleted?':<10} {'Gone?':<10}") print(f"{'-'*20} {'-'*10} {'-'*42} {'-'*10} {'-'*10} {'-'*10}") for tier in TIERS: for rt in tier: r = RESULTS.get(rt, {}) created = "✓" if r.get("created") else "✗" rid = r.get("id", "—") or "—" updated = "✓" if r.get("updated") else ("—" if not RESOURCE_DEFS[rt]["has_update"] else "✗") deleted = "✓" if r.get("deleted") else "✗" gone = "✓" if r.get("verified_gone") else "✗" print(f"{rt:<20} {created:<10} {rid:<42} {updated:<10} {deleted:<10} {gone:<10}") print(f"\n{'=' * 60}") if all_pass and sweep_ok: print("ALL TESTS PASSED ✓") else: print("SOME TESTS FAILED ✗") print(f"{'=' * 60}") return 0 if all_pass and sweep_ok else 1 if __name__ == "__main__": sys.exit(main())