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/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()
|