602 lines
22 KiB
Python
602 lines
22 KiB
Python
#!/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())
|