From f40f8be04c32446841d6ec028c698f5c61116570 Mon Sep 17 00:00:00 2001 From: nearxos Date: Fri, 12 Jun 2026 22:54:09 +0300 Subject: [PATCH] Initial asterisk-mcp: MCP server for vessel troubleshoot stack. Part of GeneseasX golden-vessel tooling (see homelab-vault Projects/vessel-troubleshoot-stack). --- .env.example | 7 ++ .gitignore | 7 ++ README.md | 39 ++++++ pyproject.toml | 23 ++++ src/asterisk_mcp/__init__.py | 1 + src/asterisk_mcp/__main__.py | 4 + src/asterisk_mcp/client.py | 97 +++++++++++++++ src/asterisk_mcp/config.py | 17 +++ src/asterisk_mcp/guards.py | 9 ++ src/asterisk_mcp/server.py | 43 +++++++ src/asterisk_mcp/targets.py | 183 +++++++++++++++++++++++++++++ src/asterisk_mcp/targets_store.py | 83 +++++++++++++ src/asterisk_mcp/tools/__init__.py | 9 ++ src/asterisk_mcp/tools/cli.py | 38 ++++++ src/asterisk_mcp/tools/config.py | 47 ++++++++ src/asterisk_mcp/tools/targets.py | 56 +++++++++ 16 files changed, 663 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/asterisk_mcp/__init__.py create mode 100644 src/asterisk_mcp/__main__.py create mode 100644 src/asterisk_mcp/client.py create mode 100644 src/asterisk_mcp/config.py create mode 100644 src/asterisk_mcp/guards.py create mode 100644 src/asterisk_mcp/server.py create mode 100644 src/asterisk_mcp/targets.py create mode 100644 src/asterisk_mcp/targets_store.py create mode 100644 src/asterisk_mcp/tools/__init__.py create mode 100644 src/asterisk_mcp/tools/cli.py create mode 100644 src/asterisk_mcp/tools/config.py create mode 100644 src/asterisk_mcp/tools/targets.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..377e025 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +ASTERISK_SSH_HOST=10.20.30.222 +ASTERISK_SSH_USER=root +ASTERISK_SSH_PORT=22 +ASTERISK_SSH_KEY_PATH=~/.ssh/id_ed25519 +ASTERISK_CONTAINER=asterisk +ASTERISK_ALLOW_WRITES=false +ASTERISK_TARGETS_PATH=~/.local/share/asterisk-mcp/targets.json 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..9f8603e --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# asterisk-mcp + +MCP server for **Asterisk in Docker** on the ship VM (`shipPortal`). SSH + `docker exec` + `asterisk -rx`. + +## Quick start + +```bash +cd ~/Projects/asterisk-mcp +python3 -m venv .venv && .venv/bin/pip install -e . +``` + +### Connect + +```text +asterisk_connect( + ssh_host="10.20.30.222", + ssh_key_path="/Users/nearxos/.ssh/id_ed25519", + container="asterisk", + name="golden-asterisk" +) +asterisk_test_connection() +asterisk_pjsip_show_endpoints() +``` + +## Tools + +| Tool | Description | +|------|-------------| +| `asterisk_connect` / `asterisk_use_target` | Persisted targets | +| `asterisk_cli` | Arbitrary `asterisk -rx` | +| `asterisk_pjsip_show_endpoints` | Registration state | +| `asterisk_core_show_channels` | Active calls | +| `asterisk_get_logs` | Tail `/var/log/asterisk/full` | +| `asterisk_read_config` | Read file in container | +| `asterisk_module_reload` | Reload module (writes) | + +## License + +MIT diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e8ccc51 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "asterisk-mcp" +version = "1.0.0" +description = "Model Context Protocol server for Asterisk in Docker on ship VM" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +keywords = ["mcp", "asterisk", "voip", "docker"] +dependencies = [ + "mcp>=1.0.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", +] + +[project.scripts] +asterisk-mcp = "asterisk_mcp.server:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/asterisk_mcp"] diff --git a/src/asterisk_mcp/__init__.py b/src/asterisk_mcp/__init__.py new file mode 100644 index 0000000..5becc17 --- /dev/null +++ b/src/asterisk_mcp/__init__.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/src/asterisk_mcp/__main__.py b/src/asterisk_mcp/__main__.py new file mode 100644 index 0000000..1895af3 --- /dev/null +++ b/src/asterisk_mcp/__main__.py @@ -0,0 +1,4 @@ +from asterisk_mcp.server import main + +if __name__ == "__main__": + main() diff --git a/src/asterisk_mcp/client.py b/src/asterisk_mcp/client.py new file mode 100644 index 0000000..2fa5d08 --- /dev/null +++ b/src/asterisk_mcp/client.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import os +import subprocess +from dataclasses import dataclass + +from asterisk_mcp.config import settings +from asterisk_mcp.guards import GuardError + + +class AsteriskExecError(Exception): + def __init__(self, message: str, returncode: int = 1, stderr: str = ""): + super().__init__(message) + self.returncode = returncode + self.stderr = stderr + + +@dataclass +class ConnectionInfo: + ssh_host: str + ssh_user: str + ssh_port: int + ssh_key_path: str + container: str + + def public_view(self) -> dict: + return { + "ssh_host": self.ssh_host, + "ssh_user": self.ssh_user, + "ssh_port": self.ssh_port, + "container": self.container, + "ssh_key_configured": bool(self.ssh_key_path), + } + + +class AsteriskClient: + def __init__(self, conn: ConnectionInfo, timeout: float | None = None): + self.conn = conn + self.timeout = timeout or settings.command_timeout + + def _ssh_base(self) -> list[str]: + if not self.conn.ssh_host: + raise GuardError("ssh_host is required.") + cmd = [ + "ssh", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "-p", + str(self.conn.ssh_port), + ] + key = os.path.expanduser(self.conn.ssh_key_path) if self.conn.ssh_key_path else "" + if key and os.path.exists(key): + cmd.extend(["-i", key]) + cmd.append(f"{self.conn.ssh_user}@{self.conn.ssh_host}") + return cmd + + def docker_exec(self, remote_command: str) -> str: + wrapped = f"docker exec {self.conn.container} sh -lc {self._shell_quote(remote_command)}" + cmd = self._ssh_base() + [wrapped] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=self.timeout) + if proc.returncode != 0: + raise AsteriskExecError( + proc.stderr.strip() or proc.stdout.strip() or "docker exec failed", + returncode=proc.returncode, + stderr=proc.stderr, + ) + return proc.stdout.strip() + + def asterisk_rx(self, command: str) -> str: + safe = command.replace('"', '\\"') + return self.docker_exec(f'asterisk -rx "{safe}"') + + def read_file(self, path: str) -> str: + return self.docker_exec(f"cat {self._shell_quote(path)}") + + def tail_log(self, path: str = "/var/log/asterisk/full", lines: int = 100) -> str: + return self.docker_exec(f"tail -n {int(lines)} {self._shell_quote(path)}") + + @staticmethod + def _shell_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def get_client(target: str = "", ssh_host: str = "", ssh_user: str = "", ssh_port: int = 0, ssh_key_path: str = "", container: str = "") -> AsteriskClient: + from asterisk_mcp.targets import resolve_connection + + conn = resolve_connection( + target=target, + ssh_host=ssh_host, + ssh_user=ssh_user, + ssh_port=ssh_port, + ssh_key_path=ssh_key_path, + container=container, + ) + return AsteriskClient(conn) diff --git a/src/asterisk_mcp/config.py b/src/asterisk_mcp/config.py new file mode 100644 index 0000000..a42096c --- /dev/null +++ b/src/asterisk_mcp/config.py @@ -0,0 +1,17 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="ASTERISK_", extra="ignore") + + ssh_host: str = "" + ssh_user: str = "root" + ssh_port: int = 22 + ssh_key_path: str = "" + container: str = "asterisk" + allow_writes: bool = False + targets_path: str = "~/.local/share/asterisk-mcp/targets.json" + command_timeout: float = 30.0 + + +settings = Settings() diff --git a/src/asterisk_mcp/guards.py b/src/asterisk_mcp/guards.py new file mode 100644 index 0000000..7f82af7 --- /dev/null +++ b/src/asterisk_mcp/guards.py @@ -0,0 +1,9 @@ +class GuardError(Exception): + pass + + +def require_writes() -> None: + from asterisk_mcp.config import settings + + if not settings.allow_writes: + raise GuardError("Write operations disabled. Set ASTERISK_ALLOW_WRITES=true.") diff --git a/src/asterisk_mcp/server.py b/src/asterisk_mcp/server.py new file mode 100644 index 0000000..67df94e --- /dev/null +++ b/src/asterisk_mcp/server.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import json + +from mcp.server.fastmcp import FastMCP + +from asterisk_mcp import __version__ +from asterisk_mcp.config import settings +from asterisk_mcp.tools import register_all + +mcp = FastMCP( + "asterisk", + instructions=( + "Asterisk MCP via SSH to ship Docker VM and docker exec into container. " + "Use asterisk_connect / asterisk_use_target. Reload requires ASTERISK_ALLOW_WRITES=true." + ), +) + + +@mcp.tool() +def asterisk_get_config() -> str: + from asterisk_mcp.targets import registry + + return json.dumps( + { + "allow_writes": settings.allow_writes, + "active_target": registry.active().name if registry.active() else None, + "targets": registry.list(), + "version": __version__, + }, + indent=2, + ) + + +register_all(mcp) + + +def main() -> None: + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/src/asterisk_mcp/targets.py b/src/asterisk_mcp/targets.py new file mode 100644 index 0000000..5622463 --- /dev/null +++ b/src/asterisk_mcp/targets.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +from asterisk_mcp.client import AsteriskClient, ConnectionInfo +from asterisk_mcp.config import settings +from asterisk_mcp.guards import GuardError + + +@dataclass +class TargetConfig: + name: str + ssh_host: str + ssh_user: str = "root" + ssh_port: int = 22 + ssh_key_path: str = "" + container: str = "asterisk" + description: str = "" + registered_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + + def to_connection(self) -> ConnectionInfo: + return ConnectionInfo( + ssh_host=self.ssh_host, + ssh_user=self.ssh_user, + ssh_port=self.ssh_port, + ssh_key_path=self.ssh_key_path, + container=self.container, + ) + + def public_view(self) -> dict[str, Any]: + return { + "name": self.name, + **self.to_connection().public_view(), + "description": self.description, + "registered_at": self.registered_at, + } + + +class TargetRegistry: + def __init__(self) -> None: + self._targets: dict[str, TargetConfig] = {} + self._active: str | None = None + self._loading: bool = False + + @staticmethod + def _normalize_name(name: str) -> str: + name = re.sub(r"[^a-z0-9_-]+", "-", name.strip().lower()) + name = re.sub(r"-+", "-", name).strip("-") + if not name: + raise GuardError("Target name must contain at least one alphanumeric character.") + return name + + def register( + self, + *, + name: str = "", + ssh_host: str, + ssh_user: str = "root", + ssh_port: int = 22, + ssh_key_path: str = "", + container: str = "asterisk", + description: str = "", + registered_at: str = "", + set_active: bool = True, + ) -> TargetConfig: + if not ssh_host.strip(): + raise GuardError("ssh_host is required.") + target_name = self._normalize_name(name) if name.strip() else self._normalize_name(ssh_host) + cfg_kwargs: dict[str, Any] = { + "name": target_name, + "ssh_host": ssh_host.strip(), + "ssh_user": ssh_user, + "ssh_port": ssh_port, + "ssh_key_path": ssh_key_path, + "container": container, + "description": description, + } + if registered_at.strip(): + cfg_kwargs["registered_at"] = registered_at.strip() + cfg = TargetConfig(**cfg_kwargs) + self._targets[target_name] = cfg + if set_active: + self._active = target_name + self._persist() + return cfg + + def unregister(self, name: str) -> None: + key = self._normalize_name(name) + if key not in self._targets: + raise GuardError(f"Unknown target '{name}'.") + del self._targets[key] + if self._active == key: + self._active = next(iter(self._targets), None) + self._persist() + + def set_active(self, name: str) -> TargetConfig: + key = self._normalize_name(name) + if key not in self._targets: + raise GuardError(f"Unknown target '{name}'.") + self._active = key + self._persist() + return self._targets[key] + + def active(self) -> TargetConfig | None: + if self._active and self._active in self._targets: + return self._targets[self._active] + return None + + def list(self) -> list[dict[str, Any]]: + active = self._active + rows = [] + for cfg in self._targets.values(): + row = cfg.public_view() + row["active"] = cfg.name == active + rows.append(row) + return sorted(rows, key=lambda r: r["name"]) + + def resolve( + self, + *, + target: str = "", + ssh_host: str = "", + ssh_user: str = "", + ssh_port: int = 0, + ssh_key_path: str = "", + container: str = "", + ) -> TargetConfig: + if ssh_host.strip(): + return TargetConfig( + name="__inline__", + ssh_host=ssh_host, + ssh_user=ssh_user or settings.ssh_user, + ssh_port=ssh_port or settings.ssh_port, + ssh_key_path=ssh_key_path or settings.ssh_key_path, + container=container or settings.container, + ) + if target.strip(): + return self.get(target) + active = self.active() + if active: + return active + if settings.ssh_host: + return TargetConfig( + name="__env__", + ssh_host=settings.ssh_host, + ssh_user=settings.ssh_user, + ssh_port=settings.ssh_port, + ssh_key_path=settings.ssh_key_path, + container=settings.container, + ) + raise GuardError("No Asterisk target connected. Call asterisk_connect first.") + + def _persist(self) -> None: + if self._loading: + return + from asterisk_mcp.targets_store import save_registry + + save_registry(self) + + def get(self, name: str) -> TargetConfig: + key = self._normalize_name(name) + if key not in self._targets: + raise GuardError(f"Unknown target '{name}'.") + return self._targets[key] + + +registry = TargetRegistry() + + +def _load_persisted_targets() -> None: + from asterisk_mcp.targets_store import load_registry + + load_registry(registry) + + +_load_persisted_targets() + + +def resolve_connection(**kwargs: Any) -> ConnectionInfo: + return registry.resolve(**kwargs).to_connection() diff --git a/src/asterisk_mcp/targets_store.py b/src/asterisk_mcp/targets_store.py new file mode 100644 index 0000000..299f555 --- /dev/null +++ b/src/asterisk_mcp/targets_store.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from asterisk_mcp.targets import TargetRegistry + + +def targets_path() -> Path: + from asterisk_mcp.config import settings + + return Path(os.path.expanduser(settings.targets_path)) + + +def load_registry(registry: TargetRegistry) -> None: + path = targets_path() + if not path.exists(): + return + try: + data = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return + registry._loading = True + loaded_active: str | None = None + for entry in data.get("targets", []): + if not isinstance(entry, dict): + continue + name = str(entry.get("name", "")).strip() + host = str(entry.get("ssh_host", "")).strip() + if not name or not host: + continue + try: + registry.register( + name=name, + ssh_host=host, + ssh_user=str(entry.get("ssh_user", "root")), + ssh_port=int(entry.get("ssh_port", 22)), + ssh_key_path=str(entry.get("ssh_key_path", "")), + container=str(entry.get("container", "asterisk")), + description=str(entry.get("description", "")), + registered_at=str(entry.get("registered_at", "")), + set_active=False, + ) + except Exception: + continue + active = str(data.get("active", "")).strip() + if active: + try: + registry.set_active(active) + loaded_active = active + except Exception: + pass + if not loaded_active and registry.list(): + registry.set_active(registry.list()[0]["name"]) + registry._loading = False + + +def save_registry(registry: TargetRegistry) -> None: + path = targets_path() + path.parent.mkdir(parents=True, exist_ok=True) + targets: list[dict[str, Any]] = [] + for cfg in registry._targets.values(): + targets.append( + { + "name": cfg.name, + "ssh_host": cfg.ssh_host, + "ssh_user": cfg.ssh_user, + "ssh_port": cfg.ssh_port, + "ssh_key_path": cfg.ssh_key_path, + "container": cfg.container, + "description": cfg.description, + "registered_at": cfg.registered_at, + } + ) + payload = {"active": registry._active, "targets": sorted(targets, key=lambda r: r["name"])} + path.write_text(json.dumps(payload, indent=2) + "\n") + try: + path.chmod(0o600) + except OSError: + pass diff --git a/src/asterisk_mcp/tools/__init__.py b/src/asterisk_mcp/tools/__init__.py new file mode 100644 index 0000000..06e1938 --- /dev/null +++ b/src/asterisk_mcp/tools/__init__.py @@ -0,0 +1,9 @@ +from mcp.server.fastmcp import FastMCP + +from asterisk_mcp.tools import cli, config, targets + + +def register_all(mcp: FastMCP) -> None: + targets.register(mcp) + cli.register(mcp) + config.register(mcp) diff --git a/src/asterisk_mcp/tools/cli.py b/src/asterisk_mcp/tools/cli.py new file mode 100644 index 0000000..09a3fb8 --- /dev/null +++ b/src/asterisk_mcp/tools/cli.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import json + +from mcp.server.fastmcp import FastMCP + +from asterisk_mcp.client import AsteriskExecError, get_client +from asterisk_mcp.guards import GuardError, require_writes + + +def register(mcp: FastMCP) -> None: + @mcp.tool() + def asterisk_cli(command: str, target: str = "") -> str: + """Run arbitrary Asterisk CLI command (read-only recommended).""" + try: + client = get_client(target=target) + output = client.asterisk_rx(command) + return json.dumps({"status": "ok", "command": command, "output": output}, indent=2) + except (GuardError, AsteriskExecError) as exc: + return json.dumps({"status": "error", "message": str(exc)}, indent=2) + + @mcp.tool() + def asterisk_pjsip_show_endpoints(target: str = "") -> str: + return asterisk_cli("pjsip show endpoints", target=target) + + @mcp.tool() + def asterisk_core_show_channels(target: str = "") -> str: + return asterisk_cli("core show channels", target=target) + + @mcp.tool() + def asterisk_pjsip_show_registrations(target: str = "") -> str: + return asterisk_cli("pjsip show registrations", target=target) + + @mcp.tool() + def asterisk_module_reload(module: str, target: str = "") -> str: + """Reload Asterisk module (e.g. res_pjsip). Requires ASTERISK_ALLOW_WRITES=true.""" + require_writes() + return asterisk_cli(f"module reload {module}", target=target) diff --git a/src/asterisk_mcp/tools/config.py b/src/asterisk_mcp/tools/config.py new file mode 100644 index 0000000..08e6cdb --- /dev/null +++ b/src/asterisk_mcp/tools/config.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import json + +from mcp.server.fastmcp import FastMCP + +from asterisk_mcp.client import AsteriskExecError, get_client +from asterisk_mcp.guards import GuardError + + +def register(mcp: FastMCP) -> None: + @mcp.tool() + def asterisk_get_logs(lines: int = 100, target: str = "") -> str: + try: + client = get_client(target=target) + output = client.tail_log(lines=lines) + return json.dumps({"status": "ok", "lines": lines, "output": output}, indent=2) + except (GuardError, AsteriskExecError) as exc: + return json.dumps({"status": "error", "message": str(exc)}, indent=2) + + @mcp.tool() + def asterisk_read_config(path: str, target: str = "") -> str: + """Read config file inside container (read-only).""" + try: + client = get_client(target=target) + content = client.read_file(path) + return json.dumps({"status": "ok", "path": path, "content": content}, indent=2) + except (GuardError, AsteriskExecError) as exc: + return json.dumps({"status": "error", "message": str(exc)}, indent=2) + + @mcp.tool() + def asterisk_inspect_pjsip_transport(target: str = "") -> str: + try: + client = get_client(target=target) + chunks = {} + for cmd in ( + "pjsip show transports", + "pjsip show endpoint", + "pjsip show aors", + ): + try: + chunks[cmd] = client.asterisk_rx(cmd) + except AsteriskExecError as exc: + chunks[cmd] = f"error: {exc}" + return json.dumps({"status": "ok", "output": chunks}, indent=2) + except GuardError as exc: + return json.dumps({"status": "error", "message": str(exc)}, indent=2) diff --git a/src/asterisk_mcp/tools/targets.py b/src/asterisk_mcp/tools/targets.py new file mode 100644 index 0000000..b5cbf11 --- /dev/null +++ b/src/asterisk_mcp/tools/targets.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import json + +from mcp.server.fastmcp import FastMCP + +from asterisk_mcp.client import AsteriskExecError, get_client +from asterisk_mcp.guards import GuardError +from asterisk_mcp.targets import registry + + +def register(mcp: FastMCP) -> None: + @mcp.tool() + def asterisk_connect( + ssh_host: str, + name: str = "", + ssh_user: str = "root", + ssh_port: int = 22, + ssh_key_path: str = "", + container: str = "asterisk", + description: str = "", + set_active: bool = True, + ) -> str: + """Register Asterisk Docker target on ship VM (SSH + docker exec).""" + cfg = registry.register( + name=name, + ssh_host=ssh_host, + ssh_user=ssh_user, + ssh_port=ssh_port, + ssh_key_path=ssh_key_path, + container=container, + description=description, + set_active=set_active, + ) + return json.dumps({"status": "connected", "target": cfg.public_view()}, indent=2) + + @mcp.tool() + def asterisk_use_target(name: str) -> str: + cfg = registry.set_active(name) + return json.dumps({"status": "ok", "active": cfg.public_view()}, indent=2) + + @mcp.tool() + def asterisk_list_targets() -> str: + return json.dumps( + {"active": registry.active().name if registry.active() else None, "targets": registry.list()}, + indent=2, + ) + + @mcp.tool() + def asterisk_test_connection(target: str = "", ssh_host: str = "") -> str: + try: + client = get_client(target=target, ssh_host=ssh_host) + out = client.asterisk_rx("core show version") + return json.dumps({"status": "ok", "version": out}, indent=2) + except (GuardError, AsteriskExecError) as exc: + return json.dumps({"status": "error", "message": str(exc)}, indent=2)