@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.
- package/LICENSE +21 -0
- package/README.md +350 -0
- package/bin/hostbridge.js +51 -0
- package/bin/server-control-mcp.js +2 -0
- package/examples/claude_desktop_config.json +8 -0
- package/examples/codex.config.toml +7 -0
- package/examples/hosts.example.json +11 -0
- package/package.json +34 -0
- package/pyproject.toml +89 -0
- package/src/server_control_mcp/__init__.py +54 -0
- package/src/server_control_mcp/__main__.py +16 -0
- package/src/server_control_mcp/cli.py +1178 -0
- package/src/server_control_mcp/hosts.py +243 -0
- package/src/server_control_mcp/manager.py +512 -0
- package/src/server_control_mcp/policy.py +187 -0
- package/src/server_control_mcp/secrets.py +54 -0
- package/src/server_control_mcp/server.py +249 -0
|
@@ -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
|