@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,54 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from contextlib import suppress
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from cryptography.fernet import Fernet, InvalidToken
9
+
10
+ DEFAULT_KEY_FILE = Path("~/.hostbridge/key").expanduser()
11
+ LEGACY_KEY_FILE = Path("~/.server_control_mcp/key").expanduser()
12
+ SCHEME = "fernet.v1"
13
+
14
+
15
+ def key_file_path() -> Path:
16
+ configured = os.environ.get("HOSTBRIDGE_KEY_FILE") or os.environ.get("SERVER_CONTROL_MCP_KEY_FILE")
17
+ if configured:
18
+ return Path(configured).expanduser()
19
+ if DEFAULT_KEY_FILE.exists() or not LEGACY_KEY_FILE.exists():
20
+ return DEFAULT_KEY_FILE
21
+ return LEGACY_KEY_FILE
22
+
23
+
24
+ def _load_or_create_key() -> bytes:
25
+ path = key_file_path()
26
+ if path.exists():
27
+ return path.read_bytes().strip()
28
+ path.parent.mkdir(parents=True, exist_ok=True)
29
+ key = Fernet.generate_key()
30
+ path.write_bytes(key + b"\n")
31
+ with suppress(OSError):
32
+ path.chmod(0o600)
33
+ return key
34
+
35
+
36
+ def encrypt_secret(value: str) -> dict[str, str]:
37
+ if not isinstance(value, str) or value == "":
38
+ raise ValueError("secret value must not be empty")
39
+ token = Fernet(_load_or_create_key()).encrypt(value.encode("utf-8")).decode("ascii")
40
+ return {"scheme": SCHEME, "ciphertext": token}
41
+
42
+
43
+ def decrypt_secret(payload: Any) -> str:
44
+ if not isinstance(payload, dict):
45
+ raise ValueError("encrypted secret must be an object")
46
+ if payload.get("scheme") != SCHEME:
47
+ raise ValueError(f"unsupported secret scheme: {payload.get('scheme')}")
48
+ ciphertext = payload.get("ciphertext")
49
+ if not isinstance(ciphertext, str) or not ciphertext:
50
+ raise ValueError("encrypted secret ciphertext must not be empty")
51
+ try:
52
+ return Fernet(_load_or_create_key()).decrypt(ciphertext.encode("ascii")).decode("utf-8")
53
+ except InvalidToken as exc:
54
+ raise ValueError("failed to decrypt secret with the configured key") from exc
@@ -0,0 +1,249 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from dataclasses import asdict
5
+ from typing import Any
6
+
7
+ from mcp.server.fastmcp import FastMCP
8
+
9
+ from . import cli
10
+ from .hosts import HostRegistry, load_hosts
11
+ from .manager import (
12
+ DEFAULT_COMMAND_TIMEOUT,
13
+ DEFAULT_CONNECT_TIMEOUT,
14
+ PolicyDeniedError,
15
+ RemoteCommandError,
16
+ SessionManager,
17
+ )
18
+ from .policy import build_audit_sink, load_policy
19
+
20
+ mcp = FastMCP("hostbridge")
21
+ _registry = HostRegistry(load_hosts())
22
+ _policy = load_policy()
23
+ _audit = build_audit_sink(_policy)
24
+ _manager = SessionManager(_registry, policy=_policy, audit_sink=_audit)
25
+ _manager.start_idle_reaper()
26
+
27
+
28
+ _ERROR_CODES = {
29
+ PolicyDeniedError: "denied",
30
+ RemoteCommandError: "remote_error",
31
+ }
32
+
33
+
34
+ def _error_code(exc: Exception) -> str:
35
+ for exc_type, code in _ERROR_CODES.items():
36
+ if isinstance(exc, exc_type):
37
+ return code
38
+ if "unknown host" in str(exc).lower() or "unknown session" in str(exc).lower() or "unknown task" in str(exc).lower():
39
+ return "not_found"
40
+ return "internal"
41
+
42
+
43
+ def _reload_hosts() -> HostRegistry:
44
+ """Reload host allowlist without dropping existing remote sessions."""
45
+
46
+ global _registry
47
+ _registry = HostRegistry(load_hosts())
48
+ _manager.hosts = _registry
49
+ return _registry
50
+
51
+
52
+ def _error(exc: Exception) -> dict[str, object]:
53
+ return {"ok": False, "error": str(exc), "error_code": _error_code(exc)}
54
+
55
+
56
+ def _ok(**payload: object) -> dict[str, object]:
57
+ return {"ok": True, **payload}
58
+
59
+
60
+ @mcp.tool()
61
+ def hosts_list() -> dict[str, object]:
62
+ """List approved connection targets and local metadata when available.
63
+
64
+ Returns an object with ``hosts``: an array of host descriptors. Safe to call
65
+ repeatedly; it triggers a config reload from hosts.json.
66
+ """
67
+ try:
68
+ registry = _reload_hosts()
69
+ return _ok(hosts=registry.describe())
70
+ except Exception as exc:
71
+ return _error(exc)
72
+
73
+
74
+ @mcp.tool()
75
+ def hosts_reload() -> dict[str, object]:
76
+ """Reload approved connection targets from hosts.json without restarting.
77
+
78
+ Returns the same shape as ``hosts_list``. Use this when you know the config
79
+ file changed and want a fresh descriptor immediately.
80
+ """
81
+ try:
82
+ registry = _reload_hosts()
83
+ return _ok(hosts=registry.describe())
84
+ except Exception as exc:
85
+ return _error(exc)
86
+
87
+
88
+ @mcp.tool()
89
+ def session_open(host_id: str, connect_timeout: int = DEFAULT_CONNECT_TIMEOUT) -> dict[str, object]:
90
+ """Open a persistent interactive shell for one approved host.
91
+
92
+ Args:
93
+ host_id: identifier of a host in hosts.json.
94
+ connect_timeout: seconds to wait for the shell to become ready (5-180).
95
+
96
+ Returns the opened session descriptor. Keep the ``session_id`` for later calls.
97
+ """
98
+ try:
99
+ _reload_hosts()
100
+ timeout = max(5, min(int(connect_timeout), 180))
101
+ session = _manager.open_session(host_id, connect_timeout=timeout)
102
+ sessions = [item for item in _manager.list_sessions() if item["session_id"] == session.id]
103
+ return _ok(session=sessions[0])
104
+ except Exception as exc:
105
+ return _error(exc)
106
+
107
+
108
+ @mcp.tool()
109
+ def sessions_list() -> dict[str, object]:
110
+ """List currently open sessions held by this MCP process."""
111
+ return _ok(sessions=_manager.list_sessions())
112
+
113
+
114
+ @mcp.tool()
115
+ def session_close(session_id: str) -> dict[str, object]:
116
+ """Close one persistent session and free its PTY.
117
+
118
+ Args:
119
+ session_id: value returned by ``session_open``.
120
+ """
121
+ try:
122
+ return _ok(**_manager.close_session(session_id))
123
+ except Exception as exc:
124
+ return _error(exc)
125
+
126
+
127
+ @mcp.tool()
128
+ def sessions_close_all() -> dict[str, object]:
129
+ """Close every live session. Use during graceful shutdown or reset.
130
+
131
+ Returns ``{"closed": [...session_ids], "count": N}``.
132
+ """
133
+ try:
134
+ return _ok(**_manager.close_all_sessions())
135
+ except Exception as exc:
136
+ return _error(exc)
137
+
138
+
139
+ @mcp.tool()
140
+ def command_run(session_id: str, command: str, timeout: int = DEFAULT_COMMAND_TIMEOUT) -> dict[str, object]:
141
+ """Run a foreground command on an open session and wait for completion or timeout.
142
+
143
+ Args:
144
+ session_id: an open session from ``session_open``.
145
+ command: the shell command string to execute.
146
+ timeout: max seconds to wait (1 - 86400). On timeout the result has ``timed_out=true``.
147
+
148
+ The command is checked against the configured denylist before execution;
149
+ a blocked command returns ``error_code="denied"``.
150
+ """
151
+ try:
152
+ safe_timeout = max(1, min(int(timeout), 24 * 60 * 60))
153
+ result = _manager.run_command(session_id, command, timeout=safe_timeout)
154
+ return _ok(result=asdict(result))
155
+ except Exception as exc:
156
+ return _error(exc)
157
+
158
+
159
+ @mcp.tool()
160
+ def task_start(session_id: str, command: str) -> dict[str, object]:
161
+ """Start a long-running background task on the remote host.
162
+
163
+ Args:
164
+ session_id: an open session.
165
+ command: the shell command to run detached on the remote host.
166
+
167
+ Returns a task descriptor with ``task_id`` for polling via ``task_status``.
168
+ The command is checked against the configured denylist before execution.
169
+ """
170
+ try:
171
+ task = _manager.start_task(session_id, command)
172
+ return _ok(task=asdict(task))
173
+ except Exception as exc:
174
+ return _error(exc)
175
+
176
+
177
+ @mcp.tool()
178
+ def task_status(session_id: str, task_id: str, tail_lines: int = 80) -> dict[str, object]:
179
+ """Poll a background task: state, exit code, and recent log tail.
180
+
181
+ Args:
182
+ session_id: the session that owns the task.
183
+ task_id: value returned by ``task_start``.
184
+ tail_lines: number of trailing log lines to return (1 - 500).
185
+ """
186
+ try:
187
+ return _ok(status=_manager.task_status(session_id, task_id, tail_lines=tail_lines))
188
+ except Exception as exc:
189
+ return _error(exc)
190
+
191
+
192
+ @mcp.tool()
193
+ def task_stop(session_id: str, task_id: str) -> dict[str, object]:
194
+ """Best-effort terminate a background task by sending SIGTERM to its remote PID.
195
+
196
+ Args:
197
+ session_id: the session that owns the task.
198
+ task_id: value returned by ``task_start``.
199
+
200
+ Returns ``{"task_id": ..., "stopped": bool, "outcome": "killed|already|noop"}``.
201
+ """
202
+ try:
203
+ return _ok(**_manager.stop_task(session_id, task_id))
204
+ except Exception as exc:
205
+ return _error(exc)
206
+
207
+
208
+ @mcp.tool()
209
+ def session_write(session_id: str, text: str) -> dict[str, object]:
210
+ """Send one raw line to a session for rare interactive operations; prefer command_run otherwise.
211
+
212
+ Args:
213
+ session_id: an open session.
214
+ text: the literal line to send. Must not be empty.
215
+ """
216
+ try:
217
+ if not text.strip():
218
+ raise RemoteCommandError("text must not be empty")
219
+ return _ok(**_manager.write(session_id, text))
220
+ except Exception as exc:
221
+ return _error(exc)
222
+
223
+
224
+ @mcp.tool()
225
+ def session_read(session_id: str, timeout: float = 1.0, max_chars: int = 20000) -> dict[str, object]:
226
+ """Read currently available raw output from a session after session_write or interactive commands.
227
+
228
+ Args:
229
+ session_id: an open session.
230
+ timeout: max seconds to block waiting for output (0.0 - 10.0).
231
+ max_chars: cap on returned characters (1 - 120000).
232
+ """
233
+ try:
234
+ safe_timeout = max(0.0, min(float(timeout), 10.0))
235
+ safe_max = max(1, min(int(max_chars), 120000))
236
+ return _ok(**_manager.read(session_id, timeout=safe_timeout, max_chars=safe_max))
237
+ except Exception as exc:
238
+ return _error(exc)
239
+
240
+
241
+ def main(argv: list[str] | None = None) -> Any:
242
+ args = sys.argv[1:] if argv is None else argv
243
+ if args and (args[0] in cli.SETUP_COMMANDS or args[0] in {"-h", "--help"}):
244
+ return cli.main(args)
245
+ return mcp.run(transport="stdio")
246
+
247
+
248
+ if __name__ == "__main__":
249
+ main()