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
sshmcp/ssh/shell.py ADDED
@@ -0,0 +1,518 @@
1
+ """Interactive SSH shell sessions."""
2
+
3
+ import queue
4
+ import threading
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timezone
8
+ from typing import Any, Callable
9
+
10
+ import paramiko
11
+ import structlog
12
+
13
+ from sshmcp.ssh.client import SSHClient, SSHConnectionError
14
+
15
+ logger = structlog.get_logger()
16
+
17
+
18
+ class ShellError(Exception):
19
+ """Error with shell session."""
20
+
21
+ pass
22
+
23
+
24
+ class ShellNotConnected(ShellError):
25
+ """Shell is not connected."""
26
+
27
+ pass
28
+
29
+
30
+ class ShellTimeout(ShellError):
31
+ """Shell operation timed out."""
32
+
33
+ pass
34
+
35
+
36
+ @dataclass
37
+ class ShellOutput:
38
+ """Output from shell session."""
39
+
40
+ data: str
41
+ timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
42
+ is_stderr: bool = False
43
+
44
+ def to_dict(self) -> dict[str, Any]:
45
+ """Convert to dictionary."""
46
+ return {
47
+ "data": self.data,
48
+ "timestamp": self.timestamp.isoformat(),
49
+ "is_stderr": self.is_stderr,
50
+ }
51
+
52
+
53
+ @dataclass
54
+ class ShellSession:
55
+ """Information about a shell session."""
56
+
57
+ session_id: str
58
+ host: str
59
+ started_at: datetime
60
+ terminal: str
61
+ width: int
62
+ height: int
63
+ is_active: bool = True
64
+
65
+ def to_dict(self) -> dict[str, Any]:
66
+ """Convert to dictionary."""
67
+ return {
68
+ "session_id": self.session_id,
69
+ "host": self.host,
70
+ "started_at": self.started_at.isoformat(),
71
+ "terminal": self.terminal,
72
+ "width": self.width,
73
+ "height": self.height,
74
+ "is_active": self.is_active,
75
+ }
76
+
77
+
78
+ class InteractiveShell:
79
+ """Interactive SSH shell with PTY support."""
80
+
81
+ def __init__(
82
+ self,
83
+ ssh_client: SSHClient,
84
+ term: str = "xterm",
85
+ width: int = 80,
86
+ height: int = 24,
87
+ ) -> None:
88
+ """
89
+ Initialize interactive shell.
90
+
91
+ Args:
92
+ ssh_client: Connected SSH client.
93
+ term: Terminal type.
94
+ width: Terminal width in characters.
95
+ height: Terminal height in characters.
96
+ """
97
+ self.ssh_client = ssh_client
98
+ self.term = term
99
+ self.width = width
100
+ self.height = height
101
+
102
+ self._channel: paramiko.Channel | None = None
103
+ self._output_queue: queue.Queue[ShellOutput] = queue.Queue()
104
+ self._reader_thread: threading.Thread | None = None
105
+ self._running = False
106
+ self._session_id = f"{ssh_client.machine.name}_{int(time.time())}"
107
+ self._started_at: datetime | None = None
108
+ self._callbacks: list[Callable[[ShellOutput], None]] = []
109
+ self._lock = threading.Lock()
110
+
111
+ @property
112
+ def is_active(self) -> bool:
113
+ """Check if shell is active."""
114
+ if self._channel is None:
115
+ return False
116
+ return not self._channel.closed and self._running
117
+
118
+ @property
119
+ def session_info(self) -> ShellSession | None:
120
+ """Get session information."""
121
+ if not self._started_at:
122
+ return None
123
+ return ShellSession(
124
+ session_id=self._session_id,
125
+ host=self.ssh_client.machine.name,
126
+ started_at=self._started_at,
127
+ terminal=self.term,
128
+ width=self.width,
129
+ height=self.height,
130
+ is_active=self.is_active,
131
+ )
132
+
133
+ def start(self) -> ShellSession:
134
+ """
135
+ Start interactive shell session.
136
+
137
+ Returns:
138
+ ShellSession with session details.
139
+
140
+ Raises:
141
+ ShellError: If shell cannot be started.
142
+ """
143
+ if self.is_active:
144
+ raise ShellError("Shell already active")
145
+
146
+ if not self.ssh_client.is_connected:
147
+ try:
148
+ self.ssh_client.connect()
149
+ except SSHConnectionError as e:
150
+ raise ShellError(f"Cannot connect: {e}")
151
+
152
+ try:
153
+ transport = self.ssh_client._client.get_transport()
154
+ if transport is None:
155
+ raise ShellError("No transport available")
156
+
157
+ self._channel = transport.open_session()
158
+ self._channel.get_pty(
159
+ term=self.term,
160
+ width=self.width,
161
+ height=self.height,
162
+ )
163
+ self._channel.invoke_shell()
164
+ self._channel.settimeout(0.1)
165
+
166
+ self._running = True
167
+ self._started_at = datetime.now(timezone.utc)
168
+
169
+ # Start reader thread
170
+ self._reader_thread = threading.Thread(
171
+ target=self._read_output,
172
+ daemon=True,
173
+ name=f"shell-reader-{self._session_id}",
174
+ )
175
+ self._reader_thread.start()
176
+
177
+ logger.info(
178
+ "shell_started",
179
+ host=self.ssh_client.machine.name,
180
+ session_id=self._session_id,
181
+ terminal=self.term,
182
+ )
183
+
184
+ return self.session_info # type: ignore
185
+
186
+ except paramiko.SSHException as e:
187
+ raise ShellError(f"Failed to start shell: {e}")
188
+ except Exception as e:
189
+ raise ShellError(f"Shell error: {e}")
190
+
191
+ def stop(self) -> None:
192
+ """Stop shell session."""
193
+ self._running = False
194
+
195
+ if self._channel:
196
+ try:
197
+ self._channel.close()
198
+ except Exception:
199
+ pass
200
+ self._channel = None
201
+
202
+ if self._reader_thread and self._reader_thread.is_alive():
203
+ self._reader_thread.join(timeout=2.0)
204
+
205
+ logger.info(
206
+ "shell_stopped",
207
+ host=self.ssh_client.machine.name,
208
+ session_id=self._session_id,
209
+ )
210
+
211
+ def send(self, data: str) -> None:
212
+ """
213
+ Send data to shell.
214
+
215
+ Args:
216
+ data: Data to send.
217
+
218
+ Raises:
219
+ ShellNotConnected: If shell is not active.
220
+ """
221
+ if not self.is_active or self._channel is None:
222
+ raise ShellNotConnected("Shell not active")
223
+
224
+ try:
225
+ self._channel.send(data)
226
+ except Exception as e:
227
+ raise ShellError(f"Failed to send: {e}")
228
+
229
+ def send_line(self, line: str) -> None:
230
+ """
231
+ Send line to shell (with newline).
232
+
233
+ Args:
234
+ line: Line to send.
235
+ """
236
+ self.send(line + "\n")
237
+
238
+ def recv(self, timeout: float = 1.0) -> list[ShellOutput]:
239
+ """
240
+ Receive available output from shell.
241
+
242
+ Args:
243
+ timeout: Maximum time to wait for output.
244
+
245
+ Returns:
246
+ List of ShellOutput items.
247
+ """
248
+ outputs: list[ShellOutput] = []
249
+ deadline = time.time() + timeout
250
+
251
+ while time.time() < deadline:
252
+ try:
253
+ output = self._output_queue.get(timeout=0.1)
254
+ outputs.append(output)
255
+ except queue.Empty:
256
+ if outputs:
257
+ break
258
+ continue
259
+
260
+ return outputs
261
+
262
+ def recv_until(
263
+ self,
264
+ pattern: str,
265
+ timeout: float = 30.0,
266
+ include_pattern: bool = True,
267
+ ) -> str:
268
+ """
269
+ Receive output until pattern is found.
270
+
271
+ Args:
272
+ pattern: Pattern to wait for.
273
+ timeout: Maximum time to wait.
274
+ include_pattern: Include pattern in result.
275
+
276
+ Returns:
277
+ Collected output.
278
+
279
+ Raises:
280
+ ShellTimeout: If pattern not found in time.
281
+ """
282
+ collected = ""
283
+ deadline = time.time() + timeout
284
+
285
+ while time.time() < deadline:
286
+ outputs = self.recv(timeout=0.5)
287
+ for output in outputs:
288
+ collected += output.data
289
+ if pattern in collected:
290
+ if include_pattern:
291
+ idx = collected.find(pattern) + len(pattern)
292
+ else:
293
+ idx = collected.find(pattern)
294
+ return collected[:idx]
295
+
296
+ raise ShellTimeout(f"Pattern '{pattern}' not found in {timeout}s")
297
+
298
+ def execute_and_wait(
299
+ self,
300
+ command: str,
301
+ prompt: str = "$ ",
302
+ timeout: float = 30.0,
303
+ ) -> str:
304
+ """
305
+ Execute command and wait for prompt.
306
+
307
+ Args:
308
+ command: Command to execute.
309
+ prompt: Shell prompt to wait for.
310
+ timeout: Maximum time to wait.
311
+
312
+ Returns:
313
+ Command output.
314
+ """
315
+ self.send_line(command)
316
+ output = self.recv_until(prompt, timeout=timeout)
317
+ # Remove command echo and prompt
318
+ lines = output.split("\n")
319
+ if lines and command in lines[0]:
320
+ lines = lines[1:]
321
+ if lines and prompt in lines[-1]:
322
+ lines[-1] = lines[-1].replace(prompt, "")
323
+ return "\n".join(lines).strip()
324
+
325
+ def resize(self, width: int, height: int) -> None:
326
+ """
327
+ Resize terminal.
328
+
329
+ Args:
330
+ width: New width in characters.
331
+ height: New height in characters.
332
+ """
333
+ if not self.is_active or self._channel is None:
334
+ raise ShellNotConnected("Shell not active")
335
+
336
+ self.width = width
337
+ self.height = height
338
+
339
+ try:
340
+ self._channel.resize_pty(width=width, height=height)
341
+ logger.info(
342
+ "shell_resized",
343
+ session_id=self._session_id,
344
+ width=width,
345
+ height=height,
346
+ )
347
+ except Exception as e:
348
+ raise ShellError(f"Failed to resize: {e}")
349
+
350
+ def register_callback(self, callback: Callable[[ShellOutput], None]) -> None:
351
+ """
352
+ Register callback for output.
353
+
354
+ Args:
355
+ callback: Function called with each output.
356
+ """
357
+ with self._lock:
358
+ self._callbacks.append(callback)
359
+
360
+ def _read_output(self) -> None:
361
+ """Background thread to read shell output."""
362
+ while self._running and self._channel:
363
+ try:
364
+ if self._channel.recv_ready():
365
+ data = self._channel.recv(4096).decode("utf-8", errors="replace")
366
+ if data:
367
+ output = ShellOutput(data=data, is_stderr=False)
368
+ self._output_queue.put(output)
369
+ self._notify_callbacks(output)
370
+
371
+ if self._channel.recv_stderr_ready():
372
+ data = self._channel.recv_stderr(4096).decode(
373
+ "utf-8", errors="replace"
374
+ )
375
+ if data:
376
+ output = ShellOutput(data=data, is_stderr=True)
377
+ self._output_queue.put(output)
378
+ self._notify_callbacks(output)
379
+
380
+ if self._channel.exit_status_ready():
381
+ break
382
+
383
+ time.sleep(0.01)
384
+
385
+ except Exception as e:
386
+ if self._running:
387
+ logger.warning(
388
+ "shell_read_error",
389
+ session_id=self._session_id,
390
+ error=str(e),
391
+ )
392
+ break
393
+
394
+ self._running = False
395
+
396
+ def _notify_callbacks(self, output: ShellOutput) -> None:
397
+ """Notify registered callbacks."""
398
+ with self._lock:
399
+ for callback in self._callbacks:
400
+ try:
401
+ callback(output)
402
+ except Exception as e:
403
+ logger.warning(
404
+ "shell_callback_error",
405
+ session_id=self._session_id,
406
+ error=str(e),
407
+ )
408
+
409
+ def __enter__(self) -> "InteractiveShell":
410
+ """Context manager entry."""
411
+ self.start()
412
+ return self
413
+
414
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
415
+ """Context manager exit."""
416
+ self.stop()
417
+
418
+
419
+ class ShellManager:
420
+ """Manage multiple shell sessions."""
421
+
422
+ def __init__(self) -> None:
423
+ """Initialize shell manager."""
424
+ self._sessions: dict[str, InteractiveShell] = {}
425
+ self._lock = threading.Lock()
426
+
427
+ def create_session(
428
+ self,
429
+ ssh_client: SSHClient,
430
+ term: str = "xterm",
431
+ width: int = 80,
432
+ height: int = 24,
433
+ ) -> ShellSession:
434
+ """
435
+ Create new shell session.
436
+
437
+ Args:
438
+ ssh_client: Connected SSH client.
439
+ term: Terminal type.
440
+ width: Terminal width.
441
+ height: Terminal height.
442
+
443
+ Returns:
444
+ ShellSession with session details.
445
+ """
446
+ shell = InteractiveShell(
447
+ ssh_client=ssh_client,
448
+ term=term,
449
+ width=width,
450
+ height=height,
451
+ )
452
+ session = shell.start()
453
+
454
+ with self._lock:
455
+ self._sessions[session.session_id] = shell
456
+
457
+ return session
458
+
459
+ def get_session(self, session_id: str) -> InteractiveShell | None:
460
+ """Get shell by session ID."""
461
+ with self._lock:
462
+ return self._sessions.get(session_id)
463
+
464
+ def list_sessions(self) -> list[ShellSession]:
465
+ """List all active sessions."""
466
+ sessions = []
467
+ with self._lock:
468
+ for shell in self._sessions.values():
469
+ info = shell.session_info
470
+ if info:
471
+ sessions.append(info)
472
+ return sessions
473
+
474
+ def close_session(self, session_id: str) -> bool:
475
+ """
476
+ Close shell session.
477
+
478
+ Args:
479
+ session_id: Session ID to close.
480
+
481
+ Returns:
482
+ True if session was closed.
483
+ """
484
+ with self._lock:
485
+ shell = self._sessions.pop(session_id, None)
486
+
487
+ if shell:
488
+ shell.stop()
489
+ return True
490
+ return False
491
+
492
+ def close_all(self) -> int:
493
+ """
494
+ Close all sessions.
495
+
496
+ Returns:
497
+ Number of sessions closed.
498
+ """
499
+ with self._lock:
500
+ sessions = list(self._sessions.values())
501
+ self._sessions.clear()
502
+
503
+ for shell in sessions:
504
+ shell.stop()
505
+
506
+ return len(sessions)
507
+
508
+
509
+ # Global shell manager
510
+ _shell_manager: ShellManager | None = None
511
+
512
+
513
+ def get_shell_manager() -> ShellManager:
514
+ """Get global shell manager."""
515
+ global _shell_manager
516
+ if _shell_manager is None:
517
+ _shell_manager = ShellManager()
518
+ return _shell_manager