@neoline/hostbridge 1.4.3 → 2.0.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 +24 -137
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/src/server_control_mcp/__init__.py +4 -5
- package/src/server_control_mcp/cli.py +229 -1231
- package/src/server_control_mcp/client.py +468 -0
- package/src/server_control_mcp/config.py +139 -0
- package/src/server_control_mcp/daemon.py +387 -306
- package/src/server_control_mcp/doctor.py +108 -0
- package/src/server_control_mcp/hosts.py +52 -7
- package/src/server_control_mcp/mock_ssh.py +177 -87
- package/src/server_control_mcp/policy.py +2 -0
- package/src/server_control_mcp/protocol.py +362 -0
- package/src/server_control_mcp/runtime.py +40 -0
- package/src/server_control_mcp/server.py +132 -188
- package/src/server_control_mcp/services.py +508 -0
- package/src/server_control_mcp/transports/__init__.py +17 -0
- package/src/server_control_mcp/transports/base.py +78 -0
- package/src/server_control_mcp/transports/pty.py +405 -0
- package/src/server_control_mcp/transports/ssh.py +195 -0
- package/src/server_control_mcp/manager.py +0 -922
|
@@ -2,281 +2,225 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import sys
|
|
5
|
-
from
|
|
6
|
-
from
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from typing import Any, TypeVar
|
|
7
8
|
|
|
8
9
|
from mcp.server.fastmcp import FastMCP
|
|
9
10
|
|
|
10
11
|
from . import cli, daemon
|
|
11
|
-
from .
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
RemoteCommandError,
|
|
18
|
-
)
|
|
12
|
+
from .client import HostBridgeClient, HostBridgeError
|
|
13
|
+
|
|
14
|
+
DEFAULT_COMMAND_TIMEOUT = 60
|
|
15
|
+
DEFAULT_CONNECT_TIMEOUT = 30
|
|
16
|
+
DEFAULT_MAX_TRANSFER_BYTES = 100 * 1024 * 1024
|
|
17
|
+
MAX_TRANSFER_BYTES = 1024 * 1024 * 1024
|
|
19
18
|
|
|
20
19
|
mcp = FastMCP("hostbridge")
|
|
20
|
+
T = TypeVar("T")
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
RemoteCommandError: "remote_error",
|
|
26
|
-
}
|
|
23
|
+
def _client() -> HostBridgeClient:
|
|
24
|
+
return HostBridgeClient()
|
|
27
25
|
|
|
28
26
|
|
|
29
|
-
def
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return code
|
|
33
|
-
if "unknown host" in str(exc).lower() or "unknown session" in str(exc).lower() or "unknown task" in str(exc).lower():
|
|
34
|
-
return "not_found"
|
|
35
|
-
return "internal"
|
|
27
|
+
def _owner() -> str | None:
|
|
28
|
+
value = os.environ.get("HOSTBRIDGE_AGENT_ID", "").strip()
|
|
29
|
+
return value or None
|
|
36
30
|
|
|
37
31
|
|
|
38
32
|
def _error(exc: Exception) -> dict[str, object]:
|
|
39
|
-
|
|
33
|
+
code = exc.code if isinstance(exc, HostBridgeError) else "internal"
|
|
34
|
+
return {"ok": False, "error": str(exc), "error_code": code}
|
|
40
35
|
|
|
41
36
|
|
|
42
|
-
def
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
37
|
+
def _call(operation: Callable[[], T], wrap: Callable[[T], dict[str, object]]) -> dict[str, object]:
|
|
38
|
+
try:
|
|
39
|
+
return wrap(operation())
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
return _error(exc)
|
|
47
42
|
|
|
48
43
|
|
|
49
|
-
def
|
|
44
|
+
def _request(
|
|
45
|
+
method: str, params: dict[str, object] | None = None, *, timeout: float | None = None
|
|
46
|
+
) -> dict[str, object]:
|
|
47
|
+
payload = dict(params or {})
|
|
48
|
+
owner = _owner()
|
|
49
|
+
if owner is not None:
|
|
50
|
+
payload.setdefault("owner", owner)
|
|
50
51
|
try:
|
|
51
|
-
return
|
|
52
|
-
except
|
|
53
|
-
if os.environ.get("HOSTBRIDGE_NPM_LAUNCHER"):
|
|
54
|
-
|
|
55
|
-
raise RuntimeError(
|
|
56
|
-
"hostbridge daemon is not reachable at "
|
|
57
|
-
f"unix://{socket_path}. Start it outside the MCP sandbox, for example: "
|
|
58
|
-
f"HOSTBRIDGE_DAEMON_SOCKET={socket_path} npx -y @neoline/hostbridge daemon start --foreground"
|
|
59
|
-
) from exc
|
|
60
|
-
# MCP stdout must stay protocol-clean, so auto-start the daemon quietly.
|
|
52
|
+
return _client().request(method, payload, timeout=timeout)
|
|
53
|
+
except HostBridgeError as exc:
|
|
54
|
+
if exc.code != "connection_lost" or os.environ.get("HOSTBRIDGE_NPM_LAUNCHER"):
|
|
55
|
+
raise
|
|
61
56
|
if daemon.start_background(quiet=True) != 0:
|
|
62
57
|
raise RuntimeError(f"hostbridge daemon failed to start; see {daemon.log_file()}") from exc
|
|
63
|
-
return
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def _daemon_payload(**payload: object) -> dict[str, object]:
|
|
67
|
-
owner = _owner_agent_id()
|
|
68
|
-
if owner is not None:
|
|
69
|
-
payload["owner_agent_id"] = owner
|
|
70
|
-
return payload
|
|
58
|
+
return _client().request(method, payload, timeout=timeout)
|
|
71
59
|
|
|
72
60
|
|
|
73
|
-
def
|
|
74
|
-
|
|
61
|
+
def _mode(value: str | int) -> int:
|
|
62
|
+
mode = int(value, 8) if isinstance(value, str) else int(value)
|
|
63
|
+
if not 0 <= mode <= 0o7777:
|
|
64
|
+
raise ValueError("mode must be between 0000 and 7777")
|
|
65
|
+
return mode
|
|
75
66
|
|
|
76
67
|
|
|
77
|
-
def
|
|
78
|
-
return max(1, min(int(
|
|
68
|
+
def _limit(value: int) -> int:
|
|
69
|
+
return max(1, min(int(value), MAX_TRANSFER_BYTES))
|
|
79
70
|
|
|
80
71
|
|
|
81
72
|
@mcp.tool()
|
|
82
73
|
def hosts_list() -> dict[str, object]:
|
|
83
|
-
"""List
|
|
84
|
-
|
|
85
|
-
return _daemon_request("POST", "/hosts/reload")
|
|
86
|
-
except Exception as exc:
|
|
87
|
-
return _error(exc)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
@mcp.tool()
|
|
91
|
-
def hosts_reload() -> dict[str, object]:
|
|
92
|
-
"""Reload approved connection targets from hosts.json without restarting."""
|
|
93
|
-
try:
|
|
94
|
-
return _daemon_request("POST", "/hosts/reload")
|
|
95
|
-
except Exception as exc:
|
|
96
|
-
return _error(exc)
|
|
74
|
+
"""List configured allowlisted hosts."""
|
|
75
|
+
return _call(lambda: _request("host.list"), lambda value: {"ok": True, **value})
|
|
97
76
|
|
|
98
77
|
|
|
99
78
|
@mcp.tool()
|
|
100
79
|
def session_open(host_id: str, connect_timeout: int = DEFAULT_CONNECT_TIMEOUT) -> dict[str, object]:
|
|
101
|
-
"""Open a persistent
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
80
|
+
"""Open a persistent session for an allowlisted host."""
|
|
81
|
+
_ = max(1, min(int(connect_timeout), 180))
|
|
82
|
+
return _call(
|
|
83
|
+
lambda: _client().open_session(host_id, owner=_owner()),
|
|
84
|
+
lambda value: {"ok": True, "session": asdict(value)},
|
|
85
|
+
)
|
|
107
86
|
|
|
108
87
|
|
|
109
88
|
@mcp.tool()
|
|
110
89
|
def sessions_list() -> dict[str, object]:
|
|
111
|
-
"""List
|
|
112
|
-
|
|
113
|
-
return _daemon_request("GET", "/sessions")
|
|
114
|
-
except Exception as exc:
|
|
115
|
-
return _error(exc)
|
|
90
|
+
"""List live sessions."""
|
|
91
|
+
return _call(lambda: _request("session.list"), lambda value: {"ok": True, **value})
|
|
116
92
|
|
|
117
93
|
|
|
118
94
|
@mcp.tool()
|
|
119
95
|
def session_close(session_id: str, force: bool = False) -> dict[str, object]:
|
|
120
|
-
"""Close
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
96
|
+
"""Close a session."""
|
|
97
|
+
return _call(
|
|
98
|
+
lambda: _client().close_session(session_id, owner=_owner(), force=bool(force)),
|
|
99
|
+
lambda _: {"ok": True, "session_id": session_id, "closed": True},
|
|
100
|
+
)
|
|
125
101
|
|
|
126
102
|
|
|
127
103
|
@mcp.tool()
|
|
128
104
|
def sessions_close_all() -> dict[str, object]:
|
|
129
|
-
"""Close
|
|
130
|
-
|
|
131
|
-
return _daemon_request("POST", "/sessions/close_all")
|
|
132
|
-
except Exception as exc:
|
|
133
|
-
return _error(exc)
|
|
105
|
+
"""Close all sessions owned by this caller."""
|
|
106
|
+
return _call(lambda: _request("session.close_all"), lambda value: {"ok": True, **value})
|
|
134
107
|
|
|
135
108
|
|
|
136
109
|
@mcp.tool()
|
|
137
110
|
def command_run(session_id: str, command: str, timeout: int = DEFAULT_COMMAND_TIMEOUT) -> dict[str, object]:
|
|
138
|
-
"""Run a foreground command
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
111
|
+
"""Run a foreground command with bounded output."""
|
|
112
|
+
safe_timeout = max(1, min(int(timeout), 24 * 60 * 60))
|
|
113
|
+
return _call(
|
|
114
|
+
lambda: _client().exec(session_id, command, timeout=safe_timeout, owner=_owner()),
|
|
115
|
+
lambda value: {
|
|
116
|
+
"ok": True,
|
|
117
|
+
"result": {
|
|
118
|
+
"session_id": value.session_id,
|
|
119
|
+
"exit_code": value.exit_code,
|
|
120
|
+
"stdout": value.stdout.decode("utf-8", errors="replace"),
|
|
121
|
+
"stderr": value.stderr.decode("utf-8", errors="replace"),
|
|
122
|
+
"timed_out": value.timed_out,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
)
|
|
149
126
|
|
|
150
127
|
|
|
151
128
|
@mcp.tool()
|
|
152
129
|
def task_start(session_id: str, command: str) -> dict[str, object]:
|
|
153
|
-
"""Start a long-running
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
130
|
+
"""Start a long-running command."""
|
|
131
|
+
return _call(
|
|
132
|
+
lambda: _request("task.start", {"session_id": session_id, "command": command}),
|
|
133
|
+
lambda value: {"ok": True, "task": value},
|
|
134
|
+
)
|
|
158
135
|
|
|
159
136
|
|
|
160
137
|
@mcp.tool()
|
|
161
138
|
def task_status(session_id: str, task_id: str, tail_lines: int = 80) -> dict[str, object]:
|
|
162
|
-
"""
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
"
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
return _error(exc)
|
|
139
|
+
"""Get task state and recent output."""
|
|
140
|
+
return _call(
|
|
141
|
+
lambda: _request(
|
|
142
|
+
"task.status",
|
|
143
|
+
{"session_id": session_id, "task_id": task_id, "tail_lines": max(0, min(int(tail_lines), 10000))},
|
|
144
|
+
),
|
|
145
|
+
lambda value: {"ok": True, "status": value},
|
|
146
|
+
)
|
|
171
147
|
|
|
172
148
|
|
|
173
149
|
@mcp.tool()
|
|
174
150
|
def task_stop(session_id: str, task_id: str) -> dict[str, object]:
|
|
175
|
-
"""
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
151
|
+
"""Cancel a background task."""
|
|
152
|
+
return _call(
|
|
153
|
+
lambda: _request("task.cancel", {"session_id": session_id, "task_id": task_id}),
|
|
154
|
+
lambda value: {"ok": True, **value},
|
|
155
|
+
)
|
|
180
156
|
|
|
181
157
|
|
|
182
158
|
@mcp.tool()
|
|
183
159
|
def session_write(session_id: str, text: str) -> dict[str, object]:
|
|
184
|
-
"""
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
160
|
+
"""Write UTF-8 data to an interactive shell."""
|
|
161
|
+
if not text:
|
|
162
|
+
return _error(ValueError("text must not be empty"))
|
|
163
|
+
return _call(
|
|
164
|
+
lambda: _client().shell_write(session_id, text.encode("utf-8"), owner=_owner()),
|
|
165
|
+
lambda value: {"ok": True, "written": value},
|
|
166
|
+
)
|
|
191
167
|
|
|
192
168
|
|
|
193
169
|
@mcp.tool()
|
|
194
170
|
def session_read(session_id: str, timeout: float = 1.0, max_chars: int = 20000) -> dict[str, object]:
|
|
195
|
-
"""Read
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
_daemon_payload(timeout=safe_timeout, max_chars=safe_max),
|
|
203
|
-
timeout=safe_timeout + 5,
|
|
204
|
-
)
|
|
205
|
-
except Exception as exc:
|
|
206
|
-
return _error(exc)
|
|
171
|
+
"""Read available UTF-8 output from an interactive shell."""
|
|
172
|
+
safe_timeout = max(0.0, min(float(timeout), 10.0))
|
|
173
|
+
safe_max = max(1, min(int(max_chars), 120000))
|
|
174
|
+
return _call(
|
|
175
|
+
lambda: _client().shell_read(session_id, timeout=safe_timeout, max_bytes=safe_max, owner=_owner()),
|
|
176
|
+
lambda value: {"ok": True, "text": value.data.decode("utf-8", errors="replace"), "alive": value.alive},
|
|
177
|
+
)
|
|
207
178
|
|
|
208
179
|
|
|
209
180
|
@mcp.tool()
|
|
210
181
|
def file_write_text(
|
|
211
|
-
session_id: str,
|
|
212
|
-
remote_path: str,
|
|
213
|
-
content: str,
|
|
214
|
-
mode: str = "0644",
|
|
215
|
-
max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
|
|
182
|
+
session_id: str, remote_path: str, content: str, mode: str = "0644", max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES
|
|
216
183
|
) -> dict[str, object]:
|
|
217
|
-
"""Write a UTF-8
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
)
|
|
225
|
-
except Exception as exc:
|
|
226
|
-
return _error(exc)
|
|
184
|
+
"""Write a UTF-8 remote file atomically."""
|
|
185
|
+
return _call(
|
|
186
|
+
lambda: _client().write_text(
|
|
187
|
+
session_id, remote_path, content, mode=_mode(mode), max_bytes=_limit(max_bytes), owner=_owner()
|
|
188
|
+
),
|
|
189
|
+
lambda value: {"ok": True, "transfer": asdict(value)},
|
|
190
|
+
)
|
|
227
191
|
|
|
228
192
|
|
|
229
193
|
@mcp.tool()
|
|
230
194
|
def file_read_text(session_id: str, remote_path: str, max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES) -> dict[str, object]:
|
|
231
|
-
"""Read a UTF-8
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
f"/sessions/{_qid(session_id)}/files",
|
|
237
|
-
_daemon_payload(action="read_text", remote_path=remote_path, max_bytes=safe_max),
|
|
238
|
-
)
|
|
239
|
-
except Exception as exc:
|
|
240
|
-
return _error(exc)
|
|
195
|
+
"""Read a bounded UTF-8 remote file."""
|
|
196
|
+
return _call(
|
|
197
|
+
lambda: _client().read_text(session_id, remote_path, max_bytes=_limit(max_bytes), owner=_owner()),
|
|
198
|
+
lambda value: {"ok": True, "content": value},
|
|
199
|
+
)
|
|
241
200
|
|
|
242
201
|
|
|
243
202
|
@mcp.tool()
|
|
244
203
|
def file_upload(
|
|
245
|
-
session_id: str,
|
|
246
|
-
local_path: str,
|
|
247
|
-
remote_path: str,
|
|
248
|
-
mode: str = "0644",
|
|
249
|
-
max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
|
|
204
|
+
session_id: str, local_path: str, remote_path: str, mode: str = "0644", max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES
|
|
250
205
|
) -> dict[str, object]:
|
|
251
|
-
"""
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
_daemon_payload(action="upload", local_path=local_path, remote_path=remote_path, mode=mode, max_bytes=safe_max),
|
|
258
|
-
)
|
|
259
|
-
except Exception as exc:
|
|
260
|
-
return _error(exc)
|
|
206
|
+
"""Stream a local file to the remote host."""
|
|
207
|
+
del max_bytes
|
|
208
|
+
return _call(
|
|
209
|
+
lambda: _client().upload_file(session_id, local_path, remote_path, mode=_mode(mode), owner=_owner()),
|
|
210
|
+
lambda value: {"ok": True, "transfer": asdict(value)},
|
|
211
|
+
)
|
|
261
212
|
|
|
262
213
|
|
|
263
214
|
@mcp.tool()
|
|
264
215
|
def file_download(
|
|
265
|
-
session_id: str,
|
|
266
|
-
remote_path: str,
|
|
267
|
-
local_path: str,
|
|
268
|
-
max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
|
|
216
|
+
session_id: str, remote_path: str, local_path: str, max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES
|
|
269
217
|
) -> dict[str, object]:
|
|
270
|
-
"""
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
_daemon_payload(action="download", remote_path=remote_path, local_path=local_path, max_bytes=safe_max),
|
|
277
|
-
)
|
|
278
|
-
except Exception as exc:
|
|
279
|
-
return _error(exc)
|
|
218
|
+
"""Stream a remote file to a local path."""
|
|
219
|
+
del max_bytes
|
|
220
|
+
return _call(
|
|
221
|
+
lambda: _client().download_file(session_id, remote_path, local_path, owner=_owner()),
|
|
222
|
+
lambda value: {"ok": True, "transfer": asdict(value)},
|
|
223
|
+
)
|
|
280
224
|
|
|
281
225
|
|
|
282
226
|
def main(argv: list[str] | None = None) -> Any:
|