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:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
25
README.md
Normal file
25
README.md
Normal 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
24
pyproject.toml
Normal 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"]
|
||||||
1
src/vessel_orchestrator/__init__.py
Normal file
1
src/vessel_orchestrator/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__version__ = "1.0.0"
|
||||||
4
src/vessel_orchestrator/__main__.py
Normal file
4
src/vessel_orchestrator/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from vessel_orchestrator.server import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
14
src/vessel_orchestrator/config.py
Normal file
14
src/vessel_orchestrator/config.py
Normal 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()
|
||||||
94
src/vessel_orchestrator/health.py
Normal file
94
src/vessel_orchestrator/health.py
Normal 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),
|
||||||
|
}
|
||||||
75
src/vessel_orchestrator/playbooks.py
Normal file
75
src/vessel_orchestrator/playbooks.py
Normal 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.",
|
||||||
|
}
|
||||||
43
src/vessel_orchestrator/server.py
Normal file
43
src/vessel_orchestrator/server.py
Normal 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()
|
||||||
141
src/vessel_orchestrator/stacks.py
Normal file
141
src/vessel_orchestrator/stacks.py
Normal 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()
|
||||||
9
src/vessel_orchestrator/tools/__init__.py
Normal file
9
src/vessel_orchestrator/tools/__init__.py
Normal 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)
|
||||||
23
src/vessel_orchestrator/tools/health.py
Normal file
23
src/vessel_orchestrator/tools/health.py
Normal 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)
|
||||||
59
src/vessel_orchestrator/tools/playbooks.py
Normal file
59
src/vessel_orchestrator/tools/playbooks.py
Normal 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,
|
||||||
|
)
|
||||||
41
src/vessel_orchestrator/tools/stacks.py
Normal file
41
src/vessel_orchestrator/tools/stacks.py
Normal 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,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user