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,247 @@
1
+ """Async SSH client wrapper for parallel execution."""
2
+
3
+ import asyncio
4
+ from typing import Any
5
+
6
+ import structlog
7
+
8
+ from sshmcp.models.command import CommandResult
9
+ from sshmcp.models.file import FileContent, FileInfo, FileUploadResult
10
+ from sshmcp.models.machine import MachineConfig
11
+ from sshmcp.ssh.client import SSHClient, SSHConnectionError, SSHExecutionError
12
+
13
+ logger = structlog.get_logger()
14
+
15
+
16
+ class AsyncSSHClient:
17
+ """
18
+ Async wrapper around SSHClient for non-blocking operations.
19
+
20
+ Uses asyncio.to_thread() to run blocking paramiko operations
21
+ in a thread pool without blocking the event loop.
22
+ """
23
+
24
+ def __init__(self, machine: MachineConfig) -> None:
25
+ """
26
+ Initialize async SSH client.
27
+
28
+ Args:
29
+ machine: Machine configuration.
30
+ """
31
+ self.machine = machine
32
+ self._sync_client: SSHClient | None = None
33
+
34
+ @property
35
+ def is_connected(self) -> bool:
36
+ """Check if SSH connection is active."""
37
+ return self._sync_client is not None and self._sync_client.is_connected
38
+
39
+ async def connect(self, retry: bool = True) -> None:
40
+ """
41
+ Establish SSH connection asynchronously.
42
+
43
+ Args:
44
+ retry: Whether to retry on failure.
45
+
46
+ Raises:
47
+ SSHConnectionError: If connection fails.
48
+ """
49
+ if self.is_connected:
50
+ return
51
+
52
+ self._sync_client = SSHClient(self.machine)
53
+ await asyncio.to_thread(self._sync_client.connect, retry)
54
+ logger.info("async_ssh_connected", host=self.machine.host)
55
+
56
+ async def disconnect(self) -> None:
57
+ """Close SSH connection asynchronously."""
58
+ if self._sync_client:
59
+ await asyncio.to_thread(self._sync_client.disconnect)
60
+ self._sync_client = None
61
+ logger.info("async_ssh_disconnected", host=self.machine.host)
62
+
63
+ async def execute(self, command: str, timeout: int | None = None) -> CommandResult:
64
+ """
65
+ Execute command asynchronously.
66
+
67
+ Args:
68
+ command: Command to execute.
69
+ timeout: Optional timeout in seconds.
70
+
71
+ Returns:
72
+ CommandResult with execution details.
73
+
74
+ Raises:
75
+ SSHExecutionError: If execution fails.
76
+ """
77
+ if not self.is_connected:
78
+ await self.connect()
79
+
80
+ return await asyncio.to_thread(
81
+ self._sync_client.execute,
82
+ command,
83
+ timeout, # type: ignore
84
+ )
85
+
86
+ async def read_file(
87
+ self,
88
+ path: str,
89
+ encoding: str = "utf-8",
90
+ max_size: int = 1024 * 1024,
91
+ ) -> FileContent:
92
+ """
93
+ Read file asynchronously.
94
+
95
+ Args:
96
+ path: Path to file.
97
+ encoding: File encoding.
98
+ max_size: Maximum file size to read.
99
+
100
+ Returns:
101
+ FileContent with file data.
102
+ """
103
+ if not self.is_connected:
104
+ await self.connect()
105
+
106
+ return await asyncio.to_thread(
107
+ self._sync_client.read_file,
108
+ path,
109
+ encoding,
110
+ max_size, # type: ignore
111
+ )
112
+
113
+ async def write_file(
114
+ self,
115
+ path: str,
116
+ content: str,
117
+ mode: str | None = None,
118
+ ) -> FileUploadResult:
119
+ """
120
+ Write file asynchronously.
121
+
122
+ Args:
123
+ path: Destination path.
124
+ content: File content.
125
+ mode: Optional file permissions.
126
+
127
+ Returns:
128
+ FileUploadResult with upload details.
129
+ """
130
+ if not self.is_connected:
131
+ await self.connect()
132
+
133
+ return await asyncio.to_thread(
134
+ self._sync_client.write_file,
135
+ path,
136
+ content,
137
+ mode, # type: ignore
138
+ )
139
+
140
+ async def list_files(
141
+ self,
142
+ directory: str,
143
+ recursive: bool = False,
144
+ ) -> list[FileInfo]:
145
+ """
146
+ List files asynchronously.
147
+
148
+ Args:
149
+ directory: Directory path.
150
+ recursive: Whether to list recursively.
151
+
152
+ Returns:
153
+ List of FileInfo objects.
154
+ """
155
+ if not self.is_connected:
156
+ await self.connect()
157
+
158
+ return await asyncio.to_thread(
159
+ self._sync_client.list_files,
160
+ directory,
161
+ recursive, # type: ignore
162
+ )
163
+
164
+ async def __aenter__(self) -> "AsyncSSHClient":
165
+ """Async context manager entry."""
166
+ await self.connect()
167
+ return self
168
+
169
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
170
+ """Async context manager exit."""
171
+ await self.disconnect()
172
+
173
+
174
+ async def execute_on_hosts_async(
175
+ machines: list[MachineConfig],
176
+ command: str,
177
+ timeout: int | None = None,
178
+ max_concurrency: int = 10,
179
+ ) -> dict[str, dict[str, Any]]:
180
+ """
181
+ Execute command on multiple hosts concurrently.
182
+
183
+ Args:
184
+ machines: List of machine configurations.
185
+ command: Command to execute.
186
+ timeout: Optional timeout per host.
187
+ max_concurrency: Maximum concurrent connections.
188
+
189
+ Returns:
190
+ Dictionary mapping host names to results.
191
+ """
192
+ semaphore = asyncio.Semaphore(max_concurrency)
193
+ results: dict[str, dict[str, Any]] = {}
194
+
195
+ async def run_on_host(machine: MachineConfig) -> tuple[str, dict[str, Any]]:
196
+ async with semaphore:
197
+ try:
198
+ async with AsyncSSHClient(machine) as client:
199
+ result = await client.execute(command, timeout)
200
+ return machine.name, {
201
+ "success": True,
202
+ **result.to_dict(),
203
+ }
204
+ except (SSHConnectionError, SSHExecutionError) as e:
205
+ return machine.name, {
206
+ "success": False,
207
+ "error": str(e),
208
+ }
209
+ except Exception as e:
210
+ return machine.name, {
211
+ "success": False,
212
+ "error": f"Unexpected error: {e}",
213
+ }
214
+
215
+ tasks = [run_on_host(machine) for machine in machines]
216
+ task_results = await asyncio.gather(*tasks, return_exceptions=True)
217
+
218
+ for result in task_results:
219
+ if isinstance(result, Exception):
220
+ logger.error("async_execution_error", error=str(result))
221
+ else:
222
+ host, data = result
223
+ results[host] = data
224
+
225
+ return results
226
+
227
+
228
+ async def health_check_hosts_async(
229
+ machines: list[MachineConfig],
230
+ max_concurrency: int = 20,
231
+ ) -> dict[str, dict[str, Any]]:
232
+ """
233
+ Check health of multiple hosts concurrently.
234
+
235
+ Args:
236
+ machines: List of machine configurations.
237
+ max_concurrency: Maximum concurrent checks.
238
+
239
+ Returns:
240
+ Dictionary mapping host names to health status.
241
+ """
242
+ return await execute_on_hosts_async(
243
+ machines,
244
+ "echo ok",
245
+ timeout=5,
246
+ max_concurrency=max_concurrency,
247
+ )
sshmcp/ssh/client.py ADDED
@@ -0,0 +1,464 @@
1
+ """SSH client for remote server connections."""
2
+
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import paramiko
8
+ import structlog
9
+
10
+ from sshmcp.models.command import CommandResult
11
+ from sshmcp.models.file import FileContent, FileInfo, FileUploadResult
12
+ from sshmcp.models.machine import MachineConfig
13
+
14
+ logger = structlog.get_logger()
15
+
16
+
17
+ class SSHConnectionError(Exception):
18
+ """Error connecting to SSH server."""
19
+
20
+ pass
21
+
22
+
23
+ class SSHExecutionError(Exception):
24
+ """Error executing command on SSH server."""
25
+
26
+ pass
27
+
28
+
29
+ class SSHClient:
30
+ """SSH client wrapper around paramiko."""
31
+
32
+ # Retry configuration
33
+ MAX_RETRIES = 3
34
+ RETRY_DELAY = 1.0 # seconds
35
+ RETRY_BACKOFF = 2.0 # exponential backoff multiplier
36
+
37
+ def __init__(self, machine: MachineConfig) -> None:
38
+ """
39
+ Initialize SSH client for a machine.
40
+
41
+ Args:
42
+ machine: Machine configuration.
43
+ """
44
+ self.machine = machine
45
+ self._client: paramiko.SSHClient | None = None
46
+ self._sftp: paramiko.SFTPClient | None = None
47
+ self._retry_count = 0
48
+
49
+ @property
50
+ def is_connected(self) -> bool:
51
+ """Check if SSH connection is active."""
52
+ if self._client is None:
53
+ return False
54
+ transport = self._client.get_transport()
55
+ return transport is not None and transport.is_active()
56
+
57
+ def connect(self, retry: bool = True) -> None:
58
+ """
59
+ Establish SSH connection to the server with retry logic.
60
+
61
+ Args:
62
+ retry: Whether to retry on failure (default: True).
63
+
64
+ Raises:
65
+ SSHConnectionError: If connection fails after all retries.
66
+ """
67
+ if self.is_connected:
68
+ return
69
+
70
+ last_error: Exception | None = None
71
+ max_attempts = self.MAX_RETRIES if retry else 1
72
+
73
+ for attempt in range(max_attempts):
74
+ try:
75
+ self._connect_once()
76
+ self._retry_count = 0 # Reset on success
77
+ return
78
+ except SSHConnectionError as e:
79
+ last_error = e
80
+ if attempt < max_attempts - 1:
81
+ delay = self.RETRY_DELAY * (self.RETRY_BACKOFF**attempt)
82
+ logger.warning(
83
+ "ssh_connection_retry",
84
+ host=self.machine.host,
85
+ attempt=attempt + 1,
86
+ max_attempts=max_attempts,
87
+ delay=delay,
88
+ error=str(e),
89
+ )
90
+ time.sleep(delay)
91
+
92
+ self._retry_count += 1
93
+ raise SSHConnectionError(
94
+ f"Failed to connect after {max_attempts} attempts: {last_error}"
95
+ )
96
+
97
+ def _connect_once(self) -> None:
98
+ """Single connection attempt without retry."""
99
+ logger.info(
100
+ "ssh_connecting",
101
+ host=self.machine.host,
102
+ port=self.machine.port,
103
+ user=self.machine.user,
104
+ )
105
+
106
+ self._client = paramiko.SSHClient()
107
+ self._client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
108
+
109
+ try:
110
+ connect_kwargs: dict[str, Any] = {
111
+ "hostname": self.machine.host,
112
+ "port": self.machine.port,
113
+ "username": self.machine.user,
114
+ "timeout": 30,
115
+ }
116
+
117
+ auth = self.machine.auth
118
+ if auth.type == "key":
119
+ key_path = Path(auth.key_path).expanduser() # type: ignore
120
+ if not key_path.exists():
121
+ raise SSHConnectionError(f"SSH key not found: {key_path}")
122
+
123
+ # Try to load the key
124
+ try:
125
+ if auth.passphrase:
126
+ pkey = paramiko.RSAKey.from_private_key_file(
127
+ str(key_path), password=auth.passphrase
128
+ )
129
+ else:
130
+ # Try RSA first, then Ed25519, then ECDSA
131
+ pkey = self._load_private_key(key_path, auth.passphrase)
132
+ connect_kwargs["pkey"] = pkey
133
+ except Exception as e:
134
+ raise SSHConnectionError(f"Failed to load SSH key: {e}")
135
+
136
+ elif auth.type == "password":
137
+ connect_kwargs["password"] = auth.password
138
+
139
+ elif auth.type == "agent":
140
+ # Use SSH agent for authentication
141
+ connect_kwargs["allow_agent"] = True
142
+ connect_kwargs["look_for_keys"] = False
143
+
144
+ # Enable agent forwarding if requested
145
+ if auth.agent_forwarding:
146
+ connect_kwargs["allow_agent"] = True
147
+
148
+ self._client.connect(**connect_kwargs)
149
+ logger.info("ssh_connected", host=self.machine.host)
150
+
151
+ except paramiko.AuthenticationException as e:
152
+ raise SSHConnectionError(f"Authentication failed: {e}")
153
+ except paramiko.SSHException as e:
154
+ raise SSHConnectionError(f"SSH error: {e}")
155
+ except Exception as e:
156
+ raise SSHConnectionError(f"Connection failed: {e}")
157
+
158
+ def _load_private_key(
159
+ self, key_path: Path, passphrase: str | None
160
+ ) -> paramiko.PKey:
161
+ """Try to load private key with different algorithms."""
162
+ key_types = [
163
+ paramiko.RSAKey,
164
+ paramiko.Ed25519Key,
165
+ paramiko.ECDSAKey,
166
+ ]
167
+
168
+ last_error = None
169
+ for key_type in key_types:
170
+ try:
171
+ return key_type.from_private_key_file(
172
+ str(key_path), password=passphrase
173
+ )
174
+ except Exception as e:
175
+ last_error = e
176
+ continue
177
+
178
+ raise SSHConnectionError(
179
+ f"Could not load SSH key with any supported algorithm: {last_error}"
180
+ )
181
+
182
+ def disconnect(self) -> None:
183
+ """Close SSH connection."""
184
+ if self._sftp:
185
+ try:
186
+ self._sftp.close()
187
+ except Exception:
188
+ pass
189
+ self._sftp = None
190
+
191
+ if self._client:
192
+ try:
193
+ self._client.close()
194
+ except Exception:
195
+ pass
196
+ self._client = None
197
+
198
+ logger.info("ssh_disconnected", host=self.machine.host)
199
+
200
+ def execute(self, command: str, timeout: int | None = None) -> CommandResult:
201
+ """
202
+ Execute command on remote server.
203
+
204
+ Args:
205
+ command: Command to execute.
206
+ timeout: Optional timeout in seconds. Uses machine config if not provided.
207
+
208
+ Returns:
209
+ CommandResult with execution details.
210
+
211
+ Raises:
212
+ SSHExecutionError: If execution fails.
213
+ """
214
+ if not self.is_connected:
215
+ self.connect()
216
+
217
+ if timeout is None:
218
+ timeout = self.machine.security.timeout_seconds
219
+
220
+ logger.info(
221
+ "ssh_executing",
222
+ host=self.machine.host,
223
+ command=command,
224
+ timeout=timeout,
225
+ )
226
+
227
+ start_time = time.time()
228
+
229
+ try:
230
+ stdin, stdout, stderr = self._client.exec_command( # type: ignore
231
+ command, timeout=timeout
232
+ )
233
+
234
+ exit_code = stdout.channel.recv_exit_status()
235
+ stdout_text = stdout.read().decode("utf-8", errors="replace")
236
+ stderr_text = stderr.read().decode("utf-8", errors="replace")
237
+
238
+ duration_ms = int((time.time() - start_time) * 1000)
239
+
240
+ result = CommandResult(
241
+ exit_code=exit_code,
242
+ stdout=stdout_text,
243
+ stderr=stderr_text,
244
+ duration_ms=duration_ms,
245
+ host=self.machine.name,
246
+ command=command,
247
+ )
248
+
249
+ logger.info(
250
+ "ssh_executed",
251
+ host=self.machine.host,
252
+ exit_code=exit_code,
253
+ duration_ms=duration_ms,
254
+ )
255
+
256
+ return result
257
+
258
+ except paramiko.SSHException as e:
259
+ raise SSHExecutionError(f"SSH execution error: {e}")
260
+ except Exception as e:
261
+ raise SSHExecutionError(f"Command execution failed: {e}")
262
+
263
+ def _get_sftp(self) -> paramiko.SFTPClient:
264
+ """Get or create SFTP client."""
265
+ if not self.is_connected:
266
+ self.connect()
267
+
268
+ if self._sftp is None:
269
+ self._sftp = self._client.open_sftp() # type: ignore
270
+
271
+ return self._sftp
272
+
273
+ def read_file(
274
+ self,
275
+ path: str,
276
+ encoding: str = "utf-8",
277
+ max_size: int = 1024 * 1024, # 1MB default
278
+ ) -> FileContent:
279
+ """
280
+ Read file content from remote server.
281
+
282
+ Args:
283
+ path: Path to file on remote server.
284
+ encoding: File encoding.
285
+ max_size: Maximum file size to read in bytes.
286
+
287
+ Returns:
288
+ FileContent with file data.
289
+
290
+ Raises:
291
+ SSHExecutionError: If file cannot be read.
292
+ """
293
+ sftp = self._get_sftp()
294
+
295
+ try:
296
+ stat = sftp.stat(path)
297
+ file_size = stat.st_size or 0
298
+
299
+ truncated = file_size > max_size
300
+ read_size = min(file_size, max_size)
301
+
302
+ with sftp.open(path, "r") as f:
303
+ content = f.read(read_size)
304
+ if isinstance(content, bytes):
305
+ content = content.decode(encoding, errors="replace")
306
+
307
+ logger.info(
308
+ "ssh_file_read",
309
+ host=self.machine.host,
310
+ path=path,
311
+ size=file_size,
312
+ truncated=truncated,
313
+ )
314
+
315
+ return FileContent(
316
+ content=content,
317
+ path=path,
318
+ size=file_size,
319
+ encoding=encoding,
320
+ truncated=truncated,
321
+ host=self.machine.name,
322
+ )
323
+
324
+ except FileNotFoundError:
325
+ raise SSHExecutionError(f"File not found: {path}")
326
+ except PermissionError:
327
+ raise SSHExecutionError(f"Permission denied: {path}")
328
+ except Exception as e:
329
+ raise SSHExecutionError(f"Error reading file: {e}")
330
+
331
+ def write_file(
332
+ self,
333
+ path: str,
334
+ content: str,
335
+ mode: str | None = None,
336
+ ) -> FileUploadResult:
337
+ """
338
+ Write file to remote server.
339
+
340
+ Args:
341
+ path: Destination path on remote server.
342
+ content: File content to write.
343
+ mode: Optional file permissions (e.g., "0644").
344
+
345
+ Returns:
346
+ FileUploadResult with upload details.
347
+
348
+ Raises:
349
+ SSHExecutionError: If file cannot be written.
350
+ """
351
+ sftp = self._get_sftp()
352
+
353
+ try:
354
+ content_bytes = content.encode("utf-8")
355
+ size = len(content_bytes)
356
+
357
+ with sftp.open(path, "w") as f:
358
+ f.write(content_bytes)
359
+
360
+ if mode:
361
+ mode_int = int(mode, 8)
362
+ sftp.chmod(path, mode_int)
363
+
364
+ logger.info(
365
+ "ssh_file_written",
366
+ host=self.machine.host,
367
+ path=path,
368
+ size=size,
369
+ )
370
+
371
+ return FileUploadResult(
372
+ success=True,
373
+ path=path,
374
+ size=size,
375
+ host=self.machine.name,
376
+ )
377
+
378
+ except PermissionError:
379
+ raise SSHExecutionError(f"Permission denied: {path}")
380
+ except Exception as e:
381
+ raise SSHExecutionError(f"Error writing file: {e}")
382
+
383
+ def list_files(
384
+ self,
385
+ directory: str,
386
+ recursive: bool = False,
387
+ ) -> list[FileInfo]:
388
+ """
389
+ List files in remote directory.
390
+
391
+ Args:
392
+ directory: Directory path on remote server.
393
+ recursive: Whether to list files recursively.
394
+
395
+ Returns:
396
+ List of FileInfo objects.
397
+
398
+ Raises:
399
+ SSHExecutionError: If directory cannot be listed.
400
+ """
401
+ sftp = self._get_sftp()
402
+ files: list[FileInfo] = []
403
+
404
+ try:
405
+ self._list_directory(sftp, directory, files, recursive)
406
+ return files
407
+
408
+ except FileNotFoundError:
409
+ raise SSHExecutionError(f"Directory not found: {directory}")
410
+ except PermissionError:
411
+ raise SSHExecutionError(f"Permission denied: {directory}")
412
+ except Exception as e:
413
+ raise SSHExecutionError(f"Error listing directory: {e}")
414
+
415
+ def _list_directory(
416
+ self,
417
+ sftp: paramiko.SFTPClient,
418
+ directory: str,
419
+ files: list[FileInfo],
420
+ recursive: bool,
421
+ ) -> None:
422
+ """Recursively list directory contents."""
423
+ import stat
424
+ from datetime import datetime
425
+
426
+ for entry in sftp.listdir_attr(directory):
427
+ full_path = f"{directory.rstrip('/')}/{entry.filename}"
428
+
429
+ # Determine file type
430
+ if stat.S_ISDIR(entry.st_mode or 0):
431
+ file_type = "directory"
432
+ elif stat.S_ISLNK(entry.st_mode or 0):
433
+ file_type = "link"
434
+ elif stat.S_ISREG(entry.st_mode or 0):
435
+ file_type = "file"
436
+ else:
437
+ file_type = "other"
438
+
439
+ file_info = FileInfo(
440
+ name=entry.filename,
441
+ path=full_path,
442
+ type=file_type, # type: ignore
443
+ size=entry.st_size or 0,
444
+ modified=datetime.fromtimestamp(entry.st_mtime or 0),
445
+ permissions=oct(entry.st_mode or 0)[-4:] if entry.st_mode else None,
446
+ owner=str(entry.st_uid) if entry.st_uid else None,
447
+ group=str(entry.st_gid) if entry.st_gid else None,
448
+ )
449
+ files.append(file_info)
450
+
451
+ if recursive and file_type == "directory":
452
+ try:
453
+ self._list_directory(sftp, full_path, files, recursive)
454
+ except PermissionError:
455
+ pass # Skip directories we can't access
456
+
457
+ def __enter__(self) -> "SSHClient":
458
+ """Context manager entry."""
459
+ self.connect()
460
+ return self
461
+
462
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
463
+ """Context manager exit."""
464
+ self.disconnect()