"""SSH host configuration for Sympathy-MCP.
Reads a TOML config file that maps VM/host names to SSH connection details.
Config file location: ~/.config/sympathy-mcp/hosts.toml
Example hosts.toml:
[hosts.hermit-dev]
host = "10.10.10.79"
user = "root"
port = 22
# key = "~/.ssh/id_ed25519" # optional, uses ssh default if omitted
[hosts.pi-node-1]
host = "192.168.1.50"
user = "pi"
port = 22
key = "~/.ssh/pi_key"
"""
from __future__ import annotations
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
# Default config location
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "sympathy-mcp"
DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "hosts.toml"
@dataclass
class HostConfig:
"""SSH connection details for a single host."""
name: str
host: str # IP address or hostname
user: str = "root"
port: int = 22
key: str | None = None # Path to SSH private key (None = ssh default)
def ssh_args(self) -> list[str]:
"""Build SSH CLI arguments for this host.
Returns base args like: ["-o", "StrictHostKeyChecking=no", "-p", "22",
"-i", "/path/to/key", "root@10.10.10.79"]
The target (user@host) is the LAST element.
"""
args: list[str] = [
"-o", "StrictHostKeyChecking=accept-new",
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=10",
"-p", str(self.port),
]
if self.key:
expanded = str(Path(self.key).expanduser())
args.extend(["-i", expanded])
args.append(f"{self.user}@{self.host}")
return args
def scp_args(self) -> list[str]:
"""Build SCP CLI arguments for this host (without source/dest).
Returns args like: ["-o", "StrictHostKeyChecking=no", "-P", "22",
"-i", "/path/to/key"]
Note: SCP uses -P (uppercase) for port, SSH uses -p (lowercase).
"""
args: list[str] = [
"-o", "StrictHostKeyChecking=accept-new",
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=10",
"-P", str(self.port),
]
if self.key:
expanded = str(Path(self.key).expanduser())
args.extend(["-i", expanded])
return args
@property
def scp_prefix(self) -> str:
"""SCP target prefix, e.g. 'root@10.10.10.79:'."""
return f"{self.user}@{self.host}:"
@dataclass
class SympathyConfig:
"""Full Sympathy-MCP configuration."""
hosts: dict[str, HostConfig] = field(default_factory=dict)
config_path: Path = DEFAULT_CONFIG_PATH
def get_host(self, name: str) -> HostConfig:
"""Look up a host by name.
Raises:
KeyError: If the host is not configured.
"""
if name not in self.hosts:
available = ", ".join(sorted(self.hosts.keys())) or "(none)"
raise KeyError(
f"Host '{name}' not found in config. "
f"Available hosts: {available}. "
f"Config file: {self.config_path}"
)
return self.hosts[name]
@classmethod
def load(cls, path: Path | None = None) -> SympathyConfig:
"""Load configuration from a TOML file.
Args:
path: Path to the TOML config file.
Defaults to ~/.config/sympathy-mcp/hosts.toml
Returns:
SympathyConfig with all hosts loaded.
Returns empty config if the file doesn't exist.
"""
config_path = path or DEFAULT_CONFIG_PATH
if not config_path.exists():
return cls(config_path=config_path)
with open(config_path, "rb") as f:
data = tomllib.load(f)
hosts: dict[str, HostConfig] = {}
for name, host_data in data.get("hosts", {}).items():
hosts[name] = HostConfig(
name=name,
host=host_data.get("host", ""),
user=host_data.get("user", "root"),
port=host_data.get("port", 22),
key=host_data.get("key"),
)
return cls(hosts=hosts, config_path=config_path)
def save(self) -> None:
"""Save the current configuration to the TOML file.
Creates the config directory if needed.
"""
self.config_path.parent.mkdir(parents=True, exist_ok=True)
lines = ["# Sympathy-MCP host configuration", ""]
for name, host in sorted(self.hosts.items()):
lines.append(f"[hosts.{name}]")
lines.append(f'host = "{host.host}"')
lines.append(f'user = "{host.user}"')
lines.append(f"port = {host.port}")
if host.key:
lines.append(f'key = "{host.key}"')
lines.append("")
self.config_path.write_text("\n".join(lines))