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.
- mcp_win_stdio/ssh/__init__.py +5 -0
- mcp_win_stdio/ssh/__main__.py +8 -0
- mcp_win_stdio/ssh/cli.py +152 -0
- mcp_win_stdio/ssh/connection.py +369 -0
- mcp_win_stdio/ssh/guide.py +94 -0
- mcp_win_stdio/ssh/pty_session.py +215 -0
- mcp_win_stdio/ssh/server.py +1380 -0
- mcp_win_stdio/ssh/sftp_ops.py +428 -0
- mcp_win_stdio/ssh/tunnels.py +168 -0
- mcp_win_stdio_ssh-0.2.4.dist-info/METADATA +96 -0
- mcp_win_stdio_ssh-0.2.4.dist-info/RECORD +13 -0
- mcp_win_stdio_ssh-0.2.4.dist-info/WHEEL +4 -0
- mcp_win_stdio_ssh-0.2.4.dist-info/entry_points.txt +3 -0
|
@@ -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
|
+
```
|