Initial vessel-orchestrator-mcp: MCP server for vessel troubleshoot stack.

Part of GeneseasX golden-vessel tooling (see homelab-vault Projects/vessel-troubleshoot-stack).
This commit is contained in:
2026-06-12 22:54:10 +03:00
commit 9528eb00b1
14 changed files with 560 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
*.egg-info/
.env
.venv/
dist/
build/

25
README.md Normal file
View File

@@ -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

24
pyproject.toml Normal file
View File

@@ -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"]

View File

@@ -0,0 +1 @@
__version__ = "1.0.0"

View File

@@ -0,0 +1,4 @@
from vessel_orchestrator.server import main
if __name__ == "__main__":
main()

View File

@@ -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()

View File

@@ -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),
}

View File

@@ -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.",
}

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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,
)