@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
|
@@ -1,28 +1,23 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import sys
|
|
4
|
-
from dataclasses import asdict
|
|
5
5
|
from typing import Any
|
|
6
|
+
from urllib.parse import quote
|
|
6
7
|
|
|
7
8
|
from mcp.server.fastmcp import FastMCP
|
|
8
9
|
|
|
9
|
-
from . import cli
|
|
10
|
-
from .hosts import HostRegistry, load_hosts
|
|
10
|
+
from . import cli, daemon
|
|
11
11
|
from .manager import (
|
|
12
12
|
DEFAULT_COMMAND_TIMEOUT,
|
|
13
13
|
DEFAULT_CONNECT_TIMEOUT,
|
|
14
|
+
DEFAULT_MAX_TRANSFER_BYTES,
|
|
15
|
+
MAX_SHELL_TRANSFER_BYTES,
|
|
14
16
|
PolicyDeniedError,
|
|
15
17
|
RemoteCommandError,
|
|
16
|
-
SessionManager,
|
|
17
18
|
)
|
|
18
|
-
from .policy import build_audit_sink, load_policy
|
|
19
19
|
|
|
20
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
21
|
|
|
27
22
|
|
|
28
23
|
_ERROR_CODES = {
|
|
@@ -40,200 +35,239 @@ def _error_code(exc: Exception) -> str:
|
|
|
40
35
|
return "internal"
|
|
41
36
|
|
|
42
37
|
|
|
43
|
-
def
|
|
44
|
-
""
|
|
38
|
+
def _error(exc: Exception) -> dict[str, object]:
|
|
39
|
+
return {"ok": False, "error": str(exc), "error_code": _error_code(exc)}
|
|
45
40
|
|
|
46
|
-
global _registry
|
|
47
|
-
_registry = HostRegistry(load_hosts())
|
|
48
|
-
_manager.hosts = _registry
|
|
49
|
-
return _registry
|
|
50
41
|
|
|
42
|
+
def _owner_agent_id() -> str | None:
|
|
43
|
+
"""Return the optional caller identity stamped onto daemon-owned sessions."""
|
|
51
44
|
|
|
52
|
-
|
|
53
|
-
return
|
|
45
|
+
value = os.environ.get("HOSTBRIDGE_AGENT_ID", "").strip()
|
|
46
|
+
return value or None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _daemon_request(method: str, path: str, payload: dict[str, object] | None = None, *, timeout: float = 5.0) -> dict[str, object]:
|
|
50
|
+
try:
|
|
51
|
+
return daemon.request_json(method, path, payload, timeout=timeout)
|
|
52
|
+
except OSError as exc:
|
|
53
|
+
# MCP stdout must stay protocol-clean, so auto-start the daemon quietly.
|
|
54
|
+
if daemon.start_background(quiet=True) != 0:
|
|
55
|
+
raise RuntimeError(f"hostbridge daemon failed to start; see {daemon.LOG_FILE}") from exc
|
|
56
|
+
return daemon.request_json(method, path, payload, timeout=timeout)
|
|
54
57
|
|
|
55
58
|
|
|
56
|
-
def
|
|
57
|
-
|
|
59
|
+
def _daemon_payload(**payload: object) -> dict[str, object]:
|
|
60
|
+
owner = _owner_agent_id()
|
|
61
|
+
if owner is not None:
|
|
62
|
+
payload["owner_agent_id"] = owner
|
|
63
|
+
return payload
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _qid(value: str) -> str:
|
|
67
|
+
return quote(value, safe="")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _safe_transfer_limit(max_bytes: int) -> int:
|
|
71
|
+
return max(1, min(int(max_bytes), MAX_SHELL_TRANSFER_BYTES))
|
|
58
72
|
|
|
59
73
|
|
|
60
74
|
@mcp.tool()
|
|
61
75
|
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
|
-
"""
|
|
76
|
+
"""List approved connection targets and local metadata when available."""
|
|
67
77
|
try:
|
|
68
|
-
|
|
69
|
-
return _ok(hosts=registry.describe())
|
|
78
|
+
return _daemon_request("POST", "/hosts/reload")
|
|
70
79
|
except Exception as exc:
|
|
71
80
|
return _error(exc)
|
|
72
81
|
|
|
73
82
|
|
|
74
83
|
@mcp.tool()
|
|
75
84
|
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
|
-
"""
|
|
85
|
+
"""Reload approved connection targets from hosts.json without restarting."""
|
|
81
86
|
try:
|
|
82
|
-
|
|
83
|
-
return _ok(hosts=registry.describe())
|
|
87
|
+
return _daemon_request("POST", "/hosts/reload")
|
|
84
88
|
except Exception as exc:
|
|
85
89
|
return _error(exc)
|
|
86
90
|
|
|
87
91
|
|
|
88
92
|
@mcp.tool()
|
|
89
93
|
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
|
-
"""
|
|
94
|
+
"""Open a persistent interactive shell for one approved host."""
|
|
98
95
|
try:
|
|
99
|
-
_reload_hosts()
|
|
100
96
|
timeout = max(5, min(int(connect_timeout), 180))
|
|
101
|
-
|
|
102
|
-
sessions = [item for item in _manager.list_sessions() if item["session_id"] == session.id]
|
|
103
|
-
return _ok(session=sessions[0])
|
|
97
|
+
return _daemon_request("POST", "/sessions", _daemon_payload(host_id=host_id, connect_timeout=timeout), timeout=timeout + 5)
|
|
104
98
|
except Exception as exc:
|
|
105
99
|
return _error(exc)
|
|
106
100
|
|
|
107
101
|
|
|
108
102
|
@mcp.tool()
|
|
109
103
|
def sessions_list() -> dict[str, object]:
|
|
110
|
-
"""List currently open sessions
|
|
111
|
-
|
|
104
|
+
"""List currently open daemon-owned sessions."""
|
|
105
|
+
try:
|
|
106
|
+
return _daemon_request("GET", "/sessions")
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
return _error(exc)
|
|
112
109
|
|
|
113
110
|
|
|
114
111
|
@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
|
-
"""
|
|
112
|
+
def session_close(session_id: str, force: bool = False) -> dict[str, object]:
|
|
113
|
+
"""Close one persistent session and free its PTY."""
|
|
121
114
|
try:
|
|
122
|
-
return
|
|
115
|
+
return _daemon_request("DELETE", f"/sessions/{_qid(session_id)}", _daemon_payload(force=bool(force)))
|
|
123
116
|
except Exception as exc:
|
|
124
117
|
return _error(exc)
|
|
125
118
|
|
|
126
119
|
|
|
127
120
|
@mcp.tool()
|
|
128
121
|
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
|
-
"""
|
|
122
|
+
"""Close every live session. Use during graceful shutdown or reset."""
|
|
133
123
|
try:
|
|
134
|
-
return
|
|
124
|
+
return _daemon_request("POST", "/sessions/close_all")
|
|
135
125
|
except Exception as exc:
|
|
136
126
|
return _error(exc)
|
|
137
127
|
|
|
138
128
|
|
|
139
129
|
@mcp.tool()
|
|
140
130
|
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
|
-
"""
|
|
131
|
+
"""Run a foreground command on an open session and wait for completion or timeout."""
|
|
151
132
|
try:
|
|
152
133
|
safe_timeout = max(1, min(int(timeout), 24 * 60 * 60))
|
|
153
|
-
|
|
154
|
-
|
|
134
|
+
return _daemon_request(
|
|
135
|
+
"POST",
|
|
136
|
+
f"/sessions/{_qid(session_id)}/commands",
|
|
137
|
+
_daemon_payload(command=command, timeout=safe_timeout),
|
|
138
|
+
timeout=safe_timeout + 5,
|
|
139
|
+
)
|
|
155
140
|
except Exception as exc:
|
|
156
141
|
return _error(exc)
|
|
157
142
|
|
|
158
143
|
|
|
159
144
|
@mcp.tool()
|
|
160
145
|
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
|
-
"""
|
|
146
|
+
"""Start a long-running background task on the remote host."""
|
|
170
147
|
try:
|
|
171
|
-
|
|
172
|
-
return _ok(task=asdict(task))
|
|
148
|
+
return _daemon_request("POST", f"/sessions/{_qid(session_id)}/tasks", _daemon_payload(command=command))
|
|
173
149
|
except Exception as exc:
|
|
174
150
|
return _error(exc)
|
|
175
151
|
|
|
176
152
|
|
|
177
153
|
@mcp.tool()
|
|
178
154
|
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
|
-
"""
|
|
155
|
+
"""Poll a background task: state, exit code, and recent log tail."""
|
|
186
156
|
try:
|
|
187
|
-
return
|
|
157
|
+
return _daemon_request(
|
|
158
|
+
"GET",
|
|
159
|
+
f"/sessions/{_qid(session_id)}/tasks/{_qid(task_id)}?tail_lines={int(tail_lines)}",
|
|
160
|
+
_daemon_payload(),
|
|
161
|
+
)
|
|
188
162
|
except Exception as exc:
|
|
189
163
|
return _error(exc)
|
|
190
164
|
|
|
191
165
|
|
|
192
166
|
@mcp.tool()
|
|
193
167
|
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
|
-
"""
|
|
168
|
+
"""Best-effort terminate a background task by sending SIGTERM to its remote PID."""
|
|
202
169
|
try:
|
|
203
|
-
return
|
|
170
|
+
return _daemon_request("DELETE", f"/sessions/{_qid(session_id)}/tasks/{_qid(task_id)}", _daemon_payload())
|
|
204
171
|
except Exception as exc:
|
|
205
172
|
return _error(exc)
|
|
206
173
|
|
|
207
174
|
|
|
208
175
|
@mcp.tool()
|
|
209
176
|
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
|
-
"""
|
|
177
|
+
"""Send one raw line to a session for rare interactive operations; prefer command_run otherwise."""
|
|
216
178
|
try:
|
|
217
179
|
if not text.strip():
|
|
218
180
|
raise RemoteCommandError("text must not be empty")
|
|
219
|
-
return
|
|
181
|
+
return _daemon_request("POST", f"/sessions/{_qid(session_id)}/write", _daemon_payload(text=text))
|
|
220
182
|
except Exception as exc:
|
|
221
183
|
return _error(exc)
|
|
222
184
|
|
|
223
185
|
|
|
224
186
|
@mcp.tool()
|
|
225
187
|
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
|
-
"""
|
|
188
|
+
"""Read currently available raw output from a session after session_write or interactive commands."""
|
|
233
189
|
try:
|
|
234
190
|
safe_timeout = max(0.0, min(float(timeout), 10.0))
|
|
235
191
|
safe_max = max(1, min(int(max_chars), 120000))
|
|
236
|
-
return
|
|
192
|
+
return _daemon_request(
|
|
193
|
+
"POST",
|
|
194
|
+
f"/sessions/{_qid(session_id)}/read",
|
|
195
|
+
_daemon_payload(timeout=safe_timeout, max_chars=safe_max),
|
|
196
|
+
timeout=safe_timeout + 5,
|
|
197
|
+
)
|
|
198
|
+
except Exception as exc:
|
|
199
|
+
return _error(exc)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@mcp.tool()
|
|
203
|
+
def file_write_text(
|
|
204
|
+
session_id: str,
|
|
205
|
+
remote_path: str,
|
|
206
|
+
content: str,
|
|
207
|
+
mode: str = "0644",
|
|
208
|
+
max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
|
|
209
|
+
) -> dict[str, object]:
|
|
210
|
+
"""Write a UTF-8 text file through the active shell session; supports up to 100MiB via chunked base64."""
|
|
211
|
+
try:
|
|
212
|
+
safe_max = _safe_transfer_limit(max_bytes)
|
|
213
|
+
return _daemon_request(
|
|
214
|
+
"POST",
|
|
215
|
+
f"/sessions/{_qid(session_id)}/files",
|
|
216
|
+
_daemon_payload(action="write_text", remote_path=remote_path, content=content, mode=mode, max_bytes=safe_max),
|
|
217
|
+
)
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
return _error(exc)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@mcp.tool()
|
|
223
|
+
def file_read_text(session_id: str, remote_path: str, max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES) -> dict[str, object]:
|
|
224
|
+
"""Read a UTF-8 text file through the active shell session; supports up to 100MiB via chunked base64."""
|
|
225
|
+
try:
|
|
226
|
+
safe_max = _safe_transfer_limit(max_bytes)
|
|
227
|
+
return _daemon_request(
|
|
228
|
+
"POST",
|
|
229
|
+
f"/sessions/{_qid(session_id)}/files",
|
|
230
|
+
_daemon_payload(action="read_text", remote_path=remote_path, max_bytes=safe_max),
|
|
231
|
+
)
|
|
232
|
+
except Exception as exc:
|
|
233
|
+
return _error(exc)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@mcp.tool()
|
|
237
|
+
def file_upload(
|
|
238
|
+
session_id: str,
|
|
239
|
+
local_path: str,
|
|
240
|
+
remote_path: str,
|
|
241
|
+
mode: str = "0644",
|
|
242
|
+
max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
|
|
243
|
+
) -> dict[str, object]:
|
|
244
|
+
"""Upload a local file through the active shell session; supports up to 100MiB via chunked base64."""
|
|
245
|
+
try:
|
|
246
|
+
safe_max = _safe_transfer_limit(max_bytes)
|
|
247
|
+
return _daemon_request(
|
|
248
|
+
"POST",
|
|
249
|
+
f"/sessions/{_qid(session_id)}/files",
|
|
250
|
+
_daemon_payload(action="upload", local_path=local_path, remote_path=remote_path, mode=mode, max_bytes=safe_max),
|
|
251
|
+
)
|
|
252
|
+
except Exception as exc:
|
|
253
|
+
return _error(exc)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@mcp.tool()
|
|
257
|
+
def file_download(
|
|
258
|
+
session_id: str,
|
|
259
|
+
remote_path: str,
|
|
260
|
+
local_path: str,
|
|
261
|
+
max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
|
|
262
|
+
) -> dict[str, object]:
|
|
263
|
+
"""Download a remote file through the active shell session; supports up to 100MiB via chunked base64."""
|
|
264
|
+
try:
|
|
265
|
+
safe_max = _safe_transfer_limit(max_bytes)
|
|
266
|
+
return _daemon_request(
|
|
267
|
+
"POST",
|
|
268
|
+
f"/sessions/{_qid(session_id)}/files",
|
|
269
|
+
_daemon_payload(action="download", remote_path=remote_path, local_path=local_path, max_bytes=safe_max),
|
|
270
|
+
)
|
|
237
271
|
except Exception as exc:
|
|
238
272
|
return _error(exc)
|
|
239
273
|
|