mcp-ssh-vps 0.4.1__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.
Files changed (47) hide show
  1. mcp_ssh_vps-0.4.1.dist-info/METADATA +482 -0
  2. mcp_ssh_vps-0.4.1.dist-info/RECORD +47 -0
  3. mcp_ssh_vps-0.4.1.dist-info/WHEEL +5 -0
  4. mcp_ssh_vps-0.4.1.dist-info/entry_points.txt +4 -0
  5. mcp_ssh_vps-0.4.1.dist-info/licenses/LICENSE +21 -0
  6. mcp_ssh_vps-0.4.1.dist-info/top_level.txt +1 -0
  7. sshmcp/__init__.py +3 -0
  8. sshmcp/cli.py +473 -0
  9. sshmcp/config.py +155 -0
  10. sshmcp/core/__init__.py +5 -0
  11. sshmcp/core/container.py +291 -0
  12. sshmcp/models/__init__.py +15 -0
  13. sshmcp/models/command.py +69 -0
  14. sshmcp/models/file.py +102 -0
  15. sshmcp/models/machine.py +139 -0
  16. sshmcp/monitoring/__init__.py +0 -0
  17. sshmcp/monitoring/alerts.py +464 -0
  18. sshmcp/prompts/__init__.py +7 -0
  19. sshmcp/prompts/backup.py +151 -0
  20. sshmcp/prompts/deploy.py +115 -0
  21. sshmcp/prompts/monitor.py +146 -0
  22. sshmcp/resources/__init__.py +7 -0
  23. sshmcp/resources/logs.py +99 -0
  24. sshmcp/resources/metrics.py +204 -0
  25. sshmcp/resources/status.py +160 -0
  26. sshmcp/security/__init__.py +7 -0
  27. sshmcp/security/audit.py +314 -0
  28. sshmcp/security/rate_limiter.py +221 -0
  29. sshmcp/security/totp.py +392 -0
  30. sshmcp/security/validator.py +234 -0
  31. sshmcp/security/whitelist.py +169 -0
  32. sshmcp/server.py +632 -0
  33. sshmcp/ssh/__init__.py +6 -0
  34. sshmcp/ssh/async_client.py +247 -0
  35. sshmcp/ssh/client.py +464 -0
  36. sshmcp/ssh/executor.py +79 -0
  37. sshmcp/ssh/forwarding.py +368 -0
  38. sshmcp/ssh/pool.py +343 -0
  39. sshmcp/ssh/shell.py +518 -0
  40. sshmcp/ssh/transfer.py +461 -0
  41. sshmcp/tools/__init__.py +13 -0
  42. sshmcp/tools/commands.py +226 -0
  43. sshmcp/tools/files.py +220 -0
  44. sshmcp/tools/helpers.py +321 -0
  45. sshmcp/tools/history.py +372 -0
  46. sshmcp/tools/processes.py +214 -0
  47. sshmcp/tools/servers.py +484 -0
@@ -0,0 +1,372 @@
1
+ """Command history tracking for SSH MCP."""
2
+
3
+ import json
4
+ import threading
5
+ from collections import deque
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import structlog
12
+
13
+ logger = structlog.get_logger()
14
+
15
+
16
+ @dataclass
17
+ class HistoryEntry:
18
+ """Single command history entry."""
19
+
20
+ host: str
21
+ command: str
22
+ exit_code: int
23
+ timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
24
+ duration_ms: int = 0
25
+ stdout_preview: str = ""
26
+ stderr_preview: str = ""
27
+
28
+ def to_dict(self) -> dict[str, Any]:
29
+ """Convert to dictionary."""
30
+ return {
31
+ "host": self.host,
32
+ "command": self.command,
33
+ "exit_code": self.exit_code,
34
+ "timestamp": self.timestamp.isoformat(),
35
+ "duration_ms": self.duration_ms,
36
+ "stdout_preview": self.stdout_preview[:200] if self.stdout_preview else "",
37
+ "stderr_preview": self.stderr_preview[:200] if self.stderr_preview else "",
38
+ }
39
+
40
+ @classmethod
41
+ def from_dict(cls, data: dict[str, Any]) -> "HistoryEntry":
42
+ """Create from dictionary."""
43
+ return cls(
44
+ host=data["host"],
45
+ command=data["command"],
46
+ exit_code=data["exit_code"],
47
+ timestamp=datetime.fromisoformat(data["timestamp"]),
48
+ duration_ms=data.get("duration_ms", 0),
49
+ stdout_preview=data.get("stdout_preview", ""),
50
+ stderr_preview=data.get("stderr_preview", ""),
51
+ )
52
+
53
+
54
+ class CommandHistory:
55
+ """
56
+ Command history manager.
57
+
58
+ Tracks executed commands per host with optional persistence.
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ max_entries_per_host: int = 100,
64
+ max_total_entries: int = 1000,
65
+ persistence_file: str | None = None,
66
+ ) -> None:
67
+ """
68
+ Initialize command history.
69
+
70
+ Args:
71
+ max_entries_per_host: Maximum entries to keep per host.
72
+ max_total_entries: Maximum total entries across all hosts.
73
+ persistence_file: Optional file to persist history.
74
+ """
75
+ self.max_entries_per_host = max_entries_per_host
76
+ self.max_total_entries = max_total_entries
77
+ self.persistence_file = persistence_file
78
+
79
+ self._history: dict[str, deque[HistoryEntry]] = {}
80
+ self._lock = threading.Lock()
81
+ self._total_count = 0
82
+
83
+ if persistence_file:
84
+ self._load()
85
+
86
+ def add(
87
+ self,
88
+ host: str,
89
+ command: str,
90
+ exit_code: int,
91
+ duration_ms: int = 0,
92
+ stdout: str = "",
93
+ stderr: str = "",
94
+ ) -> HistoryEntry:
95
+ """
96
+ Add a command to history.
97
+
98
+ Args:
99
+ host: Host where command was executed.
100
+ command: The command that was executed.
101
+ exit_code: Command exit code.
102
+ duration_ms: Execution duration in milliseconds.
103
+ stdout: Standard output (will be truncated).
104
+ stderr: Standard error (will be truncated).
105
+
106
+ Returns:
107
+ The created history entry.
108
+ """
109
+ entry = HistoryEntry(
110
+ host=host,
111
+ command=command,
112
+ exit_code=exit_code,
113
+ duration_ms=duration_ms,
114
+ stdout_preview=stdout[:200] if stdout else "",
115
+ stderr_preview=stderr[:200] if stderr else "",
116
+ )
117
+
118
+ with self._lock:
119
+ if host not in self._history:
120
+ self._history[host] = deque(maxlen=self.max_entries_per_host)
121
+
122
+ self._history[host].append(entry)
123
+ self._total_count += 1
124
+
125
+ # Enforce total limit
126
+ while self._total_count > self.max_total_entries:
127
+ self._evict_oldest()
128
+
129
+ if self.persistence_file:
130
+ self._save()
131
+
132
+ return entry
133
+
134
+ def get_history(
135
+ self,
136
+ host: str | None = None,
137
+ limit: int = 50,
138
+ success_only: bool = False,
139
+ failed_only: bool = False,
140
+ ) -> list[HistoryEntry]:
141
+ """
142
+ Get command history.
143
+
144
+ Args:
145
+ host: Filter by host (None for all hosts).
146
+ limit: Maximum entries to return.
147
+ success_only: Only return successful commands.
148
+ failed_only: Only return failed commands.
149
+
150
+ Returns:
151
+ List of history entries, newest first.
152
+ """
153
+ with self._lock:
154
+ if host:
155
+ entries = list(self._history.get(host, []))
156
+ else:
157
+ entries = []
158
+ for host_entries in self._history.values():
159
+ entries.extend(host_entries)
160
+
161
+ # Filter
162
+ if success_only:
163
+ entries = [e for e in entries if e.exit_code == 0]
164
+ elif failed_only:
165
+ entries = [e for e in entries if e.exit_code != 0]
166
+
167
+ # Sort by timestamp descending and limit
168
+ entries.sort(key=lambda e: e.timestamp, reverse=True)
169
+ return entries[:limit]
170
+
171
+ def search(
172
+ self,
173
+ pattern: str,
174
+ host: str | None = None,
175
+ limit: int = 50,
176
+ ) -> list[HistoryEntry]:
177
+ """
178
+ Search command history.
179
+
180
+ Args:
181
+ pattern: Pattern to search for in commands.
182
+ host: Filter by host.
183
+ limit: Maximum entries to return.
184
+
185
+ Returns:
186
+ Matching history entries.
187
+ """
188
+ entries = self.get_history(host=host, limit=self.max_total_entries)
189
+ pattern_lower = pattern.lower()
190
+ matches = [e for e in entries if pattern_lower in e.command.lower()]
191
+ return matches[:limit]
192
+
193
+ def get_last_command(self, host: str) -> HistoryEntry | None:
194
+ """
195
+ Get the last command executed on a host.
196
+
197
+ Args:
198
+ host: Host name.
199
+
200
+ Returns:
201
+ Last history entry or None.
202
+ """
203
+ with self._lock:
204
+ if host in self._history and self._history[host]:
205
+ return self._history[host][-1]
206
+ return None
207
+
208
+ def get_hosts(self) -> list[str]:
209
+ """Get list of hosts with history."""
210
+ with self._lock:
211
+ return list(self._history.keys())
212
+
213
+ def get_stats(self, host: str | None = None) -> dict[str, Any]:
214
+ """
215
+ Get history statistics.
216
+
217
+ Args:
218
+ host: Filter by host (None for all).
219
+
220
+ Returns:
221
+ Statistics dictionary.
222
+ """
223
+ entries = self.get_history(host=host, limit=self.max_total_entries)
224
+
225
+ if not entries:
226
+ return {
227
+ "total_commands": 0,
228
+ "success_count": 0,
229
+ "failure_count": 0,
230
+ "success_rate": 0.0,
231
+ }
232
+
233
+ success_count = sum(1 for e in entries if e.exit_code == 0)
234
+ failure_count = len(entries) - success_count
235
+
236
+ return {
237
+ "total_commands": len(entries),
238
+ "success_count": success_count,
239
+ "failure_count": failure_count,
240
+ "success_rate": round(success_count / len(entries) * 100, 1),
241
+ "avg_duration_ms": round(
242
+ sum(e.duration_ms for e in entries) / len(entries)
243
+ ),
244
+ "hosts": len(set(e.host for e in entries)),
245
+ }
246
+
247
+ def clear(self, host: str | None = None) -> int:
248
+ """
249
+ Clear history.
250
+
251
+ Args:
252
+ host: Host to clear (None for all).
253
+
254
+ Returns:
255
+ Number of entries cleared.
256
+ """
257
+ with self._lock:
258
+ if host:
259
+ if host in self._history:
260
+ count = len(self._history[host])
261
+ del self._history[host]
262
+ self._total_count -= count
263
+ return count
264
+ return 0
265
+ else:
266
+ count = self._total_count
267
+ self._history.clear()
268
+ self._total_count = 0
269
+ return count
270
+
271
+ def _evict_oldest(self) -> None:
272
+ """Evict oldest entry across all hosts."""
273
+ oldest_host = None
274
+ oldest_time = None
275
+
276
+ for host, entries in self._history.items():
277
+ if entries:
278
+ entry_time = entries[0].timestamp
279
+ if oldest_time is None or entry_time < oldest_time:
280
+ oldest_time = entry_time
281
+ oldest_host = host
282
+
283
+ if oldest_host and self._history[oldest_host]:
284
+ self._history[oldest_host].popleft()
285
+ self._total_count -= 1
286
+
287
+ if not self._history[oldest_host]:
288
+ del self._history[oldest_host]
289
+
290
+ def _save(self) -> None:
291
+ """Save history to file."""
292
+ if not self.persistence_file:
293
+ return
294
+
295
+ try:
296
+ path = Path(self.persistence_file)
297
+ path.parent.mkdir(parents=True, exist_ok=True)
298
+
299
+ with self._lock:
300
+ data = {
301
+ host: [e.to_dict() for e in entries]
302
+ for host, entries in self._history.items()
303
+ }
304
+
305
+ with open(path, "w") as f:
306
+ json.dump(data, f, indent=2)
307
+
308
+ except Exception as e:
309
+ logger.error("history_save_error", error=str(e))
310
+
311
+ def _load(self) -> None:
312
+ """Load history from file."""
313
+ if not self.persistence_file:
314
+ return
315
+
316
+ path = Path(self.persistence_file)
317
+ if not path.exists():
318
+ return
319
+
320
+ try:
321
+ with open(path) as f:
322
+ data = json.load(f)
323
+
324
+ with self._lock:
325
+ for host, entries in data.items():
326
+ self._history[host] = deque(
327
+ [HistoryEntry.from_dict(e) for e in entries],
328
+ maxlen=self.max_entries_per_host,
329
+ )
330
+ self._total_count += len(self._history[host])
331
+
332
+ logger.info("history_loaded", entries=self._total_count)
333
+
334
+ except Exception as e:
335
+ logger.error("history_load_error", error=str(e))
336
+
337
+
338
+ # Global history instance
339
+ _history: CommandHistory | None = None
340
+
341
+
342
+ def get_history() -> CommandHistory:
343
+ """Get or create the global command history."""
344
+ global _history
345
+ if _history is None:
346
+ _history = CommandHistory()
347
+ return _history
348
+
349
+
350
+ def init_history(
351
+ max_entries_per_host: int = 100,
352
+ max_total_entries: int = 1000,
353
+ persistence_file: str | None = None,
354
+ ) -> CommandHistory:
355
+ """
356
+ Initialize the global command history.
357
+
358
+ Args:
359
+ max_entries_per_host: Maximum entries per host.
360
+ max_total_entries: Maximum total entries.
361
+ persistence_file: Optional persistence file path.
362
+
363
+ Returns:
364
+ Initialized CommandHistory.
365
+ """
366
+ global _history
367
+ _history = CommandHistory(
368
+ max_entries_per_host=max_entries_per_host,
369
+ max_total_entries=max_total_entries,
370
+ persistence_file=persistence_file,
371
+ )
372
+ return _history
@@ -0,0 +1,214 @@
1
+ """MCP Tool for process management."""
2
+
3
+ from typing import Any, Literal
4
+
5
+ import structlog
6
+
7
+ from sshmcp.config import get_machine
8
+ from sshmcp.security.audit import get_audit_logger
9
+ from sshmcp.ssh.client import SSHExecutionError
10
+ from sshmcp.ssh.pool import get_pool
11
+
12
+ logger = structlog.get_logger()
13
+
14
+
15
+ ServiceManager = Literal["systemd", "pm2", "supervisor", "auto"]
16
+ ProcessAction = Literal["start", "stop", "restart", "status"]
17
+
18
+
19
+ def manage_process(
20
+ host: str,
21
+ action: ProcessAction,
22
+ process_name: str,
23
+ service_manager: ServiceManager = "auto",
24
+ ) -> dict[str, Any]:
25
+ """
26
+ Manage processes on remote VPS server.
27
+
28
+ Supports systemd, pm2, and supervisor service managers.
29
+
30
+ Args:
31
+ host: Name of the host from machines.json configuration.
32
+ action: Action to perform (start, stop, restart, status).
33
+ process_name: Name of the process or service.
34
+ service_manager: Service manager to use (systemd, pm2, supervisor, auto).
35
+
36
+ Returns:
37
+ Dictionary with:
38
+ - success: Whether action was successful
39
+ - action: Action that was performed
40
+ - process: Process name
41
+ - status: Current process status (if available)
42
+ - output: Command output
43
+ - host: Host where action was performed
44
+
45
+ Raises:
46
+ ValueError: If host not found or invalid parameters.
47
+ RuntimeError: If action fails.
48
+
49
+ Example:
50
+ >>> manage_process("production-server", "restart", "nginx")
51
+ {"success": true, "action": "restart", "process": "nginx", ...}
52
+ """
53
+ audit = get_audit_logger()
54
+
55
+ # Validate action
56
+ valid_actions = ("start", "stop", "restart", "status")
57
+ if action not in valid_actions:
58
+ raise ValueError(f"Invalid action: {action}. Must be one of {valid_actions}")
59
+
60
+ # Get machine configuration
61
+ try:
62
+ machine = get_machine(host)
63
+ except Exception as e:
64
+ raise ValueError(f"Host not found: {host}") from e
65
+
66
+ # Get pool and client
67
+ pool = get_pool()
68
+ pool.register_machine(machine)
69
+
70
+ try:
71
+ client = pool.get_client(host)
72
+ try:
73
+ # Detect service manager if auto
74
+ if service_manager == "auto":
75
+ service_manager = _detect_service_manager(client, process_name)
76
+
77
+ # Build and execute command
78
+ command = _build_process_command(service_manager, action, process_name)
79
+
80
+ result = client.execute(command)
81
+
82
+ # Parse status if requested
83
+ status = None
84
+ if action == "status":
85
+ status = _parse_status(service_manager, result.stdout, result.exit_code)
86
+
87
+ audit.log(
88
+ event="process_managed",
89
+ host=host,
90
+ metadata={
91
+ "action": action,
92
+ "process": process_name,
93
+ "service_manager": service_manager,
94
+ "exit_code": result.exit_code,
95
+ },
96
+ )
97
+
98
+ return {
99
+ "success": result.exit_code == 0 or (action == "status"),
100
+ "action": action,
101
+ "process": process_name,
102
+ "service_manager": service_manager,
103
+ "status": status,
104
+ "output": result.stdout or result.stderr,
105
+ "exit_code": result.exit_code,
106
+ "host": host,
107
+ }
108
+
109
+ finally:
110
+ pool.release_client(client)
111
+
112
+ except SSHExecutionError as e:
113
+ raise RuntimeError(f"Process management failed: {e}") from e
114
+ except Exception as e:
115
+ raise RuntimeError(f"SSH error: {e}") from e
116
+
117
+
118
+ def _detect_service_manager(client: Any, process_name: str) -> str:
119
+ """Detect which service manager to use."""
120
+ # Try systemd first
121
+ try:
122
+ result = client.execute(f"systemctl is-enabled {process_name} 2>/dev/null")
123
+ if (
124
+ result.exit_code == 0
125
+ or "enabled" in result.stdout
126
+ or "disabled" in result.stdout
127
+ ):
128
+ return "systemd"
129
+ except Exception:
130
+ pass
131
+
132
+ # Try pm2
133
+ try:
134
+ result = client.execute("which pm2 2>/dev/null")
135
+ if result.exit_code == 0:
136
+ result = client.execute(f"pm2 describe {process_name} 2>/dev/null")
137
+ if result.exit_code == 0:
138
+ return "pm2"
139
+ except Exception:
140
+ pass
141
+
142
+ # Try supervisor
143
+ try:
144
+ result = client.execute("which supervisorctl 2>/dev/null")
145
+ if result.exit_code == 0:
146
+ return "supervisor"
147
+ except Exception:
148
+ pass
149
+
150
+ # Default to systemd
151
+ return "systemd"
152
+
153
+
154
+ def _build_process_command(
155
+ service_manager: str,
156
+ action: str,
157
+ process_name: str,
158
+ ) -> str:
159
+ """Build command for the service manager."""
160
+ if service_manager == "systemd":
161
+ return f"systemctl {action} {process_name}"
162
+ elif service_manager == "pm2":
163
+ if action == "status":
164
+ return f"pm2 describe {process_name}"
165
+ return f"pm2 {action} {process_name}"
166
+ elif service_manager == "supervisor":
167
+ if action == "status":
168
+ return f"supervisorctl status {process_name}"
169
+ return f"supervisorctl {action} {process_name}"
170
+ else:
171
+ raise ValueError(f"Unknown service manager: {service_manager}")
172
+
173
+
174
+ def _parse_status(
175
+ service_manager: str,
176
+ output: str,
177
+ exit_code: int,
178
+ ) -> str:
179
+ """Parse status from command output."""
180
+ output_lower = output.lower()
181
+
182
+ if service_manager == "systemd":
183
+ if "active (running)" in output_lower:
184
+ return "running"
185
+ elif "inactive" in output_lower:
186
+ return "stopped"
187
+ elif "failed" in output_lower:
188
+ return "failed"
189
+ elif "activating" in output_lower:
190
+ return "starting"
191
+ else:
192
+ return "unknown"
193
+
194
+ elif service_manager == "pm2":
195
+ if "online" in output_lower:
196
+ return "running"
197
+ elif "stopped" in output_lower:
198
+ return "stopped"
199
+ elif "errored" in output_lower:
200
+ return "failed"
201
+ else:
202
+ return "unknown"
203
+
204
+ elif service_manager == "supervisor":
205
+ if "running" in output_lower:
206
+ return "running"
207
+ elif "stopped" in output_lower:
208
+ return "stopped"
209
+ elif "fatal" in output_lower or "error" in output_lower:
210
+ return "failed"
211
+ else:
212
+ return "unknown"
213
+
214
+ return "unknown"