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,291 @@
1
+ """Dependency injection container for sshmcp."""
2
+
3
+ import threading
4
+ from dataclasses import dataclass
5
+ from typing import Any, Callable, Generic, TypeVar
6
+
7
+ import structlog
8
+
9
+ from sshmcp.models.machine import MachinesConfig
10
+ from sshmcp.security.audit import AuditLogger
11
+ from sshmcp.security.rate_limiter import RateLimiter
12
+ from sshmcp.ssh.pool import SSHConnectionPool
13
+ from sshmcp.ssh.shell import ShellManager
14
+
15
+ logger = structlog.get_logger()
16
+
17
+ T = TypeVar("T")
18
+
19
+
20
+ class Provider(Generic[T]):
21
+ """Lazy provider for dependency instances."""
22
+
23
+ def __init__(self, factory: Callable[[], T]) -> None:
24
+ """
25
+ Initialize provider.
26
+
27
+ Args:
28
+ factory: Factory function to create instance.
29
+ """
30
+ self._factory = factory
31
+ self._instance: T | None = None
32
+ self._lock = threading.Lock()
33
+
34
+ def get(self) -> T:
35
+ """Get or create the instance."""
36
+ if self._instance is None:
37
+ with self._lock:
38
+ if self._instance is None:
39
+ self._instance = self._factory()
40
+ return self._instance
41
+
42
+ def reset(self) -> None:
43
+ """Reset the instance."""
44
+ with self._lock:
45
+ self._instance = None
46
+
47
+ @property
48
+ def is_initialized(self) -> bool:
49
+ """Check if instance is initialized."""
50
+ return self._instance is not None
51
+
52
+
53
+ @dataclass
54
+ class ContainerConfig:
55
+ """Configuration for the container."""
56
+
57
+ # Pool settings
58
+ max_connections_per_host: int = 3
59
+ idle_timeout: int = 300
60
+ cleanup_interval: int = 60
61
+ health_check_interval: int = 30
62
+ enable_background_cleanup: bool = True
63
+
64
+ # Rate limiter settings
65
+ requests_per_minute: int = 60
66
+ requests_per_hour: int = 1000
67
+
68
+ # Audit settings
69
+ audit_log_path: str | None = None
70
+ audit_retention_days: int = 30
71
+
72
+
73
+ class Container:
74
+ """
75
+ Dependency injection container.
76
+
77
+ Manages lifecycle of shared services like connection pool,
78
+ shell manager, audit logger, rate limiter, etc.
79
+ """
80
+
81
+ def __init__(self, config: ContainerConfig | None = None) -> None:
82
+ """
83
+ Initialize container.
84
+
85
+ Args:
86
+ config: Optional configuration.
87
+ """
88
+ self.config = config or ContainerConfig()
89
+ self._providers: dict[str, Provider[Any]] = {}
90
+ self._machines_config: MachinesConfig | None = None
91
+ self._lock = threading.Lock()
92
+
93
+ self._register_defaults()
94
+
95
+ def _register_defaults(self) -> None:
96
+ """Register default providers."""
97
+ self._providers["pool"] = Provider(self._create_pool)
98
+ self._providers["shell_manager"] = Provider(self._create_shell_manager)
99
+ self._providers["audit_logger"] = Provider(self._create_audit_logger)
100
+ self._providers["rate_limiter"] = Provider(self._create_rate_limiter)
101
+
102
+ def _create_pool(self) -> SSHConnectionPool:
103
+ """Create connection pool."""
104
+ pool = SSHConnectionPool(
105
+ max_connections_per_host=self.config.max_connections_per_host,
106
+ idle_timeout=self.config.idle_timeout,
107
+ cleanup_interval=self.config.cleanup_interval,
108
+ health_check_interval=self.config.health_check_interval,
109
+ enable_background_cleanup=self.config.enable_background_cleanup,
110
+ )
111
+
112
+ # Register machines if config available
113
+ if self._machines_config:
114
+ for machine in self._machines_config.machines:
115
+ pool.register_machine(machine)
116
+
117
+ logger.info("container_pool_created")
118
+ return pool
119
+
120
+ def _create_shell_manager(self) -> ShellManager:
121
+ """Create shell manager."""
122
+ logger.info("container_shell_manager_created")
123
+ return ShellManager()
124
+
125
+ def _create_audit_logger(self) -> AuditLogger:
126
+ """Create audit logger."""
127
+ logger.info("container_audit_logger_created")
128
+ return AuditLogger(log_file=self.config.audit_log_path)
129
+
130
+ def _create_rate_limiter(self) -> RateLimiter:
131
+ """Create rate limiter."""
132
+ from sshmcp.security.rate_limiter import RateLimitConfig
133
+
134
+ logger.info("container_rate_limiter_created")
135
+ config = RateLimitConfig(
136
+ requests_per_minute=self.config.requests_per_minute,
137
+ requests_per_hour=self.config.requests_per_hour,
138
+ )
139
+ return RateLimiter(config=config)
140
+
141
+ def set_machines_config(self, config: MachinesConfig) -> None:
142
+ """
143
+ Set machines configuration.
144
+
145
+ Args:
146
+ config: Machines configuration.
147
+ """
148
+ self._machines_config = config
149
+
150
+ # If pool already exists, register machines
151
+ if self._providers["pool"].is_initialized:
152
+ pool = self.pool
153
+ for machine in config.machines:
154
+ pool.register_machine(machine)
155
+
156
+ @property
157
+ def pool(self) -> SSHConnectionPool:
158
+ """Get connection pool."""
159
+ return self._providers["pool"].get()
160
+
161
+ @property
162
+ def shell_manager(self) -> ShellManager:
163
+ """Get shell manager."""
164
+ return self._providers["shell_manager"].get()
165
+
166
+ @property
167
+ def audit_logger(self) -> AuditLogger:
168
+ """Get audit logger."""
169
+ return self._providers["audit_logger"].get()
170
+
171
+ @property
172
+ def rate_limiter(self) -> RateLimiter:
173
+ """Get rate limiter."""
174
+ return self._providers["rate_limiter"].get()
175
+
176
+ def register(self, name: str, factory: Callable[[], Any]) -> None:
177
+ """
178
+ Register a custom provider.
179
+
180
+ Args:
181
+ name: Provider name.
182
+ factory: Factory function.
183
+ """
184
+ with self._lock:
185
+ self._providers[name] = Provider(factory)
186
+
187
+ def get(self, name: str) -> Any:
188
+ """
189
+ Get a service by name.
190
+
191
+ Args:
192
+ name: Service name.
193
+
194
+ Returns:
195
+ Service instance.
196
+
197
+ Raises:
198
+ KeyError: If service not registered.
199
+ """
200
+ if name not in self._providers:
201
+ raise KeyError(f"Service not registered: {name}")
202
+ return self._providers[name].get()
203
+
204
+ def reset(self, name: str | None = None) -> None:
205
+ """
206
+ Reset service(s).
207
+
208
+ Args:
209
+ name: Service name to reset, or None to reset all.
210
+ """
211
+ with self._lock:
212
+ if name:
213
+ if name in self._providers:
214
+ self._providers[name].reset()
215
+ else:
216
+ for provider in self._providers.values():
217
+ provider.reset()
218
+
219
+ def shutdown(self) -> None:
220
+ """Shutdown all services."""
221
+ logger.info("container_shutting_down")
222
+
223
+ # Shutdown pool
224
+ if self._providers["pool"].is_initialized:
225
+ self.pool.shutdown()
226
+
227
+ # Close shell sessions
228
+ if self._providers["shell_manager"].is_initialized:
229
+ self.shell_manager.close_all()
230
+
231
+ # Reset all
232
+ self.reset()
233
+ logger.info("container_shutdown_complete")
234
+
235
+ def __enter__(self) -> "Container":
236
+ """Context manager entry."""
237
+ return self
238
+
239
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
240
+ """Context manager exit."""
241
+ self.shutdown()
242
+
243
+
244
+ # Global container instance
245
+ _container: Container | None = None
246
+ _container_lock = threading.Lock()
247
+
248
+
249
+ def get_container() -> Container:
250
+ """Get or create the global container."""
251
+ global _container
252
+ with _container_lock:
253
+ if _container is None:
254
+ _container = Container()
255
+ return _container
256
+
257
+
258
+ def init_container(
259
+ config: ContainerConfig | None = None,
260
+ machines: MachinesConfig | None = None,
261
+ ) -> Container:
262
+ """
263
+ Initialize the global container.
264
+
265
+ Args:
266
+ config: Container configuration.
267
+ machines: Machines configuration.
268
+
269
+ Returns:
270
+ Initialized Container.
271
+ """
272
+ global _container
273
+ with _container_lock:
274
+ if _container is not None:
275
+ _container.shutdown()
276
+
277
+ _container = Container(config=config)
278
+
279
+ if machines:
280
+ _container.set_machines_config(machines)
281
+
282
+ return _container
283
+
284
+
285
+ def reset_container() -> None:
286
+ """Reset the global container."""
287
+ global _container
288
+ with _container_lock:
289
+ if _container is not None:
290
+ _container.shutdown()
291
+ _container = None
@@ -0,0 +1,15 @@
1
+ """Pydantic models for SSH MCP Server."""
2
+
3
+ from sshmcp.models.command import CommandError, CommandResult
4
+ from sshmcp.models.file import FileContent, FileInfo
5
+ from sshmcp.models.machine import AuthConfig, MachineConfig, SecurityConfig
6
+
7
+ __all__ = [
8
+ "MachineConfig",
9
+ "AuthConfig",
10
+ "SecurityConfig",
11
+ "CommandResult",
12
+ "CommandError",
13
+ "FileInfo",
14
+ "FileContent",
15
+ ]
@@ -0,0 +1,69 @@
1
+ """Pydantic models for command execution results."""
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class CommandResult(BaseModel):
10
+ """Result of command execution on remote server."""
11
+
12
+ exit_code: int = Field(description="Command exit code (0 = success)")
13
+ stdout: str = Field(default="", description="Standard output")
14
+ stderr: str = Field(default="", description="Standard error output")
15
+ duration_ms: int = Field(ge=0, description="Execution duration in milliseconds")
16
+ host: str = Field(description="Host where command was executed")
17
+ command: str = Field(description="Executed command")
18
+ timestamp: datetime = Field(
19
+ default_factory=datetime.utcnow, description="Execution timestamp"
20
+ )
21
+
22
+ @property
23
+ def success(self) -> bool:
24
+ """Check if command executed successfully."""
25
+ return self.exit_code == 0
26
+
27
+ def to_dict(self) -> dict[str, Any]:
28
+ """Convert to dictionary for MCP response."""
29
+ return {
30
+ "exit_code": self.exit_code,
31
+ "stdout": self.stdout,
32
+ "stderr": self.stderr,
33
+ "duration_ms": self.duration_ms,
34
+ "host": self.host,
35
+ "command": self.command,
36
+ "success": self.success,
37
+ "timestamp": self.timestamp.isoformat(),
38
+ }
39
+
40
+
41
+ class CommandError(BaseModel):
42
+ """Error details for failed command execution."""
43
+
44
+ error_type: str = Field(description="Type of error")
45
+ message: str = Field(description="Error message")
46
+ host: str | None = Field(default=None, description="Host where error occurred")
47
+ command: str | None = Field(default=None, description="Command that caused error")
48
+ details: dict[str, Any] | None = Field(
49
+ default=None, description="Additional error details"
50
+ )
51
+ timestamp: datetime = Field(
52
+ default_factory=datetime.utcnow, description="Error timestamp"
53
+ )
54
+
55
+ def to_dict(self) -> dict[str, Any]:
56
+ """Convert to dictionary for MCP response."""
57
+ result = {
58
+ "error": True,
59
+ "error_type": self.error_type,
60
+ "message": self.message,
61
+ "timestamp": self.timestamp.isoformat(),
62
+ }
63
+ if self.host:
64
+ result["host"] = self.host
65
+ if self.command:
66
+ result["command"] = self.command
67
+ if self.details:
68
+ result["details"] = self.details
69
+ return result
sshmcp/models/file.py ADDED
@@ -0,0 +1,102 @@
1
+ """Pydantic models for file operations."""
2
+
3
+ from datetime import datetime
4
+ from typing import Any, Literal
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class FileInfo(BaseModel):
10
+ """Information about a file on remote server."""
11
+
12
+ name: str = Field(description="File name")
13
+ path: str = Field(description="Full path to file")
14
+ type: Literal["file", "directory", "link", "other"] = Field(description="File type")
15
+ size: int = Field(ge=0, description="File size in bytes")
16
+ modified: datetime = Field(description="Last modification time")
17
+ permissions: str | None = Field(
18
+ default=None, description="File permissions (e.g., '0644')"
19
+ )
20
+ owner: str | None = Field(default=None, description="File owner")
21
+ group: str | None = Field(default=None, description="File group")
22
+
23
+ def to_dict(self) -> dict[str, Any]:
24
+ """Convert to dictionary for MCP response."""
25
+ result = {
26
+ "name": self.name,
27
+ "path": self.path,
28
+ "type": self.type,
29
+ "size": self.size,
30
+ "modified": self.modified.isoformat(),
31
+ }
32
+ if self.permissions:
33
+ result["permissions"] = self.permissions
34
+ if self.owner:
35
+ result["owner"] = self.owner
36
+ if self.group:
37
+ result["group"] = self.group
38
+ return result
39
+
40
+
41
+ class FileContent(BaseModel):
42
+ """Content of a file from remote server."""
43
+
44
+ content: str = Field(description="File content")
45
+ path: str = Field(description="Full path to file")
46
+ size: int = Field(ge=0, description="File size in bytes")
47
+ encoding: str = Field(default="utf-8", description="File encoding")
48
+ truncated: bool = Field(
49
+ default=False, description="Whether content was truncated due to size limit"
50
+ )
51
+ host: str = Field(description="Host where file is located")
52
+
53
+ def to_dict(self) -> dict[str, Any]:
54
+ """Convert to dictionary for MCP response."""
55
+ return {
56
+ "content": self.content,
57
+ "path": self.path,
58
+ "size": self.size,
59
+ "encoding": self.encoding,
60
+ "truncated": self.truncated,
61
+ "host": self.host,
62
+ }
63
+
64
+
65
+ class FileUploadResult(BaseModel):
66
+ """Result of file upload operation."""
67
+
68
+ success: bool = Field(description="Whether upload was successful")
69
+ path: str = Field(description="Path where file was uploaded")
70
+ size: int = Field(ge=0, description="Uploaded file size in bytes")
71
+ host: str = Field(description="Host where file was uploaded")
72
+ message: str | None = Field(default=None, description="Additional message")
73
+
74
+ def to_dict(self) -> dict[str, Any]:
75
+ """Convert to dictionary for MCP response."""
76
+ result = {
77
+ "success": self.success,
78
+ "path": self.path,
79
+ "size": self.size,
80
+ "host": self.host,
81
+ }
82
+ if self.message:
83
+ result["message"] = self.message
84
+ return result
85
+
86
+
87
+ class FileListResult(BaseModel):
88
+ """Result of listing files in directory."""
89
+
90
+ files: list[FileInfo] = Field(description="List of files")
91
+ directory: str = Field(description="Listed directory path")
92
+ host: str = Field(description="Host where files are located")
93
+ total_count: int = Field(ge=0, description="Total number of files")
94
+
95
+ def to_dict(self) -> dict[str, Any]:
96
+ """Convert to dictionary for MCP response."""
97
+ return {
98
+ "files": [f.to_dict() for f in self.files],
99
+ "directory": self.directory,
100
+ "host": self.host,
101
+ "total_count": self.total_count,
102
+ }
@@ -0,0 +1,139 @@
1
+ """Pydantic models for machine configuration."""
2
+
3
+ import os
4
+ from typing import Literal
5
+
6
+ from pydantic import BaseModel, Field, field_validator
7
+
8
+
9
+ class AuthConfig(BaseModel):
10
+ """Authentication configuration for SSH connection."""
11
+
12
+ type: Literal["key", "password", "agent"] = Field(
13
+ description="Authentication type: 'key' for SSH key, 'password' for password, 'agent' for SSH agent"
14
+ )
15
+ key_path: str | None = Field(
16
+ default=None, description="Path to SSH private key file"
17
+ )
18
+ passphrase: str | None = Field(
19
+ default=None, description="Passphrase for encrypted SSH key"
20
+ )
21
+ password: str | None = Field(
22
+ default=None, description="Password for password authentication"
23
+ )
24
+ agent_forwarding: bool = Field(
25
+ default=False, description="Enable SSH agent forwarding"
26
+ )
27
+
28
+ @field_validator("key_path")
29
+ @classmethod
30
+ def expand_key_path(cls, v: str | None) -> str | None:
31
+ """Expand ~ in key path."""
32
+ if v:
33
+ return os.path.expanduser(v)
34
+ return v
35
+
36
+ def model_post_init(self, __context: object) -> None:
37
+ """Validate that required fields are present based on auth type."""
38
+ if self.type == "key" and not self.key_path:
39
+ raise ValueError("key_path is required when auth type is 'key'")
40
+ if self.type == "password" and not self.password:
41
+ raise ValueError("password is required when auth type is 'password'")
42
+ # 'agent' type doesn't require additional fields
43
+
44
+
45
+ class SecurityConfig(BaseModel):
46
+ """Security configuration for command execution."""
47
+
48
+ allowed_commands: list[str] = Field(
49
+ default_factory=list,
50
+ description="List of regex patterns for allowed commands",
51
+ )
52
+ forbidden_commands: list[str] = Field(
53
+ default_factory=lambda: [
54
+ r".*rm\s+-rf\s+/.*",
55
+ r".*sudo\s+.*",
56
+ r".*su\s+-.*",
57
+ r".*dd\s+if=.*",
58
+ r".*mkfs\..*",
59
+ r".*:\(\)\{.*", # Fork bomb
60
+ ],
61
+ description="List of regex patterns for forbidden commands",
62
+ )
63
+ timeout_seconds: int = Field(
64
+ default=30, ge=1, le=3600, description="Command timeout in seconds"
65
+ )
66
+ max_concurrent_commands: int = Field(
67
+ default=3, ge=1, le=10, description="Maximum concurrent commands per machine"
68
+ )
69
+ allowed_paths: list[str] = Field(
70
+ default_factory=list,
71
+ description="List of allowed file paths for read/write operations",
72
+ )
73
+ forbidden_paths: list[str] = Field(
74
+ default_factory=lambda: [
75
+ "/etc/passwd",
76
+ "/etc/shadow",
77
+ "/etc/sudoers",
78
+ "/root",
79
+ "~/.ssh",
80
+ ],
81
+ description="List of forbidden file paths",
82
+ )
83
+
84
+
85
+ class MachineConfig(BaseModel):
86
+ """Configuration for a VPS machine."""
87
+
88
+ name: str = Field(
89
+ min_length=1, max_length=64, description="Unique name for the machine"
90
+ )
91
+ host: str = Field(min_length=1, description="Hostname or IP address")
92
+ port: int = Field(default=22, ge=1, le=65535, description="SSH port")
93
+ user: str = Field(min_length=1, description="SSH username")
94
+ auth: AuthConfig = Field(description="Authentication configuration")
95
+ security: SecurityConfig = Field(
96
+ default_factory=SecurityConfig, description="Security configuration"
97
+ )
98
+ description: str | None = Field(
99
+ default=None, description="Optional description of the machine"
100
+ )
101
+ tags: list[str] = Field(
102
+ default_factory=list,
103
+ description="Tags for grouping and filtering servers (e.g., ['production', 'web'])",
104
+ )
105
+
106
+ @field_validator("name")
107
+ @classmethod
108
+ def validate_name(cls, v: str) -> str:
109
+ """Validate machine name contains only safe characters."""
110
+ import re
111
+
112
+ if not re.match(r"^[a-zA-Z0-9_-]+$", v):
113
+ raise ValueError(
114
+ "Machine name must contain only alphanumeric characters, underscores, and hyphens"
115
+ )
116
+ return v
117
+
118
+
119
+ class MachinesConfig(BaseModel):
120
+ """Root configuration containing all machines."""
121
+
122
+ machines: list[MachineConfig] = Field(
123
+ default_factory=list, description="List of configured machines"
124
+ )
125
+
126
+ def get_machine(self, name: str) -> MachineConfig | None:
127
+ """Get machine by name."""
128
+ for machine in self.machines:
129
+ if machine.name == name:
130
+ return machine
131
+ return None
132
+
133
+ def has_machine(self, name: str) -> bool:
134
+ """Check if machine exists."""
135
+ return self.get_machine(name) is not None
136
+
137
+ def get_machine_names(self) -> list[str]:
138
+ """Get list of all machine names."""
139
+ return [m.name for m in self.machines]
File without changes