From 9528eb00b1ff9c0e23cc361ea1105f6215398da9 Mon Sep 17 00:00:00 2001 From: nearxos Date: Fri, 12 Jun 2026 22:54:10 +0300 Subject: [PATCH] Initial vessel-orchestrator-mcp: MCP server for vessel troubleshoot stack. Part of GeneseasX golden-vessel tooling (see homelab-vault Projects/vessel-troubleshoot-stack). --- .gitignore | 7 + README.md | 25 ++++ pyproject.toml | 24 ++++ src/vessel_orchestrator/__init__.py | 1 + src/vessel_orchestrator/__main__.py | 4 + src/vessel_orchestrator/config.py | 14 ++ src/vessel_orchestrator/health.py | 94 ++++++++++++++ src/vessel_orchestrator/playbooks.py | 75 +++++++++++ src/vessel_orchestrator/server.py | 43 +++++++ src/vessel_orchestrator/stacks.py | 141 +++++++++++++++++++++ src/vessel_orchestrator/tools/__init__.py | 9 ++ src/vessel_orchestrator/tools/health.py | 23 ++++ src/vessel_orchestrator/tools/playbooks.py | 59 +++++++++ src/vessel_orchestrator/tools/stacks.py | 41 ++++++ 14 files changed, 560 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/vessel_orchestrator/__init__.py create mode 100644 src/vessel_orchestrator/__main__.py create mode 100644 src/vessel_orchestrator/config.py create mode 100644 src/vessel_orchestrator/health.py create mode 100644 src/vessel_orchestrator/playbooks.py create mode 100644 src/vessel_orchestrator/server.py create mode 100644 src/vessel_orchestrator/stacks.py create mode 100644 src/vessel_orchestrator/tools/__init__.py create mode 100644 src/vessel_orchestrator/tools/health.py create mode 100644 src/vessel_orchestrator/tools/playbooks.py create mode 100644 src/vessel_orchestrator/tools/stacks.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c068ad8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.env +.venv/ +dist/ +build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..79630a4 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# vessel-orchestrator-mcp + +Orchestrator MCP for the **GeneseasX vessel stack**: Proxmox → pfSense → Docker VM → Asterisk. + +## Tools + +| Tool | Description | +|------|-------------| +| `vessel_register_stack` | Map golden targets into one stack | +| `vessel_health_snapshot` | HTTP health for pfSense + Proxmox | +| `vessel_route_symptom` | Symptom → playbook + layer | +| `vessel_playbook_run` | Step list for one-way-audio, no-registration, etc. | +| `vessel_memory_context` | memory_search + Obsidian paths for agent | + +## Default stack: `golden-vessel` + +| Layer | Target | +|-------|--------| +| pfSense | `golden-pfsense` | +| Proxmox | `golden-proxmox` | +| Asterisk | `golden-asterisk` | + +## License + +MIT diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6d4bbd3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "vessel-orchestrator-mcp" +version = "1.0.0" +description = "Orchestrator MCP for vessel GeneseasX troubleshoot stack" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +keywords = ["mcp", "vessel", "orchestrator", "voip"] +dependencies = [ + "mcp>=1.0.0", + "httpx>=0.27.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", +] + +[project.scripts] +vessel-orchestrator-mcp = "vessel_orchestrator.server:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/vessel_orchestrator"] diff --git a/src/vessel_orchestrator/__init__.py b/src/vessel_orchestrator/__init__.py new file mode 100644 index 0000000..5becc17 --- /dev/null +++ b/src/vessel_orchestrator/__init__.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/src/vessel_orchestrator/__main__.py b/src/vessel_orchestrator/__main__.py new file mode 100644 index 0000000..ca5ab66 --- /dev/null +++ b/src/vessel_orchestrator/__main__.py @@ -0,0 +1,4 @@ +from vessel_orchestrator.server import main + +if __name__ == "__main__": + main() diff --git a/src/vessel_orchestrator/config.py b/src/vessel_orchestrator/config.py new file mode 100644 index 0000000..f0656b0 --- /dev/null +++ b/src/vessel_orchestrator/config.py @@ -0,0 +1,14 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="VESSEL_", extra="ignore") + + stacks_path: str = "~/.local/share/vessel-orchestrator/stacks.json" + pfsense_targets_path: str = "~/.local/share/pfsense-mcp/targets.json" + proxmox_targets_path: str = "~/.local/share/proxmox-mcp/targets.json" + asterisk_targets_path: str = "~/.local/share/asterisk-mcp/targets.json" + memory_project_id: str = "asterisk-troubleshoot" + + +settings = Settings() diff --git a/src/vessel_orchestrator/health.py b/src/vessel_orchestrator/health.py new file mode 100644 index 0000000..6e2d967 --- /dev/null +++ b/src/vessel_orchestrator/health.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +import httpx + +from vessel_orchestrator.config import settings +from vessel_orchestrator.stacks import StackConfig + + +def _load_targets(path: str) -> dict[str, Any]: + p = Path(os.path.expanduser(path)) + if not p.exists(): + return {} + try: + return json.loads(p.read_text()) + except (OSError, json.JSONDecodeError): + return {} + + +def _find_target(targets_data: dict, name: str) -> dict | None: + for entry in targets_data.get("targets", []): + if entry.get("name") == name: + return entry + return None + + +def check_pfsense(target_name: str) -> dict[str, Any]: + data = _load_targets(settings.pfsense_targets_path) + entry = _find_target(data, target_name) + if not entry: + return {"status": "error", "message": f"pfSense target '{target_name}' not in targets.json"} + url = entry["url"].rstrip("/") + api_key = entry.get("api_key", "") + try: + with httpx.Client(verify=entry.get("verify_ssl", False), timeout=30) as client: + r = client.get( + f"{url}/api/v2/status/system", + headers={"X-API-Key": api_key}, + ) + if r.status_code >= 400: + return {"status": "error", "http": r.status_code, "url": url} + body = r.json() + return {"status": "ok", "url": url, "system": body.get("data", body)} + except Exception as exc: + return {"status": "error", "message": str(exc), "url": url} + + +def check_proxmox(target_name: str) -> dict[str, Any]: + data = _load_targets(settings.proxmox_targets_path) + entry = _find_target(data, target_name) + if not entry: + return {"status": "skipped", "message": f"Proxmox target '{target_name}' not configured"} + url = entry["url"].rstrip("/") + token_id = entry.get("token_id", "") + token_secret = entry.get("token_secret", "") + if not token_id or not token_secret: + return {"status": "skipped", "message": "Proxmox token not set in targets.json"} + try: + with httpx.Client(verify=entry.get("verify_ssl", False), timeout=30) as client: + r = client.get( + f"{url}/api2/json/version", + headers={"Authorization": f"PVEAPIToken={token_id}={token_secret}"}, + ) + if r.status_code >= 400: + return {"status": "error", "http": r.status_code} + return {"status": "ok", "url": url, "version": r.json().get("data")} + except Exception as exc: + return {"status": "error", "message": str(exc)} + + +def check_asterisk(target_name: str) -> dict[str, Any]: + data = _load_targets(settings.asterisk_targets_path) + entry = _find_target(data, target_name) + if not entry: + return {"status": "skipped", "message": f"Asterisk target '{target_name}' not configured"} + return { + "status": "configured", + "ssh_host": entry.get("ssh_host"), + "container": entry.get("container"), + "hint": "Run asterisk_test_connection via asterisk MCP (requires SSH from Mac/VPN).", + } + + +def health_snapshot(stack: StackConfig) -> dict[str, Any]: + return { + "stack": stack.public_view(), + "pfsense": check_pfsense(stack.pfsense_target), + "proxmox": check_proxmox(stack.proxmox_target), + "asterisk": check_asterisk(stack.asterisk_target), + } diff --git a/src/vessel_orchestrator/playbooks.py b/src/vessel_orchestrator/playbooks.py new file mode 100644 index 0000000..2545cb8 --- /dev/null +++ b/src/vessel_orchestrator/playbooks.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +PLAYBOOKS: dict[str, dict] = { + "one-way-audio": { + "symptoms": ["one-way audio", "caller hears but callee does not", "no audio inbound"], + "layers": ["pfsense", "asterisk"], + "steps": [ + "memory_search asterisk-troubleshoot: one-way audio RTP pfSense Business", + "pfsense_list_firewall_rules query=interface=vtnet3 — check UDP 10000-10029 pass to shipPortal", + "pfsense_list_port_forwards — confirm SIP/RTP NAT on Business if used", + "pfsense_get_firewall_log — filter blocked UDP 100xx from phone IP", + "asterisk_pjsip_show_endpoints — verify endpoint reachable", + "asterisk_read_config path for pjsip — local_net includes phone subnet + 10.20.30.0/24", + "asterisk_core_show_channels during live call", + "memory_upsert fix with tracker IDs and root cause", + ], + }, + "no-registration": { + "symptoms": ["phone won't register", "401", "403", "timeout on register"], + "layers": ["pfsense", "asterisk"], + "steps": [ + "pfsense_list_firewall_rules query=descr__contains=SIP or interface=vtnet3", + "pfsense_list_port_forwards — SIP 5060 redirect if applicable", + "asterisk_pjsip_show_registrations", + "asterisk_pjsip_show_endpoints", + "asterisk_get_logs lines=200", + ], + }, + "container-down": { + "symptoms": ["asterisk unreachable", "docker down", "ship portal down"], + "layers": ["proxmox", "asterisk"], + "steps": [ + "proxmox_list_qemu / proxmox_get_qemu_status for ship VM", + "asterisk_test_connection", + "asterisk_cli core show version", + "proxmox_get_task_log if recent restart", + ], + }, + "dns-captive-portal": { + "symptoms": ["dns fail", "captive portal", "crew no redirect"], + "layers": ["pfsense"], + "steps": [ + "pfsense_list_port_forwards query=interface=vtnet2", + "pfsense_list_firewall_rules — CREW floating rules order", + "pfsense_list_aliases name=GENESEASX_LOCAL_SERVICES", + ], + }, +} + +SYMPTOM_ROUTES: list[tuple[str, str, str]] = [ + ("one-way audio", "one-way-audio", "pfsense"), + ("no registration", "no-registration", "pfsense"), + ("register", "no-registration", "pfsense"), + ("container", "container-down", "proxmox"), + ("docker", "container-down", "proxmox"), + ("dns", "dns-captive-portal", "pfsense"), + ("captive", "dns-captive-portal", "pfsense"), + ("crew", "dns-captive-portal", "pfsense"), +] + + +def route_symptom(symptom: str) -> dict: + lower = symptom.lower() + for needle, playbook, layer in SYMPTOM_ROUTES: + if needle in lower: + return { + "playbook": playbook, + "first_layer": layer, + "playbook_detail": PLAYBOOKS.get(playbook, {}), + } + return { + "playbook": None, + "first_layer": "pfsense", + "hint": "No exact match. Start with pfsense_test_connection and asterisk_test_connection.", + } diff --git a/src/vessel_orchestrator/server.py b/src/vessel_orchestrator/server.py new file mode 100644 index 0000000..213188d --- /dev/null +++ b/src/vessel_orchestrator/server.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import json + +from mcp.server.fastmcp import FastMCP + +from vessel_orchestrator import __version__ +from vessel_orchestrator.config import settings +from vessel_orchestrator.stacks import registry +from vessel_orchestrator.tools import register_all + +mcp = FastMCP( + "vessel-orchestrator", + instructions=( + "Vessel GeneseasX orchestrator. Registers stacks that map to pfsense/proxmox/asterisk targets. " + "Use vessel_health_snapshot, vessel_route_symptom, vessel_playbook_run, vessel_memory_context. " + "Does not replace layer MCPs — composes playbooks and health checks." + ), +) + + +@mcp.tool() +def vessel_get_config() -> str: + return json.dumps( + { + "active_stack": registry.active().name if registry.active() else None, + "stacks": registry.list(), + "memory_project_id": settings.memory_project_id, + "version": __version__, + }, + indent=2, + ) + + +register_all(mcp) + + +def main() -> None: + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/src/vessel_orchestrator/stacks.py b/src/vessel_orchestrator/stacks.py new file mode 100644 index 0000000..34ba5a2 --- /dev/null +++ b/src/vessel_orchestrator/stacks.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from vessel_orchestrator.config import settings + + +@dataclass +class StackConfig: + name: str + pfsense_target: str = "golden-pfsense" + proxmox_target: str = "golden-proxmox" + asterisk_target: str = "golden-asterisk" + proxmox_node: str = "pve" + description: str = "" + registered_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + + def public_view(self) -> dict[str, Any]: + return { + "name": self.name, + "pfsense_target": self.pfsense_target, + "proxmox_target": self.proxmox_target, + "asterisk_target": self.asterisk_target, + "proxmox_node": self.proxmox_node, + "description": self.description, + "registered_at": self.registered_at, + } + + +class StackRegistry: + def __init__(self) -> None: + self._stacks: dict[str, StackConfig] = {} + self._active: str | None = None + self._loading = False + self._load() + + @staticmethod + def _normalize(name: str) -> str: + name = re.sub(r"[^a-z0-9_-]+", "-", name.strip().lower()).strip("-") + if not name: + raise ValueError("Stack name required") + return name + + def _path(self) -> Path: + return Path(os.path.expanduser(settings.stacks_path)) + + def _load(self) -> None: + path = self._path() + if not path.exists(): + return + try: + data = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return + self._loading = True + for entry in data.get("stacks", []): + if not isinstance(entry, dict): + continue + name = str(entry.get("name", "")).strip() + if not name: + continue + cfg = StackConfig( + name=self._normalize(name), + pfsense_target=str(entry.get("pfsense_target", "golden-pfsense")), + proxmox_target=str(entry.get("proxmox_target", "golden-proxmox")), + asterisk_target=str(entry.get("asterisk_target", "golden-asterisk")), + proxmox_node=str(entry.get("proxmox_node", "pve")), + description=str(entry.get("description", "")), + ) + if entry.get("registered_at"): + cfg.registered_at = str(entry["registered_at"]) + self._stacks[cfg.name] = cfg + active = str(data.get("active", "")).strip() + if active and active in self._stacks: + self._active = active + elif self._stacks: + self._active = next(iter(self._stacks)) + self._loading = False + + def _save(self) -> None: + if self._loading: + return + path = self._path() + path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "active": self._active, + "stacks": sorted( + [cfg.public_view() for cfg in self._stacks.values()], + key=lambda r: r["name"], + ), + } + path.write_text(json.dumps(payload, indent=2) + "\n") + try: + path.chmod(0o600) + except OSError: + pass + + def register(self, **kwargs: Any) -> StackConfig: + name = self._normalize(kwargs.get("name", "golden-vessel")) + cfg = StackConfig( + name=name, + pfsense_target=kwargs.get("pfsense_target", "golden-pfsense"), + proxmox_target=kwargs.get("proxmox_target", "golden-proxmox"), + asterisk_target=kwargs.get("asterisk_target", "golden-asterisk"), + proxmox_node=kwargs.get("proxmox_node", "pve"), + description=kwargs.get("description", ""), + ) + self._stacks[name] = cfg + self._active = name + self._save() + return cfg + + def set_active(self, name: str) -> StackConfig: + key = self._normalize(name) + if key not in self._stacks: + raise ValueError(f"Unknown stack '{name}'") + self._active = key + self._save() + return self._stacks[key] + + def active(self) -> StackConfig | None: + if self._active and self._active in self._stacks: + return self._stacks[self._active] + return None + + def list(self) -> list[dict[str, Any]]: + rows = [] + for cfg in self._stacks.values(): + row = cfg.public_view() + row["active"] = cfg.name == self._active + rows.append(row) + return sorted(rows, key=lambda r: r["name"]) + + +registry = StackRegistry() diff --git a/src/vessel_orchestrator/tools/__init__.py b/src/vessel_orchestrator/tools/__init__.py new file mode 100644 index 0000000..4d189be --- /dev/null +++ b/src/vessel_orchestrator/tools/__init__.py @@ -0,0 +1,9 @@ +from mcp.server.fastmcp import FastMCP + +from vessel_orchestrator.tools import health, playbooks, stacks + + +def register_all(mcp: FastMCP) -> None: + stacks.register(mcp) + health.register(mcp) + playbooks.register(mcp) diff --git a/src/vessel_orchestrator/tools/health.py b/src/vessel_orchestrator/tools/health.py new file mode 100644 index 0000000..d7dc325 --- /dev/null +++ b/src/vessel_orchestrator/tools/health.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import json + +from mcp.server.fastmcp import FastMCP + +from vessel_orchestrator.health import health_snapshot +from vessel_orchestrator.stacks import registry + + +def register(mcp: FastMCP) -> None: + @mcp.tool() + def vessel_health_snapshot(stack: str = "") -> str: + """Check pfSense (HTTP), Proxmox (HTTP), Asterisk (configured) for active or named stack.""" + cfg = registry.active() + if stack.strip(): + cfg = registry._stacks.get(registry._normalize(stack)) + if not cfg: + return json.dumps( + {"status": "error", "message": "No stack registered. Call vessel_register_stack first."}, + indent=2, + ) + return json.dumps(health_snapshot(cfg), indent=2, default=str) diff --git a/src/vessel_orchestrator/tools/playbooks.py b/src/vessel_orchestrator/tools/playbooks.py new file mode 100644 index 0000000..8206cd7 --- /dev/null +++ b/src/vessel_orchestrator/tools/playbooks.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import json + +from mcp.server.fastmcp import FastMCP + +from vessel_orchestrator.config import settings +from vessel_orchestrator.playbooks import PLAYBOOKS, route_symptom + + +def register(mcp: FastMCP) -> None: + @mcp.tool() + def vessel_route_symptom(symptom: str) -> str: + """Map a symptom description to playbook name and first MCP layer.""" + result = route_symptom(symptom) + return json.dumps(result, indent=2, default=str) + + @mcp.tool() + def vessel_playbook_run(name: str) -> str: + """Return ordered troubleshooting steps for a named playbook.""" + pb = PLAYBOOKS.get(name) + if not pb: + return json.dumps( + {"status": "error", "message": f"Unknown playbook '{name}'", "available": list(PLAYBOOKS)}, + indent=2, + ) + return json.dumps({"status": "ok", "playbook": name, **pb}, indent=2) + + @mcp.tool() + def vessel_list_playbooks() -> str: + return json.dumps({"playbooks": list(PLAYBOOKS.keys())}, indent=2) + + @mcp.tool() + def vessel_memory_context(symptom: str) -> str: + """Return memory_search guidance for Cursor agent (project-memory MCP).""" + route = route_symptom(symptom) + keywords = symptom.lower().split()[:6] + return json.dumps( + { + "project_id": settings.memory_project_id, + "also_search_global": True, + "memory_search_queries": [ + {"project_id": settings.memory_project_id, "query": " ".join(keywords)}, + {"project_id": "global", "query": " ".join(keywords[:4])}, + ], + "obsidian_paths": [ + "Meta/Agent-Memory/asterisk-troubleshoot.md", + "Meta/Open-Items.md", + "Projects/vessel-troubleshoot-stack.md", + ], + "route": route, + "upsert_when_done": { + "project_id": settings.memory_project_id, + "memory_type": "fix", + "importance": 5, + }, + }, + indent=2, + ) diff --git a/src/vessel_orchestrator/tools/stacks.py b/src/vessel_orchestrator/tools/stacks.py new file mode 100644 index 0000000..4d23688 --- /dev/null +++ b/src/vessel_orchestrator/tools/stacks.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import json + +from mcp.server.fastmcp import FastMCP + +from vessel_orchestrator.stacks import registry + + +def register(mcp: FastMCP) -> None: + @mcp.tool() + def vessel_register_stack( + name: str = "golden-vessel", + pfsense_target: str = "golden-pfsense", + proxmox_target: str = "golden-proxmox", + asterisk_target: str = "golden-asterisk", + proxmox_node: str = "pve", + description: str = "", + ) -> str: + """Register a named vessel stack mapping to layer MCP targets.""" + cfg = registry.register( + name=name, + pfsense_target=pfsense_target, + proxmox_target=proxmox_target, + asterisk_target=asterisk_target, + proxmox_node=proxmox_node, + description=description, + ) + return json.dumps({"status": "ok", "stack": cfg.public_view()}, indent=2) + + @mcp.tool() + def vessel_use_stack(name: str) -> str: + cfg = registry.set_active(name) + return json.dumps({"status": "ok", "active": cfg.public_view()}, indent=2) + + @mcp.tool() + def vessel_list_stacks() -> str: + return json.dumps( + {"active": registry.active().name if registry.active() else None, "stacks": registry.list()}, + indent=2, + )