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/cli.py ADDED
@@ -0,0 +1,473 @@
1
+ """CLI interface for SSH MCP server management."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import click
10
+ import structlog
11
+
12
+ from sshmcp.models.machine import (
13
+ AuthConfig,
14
+ MachineConfig,
15
+ MachinesConfig,
16
+ SecurityConfig,
17
+ )
18
+
19
+ logger = structlog.get_logger()
20
+
21
+ DEFAULT_CONFIG_PATH = Path.home() / ".sshmcp" / "machines.json"
22
+
23
+
24
+ def get_config_path() -> Path:
25
+ """Get configuration file path."""
26
+ env_path = os.environ.get("SSHMCP_CONFIG_PATH")
27
+ if env_path:
28
+ return Path(env_path).expanduser()
29
+ return DEFAULT_CONFIG_PATH
30
+
31
+
32
+ def load_machines_config() -> MachinesConfig:
33
+ """Load machines configuration."""
34
+ config_path = get_config_path()
35
+ if not config_path.exists():
36
+ return MachinesConfig(machines=[])
37
+
38
+ with open(config_path, "r") as f:
39
+ data = json.load(f)
40
+ return MachinesConfig.model_validate(data)
41
+
42
+
43
+ def save_machines_config(config: MachinesConfig) -> None:
44
+ """Save machines configuration."""
45
+ config_path = get_config_path()
46
+ config_path.parent.mkdir(parents=True, exist_ok=True)
47
+
48
+ with open(config_path, "w") as f:
49
+ json.dump(config.model_dump(), f, indent=2)
50
+
51
+ click.echo(f"Configuration saved to {config_path}")
52
+
53
+
54
+ @click.group()
55
+ @click.version_option(version="0.1.0")
56
+ def cli() -> None:
57
+ """SSH MCP Server - Manage VPS servers for AI agents."""
58
+ pass
59
+
60
+
61
+ @cli.group()
62
+ def server() -> None:
63
+ """Manage VPS servers."""
64
+ pass
65
+
66
+
67
+ @server.command("list")
68
+ @click.option("--verbose", "-v", is_flag=True, help="Show detailed information")
69
+ def list_servers(verbose: bool) -> None:
70
+ """List all configured servers."""
71
+ config = load_machines_config()
72
+
73
+ if not config.machines:
74
+ click.echo("No servers configured yet.")
75
+ click.echo("\nAdd a server with: sshmcp server add")
76
+ return
77
+
78
+ click.echo(f"\n{'Name':<20} {'Host':<25} {'User':<15} {'Auth':<10}")
79
+ click.echo("-" * 70)
80
+
81
+ for machine in config.machines:
82
+ auth_type = machine.auth.type
83
+ click.echo(
84
+ f"{machine.name:<20} {machine.host:<25} {machine.user:<15} {auth_type:<10}"
85
+ )
86
+
87
+ if verbose:
88
+ click.echo(f" Port: {machine.port}")
89
+ if machine.description:
90
+ click.echo(f" Description: {machine.description}")
91
+ click.echo(f" Timeout: {machine.security.timeout_seconds}s")
92
+ click.echo(f" Allowed commands: {len(machine.security.allowed_commands)}")
93
+ click.echo()
94
+
95
+ click.echo(f"\nTotal: {len(config.machines)} server(s)")
96
+
97
+
98
+ @server.command("add")
99
+ @click.option("--name", "-n", prompt="Server name", help="Unique name for the server")
100
+ @click.option(
101
+ "--host", "-h", prompt="Host (IP or domain)", help="Server hostname or IP"
102
+ )
103
+ @click.option(
104
+ "--port", "-p", default=22, prompt="SSH port", help="SSH port (default: 22)"
105
+ )
106
+ @click.option("--user", "-u", prompt="SSH user", help="SSH username")
107
+ @click.option(
108
+ "--auth-type",
109
+ "-a",
110
+ type=click.Choice(["key", "password"]),
111
+ prompt="Authentication type",
112
+ help="Authentication method",
113
+ )
114
+ @click.option(
115
+ "--key-path", "-k", default="~/.ssh/id_rsa", help="Path to SSH private key"
116
+ )
117
+ @click.option("--password", help="SSH password (will prompt if auth-type is password)")
118
+ @click.option("--description", "-d", default="", help="Server description")
119
+ @click.option(
120
+ "--security-profile",
121
+ "-s",
122
+ type=click.Choice(["strict", "moderate", "full"]),
123
+ default="moderate",
124
+ help="Security profile",
125
+ )
126
+ def add_server(
127
+ name: str,
128
+ host: str,
129
+ port: int,
130
+ user: str,
131
+ auth_type: str,
132
+ key_path: str,
133
+ password: Optional[str],
134
+ description: str,
135
+ security_profile: str,
136
+ ) -> None:
137
+ """Add a new VPS server."""
138
+ config = load_machines_config()
139
+
140
+ # Check if server already exists
141
+ if config.has_machine(name):
142
+ click.echo(f"Error: Server '{name}' already exists.", err=True)
143
+ sys.exit(1)
144
+
145
+ # Handle authentication
146
+ if auth_type == "key":
147
+ key_path_expanded = str(Path(key_path).expanduser())
148
+ if not Path(key_path_expanded).exists():
149
+ click.echo(f"Warning: Key file not found: {key_path_expanded}")
150
+ if not click.confirm("Continue anyway?"):
151
+ sys.exit(1)
152
+ auth = AuthConfig(type="key", key_path=key_path)
153
+ else:
154
+ if not password:
155
+ password = click.prompt("SSH password", hide_input=True)
156
+ auth = AuthConfig(type="password", password=password)
157
+
158
+ # Security profiles
159
+ security_profiles = {
160
+ "strict": SecurityConfig(
161
+ allowed_commands=[
162
+ r"^git (pull|status|log|diff).*",
163
+ r"^ls .*",
164
+ r"^cat .*",
165
+ r"^tail .*",
166
+ r"^head .*",
167
+ r"^pwd$",
168
+ r"^whoami$",
169
+ r"^df -h$",
170
+ r"^free -m$",
171
+ r"^uptime$",
172
+ ],
173
+ forbidden_commands=[
174
+ r".*rm\s+-rf.*",
175
+ r".*sudo.*",
176
+ r".*su\s+-.*",
177
+ r".*dd\s+if=.*",
178
+ r".*mkfs\..*",
179
+ r".*chmod\s+777.*",
180
+ ],
181
+ timeout_seconds=30,
182
+ ),
183
+ "moderate": SecurityConfig(
184
+ allowed_commands=[
185
+ r"^git .*",
186
+ r"^npm .*",
187
+ r"^yarn .*",
188
+ r"^pip .*",
189
+ r"^pm2 .*",
190
+ r"^systemctl (status|restart).*",
191
+ r"^docker (ps|logs|stats).*",
192
+ r"^ls .*",
193
+ r"^cat .*",
194
+ r"^tail .*",
195
+ r"^head .*",
196
+ r"^pwd$",
197
+ r"^whoami$",
198
+ r"^df -h$",
199
+ r"^free -m$",
200
+ r"^uptime$",
201
+ r"^ps aux$",
202
+ r"^top -bn1$",
203
+ ],
204
+ forbidden_commands=[
205
+ r".*rm\s+-rf\s+/.*",
206
+ r".*sudo\s+rm.*",
207
+ r".*dd\s+if=.*",
208
+ ],
209
+ timeout_seconds=60,
210
+ ),
211
+ "full": SecurityConfig(
212
+ allowed_commands=[r".*"],
213
+ forbidden_commands=[r".*rm\s+-rf\s+/$"],
214
+ timeout_seconds=120,
215
+ ),
216
+ }
217
+
218
+ security = security_profiles[security_profile]
219
+
220
+ # Create machine config
221
+ machine = MachineConfig(
222
+ name=name,
223
+ host=host,
224
+ port=port,
225
+ user=user,
226
+ auth=auth,
227
+ security=security,
228
+ description=description or None,
229
+ )
230
+
231
+ config.machines.append(machine)
232
+ save_machines_config(config)
233
+
234
+ click.echo(f"\n✓ Server '{name}' added successfully!")
235
+ click.echo(f" Host: {host}:{port}")
236
+ click.echo(f" User: {user}")
237
+ click.echo(f" Auth: {auth_type}")
238
+ click.echo(f" Security: {security_profile}")
239
+
240
+ if click.confirm("\nTest connection now?"):
241
+ _test_connection(machine)
242
+
243
+
244
+ @server.command("remove")
245
+ @click.argument("name")
246
+ @click.option("--force", "-f", is_flag=True, help="Skip confirmation")
247
+ def remove_server(name: str, force: bool) -> None:
248
+ """Remove a VPS server."""
249
+ config = load_machines_config()
250
+
251
+ if not config.has_machine(name):
252
+ click.echo(f"Error: Server '{name}' not found.", err=True)
253
+ sys.exit(1)
254
+
255
+ if not force:
256
+ if not click.confirm(f"Remove server '{name}'?"):
257
+ click.echo("Cancelled.")
258
+ return
259
+
260
+ config.machines = [m for m in config.machines if m.name != name]
261
+ save_machines_config(config)
262
+
263
+ click.echo(f"✓ Server '{name}' removed.")
264
+
265
+
266
+ @server.command("test")
267
+ @click.argument("name")
268
+ def test_server(name: str) -> None:
269
+ """Test connection to a server."""
270
+ config = load_machines_config()
271
+
272
+ machine = config.get_machine(name)
273
+ if not machine:
274
+ click.echo(f"Error: Server '{name}' not found.", err=True)
275
+ sys.exit(1)
276
+
277
+ _test_connection(machine)
278
+
279
+
280
+ @server.command("edit")
281
+ @click.argument("name")
282
+ def edit_server(name: str) -> None:
283
+ """Edit server configuration (opens in editor)."""
284
+ config_path = get_config_path()
285
+
286
+ if not config_path.exists():
287
+ click.echo("No configuration file found.")
288
+ return
289
+
290
+ editor = os.environ.get("EDITOR", "nano")
291
+ os.system(f"{editor} {config_path}")
292
+
293
+
294
+ @server.command("import-ssh")
295
+ @click.option("--ssh-config", default="~/.ssh/config", help="Path to SSH config")
296
+ def import_from_ssh(ssh_config: str) -> None:
297
+ """Import servers from ~/.ssh/config."""
298
+ ssh_config_path = Path(ssh_config).expanduser()
299
+
300
+ if not ssh_config_path.exists():
301
+ click.echo(f"SSH config not found: {ssh_config_path}", err=True)
302
+ sys.exit(1)
303
+
304
+ # Parse SSH config
305
+ hosts = _parse_ssh_config(ssh_config_path)
306
+
307
+ if not hosts:
308
+ click.echo("No hosts found in SSH config.")
309
+ return
310
+
311
+ click.echo(f"Found {len(hosts)} host(s) in SSH config:\n")
312
+ for host in hosts:
313
+ click.echo(f" - {host['name']}: {host.get('hostname', 'N/A')}")
314
+
315
+ if not click.confirm("\nImport these hosts?"):
316
+ return
317
+
318
+ config = load_machines_config()
319
+ imported = 0
320
+
321
+ for host in hosts:
322
+ if config.has_machine(host["name"]):
323
+ click.echo(f" Skipping '{host['name']}' (already exists)")
324
+ continue
325
+
326
+ try:
327
+ machine = MachineConfig(
328
+ name=host["name"],
329
+ host=host.get("hostname", host["name"]),
330
+ port=int(host.get("port", 22)),
331
+ user=host.get("user", os.environ.get("USER", "root")),
332
+ auth=AuthConfig(
333
+ type="key", key_path=host.get("identityfile", "~/.ssh/id_rsa")
334
+ ),
335
+ security=SecurityConfig(
336
+ allowed_commands=[r".*"],
337
+ forbidden_commands=[r".*rm\s+-rf\s+/$"],
338
+ ),
339
+ description="Imported from SSH config",
340
+ )
341
+ config.machines.append(machine)
342
+ imported += 1
343
+ click.echo(f" ✓ Imported '{host['name']}'")
344
+ except Exception as e:
345
+ click.echo(f" ✗ Failed to import '{host['name']}': {e}")
346
+
347
+ if imported > 0:
348
+ save_machines_config(config)
349
+ click.echo(f"\n✓ Imported {imported} server(s)")
350
+
351
+
352
+ def _parse_ssh_config(path: Path) -> list[dict]:
353
+ """Parse SSH config file."""
354
+ hosts = []
355
+ current_host = None
356
+
357
+ with open(path, "r") as f:
358
+ for line in f:
359
+ line = line.strip()
360
+ if not line or line.startswith("#"):
361
+ continue
362
+
363
+ parts = line.split(None, 1)
364
+ if len(parts) != 2:
365
+ continue
366
+
367
+ key, value = parts[0].lower(), parts[1]
368
+
369
+ if key == "host":
370
+ if current_host and current_host["name"] != "*":
371
+ hosts.append(current_host)
372
+ current_host = {"name": value}
373
+ elif current_host:
374
+ if key == "hostname":
375
+ current_host["hostname"] = value
376
+ elif key == "port":
377
+ current_host["port"] = value
378
+ elif key == "user":
379
+ current_host["user"] = value
380
+ elif key == "identityfile":
381
+ current_host["identityfile"] = value
382
+
383
+ if current_host and current_host["name"] != "*":
384
+ hosts.append(current_host)
385
+
386
+ return hosts
387
+
388
+
389
+ def _test_connection(machine: MachineConfig) -> bool:
390
+ """Test SSH connection to a machine."""
391
+ click.echo(f"\nTesting connection to {machine.name}...")
392
+
393
+ try:
394
+ from sshmcp.ssh.client import SSHClient
395
+
396
+ client = SSHClient(machine)
397
+ client.connect()
398
+
399
+ result = client.execute("echo 'Connection successful!' && hostname && uptime")
400
+ client.disconnect()
401
+
402
+ if result.exit_code == 0:
403
+ click.echo("✓ Connection successful!")
404
+ click.echo(f"\n{result.stdout}")
405
+ return True
406
+ else:
407
+ click.echo(f"✗ Command failed: {result.stderr}", err=True)
408
+ return False
409
+
410
+ except Exception as e:
411
+ click.echo(f"✗ Connection failed: {e}", err=True)
412
+ return False
413
+
414
+
415
+ @cli.command("run")
416
+ @click.option(
417
+ "--transport",
418
+ "-t",
419
+ type=click.Choice(["stdio", "http"]),
420
+ default="stdio",
421
+ help="Transport type",
422
+ )
423
+ @click.option("--port", "-p", default=8000, help="HTTP port")
424
+ def run_server(transport: str, port: int) -> None:
425
+ """Start the MCP server."""
426
+ from sshmcp.server import main as server_main
427
+
428
+ # Set config path
429
+ os.environ["SSHMCP_CONFIG_PATH"] = str(get_config_path())
430
+
431
+ if transport == "http":
432
+ sys.argv = ["sshmcp", "--transport", "streamable-http", "--port", str(port)]
433
+ else:
434
+ sys.argv = ["sshmcp"]
435
+
436
+ server_main()
437
+
438
+
439
+ @cli.command("init")
440
+ def init_config() -> None:
441
+ """Initialize configuration with interactive wizard."""
442
+ config_path = get_config_path()
443
+
444
+ click.echo("SSH MCP Server Setup Wizard")
445
+ click.echo("=" * 40)
446
+
447
+ if config_path.exists():
448
+ if not click.confirm(f"\nConfig already exists at {config_path}. Overwrite?"):
449
+ click.echo("Cancelled.")
450
+ return
451
+
452
+ click.echo("\nLet's add your first VPS server.\n")
453
+
454
+ # Invoke add command
455
+ ctx = click.Context(add_server)
456
+ ctx.invoke(add_server)
457
+
458
+ click.echo("\n" + "=" * 40)
459
+ click.echo("Setup complete!")
460
+ click.echo(f"\nConfig file: {config_path}")
461
+ click.echo("\nNext steps:")
462
+ click.echo(" 1. Add more servers: sshmcp server add")
463
+ click.echo(" 2. Start MCP server: sshmcp run")
464
+ click.echo(" 3. Configure your AI agent (see docs/integration.md)")
465
+
466
+
467
+ def main() -> None:
468
+ """Main entry point."""
469
+ cli()
470
+
471
+
472
+ if __name__ == "__main__":
473
+ main()
sshmcp/config.py ADDED
@@ -0,0 +1,155 @@
1
+ """Configuration loading and validation."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+
7
+ import structlog
8
+
9
+ from sshmcp.models.machine import MachineConfig, MachinesConfig
10
+
11
+ logger = structlog.get_logger()
12
+
13
+ DEFAULT_CONFIG_PATH = "config/machines.json"
14
+ CONFIG_ENV_VAR = "SSHMCP_CONFIG_PATH"
15
+
16
+ _config_cache: MachinesConfig | None = None
17
+
18
+
19
+ class ConfigurationError(Exception):
20
+ """Error loading or validating configuration."""
21
+
22
+ pass
23
+
24
+
25
+ def get_config_path() -> Path:
26
+ """Get configuration file path from environment or default."""
27
+ env_path = os.environ.get(CONFIG_ENV_VAR)
28
+ if env_path:
29
+ return Path(env_path).expanduser().resolve()
30
+
31
+ # Try relative path from current directory
32
+ cwd_config = Path.cwd() / DEFAULT_CONFIG_PATH
33
+ if cwd_config.exists():
34
+ return cwd_config
35
+
36
+ # Try relative to package
37
+ package_dir = Path(__file__).parent.parent
38
+ package_config = package_dir / DEFAULT_CONFIG_PATH
39
+ if package_config.exists():
40
+ return package_config
41
+
42
+ # Return default path (will raise error if not exists)
43
+ return cwd_config
44
+
45
+
46
+ def load_config(config_path: str | Path | None = None) -> MachinesConfig:
47
+ """
48
+ Load and validate configuration from JSON file.
49
+
50
+ Args:
51
+ config_path: Optional path to configuration file.
52
+ If not provided, uses SSHMCP_CONFIG_PATH env var or default.
53
+
54
+ Returns:
55
+ MachinesConfig object with validated configuration.
56
+
57
+ Raises:
58
+ ConfigurationError: If file not found, invalid JSON, or validation fails.
59
+ """
60
+ global _config_cache
61
+
62
+ if config_path is None:
63
+ path = get_config_path()
64
+ else:
65
+ path = Path(config_path).expanduser().resolve()
66
+
67
+ logger.info("loading_config", path=str(path))
68
+
69
+ if not path.exists():
70
+ raise ConfigurationError(f"Configuration file not found: {path}")
71
+
72
+ try:
73
+ with open(path, "r", encoding="utf-8") as f:
74
+ data = json.load(f)
75
+ except json.JSONDecodeError as e:
76
+ raise ConfigurationError(f"Invalid JSON in configuration file: {e}")
77
+ except PermissionError:
78
+ raise ConfigurationError(f"Permission denied reading configuration: {path}")
79
+ except Exception as e:
80
+ raise ConfigurationError(f"Error reading configuration file: {e}")
81
+
82
+ try:
83
+ config = MachinesConfig.model_validate(data)
84
+ except Exception as e:
85
+ raise ConfigurationError(f"Configuration validation failed: {e}")
86
+
87
+ logger.info(
88
+ "config_loaded",
89
+ machines_count=len(config.machines),
90
+ machine_names=config.get_machine_names(),
91
+ )
92
+
93
+ _config_cache = config
94
+ return config
95
+
96
+
97
+ def get_config() -> MachinesConfig:
98
+ """
99
+ Get cached configuration or load it.
100
+
101
+ Returns:
102
+ MachinesConfig object.
103
+
104
+ Raises:
105
+ ConfigurationError: If configuration cannot be loaded.
106
+ """
107
+ global _config_cache
108
+ if _config_cache is None:
109
+ return load_config()
110
+ return _config_cache
111
+
112
+
113
+ def reload_config() -> MachinesConfig:
114
+ """
115
+ Force reload configuration from file.
116
+
117
+ Returns:
118
+ MachinesConfig object with fresh configuration.
119
+ """
120
+ global _config_cache
121
+ _config_cache = None
122
+ return load_config()
123
+
124
+
125
+ def get_machine(name: str) -> MachineConfig:
126
+ """
127
+ Get machine configuration by name.
128
+
129
+ Args:
130
+ name: Machine name.
131
+
132
+ Returns:
133
+ MachineConfig for the specified machine.
134
+
135
+ Raises:
136
+ ConfigurationError: If machine not found.
137
+ """
138
+ config = get_config()
139
+ machine = config.get_machine(name)
140
+ if machine is None:
141
+ available = config.get_machine_names()
142
+ raise ConfigurationError(
143
+ f"Machine '{name}' not found. Available machines: {available}"
144
+ )
145
+ return machine
146
+
147
+
148
+ def list_machines() -> list[str]:
149
+ """
150
+ Get list of configured machine names.
151
+
152
+ Returns:
153
+ List of machine names.
154
+ """
155
+ return get_config().get_machine_names()
@@ -0,0 +1,5 @@
1
+ """Core infrastructure components."""
2
+
3
+ from sshmcp.core.container import Container, get_container, init_container
4
+
5
+ __all__ = ["Container", "get_container", "init_container"]