Initial asterisk-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:09 +03:00
commit f40f8be04c
16 changed files with 663 additions and 0 deletions

7
.env.example Normal file
View File

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

7
.gitignore vendored Normal file
View File

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

39
README.md Normal file
View File

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

23
pyproject.toml Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

183
src/asterisk_mcp/targets.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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