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:
7
.env.example
Normal file
7
.env.example
Normal 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
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.env
|
||||
.venv/
|
||||
dist/
|
||||
build/
|
||||
39
README.md
Normal file
39
README.md
Normal 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
23
pyproject.toml
Normal 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"]
|
||||
1
src/asterisk_mcp/__init__.py
Normal file
1
src/asterisk_mcp/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__version__ = "1.0.0"
|
||||
4
src/asterisk_mcp/__main__.py
Normal file
4
src/asterisk_mcp/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from asterisk_mcp.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
97
src/asterisk_mcp/client.py
Normal file
97
src/asterisk_mcp/client.py
Normal 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)
|
||||
17
src/asterisk_mcp/config.py
Normal file
17
src/asterisk_mcp/config.py
Normal 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()
|
||||
9
src/asterisk_mcp/guards.py
Normal file
9
src/asterisk_mcp/guards.py
Normal 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.")
|
||||
43
src/asterisk_mcp/server.py
Normal file
43
src/asterisk_mcp/server.py
Normal 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
183
src/asterisk_mcp/targets.py
Normal 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()
|
||||
83
src/asterisk_mcp/targets_store.py
Normal file
83
src/asterisk_mcp/targets_store.py
Normal 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
|
||||
9
src/asterisk_mcp/tools/__init__.py
Normal file
9
src/asterisk_mcp/tools/__init__.py
Normal 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)
|
||||
38
src/asterisk_mcp/tools/cli.py
Normal file
38
src/asterisk_mcp/tools/cli.py
Normal 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)
|
||||
47
src/asterisk_mcp/tools/config.py
Normal file
47
src/asterisk_mcp/tools/config.py
Normal 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)
|
||||
56
src/asterisk_mcp/tools/targets.py
Normal file
56
src/asterisk_mcp/tools/targets.py
Normal 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)
|
||||
Reference in New Issue
Block a user