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