@neoline/hostbridge 1.4.4 → 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.
@@ -2,281 +2,225 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  import sys
5
- from typing import Any
6
- from urllib.parse import quote
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 .manager import (
12
- DEFAULT_COMMAND_TIMEOUT,
13
- DEFAULT_CONNECT_TIMEOUT,
14
- DEFAULT_MAX_TRANSFER_BYTES,
15
- MAX_SHELL_TRANSFER_BYTES,
16
- PolicyDeniedError,
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
- _ERROR_CODES = {
24
- PolicyDeniedError: "denied",
25
- RemoteCommandError: "remote_error",
26
- }
23
+ def _client() -> HostBridgeClient:
24
+ return HostBridgeClient()
27
25
 
28
26
 
29
- def _error_code(exc: Exception) -> str:
30
- for exc_type, code in _ERROR_CODES.items():
31
- if isinstance(exc, exc_type):
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
- return {"ok": False, "error": str(exc), "error_code": _error_code(exc)}
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 _owner_agent_id() -> str | None:
43
- """Return the optional caller identity stamped onto daemon-owned sessions."""
44
-
45
- value = os.environ.get("HOSTBRIDGE_AGENT_ID", "").strip()
46
- return value or None
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 _daemon_request(method: str, path: str, payload: dict[str, object] | None = None, *, timeout: float = 5.0) -> dict[str, object]:
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 daemon.request_json(method, path, payload, timeout=timeout)
52
- except OSError as exc:
53
- if os.environ.get("HOSTBRIDGE_NPM_LAUNCHER"):
54
- socket_path = daemon.socket_path()
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 daemon.request_json(method, path, payload, timeout=timeout)
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 _qid(value: str) -> str:
74
- return quote(value, safe="")
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 _safe_transfer_limit(max_bytes: int) -> int:
78
- return max(1, min(int(max_bytes), MAX_SHELL_TRANSFER_BYTES))
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 approved connection targets and local metadata when available."""
84
- try:
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 interactive shell for one approved host."""
102
- try:
103
- timeout = max(5, min(int(connect_timeout), 180))
104
- return _daemon_request("POST", "/sessions", _daemon_payload(host_id=host_id, connect_timeout=timeout), timeout=timeout + 5)
105
- except Exception as exc:
106
- return _error(exc)
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 currently open daemon-owned sessions."""
112
- try:
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 one persistent session and free its PTY."""
121
- try:
122
- return _daemon_request("DELETE", f"/sessions/{_qid(session_id)}", _daemon_payload(force=bool(force)))
123
- except Exception as exc:
124
- return _error(exc)
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 every live session. Use during graceful shutdown or reset."""
130
- try:
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 on an open session and wait for completion or timeout."""
139
- try:
140
- safe_timeout = max(1, min(int(timeout), 24 * 60 * 60))
141
- return _daemon_request(
142
- "POST",
143
- f"/sessions/{_qid(session_id)}/commands",
144
- _daemon_payload(command=command, timeout=safe_timeout),
145
- timeout=safe_timeout + 5,
146
- )
147
- except Exception as exc:
148
- return _error(exc)
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 background task on the remote host."""
154
- try:
155
- return _daemon_request("POST", f"/sessions/{_qid(session_id)}/tasks", _daemon_payload(command=command))
156
- except Exception as exc:
157
- return _error(exc)
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
- """Poll a background task: state, exit code, and recent log tail."""
163
- try:
164
- return _daemon_request(
165
- "GET",
166
- f"/sessions/{_qid(session_id)}/tasks/{_qid(task_id)}?tail_lines={int(tail_lines)}",
167
- _daemon_payload(),
168
- )
169
- except Exception as exc:
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
- """Best-effort terminate a background task by sending SIGTERM to its remote PID."""
176
- try:
177
- return _daemon_request("DELETE", f"/sessions/{_qid(session_id)}/tasks/{_qid(task_id)}", _daemon_payload())
178
- except Exception as exc:
179
- return _error(exc)
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
- """Send one raw line to a session for rare interactive operations; prefer command_run otherwise."""
185
- try:
186
- if not text.strip():
187
- raise RemoteCommandError("text must not be empty")
188
- return _daemon_request("POST", f"/sessions/{_qid(session_id)}/write", _daemon_payload(text=text))
189
- except Exception as exc:
190
- return _error(exc)
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 currently available raw output from a session after session_write or interactive commands."""
196
- try:
197
- safe_timeout = max(0.0, min(float(timeout), 10.0))
198
- safe_max = max(1, min(int(max_chars), 120000))
199
- return _daemon_request(
200
- "POST",
201
- f"/sessions/{_qid(session_id)}/read",
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 text file through the active shell session; supports up to 100MiB via chunked base64."""
218
- try:
219
- safe_max = _safe_transfer_limit(max_bytes)
220
- return _daemon_request(
221
- "POST",
222
- f"/sessions/{_qid(session_id)}/files",
223
- _daemon_payload(action="write_text", remote_path=remote_path, content=content, mode=mode, max_bytes=safe_max),
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 text file through the active shell session; supports up to 100MiB via chunked base64."""
232
- try:
233
- safe_max = _safe_transfer_limit(max_bytes)
234
- return _daemon_request(
235
- "POST",
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
- """Upload a local file through the active shell session; supports up to 100MiB via chunked base64."""
252
- try:
253
- safe_max = _safe_transfer_limit(max_bytes)
254
- return _daemon_request(
255
- "POST",
256
- f"/sessions/{_qid(session_id)}/files",
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
- """Download a remote file through the active shell session; supports up to 100MiB via chunked base64."""
271
- try:
272
- safe_max = _safe_transfer_limit(max_bytes)
273
- return _daemon_request(
274
- "POST",
275
- f"/sessions/{_qid(session_id)}/files",
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: