mcp-win-stdio-ssh 0.2.4__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.
@@ -0,0 +1,428 @@
1
+ """
2
+ SFTP File Operations and Directory Traversal for mcp-win-stdio-ssh.
3
+ Provides token-safe remote file reading, streaming writing, metadata inspection, and sync operations.
4
+ """
5
+
6
+ from datetime import datetime
7
+ import os
8
+ from pathlib import Path
9
+ import stat
10
+ import threading
11
+ import time
12
+ from typing import Any, Dict, List, Optional, Tuple, Union
13
+
14
+ import paramiko
15
+
16
+ from mcp_win_stdio.ssh.connection import get_cached_or_connect, resolve_host_info
17
+
18
+ _SFTP_CLIENTS: Dict[str, paramiko.SFTPClient] = {}
19
+ _SFTP_LOCK = threading.RLock()
20
+
21
+
22
+ def get_sftp_client(target_name: Optional[str] = None) -> Tuple[paramiko.SFTPClient, Dict[str, Any]]:
23
+ """Retrieve an active SFTP client for target host or open a new one."""
24
+ host_info = resolve_host_info(target_name)
25
+ name = host_info["name"]
26
+
27
+ with _SFTP_LOCK:
28
+ if name in _SFTP_CLIENTS:
29
+ sftp = _SFTP_CLIENTS[name]
30
+ # Check if channel active
31
+ if sftp.get_channel() and not sftp.get_channel().closed:
32
+ return sftp, host_info
33
+ try:
34
+ sftp.close()
35
+ except Exception:
36
+ pass
37
+ del _SFTP_CLIENTS[name]
38
+
39
+ ssh_client = get_cached_or_connect(name)
40
+ sftp = ssh_client.open_sftp()
41
+ _SFTP_CLIENTS[name] = sftp
42
+ return sftp, host_info
43
+
44
+
45
+ def format_file_mode(mode_int: int) -> str:
46
+ """Convert integer file mode to unix string representation (e.g. -rwxr-xr-x)."""
47
+ return stat.filemode(mode_int)
48
+
49
+
50
+ def list_remote_directory(
51
+ remote_path: str = ".",
52
+ limit: int = 100,
53
+ offset: int = 0,
54
+ filter: Optional[str] = None,
55
+ host: Optional[str] = None,
56
+ ) -> Dict[str, Any]:
57
+ """
58
+ List contents of a remote directory with detailed metadata and context window protection.
59
+
60
+ Args:
61
+ remote_path: Target directory path on remote server.
62
+ limit: Max items to return per batch (default 100, max 250).
63
+ offset: Starting offset for pagination (default 0).
64
+ filter: Optional substring filter for filenames.
65
+ host: Target SSH host (or active host).
66
+ """
67
+ sftp, host_info = get_sftp_client(host)
68
+
69
+ target_path = remote_path.strip() or "."
70
+ try:
71
+ # Normalize path
72
+ if target_path == ".":
73
+ target_path = sftp.normalize(".")
74
+
75
+ attr_list = sftp.listdir_attr(target_path)
76
+ items = []
77
+
78
+ clean_filter = filter.strip().lower() if filter else None
79
+
80
+ for attr in sorted(attr_list, key=lambda a: (not stat.S_ISDIR(a.st_mode), a.filename.lower())):
81
+ if clean_filter and clean_filter not in attr.filename.lower():
82
+ continue
83
+
84
+ is_dir = stat.S_ISDIR(attr.st_mode)
85
+ is_symlink = stat.S_ISLNK(attr.st_mode)
86
+ mtime_dt = datetime.fromtimestamp(attr.st_mtime) if attr.st_mtime else None
87
+
88
+ items.append({
89
+ "name": attr.filename,
90
+ "type": "directory" if is_dir else ("symlink" if is_symlink else "file"),
91
+ "sizeBytes": attr.st_size,
92
+ "sizeFormatted": f"{attr.st_size / 1024:.1f} KB" if attr.st_size < 1024*1024 else f"{attr.st_size / (1024*1024):.2f} MB",
93
+ "permissions": format_file_mode(attr.st_mode),
94
+ "modified": mtime_dt.isoformat() if mtime_dt else None,
95
+ "uid": attr.st_uid,
96
+ "gid": attr.st_gid,
97
+ })
98
+
99
+ safe_limit = min(max(1, limit), 250)
100
+ safe_offset = max(0, offset)
101
+ total_items = len(items)
102
+ display_items = items[safe_offset : safe_offset + safe_limit]
103
+ has_more = total_items > (safe_offset + len(display_items))
104
+
105
+ res: Dict[str, Any] = {
106
+ "host": host_info["name"],
107
+ "remotePath": target_path,
108
+ "totalItems": total_items,
109
+ "returnedItems": len(display_items),
110
+ "offset": safe_offset,
111
+ "limit": safe_limit,
112
+ "hasMore": has_more,
113
+ "truncated": has_more,
114
+ "items": display_items,
115
+ }
116
+
117
+ if has_more:
118
+ next_offset = safe_offset + len(display_items)
119
+ res["notice"] = (
120
+ f"... [TRUNCATED: Showing items {safe_offset + 1}-{next_offset} of {total_items}. "
121
+ f"Use offset={next_offset} or provide 'filter' parameter to narrow search] ..."
122
+ )
123
+
124
+ return res
125
+ except Exception as e:
126
+ return {
127
+ "error": True,
128
+ "host": host_info["name"],
129
+ "remotePath": target_path,
130
+ "message": f"Failed to list remote directory '{target_path}': {e}",
131
+ }
132
+
133
+
134
+ def read_remote_text_file(
135
+ remote_path: str,
136
+ max_chars: int = 15000,
137
+ offset_lines: int = 0,
138
+ host: Optional[str] = None,
139
+ ) -> Dict[str, Any]:
140
+ """Read a remote text file with line offset support and token safety."""
141
+ sftp, host_info = get_sftp_client(host)
142
+
143
+ try:
144
+ with sftp.open(remote_path, "r") as f:
145
+ lines = f.readlines()
146
+
147
+ total_lines = len(lines)
148
+ selected_lines = lines[offset_lines:] if offset_lines < total_lines else []
149
+ content = "".join(selected_lines)
150
+
151
+ is_truncated = False
152
+ if len(content) > max_chars:
153
+ content = content[:max_chars]
154
+ is_truncated = True
155
+
156
+ return {
157
+ "host": host_info["name"],
158
+ "remotePath": remote_path,
159
+ "totalLines": total_lines,
160
+ "offsetLines": offset_lines,
161
+ "linesReturned": len(selected_lines),
162
+ "isTruncated": is_truncated,
163
+ "content": content,
164
+ }
165
+ except Exception as e:
166
+ return {
167
+ "error": True,
168
+ "host": host_info["name"],
169
+ "remotePath": remote_path,
170
+ "message": f"Failed to read remote file '{remote_path}': {e}",
171
+ }
172
+
173
+
174
+ def write_remote_text_file(
175
+ remote_path: str,
176
+ content: str,
177
+ mode: str = "write",
178
+ host: Optional[str] = None,
179
+ ) -> Dict[str, Any]:
180
+ """Write or append text content to a remote file."""
181
+ sftp, host_info = get_sftp_client(host)
182
+
183
+ open_mode = "w" if mode == "write" else "a"
184
+ try:
185
+ # Ensure parent directories exist
186
+ parent = str(Path(remote_path).parent).replace("\\", "/")
187
+ if parent and parent != "." and parent != "/":
188
+ _ensure_remote_dir(sftp, parent)
189
+
190
+ with sftp.open(remote_path, open_mode) as f:
191
+ f.write(content)
192
+
193
+ return {
194
+ "success": True,
195
+ "host": host_info["name"],
196
+ "remotePath": remote_path,
197
+ "bytesWritten": len(content.encode("utf-8")),
198
+ "mode": mode,
199
+ "message": f"Successfully wrote {len(content.encode('utf-8'))} bytes to '{remote_path}' on {host_info['name']}.",
200
+ }
201
+ except Exception as e:
202
+ return {
203
+ "error": True,
204
+ "host": host_info["name"],
205
+ "remotePath": remote_path,
206
+ "message": f"Failed to write to remote file '{remote_path}': {e}",
207
+ }
208
+
209
+
210
+ def _ensure_remote_dir(sftp: paramiko.SFTPClient, remote_dir: str) -> None:
211
+ """Helper to recursively create remote directories if missing."""
212
+ parts = remote_dir.strip("/").split("/")
213
+ cur = "/" if remote_dir.startswith("/") else ""
214
+ for part in parts:
215
+ cur += part + "/"
216
+ try:
217
+ sftp.stat(cur)
218
+ except IOError:
219
+ try:
220
+ sftp.mkdir(cur)
221
+ except Exception:
222
+ pass
223
+
224
+
225
+ def stat_remote_path(
226
+ remote_path: str,
227
+ host: Optional[str] = None,
228
+ ) -> Dict[str, Any]:
229
+ """Inspect metadata, permissions, and stats for a remote path."""
230
+ sftp, host_info = get_sftp_client(host)
231
+
232
+ try:
233
+ st = sftp.stat(remote_path)
234
+ is_dir = stat.S_ISDIR(st.st_mode)
235
+ is_file = stat.S_ISREG(st.st_mode)
236
+ is_sym = stat.S_ISLNK(st.st_mode)
237
+ mtime_dt = datetime.fromtimestamp(st.st_mtime) if st.st_mtime else None
238
+ atime_dt = datetime.fromtimestamp(st.st_atime) if st.st_atime else None
239
+
240
+ return {
241
+ "host": host_info["name"],
242
+ "remotePath": remote_path,
243
+ "exists": True,
244
+ "isDirectory": is_dir,
245
+ "isFile": is_file,
246
+ "isSymlink": is_sym,
247
+ "sizeBytes": st.st_size,
248
+ "permissions": format_file_mode(st.st_mode),
249
+ "octalPermissions": oct(stat.S_IMODE(st.st_mode)),
250
+ "uid": st.st_uid,
251
+ "gid": st.st_gid,
252
+ "modified": mtime_dt.isoformat() if mtime_dt else None,
253
+ "accessed": atime_dt.isoformat() if atime_dt else None,
254
+ }
255
+ except IOError:
256
+ return {
257
+ "host": host_info["name"],
258
+ "remotePath": remote_path,
259
+ "exists": False,
260
+ "message": f"Path '{remote_path}' does not exist on '{host_info['name']}'.",
261
+ }
262
+ except Exception as e:
263
+ return {
264
+ "error": True,
265
+ "host": host_info["name"],
266
+ "remotePath": remote_path,
267
+ "message": f"Failed to stat remote path '{remote_path}': {e}",
268
+ }
269
+
270
+
271
+ def upload_path(
272
+ local_path: str,
273
+ remote_path: str,
274
+ host: Optional[str] = None,
275
+ ) -> Dict[str, Any]:
276
+ """Upload a local file or folder to the remote host."""
277
+ sftp, host_info = get_sftp_client(host)
278
+ local_p = Path(local_path).resolve()
279
+
280
+ if not local_p.exists():
281
+ return {
282
+ "error": True,
283
+ "message": f"Local path '{local_path}' does not exist.",
284
+ }
285
+
286
+ try:
287
+ if local_p.is_file():
288
+ _ensure_remote_dir(sftp, str(Path(remote_path).parent).replace("\\", "/"))
289
+ sftp.put(str(local_p), remote_path.replace("\\", "/"))
290
+ return {
291
+ "success": True,
292
+ "host": host_info["name"],
293
+ "localPath": str(local_p),
294
+ "remotePath": remote_path,
295
+ "sizeBytes": local_p.stat().st_size,
296
+ "message": f"Uploaded '{local_p.name}' ({local_p.stat().st_size} bytes) to '{remote_path}' on {host_info['name']}.",
297
+ }
298
+
299
+ # Directory recursive upload
300
+ uploaded_count = 0
301
+ for root, dirs, files in os.walk(str(local_p)):
302
+ rel_path = os.path.relpath(root, str(local_p))
303
+ dest_dir = (Path(remote_path) / rel_path).as_posix() if rel_path != "." else remote_path.replace("\\", "/")
304
+ _ensure_remote_dir(sftp, dest_dir)
305
+
306
+ for f in files:
307
+ src_file = Path(root) / f
308
+ dst_file = f"{dest_dir.rstrip('/')}/{f}"
309
+ sftp.put(str(src_file), dst_file)
310
+ uploaded_count += 1
311
+
312
+ return {
313
+ "success": True,
314
+ "host": host_info["name"],
315
+ "localPath": str(local_p),
316
+ "remotePath": remote_path,
317
+ "filesUploaded": uploaded_count,
318
+ "message": f"Recursively uploaded directory ({uploaded_count} files) to '{remote_path}' on {host_info['name']}.",
319
+ }
320
+ except Exception as e:
321
+ return {
322
+ "error": True,
323
+ "host": host_info["name"],
324
+ "localPath": str(local_p),
325
+ "remotePath": remote_path,
326
+ "message": f"Upload failed: {e}",
327
+ }
328
+
329
+
330
+ def download_path(
331
+ remote_path: str,
332
+ local_path: str,
333
+ host: Optional[str] = None,
334
+ ) -> Dict[str, Any]:
335
+ """Download a remote file or folder to the local machine."""
336
+ sftp, host_info = get_sftp_client(host)
337
+ local_p = Path(local_path).resolve()
338
+
339
+ try:
340
+ st = sftp.stat(remote_path)
341
+ is_dir = stat.S_ISDIR(st.st_mode)
342
+
343
+ if not is_dir:
344
+ local_p.parent.mkdir(parents=True, exist_ok=True)
345
+ sftp.get(remote_path, str(local_p))
346
+ return {
347
+ "success": True,
348
+ "host": host_info["name"],
349
+ "remotePath": remote_path,
350
+ "localPath": str(local_p),
351
+ "sizeBytes": st.st_size,
352
+ "message": f"Downloaded '{remote_path}' ({st.st_size} bytes) to '{local_p}'.",
353
+ }
354
+
355
+ # Recursive directory download
356
+ downloaded_count = 0
357
+
358
+ def _download_dir_recursive(rem_dir: str, loc_dir: Path):
359
+ nonlocal downloaded_count
360
+ loc_dir.mkdir(parents=True, exist_ok=True)
361
+ for attr in sftp.listdir_attr(rem_dir):
362
+ r_item = f"{rem_dir.rstrip('/')}/{attr.filename}"
363
+ l_item = loc_dir / attr.filename
364
+ if stat.S_ISDIR(attr.st_mode):
365
+ _download_dir_recursive(r_item, l_item)
366
+ else:
367
+ sftp.get(r_item, str(l_item))
368
+ downloaded_count += 1
369
+
370
+ _download_dir_recursive(remote_path, local_p)
371
+
372
+ return {
373
+ "success": True,
374
+ "host": host_info["name"],
375
+ "remotePath": remote_path,
376
+ "localPath": str(local_p),
377
+ "filesDownloaded": downloaded_count,
378
+ "message": f"Recursively downloaded remote directory ({downloaded_count} files) to '{local_p}'.",
379
+ }
380
+ except Exception as e:
381
+ return {
382
+ "error": True,
383
+ "host": host_info["name"],
384
+ "remotePath": remote_path,
385
+ "localPath": str(local_p),
386
+ "message": f"Download failed: {e}",
387
+ }
388
+
389
+
390
+ def remove_remote_path(
391
+ remote_path: str,
392
+ recursive: bool = False,
393
+ host: Optional[str] = None,
394
+ ) -> Dict[str, Any]:
395
+ """Delete a remote file or directory."""
396
+ sftp, host_info = get_sftp_client(host)
397
+
398
+ try:
399
+ st = sftp.stat(remote_path)
400
+ if stat.S_ISDIR(st.st_mode):
401
+ if recursive:
402
+ def _rm_recursive(rem_dir: str):
403
+ for attr in sftp.listdir_attr(rem_dir):
404
+ item = f"{rem_dir.rstrip('/')}/{attr.filename}"
405
+ if stat.S_ISDIR(attr.st_mode):
406
+ _rm_recursive(item)
407
+ else:
408
+ sftp.remove(item)
409
+ sftp.rmdir(rem_dir)
410
+ _rm_recursive(remote_path)
411
+ else:
412
+ sftp.rmdir(remote_path)
413
+ else:
414
+ sftp.remove(remote_path)
415
+
416
+ return {
417
+ "success": True,
418
+ "host": host_info["name"],
419
+ "remotePath": remote_path,
420
+ "message": f"Removed '{remote_path}' on {host_info['name']}.",
421
+ }
422
+ except Exception as e:
423
+ return {
424
+ "error": True,
425
+ "host": host_info["name"],
426
+ "remotePath": remote_path,
427
+ "message": f"Failed to remove remote path '{remote_path}': {e}",
428
+ }
@@ -0,0 +1,168 @@
1
+ """
2
+ SSH Port Forwarding & Tunneling Manager for mcp-win-stdio-ssh.
3
+ Allows creating background local-to-remote tunnels using paramiko direct-tcpip channels.
4
+ """
5
+
6
+ import select
7
+ import socket
8
+ import socketserver
9
+ import threading
10
+ import time
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ import paramiko
14
+
15
+ from mcp_win_stdio.ssh.connection import get_cached_or_connect, resolve_host_info
16
+
17
+ _ACTIVE_TUNNELS: Dict[str, Dict[str, Any]] = {}
18
+ _TUNNEL_LOCK = threading.RLock()
19
+
20
+
21
+ class ForwardServer(socketserver.ThreadingTCPServer):
22
+ daemon_threads = True
23
+ allow_reuse_address = True
24
+
25
+
26
+ class ForwardHandler(socketserver.BaseRequestHandler):
27
+ def handle(self):
28
+ try:
29
+ chan = self.ssh_transport.open_channel(
30
+ "direct-tcpip",
31
+ (self.chain_host, self.chain_port),
32
+ self.request.getpeername(),
33
+ )
34
+ except Exception:
35
+ return
36
+
37
+ if chan is None:
38
+ return
39
+
40
+ while True:
41
+ r, w, x = select.select([self.request, chan], [], [])
42
+ if self.request in r:
43
+ data = self.request.recv(1024)
44
+ if len(data) == 0:
45
+ break
46
+ chan.send(data)
47
+ if chan in r:
48
+ data = chan.recv(1024)
49
+ if len(data) == 0:
50
+ break
51
+ self.request.send(data)
52
+
53
+ chan.close()
54
+ self.request.close()
55
+
56
+
57
+ def open_local_tunnel(
58
+ local_port: int,
59
+ remote_port: int,
60
+ remote_host: str = "localhost",
61
+ tunnel_name: Optional[str] = None,
62
+ host: Optional[str] = None,
63
+ ) -> Dict[str, Any]:
64
+ """Start local port forwarding tunnel in a background thread."""
65
+ host_info = resolve_host_info(host)
66
+ client = get_cached_or_connect(host_info["name"])
67
+ transport = client.get_transport()
68
+
69
+ if not transport or not transport.is_active():
70
+ raise ConnectionError(f"SSH connection to '{host_info['name']}' is not active.")
71
+
72
+ name = tunnel_name or f"tunnel-{local_port}-to-{remote_host}-{remote_port}"
73
+
74
+ with _TUNNEL_LOCK:
75
+ if name in _ACTIVE_TUNNELS:
76
+ return {
77
+ "success": True,
78
+ "message": f"Tunnel '{name}' is already active.",
79
+ "tunnel": _ACTIVE_TUNNELS[name]["info"],
80
+ }
81
+
82
+ class CustomHandler(ForwardHandler):
83
+ chain_host = remote_host
84
+ chain_port = remote_port
85
+ ssh_transport = transport
86
+
87
+ try:
88
+ server = ForwardServer(("127.0.0.1", local_port), CustomHandler)
89
+ except Exception as e:
90
+ raise OSError(f"Failed to bind local port {local_port}: {e}")
91
+
92
+ server_thread = threading.Thread(
93
+ target=server.serve_forever, daemon=True, name=f"SSHTunnel-{name}"
94
+ )
95
+ server_thread.start()
96
+
97
+ info = {
98
+ "name": name,
99
+ "localPort": local_port,
100
+ "localAddress": f"127.0.0.1:{local_port}",
101
+ "remoteHost": remote_host,
102
+ "remotePort": remote_port,
103
+ "sshHost": host_info["name"],
104
+ "status": "active",
105
+ "startedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
106
+ }
107
+
108
+ _ACTIVE_TUNNELS[name] = {
109
+ "server": server,
110
+ "thread": server_thread,
111
+ "info": info,
112
+ }
113
+
114
+ return {
115
+ "success": True,
116
+ "message": f"SSH Tunnel '{name}' established on 127.0.0.1:{local_port} -> {remote_host}:{remote_port} via {host_info['name']}.",
117
+ "tunnel": info,
118
+ }
119
+
120
+
121
+ def list_active_tunnels() -> List[Dict[str, Any]]:
122
+ """List all currently active SSH port forwarding tunnels."""
123
+ with _TUNNEL_LOCK:
124
+ return [entry["info"] for entry in _ACTIVE_TUNNELS.values()]
125
+
126
+
127
+ def close_tunnel(tunnel_id_or_name: str) -> Dict[str, Any]:
128
+ """Close an active port forwarding tunnel."""
129
+ with _TUNNEL_LOCK:
130
+ # Check by name or local port
131
+ target_key = None
132
+ for k, v in _ACTIVE_TUNNELS.items():
133
+ if k == tunnel_id_or_name or str(v["info"]["localPort"]) == str(tunnel_id_or_name):
134
+ target_key = k
135
+ break
136
+
137
+ if not target_key:
138
+ return {
139
+ "error": True,
140
+ "message": f"Tunnel '{tunnel_id_or_name}' not found. Active tunnels: {list(_ACTIVE_TUNNELS.keys())}",
141
+ }
142
+
143
+ entry = _ACTIVE_TUNNELS.pop(target_key)
144
+ try:
145
+ entry["server"].shutdown()
146
+ entry["server"].server_close()
147
+ except Exception:
148
+ pass
149
+
150
+ return {
151
+ "success": True,
152
+ "message": f"Tunnel '{target_key}' closed successfully.",
153
+ }
154
+
155
+
156
+ def close_all_tunnels() -> int:
157
+ """Close all open tunnels."""
158
+ closed = 0
159
+ with _TUNNEL_LOCK:
160
+ for k in list(_ACTIVE_TUNNELS.keys()):
161
+ entry = _ACTIVE_TUNNELS.pop(k)
162
+ try:
163
+ entry["server"].shutdown()
164
+ entry["server"].server_close()
165
+ closed += 1
166
+ except Exception:
167
+ pass
168
+ return closed
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.5
2
+ Name: mcp-win-stdio-ssh
3
+ Version: 0.2.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
+ Author: Mohan Kumar Indala
6
+ License-Expression: MIT
7
+ Keywords: ai,claude,docker,llm,mcp,port-forwarding,pty,remote-management,sftp,ssh,systemd,tunnels,windows
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: Win32 (MS Windows)
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: Microsoft :: Windows
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: System :: Systems Administration
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: cryptography>=3.3.0
22
+ Requires-Dist: mcp-win-stdio>=0.2.4
23
+ Requires-Dist: mcp>=1.2.0
24
+ Requires-Dist: paramiko>=3.0.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # mcp-win-stdio-ssh
28
+
29
+ > Advanced Multi-SSH Connection & Remote Management Model Context Protocol (MCP) Server for Windows & Claude Desktop / Claude Code CLI.
30
+
31
+ Part of the **[mcp-win-stdio](https://github.com/indala/mcp-win-stdio)** suite.
32
+
33
+ ---
34
+
35
+ ## 🌟 Key Features
36
+
37
+ - **Multi-Host Connection Pooling & Persistence**: Connect to unlimited remote servers. Automatically parses `~/.ssh/config` (aliases, identity files, JumpHosts) and persists active host preferences.
38
+ - **Sticky Active Host**: Run commands against an active default host or specify `host="server_name"` per tool call.
39
+ - **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`).
40
+ - **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.
41
+ - **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`).
42
+ - **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`).
43
+ - **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.
44
+
45
+ ---
46
+
47
+ ## 📦 Installation
48
+
49
+ ```bash
50
+ # Standalone package installation:
51
+ pip install mcp-win-stdio-ssh
52
+
53
+ # Or with full mcp-win-stdio suite:
54
+ pip install "mcp-win-stdio[all]"
55
+ ```
56
+
57
+ ---
58
+
59
+ ## 🚀 Quick Start & Claude Configuration
60
+
61
+ ### Claude Desktop (`%APPDATA%\Claude\claude_desktop_config.json`)
62
+
63
+ ```json
64
+ {
65
+ "mcpServers": {
66
+ "ssh": {
67
+ "command": "mws-ssh",
68
+ "args": ["run"]
69
+ }
70
+ }
71
+ }
72
+ ```
73
+
74
+ ### Claude Code CLI
75
+
76
+ ```bash
77
+ claude mcp add ssh mws-ssh run
78
+ ```
79
+
80
+ ---
81
+
82
+ ## 🛠️ CLI Utilities
83
+
84
+ ```bash
85
+ # Open interactive diagnostic CLI & host manager
86
+ mws-ssh
87
+
88
+ # List configured SSH hosts and test latency
89
+ mws-ssh test
90
+
91
+ # Launch MCP stdio server
92
+ mws-ssh run
93
+
94
+ # View comprehensive prompt recipes & tool documentation
95
+ mws-ssh guide
96
+ ```