mcp-win-stdio-ssh 0.3.4__tar.gz → 0.4.0__tar.gz

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.
@@ -1,63 +1,63 @@
1
- __pycache__/
2
- *.py[cod]
3
- *$py.class
4
- *.so
5
- .Python
6
- build/
7
- develop-eggs/
8
- dist/
9
- downloads/
10
- eggs/
11
- .eggs/
12
- lib/
13
- lib64/
14
- parts/
15
- sdist/
16
- var/
17
- wheels/
18
- share/python-wheels/
19
- *.egg-info/
20
- .installed.cfg
21
- *.egg
22
- MANIFEST
23
-
24
- *.manifest
25
- *.spec
26
- api.txt
27
- pypi-api.txt
28
-
29
-
30
- pip-log.txt
31
- pip-delete-this-directory.txt
32
- pypi api.txt
33
- htmlcov/
34
- .tox/
35
- .nox/
36
- .coverage
37
- .coverage.*
38
- .cache
39
- nosetests.xml
40
- coverage.xml
41
- *.cover
42
- *.py,cover
43
- .hypothesis/
44
- .pytest_cache/
45
- cover/
46
-
47
- *.mo
48
- *.pot
49
-
50
- .env
51
- .venv
52
- env/
53
- venv/
54
- ENV/
55
- env.bak/
56
- venv.bak/
57
-
58
- .idea/
59
- .vscode/
60
- *.swp
61
- *.swo
62
-
63
- *.log
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.so
5
+ .Python
6
+ build/
7
+ develop-eggs/
8
+ dist/
9
+ downloads/
10
+ eggs/
11
+ .eggs/
12
+ lib/
13
+ lib64/
14
+ parts/
15
+ sdist/
16
+ var/
17
+ wheels/
18
+ share/python-wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+ MANIFEST
23
+
24
+ *.manifest
25
+ *.spec
26
+ api.txt
27
+ pypi-api.txt
28
+
29
+
30
+ pip-log.txt
31
+ pip-delete-this-directory.txt
32
+ pypi api.txt
33
+ htmlcov/
34
+ .tox/
35
+ .nox/
36
+ .coverage
37
+ .coverage.*
38
+ .cache
39
+ nosetests.xml
40
+ coverage.xml
41
+ *.cover
42
+ *.py,cover
43
+ .hypothesis/
44
+ .pytest_cache/
45
+ cover/
46
+
47
+ *.mo
48
+ *.pot
49
+
50
+ .env
51
+ .venv
52
+ env/
53
+ venv/
54
+ ENV/
55
+ env.bak/
56
+ venv.bak/
57
+
58
+ .idea/
59
+ .vscode/
60
+ *.swp
61
+ *.swo
62
+
63
+ *.log
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcp-win-stdio-ssh
3
- Version: 0.3.4
3
+ Version: 0.4.0
4
4
  Summary: Advanced Multi-SSH Connection & Remote Management Model Context Protocol (MCP) server for Windows & Claude: 25+ tools for multi-host pooling, ~/.ssh/config auto-discovery, PTY interactive shells, SFTP file management, systemd/docker services, background jobs, and local port forwarding tunnels.
5
5
  Author: Mohan Kumar Indala
6
6
  License-Expression: MIT
@@ -19,7 +19,7 @@ Classifier: Programming Language :: Python :: 3.14
19
19
  Classifier: Topic :: System :: Systems Administration
20
20
  Requires-Python: >=3.10
21
21
  Requires-Dist: cryptography>=3.3.0
22
- Requires-Dist: mcp-win-stdio>=0.3.4
22
+ Requires-Dist: mcp-win-stdio>=0.4.0
23
23
  Requires-Dist: mcp>=1.2.0
24
24
  Requires-Dist: paramiko>=3.0.0
25
25
  Description-Content-Type: text/markdown
@@ -1,70 +1,70 @@
1
- # mcp-win-stdio-ssh
2
-
3
- > Advanced Multi-SSH Connection & Remote Management Model Context Protocol (MCP) Server for Windows & Claude Desktop / Claude Code CLI: **34 Tools** for stateful SSH connection pooling, background execution, interactive PTYs, SFTP, and port tunnels. Zero mandatory environment variables (persists dynamically to `~/.mcp-win-stdio/ssh_hosts.json`).
4
-
5
- Part of the **[mcp-win-stdio](https://github.com/indala/mcp-win-stdio)** suite.
6
-
7
- ---
8
-
9
- ## 🌟 Key Features
10
-
11
- - **Multi-Host Connection Pooling & Persistence**: Connect to unlimited remote servers. Automatically parses `~/.ssh/config` (aliases, identity files, JumpHosts) and persists active host preferences.
12
- - **Sticky Active Host**: Run commands against an active default host or specify `host="server_name"` per tool call.
13
- - **Remote Execution & Elevated Commands**: Non-interactive command execution (`ssh_exec`), `sudo` password automation (`ssh_exec_sudo`), multi-line script execution (`ssh_exec_script`), and detached background jobs (`ssh_exec_background`).
14
- - **Interactive PTY Shell Sessions**: Start stateful pseudo-terminals (`ssh_pty_start`, `ssh_pty_send`, `ssh_pty_read`) for interactive REPLs, prompts, and long-running interactive tools.
15
- - **Remote Diagnostics & Services**: System health stats (`ssh_system_overview`), process monitoring (`ssh_list_processes`), systemd / docker / pm2 services (`ssh_list_services`, `ssh_service_action`), and live log tailing (`ssh_tail_logs`).
16
- - **High-Performance SFTP**: Explore remote file systems (`sftp_list_dir`), read/write files (`sftp_read_file`, `sftp_write_file`), inspect metadata (`sftp_stat`), and sync local/remote folders (`sftp_upload`, `sftp_download`).
17
- - **Port Forwarding & Tunnels**: Create background local-to-remote SSH port forwarding tunnels (`ssh_tunnel_open`, `ssh_tunnel_list`, `ssh_tunnel_close`) to securely access remote databases or web services.
18
-
19
- ---
20
-
21
- ## 📦 Installation
22
-
23
- ```bash
24
- # Standalone package installation:
25
- pip install mcp-win-stdio-ssh
26
-
27
- # Or with full mcp-win-stdio suite:
28
- pip install "mcp-win-stdio[all]"
29
- ```
30
-
31
- ---
32
-
33
- ## 🚀 Quick Start & Claude Configuration
34
-
35
- ### Claude Desktop (`%APPDATA%\Claude\claude_desktop_config.json`)
36
-
37
- ```json
38
- {
39
- "mcpServers": {
40
- "ssh": {
41
- "command": "mws-ssh",
42
- "args": ["run"]
43
- }
44
- }
45
- }
46
- ```
47
-
48
- ### Claude Code CLI
49
-
50
- ```bash
51
- claude mcp add ssh mws-ssh run
52
- ```
53
-
54
- ---
55
-
56
- ## 🛠️ CLI Utilities
57
-
58
- ```bash
59
- # Open interactive diagnostic CLI & host manager
60
- mws-ssh
61
-
62
- # List configured SSH hosts and test latency
63
- mws-ssh test
64
-
65
- # Launch MCP stdio server
66
- mws-ssh run
67
-
68
- # View comprehensive prompt recipes & tool documentation
69
- mws-ssh guide
70
- ```
1
+ # mcp-win-stdio-ssh
2
+
3
+ > Advanced Multi-SSH Connection & Remote Management Model Context Protocol (MCP) Server for Windows & Claude Desktop / Claude Code CLI: **34 Tools** for stateful SSH connection pooling, background execution, interactive PTYs, SFTP, and port tunnels. Zero mandatory environment variables (persists dynamically to `~/.mcp-win-stdio/ssh_hosts.json`).
4
+
5
+ Part of the **[mcp-win-stdio](https://github.com/indala/mcp-win-stdio)** suite.
6
+
7
+ ---
8
+
9
+ ## 🌟 Key Features
10
+
11
+ - **Multi-Host Connection Pooling & Persistence**: Connect to unlimited remote servers. Automatically parses `~/.ssh/config` (aliases, identity files, JumpHosts) and persists active host preferences.
12
+ - **Sticky Active Host**: Run commands against an active default host or specify `host="server_name"` per tool call.
13
+ - **Remote Execution & Elevated Commands**: Non-interactive command execution (`ssh_exec`), `sudo` password automation (`ssh_exec_sudo`), multi-line script execution (`ssh_exec_script`), and detached background jobs (`ssh_exec_background`).
14
+ - **Interactive PTY Shell Sessions**: Start stateful pseudo-terminals (`ssh_pty_start`, `ssh_pty_send`, `ssh_pty_read`) for interactive REPLs, prompts, and long-running interactive tools.
15
+ - **Remote Diagnostics & Services**: System health stats (`ssh_system_overview`), process monitoring (`ssh_list_processes`), systemd / docker / pm2 services (`ssh_list_services`, `ssh_service_action`), and live log tailing (`ssh_tail_logs`).
16
+ - **High-Performance SFTP**: Explore remote file systems (`sftp_list_dir`), read/write files (`sftp_read_file`, `sftp_write_file`), inspect metadata (`sftp_stat`), and sync local/remote folders (`sftp_upload`, `sftp_download`).
17
+ - **Port Forwarding & Tunnels**: Create background local-to-remote SSH port forwarding tunnels (`ssh_tunnel_open`, `ssh_tunnel_list`, `ssh_tunnel_close`) to securely access remote databases or web services.
18
+
19
+ ---
20
+
21
+ ## 📦 Installation
22
+
23
+ ```bash
24
+ # Standalone package installation:
25
+ pip install mcp-win-stdio-ssh
26
+
27
+ # Or with full mcp-win-stdio suite:
28
+ pip install "mcp-win-stdio[all]"
29
+ ```
30
+
31
+ ---
32
+
33
+ ## 🚀 Quick Start & Claude Configuration
34
+
35
+ ### Claude Desktop (`%APPDATA%\Claude\claude_desktop_config.json`)
36
+
37
+ ```json
38
+ {
39
+ "mcpServers": {
40
+ "ssh": {
41
+ "command": "mws-ssh",
42
+ "args": ["run"]
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ ### Claude Code CLI
49
+
50
+ ```bash
51
+ claude mcp add ssh mws-ssh run
52
+ ```
53
+
54
+ ---
55
+
56
+ ## 🛠️ CLI Utilities
57
+
58
+ ```bash
59
+ # Open interactive diagnostic CLI & host manager
60
+ mws-ssh
61
+
62
+ # List configured SSH hosts and test latency
63
+ mws-ssh test
64
+
65
+ # Launch MCP stdio server
66
+ mws-ssh run
67
+
68
+ # View comprehensive prompt recipes & tool documentation
69
+ mws-ssh guide
70
+ ```
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mcp-win-stdio-ssh"
7
- version = "0.3.4"
7
+ version = "0.4.0"
8
8
  description = "Advanced Multi-SSH Connection & Remote Management Model Context Protocol (MCP) server for Windows & Claude: 25+ tools for multi-host pooling, ~/.ssh/config auto-discovery, PTY interactive shells, SFTP file management, systemd/docker services, background jobs, and local port forwarding tunnels."
9
9
  readme = "README.md"
10
10
 
@@ -30,7 +30,7 @@ classifiers = [
30
30
  ]
31
31
 
32
32
  dependencies = [
33
- "mcp-win-stdio>=0.3.4",
33
+ "mcp-win-stdio>=0.4.0",
34
34
  "mcp>=1.2.0",
35
35
  "paramiko>=3.0.0",
36
36
  "cryptography>=3.3.0",
@@ -2,4 +2,4 @@
2
2
  Advanced Multi-SSH Connection & Remote Management MCP Server for mcp-win-stdio.
3
3
  """
4
4
 
5
- __version__ = "0.3.4"
5
+ __version__ = "0.4.0"
@@ -1,365 +1,365 @@
1
- """
2
- SSH Connection Pool and Host Configuration Manager for mcp-win-stdio-ssh.
3
- Handles ~/.ssh/config auto-discovery, persistent host bookmarks, authentication resolution,
4
- bastion/jump host tunneling, and resilient connection pooling.
5
- """
6
-
7
- import json
8
- import os
9
- import sys
10
- import threading
11
- from pathlib import Path
12
- from typing import Any, Dict, List, Optional
13
-
14
- import paramiko
15
- from paramiko.config import SSHConfig
16
-
17
- from mcp_win_stdio.core.config import USER_DIR, ensure_workspace_dirs
18
-
19
- SSH_CONFIG_DIR = USER_DIR
20
- HOSTS_CONFIG_FILE = SSH_CONFIG_DIR / "ssh_hosts.json"
21
- SSH_USER_DIR = Path.home() / ".ssh"
22
- SYSTEM_SSH_CONFIG = SSH_USER_DIR / "config"
23
-
24
- _ACTIVE_HOST: Optional[str] = None
25
- _CLIENT_POOL: Dict[str, paramiko.SSHClient] = {}
26
- _POOL_LOCK = threading.RLock()
27
-
28
-
29
- def _normalize_host_param(host: Any) -> Optional[str]:
30
- """Normalize host parameter from LLM tool call, filtering out null/empty strings."""
31
- if host is None:
32
- return None
33
- s = str(host).strip()
34
- if s.lower() in ("", "null", "none", "undefined", "default"):
35
- return None
36
- return s
37
-
38
-
39
- def load_ssh_hosts() -> Dict[str, Any]:
40
- """Load persistent SSH hosts from ~/.mcp-win-stdio/ssh_hosts.json."""
41
- ensure_workspace_dirs()
42
- if not HOSTS_CONFIG_FILE.exists():
43
- return {}
44
- try:
45
- with open(HOSTS_CONFIG_FILE, "r", encoding="utf-8") as f:
46
- data = json.load(f)
47
- return data if isinstance(data, dict) else {}
48
- except Exception:
49
- return {}
50
-
51
-
52
- def save_ssh_hosts(data: Dict[str, Any]) -> None:
53
- """Save persistent SSH hosts to ~/.mcp-win-stdio/ssh_hosts.json."""
54
- ensure_workspace_dirs()
55
- try:
56
- with open(HOSTS_CONFIG_FILE, "w", encoding="utf-8") as f:
57
- json.dump(data, f, indent=2)
58
- except Exception as e:
59
- sys.stderr.write(f"Warning: Failed to save ssh hosts: {e}\n")
60
-
61
-
62
- def get_active_host_name() -> Optional[str]:
63
- """Get the currently active host name (memory or persistent)."""
64
- global _ACTIVE_HOST
65
- if _ACTIVE_HOST:
66
- return _ACTIVE_HOST
67
- data = load_ssh_hosts()
68
- active = data.get("active_host")
69
- if active:
70
- _ACTIVE_HOST = active
71
- return active
72
- return None
73
-
74
-
75
- def set_active_host_name(name: Optional[str]) -> None:
76
- """Set and persist the active host name."""
77
- global _ACTIVE_HOST
78
- _ACTIVE_HOST = name
79
- data = load_ssh_hosts()
80
- data["active_host"] = name
81
- save_ssh_hosts(data)
82
-
83
-
84
- def parse_system_ssh_config() -> Dict[str, Dict[str, Any]]:
85
- """Parse ~/.ssh/config and return a mapping of host aliases to connection configs."""
86
- configs: Dict[str, Dict[str, Any]] = {}
87
- if not SYSTEM_SSH_CONFIG.exists():
88
- return configs
89
-
90
- try:
91
- ssh_cfg = SSHConfig()
92
- with open(SYSTEM_SSH_CONFIG, "r", encoding="utf-8", errors="replace") as f:
93
- ssh_cfg.parse(f)
94
-
95
- # Iterate through host entries
96
- for host in ssh_cfg.get_hostnames():
97
- if host == "*":
98
- continue
99
- entry = ssh_cfg.lookup(host)
100
- identity_files = entry.get("identityfile", [])
101
- key_path = identity_files[0] if identity_files else None
102
-
103
- # Resolve ~ in key path
104
- if key_path:
105
- key_path = str(Path(key_path).expanduser())
106
-
107
- configs[host] = {
108
- "name": host,
109
- "hostname": entry.get("hostname", host),
110
- "user": entry.get("user", os.environ.get("USERNAME", "root")),
111
- "port": int(entry.get("port", 22)),
112
- "key_path": key_path,
113
- "proxyjump": entry.get("proxyjump"),
114
- "proxycommand": entry.get("proxycommand"),
115
- "source": "ssh_config",
116
- }
117
- except Exception as e:
118
- sys.stderr.write(f"Warning: Failed to parse system SSH config: {e}\n")
119
-
120
- return configs
121
-
122
-
123
- def get_all_registered_hosts() -> Dict[str, Dict[str, Any]]:
124
- """Merge SSH config hosts, persistent custom hosts, and env variables."""
125
- all_hosts: Dict[str, Dict[str, Any]] = {}
126
-
127
- # 1. System ~/.ssh/config
128
- system_hosts = parse_system_ssh_config()
129
- all_hosts.update(system_hosts)
130
-
131
- # 2. Persistent ~/.mcp-win-stdio/ssh_hosts.json
132
- saved_data = load_ssh_hosts()
133
- hosts_dict = saved_data.get("hosts", {})
134
- for name, host_data in hosts_dict.items():
135
- if isinstance(host_data, dict):
136
- entry = dict(host_data)
137
- entry["name"] = name
138
- entry["source"] = "saved_config"
139
- all_hosts[name] = entry
140
-
141
- # 3. Environment variable SSH_SERVERS (JSON format)
142
- env_servers = os.environ.get("SSH_SERVERS")
143
- if env_servers:
144
- try:
145
- parsed = json.loads(env_servers)
146
- if isinstance(parsed, dict):
147
- for name, cfg in parsed.items():
148
- if isinstance(cfg, dict):
149
- entry = dict(cfg)
150
- entry["name"] = name
151
- entry["source"] = "env"
152
- all_hosts[name] = entry
153
- except Exception:
154
- pass
155
-
156
- return all_hosts
157
-
158
-
159
- def resolve_host_info(target_name: Optional[str] = None) -> Dict[str, Any]:
160
- """
161
- Resolve connection parameters for a host by name or active default.
162
- Supports user@host:port strings or registered aliases.
163
- """
164
- norm = _normalize_host_param(target_name)
165
- host_name = norm or get_active_host_name()
166
-
167
- registered = get_all_registered_hosts()
168
-
169
- if host_name and host_name in registered:
170
- return registered[host_name]
171
-
172
- # Check if target is a direct user@hostname:port connection string
173
- if host_name and ("@" in host_name or "." in host_name):
174
- user = os.environ.get("USERNAME", "root")
175
- host_str = host_name
176
- port = 22
177
-
178
- if "@" in host_str:
179
- user, host_str = host_str.split("@", 1)
180
-
181
- if ":" in host_str:
182
- host_str, port_str = host_str.rsplit(":", 1)
183
- try:
184
- port = int(port_str)
185
- except ValueError:
186
- port = 22
187
-
188
- return {
189
- "name": host_name,
190
- "hostname": host_str,
191
- "user": user,
192
- "port": port,
193
- "key_path": None,
194
- "source": "ad_hoc",
195
- }
196
-
197
- if not host_name:
198
- if registered:
199
- first_name = next(iter(registered.keys()))
200
- set_active_host_name(first_name)
201
- return registered[first_name]
202
- raise ValueError(
203
- "No SSH host specified and no active host is configured. Use 'add_host' or provide 'host' parameter."
204
- )
205
-
206
- raise ValueError(f"SSH host '{host_name}' not found. Available hosts: {list(registered.keys())}")
207
-
208
-
209
- def _find_default_keys() -> List[str]:
210
- """Find default SSH keys in ~/.ssh/ directory."""
211
- keys = []
212
- if SSH_USER_DIR.is_dir():
213
- for key_name in ["id_ed25519", "id_rsa", "id_ecdsa", "id_dsa"]:
214
- kp = SSH_USER_DIR / key_name
215
- if kp.exists():
216
- keys.append(str(kp))
217
- return keys
218
-
219
-
220
- def create_ssh_client(
221
- host_info: Dict[str, Any],
222
- password: Optional[str] = None,
223
- passphrase: Optional[str] = None,
224
- timeout: int = 15,
225
- ) -> paramiko.SSHClient:
226
- """Instantiate and connect a paramiko.SSHClient based on host info."""
227
- client = paramiko.SSHClient()
228
- client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
229
-
230
- hostname = host_info.get("hostname", "localhost")
231
- port = int(host_info.get("port", 22))
232
- username = host_info.get("user") or os.environ.get("USERNAME", "root")
233
- key_path = host_info.get("key_path")
234
- pwd = password or host_info.get("password")
235
- pass_phrase = passphrase or host_info.get("passphrase")
236
-
237
- key_filename = None
238
- if key_path and Path(key_path).expanduser().exists():
239
- key_filename = str(Path(key_path).expanduser())
240
- elif not pwd:
241
- # Check default keys ONLY if no explicit password was configured
242
- default_keys = _find_default_keys()
243
- if default_keys:
244
- key_filename = default_keys[0]
245
-
246
- # Support JumpHost / Bastion if specified
247
- sock = None
248
- jump_host = host_info.get("jump_host") or host_info.get("proxyjump")
249
- if jump_host:
250
- jump_info = resolve_host_info(jump_host)
251
- jump_client = get_cached_or_connect(jump_info["name"])
252
- transport = jump_client.get_transport()
253
- if transport and transport.is_active():
254
- dest_addr = (hostname, port)
255
- local_addr = ("127.0.0.1", 0)
256
- sock = transport.open_channel("direct-tcpip", dest_addr, local_addr)
257
-
258
- connect_kwargs: Dict[str, Any] = {
259
- "hostname": hostname,
260
- "port": port,
261
- "username": username,
262
- "timeout": timeout,
263
- "allow_agent": False if pwd else True,
264
- "look_for_keys": False if pwd else True,
265
- }
266
-
267
- if sock:
268
- connect_kwargs["sock"] = sock
269
- if pwd:
270
- connect_kwargs["password"] = pwd
271
- if key_filename:
272
- connect_kwargs["key_filename"] = key_filename
273
- if pass_phrase:
274
- connect_kwargs["passphrase"] = pass_phrase
275
-
276
- try:
277
- client.connect(**connect_kwargs)
278
- # Enable keepalive
279
- transport = client.get_transport()
280
- if transport:
281
- transport.set_keepalive(30)
282
- return client
283
- except Exception as e:
284
- client.close()
285
- raise ConnectionError(
286
- f"Failed to connect to SSH host '{host_info.get('name', hostname)}' ({username}@{hostname}:{port}): {e}"
287
- )
288
-
289
-
290
- def get_cached_or_connect(
291
- target_name: Optional[str] = None,
292
- password: Optional[str] = None,
293
- passphrase: Optional[str] = None,
294
- timeout: int = 15,
295
- ) -> paramiko.SSHClient:
296
- """Retrieve an active SSH client from the pool or establish a new connection."""
297
- host_info = resolve_host_info(target_name)
298
- name = host_info["name"]
299
-
300
- with _POOL_LOCK:
301
- if name in _CLIENT_POOL:
302
- client = _CLIENT_POOL[name]
303
- transport = client.get_transport()
304
- if transport and transport.is_active():
305
- return client
306
- # Drop dead connection
307
- try:
308
- client.close()
309
- except Exception:
310
- pass
311
- del _CLIENT_POOL[name]
312
-
313
- client = create_ssh_client(host_info, password=password, passphrase=passphrase, timeout=timeout)
314
- _CLIENT_POOL[name] = client
315
- return client
316
-
317
-
318
- def close_connection(target_name: Optional[str] = None) -> bool:
319
- """Close connection for a specific host in the pool."""
320
- norm = _normalize_host_param(target_name)
321
- name = norm or get_active_host_name()
322
- if not name:
323
- return False
324
-
325
- with _POOL_LOCK:
326
- if name in _CLIENT_POOL:
327
- try:
328
- _CLIENT_POOL[name].close()
329
- except Exception:
330
- pass
331
- del _CLIENT_POOL[name]
332
- return True
333
- return False
334
-
335
-
336
- def close_all_connections() -> int:
337
- """Close all open SSH connections in the pool."""
338
- closed = 0
339
- with _POOL_LOCK:
340
- for name, client in list(_CLIENT_POOL.items()):
341
- try:
342
- client.close()
343
- closed += 1
344
- except Exception:
345
- pass
346
- _CLIENT_POOL.clear()
347
- return closed
348
-
349
-
350
- def get_pool_status() -> List[Dict[str, Any]]:
351
- """Return status of all live connections in the pool."""
352
- status = []
353
- with _POOL_LOCK:
354
- for name, client in _CLIENT_POOL.items():
355
- transport = client.get_transport()
356
- is_active = transport.is_active() if transport else False
357
- status.append(
358
- {
359
- "name": name,
360
- "isActive": is_active,
361
- "remoteAddress": f"{transport.getpeername()}" if (transport and is_active) else "closed",
362
- "isCurrentDefault": name == get_active_host_name(),
363
- }
364
- )
365
- return status
1
+ """
2
+ SSH Connection Pool and Host Configuration Manager for mcp-win-stdio-ssh.
3
+ Handles ~/.ssh/config auto-discovery, persistent host bookmarks, authentication resolution,
4
+ bastion/jump host tunneling, and resilient connection pooling.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import sys
10
+ import threading
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ import paramiko
15
+ from paramiko.config import SSHConfig
16
+
17
+ from mcp_win_stdio.core.config import USER_DIR, ensure_workspace_dirs
18
+
19
+ SSH_CONFIG_DIR = USER_DIR
20
+ HOSTS_CONFIG_FILE = SSH_CONFIG_DIR / "ssh_hosts.json"
21
+ SSH_USER_DIR = Path.home() / ".ssh"
22
+ SYSTEM_SSH_CONFIG = SSH_USER_DIR / "config"
23
+
24
+ _ACTIVE_HOST: Optional[str] = None
25
+ _CLIENT_POOL: Dict[str, paramiko.SSHClient] = {}
26
+ _POOL_LOCK = threading.RLock()
27
+
28
+
29
+ def _normalize_host_param(host: Any) -> Optional[str]:
30
+ """Normalize host parameter from LLM tool call, filtering out null/empty strings."""
31
+ if host is None:
32
+ return None
33
+ s = str(host).strip()
34
+ if s.lower() in ("", "null", "none", "undefined", "default"):
35
+ return None
36
+ return s
37
+
38
+
39
+ def load_ssh_hosts() -> Dict[str, Any]:
40
+ """Load persistent SSH hosts from ~/.mcp-win-stdio/ssh_hosts.json."""
41
+ ensure_workspace_dirs()
42
+ if not HOSTS_CONFIG_FILE.exists():
43
+ return {}
44
+ try:
45
+ with open(HOSTS_CONFIG_FILE, "r", encoding="utf-8") as f:
46
+ data = json.load(f)
47
+ return data if isinstance(data, dict) else {}
48
+ except Exception:
49
+ return {}
50
+
51
+
52
+ def save_ssh_hosts(data: Dict[str, Any]) -> None:
53
+ """Save persistent SSH hosts to ~/.mcp-win-stdio/ssh_hosts.json."""
54
+ ensure_workspace_dirs()
55
+ try:
56
+ with open(HOSTS_CONFIG_FILE, "w", encoding="utf-8") as f:
57
+ json.dump(data, f, indent=2)
58
+ except Exception as e:
59
+ sys.stderr.write(f"Warning: Failed to save ssh hosts: {e}\n")
60
+
61
+
62
+ def get_active_host_name() -> Optional[str]:
63
+ """Get the currently active host name (memory or persistent)."""
64
+ global _ACTIVE_HOST
65
+ if _ACTIVE_HOST:
66
+ return _ACTIVE_HOST
67
+ data = load_ssh_hosts()
68
+ active = data.get("active_host")
69
+ if active:
70
+ _ACTIVE_HOST = active
71
+ return active
72
+ return None
73
+
74
+
75
+ def set_active_host_name(name: Optional[str]) -> None:
76
+ """Set and persist the active host name."""
77
+ global _ACTIVE_HOST
78
+ _ACTIVE_HOST = name
79
+ data = load_ssh_hosts()
80
+ data["active_host"] = name
81
+ save_ssh_hosts(data)
82
+
83
+
84
+ def parse_system_ssh_config() -> Dict[str, Dict[str, Any]]:
85
+ """Parse ~/.ssh/config and return a mapping of host aliases to connection configs."""
86
+ configs: Dict[str, Dict[str, Any]] = {}
87
+ if not SYSTEM_SSH_CONFIG.exists():
88
+ return configs
89
+
90
+ try:
91
+ ssh_cfg = SSHConfig()
92
+ with open(SYSTEM_SSH_CONFIG, "r", encoding="utf-8", errors="replace") as f:
93
+ ssh_cfg.parse(f)
94
+
95
+ # Iterate through host entries
96
+ for host in ssh_cfg.get_hostnames():
97
+ if host == "*":
98
+ continue
99
+ entry = ssh_cfg.lookup(host)
100
+ identity_files = entry.get("identityfile", [])
101
+ key_path = identity_files[0] if identity_files else None
102
+
103
+ # Resolve ~ in key path
104
+ if key_path:
105
+ key_path = str(Path(key_path).expanduser())
106
+
107
+ configs[host] = {
108
+ "name": host,
109
+ "hostname": entry.get("hostname", host),
110
+ "user": entry.get("user", os.environ.get("USERNAME", "root")),
111
+ "port": int(entry.get("port", 22)),
112
+ "key_path": key_path,
113
+ "proxyjump": entry.get("proxyjump"),
114
+ "proxycommand": entry.get("proxycommand"),
115
+ "source": "ssh_config",
116
+ }
117
+ except Exception as e:
118
+ sys.stderr.write(f"Warning: Failed to parse system SSH config: {e}\n")
119
+
120
+ return configs
121
+
122
+
123
+ def get_all_registered_hosts() -> Dict[str, Dict[str, Any]]:
124
+ """Merge SSH config hosts, persistent custom hosts, and env variables."""
125
+ all_hosts: Dict[str, Dict[str, Any]] = {}
126
+
127
+ # 1. System ~/.ssh/config
128
+ system_hosts = parse_system_ssh_config()
129
+ all_hosts.update(system_hosts)
130
+
131
+ # 2. Persistent ~/.mcp-win-stdio/ssh_hosts.json
132
+ saved_data = load_ssh_hosts()
133
+ hosts_dict = saved_data.get("hosts", {})
134
+ for name, host_data in hosts_dict.items():
135
+ if isinstance(host_data, dict):
136
+ entry = dict(host_data)
137
+ entry["name"] = name
138
+ entry["source"] = "saved_config"
139
+ all_hosts[name] = entry
140
+
141
+ # 3. Environment variable SSH_SERVERS (JSON format)
142
+ env_servers = os.environ.get("SSH_SERVERS")
143
+ if env_servers:
144
+ try:
145
+ parsed = json.loads(env_servers)
146
+ if isinstance(parsed, dict):
147
+ for name, cfg in parsed.items():
148
+ if isinstance(cfg, dict):
149
+ entry = dict(cfg)
150
+ entry["name"] = name
151
+ entry["source"] = "env"
152
+ all_hosts[name] = entry
153
+ except Exception:
154
+ pass
155
+
156
+ return all_hosts
157
+
158
+
159
+ def resolve_host_info(target_name: Optional[str] = None) -> Dict[str, Any]:
160
+ """
161
+ Resolve connection parameters for a host by name or active default.
162
+ Supports user@host:port strings or registered aliases.
163
+ """
164
+ norm = _normalize_host_param(target_name)
165
+ host_name = norm or get_active_host_name()
166
+
167
+ registered = get_all_registered_hosts()
168
+
169
+ if host_name and host_name in registered:
170
+ return registered[host_name]
171
+
172
+ # Check if target is a direct user@hostname:port connection string
173
+ if host_name and ("@" in host_name or "." in host_name):
174
+ user = os.environ.get("USERNAME", "root")
175
+ host_str = host_name
176
+ port = 22
177
+
178
+ if "@" in host_str:
179
+ user, host_str = host_str.split("@", 1)
180
+
181
+ if ":" in host_str:
182
+ host_str, port_str = host_str.rsplit(":", 1)
183
+ try:
184
+ port = int(port_str)
185
+ except ValueError:
186
+ port = 22
187
+
188
+ return {
189
+ "name": host_name,
190
+ "hostname": host_str,
191
+ "user": user,
192
+ "port": port,
193
+ "key_path": None,
194
+ "source": "ad_hoc",
195
+ }
196
+
197
+ if not host_name:
198
+ if registered:
199
+ first_name = next(iter(registered.keys()))
200
+ set_active_host_name(first_name)
201
+ return registered[first_name]
202
+ raise ValueError(
203
+ "No SSH host specified and no active host is configured. Use 'add_host' or provide 'host' parameter."
204
+ )
205
+
206
+ raise ValueError(f"SSH host '{host_name}' not found. Available hosts: {list(registered.keys())}")
207
+
208
+
209
+ def _find_default_keys() -> List[str]:
210
+ """Find default SSH keys in ~/.ssh/ directory."""
211
+ keys = []
212
+ if SSH_USER_DIR.is_dir():
213
+ for key_name in ["id_ed25519", "id_rsa", "id_ecdsa", "id_dsa"]:
214
+ kp = SSH_USER_DIR / key_name
215
+ if kp.exists():
216
+ keys.append(str(kp))
217
+ return keys
218
+
219
+
220
+ def create_ssh_client(
221
+ host_info: Dict[str, Any],
222
+ password: Optional[str] = None,
223
+ passphrase: Optional[str] = None,
224
+ timeout: int = 15,
225
+ ) -> paramiko.SSHClient:
226
+ """Instantiate and connect a paramiko.SSHClient based on host info."""
227
+ client = paramiko.SSHClient()
228
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
229
+
230
+ hostname = host_info.get("hostname", "localhost")
231
+ port = int(host_info.get("port", 22))
232
+ username = host_info.get("user") or os.environ.get("USERNAME", "root")
233
+ key_path = host_info.get("key_path")
234
+ pwd = password or host_info.get("password")
235
+ pass_phrase = passphrase or host_info.get("passphrase")
236
+
237
+ key_filename = None
238
+ if key_path and Path(key_path).expanduser().exists():
239
+ key_filename = str(Path(key_path).expanduser())
240
+ elif not pwd:
241
+ # Check default keys ONLY if no explicit password was configured
242
+ default_keys = _find_default_keys()
243
+ if default_keys:
244
+ key_filename = default_keys[0]
245
+
246
+ # Support JumpHost / Bastion if specified
247
+ sock = None
248
+ jump_host = host_info.get("jump_host") or host_info.get("proxyjump")
249
+ if jump_host:
250
+ jump_info = resolve_host_info(jump_host)
251
+ jump_client = get_cached_or_connect(jump_info["name"])
252
+ transport = jump_client.get_transport()
253
+ if transport and transport.is_active():
254
+ dest_addr = (hostname, port)
255
+ local_addr = ("127.0.0.1", 0)
256
+ sock = transport.open_channel("direct-tcpip", dest_addr, local_addr)
257
+
258
+ connect_kwargs: Dict[str, Any] = {
259
+ "hostname": hostname,
260
+ "port": port,
261
+ "username": username,
262
+ "timeout": timeout,
263
+ "allow_agent": False if pwd else True,
264
+ "look_for_keys": False if pwd else True,
265
+ }
266
+
267
+ if sock:
268
+ connect_kwargs["sock"] = sock
269
+ if pwd:
270
+ connect_kwargs["password"] = pwd
271
+ if key_filename:
272
+ connect_kwargs["key_filename"] = key_filename
273
+ if pass_phrase:
274
+ connect_kwargs["passphrase"] = pass_phrase
275
+
276
+ try:
277
+ client.connect(**connect_kwargs)
278
+ # Enable keepalive
279
+ transport = client.get_transport()
280
+ if transport:
281
+ transport.set_keepalive(30)
282
+ return client
283
+ except Exception as e:
284
+ client.close()
285
+ raise ConnectionError(
286
+ f"Failed to connect to SSH host '{host_info.get('name', hostname)}' ({username}@{hostname}:{port}): {e}"
287
+ )
288
+
289
+
290
+ def get_cached_or_connect(
291
+ target_name: Optional[str] = None,
292
+ password: Optional[str] = None,
293
+ passphrase: Optional[str] = None,
294
+ timeout: int = 15,
295
+ ) -> paramiko.SSHClient:
296
+ """Retrieve an active SSH client from the pool or establish a new connection."""
297
+ host_info = resolve_host_info(target_name)
298
+ name = host_info["name"]
299
+
300
+ with _POOL_LOCK:
301
+ if name in _CLIENT_POOL:
302
+ client = _CLIENT_POOL[name]
303
+ transport = client.get_transport()
304
+ if transport and transport.is_active():
305
+ return client
306
+ # Drop dead connection
307
+ try:
308
+ client.close()
309
+ except Exception:
310
+ pass
311
+ del _CLIENT_POOL[name]
312
+
313
+ client = create_ssh_client(host_info, password=password, passphrase=passphrase, timeout=timeout)
314
+ _CLIENT_POOL[name] = client
315
+ return client
316
+
317
+
318
+ def close_connection(target_name: Optional[str] = None) -> bool:
319
+ """Close connection for a specific host in the pool."""
320
+ norm = _normalize_host_param(target_name)
321
+ name = norm or get_active_host_name()
322
+ if not name:
323
+ return False
324
+
325
+ with _POOL_LOCK:
326
+ if name in _CLIENT_POOL:
327
+ try:
328
+ _CLIENT_POOL[name].close()
329
+ except Exception:
330
+ pass
331
+ del _CLIENT_POOL[name]
332
+ return True
333
+ return False
334
+
335
+
336
+ def close_all_connections() -> int:
337
+ """Close all open SSH connections in the pool."""
338
+ closed = 0
339
+ with _POOL_LOCK:
340
+ for name, client in list(_CLIENT_POOL.items()):
341
+ try:
342
+ client.close()
343
+ closed += 1
344
+ except Exception:
345
+ pass
346
+ _CLIENT_POOL.clear()
347
+ return closed
348
+
349
+
350
+ def get_pool_status() -> List[Dict[str, Any]]:
351
+ """Return status of all live connections in the pool."""
352
+ status = []
353
+ with _POOL_LOCK:
354
+ for name, client in _CLIENT_POOL.items():
355
+ transport = client.get_transport()
356
+ is_active = transport.is_active() if transport else False
357
+ status.append(
358
+ {
359
+ "name": name,
360
+ "isActive": is_active,
361
+ "remoteAddress": f"{transport.getpeername()}" if (transport and is_active) else "closed",
362
+ "isCurrentDefault": name == get_active_host_name(),
363
+ }
364
+ )
365
+ return status