mcp-win-stdio-ssh 0.2.4__py3-none-any.whl
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.
- mcp_win_stdio/ssh/__init__.py +5 -0
- mcp_win_stdio/ssh/__main__.py +8 -0
- mcp_win_stdio/ssh/cli.py +152 -0
- mcp_win_stdio/ssh/connection.py +369 -0
- mcp_win_stdio/ssh/guide.py +94 -0
- mcp_win_stdio/ssh/pty_session.py +215 -0
- mcp_win_stdio/ssh/server.py +1380 -0
- mcp_win_stdio/ssh/sftp_ops.py +428 -0
- mcp_win_stdio/ssh/tunnels.py +168 -0
- mcp_win_stdio_ssh-0.2.4.dist-info/METADATA +96 -0
- mcp_win_stdio_ssh-0.2.4.dist-info/RECORD +13 -0
- mcp_win_stdio_ssh-0.2.4.dist-info/WHEEL +4 -0
- mcp_win_stdio_ssh-0.2.4.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,1380 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Unified Multi-SSH Connection & Remote System Management MCP Server for mcp-win-stdio.
|
|
4
|
+
Provides sticky multi-host pooling, command execution, PTY sessions, SFTP operations,
|
|
5
|
+
service/process management, and local port forwarding tunnels.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import re
|
|
12
|
+
import shlex
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any, Dict, List, Literal, Optional, Union
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from mcp.server.mcpserver import MCPServer as FastMCP
|
|
20
|
+
except (ImportError, ModuleNotFoundError):
|
|
21
|
+
from mcp.server.fastmcp import FastMCP
|
|
22
|
+
|
|
23
|
+
import paramiko
|
|
24
|
+
|
|
25
|
+
from mcp_win_stdio.ssh.connection import (
|
|
26
|
+
_normalize_host_param,
|
|
27
|
+
close_all_connections,
|
|
28
|
+
close_connection,
|
|
29
|
+
get_active_host_name,
|
|
30
|
+
get_all_registered_hosts,
|
|
31
|
+
get_cached_or_connect,
|
|
32
|
+
get_pool_status,
|
|
33
|
+
load_ssh_hosts,
|
|
34
|
+
resolve_host_info,
|
|
35
|
+
save_ssh_hosts,
|
|
36
|
+
set_active_host_name,
|
|
37
|
+
)
|
|
38
|
+
from mcp_win_stdio.ssh.pty_session import (
|
|
39
|
+
close_all_pty_sessions,
|
|
40
|
+
close_pty_session,
|
|
41
|
+
list_pty_sessions,
|
|
42
|
+
read_pty_buffer,
|
|
43
|
+
send_to_pty,
|
|
44
|
+
start_pty_session,
|
|
45
|
+
strip_ansi,
|
|
46
|
+
)
|
|
47
|
+
from mcp_win_stdio.ssh.sftp_ops import (
|
|
48
|
+
download_path,
|
|
49
|
+
list_remote_directory,
|
|
50
|
+
read_remote_text_file,
|
|
51
|
+
remove_remote_path,
|
|
52
|
+
stat_remote_path,
|
|
53
|
+
upload_path,
|
|
54
|
+
write_remote_text_file,
|
|
55
|
+
)
|
|
56
|
+
from mcp_win_stdio.ssh.tunnels import (
|
|
57
|
+
close_all_tunnels,
|
|
58
|
+
close_tunnel,
|
|
59
|
+
list_active_tunnels,
|
|
60
|
+
open_local_tunnel,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
mcp = FastMCP("ssh-mcp")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ==============================================================================
|
|
67
|
+
# Helper Functions
|
|
68
|
+
# ==============================================================================
|
|
69
|
+
|
|
70
|
+
def _format_ssh_error(e: Exception, host: Optional[str] = None, command: Optional[str] = None) -> Dict[str, Any]:
|
|
71
|
+
"""Format SSH exception into clean, structured diagnostics with helpful advice."""
|
|
72
|
+
error_payload = {
|
|
73
|
+
"error": True,
|
|
74
|
+
"error_type": type(e).__name__,
|
|
75
|
+
"message": str(e).strip(),
|
|
76
|
+
"host": host or get_active_host_name() or "unknown",
|
|
77
|
+
}
|
|
78
|
+
if command:
|
|
79
|
+
error_payload["command_snippet"] = command[:150] + ("..." if len(command) > 150 else "")
|
|
80
|
+
|
|
81
|
+
msg = str(e).lower()
|
|
82
|
+
if "authentication failed" in msg or "permission denied (publickey)" in msg:
|
|
83
|
+
error_payload["suggestion"] = (
|
|
84
|
+
"Authentication failed: Check if the SSH private key path is correct, "
|
|
85
|
+
"the key is added to ssh-agent, or provide password/passphrase in add_host."
|
|
86
|
+
)
|
|
87
|
+
elif "connection refused" in msg:
|
|
88
|
+
error_payload["suggestion"] = (
|
|
89
|
+
"Connection refused: Ensure SSH server (sshd) is running on the target port and firewall allows traffic."
|
|
90
|
+
)
|
|
91
|
+
elif "timed out" in msg or "timeout" in msg:
|
|
92
|
+
error_payload["suggestion"] = (
|
|
93
|
+
"Connection timed out: Check if the remote host IP/hostname is reachable and not blocked by a security group."
|
|
94
|
+
)
|
|
95
|
+
elif "not in known_hosts" in msg:
|
|
96
|
+
error_payload["suggestion"] = "Host key verification failed. The host key might have changed."
|
|
97
|
+
|
|
98
|
+
return error_payload
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _truncate_output(
|
|
102
|
+
text: str,
|
|
103
|
+
max_lines: int = 250,
|
|
104
|
+
max_chars: int = 20000,
|
|
105
|
+
notice_context: str = "output",
|
|
106
|
+
) -> Dict[str, Any]:
|
|
107
|
+
"""Token-safe truncation helper for command output and text streams."""
|
|
108
|
+
lines = text.splitlines()
|
|
109
|
+
total_lines = len(lines)
|
|
110
|
+
total_chars = len(text)
|
|
111
|
+
truncated = False
|
|
112
|
+
|
|
113
|
+
if total_lines > max_lines:
|
|
114
|
+
lines = lines[:max_lines]
|
|
115
|
+
truncated = True
|
|
116
|
+
|
|
117
|
+
result_text = "\n".join(lines)
|
|
118
|
+
if len(result_text) > max_chars:
|
|
119
|
+
result_text = result_text[:max_chars]
|
|
120
|
+
truncated = True
|
|
121
|
+
|
|
122
|
+
res = {
|
|
123
|
+
"content": result_text,
|
|
124
|
+
"total_lines": total_lines,
|
|
125
|
+
"returned_lines": len(lines),
|
|
126
|
+
"total_chars": total_chars,
|
|
127
|
+
"returned_chars": len(result_text),
|
|
128
|
+
"truncated": truncated,
|
|
129
|
+
}
|
|
130
|
+
if truncated:
|
|
131
|
+
res["notice"] = (
|
|
132
|
+
f"... [TRUNCATED: Showing {len(lines)} of {total_lines} lines ({len(result_text)} of {total_chars} chars) "
|
|
133
|
+
f"to protect agent context window. Narrow your command, use grep, or view logs via ssh_tail_logs / sftp_read_file] ..."
|
|
134
|
+
)
|
|
135
|
+
return res
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _run_exec_channel(
|
|
139
|
+
client: paramiko.SSHClient,
|
|
140
|
+
command: str,
|
|
141
|
+
cwd: Optional[str] = None,
|
|
142
|
+
env: Optional[Dict[str, str]] = None,
|
|
143
|
+
timeout: int = 60,
|
|
144
|
+
max_lines: int = 250,
|
|
145
|
+
max_chars: int = 20000,
|
|
146
|
+
) -> Dict[str, Any]:
|
|
147
|
+
"""Execute command over SSH channel with timeout, cwd, environment variables, and token-safe output truncation."""
|
|
148
|
+
cmd = command
|
|
149
|
+
if cwd:
|
|
150
|
+
cmd = f"cd {shlex.quote(cwd)} && {cmd}"
|
|
151
|
+
|
|
152
|
+
if env:
|
|
153
|
+
exports = " ".join([f"{k}={shlex.quote(str(v))}" for k, v in env.items()])
|
|
154
|
+
cmd = f"export {exports} && {cmd}"
|
|
155
|
+
|
|
156
|
+
start_t = time.time()
|
|
157
|
+
stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout, get_pty=False)
|
|
158
|
+
|
|
159
|
+
# Read output
|
|
160
|
+
out_str = stdout.read().decode("utf-8", errors="replace")
|
|
161
|
+
err_str = stderr.read().decode("utf-8", errors="replace")
|
|
162
|
+
exit_code = stdout.channel.recv_exit_status()
|
|
163
|
+
duration_ms = round((time.time() - start_t) * 1000, 2)
|
|
164
|
+
|
|
165
|
+
capped_out = _truncate_output(out_str.strip(), max_lines=max_lines, max_chars=max_chars, notice_context="stdout")
|
|
166
|
+
capped_err = _truncate_output(err_str.strip(), max_lines=100, max_chars=8000, notice_context="stderr")
|
|
167
|
+
|
|
168
|
+
res = {
|
|
169
|
+
"command": command,
|
|
170
|
+
"exit_code": exit_code,
|
|
171
|
+
"duration_ms": duration_ms,
|
|
172
|
+
"stdout": capped_out["content"],
|
|
173
|
+
"stderr": capped_err["content"],
|
|
174
|
+
"is_success": exit_code == 0,
|
|
175
|
+
"truncated": capped_out["truncated"] or capped_err["truncated"],
|
|
176
|
+
}
|
|
177
|
+
if capped_out.get("notice"):
|
|
178
|
+
res["notice"] = capped_out["notice"]
|
|
179
|
+
return res
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ==============================================================================
|
|
183
|
+
# 1. Host & Connection Management Tools
|
|
184
|
+
# ==============================================================================
|
|
185
|
+
|
|
186
|
+
@mcp.tool()
|
|
187
|
+
def list_hosts() -> Dict[str, Any]:
|
|
188
|
+
"""
|
|
189
|
+
List all configured and discovered SSH hosts (from ~/.ssh/config, persistent storage, and environment),
|
|
190
|
+
indicating the active sticky host, user, hostname, port, and connection status.
|
|
191
|
+
"""
|
|
192
|
+
all_hosts = get_all_registered_hosts()
|
|
193
|
+
active_name = get_active_host_name()
|
|
194
|
+
|
|
195
|
+
pool_status = {item["name"]: item for item in get_pool_status()}
|
|
196
|
+
|
|
197
|
+
results = []
|
|
198
|
+
for name, info in all_hosts.items():
|
|
199
|
+
is_active = (name == active_name)
|
|
200
|
+
in_pool = pool_status.get(name, {})
|
|
201
|
+
|
|
202
|
+
results.append({
|
|
203
|
+
"name": name,
|
|
204
|
+
"hostname": info.get("hostname", "localhost"),
|
|
205
|
+
"user": info.get("user", "root"),
|
|
206
|
+
"port": info.get("port", 22),
|
|
207
|
+
"keyPath": info.get("key_path"),
|
|
208
|
+
"hasPassword": bool(info.get("password")),
|
|
209
|
+
"jumpHost": info.get("jump_host") or info.get("proxyjump"),
|
|
210
|
+
"source": info.get("source", "config"),
|
|
211
|
+
"isActiveDefault": is_active,
|
|
212
|
+
"poolConnection": "connected" if in_pool.get("isActive") else "idle",
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
"activeHost": active_name,
|
|
217
|
+
"totalHosts": len(results),
|
|
218
|
+
"hosts": results,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@mcp.tool()
|
|
223
|
+
def use_host(host: str) -> Dict[str, Any]:
|
|
224
|
+
"""
|
|
225
|
+
Switch the active default SSH host context. All subsequent SSH, SFTP, and diagnostic tool calls
|
|
226
|
+
will target this host automatically when host is not specified.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
host: Host alias, hostname, or user@hostname:port to switch to.
|
|
230
|
+
"""
|
|
231
|
+
try:
|
|
232
|
+
info = resolve_host_info(host)
|
|
233
|
+
target_name = info["name"]
|
|
234
|
+
set_active_host_name(target_name)
|
|
235
|
+
return {
|
|
236
|
+
"success": True,
|
|
237
|
+
"message": f"Active SSH host switched to '{target_name}' ({info.get('user')}@{info.get('hostname')}:{info.get('port')}).",
|
|
238
|
+
"activeHost": target_name,
|
|
239
|
+
"hostInfo": {
|
|
240
|
+
"hostname": info.get("hostname"),
|
|
241
|
+
"user": info.get("user"),
|
|
242
|
+
"port": info.get("port"),
|
|
243
|
+
"source": info.get("source"),
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
except Exception as e:
|
|
247
|
+
return _format_ssh_error(e, host=host)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@mcp.tool()
|
|
251
|
+
def add_host(
|
|
252
|
+
name: str,
|
|
253
|
+
hostname: str,
|
|
254
|
+
user: Optional[str] = None,
|
|
255
|
+
port: int = 22,
|
|
256
|
+
key_path: Optional[str] = None,
|
|
257
|
+
password: Optional[str] = None,
|
|
258
|
+
passphrase: Optional[str] = None,
|
|
259
|
+
jump_host: Optional[str] = None,
|
|
260
|
+
description: Optional[str] = None,
|
|
261
|
+
) -> Dict[str, Any]:
|
|
262
|
+
"""
|
|
263
|
+
Register and persist a new SSH host configuration to ~/.mcp-win-stdio/ssh_hosts.json.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
name: Unique alias name for the host (e.g. 'prod-web-01', 'db-cluster', 'staging').
|
|
267
|
+
hostname: IP address or domain name of the remote server.
|
|
268
|
+
user: SSH login username (defaults to current Windows username or root).
|
|
269
|
+
port: SSH port (default: 22).
|
|
270
|
+
key_path: Path to private key file (e.g. ~/.ssh/id_rsa, C:/Users/.../.ssh/id_ed25519).
|
|
271
|
+
password: Plaintext password (optional, key-based auth preferred).
|
|
272
|
+
passphrase: Key passphrase if private key is encrypted.
|
|
273
|
+
jump_host: Optional bastion/jump host alias to route connection through.
|
|
274
|
+
description: Friendly description of the server role.
|
|
275
|
+
"""
|
|
276
|
+
clean_name = name.strip()
|
|
277
|
+
clean_host = hostname.strip()
|
|
278
|
+
clean_user = user.strip() if user else os.environ.get("USERNAME", "root")
|
|
279
|
+
|
|
280
|
+
saved = load_ssh_hosts()
|
|
281
|
+
hosts_dict = saved.get("hosts", {})
|
|
282
|
+
|
|
283
|
+
host_entry = {
|
|
284
|
+
"hostname": clean_host,
|
|
285
|
+
"user": clean_user,
|
|
286
|
+
"port": port,
|
|
287
|
+
"key_path": key_path.strip() if key_path else None,
|
|
288
|
+
"password": password if password else None,
|
|
289
|
+
"passphrase": passphrase if passphrase else None,
|
|
290
|
+
"jump_host": jump_host.strip() if jump_host else None,
|
|
291
|
+
"description": description.strip() if description else None,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
hosts_dict[clean_name] = host_entry
|
|
295
|
+
saved["hosts"] = hosts_dict
|
|
296
|
+
|
|
297
|
+
if not saved.get("active_host"):
|
|
298
|
+
saved["active_host"] = clean_name
|
|
299
|
+
|
|
300
|
+
save_ssh_hosts(saved)
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
"success": True,
|
|
304
|
+
"message": f"Host '{clean_name}' ({clean_user}@{clean_host}:{port}) registered successfully.",
|
|
305
|
+
"host": clean_name,
|
|
306
|
+
"details": host_entry,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@mcp.tool()
|
|
311
|
+
def remove_host(name: str) -> Dict[str, Any]:
|
|
312
|
+
"""
|
|
313
|
+
Remove a saved SSH host configuration from ~/.mcp-win-stdio/ssh_hosts.json.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
name: Name of the host to delete.
|
|
317
|
+
"""
|
|
318
|
+
clean_name = name.strip()
|
|
319
|
+
saved = load_ssh_hosts()
|
|
320
|
+
hosts_dict = saved.get("hosts", {})
|
|
321
|
+
|
|
322
|
+
if clean_name not in hosts_dict:
|
|
323
|
+
return {
|
|
324
|
+
"error": True,
|
|
325
|
+
"message": f"Host '{clean_name}' not found in saved hosts. (Note: Hosts defined in ~/.ssh/config cannot be deleted via MCP).",
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
del hosts_dict[clean_name]
|
|
329
|
+
saved["hosts"] = hosts_dict
|
|
330
|
+
new_active = saved.get("active_host")
|
|
331
|
+
if new_active == clean_name:
|
|
332
|
+
new_active = next(iter(hosts_dict.keys())) if hosts_dict else None
|
|
333
|
+
saved["active_host"] = new_active
|
|
334
|
+
|
|
335
|
+
save_ssh_hosts(saved)
|
|
336
|
+
set_active_host_name(new_active)
|
|
337
|
+
close_connection(clean_name)
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
"success": True,
|
|
341
|
+
"message": f"Host '{clean_name}' removed successfully.",
|
|
342
|
+
"newActiveHost": new_active,
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@mcp.tool()
|
|
347
|
+
def test_host(host: Optional[str] = None) -> Dict[str, Any]:
|
|
348
|
+
"""
|
|
349
|
+
Test SSH connectivity, authentication, latency, and retrieve remote OS info.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
host: Host alias, hostname, or user@hostname:port (defaults to active host).
|
|
353
|
+
"""
|
|
354
|
+
try:
|
|
355
|
+
host_info = resolve_host_info(host)
|
|
356
|
+
start_t = time.time()
|
|
357
|
+
client = get_cached_or_connect(host_info["name"])
|
|
358
|
+
latency_ms = round((time.time() - start_t) * 1000, 2)
|
|
359
|
+
|
|
360
|
+
# Quick uname / os check
|
|
361
|
+
stdin, stdout, stderr = client.exec_command("uname -srmo 2>/dev/null || ver", timeout=5)
|
|
362
|
+
os_info = stdout.read().decode("utf-8", errors="replace").strip()
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
"success": True,
|
|
366
|
+
"status": "connected",
|
|
367
|
+
"host": host_info["name"],
|
|
368
|
+
"remoteTarget": f"{host_info.get('user')}@{host_info.get('hostname')}:{host_info.get('port')}",
|
|
369
|
+
"latency_ms": latency_ms,
|
|
370
|
+
"remoteOS": os_info or "Unknown OS",
|
|
371
|
+
"message": f"Successfully connected to '{host_info['name']}' in {latency_ms}ms.",
|
|
372
|
+
}
|
|
373
|
+
except Exception as e:
|
|
374
|
+
return _format_ssh_error(e, host=host)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@mcp.tool()
|
|
378
|
+
def list_active_connections() -> Dict[str, Any]:
|
|
379
|
+
"""List all currently active, live SSH sessions in the connection pool."""
|
|
380
|
+
active_conns = get_pool_status()
|
|
381
|
+
return {
|
|
382
|
+
"activeCount": len(active_conns),
|
|
383
|
+
"connections": active_conns,
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@mcp.tool()
|
|
388
|
+
def disconnect_host(host: Optional[str] = None) -> Dict[str, Any]:
|
|
389
|
+
"""
|
|
390
|
+
Close and disconnect the SSH connection for a specific host from the connection pool.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
host: Host name to disconnect (or active host if omitted).
|
|
394
|
+
"""
|
|
395
|
+
closed = close_connection(host)
|
|
396
|
+
name = host or get_active_host_name() or "active host"
|
|
397
|
+
return {
|
|
398
|
+
"success": closed,
|
|
399
|
+
"message": f"Disconnected connection for '{name}'." if closed else f"No active connection found for '{name}'.",
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ==============================================================================
|
|
404
|
+
# 2. Remote Command & Script Execution Tools
|
|
405
|
+
# ==============================================================================
|
|
406
|
+
|
|
407
|
+
@mcp.tool()
|
|
408
|
+
def ssh_exec(
|
|
409
|
+
command: str,
|
|
410
|
+
host: Optional[str] = None,
|
|
411
|
+
cwd: Optional[str] = None,
|
|
412
|
+
timeout: int = 60,
|
|
413
|
+
env: Optional[Dict[str, str]] = None,
|
|
414
|
+
) -> Dict[str, Any]:
|
|
415
|
+
"""
|
|
416
|
+
Execute a non-interactive shell command on the remote SSH host.
|
|
417
|
+
Returns exit code, stdout, stderr, execution duration, and structured results.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
command: The shell command line string to run.
|
|
421
|
+
host: Target SSH host (defaults to active host).
|
|
422
|
+
cwd: Remote working directory to cd into before executing.
|
|
423
|
+
timeout: Execution timeout in seconds (default: 60).
|
|
424
|
+
env: Optional environment variables dictionary to export.
|
|
425
|
+
"""
|
|
426
|
+
try:
|
|
427
|
+
host_info = resolve_host_info(host)
|
|
428
|
+
client = get_cached_or_connect(host_info["name"])
|
|
429
|
+
res = _run_exec_channel(client, command, cwd=cwd, env=env, timeout=timeout)
|
|
430
|
+
res["host"] = host_info["name"]
|
|
431
|
+
return res
|
|
432
|
+
except Exception as e:
|
|
433
|
+
return _format_ssh_error(e, host=host, command=command)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@mcp.tool()
|
|
437
|
+
def ssh_exec_sudo(
|
|
438
|
+
command: str,
|
|
439
|
+
sudo_password: Optional[str] = None,
|
|
440
|
+
host: Optional[str] = None,
|
|
441
|
+
timeout: int = 60,
|
|
442
|
+
) -> Dict[str, Any]:
|
|
443
|
+
"""
|
|
444
|
+
Execute a command with elevated sudo privileges, automatically handling the sudo password prompt if needed.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
command: Command to execute with sudo (e.g. 'systemctl restart nginx', 'apt update').
|
|
448
|
+
sudo_password: Password for sudo prompt (if omitted, uses host password or passwordless sudo).
|
|
449
|
+
host: Target SSH host (defaults to active host).
|
|
450
|
+
timeout: Execution timeout in seconds (default: 60).
|
|
451
|
+
"""
|
|
452
|
+
try:
|
|
453
|
+
host_info = resolve_host_info(host)
|
|
454
|
+
client = get_cached_or_connect(host_info["name"])
|
|
455
|
+
|
|
456
|
+
clean_cmd = command.strip()
|
|
457
|
+
if clean_cmd.startswith("sudo "):
|
|
458
|
+
clean_cmd = clean_cmd[5:].strip()
|
|
459
|
+
|
|
460
|
+
pwd = sudo_password or host_info.get("password")
|
|
461
|
+
|
|
462
|
+
start_t = time.time()
|
|
463
|
+
# Request PTY for sudo prompt handling
|
|
464
|
+
stdin, stdout, stderr = client.exec_command(f"sudo -S -p '[SUDO_PROMPT]' {clean_cmd}", get_pty=True, timeout=timeout)
|
|
465
|
+
|
|
466
|
+
if pwd:
|
|
467
|
+
# Send password when prompt requested
|
|
468
|
+
time.sleep(0.3)
|
|
469
|
+
stdin.write(f"{pwd}\n")
|
|
470
|
+
stdin.flush()
|
|
471
|
+
|
|
472
|
+
out_str = stdout.read().decode("utf-8", errors="replace")
|
|
473
|
+
# Clean out sudo prompt artifacts
|
|
474
|
+
clean_out = re.sub(r"\[SUDO_PROMPT\]", "", out_str).strip()
|
|
475
|
+
clean_out = strip_ansi(clean_out)
|
|
476
|
+
exit_code = stdout.channel.recv_exit_status()
|
|
477
|
+
duration_ms = round((time.time() - start_t) * 1000, 2)
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
"command": f"sudo {clean_cmd}",
|
|
481
|
+
"exit_code": exit_code,
|
|
482
|
+
"duration_ms": duration_ms,
|
|
483
|
+
"stdout": clean_out,
|
|
484
|
+
"is_success": exit_code == 0,
|
|
485
|
+
"host": host_info["name"],
|
|
486
|
+
}
|
|
487
|
+
except Exception as e:
|
|
488
|
+
return _format_ssh_error(e, host=host, command=command)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@mcp.tool()
|
|
492
|
+
def ssh_exec_script(
|
|
493
|
+
script_content: str,
|
|
494
|
+
interpreter: str = "bash",
|
|
495
|
+
host: Optional[str] = None,
|
|
496
|
+
timeout: int = 120,
|
|
497
|
+
) -> Dict[str, Any]:
|
|
498
|
+
"""
|
|
499
|
+
Upload and execute a multi-line script (bash, sh, python, node) on the remote server, returning execution results.
|
|
500
|
+
|
|
501
|
+
Args:
|
|
502
|
+
script_content: Full multi-line script code.
|
|
503
|
+
interpreter: Interpreter to run script with ('bash', 'sh', 'python3', 'node', 'pwsh').
|
|
504
|
+
host: Target SSH host (defaults to active host).
|
|
505
|
+
timeout: Execution timeout in seconds (default: 120).
|
|
506
|
+
"""
|
|
507
|
+
try:
|
|
508
|
+
host_info = resolve_host_info(host)
|
|
509
|
+
client = get_cached_or_connect(host_info["name"])
|
|
510
|
+
sftp = client.open_sftp()
|
|
511
|
+
|
|
512
|
+
# Write to remote temp file
|
|
513
|
+
remote_tmp = f"/tmp/mcp_script_{int(time.time())}_{os.getpid()}.sh"
|
|
514
|
+
try:
|
|
515
|
+
with sftp.open(remote_tmp, "w") as f:
|
|
516
|
+
f.write(script_content)
|
|
517
|
+
sftp.chmod(remote_tmp, 0o755)
|
|
518
|
+
except Exception:
|
|
519
|
+
# Fallback to local /tmp equivalent if /tmp doesn't exist
|
|
520
|
+
remote_tmp = f"mcp_script_{int(time.time())}.sh"
|
|
521
|
+
with sftp.open(remote_tmp, "w") as f:
|
|
522
|
+
f.write(script_content)
|
|
523
|
+
|
|
524
|
+
sftp.close()
|
|
525
|
+
|
|
526
|
+
# Execute
|
|
527
|
+
run_cmd = f"{interpreter} {shlex.quote(remote_tmp)}"
|
|
528
|
+
res = _run_exec_channel(client, run_cmd, timeout=timeout)
|
|
529
|
+
|
|
530
|
+
# Cleanup
|
|
531
|
+
try:
|
|
532
|
+
client.exec_command(f"rm -f {shlex.quote(remote_tmp)}")
|
|
533
|
+
except Exception:
|
|
534
|
+
pass
|
|
535
|
+
|
|
536
|
+
res["host"] = host_info["name"]
|
|
537
|
+
res["scriptLength"] = len(script_content)
|
|
538
|
+
return res
|
|
539
|
+
except Exception as e:
|
|
540
|
+
return _format_ssh_error(e, host=host)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@mcp.tool()
|
|
544
|
+
def ssh_exec_background(
|
|
545
|
+
command: str,
|
|
546
|
+
job_name: Optional[str] = None,
|
|
547
|
+
host: Optional[str] = None,
|
|
548
|
+
log_file: Optional[str] = None,
|
|
549
|
+
) -> Dict[str, Any]:
|
|
550
|
+
"""
|
|
551
|
+
Launch a long-running process in the detached background (using nohup) and track its Process ID (PID).
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
command: Long-running command (e.g. 'npm run start', 'python train.py', 'backup.sh').
|
|
555
|
+
job_name: Optional label for the job.
|
|
556
|
+
host: Target SSH host (defaults to active host).
|
|
557
|
+
log_file: Path to redirect output logs (defaults to /tmp/mcp_bg_<job_name>.log).
|
|
558
|
+
"""
|
|
559
|
+
try:
|
|
560
|
+
host_info = resolve_host_info(host)
|
|
561
|
+
client = get_cached_or_connect(host_info["name"])
|
|
562
|
+
|
|
563
|
+
name = job_name or f"job_{int(time.time())}"
|
|
564
|
+
log_path = log_file or f"/tmp/mcp_bg_{name}.log"
|
|
565
|
+
|
|
566
|
+
# Nohup execution
|
|
567
|
+
bg_cmd = f"nohup {command} > {shlex.quote(log_path)} 2>&1 & echo $!"
|
|
568
|
+
stdin, stdout, stderr = client.exec_command(bg_cmd, timeout=10)
|
|
569
|
+
pid_str = stdout.read().decode("utf-8", errors="replace").strip()
|
|
570
|
+
|
|
571
|
+
try:
|
|
572
|
+
pid = int(pid_str)
|
|
573
|
+
except ValueError:
|
|
574
|
+
pid = None
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
"success": bool(pid),
|
|
578
|
+
"host": host_info["name"],
|
|
579
|
+
"jobName": name,
|
|
580
|
+
"pid": pid,
|
|
581
|
+
"logFile": log_path,
|
|
582
|
+
"command": command,
|
|
583
|
+
"message": f"Background job '{name}' spawned with PID {pid}. Inspect output via ssh_check_job or ssh_tail_logs.",
|
|
584
|
+
}
|
|
585
|
+
except Exception as e:
|
|
586
|
+
return _format_ssh_error(e, host=host, command=command)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
@mcp.tool()
|
|
590
|
+
def ssh_check_job(job_id_or_pid: Union[int, str], host: Optional[str] = None) -> Dict[str, Any]:
|
|
591
|
+
"""
|
|
592
|
+
Check if a detached background job/PID is still running and read the latest log output.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
job_id_or_pid: The PID or job name to check.
|
|
596
|
+
host: Target SSH host (defaults to active host).
|
|
597
|
+
"""
|
|
598
|
+
try:
|
|
599
|
+
host_info = resolve_host_info(host)
|
|
600
|
+
client = get_cached_or_connect(host_info["name"])
|
|
601
|
+
|
|
602
|
+
pid_val = str(job_id_or_pid)
|
|
603
|
+
# Check process status
|
|
604
|
+
ps_cmd = f"ps -p {shlex.quote(pid_val)} -o pid,user,%cpu,%mem,stat,time,command --no-headers 2>/dev/null"
|
|
605
|
+
stdin, stdout, stderr = client.exec_command(ps_cmd, timeout=5)
|
|
606
|
+
ps_out = stdout.read().decode("utf-8", errors="replace").strip()
|
|
607
|
+
is_running = bool(ps_out)
|
|
608
|
+
|
|
609
|
+
# Try to read default log file if exists
|
|
610
|
+
log_candidate = f"/tmp/mcp_bg_{pid_val}.log"
|
|
611
|
+
log_cmd = f"tail -n 30 {shlex.quote(log_candidate)} 2>/dev/null"
|
|
612
|
+
stdin, stdout, stderr = client.exec_command(log_cmd, timeout=5)
|
|
613
|
+
recent_logs = stdout.read().decode("utf-8", errors="replace").strip()
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
"host": host_info["name"],
|
|
617
|
+
"pid": pid_val,
|
|
618
|
+
"isRunning": is_running,
|
|
619
|
+
"processDetails": ps_out if is_running else "Process has terminated or PID not found.",
|
|
620
|
+
"recentLogs": recent_logs or "No log file found at default path.",
|
|
621
|
+
}
|
|
622
|
+
except Exception as e:
|
|
623
|
+
return _format_ssh_error(e, host=host)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
@mcp.tool()
|
|
627
|
+
def ssh_kill_job(pid: int, signal: str = "SIGTERM", host: Optional[str] = None) -> Dict[str, Any]:
|
|
628
|
+
"""
|
|
629
|
+
Terminate a remote process by PID.
|
|
630
|
+
|
|
631
|
+
Args:
|
|
632
|
+
pid: Remote process ID.
|
|
633
|
+
signal: Signal name ('SIGTERM', 'SIGKILL', 'SIGHUP', 'SIGINT').
|
|
634
|
+
host: Target SSH host (defaults to active host).
|
|
635
|
+
"""
|
|
636
|
+
try:
|
|
637
|
+
host_info = resolve_host_info(host)
|
|
638
|
+
client = get_cached_or_connect(host_info["name"])
|
|
639
|
+
|
|
640
|
+
sig_flag = "-9" if signal == "SIGKILL" else ("-15" if signal == "SIGTERM" else "-1")
|
|
641
|
+
kill_cmd = f"kill {sig_flag} {pid}"
|
|
642
|
+
res = _run_exec_channel(client, kill_cmd, timeout=10)
|
|
643
|
+
return {
|
|
644
|
+
"success": res["is_success"],
|
|
645
|
+
"host": host_info["name"],
|
|
646
|
+
"pid": pid,
|
|
647
|
+
"signal": signal,
|
|
648
|
+
"message": f"Sent {signal} to PID {pid}." if res["is_success"] else f"Failed to kill PID {pid}: {res['stderr']}",
|
|
649
|
+
}
|
|
650
|
+
except Exception as e:
|
|
651
|
+
return _format_ssh_error(e, host=host)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
# ==============================================================================
|
|
655
|
+
# 3. Interactive PTY / Pseudo-Terminal Tools
|
|
656
|
+
# ==============================================================================
|
|
657
|
+
|
|
658
|
+
@mcp.tool()
|
|
659
|
+
def ssh_pty_start(
|
|
660
|
+
session_name: str,
|
|
661
|
+
host: Optional[str] = None,
|
|
662
|
+
term: str = "xterm-256color",
|
|
663
|
+
) -> Dict[str, Any]:
|
|
664
|
+
"""
|
|
665
|
+
Start an interactive pseudo-terminal (PTY) session for stateful interactions, REPLs, and prompts.
|
|
666
|
+
|
|
667
|
+
Args:
|
|
668
|
+
session_name: Unique identifier for this terminal session (e.g. 'wizard', 'python-repl').
|
|
669
|
+
host: Target SSH host (defaults to active host).
|
|
670
|
+
term: Terminal type emulation (default: 'xterm-256color').
|
|
671
|
+
"""
|
|
672
|
+
try:
|
|
673
|
+
return start_pty_session(session_name, host=host, term=term)
|
|
674
|
+
except Exception as e:
|
|
675
|
+
return _format_ssh_error(e, host=host)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
@mcp.tool()
|
|
679
|
+
def ssh_pty_send(
|
|
680
|
+
session_name: str,
|
|
681
|
+
input_text: str,
|
|
682
|
+
wait_ms: int = 500,
|
|
683
|
+
) -> Dict[str, Any]:
|
|
684
|
+
"""
|
|
685
|
+
Send keystrokes, answers, or commands into an active interactive PTY session and collect output.
|
|
686
|
+
|
|
687
|
+
Args:
|
|
688
|
+
session_name: Name of active PTY session.
|
|
689
|
+
input_text: Text/command to send.
|
|
690
|
+
wait_ms: Milliseconds to wait before capturing terminal output (default: 500).
|
|
691
|
+
"""
|
|
692
|
+
try:
|
|
693
|
+
return send_to_pty(session_name, input_text, wait_ms=wait_ms)
|
|
694
|
+
except Exception as e:
|
|
695
|
+
return _format_ssh_error(e)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
@mcp.tool()
|
|
699
|
+
def ssh_pty_read(session_name: str, max_chars: int = 4000) -> Dict[str, Any]:
|
|
700
|
+
"""
|
|
701
|
+
Read the output buffer of an active interactive PTY session.
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
session_name: Name of active PTY session.
|
|
705
|
+
max_chars: Maximum character limit for output.
|
|
706
|
+
"""
|
|
707
|
+
try:
|
|
708
|
+
return read_pty_buffer(session_name, max_chars=max_chars)
|
|
709
|
+
except Exception as e:
|
|
710
|
+
return _format_ssh_error(e)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
@mcp.tool()
|
|
714
|
+
def ssh_list_pty_sessions() -> Dict[str, Any]:
|
|
715
|
+
"""List all currently active interactive PTY sessions."""
|
|
716
|
+
sessions = list_pty_sessions()
|
|
717
|
+
return {
|
|
718
|
+
"activePtyCount": len(sessions),
|
|
719
|
+
"sessions": sessions,
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
@mcp.tool()
|
|
724
|
+
def ssh_pty_close(session_name: str) -> Dict[str, Any]:
|
|
725
|
+
"""
|
|
726
|
+
Close and terminate an interactive PTY session.
|
|
727
|
+
|
|
728
|
+
Args:
|
|
729
|
+
session_name: Name of the session to terminate.
|
|
730
|
+
"""
|
|
731
|
+
return close_pty_session(session_name)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
# ==============================================================================
|
|
735
|
+
# 4. Diagnostics & Remote Services Management
|
|
736
|
+
# ==============================================================================
|
|
737
|
+
|
|
738
|
+
@mcp.tool()
|
|
739
|
+
def ssh_system_overview(host: Optional[str] = None) -> Dict[str, Any]:
|
|
740
|
+
"""
|
|
741
|
+
Retrieve comprehensive system diagnostics: OS version, kernel, CPU count, RAM utilization,
|
|
742
|
+
load averages, uptime, and disk usage (df -h).
|
|
743
|
+
|
|
744
|
+
Args:
|
|
745
|
+
host: Target SSH host (defaults to active host).
|
|
746
|
+
"""
|
|
747
|
+
try:
|
|
748
|
+
host_info = resolve_host_info(host)
|
|
749
|
+
client = get_cached_or_connect(host_info["name"])
|
|
750
|
+
|
|
751
|
+
diag_script = """
|
|
752
|
+
echo "=== UNAME ==="
|
|
753
|
+
uname -a 2>/dev/null || ver
|
|
754
|
+
echo "=== UPTIME ==="
|
|
755
|
+
uptime 2>/dev/null
|
|
756
|
+
echo "=== CPU_INFO ==="
|
|
757
|
+
nproc 2>/dev/null || echo "1"
|
|
758
|
+
echo "=== MEMORY ==="
|
|
759
|
+
free -m 2>/dev/null || echo "N/A"
|
|
760
|
+
echo "=== DISK ==="
|
|
761
|
+
df -h 2>/dev/null || echo "N/A"
|
|
762
|
+
echo "=== OS_RELEASE ==="
|
|
763
|
+
cat /etc/os-release 2>/dev/null || echo "N/A"
|
|
764
|
+
"""
|
|
765
|
+
res = _run_exec_channel(client, diag_script, timeout=15)
|
|
766
|
+
raw = res["stdout"]
|
|
767
|
+
|
|
768
|
+
# Parse sections
|
|
769
|
+
sections = {}
|
|
770
|
+
cur_sec = "header"
|
|
771
|
+
cur_lines = []
|
|
772
|
+
for line in raw.splitlines():
|
|
773
|
+
if line.startswith("=== ") and line.endswith(" ==="):
|
|
774
|
+
sections[cur_sec] = "\n".join(cur_lines).strip()
|
|
775
|
+
cur_sec = line.strip("= ").lower()
|
|
776
|
+
cur_lines = []
|
|
777
|
+
else:
|
|
778
|
+
cur_lines.append(line)
|
|
779
|
+
sections[cur_sec] = "\n".join(cur_lines).strip()
|
|
780
|
+
|
|
781
|
+
return {
|
|
782
|
+
"host": host_info["name"],
|
|
783
|
+
"remoteTarget": f"{host_info.get('user')}@{host_info.get('hostname')}",
|
|
784
|
+
"osRelease": sections.get("os_release", ""),
|
|
785
|
+
"uname": sections.get("uname", ""),
|
|
786
|
+
"uptime": sections.get("uptime", ""),
|
|
787
|
+
"cpuCores": sections.get("cpu_info", ""),
|
|
788
|
+
"memoryUsage": sections.get("memory", ""),
|
|
789
|
+
"diskUsage": sections.get("disk", ""),
|
|
790
|
+
}
|
|
791
|
+
except Exception as e:
|
|
792
|
+
return _format_ssh_error(e, host=host)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
@mcp.tool()
|
|
796
|
+
def ssh_list_packages(
|
|
797
|
+
package_manager: Optional[Literal["apt", "dpkg", "rpm", "dnf", "yum", "pip", "npm", "brew", "pacman", "apk", "winget"]] = None,
|
|
798
|
+
filter: Optional[str] = None,
|
|
799
|
+
limit: int = 50,
|
|
800
|
+
offset: int = 0,
|
|
801
|
+
host: Optional[str] = None,
|
|
802
|
+
) -> Dict[str, Any]:
|
|
803
|
+
"""
|
|
804
|
+
List installed software packages on the remote server across Linux/macOS/Windows package managers.
|
|
805
|
+
Features automatic package manager detection, token-safe pagination, and filtering to prevent context bloat.
|
|
806
|
+
|
|
807
|
+
Args:
|
|
808
|
+
package_manager: Package manager to query (auto-detected if None: 'dpkg'/'apt', 'rpm'/'dnf'/'yum', 'pip', 'npm', 'brew', 'pacman', 'apk', 'winget').
|
|
809
|
+
filter: Optional keyword or pattern filter on package name or description.
|
|
810
|
+
limit: Maximum number of packages to return in this batch (default 50, max 200).
|
|
811
|
+
offset: Starting offset for pagination (default 0).
|
|
812
|
+
host: Target SSH host (defaults to active host).
|
|
813
|
+
"""
|
|
814
|
+
try:
|
|
815
|
+
host_info = resolve_host_info(host)
|
|
816
|
+
client = get_cached_or_connect(host_info["name"])
|
|
817
|
+
|
|
818
|
+
# Detection script if not provided
|
|
819
|
+
pm = package_manager
|
|
820
|
+
if not pm:
|
|
821
|
+
detect_script = """
|
|
822
|
+
if command -v dpkg-query >/dev/null 2>&1; then
|
|
823
|
+
echo "dpkg"
|
|
824
|
+
elif command -v rpm >/dev/null 2>&1; then
|
|
825
|
+
echo "rpm"
|
|
826
|
+
elif command -v pacman >/dev/null 2>&1; then
|
|
827
|
+
echo "pacman"
|
|
828
|
+
elif command -v apk >/dev/null 2>&1; then
|
|
829
|
+
echo "apk"
|
|
830
|
+
elif command -v brew >/dev/null 2>&1; then
|
|
831
|
+
echo "brew"
|
|
832
|
+
elif command -v winget >/dev/null 2>&1; then
|
|
833
|
+
echo "winget"
|
|
834
|
+
elif command -v pip3 >/dev/null 2>&1 || command -v pip >/dev/null 2>&1; then
|
|
835
|
+
echo "pip"
|
|
836
|
+
else
|
|
837
|
+
echo "unknown"
|
|
838
|
+
fi
|
|
839
|
+
"""
|
|
840
|
+
stdin, stdout, stderr = client.exec_command(detect_script, timeout=5)
|
|
841
|
+
detected = stdout.read().decode("utf-8", errors="replace").strip().lower()
|
|
842
|
+
pm = detected if detected and detected != "unknown" else "pip"
|
|
843
|
+
|
|
844
|
+
# Query command per package manager
|
|
845
|
+
if pm in ("dpkg", "apt"):
|
|
846
|
+
query_cmd = "dpkg-query -W -f='${binary:Package}\t${Version}\t${Section}\n' 2>/dev/null || dpkg -l"
|
|
847
|
+
elif pm in ("rpm", "dnf", "yum"):
|
|
848
|
+
query_cmd = "rpm -qa --qf '%{NAME}\t%{VERSION}-%{RELEASE}\t%{SUMMARY}\n' 2>/dev/null"
|
|
849
|
+
elif pm == "pacman":
|
|
850
|
+
query_cmd = "pacman -Q 2>/dev/null"
|
|
851
|
+
elif pm == "apk":
|
|
852
|
+
query_cmd = "apk info -v 2>/dev/null"
|
|
853
|
+
elif pm == "brew":
|
|
854
|
+
query_cmd = "brew list --versions 2>/dev/null"
|
|
855
|
+
elif pm == "winget":
|
|
856
|
+
query_cmd = "winget list 2>/dev/null"
|
|
857
|
+
elif pm == "npm":
|
|
858
|
+
query_cmd = "npm list -g --depth=0 --json 2>/dev/null || npm list -g --depth=0"
|
|
859
|
+
else: # pip
|
|
860
|
+
query_cmd = "pip list --format=json 2>/dev/null || pip list"
|
|
861
|
+
|
|
862
|
+
stdin, stdout, stderr = client.exec_command(query_cmd, timeout=20)
|
|
863
|
+
raw_out = stdout.read().decode("utf-8", errors="replace").strip()
|
|
864
|
+
|
|
865
|
+
# Parse into package objects
|
|
866
|
+
packages = []
|
|
867
|
+
clean_filter = filter.strip().lower() if filter else None
|
|
868
|
+
|
|
869
|
+
if raw_out.startswith("[") and raw_out.endswith("]"):
|
|
870
|
+
try:
|
|
871
|
+
parsed_json = json.loads(raw_out)
|
|
872
|
+
for item in parsed_json:
|
|
873
|
+
name = str(item.get("name", ""))
|
|
874
|
+
ver = str(item.get("version", ""))
|
|
875
|
+
if clean_filter and (clean_filter not in name.lower() and clean_filter not in ver.lower()):
|
|
876
|
+
continue
|
|
877
|
+
packages.append({"name": name, "version": ver})
|
|
878
|
+
except Exception:
|
|
879
|
+
pass
|
|
880
|
+
|
|
881
|
+
if not packages and raw_out:
|
|
882
|
+
for line in raw_out.splitlines():
|
|
883
|
+
line_str = line.strip()
|
|
884
|
+
if not line_str or line_str.startswith("Desired=") or line_str.startswith("|") or line_str.startswith("Name ") or line_str.startswith("---"):
|
|
885
|
+
continue
|
|
886
|
+
|
|
887
|
+
parts = re.split(r"\t+|\s{2,}", line_str)
|
|
888
|
+
if len(parts) >= 2:
|
|
889
|
+
pkg_name = parts[0].strip()
|
|
890
|
+
pkg_ver = parts[1].strip()
|
|
891
|
+
pkg_summary = parts[2].strip() if len(parts) > 2 else ""
|
|
892
|
+
elif " " in line_str:
|
|
893
|
+
subparts = line_str.split(None, 1)
|
|
894
|
+
pkg_name = subparts[0].strip()
|
|
895
|
+
pkg_ver = subparts[1].strip() if len(subparts) > 1 else ""
|
|
896
|
+
pkg_summary = ""
|
|
897
|
+
else:
|
|
898
|
+
pkg_name = line_str
|
|
899
|
+
pkg_ver = ""
|
|
900
|
+
pkg_summary = ""
|
|
901
|
+
|
|
902
|
+
if clean_filter and (clean_filter not in pkg_name.lower() and clean_filter not in pkg_summary.lower() and clean_filter not in pkg_ver.lower()):
|
|
903
|
+
continue
|
|
904
|
+
|
|
905
|
+
entry = {"name": pkg_name, "version": pkg_ver}
|
|
906
|
+
if pkg_summary:
|
|
907
|
+
entry["summary"] = pkg_summary
|
|
908
|
+
packages.append(entry)
|
|
909
|
+
|
|
910
|
+
safe_limit = min(max(1, limit), 200)
|
|
911
|
+
safe_offset = max(0, offset)
|
|
912
|
+
total_packages = len(packages)
|
|
913
|
+
display_packages = packages[safe_offset : safe_offset + safe_limit]
|
|
914
|
+
has_more = total_packages > (safe_offset + len(display_packages))
|
|
915
|
+
|
|
916
|
+
res: Dict[str, Any] = {
|
|
917
|
+
"host": host_info["name"],
|
|
918
|
+
"packageManager": pm,
|
|
919
|
+
"filter": filter,
|
|
920
|
+
"totalPackages": total_packages,
|
|
921
|
+
"returnedPackages": len(display_packages),
|
|
922
|
+
"offset": safe_offset,
|
|
923
|
+
"limit": safe_limit,
|
|
924
|
+
"hasMore": has_more,
|
|
925
|
+
"truncated": has_more,
|
|
926
|
+
"packages": display_packages,
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if has_more:
|
|
930
|
+
next_offset = safe_offset + len(display_packages)
|
|
931
|
+
res["notice"] = (
|
|
932
|
+
f"... [TRUNCATED: Showing packages {safe_offset + 1}-{next_offset} of {total_packages}. "
|
|
933
|
+
f"Use offset={next_offset} to view next batch, or pass 'filter' to search by name] ..."
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
return res
|
|
937
|
+
except Exception as e:
|
|
938
|
+
return _format_ssh_error(e, host=host)
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
@mcp.tool()
|
|
942
|
+
def ssh_list_services(
|
|
943
|
+
service_type: Literal["systemd", "docker", "pm2"] = "systemd",
|
|
944
|
+
filter: Optional[str] = None,
|
|
945
|
+
limit: int = 50,
|
|
946
|
+
offset: int = 0,
|
|
947
|
+
host: Optional[str] = None,
|
|
948
|
+
) -> Dict[str, Any]:
|
|
949
|
+
"""
|
|
950
|
+
Inspect running remote services across systemd units, Docker containers, or PM2 node processes with pagination.
|
|
951
|
+
|
|
952
|
+
Args:
|
|
953
|
+
service_type: Service framework ('systemd', 'docker', 'pm2').
|
|
954
|
+
filter: Optional keyword or pattern filter.
|
|
955
|
+
limit: Max services to return per batch (default 50, max 150).
|
|
956
|
+
offset: Starting offset for pagination (default 0).
|
|
957
|
+
host: Target SSH host (defaults to active host).
|
|
958
|
+
"""
|
|
959
|
+
try:
|
|
960
|
+
host_info = resolve_host_info(host)
|
|
961
|
+
client = get_cached_or_connect(host_info["name"])
|
|
962
|
+
|
|
963
|
+
if service_type == "systemd":
|
|
964
|
+
cmd = "systemctl list-units --type=service --state=running --no-pager --no-legend"
|
|
965
|
+
if filter:
|
|
966
|
+
cmd += f" | grep -i {shlex.quote(filter)}"
|
|
967
|
+
elif service_type == "docker":
|
|
968
|
+
cmd = "docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'"
|
|
969
|
+
if filter:
|
|
970
|
+
cmd += f" | grep -i {shlex.quote(filter)}"
|
|
971
|
+
else:
|
|
972
|
+
cmd = "pm2 jlist 2>/dev/null || pm2 list"
|
|
973
|
+
|
|
974
|
+
stdin, stdout, stderr = client.exec_command(cmd, timeout=15)
|
|
975
|
+
raw_out = stdout.read().decode("utf-8", errors="replace").strip()
|
|
976
|
+
|
|
977
|
+
lines = [ln.strip() for ln in raw_out.splitlines() if ln.strip()]
|
|
978
|
+
total_services = len(lines)
|
|
979
|
+
safe_limit = min(max(1, limit), 150)
|
|
980
|
+
safe_offset = max(0, offset)
|
|
981
|
+
display_lines = lines[safe_offset : safe_offset + safe_limit]
|
|
982
|
+
has_more = total_services > (safe_offset + len(display_lines))
|
|
983
|
+
|
|
984
|
+
res: Dict[str, Any] = {
|
|
985
|
+
"host": host_info["name"],
|
|
986
|
+
"serviceType": service_type,
|
|
987
|
+
"filter": filter,
|
|
988
|
+
"totalServices": total_services,
|
|
989
|
+
"returnedServices": len(display_lines),
|
|
990
|
+
"offset": safe_offset,
|
|
991
|
+
"limit": safe_limit,
|
|
992
|
+
"hasMore": has_more,
|
|
993
|
+
"truncated": has_more,
|
|
994
|
+
"services": display_lines,
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
if has_more:
|
|
998
|
+
next_offset = safe_offset + len(display_lines)
|
|
999
|
+
res["notice"] = (
|
|
1000
|
+
f"... [TRUNCATED: Showing services {safe_offset + 1}-{next_offset} of {total_services}. "
|
|
1001
|
+
f"Use offset={next_offset} or provide 'filter' to narrow query] ..."
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
return res
|
|
1005
|
+
except Exception as e:
|
|
1006
|
+
return _format_ssh_error(e, host=host)
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
@mcp.tool()
|
|
1010
|
+
def ssh_service_action(
|
|
1011
|
+
service_name: str,
|
|
1012
|
+
action: Literal["status", "start", "stop", "restart", "reload", "enable", "disable"] = "status",
|
|
1013
|
+
service_type: Literal["systemd", "docker", "pm2"] = "systemd",
|
|
1014
|
+
host: Optional[str] = None,
|
|
1015
|
+
) -> Dict[str, Any]:
|
|
1016
|
+
"""
|
|
1017
|
+
Manage a remote daemon or container (status, start, stop, restart, reload).
|
|
1018
|
+
|
|
1019
|
+
Args:
|
|
1020
|
+
service_name: Name of the service unit (e.g. 'nginx', 'postgresql', 'my-container').
|
|
1021
|
+
action: Lifecycle action to take.
|
|
1022
|
+
service_type: Service framework ('systemd', 'docker', 'pm2').
|
|
1023
|
+
host: Target SSH host (defaults to active host).
|
|
1024
|
+
"""
|
|
1025
|
+
try:
|
|
1026
|
+
host_info = resolve_host_info(host)
|
|
1027
|
+
client = get_cached_or_connect(host_info["name"])
|
|
1028
|
+
|
|
1029
|
+
s_name = shlex.quote(service_name)
|
|
1030
|
+
if service_type == "systemd":
|
|
1031
|
+
cmd = f"sudo systemctl {action} {s_name} --no-pager" if action != "status" else f"systemctl status {s_name} --no-pager"
|
|
1032
|
+
elif service_type == "docker":
|
|
1033
|
+
cmd = f"docker {action} {s_name}"
|
|
1034
|
+
else:
|
|
1035
|
+
cmd = f"pm2 {action} {s_name}"
|
|
1036
|
+
|
|
1037
|
+
res = _run_exec_channel(client, cmd, timeout=20)
|
|
1038
|
+
return {
|
|
1039
|
+
"host": host_info["name"],
|
|
1040
|
+
"service": service_name,
|
|
1041
|
+
"action": action,
|
|
1042
|
+
"success": res["is_success"],
|
|
1043
|
+
"output": res["stdout"],
|
|
1044
|
+
"error": res["stderr"] if not res["is_success"] else None,
|
|
1045
|
+
}
|
|
1046
|
+
except Exception as e:
|
|
1047
|
+
return _format_ssh_error(e, host=host)
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
@mcp.tool()
|
|
1051
|
+
def ssh_tail_logs(
|
|
1052
|
+
target: str,
|
|
1053
|
+
lines: int = 50,
|
|
1054
|
+
is_journal: bool = False,
|
|
1055
|
+
host: Optional[str] = None,
|
|
1056
|
+
) -> Dict[str, Any]:
|
|
1057
|
+
"""
|
|
1058
|
+
Tail remote log files (e.g. /var/log/syslog, /var/log/nginx/error.log) or systemd journal logs.
|
|
1059
|
+
Features safety caps on line count and character length to prevent context bloat.
|
|
1060
|
+
|
|
1061
|
+
Args:
|
|
1062
|
+
target: Log file path (e.g. '/var/log/syslog') or systemd service unit name if is_journal=True.
|
|
1063
|
+
lines: Number of trailing lines to return (default: 50, max: 200).
|
|
1064
|
+
is_journal: If True, uses 'journalctl -u <target> -n <lines> --no-pager'.
|
|
1065
|
+
host: Target SSH host (defaults to active host).
|
|
1066
|
+
"""
|
|
1067
|
+
try:
|
|
1068
|
+
host_info = resolve_host_info(host)
|
|
1069
|
+
client = get_cached_or_connect(host_info["name"])
|
|
1070
|
+
|
|
1071
|
+
safe_lines = min(max(1, lines), 200)
|
|
1072
|
+
if is_journal:
|
|
1073
|
+
cmd = f"sudo journalctl -u {shlex.quote(target)} -n {safe_lines} --no-pager"
|
|
1074
|
+
else:
|
|
1075
|
+
cmd = f"sudo tail -n {safe_lines} {shlex.quote(target)}"
|
|
1076
|
+
|
|
1077
|
+
res = _run_exec_channel(client, cmd, timeout=15, max_lines=safe_lines, max_chars=15000)
|
|
1078
|
+
return {
|
|
1079
|
+
"host": host_info["name"],
|
|
1080
|
+
"target": target,
|
|
1081
|
+
"lines": safe_lines,
|
|
1082
|
+
"isJournal": is_journal,
|
|
1083
|
+
"logs": res["stdout"],
|
|
1084
|
+
"truncated": res.get("truncated", False),
|
|
1085
|
+
"notice": res.get("notice"),
|
|
1086
|
+
"error": res["stderr"] if not res["is_success"] else None,
|
|
1087
|
+
}
|
|
1088
|
+
except Exception as e:
|
|
1089
|
+
return _format_ssh_error(e, host=host)
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
@mcp.tool()
|
|
1093
|
+
def ssh_list_processes(
|
|
1094
|
+
filter: Optional[str] = None,
|
|
1095
|
+
sort_by: Literal["cpu", "mem"] = "cpu",
|
|
1096
|
+
limit: int = 30,
|
|
1097
|
+
offset: int = 0,
|
|
1098
|
+
host: Optional[str] = None,
|
|
1099
|
+
) -> Dict[str, Any]:
|
|
1100
|
+
"""
|
|
1101
|
+
List top remote processes sorted by CPU or Memory usage with token-safe limits.
|
|
1102
|
+
|
|
1103
|
+
Args:
|
|
1104
|
+
filter: Optional process name or command filter.
|
|
1105
|
+
sort_by: Sort metric ('cpu' or 'mem').
|
|
1106
|
+
limit: Max number of processes to return (default: 30, max: 100).
|
|
1107
|
+
offset: Starting offset for pagination (default 0).
|
|
1108
|
+
host: Target SSH host (defaults to active host).
|
|
1109
|
+
"""
|
|
1110
|
+
try:
|
|
1111
|
+
host_info = resolve_host_info(host)
|
|
1112
|
+
client = get_cached_or_connect(host_info["name"])
|
|
1113
|
+
|
|
1114
|
+
safe_limit = min(max(1, limit), 100)
|
|
1115
|
+
safe_offset = max(0, offset)
|
|
1116
|
+
sort_col = "%cpu" if sort_by == "cpu" else "%mem"
|
|
1117
|
+
cmd = f"ps -eo pid,user,%cpu,%mem,stat,time,command --sort=-{sort_col}"
|
|
1118
|
+
if filter:
|
|
1119
|
+
cmd += f" | grep -E 'PID|{shlex.quote(filter)}'"
|
|
1120
|
+
|
|
1121
|
+
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
|
1122
|
+
raw_out = stdout.read().decode("utf-8", errors="replace").strip()
|
|
1123
|
+
|
|
1124
|
+
all_lines = raw_out.splitlines()
|
|
1125
|
+
header = all_lines[0] if all_lines else "PID USER %CPU %MEM STAT TIME COMMAND"
|
|
1126
|
+
data_lines = all_lines[1:] if len(all_lines) > 1 else []
|
|
1127
|
+
|
|
1128
|
+
total_procs = len(data_lines)
|
|
1129
|
+
display_lines = data_lines[safe_offset : safe_offset + safe_limit]
|
|
1130
|
+
has_more = total_procs > (safe_offset + len(display_lines))
|
|
1131
|
+
|
|
1132
|
+
result_text = header + "\n" + "\n".join(display_lines)
|
|
1133
|
+
|
|
1134
|
+
res: Dict[str, Any] = {
|
|
1135
|
+
"host": host_info["name"],
|
|
1136
|
+
"sortBy": sort_by,
|
|
1137
|
+
"filter": filter,
|
|
1138
|
+
"totalProcesses": total_procs,
|
|
1139
|
+
"returnedProcesses": len(display_lines),
|
|
1140
|
+
"offset": safe_offset,
|
|
1141
|
+
"limit": safe_limit,
|
|
1142
|
+
"hasMore": has_more,
|
|
1143
|
+
"truncated": has_more,
|
|
1144
|
+
"processes": result_text,
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
if has_more:
|
|
1148
|
+
next_offset = safe_offset + len(display_lines)
|
|
1149
|
+
res["notice"] = (
|
|
1150
|
+
f"... [TRUNCATED: Showing processes {safe_offset + 1}-{next_offset} of {total_procs}. "
|
|
1151
|
+
f"Use offset={next_offset} or provide 'filter' to locate specific processes] ..."
|
|
1152
|
+
)
|
|
1153
|
+
|
|
1154
|
+
return res
|
|
1155
|
+
except Exception as e:
|
|
1156
|
+
return _format_ssh_error(e, host=host)
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
# ==============================================================================
|
|
1160
|
+
# 5. SFTP Remote File Operations Tools
|
|
1161
|
+
# ==============================================================================
|
|
1162
|
+
|
|
1163
|
+
@mcp.tool()
|
|
1164
|
+
def sftp_list_dir(
|
|
1165
|
+
remote_path: str = ".",
|
|
1166
|
+
limit: int = 100,
|
|
1167
|
+
offset: int = 0,
|
|
1168
|
+
filter: Optional[str] = None,
|
|
1169
|
+
host: Optional[str] = None,
|
|
1170
|
+
) -> Dict[str, Any]:
|
|
1171
|
+
"""
|
|
1172
|
+
List contents of a remote directory with file types, sizes, permissions, timestamps, and context window protection.
|
|
1173
|
+
|
|
1174
|
+
Args:
|
|
1175
|
+
remote_path: Remote directory path (default: current directory '.').
|
|
1176
|
+
limit: Max items to return per batch (default 100, max 250).
|
|
1177
|
+
offset: Starting offset for pagination (default 0).
|
|
1178
|
+
filter: Optional filename substring filter.
|
|
1179
|
+
host: Target SSH host (defaults to active host).
|
|
1180
|
+
"""
|
|
1181
|
+
try:
|
|
1182
|
+
return list_remote_directory(remote_path=remote_path, limit=limit, offset=offset, filter=filter, host=host)
|
|
1183
|
+
except Exception as e:
|
|
1184
|
+
return _format_ssh_error(e, host=host)
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
@mcp.tool()
|
|
1188
|
+
def sftp_read_file(
|
|
1189
|
+
remote_path: str,
|
|
1190
|
+
max_chars: int = 15000,
|
|
1191
|
+
offset_lines: int = 0,
|
|
1192
|
+
host: Optional[str] = None,
|
|
1193
|
+
) -> Dict[str, Any]:
|
|
1194
|
+
"""
|
|
1195
|
+
Read text/source code from a remote file with line offset support and token safety.
|
|
1196
|
+
|
|
1197
|
+
Args:
|
|
1198
|
+
remote_path: Remote file path.
|
|
1199
|
+
max_chars: Maximum characters to return (default: 15,000).
|
|
1200
|
+
offset_lines: Starting line offset (0-indexed).
|
|
1201
|
+
host: Target SSH host (defaults to active host).
|
|
1202
|
+
"""
|
|
1203
|
+
try:
|
|
1204
|
+
return read_remote_text_file(
|
|
1205
|
+
remote_path=remote_path,
|
|
1206
|
+
max_chars=max_chars,
|
|
1207
|
+
offset_lines=offset_lines,
|
|
1208
|
+
host=host,
|
|
1209
|
+
)
|
|
1210
|
+
except Exception as e:
|
|
1211
|
+
return _format_ssh_error(e, host=host)
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
@mcp.tool()
|
|
1215
|
+
def sftp_write_file(
|
|
1216
|
+
remote_path: str,
|
|
1217
|
+
content: str,
|
|
1218
|
+
mode: Literal["write", "append"] = "write",
|
|
1219
|
+
host: Optional[str] = None,
|
|
1220
|
+
) -> Dict[str, Any]:
|
|
1221
|
+
"""
|
|
1222
|
+
Write or append text content to a remote file via SFTP.
|
|
1223
|
+
|
|
1224
|
+
Args:
|
|
1225
|
+
remote_path: Remote destination path.
|
|
1226
|
+
content: Text content to write.
|
|
1227
|
+
mode: 'write' (overwrite) or 'append'.
|
|
1228
|
+
host: Target SSH host (defaults to active host).
|
|
1229
|
+
"""
|
|
1230
|
+
try:
|
|
1231
|
+
return write_remote_text_file(
|
|
1232
|
+
remote_path=remote_path,
|
|
1233
|
+
content=content,
|
|
1234
|
+
mode=mode,
|
|
1235
|
+
host=host,
|
|
1236
|
+
)
|
|
1237
|
+
except Exception as e:
|
|
1238
|
+
return _format_ssh_error(e, host=host)
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
@mcp.tool()
|
|
1242
|
+
def sftp_stat(
|
|
1243
|
+
remote_path: str,
|
|
1244
|
+
host: Optional[str] = None,
|
|
1245
|
+
) -> Dict[str, Any]:
|
|
1246
|
+
"""
|
|
1247
|
+
Inspect detailed file/directory metadata, size, permissions, uid/gid, and timestamps.
|
|
1248
|
+
|
|
1249
|
+
Args:
|
|
1250
|
+
remote_path: Remote file or directory path.
|
|
1251
|
+
host: Target SSH host (defaults to active host).
|
|
1252
|
+
"""
|
|
1253
|
+
try:
|
|
1254
|
+
return stat_remote_path(remote_path=remote_path, host=host)
|
|
1255
|
+
except Exception as e:
|
|
1256
|
+
return _format_ssh_error(e, host=host)
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
@mcp.tool()
|
|
1260
|
+
def sftp_upload(
|
|
1261
|
+
local_path: str,
|
|
1262
|
+
remote_path: str,
|
|
1263
|
+
host: Optional[str] = None,
|
|
1264
|
+
) -> Dict[str, Any]:
|
|
1265
|
+
"""
|
|
1266
|
+
Upload a local file or entire folder to the remote host.
|
|
1267
|
+
|
|
1268
|
+
Args:
|
|
1269
|
+
local_path: Local path on machine (file or folder).
|
|
1270
|
+
remote_path: Remote destination path.
|
|
1271
|
+
host: Target SSH host (defaults to active host).
|
|
1272
|
+
"""
|
|
1273
|
+
try:
|
|
1274
|
+
return upload_path(local_path=local_path, remote_path=remote_path, host=host)
|
|
1275
|
+
except Exception as e:
|
|
1276
|
+
return _format_ssh_error(e, host=host)
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
@mcp.tool()
|
|
1280
|
+
def sftp_download(
|
|
1281
|
+
remote_path: str,
|
|
1282
|
+
local_path: str,
|
|
1283
|
+
host: Optional[str] = None,
|
|
1284
|
+
) -> Dict[str, Any]:
|
|
1285
|
+
"""
|
|
1286
|
+
Download a remote file or folder to the local machine.
|
|
1287
|
+
|
|
1288
|
+
Args:
|
|
1289
|
+
remote_path: Remote source path.
|
|
1290
|
+
local_path: Local destination path.
|
|
1291
|
+
host: Target SSH host (defaults to active host).
|
|
1292
|
+
"""
|
|
1293
|
+
try:
|
|
1294
|
+
return download_path(remote_path=remote_path, local_path=local_path, host=host)
|
|
1295
|
+
except Exception as e:
|
|
1296
|
+
return _format_ssh_error(e, host=host)
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
@mcp.tool()
|
|
1300
|
+
def sftp_remove(
|
|
1301
|
+
remote_path: str,
|
|
1302
|
+
recursive: bool = False,
|
|
1303
|
+
host: Optional[str] = None,
|
|
1304
|
+
) -> Dict[str, Any]:
|
|
1305
|
+
"""
|
|
1306
|
+
Delete a remote file or directory.
|
|
1307
|
+
|
|
1308
|
+
Args:
|
|
1309
|
+
remote_path: Remote path to remove.
|
|
1310
|
+
recursive: If True, recursively deletes non-empty directories.
|
|
1311
|
+
host: Target SSH host (defaults to active host).
|
|
1312
|
+
"""
|
|
1313
|
+
try:
|
|
1314
|
+
return remove_remote_path(remote_path=remote_path, recursive=recursive, host=host)
|
|
1315
|
+
except Exception as e:
|
|
1316
|
+
return _format_ssh_error(e, host=host)
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
# ==============================================================================
|
|
1320
|
+
# 6. Port Forwarding & Tunnels Tools
|
|
1321
|
+
# ==============================================================================
|
|
1322
|
+
|
|
1323
|
+
@mcp.tool()
|
|
1324
|
+
def ssh_tunnel_open(
|
|
1325
|
+
local_port: int,
|
|
1326
|
+
remote_port: int,
|
|
1327
|
+
remote_host: str = "localhost",
|
|
1328
|
+
tunnel_name: Optional[str] = None,
|
|
1329
|
+
host: Optional[str] = None,
|
|
1330
|
+
) -> Dict[str, Any]:
|
|
1331
|
+
"""
|
|
1332
|
+
Establish a local-to-remote SSH port forwarding tunnel in the background.
|
|
1333
|
+
Allows accessing remote databases, APIs, or services via 127.0.0.1:<local_port>.
|
|
1334
|
+
|
|
1335
|
+
Args:
|
|
1336
|
+
local_port: Local port to bind on machine (e.g. 15432, 13306, 8080).
|
|
1337
|
+
remote_port: Remote target port on the server (e.g. 5432, 3306, 80).
|
|
1338
|
+
remote_host: Remote host to bind to from SSH server perspective (default: 'localhost').
|
|
1339
|
+
tunnel_name: Optional custom name for the tunnel.
|
|
1340
|
+
host: Target SSH host (defaults to active host).
|
|
1341
|
+
"""
|
|
1342
|
+
try:
|
|
1343
|
+
return open_local_tunnel(
|
|
1344
|
+
local_port=local_port,
|
|
1345
|
+
remote_port=remote_port,
|
|
1346
|
+
remote_host=remote_host,
|
|
1347
|
+
tunnel_name=tunnel_name,
|
|
1348
|
+
host=host,
|
|
1349
|
+
)
|
|
1350
|
+
except Exception as e:
|
|
1351
|
+
return _format_ssh_error(e, host=host)
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
@mcp.tool()
|
|
1355
|
+
def ssh_tunnel_list() -> Dict[str, Any]:
|
|
1356
|
+
"""List all currently active local-to-remote SSH port forwarding tunnels."""
|
|
1357
|
+
tunnels = list_active_tunnels()
|
|
1358
|
+
return {
|
|
1359
|
+
"activeTunnelsCount": len(tunnels),
|
|
1360
|
+
"tunnels": tunnels,
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
@mcp.tool()
|
|
1365
|
+
def ssh_tunnel_close(tunnel_id_or_name: str) -> Dict[str, Any]:
|
|
1366
|
+
"""
|
|
1367
|
+
Close and terminate an active SSH port forwarding tunnel.
|
|
1368
|
+
|
|
1369
|
+
Args:
|
|
1370
|
+
tunnel_id_or_name: Tunnel name or local port number.
|
|
1371
|
+
"""
|
|
1372
|
+
return close_tunnel(tunnel_id_or_name)
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
# ==============================================================================
|
|
1376
|
+
# Server Entrypoint
|
|
1377
|
+
# ==============================================================================
|
|
1378
|
+
|
|
1379
|
+
if __name__ == "__main__":
|
|
1380
|
+
mcp.run(transport="stdio")
|