@neoline/hostbridge 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,243 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from collections.abc import Iterable, Sequence
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from .secrets import decrypt_secret
11
+
12
+ DEFAULT_HOSTS_FILE = Path("~/.hostbridge/hosts.json").expanduser()
13
+ LEGACY_HOSTS_FILE = Path("~/.server_control_mcp/hosts.json").expanduser()
14
+ HOST_ID_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+$")
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class LoginStep:
19
+ expect: str
20
+ send: str | None = None
21
+ send_secret: str | None = None
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class HostConfig:
26
+ """A single approved connection target."""
27
+
28
+ id: str
29
+ label: str
30
+ command: str
31
+ args: list[str]
32
+ connection_type: str = "command"
33
+ script_path: Path | None = None
34
+ secrets: dict[str, str] = field(default_factory=dict)
35
+ login_steps: list[LoginStep] = field(default_factory=list)
36
+ ready_expect: str | None = None
37
+
38
+ @classmethod
39
+ def script(cls, host_id: str, label: str, script: Path | str) -> HostConfig:
40
+ path = Path(script).expanduser()
41
+ return cls(host_id, label, "/bin/sh", [str(path)], "script", path)
42
+
43
+ @classmethod
44
+ def command_line(
45
+ cls,
46
+ host_id: str,
47
+ label: str,
48
+ command: str,
49
+ args: Sequence[str] | None = None,
50
+ *,
51
+ connection_type: str = "command",
52
+ secrets: dict[str, str] | None = None,
53
+ login_steps: list[LoginStep] | None = None,
54
+ ready_expect: str | None = None,
55
+ ) -> HostConfig:
56
+ return cls(
57
+ host_id,
58
+ label,
59
+ command,
60
+ [str(arg) for arg in (args or ())],
61
+ connection_type,
62
+ None,
63
+ secrets or {},
64
+ login_steps or [],
65
+ ready_expect,
66
+ )
67
+
68
+ def describe(self) -> dict[str, object]:
69
+ info: dict[str, object] = {
70
+ "id": self.id,
71
+ "label": self.label,
72
+ "connection_type": self.connection_type,
73
+ "command": self.command,
74
+ "args": list(self.args),
75
+ }
76
+ if self.script_path is not None:
77
+ info.update(
78
+ {
79
+ "script": str(self.script_path),
80
+ "script_exists": self.script_path.exists(),
81
+ "script_executable": self.script_path.exists() and self.script_path.stat().st_mode & 0o111 != 0,
82
+ "script_runnable_via_sh": self.script_path.exists(),
83
+ }
84
+ )
85
+ return info
86
+
87
+
88
+ # Kept as an empty compatibility constant so imports do not leak personal paths.
89
+ DEFAULT_HOSTS: list[HostConfig] = []
90
+
91
+
92
+ def load_hosts(config_path: Path | str | None = None) -> list[HostConfig]:
93
+ """Load approved hosts from JSON configuration."""
94
+
95
+ configured = config_path or os.environ.get("HOSTBRIDGE_HOSTS_FILE") or os.environ.get("SERVER_CONTROL_MCP_HOSTS_FILE")
96
+ path = Path(configured).expanduser() if configured else _default_hosts_path()
97
+ if not path.exists():
98
+ return []
99
+
100
+ data = json.loads(path.read_text(encoding="utf-8"))
101
+ raw_hosts = data.get("hosts") if isinstance(data, dict) else None
102
+ if not isinstance(raw_hosts, list):
103
+ raise ValueError(f"{path} must contain a top-level 'hosts' list")
104
+
105
+ hosts_by_id: dict[str, HostConfig] = {}
106
+ order: list[str] = []
107
+ for index, item in enumerate(raw_hosts):
108
+ host = _parse_host(item, path, index)
109
+ if host.id not in hosts_by_id:
110
+ order.append(host.id)
111
+ hosts_by_id[host.id] = host
112
+
113
+ return [hosts_by_id[host_id] for host_id in order]
114
+
115
+
116
+ def _parse_host(item: object, path: Path, index: int) -> HostConfig:
117
+ if not isinstance(item, dict):
118
+ raise ValueError(f"{path} hosts[{index}] must be an object")
119
+ host_id = str(item.get("id", "")).strip()
120
+ label = str(item.get("label", host_id)).strip()
121
+ if not host_id or not HOST_ID_PATTERN.fullmatch(host_id):
122
+ raise ValueError(f"{path} hosts[{index}].id contains invalid characters")
123
+ if not label:
124
+ raise ValueError(f"{path} hosts[{index}].label must not be empty")
125
+
126
+ styles = [name for name in ("script", "ssh", "command") if name in item]
127
+ if len(styles) != 1:
128
+ raise ValueError(f"{path} hosts[{index}] must define exactly one of: script, ssh, command")
129
+
130
+ secrets = _parse_secrets(item.get("secrets", {}), path, index)
131
+ login_steps = _parse_login_steps(item.get("login_steps", []), path, index)
132
+ ready_expect = _parse_ready_expect(item.get("ready_expect"), path, index)
133
+
134
+ if "script" in item:
135
+ script = str(item.get("script", "")).strip()
136
+ if not script:
137
+ raise ValueError(f"{path} hosts[{index}].script must not be empty")
138
+ host = HostConfig.script(host_id, label, script)
139
+ return HostConfig(host.id, host.label, host.command, host.args, host.connection_type, host.script_path, secrets, login_steps, ready_expect)
140
+
141
+ if "ssh" in item:
142
+ return HostConfig.command_line(host_id, label, "ssh", _parse_ssh_args(item["ssh"], path, index), connection_type="ssh", secrets=secrets, login_steps=login_steps, ready_expect=ready_expect)
143
+
144
+ command = str(item.get("command", "")).strip()
145
+ args = item.get("args", [])
146
+ if not command:
147
+ raise ValueError(f"{path} hosts[{index}].command must not be empty")
148
+ if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args):
149
+ raise ValueError(f"{path} hosts[{index}].args must be a list of strings")
150
+ return HostConfig.command_line(host_id, label, command, args, secrets=secrets, login_steps=login_steps, ready_expect=ready_expect)
151
+
152
+
153
+ def _parse_ready_expect(raw_ready_expect: object, path: Path, index: int) -> str | None:
154
+ if raw_ready_expect in (None, ""):
155
+ return None
156
+ if not isinstance(raw_ready_expect, str):
157
+ raise ValueError(f"{path} hosts[{index}].ready_expect must be a string")
158
+ return raw_ready_expect
159
+
160
+
161
+ def _parse_secrets(raw_secrets: object, path: Path, index: int) -> dict[str, str]:
162
+ if raw_secrets in (None, {}):
163
+ return {}
164
+ if not isinstance(raw_secrets, dict) or not all(isinstance(name, str) for name in raw_secrets):
165
+ raise ValueError(f"{path} hosts[{index}].secrets must be an object")
166
+ return {name: decrypt_secret(payload) for name, payload in raw_secrets.items()}
167
+
168
+
169
+ def _parse_login_steps(raw_steps: object, path: Path, index: int) -> list[LoginStep]:
170
+ if raw_steps in (None, []):
171
+ return []
172
+ if not isinstance(raw_steps, list):
173
+ raise ValueError(f"{path} hosts[{index}].login_steps must be a list")
174
+ steps: list[LoginStep] = []
175
+ for step_index, raw_step in enumerate(raw_steps):
176
+ if not isinstance(raw_step, dict):
177
+ raise ValueError(f"{path} hosts[{index}].login_steps[{step_index}] must be an object")
178
+ expect = str(raw_step.get("expect", "")).strip()
179
+ send = raw_step.get("send")
180
+ send_secret = raw_step.get("send_secret")
181
+ if not expect:
182
+ raise ValueError(f"{path} hosts[{index}].login_steps[{step_index}].expect must not be empty")
183
+ if (send is None) == (send_secret is None):
184
+ raise ValueError(f"{path} hosts[{index}].login_steps[{step_index}] must define exactly one of send or send_secret")
185
+ steps.append(LoginStep(expect=expect, send=None if send is None else str(send), send_secret=None if send_secret is None else str(send_secret)))
186
+ return steps
187
+
188
+
189
+ def _parse_ssh_args(raw_ssh: object, path: Path, index: int) -> list[str]:
190
+ if not isinstance(raw_ssh, dict):
191
+ raise ValueError(f"{path} hosts[{index}].ssh must be an object")
192
+ target = str(raw_ssh.get("host", "")).strip()
193
+ if not target:
194
+ raise ValueError(f"{path} hosts[{index}].ssh.host must not be empty")
195
+
196
+ args: list[str] = []
197
+ port = raw_ssh.get("port")
198
+ if port not in (None, ""):
199
+ args.extend(["-p", str(int(port))])
200
+ identity_file = str(raw_ssh.get("identity_file", "")).strip()
201
+ if identity_file:
202
+ args.extend(["-i", str(Path(identity_file).expanduser())])
203
+ proxy_jump = str(raw_ssh.get("proxy_jump", "")).strip()
204
+ if proxy_jump:
205
+ args.extend(["-J", proxy_jump])
206
+ extra_args = raw_ssh.get("extra_args", [])
207
+ if not isinstance(extra_args, list) or not all(isinstance(arg, str) for arg in extra_args):
208
+ raise ValueError(f"{path} hosts[{index}].ssh.extra_args must be a list of strings")
209
+ args.extend(extra_args)
210
+ args.append(target)
211
+ return args
212
+
213
+
214
+ class HostRegistry:
215
+ """Allowlist of connection targets that the MCP server may spawn."""
216
+
217
+ def __init__(self, hosts: Iterable[HostConfig]):
218
+ self._hosts = {host.id: host for host in hosts}
219
+
220
+ @classmethod
221
+ def from_specs(cls, specs: Iterable[tuple[str, str, str, Sequence[str]]]) -> HostRegistry:
222
+ return cls(HostConfig.command_line(host_id, label, command, args) for host_id, label, command, args in specs)
223
+
224
+ def get(self, host_id: str) -> HostConfig:
225
+ try:
226
+ return self._hosts[host_id]
227
+ except KeyError as exc:
228
+ if not self._hosts:
229
+ raise KeyError(
230
+ "no hosts are configured; create ~/.hostbridge/hosts.json "
231
+ "or set HOSTBRIDGE_HOSTS_FILE"
232
+ ) from exc
233
+ valid = ", ".join(sorted(self._hosts))
234
+ raise KeyError(f"unknown host '{host_id}'; valid hosts: {valid}") from exc
235
+
236
+ def describe(self) -> list[dict[str, object]]:
237
+ return [host.describe() for host in self._hosts.values()]
238
+
239
+
240
+ def _default_hosts_path() -> Path:
241
+ if DEFAULT_HOSTS_FILE.exists() or not LEGACY_HOSTS_FILE.exists():
242
+ return DEFAULT_HOSTS_FILE
243
+ return LEGACY_HOSTS_FILE