@neoline/hostbridge 0.2.2 → 1.1.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/README.md +46 -2
- package/examples/claude_desktop_config.json +4 -1
- package/examples/codex.config.toml +2 -2
- package/package.json +1 -1
- package/pyproject.toml +2 -2
- package/src/server_control_mcp/__init__.py +1 -1
- package/src/server_control_mcp/__main__.py +6 -3
- package/src/server_control_mcp/cli.py +52 -11
- package/src/server_control_mcp/daemon.py +420 -0
- package/src/server_control_mcp/manager.py +374 -11
- package/src/server_control_mcp/server.py +149 -115
package/README.md
CHANGED
|
@@ -17,6 +17,10 @@ This repo is portable: it ships with no personal server paths or built-in hosts.
|
|
|
17
17
|
- `task_stop(session_id, task_id)` — terminate a running background job and reap its process.
|
|
18
18
|
- `session_write(session_id, text)` — send a raw line for interactive edge cases.
|
|
19
19
|
- `session_read(session_id, timeout=1.0, max_chars=20000)` — read available raw output after interactive writes.
|
|
20
|
+
- `file_write_text(session_id, remote_path, content)` — write a UTF-8 text file through the active shell, up to 100MiB.
|
|
21
|
+
- `file_read_text(session_id, remote_path)` — read a UTF-8 text file through the active shell, up to 100MiB.
|
|
22
|
+
- `file_upload(session_id, local_path, remote_path)` — upload a local file with chunked shell/base64 transfer, up to 100MiB.
|
|
23
|
+
- `file_download(session_id, remote_path, local_path)` — download a remote file with chunked shell/base64 transfer, up to 100MiB.
|
|
20
24
|
- `session_close(session_id)` — close a persistent session.
|
|
21
25
|
|
|
22
26
|
Every tool returns a structured payload with a stable `error_code` on failure (`HOST_NOT_FOUND`, `SESSION_NOT_FOUND`, `POLICY_DENIED`, `TASK_NOT_FOUND`, `TIMEOUT`, `INTERNAL`).
|
|
@@ -70,6 +74,43 @@ python3 -m server_control_mcp.server install
|
|
|
70
74
|
npm run test:node
|
|
71
75
|
```
|
|
72
76
|
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
## File transfer model
|
|
80
|
+
|
|
81
|
+
HostBridge file transfer is shell-based because recorded login paths may be JumpServer menus, OTP flows, wrapper scripts, or `docker exec` sessions rather than standard SSH/SFTP connections. The first-class transfer tools stream chunked base64 through the already-open shell session, verify hashes, and work for text or binary artifacts up to 100MiB:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
file_upload(session_id, "/absolute/local/script.py", "/remote/work/script.py")
|
|
85
|
+
file_download(session_id, "/remote/work/result.log", "/absolute/local/result.log")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Give file tools local and remote paths; do not paste base64 into tool arguments. Files larger than 100MiB are deliberately rejected with guidance to use remote pull instead, for example `curl`, `wget`, `git clone`, `modelscope download`, or another remote-native downloader. HostBridge does not plan a standard SSH/SFTP fast path; the reliable fallback for non-standard login flows is shell/base64, and large model/data movement should happen from the remote host.
|
|
89
|
+
|
|
90
|
+
## Daemon lifecycle
|
|
91
|
+
|
|
92
|
+
HostBridge always keeps remote sessions in the local daemon. The MCP server process is only a lightweight stdio adapter for agent clients and talks to the daemon over a Unix domain socket. If the daemon is not already running, the MCP adapter starts it lazily on the first tool call.
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
hostbridge daemon start
|
|
96
|
+
hostbridge daemon status
|
|
97
|
+
hostbridge daemon stop
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The daemon uses a Unix domain socket with a small JSON-line RPC protocol by default, not HTTP and not a TCP port:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
~/.hostbridge/daemon.sock
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
This avoids fixed port conflicts, removes the local HTTP surface, and keeps the control API local to the current machine. The `~/.hostbridge` directory should be private to the local user. To point daemon management commands at a custom socket path:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
hostbridge daemon start --socket /tmp/hostbridge.sock
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Sessions can outlive a single agent client process. HostBridge records an optional `HOSTBRIDGE_AGENT_ID` owner on sessions and refuses cross-owner operations unless a force-close path is used. Set `HOSTBRIDGE_MAX_SESSIONS_PER_HOST` to cap concurrent live sessions per configured host (default `0`, unlimited).
|
|
113
|
+
|
|
73
114
|
## Guided setup wizard
|
|
74
115
|
|
|
75
116
|
Run the local setup wizard instead of hand-writing `hosts.json`:
|
|
@@ -270,6 +311,8 @@ Codex config written by the installer, for example in `~/.codex/config.toml`:
|
|
|
270
311
|
[mcp_servers.hostbridge]
|
|
271
312
|
command = "npx"
|
|
272
313
|
args = ["-y", "hostbridge"]
|
|
314
|
+
[mcp_servers.hostbridge.env]
|
|
315
|
+
HOSTBRIDGE_AGENT_ID = "codex"
|
|
273
316
|
```
|
|
274
317
|
|
|
275
318
|
Claude Desktop style config:
|
|
@@ -279,7 +322,8 @@ Claude Desktop style config:
|
|
|
279
322
|
"mcpServers": {
|
|
280
323
|
"hostbridge": {
|
|
281
324
|
"command": "npx",
|
|
282
|
-
"args": ["-y", "hostbridge"]
|
|
325
|
+
"args": ["-y", "hostbridge"],
|
|
326
|
+
"env": {"HOSTBRIDGE_AGENT_ID": "claude-code"}
|
|
283
327
|
}
|
|
284
328
|
}
|
|
285
329
|
}
|
|
@@ -296,7 +340,7 @@ See `examples/` for copyable config templates.
|
|
|
296
340
|
|
|
297
341
|
## Policy, audit, and idle reaping
|
|
298
342
|
|
|
299
|
-
hostbridge ships a lightweight policy layer that runs
|
|
343
|
+
hostbridge ships a lightweight policy layer that runs inside the daemon process. It is on by default and can be tuned or disabled via environment variables or a JSON config file.
|
|
300
344
|
|
|
301
345
|
Environment variables:
|
|
302
346
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[mcp_servers.hostbridge]
|
|
2
2
|
command = "npx"
|
|
3
3
|
args = ["-y", "hostbridge"]
|
|
4
|
-
|
|
4
|
+
[mcp_servers.hostbridge.env]
|
|
5
|
+
HOSTBRIDGE_AGENT_ID = "codex"
|
|
5
6
|
# Optional: use a project-specific host file instead of ~/.hostbridge/hosts.json
|
|
6
|
-
# [mcp_servers.hostbridge.env]
|
|
7
7
|
# HOSTBRIDGE_HOSTS_FILE = "/absolute/path/to/hosts.json"
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hostbridge"
|
|
7
|
-
version = "
|
|
7
|
+
version = "1.1.0"
|
|
8
8
|
description = "HostBridge MCP server for persistent allowlisted SSH and shell sessions."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -86,4 +86,4 @@ python_version = "3.11"
|
|
|
86
86
|
ignore_missing_imports = true
|
|
87
87
|
warn_unused_ignores = true
|
|
88
88
|
warn_redundant_casts = true
|
|
89
|
-
files = ["src/server_control_mcp"]
|
|
89
|
+
files = ["src/server_control_mcp"]
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import sys
|
|
4
|
+
|
|
3
5
|
from . import ensure_runtime
|
|
4
6
|
|
|
5
7
|
|
|
6
|
-
def main() ->
|
|
8
|
+
def main() -> int:
|
|
7
9
|
ensure_runtime()
|
|
8
10
|
# Imported lazily so a missing mcp/pexpect/cryptography dependency surfaces
|
|
9
11
|
# the friendly ensure_runtime() message instead of a raw ImportError.
|
|
10
12
|
from .server import main as server_main
|
|
11
13
|
|
|
12
|
-
server_main()
|
|
14
|
+
result = server_main()
|
|
15
|
+
return result if isinstance(result, int) else 0
|
|
13
16
|
|
|
14
17
|
|
|
15
18
|
if __name__ == "__main__":
|
|
16
|
-
main()
|
|
19
|
+
sys.exit(main())
|
|
@@ -38,6 +38,9 @@ Use HostBridge when the user asks to connect to, inspect, or operate an allowlis
|
|
|
38
38
|
- Do not run `hostbridge check` before every connection; only use `hostbridge check <host_id>` when `session_open` times out or fails.
|
|
39
39
|
- Do not search shell history, SSH config, or old scripts unless the setup flow lacks required information or the user explicitly asks.
|
|
40
40
|
- Keep long-running work in `task_start`/`task_status`; use `session_close` or `sessions_close_all` when done.
|
|
41
|
+
- Use `file_write_text`/`file_read_text` for UTF-8 files and `file_upload`/`file_download` for binary artifacts up to 100MiB; these tools stream through the already-open shell and work across JumpServer/menu/sudo flows where SFTP may not exist.
|
|
42
|
+
- For files larger than 100MiB, do not use HostBridge file tools; start a remote pull instead (`curl`, `wget`, `git clone`, `modelscope download`, etc.) and poll it with `task_status`.
|
|
43
|
+
- Do not ask the user to provide base64 content. Give file tools local/remote paths; HostBridge handles chunking, encoding, hashing, and cleanup internally.
|
|
41
44
|
{HOSTBRIDGE_GUIDANCE_END}
|
|
42
45
|
"""
|
|
43
46
|
SUPPORTED_INSTALL_TARGETS = (
|
|
@@ -51,7 +54,7 @@ SUPPORTED_INSTALL_TARGETS = (
|
|
|
51
54
|
"antigravity",
|
|
52
55
|
"kiro",
|
|
53
56
|
)
|
|
54
|
-
SETUP_COMMANDS = {"install", "setup", "check", "list", "remove", "reload", "secret", "step"}
|
|
57
|
+
SETUP_COMMANDS = {"install", "setup", "check", "list", "remove", "reload", "secret", "step", "daemon"}
|
|
55
58
|
AskFunc = Callable[[str], str]
|
|
56
59
|
SecretAskFunc = Callable[[str], str]
|
|
57
60
|
VerifyFunc = Callable[[dict[str, object], int], "VerificationResult"]
|
|
@@ -268,11 +271,13 @@ def _toml_array(values: list[str]) -> str:
|
|
|
268
271
|
return "[" + ", ".join(_toml_quote(value) for value in values) + "]"
|
|
269
272
|
|
|
270
273
|
|
|
271
|
-
def _server_control_block(command: str, args: list[str]) -> str:
|
|
274
|
+
def _server_control_block(command: str, args: list[str], *, agent_id: str) -> str:
|
|
272
275
|
return "\n".join([
|
|
273
276
|
f"[mcp_servers.{PUBLIC_NAME}]",
|
|
274
277
|
f"command = {_toml_quote(command)}",
|
|
275
278
|
f"args = {_toml_array(args)}",
|
|
279
|
+
f"[mcp_servers.{PUBLIC_NAME}.env]",
|
|
280
|
+
f"HOSTBRIDGE_AGENT_ID = {_toml_quote(agent_id)}",
|
|
276
281
|
"",
|
|
277
282
|
])
|
|
278
283
|
|
|
@@ -360,27 +365,28 @@ def _remove_legacy_mcp_server_keys(value: object) -> None:
|
|
|
360
365
|
_remove_legacy_mcp_server_keys(child)
|
|
361
366
|
|
|
362
367
|
|
|
363
|
-
def _install_codex_config(config_path: Path, command: str, args: list[str]) -> None:
|
|
364
|
-
block = _server_control_block(command, args)
|
|
368
|
+
def _install_codex_config(config_path: Path, command: str, args: list[str], *, agent_id: str) -> None:
|
|
369
|
+
block = _server_control_block(command, args, agent_id=agent_id)
|
|
365
370
|
existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
|
|
366
371
|
updated = _remove_toml_table(existing, f"[mcp_servers.{LEGACY_PUBLIC_NAME}]")
|
|
367
372
|
updated = _remove_toml_table(updated, f"[mcp_servers.{LEGACY_PACKAGE_NAME}]")
|
|
373
|
+
updated = _remove_toml_table(updated, f"[mcp_servers.{PUBLIC_NAME}.env]")
|
|
368
374
|
updated = _replace_toml_table(updated, f"[mcp_servers.{PUBLIC_NAME}]", block)
|
|
369
375
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
370
376
|
config_path.write_text(updated, encoding="utf-8")
|
|
371
377
|
|
|
372
378
|
|
|
373
|
-
def _install_mcp_servers_json(config_path: Path, command: str, args: list[str]) -> None:
|
|
379
|
+
def _install_mcp_servers_json(config_path: Path, command: str, args: list[str], *, agent_id: str) -> None:
|
|
374
380
|
data = _read_json_object(config_path)
|
|
375
381
|
_remove_legacy_mcp_server_keys(data)
|
|
376
382
|
servers = data.setdefault("mcpServers", {})
|
|
377
383
|
if not isinstance(servers, dict):
|
|
378
384
|
raise ValueError(f"{config_path} mcpServers must be an object")
|
|
379
|
-
servers[PUBLIC_NAME] = {"command": command, "args": args}
|
|
385
|
+
servers[PUBLIC_NAME] = {"command": command, "args": args, "env": {"HOSTBRIDGE_AGENT_ID": agent_id}}
|
|
380
386
|
_write_json_object(config_path, data)
|
|
381
387
|
|
|
382
388
|
|
|
383
|
-
def _install_opencode_config(config_path: Path, command: str, args: list[str]) -> None:
|
|
389
|
+
def _install_opencode_config(config_path: Path, command: str, args: list[str], *, agent_id: str) -> None:
|
|
384
390
|
data = _read_json_object(config_path)
|
|
385
391
|
servers = data.setdefault("mcp", {})
|
|
386
392
|
if not isinstance(servers, dict):
|
|
@@ -391,6 +397,7 @@ def _install_opencode_config(config_path: Path, command: str, args: list[str]) -
|
|
|
391
397
|
"type": "local",
|
|
392
398
|
"command": [command, *args],
|
|
393
399
|
"enabled": True,
|
|
400
|
+
"env": {"HOSTBRIDGE_AGENT_ID": agent_id},
|
|
394
401
|
}
|
|
395
402
|
_write_json_object(config_path, data)
|
|
396
403
|
|
|
@@ -420,11 +427,11 @@ def _agent_install_specs(home: Path) -> list[AgentInstallSpec]:
|
|
|
420
427
|
|
|
421
428
|
def _install_spec(spec: AgentInstallSpec, command: str, args: list[str]) -> None:
|
|
422
429
|
if spec.kind == "codex_toml":
|
|
423
|
-
_install_codex_config(spec.path, command, args)
|
|
430
|
+
_install_codex_config(spec.path, command, args, agent_id=spec.target)
|
|
424
431
|
elif spec.kind == "opencode_json":
|
|
425
|
-
_install_opencode_config(spec.path, command, args)
|
|
432
|
+
_install_opencode_config(spec.path, command, args, agent_id=spec.target)
|
|
426
433
|
else:
|
|
427
|
-
_install_mcp_servers_json(spec.path, command, args)
|
|
434
|
+
_install_mcp_servers_json(spec.path, command, args, agent_id=spec.target)
|
|
428
435
|
if spec.guidance_path is not None:
|
|
429
436
|
_install_guidance_file(spec.guidance_path)
|
|
430
437
|
|
|
@@ -458,7 +465,7 @@ def run_install(
|
|
|
458
465
|
|
|
459
466
|
if target == "codex":
|
|
460
467
|
resolved_config = Path(config_path or DEFAULT_CODEX_CONFIG).expanduser()
|
|
461
|
-
_install_codex_config(resolved_config, resolved_command, resolved_args)
|
|
468
|
+
_install_codex_config(resolved_config, resolved_command, resolved_args, agent_id="codex")
|
|
462
469
|
label = "Codex"
|
|
463
470
|
else:
|
|
464
471
|
home = Path(home_path or Path.home()).expanduser()
|
|
@@ -982,6 +989,24 @@ def run_reload() -> int:
|
|
|
982
989
|
return 0
|
|
983
990
|
|
|
984
991
|
|
|
992
|
+
def run_daemon_command(command: str, *, foreground: bool = False, socket_file: Path | str | None = None) -> int:
|
|
993
|
+
from . import daemon
|
|
994
|
+
|
|
995
|
+
if command == "start":
|
|
996
|
+
if foreground:
|
|
997
|
+
return daemon.run_daemon(socket_file=socket_file)
|
|
998
|
+
return daemon.start_background(socket_file=socket_file)
|
|
999
|
+
if command == "stop":
|
|
1000
|
+
return daemon.stop_background(socket_file=socket_file)
|
|
1001
|
+
if command == "status":
|
|
1002
|
+
if daemon.is_running(socket_file=socket_file):
|
|
1003
|
+
print(f"hostbridge daemon running at unix://{daemon.socket_path(socket_file)}")
|
|
1004
|
+
return 0
|
|
1005
|
+
print("hostbridge daemon is not running")
|
|
1006
|
+
return 1
|
|
1007
|
+
raise ValueError(f"unknown daemon command: {command}")
|
|
1008
|
+
|
|
1009
|
+
|
|
985
1010
|
|
|
986
1011
|
def run_entry_login_steps(child: object, entry: dict[str, object], timeout: int) -> None:
|
|
987
1012
|
raw_steps = entry.get("login_steps", [])
|
|
@@ -1128,6 +1153,16 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1128
1153
|
subparsers.add_parser("list", parents=[config_parent], help="List configured hosts")
|
|
1129
1154
|
subparsers.add_parser("reload", help="Explain runtime host reload behavior")
|
|
1130
1155
|
|
|
1156
|
+
daemon_parser = subparsers.add_parser("daemon", help="Manage the HostBridge daemon")
|
|
1157
|
+
daemon_subparsers = daemon_parser.add_subparsers(dest="daemon_command", required=True)
|
|
1158
|
+
daemon_start = daemon_subparsers.add_parser("start", help="Start the HostBridge daemon")
|
|
1159
|
+
daemon_start.add_argument("--foreground", action="store_true", help="Run daemon in the foreground")
|
|
1160
|
+
daemon_start.add_argument("--socket", dest="socket_file", default=None, help="Unix socket path; default ~/.hostbridge/daemon.sock")
|
|
1161
|
+
daemon_stop = daemon_subparsers.add_parser("stop", help="Stop the HostBridge daemon")
|
|
1162
|
+
daemon_stop.add_argument("--socket", dest="socket_file", default=None, help="Unix socket path; default ~/.hostbridge/daemon.sock")
|
|
1163
|
+
daemon_status = daemon_subparsers.add_parser("status", help="Show daemon status")
|
|
1164
|
+
daemon_status.add_argument("--socket", dest="socket_file", default=None, help="Unix socket path; default ~/.hostbridge/daemon.sock")
|
|
1165
|
+
|
|
1131
1166
|
remove_parser = subparsers.add_parser("remove", parents=[config_parent], help="Remove a configured host")
|
|
1132
1167
|
remove_parser.add_argument("host_id")
|
|
1133
1168
|
|
|
@@ -1176,6 +1211,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1176
1211
|
return run_remove(args.host_id, config_path=config_path)
|
|
1177
1212
|
if args.command == "reload":
|
|
1178
1213
|
return run_reload()
|
|
1214
|
+
if args.command == "daemon":
|
|
1215
|
+
return run_daemon_command(
|
|
1216
|
+
args.daemon_command,
|
|
1217
|
+
foreground=getattr(args, "foreground", False),
|
|
1218
|
+
socket_file=getattr(args, "socket_file", None),
|
|
1219
|
+
)
|
|
1179
1220
|
if args.command == "secret" and args.secret_command == "set":
|
|
1180
1221
|
value = getpass.getpass(f"Secret value for {args.host_id}/{args.name}: ")
|
|
1181
1222
|
set_host_secret(config_path, args.host_id, args.name, value)
|