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.
- mcp_ssh_vps-0.4.1.dist-info/METADATA +482 -0
- mcp_ssh_vps-0.4.1.dist-info/RECORD +47 -0
- mcp_ssh_vps-0.4.1.dist-info/WHEEL +5 -0
- mcp_ssh_vps-0.4.1.dist-info/entry_points.txt +4 -0
- mcp_ssh_vps-0.4.1.dist-info/licenses/LICENSE +21 -0
- mcp_ssh_vps-0.4.1.dist-info/top_level.txt +1 -0
- sshmcp/__init__.py +3 -0
- sshmcp/cli.py +473 -0
- sshmcp/config.py +155 -0
- sshmcp/core/__init__.py +5 -0
- sshmcp/core/container.py +291 -0
- sshmcp/models/__init__.py +15 -0
- sshmcp/models/command.py +69 -0
- sshmcp/models/file.py +102 -0
- sshmcp/models/machine.py +139 -0
- sshmcp/monitoring/__init__.py +0 -0
- sshmcp/monitoring/alerts.py +464 -0
- sshmcp/prompts/__init__.py +7 -0
- sshmcp/prompts/backup.py +151 -0
- sshmcp/prompts/deploy.py +115 -0
- sshmcp/prompts/monitor.py +146 -0
- sshmcp/resources/__init__.py +7 -0
- sshmcp/resources/logs.py +99 -0
- sshmcp/resources/metrics.py +204 -0
- sshmcp/resources/status.py +160 -0
- sshmcp/security/__init__.py +7 -0
- sshmcp/security/audit.py +314 -0
- sshmcp/security/rate_limiter.py +221 -0
- sshmcp/security/totp.py +392 -0
- sshmcp/security/validator.py +234 -0
- sshmcp/security/whitelist.py +169 -0
- sshmcp/server.py +632 -0
- sshmcp/ssh/__init__.py +6 -0
- sshmcp/ssh/async_client.py +247 -0
- sshmcp/ssh/client.py +464 -0
- sshmcp/ssh/executor.py +79 -0
- sshmcp/ssh/forwarding.py +368 -0
- sshmcp/ssh/pool.py +343 -0
- sshmcp/ssh/shell.py +518 -0
- sshmcp/ssh/transfer.py +461 -0
- sshmcp/tools/__init__.py +13 -0
- sshmcp/tools/commands.py +226 -0
- sshmcp/tools/files.py +220 -0
- sshmcp/tools/helpers.py +321 -0
- sshmcp/tools/history.py +372 -0
- sshmcp/tools/processes.py +214 -0
- sshmcp/tools/servers.py +484 -0
sshmcp/tools/servers.py
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""MCP Tools for server management."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import structlog
|
|
9
|
+
|
|
10
|
+
from sshmcp.config import reload_config as reload_global_config
|
|
11
|
+
from sshmcp.models.machine import (
|
|
12
|
+
AuthConfig,
|
|
13
|
+
MachineConfig,
|
|
14
|
+
MachinesConfig,
|
|
15
|
+
SecurityConfig,
|
|
16
|
+
)
|
|
17
|
+
from sshmcp.security.whitelist import init_whitelist
|
|
18
|
+
from sshmcp.ssh.pool import get_pool
|
|
19
|
+
|
|
20
|
+
logger = structlog.get_logger()
|
|
21
|
+
|
|
22
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".sshmcp" / "machines.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_config_path() -> Path:
|
|
26
|
+
"""Get configuration file path."""
|
|
27
|
+
env_path = os.environ.get("SSHMCP_CONFIG_PATH")
|
|
28
|
+
if env_path:
|
|
29
|
+
return Path(env_path).expanduser()
|
|
30
|
+
return DEFAULT_CONFIG_PATH
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_config() -> MachinesConfig:
|
|
34
|
+
"""Load machines configuration."""
|
|
35
|
+
config_path = _get_config_path()
|
|
36
|
+
if not config_path.exists():
|
|
37
|
+
return MachinesConfig(machines=[])
|
|
38
|
+
|
|
39
|
+
with open(config_path, "r") as f:
|
|
40
|
+
data = json.load(f)
|
|
41
|
+
return MachinesConfig.model_validate(data)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _save_config(config: MachinesConfig) -> None:
|
|
45
|
+
"""Save machines configuration."""
|
|
46
|
+
config_path = _get_config_path()
|
|
47
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
with open(config_path, "w") as f:
|
|
50
|
+
json.dump(config.model_dump(), f, indent=2)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def list_servers(tag: str | None = None) -> dict[str, Any]:
|
|
54
|
+
"""
|
|
55
|
+
List all configured VPS servers.
|
|
56
|
+
|
|
57
|
+
Returns a list of all servers with their basic information.
|
|
58
|
+
Use this to see what servers are available for commands.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
tag: Optional tag to filter servers. Only servers with this tag will be returned.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Dictionary with:
|
|
65
|
+
- servers: List of server info (name, host, user, description, tags)
|
|
66
|
+
- count: Total number of servers (after filtering)
|
|
67
|
+
- all_tags: List of all available tags
|
|
68
|
+
- config_path: Path to configuration file
|
|
69
|
+
|
|
70
|
+
Example:
|
|
71
|
+
>>> list_servers()
|
|
72
|
+
{"servers": [{"name": "prod", "host": "1.2.3.4", ...}], "count": 1}
|
|
73
|
+
|
|
74
|
+
>>> list_servers(tag="production")
|
|
75
|
+
{"servers": [...], "count": 2, "filter": "production"}
|
|
76
|
+
"""
|
|
77
|
+
config = _load_config()
|
|
78
|
+
|
|
79
|
+
# Collect all tags
|
|
80
|
+
all_tags = set()
|
|
81
|
+
for machine in config.machines:
|
|
82
|
+
if machine.tags:
|
|
83
|
+
all_tags.update(machine.tags)
|
|
84
|
+
|
|
85
|
+
servers = []
|
|
86
|
+
for machine in config.machines:
|
|
87
|
+
# Filter by tag if specified
|
|
88
|
+
if tag and tag not in (machine.tags or []):
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
servers.append(
|
|
92
|
+
{
|
|
93
|
+
"name": machine.name,
|
|
94
|
+
"host": machine.host,
|
|
95
|
+
"port": machine.port,
|
|
96
|
+
"user": machine.user,
|
|
97
|
+
"description": machine.description,
|
|
98
|
+
"auth_type": machine.auth.type,
|
|
99
|
+
"tags": machine.tags or [],
|
|
100
|
+
"security_level": "full"
|
|
101
|
+
if ".*" in machine.security.allowed_commands
|
|
102
|
+
else "restricted",
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
result = {
|
|
107
|
+
"servers": servers,
|
|
108
|
+
"count": len(servers),
|
|
109
|
+
"all_tags": sorted(all_tags),
|
|
110
|
+
"config_path": str(_get_config_path()),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if tag:
|
|
114
|
+
result["filter"] = f"tag:{tag}"
|
|
115
|
+
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def add_server(
|
|
120
|
+
name: str,
|
|
121
|
+
host: str,
|
|
122
|
+
user: str,
|
|
123
|
+
port: int = 22,
|
|
124
|
+
auth_type: str = "key",
|
|
125
|
+
key_path: str = "~/.ssh/id_rsa",
|
|
126
|
+
password: str | None = None,
|
|
127
|
+
description: str | None = None,
|
|
128
|
+
security_level: str = "full",
|
|
129
|
+
tags: list[str] | None = None,
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
"""
|
|
132
|
+
Add a new VPS server to the configuration.
|
|
133
|
+
|
|
134
|
+
Adds a new server that can be used with execute_command and other tools.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
name: Unique name for the server (e.g., "production", "staging").
|
|
138
|
+
host: Server hostname or IP address.
|
|
139
|
+
user: SSH username.
|
|
140
|
+
port: SSH port (default: 22).
|
|
141
|
+
auth_type: Authentication type - "key" or "password".
|
|
142
|
+
key_path: Path to SSH private key (for key auth).
|
|
143
|
+
password: SSH password (for password auth).
|
|
144
|
+
description: Optional description of the server.
|
|
145
|
+
security_level: Security profile - "strict", "moderate", or "full" (default: full).
|
|
146
|
+
tags: List of tags for grouping servers (e.g., ["production", "web"]).
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Dictionary with success status and server details.
|
|
150
|
+
|
|
151
|
+
Example:
|
|
152
|
+
>>> add_server("prod", "192.168.1.100", "deploy", tags=["production"])
|
|
153
|
+
{"success": true, "name": "prod", "message": "Server added"}
|
|
154
|
+
"""
|
|
155
|
+
config = _load_config()
|
|
156
|
+
|
|
157
|
+
# Check if already exists
|
|
158
|
+
if config.has_machine(name):
|
|
159
|
+
return {
|
|
160
|
+
"success": False,
|
|
161
|
+
"error": f"Server '{name}' already exists",
|
|
162
|
+
"hint": "Use a different name or remove the existing server first",
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
# Validate auth
|
|
166
|
+
if auth_type == "key":
|
|
167
|
+
auth = AuthConfig(type="key", key_path=key_path)
|
|
168
|
+
elif auth_type == "password":
|
|
169
|
+
if not password:
|
|
170
|
+
return {
|
|
171
|
+
"success": False,
|
|
172
|
+
"error": "Password required for password authentication",
|
|
173
|
+
}
|
|
174
|
+
auth = AuthConfig(type="password", password=password)
|
|
175
|
+
else:
|
|
176
|
+
return {
|
|
177
|
+
"success": False,
|
|
178
|
+
"error": f"Invalid auth_type: {auth_type}. Use 'key' or 'password'",
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# Security profiles
|
|
182
|
+
security_profiles = {
|
|
183
|
+
"strict": SecurityConfig(
|
|
184
|
+
allowed_commands=[
|
|
185
|
+
r"^git (pull|status|log|diff).*",
|
|
186
|
+
r"^ls .*",
|
|
187
|
+
r"^cat .*",
|
|
188
|
+
r"^tail .*",
|
|
189
|
+
r"^head .*",
|
|
190
|
+
r"^pwd$",
|
|
191
|
+
r"^whoami$",
|
|
192
|
+
r"^df -h$",
|
|
193
|
+
r"^free -m$",
|
|
194
|
+
r"^uptime$",
|
|
195
|
+
],
|
|
196
|
+
forbidden_commands=[
|
|
197
|
+
r".*rm\s+-rf.*",
|
|
198
|
+
r".*sudo.*",
|
|
199
|
+
r".*su\s+-.*",
|
|
200
|
+
],
|
|
201
|
+
timeout_seconds=30,
|
|
202
|
+
),
|
|
203
|
+
"moderate": SecurityConfig(
|
|
204
|
+
allowed_commands=[
|
|
205
|
+
r"^git .*",
|
|
206
|
+
r"^npm .*",
|
|
207
|
+
r"^yarn .*",
|
|
208
|
+
r"^pip .*",
|
|
209
|
+
r"^pm2 .*",
|
|
210
|
+
r"^systemctl .*",
|
|
211
|
+
r"^docker .*",
|
|
212
|
+
r"^ls .*",
|
|
213
|
+
r"^cat .*",
|
|
214
|
+
r"^tail .*",
|
|
215
|
+
r"^head .*",
|
|
216
|
+
r"^pwd$",
|
|
217
|
+
r"^whoami$",
|
|
218
|
+
r"^df -h$",
|
|
219
|
+
r"^free -m$",
|
|
220
|
+
r"^uptime$",
|
|
221
|
+
r"^ps aux$",
|
|
222
|
+
r"^top -bn1$",
|
|
223
|
+
],
|
|
224
|
+
forbidden_commands=[
|
|
225
|
+
r".*rm\s+-rf\s+/.*",
|
|
226
|
+
r".*sudo\s+rm.*",
|
|
227
|
+
],
|
|
228
|
+
timeout_seconds=60,
|
|
229
|
+
),
|
|
230
|
+
"full": SecurityConfig(
|
|
231
|
+
allowed_commands=[r".*"],
|
|
232
|
+
forbidden_commands=[r".*rm\s+-rf\s+/$"],
|
|
233
|
+
timeout_seconds=120,
|
|
234
|
+
),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if security_level not in security_profiles:
|
|
238
|
+
return {
|
|
239
|
+
"success": False,
|
|
240
|
+
"error": f"Invalid security_level: {security_level}",
|
|
241
|
+
"valid_options": list(security_profiles.keys()),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
security = security_profiles[security_level]
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
machine = MachineConfig(
|
|
248
|
+
name=name,
|
|
249
|
+
host=host,
|
|
250
|
+
port=port,
|
|
251
|
+
user=user,
|
|
252
|
+
auth=auth,
|
|
253
|
+
security=security,
|
|
254
|
+
description=description,
|
|
255
|
+
tags=tags or [],
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
config.machines.append(machine)
|
|
259
|
+
_save_config(config)
|
|
260
|
+
|
|
261
|
+
# Reload global config cache so get_machine() finds the new server
|
|
262
|
+
reload_global_config()
|
|
263
|
+
|
|
264
|
+
# Register in connection pool immediately
|
|
265
|
+
pool = get_pool()
|
|
266
|
+
pool.register_machine(machine)
|
|
267
|
+
init_whitelist(MachinesConfig(machines=[machine]))
|
|
268
|
+
|
|
269
|
+
logger.info("server_added", name=name, host=host)
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
"success": True,
|
|
273
|
+
"name": name,
|
|
274
|
+
"host": host,
|
|
275
|
+
"user": user,
|
|
276
|
+
"port": port,
|
|
277
|
+
"auth_type": auth_type,
|
|
278
|
+
"security_level": security_level,
|
|
279
|
+
"tags": tags or [],
|
|
280
|
+
"message": f"Server '{name}' added successfully",
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
except Exception as e:
|
|
284
|
+
return {
|
|
285
|
+
"success": False,
|
|
286
|
+
"error": str(e),
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def remove_server(name: str) -> dict[str, Any]:
|
|
291
|
+
"""
|
|
292
|
+
Remove a VPS server from the configuration.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
name: Name of the server to remove.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
Dictionary with success status.
|
|
299
|
+
|
|
300
|
+
Example:
|
|
301
|
+
>>> remove_server("old-server")
|
|
302
|
+
{"success": true, "message": "Server removed"}
|
|
303
|
+
"""
|
|
304
|
+
config = _load_config()
|
|
305
|
+
|
|
306
|
+
if not config.has_machine(name):
|
|
307
|
+
return {
|
|
308
|
+
"success": False,
|
|
309
|
+
"error": f"Server '{name}' not found",
|
|
310
|
+
"available_servers": config.get_machine_names(),
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
config.machines = [m for m in config.machines if m.name != name]
|
|
314
|
+
_save_config(config)
|
|
315
|
+
reload_global_config()
|
|
316
|
+
|
|
317
|
+
logger.info("server_removed", name=name)
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
"success": True,
|
|
321
|
+
"name": name,
|
|
322
|
+
"message": f"Server '{name}' removed successfully",
|
|
323
|
+
"remaining_servers": config.get_machine_names(),
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def test_server_connection(name: str) -> dict[str, Any]:
|
|
328
|
+
"""
|
|
329
|
+
Test SSH connection to a server.
|
|
330
|
+
|
|
331
|
+
Attempts to connect and run a simple command to verify the server is accessible.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
name: Name of the server to test.
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
Dictionary with connection status and details.
|
|
338
|
+
|
|
339
|
+
Example:
|
|
340
|
+
>>> test_server_connection("prod")
|
|
341
|
+
{"success": true, "hostname": "server1", "message": "Connection OK"}
|
|
342
|
+
"""
|
|
343
|
+
config = _load_config()
|
|
344
|
+
|
|
345
|
+
machine = config.get_machine(name)
|
|
346
|
+
if not machine:
|
|
347
|
+
return {
|
|
348
|
+
"success": False,
|
|
349
|
+
"error": f"Server '{name}' not found",
|
|
350
|
+
"available_servers": config.get_machine_names(),
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
from sshmcp.ssh.client import SSHClient
|
|
355
|
+
|
|
356
|
+
client = SSHClient(machine)
|
|
357
|
+
client.connect()
|
|
358
|
+
|
|
359
|
+
# Run test commands
|
|
360
|
+
result = client.execute("hostname && uptime")
|
|
361
|
+
client.disconnect()
|
|
362
|
+
|
|
363
|
+
if result.exit_code == 0:
|
|
364
|
+
lines = result.stdout.strip().split("\n")
|
|
365
|
+
hostname = lines[0] if lines else "unknown"
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
"success": True,
|
|
369
|
+
"name": name,
|
|
370
|
+
"hostname": hostname,
|
|
371
|
+
"host": machine.host,
|
|
372
|
+
"output": result.stdout,
|
|
373
|
+
"message": "Connection successful",
|
|
374
|
+
}
|
|
375
|
+
else:
|
|
376
|
+
return {
|
|
377
|
+
"success": False,
|
|
378
|
+
"name": name,
|
|
379
|
+
"error": "Command failed",
|
|
380
|
+
"stderr": result.stderr,
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
except Exception as e:
|
|
384
|
+
return {
|
|
385
|
+
"success": False,
|
|
386
|
+
"name": name,
|
|
387
|
+
"error": str(e),
|
|
388
|
+
"message": "Connection failed",
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def update_server(
|
|
393
|
+
name: str,
|
|
394
|
+
host: str | None = None,
|
|
395
|
+
user: str | None = None,
|
|
396
|
+
port: int | None = None,
|
|
397
|
+
description: str | None = None,
|
|
398
|
+
security_level: str | None = None,
|
|
399
|
+
) -> dict[str, Any]:
|
|
400
|
+
"""
|
|
401
|
+
Update an existing server's configuration.
|
|
402
|
+
|
|
403
|
+
Only specified fields will be updated.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
name: Name of the server to update.
|
|
407
|
+
host: New hostname or IP (optional).
|
|
408
|
+
user: New SSH username (optional).
|
|
409
|
+
port: New SSH port (optional).
|
|
410
|
+
description: New description (optional).
|
|
411
|
+
security_level: New security level (optional).
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
Dictionary with update status.
|
|
415
|
+
|
|
416
|
+
Example:
|
|
417
|
+
>>> update_server("prod", host="10.0.0.1")
|
|
418
|
+
{"success": true, "message": "Server updated"}
|
|
419
|
+
"""
|
|
420
|
+
config = _load_config()
|
|
421
|
+
|
|
422
|
+
machine = config.get_machine(name)
|
|
423
|
+
if not machine:
|
|
424
|
+
return {
|
|
425
|
+
"success": False,
|
|
426
|
+
"error": f"Server '{name}' not found",
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
# Find and update
|
|
430
|
+
for i, m in enumerate(config.machines):
|
|
431
|
+
if m.name == name:
|
|
432
|
+
if host:
|
|
433
|
+
config.machines[i].host = host
|
|
434
|
+
if user:
|
|
435
|
+
config.machines[i].user = user
|
|
436
|
+
if port:
|
|
437
|
+
config.machines[i].port = port
|
|
438
|
+
if description is not None:
|
|
439
|
+
config.machines[i].description = description
|
|
440
|
+
|
|
441
|
+
if security_level:
|
|
442
|
+
security_profiles = {
|
|
443
|
+
"strict": SecurityConfig(
|
|
444
|
+
allowed_commands=[r"^git.*", r"^ls.*", r"^cat.*"],
|
|
445
|
+
forbidden_commands=[r".*rm\s+-rf.*", r".*sudo.*"],
|
|
446
|
+
timeout_seconds=30,
|
|
447
|
+
),
|
|
448
|
+
"moderate": SecurityConfig(
|
|
449
|
+
allowed_commands=[
|
|
450
|
+
r"^git.*",
|
|
451
|
+
r"^npm.*",
|
|
452
|
+
r"^pm2.*",
|
|
453
|
+
r"^docker.*",
|
|
454
|
+
],
|
|
455
|
+
forbidden_commands=[r".*rm\s+-rf\s+/.*"],
|
|
456
|
+
timeout_seconds=60,
|
|
457
|
+
),
|
|
458
|
+
"full": SecurityConfig(
|
|
459
|
+
allowed_commands=[r".*"],
|
|
460
|
+
forbidden_commands=[r".*rm\s+-rf\s+/$"],
|
|
461
|
+
timeout_seconds=120,
|
|
462
|
+
),
|
|
463
|
+
}
|
|
464
|
+
if security_level in security_profiles:
|
|
465
|
+
config.machines[i].security = security_profiles[security_level]
|
|
466
|
+
|
|
467
|
+
break
|
|
468
|
+
|
|
469
|
+
_save_config(config)
|
|
470
|
+
reload_global_config()
|
|
471
|
+
|
|
472
|
+
# Re-register in pool with updated config
|
|
473
|
+
pool = get_pool()
|
|
474
|
+
for m in config.machines:
|
|
475
|
+
if m.name == name:
|
|
476
|
+
pool.register_machine(m)
|
|
477
|
+
init_whitelist(MachinesConfig(machines=[m]))
|
|
478
|
+
break
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
"success": True,
|
|
482
|
+
"name": name,
|
|
483
|
+
"message": f"Server '{name}' updated",
|
|
484
|
+
}
|