mcp-win-stdio-ssh 0.2.4__tar.gz → 0.2.5__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.
@@ -23,6 +23,9 @@ MANIFEST
23
23
 
24
24
  *.manifest
25
25
  *.spec
26
+ api.txt
27
+ pypi-api.txt
28
+
26
29
 
27
30
  pip-log.txt
28
31
  pip-delete-this-directory.txt
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcp-win-stdio-ssh
3
- Version: 0.2.4
3
+ Version: 0.2.5
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.2.4
22
+ Requires-Dist: mcp-win-stdio>=0.2.5
23
23
  Requires-Dist: mcp>=1.2.0
24
24
  Requires-Dist: paramiko>=3.0.0
25
25
  Description-Content-Type: text/markdown
@@ -1,44 +1,44 @@
1
- [build-system]
2
- requires = ["hatchling"]
3
- build-backend = "hatchling.build"
4
-
5
- [project]
6
- name = "mcp-win-stdio-ssh"
7
- version = "0.2.4"
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
- readme = "README.md"
10
-
11
- requires-python = ">=3.10"
12
- license = "MIT"
13
- authors = [
14
- { name = "Mohan Kumar Indala" }
15
- ]
16
- keywords = ["mcp", "claude", "ssh", "sftp", "pty", "tunnels", "port-forwarding", "remote-management", "systemd", "docker", "ai", "llm", "windows"]
17
- classifiers = [
18
- "Development Status :: 4 - Beta",
19
- "Environment :: Win32 (MS Windows)",
20
- "Intended Audience :: Developers",
21
- "License :: OSI Approved :: MIT License",
22
- "Operating System :: Microsoft :: Windows",
23
- "Programming Language :: Python :: 3",
24
- "Programming Language :: Python :: 3.10",
25
- "Programming Language :: Python :: 3.11",
26
- "Programming Language :: Python :: 3.12",
27
- "Programming Language :: Python :: 3.13",
28
- "Programming Language :: Python :: 3.14",
29
- "Topic :: System :: Systems Administration",
30
- ]
31
-
32
- dependencies = [
33
- "mcp-win-stdio>=0.2.4",
34
- "mcp>=1.2.0",
35
- "paramiko>=3.0.0",
36
- "cryptography>=3.3.0",
37
- ]
38
-
39
- [project.scripts]
40
- mcp-win-stdio-ssh = "mcp_win_stdio.ssh.cli:main"
41
- mws-ssh = "mcp_win_stdio.ssh.cli:main"
42
-
43
- [tool.hatch.build.targets.wheel]
44
- packages = ["src/mcp_win_stdio"]
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mcp-win-stdio-ssh"
7
+ version = "0.2.5"
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
+ readme = "README.md"
10
+
11
+ requires-python = ">=3.10"
12
+ license = "MIT"
13
+ authors = [
14
+ { name = "Mohan Kumar Indala" }
15
+ ]
16
+ keywords = ["mcp", "claude", "ssh", "sftp", "pty", "tunnels", "port-forwarding", "remote-management", "systemd", "docker", "ai", "llm", "windows"]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Environment :: Win32 (MS Windows)",
20
+ "Intended Audience :: Developers",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Operating System :: Microsoft :: Windows",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: 3.14",
29
+ "Topic :: System :: Systems Administration",
30
+ ]
31
+
32
+ dependencies = [
33
+ "mcp-win-stdio>=0.2.5",
34
+ "mcp>=1.2.0",
35
+ "paramiko>=3.0.0",
36
+ "cryptography>=3.3.0",
37
+ ]
38
+
39
+ [project.scripts]
40
+ mcp-win-stdio-ssh = "mcp_win_stdio.ssh.cli:main"
41
+ mws-ssh = "mcp_win_stdio.ssh.cli:main"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/mcp_win_stdio"]
@@ -1,5 +1,5 @@
1
- """
2
- Advanced Multi-SSH Connection & Remote Management MCP Server for mcp-win-stdio.
3
- """
4
-
5
- __version__ = "0.2.4"
1
+ """
2
+ Advanced Multi-SSH Connection & Remote Management MCP Server for mcp-win-stdio.
3
+ """
4
+
5
+ __version__ = "0.2.5"
@@ -3,12 +3,10 @@ CLI entry point for mcp-win-stdio-ssh.
3
3
  """
4
4
 
5
5
  import argparse
6
- import os
7
- from pathlib import Path
8
6
  import shutil
9
- import subprocess
10
7
  import sys
11
8
  import time
9
+ from pathlib import Path
12
10
 
13
11
  # Ensure Windows console uses UTF-8 without crashing on cp1252
14
12
  if sys.platform == "win32":
@@ -21,10 +19,10 @@ if sys.platform == "win32":
21
19
  from mcp_win_stdio.ssh import __version__
22
20
  from mcp_win_stdio.ssh.connection import (
23
21
  SYSTEM_SSH_CONFIG,
24
- get_all_registered_hosts,
25
22
  get_active_host_name,
26
- resolve_host_info,
23
+ get_all_registered_hosts,
27
24
  get_cached_or_connect,
25
+ resolve_host_info,
28
26
  )
29
27
  from mcp_win_stdio.ssh.guide import print_ssh_guide
30
28
  from mcp_win_stdio.ssh.server import mcp
@@ -54,7 +52,7 @@ def cmd_doctor(args: argparse.Namespace) -> None:
54
52
  if ssh_bin:
55
53
  print(f"[OK] System SSH CLI: {ssh_bin}")
56
54
  else:
57
- print(f"[INFO] System SSH CLI: Not on PATH (Optional: Paramiko native SSH client active)")
55
+ print("[INFO] System SSH CLI: Not on PATH (Optional: Paramiko native SSH client active)")
58
56
 
59
57
  # 3. SSH Config & Keys
60
58
  ssh_dir = Path.home() / ".ssh"
@@ -63,13 +61,13 @@ def cmd_doctor(args: argparse.Namespace) -> None:
63
61
  if SYSTEM_SSH_CONFIG.exists():
64
62
  print(f"[OK] SSH Config: {SYSTEM_SSH_CONFIG}")
65
63
  else:
66
- print(f"[INFO] SSH Config: No ~/.ssh/config found (can use add_host or create config)")
64
+ print("[INFO] SSH Config: No ~/.ssh/config found (can use add_host or create config)")
67
65
 
68
66
  keys = [f.name for f in ssh_dir.iterdir() if f.is_file() and not f.name.endswith(".pub") and "id_" in f.name]
69
67
  if keys:
70
68
  print(f"[OK] Found Keys: {', '.join(keys)}")
71
69
  else:
72
- print(f"[INFO] Found Keys: No default id_* keys found in ~/.ssh/")
70
+ print("[INFO] Found Keys: No default id_* keys found in ~/.ssh/")
73
71
  else:
74
72
  print(f"[INFO] SSH Directory: {ssh_dir} not created yet.")
75
73
 
@@ -107,7 +105,9 @@ def cmd_test(args: argparse.Namespace) -> None:
107
105
  stdin, stdout, stderr = client.exec_command("uname -srmo 2>/dev/null || ver", timeout=5)
108
106
  os_info = stdout.read().decode("utf-8", errors="replace").strip()
109
107
 
110
- print(f" [OK] {h_name:<16} -> {h_info.get('user')}@{h_info.get('hostname')}:{h_info.get('port')} ({lat_ms}ms) | {os_info}")
108
+ print(
109
+ f" [OK] {h_name:<16} -> {h_info.get('user')}@{h_info.get('hostname')}:{h_info.get('port')} ({lat_ms}ms) | {os_info}"
110
+ )
111
111
  except Exception as e:
112
112
  print(f" [FAIL] {h_name:<14} -> Error: {e}")
113
113
 
@@ -6,13 +6,10 @@ bastion/jump host tunneling, and resilient connection pooling.
6
6
 
7
7
  import json
8
8
  import os
9
- from pathlib import Path
10
- import re
11
- import socket
12
9
  import sys
13
10
  import threading
14
- import time
15
- from typing import Any, Dict, List, Optional, Tuple, Union
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional
16
13
 
17
14
  import paramiko
18
15
  from paramiko.config import SSHConfig
@@ -102,7 +99,7 @@ def parse_system_ssh_config() -> Dict[str, Dict[str, Any]]:
102
99
  entry = ssh_cfg.lookup(host)
103
100
  identity_files = entry.get("identityfile", [])
104
101
  key_path = identity_files[0] if identity_files else None
105
-
102
+
106
103
  # Resolve ~ in key path
107
104
  if key_path:
108
105
  key_path = str(Path(key_path).expanduser())
@@ -203,8 +200,7 @@ def resolve_host_info(target_name: Optional[str] = None) -> Dict[str, Any]:
203
200
  set_active_host_name(first_name)
204
201
  return registered[first_name]
205
202
  raise ValueError(
206
- "No SSH host specified and no active host is configured. "
207
- "Use 'add_host' or provide 'host' parameter."
203
+ "No SSH host specified and no active host is configured. Use 'add_host' or provide 'host' parameter."
208
204
  )
209
205
 
210
206
  raise ValueError(f"SSH host '{host_name}' not found. Available hosts: {list(registered.keys())}")
@@ -314,9 +310,7 @@ def get_cached_or_connect(
314
310
  pass
315
311
  del _CLIENT_POOL[name]
316
312
 
317
- client = create_ssh_client(
318
- host_info, password=password, passphrase=passphrase, timeout=timeout
319
- )
313
+ client = create_ssh_client(host_info, password=password, passphrase=passphrase, timeout=timeout)
320
314
  _CLIENT_POOL[name] = client
321
315
  return client
322
316
 
@@ -360,10 +354,12 @@ def get_pool_status() -> List[Dict[str, Any]]:
360
354
  for name, client in _CLIENT_POOL.items():
361
355
  transport = client.get_transport()
362
356
  is_active = transport.is_active() if transport else False
363
- status.append({
364
- "name": name,
365
- "isActive": is_active,
366
- "remoteAddress": f"{transport.getpeername()}" if (transport and is_active) else "closed",
367
- "isCurrentDefault": name == get_active_host_name(),
368
- })
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
+ )
369
365
  return status
@@ -2,6 +2,7 @@
2
2
  Comprehensive guide, tool reference, and Claude prompt recipes for mcp-win-stdio-ssh.
3
3
  """
4
4
 
5
+
5
6
  def print_ssh_guide() -> None:
6
7
  """Print complete SSH MCP tool reference and workflow recipes."""
7
8
  guide_text = """
@@ -4,13 +4,11 @@ Maintains long-lived interactive shell sessions for REPLs, prompts, and CLI wiza
4
4
  """
5
5
 
6
6
  import re
7
- import select
8
7
  import threading
9
8
  import time
10
9
  from typing import Any, Dict, List, Optional
11
10
 
12
11
  import paramiko
13
-
14
12
  from mcp_win_stdio.ssh.connection import get_cached_or_connect, resolve_host_info
15
13
 
16
14
  _PTY_SESSIONS: Dict[str, Dict[str, Any]] = {}
@@ -171,12 +169,14 @@ def list_pty_sessions() -> List[Dict[str, Any]]:
171
169
  with _PTY_LOCK:
172
170
  for name, sess in _PTY_SESSIONS.items():
173
171
  chan = sess["channel"]
174
- res.append({
175
- "sessionName": name,
176
- "host": sess["host"],
177
- "createdAt": sess["createdAt"],
178
- "isActive": not chan.closed,
179
- })
172
+ res.append(
173
+ {
174
+ "sessionName": name,
175
+ "host": sess["host"],
176
+ "createdAt": sess["createdAt"],
177
+ "isActive": not chan.closed,
178
+ }
179
+ )
180
180
  return res
181
181
 
182
182
 
@@ -7,13 +7,10 @@ service/process management, and local port forwarding tunnels.
7
7
 
8
8
  import json
9
9
  import os
10
- from pathlib import Path
11
10
  import re
12
11
  import shlex
13
- import sys
14
- import tempfile
15
12
  import time
16
- from typing import Any, Dict, List, Literal, Optional, Union
13
+ from typing import Any, Dict, Literal, Optional, Union
17
14
 
18
15
  try:
19
16
  from mcp.server.mcpserver import MCPServer as FastMCP
@@ -21,10 +18,7 @@ except (ImportError, ModuleNotFoundError):
21
18
  from mcp.server.fastmcp import FastMCP
22
19
 
23
20
  import paramiko
24
-
25
21
  from mcp_win_stdio.ssh.connection import (
26
- _normalize_host_param,
27
- close_all_connections,
28
22
  close_connection,
29
23
  get_active_host_name,
30
24
  get_all_registered_hosts,
@@ -36,7 +30,6 @@ from mcp_win_stdio.ssh.connection import (
36
30
  set_active_host_name,
37
31
  )
38
32
  from mcp_win_stdio.ssh.pty_session import (
39
- close_all_pty_sessions,
40
33
  close_pty_session,
41
34
  list_pty_sessions,
42
35
  read_pty_buffer,
@@ -54,7 +47,6 @@ from mcp_win_stdio.ssh.sftp_ops import (
54
47
  write_remote_text_file,
55
48
  )
56
49
  from mcp_win_stdio.ssh.tunnels import (
57
- close_all_tunnels,
58
50
  close_tunnel,
59
51
  list_active_tunnels,
60
52
  open_local_tunnel,
@@ -67,6 +59,7 @@ mcp = FastMCP("ssh-mcp")
67
59
  # Helper Functions
68
60
  # ==============================================================================
69
61
 
62
+
70
63
  def _format_ssh_error(e: Exception, host: Optional[str] = None, command: Optional[str] = None) -> Dict[str, Any]:
71
64
  """Format SSH exception into clean, structured diagnostics with helpful advice."""
72
65
  error_payload = {
@@ -155,7 +148,7 @@ def _run_exec_channel(
155
148
 
156
149
  start_t = time.time()
157
150
  stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout, get_pty=False)
158
-
151
+
159
152
  # Read output
160
153
  out_str = stdout.read().decode("utf-8", errors="replace")
161
154
  err_str = stderr.read().decode("utf-8", errors="replace")
@@ -183,6 +176,7 @@ def _run_exec_channel(
183
176
  # 1. Host & Connection Management Tools
184
177
  # ==============================================================================
185
178
 
179
+
186
180
  @mcp.tool()
187
181
  def list_hosts() -> Dict[str, Any]:
188
182
  """
@@ -196,21 +190,23 @@ def list_hosts() -> Dict[str, Any]:
196
190
 
197
191
  results = []
198
192
  for name, info in all_hosts.items():
199
- is_active = (name == active_name)
193
+ is_active = name == active_name
200
194
  in_pool = pool_status.get(name, {})
201
195
 
202
- results.append({
203
- "name": name,
204
- "hostname": info.get("hostname", "localhost"),
205
- "user": info.get("user", "root"),
206
- "port": info.get("port", 22),
207
- "keyPath": info.get("key_path"),
208
- "hasPassword": bool(info.get("password")),
209
- "jumpHost": info.get("jump_host") or info.get("proxyjump"),
210
- "source": info.get("source", "config"),
211
- "isActiveDefault": is_active,
212
- "poolConnection": "connected" if in_pool.get("isActive") else "idle",
213
- })
196
+ results.append(
197
+ {
198
+ "name": name,
199
+ "hostname": info.get("hostname", "localhost"),
200
+ "user": info.get("user", "root"),
201
+ "port": info.get("port", 22),
202
+ "keyPath": info.get("key_path"),
203
+ "hasPassword": bool(info.get("password")),
204
+ "jumpHost": info.get("jump_host") or info.get("proxyjump"),
205
+ "source": info.get("source", "config"),
206
+ "isActiveDefault": is_active,
207
+ "poolConnection": "connected" if in_pool.get("isActive") else "idle",
208
+ }
209
+ )
214
210
 
215
211
  return {
216
212
  "activeHost": active_name,
@@ -224,7 +220,7 @@ def use_host(host: str) -> Dict[str, Any]:
224
220
  """
225
221
  Switch the active default SSH host context. All subsequent SSH, SFTP, and diagnostic tool calls
226
222
  will target this host automatically when host is not specified.
227
-
223
+
228
224
  Args:
229
225
  host: Host alias, hostname, or user@hostname:port to switch to.
230
226
  """
@@ -241,7 +237,7 @@ def use_host(host: str) -> Dict[str, Any]:
241
237
  "user": info.get("user"),
242
238
  "port": info.get("port"),
243
239
  "source": info.get("source"),
244
- }
240
+ },
245
241
  }
246
242
  except Exception as e:
247
243
  return _format_ssh_error(e, host=host)
@@ -261,7 +257,7 @@ def add_host(
261
257
  ) -> Dict[str, Any]:
262
258
  """
263
259
  Register and persist a new SSH host configuration to ~/.mcp-win-stdio/ssh_hosts.json.
264
-
260
+
265
261
  Args:
266
262
  name: Unique alias name for the host (e.g. 'prod-web-01', 'db-cluster', 'staging').
267
263
  hostname: IP address or domain name of the remote server.
@@ -311,7 +307,7 @@ def add_host(
311
307
  def remove_host(name: str) -> Dict[str, Any]:
312
308
  """
313
309
  Remove a saved SSH host configuration from ~/.mcp-win-stdio/ssh_hosts.json.
314
-
310
+
315
311
  Args:
316
312
  name: Name of the host to delete.
317
313
  """
@@ -347,7 +343,7 @@ def remove_host(name: str) -> Dict[str, Any]:
347
343
  def test_host(host: Optional[str] = None) -> Dict[str, Any]:
348
344
  """
349
345
  Test SSH connectivity, authentication, latency, and retrieve remote OS info.
350
-
346
+
351
347
  Args:
352
348
  host: Host alias, hostname, or user@hostname:port (defaults to active host).
353
349
  """
@@ -388,7 +384,7 @@ def list_active_connections() -> Dict[str, Any]:
388
384
  def disconnect_host(host: Optional[str] = None) -> Dict[str, Any]:
389
385
  """
390
386
  Close and disconnect the SSH connection for a specific host from the connection pool.
391
-
387
+
392
388
  Args:
393
389
  host: Host name to disconnect (or active host if omitted).
394
390
  """
@@ -404,6 +400,7 @@ def disconnect_host(host: Optional[str] = None) -> Dict[str, Any]:
404
400
  # 2. Remote Command & Script Execution Tools
405
401
  # ==============================================================================
406
402
 
403
+
407
404
  @mcp.tool()
408
405
  def ssh_exec(
409
406
  command: str,
@@ -415,7 +412,7 @@ def ssh_exec(
415
412
  """
416
413
  Execute a non-interactive shell command on the remote SSH host.
417
414
  Returns exit code, stdout, stderr, execution duration, and structured results.
418
-
415
+
419
416
  Args:
420
417
  command: The shell command line string to run.
421
418
  host: Target SSH host (defaults to active host).
@@ -442,7 +439,7 @@ def ssh_exec_sudo(
442
439
  ) -> Dict[str, Any]:
443
440
  """
444
441
  Execute a command with elevated sudo privileges, automatically handling the sudo password prompt if needed.
445
-
442
+
446
443
  Args:
447
444
  command: Command to execute with sudo (e.g. 'systemctl restart nginx', 'apt update').
448
445
  sudo_password: Password for sudo prompt (if omitted, uses host password or passwordless sudo).
@@ -461,7 +458,9 @@ def ssh_exec_sudo(
461
458
 
462
459
  start_t = time.time()
463
460
  # Request PTY for sudo prompt handling
464
- stdin, stdout, stderr = client.exec_command(f"sudo -S -p '[SUDO_PROMPT]' {clean_cmd}", get_pty=True, timeout=timeout)
461
+ stdin, stdout, stderr = client.exec_command(
462
+ f"sudo -S -p '[SUDO_PROMPT]' {clean_cmd}", get_pty=True, timeout=timeout
463
+ )
465
464
 
466
465
  if pwd:
467
466
  # Send password when prompt requested
@@ -497,7 +496,7 @@ def ssh_exec_script(
497
496
  ) -> Dict[str, Any]:
498
497
  """
499
498
  Upload and execute a multi-line script (bash, sh, python, node) on the remote server, returning execution results.
500
-
499
+
501
500
  Args:
502
501
  script_content: Full multi-line script code.
503
502
  interpreter: Interpreter to run script with ('bash', 'sh', 'python3', 'node', 'pwsh').
@@ -526,7 +525,7 @@ def ssh_exec_script(
526
525
  # Execute
527
526
  run_cmd = f"{interpreter} {shlex.quote(remote_tmp)}"
528
527
  res = _run_exec_channel(client, run_cmd, timeout=timeout)
529
-
528
+
530
529
  # Cleanup
531
530
  try:
532
531
  client.exec_command(f"rm -f {shlex.quote(remote_tmp)}")
@@ -549,7 +548,7 @@ def ssh_exec_background(
549
548
  ) -> Dict[str, Any]:
550
549
  """
551
550
  Launch a long-running process in the detached background (using nohup) and track its Process ID (PID).
552
-
551
+
553
552
  Args:
554
553
  command: Long-running command (e.g. 'npm run start', 'python train.py', 'backup.sh').
555
554
  job_name: Optional label for the job.
@@ -590,7 +589,7 @@ def ssh_exec_background(
590
589
  def ssh_check_job(job_id_or_pid: Union[int, str], host: Optional[str] = None) -> Dict[str, Any]:
591
590
  """
592
591
  Check if a detached background job/PID is still running and read the latest log output.
593
-
592
+
594
593
  Args:
595
594
  job_id_or_pid: The PID or job name to check.
596
595
  host: Target SSH host (defaults to active host).
@@ -627,7 +626,7 @@ def ssh_check_job(job_id_or_pid: Union[int, str], host: Optional[str] = None) ->
627
626
  def ssh_kill_job(pid: int, signal: str = "SIGTERM", host: Optional[str] = None) -> Dict[str, Any]:
628
627
  """
629
628
  Terminate a remote process by PID.
630
-
629
+
631
630
  Args:
632
631
  pid: Remote process ID.
633
632
  signal: Signal name ('SIGTERM', 'SIGKILL', 'SIGHUP', 'SIGINT').
@@ -645,7 +644,9 @@ def ssh_kill_job(pid: int, signal: str = "SIGTERM", host: Optional[str] = None)
645
644
  "host": host_info["name"],
646
645
  "pid": pid,
647
646
  "signal": signal,
648
- "message": f"Sent {signal} to PID {pid}." if res["is_success"] else f"Failed to kill PID {pid}: {res['stderr']}",
647
+ "message": f"Sent {signal} to PID {pid}."
648
+ if res["is_success"]
649
+ else f"Failed to kill PID {pid}: {res['stderr']}",
649
650
  }
650
651
  except Exception as e:
651
652
  return _format_ssh_error(e, host=host)
@@ -655,6 +656,7 @@ def ssh_kill_job(pid: int, signal: str = "SIGTERM", host: Optional[str] = None)
655
656
  # 3. Interactive PTY / Pseudo-Terminal Tools
656
657
  # ==============================================================================
657
658
 
659
+
658
660
  @mcp.tool()
659
661
  def ssh_pty_start(
660
662
  session_name: str,
@@ -663,7 +665,7 @@ def ssh_pty_start(
663
665
  ) -> Dict[str, Any]:
664
666
  """
665
667
  Start an interactive pseudo-terminal (PTY) session for stateful interactions, REPLs, and prompts.
666
-
668
+
667
669
  Args:
668
670
  session_name: Unique identifier for this terminal session (e.g. 'wizard', 'python-repl').
669
671
  host: Target SSH host (defaults to active host).
@@ -683,7 +685,7 @@ def ssh_pty_send(
683
685
  ) -> Dict[str, Any]:
684
686
  """
685
687
  Send keystrokes, answers, or commands into an active interactive PTY session and collect output.
686
-
688
+
687
689
  Args:
688
690
  session_name: Name of active PTY session.
689
691
  input_text: Text/command to send.
@@ -699,7 +701,7 @@ def ssh_pty_send(
699
701
  def ssh_pty_read(session_name: str, max_chars: int = 4000) -> Dict[str, Any]:
700
702
  """
701
703
  Read the output buffer of an active interactive PTY session.
702
-
704
+
703
705
  Args:
704
706
  session_name: Name of active PTY session.
705
707
  max_chars: Maximum character limit for output.
@@ -724,7 +726,7 @@ def ssh_list_pty_sessions() -> Dict[str, Any]:
724
726
  def ssh_pty_close(session_name: str) -> Dict[str, Any]:
725
727
  """
726
728
  Close and terminate an interactive PTY session.
727
-
729
+
728
730
  Args:
729
731
  session_name: Name of the session to terminate.
730
732
  """
@@ -735,12 +737,13 @@ def ssh_pty_close(session_name: str) -> Dict[str, Any]:
735
737
  # 4. Diagnostics & Remote Services Management
736
738
  # ==============================================================================
737
739
 
740
+
738
741
  @mcp.tool()
739
742
  def ssh_system_overview(host: Optional[str] = None) -> Dict[str, Any]:
740
743
  """
741
744
  Retrieve comprehensive system diagnostics: OS version, kernel, CPU count, RAM utilization,
742
745
  load averages, uptime, and disk usage (df -h).
743
-
746
+
744
747
  Args:
745
748
  host: Target SSH host (defaults to active host).
746
749
  """
@@ -794,7 +797,9 @@ cat /etc/os-release 2>/dev/null || echo "N/A"
794
797
 
795
798
  @mcp.tool()
796
799
  def ssh_list_packages(
797
- package_manager: Optional[Literal["apt", "dpkg", "rpm", "dnf", "yum", "pip", "npm", "brew", "pacman", "apk", "winget"]] = None,
800
+ package_manager: Optional[
801
+ Literal["apt", "dpkg", "rpm", "dnf", "yum", "pip", "npm", "brew", "pacman", "apk", "winget"]
802
+ ] = None,
798
803
  filter: Optional[str] = None,
799
804
  limit: int = 50,
800
805
  offset: int = 0,
@@ -803,7 +808,7 @@ def ssh_list_packages(
803
808
  """
804
809
  List installed software packages on the remote server across Linux/macOS/Windows package managers.
805
810
  Features automatic package manager detection, token-safe pagination, and filtering to prevent context bloat.
806
-
811
+
807
812
  Args:
808
813
  package_manager: Package manager to query (auto-detected if None: 'dpkg'/'apt', 'rpm'/'dnf'/'yum', 'pip', 'npm', 'brew', 'pacman', 'apk', 'winget').
809
814
  filter: Optional keyword or pattern filter on package name or description.
@@ -856,7 +861,7 @@ fi
856
861
  query_cmd = "winget list 2>/dev/null"
857
862
  elif pm == "npm":
858
863
  query_cmd = "npm list -g --depth=0 --json 2>/dev/null || npm list -g --depth=0"
859
- else: # pip
864
+ else: # pip
860
865
  query_cmd = "pip list --format=json 2>/dev/null || pip list"
861
866
 
862
867
  stdin, stdout, stderr = client.exec_command(query_cmd, timeout=20)
@@ -881,7 +886,13 @@ fi
881
886
  if not packages and raw_out:
882
887
  for line in raw_out.splitlines():
883
888
  line_str = line.strip()
884
- if not line_str or line_str.startswith("Desired=") or line_str.startswith("|") or line_str.startswith("Name ") or line_str.startswith("---"):
889
+ if (
890
+ not line_str
891
+ or line_str.startswith("Desired=")
892
+ or line_str.startswith("|")
893
+ or line_str.startswith("Name ")
894
+ or line_str.startswith("---")
895
+ ):
885
896
  continue
886
897
 
887
898
  parts = re.split(r"\t+|\s{2,}", line_str)
@@ -899,7 +910,11 @@ fi
899
910
  pkg_ver = ""
900
911
  pkg_summary = ""
901
912
 
902
- if clean_filter and (clean_filter not in pkg_name.lower() and clean_filter not in pkg_summary.lower() and clean_filter not in pkg_ver.lower()):
913
+ if clean_filter and (
914
+ clean_filter not in pkg_name.lower()
915
+ and clean_filter not in pkg_summary.lower()
916
+ and clean_filter not in pkg_ver.lower()
917
+ ):
903
918
  continue
904
919
 
905
920
  entry = {"name": pkg_name, "version": pkg_ver}
@@ -948,7 +963,7 @@ def ssh_list_services(
948
963
  ) -> Dict[str, Any]:
949
964
  """
950
965
  Inspect running remote services across systemd units, Docker containers, or PM2 node processes with pagination.
951
-
966
+
952
967
  Args:
953
968
  service_type: Service framework ('systemd', 'docker', 'pm2').
954
969
  filter: Optional keyword or pattern filter.
@@ -1015,7 +1030,7 @@ def ssh_service_action(
1015
1030
  ) -> Dict[str, Any]:
1016
1031
  """
1017
1032
  Manage a remote daemon or container (status, start, stop, restart, reload).
1018
-
1033
+
1019
1034
  Args:
1020
1035
  service_name: Name of the service unit (e.g. 'nginx', 'postgresql', 'my-container').
1021
1036
  action: Lifecycle action to take.
@@ -1028,7 +1043,11 @@ def ssh_service_action(
1028
1043
 
1029
1044
  s_name = shlex.quote(service_name)
1030
1045
  if service_type == "systemd":
1031
- cmd = f"sudo systemctl {action} {s_name} --no-pager" if action != "status" else f"systemctl status {s_name} --no-pager"
1046
+ cmd = (
1047
+ f"sudo systemctl {action} {s_name} --no-pager"
1048
+ if action != "status"
1049
+ else f"systemctl status {s_name} --no-pager"
1050
+ )
1032
1051
  elif service_type == "docker":
1033
1052
  cmd = f"docker {action} {s_name}"
1034
1053
  else:
@@ -1057,7 +1076,7 @@ def ssh_tail_logs(
1057
1076
  """
1058
1077
  Tail remote log files (e.g. /var/log/syslog, /var/log/nginx/error.log) or systemd journal logs.
1059
1078
  Features safety caps on line count and character length to prevent context bloat.
1060
-
1079
+
1061
1080
  Args:
1062
1081
  target: Log file path (e.g. '/var/log/syslog') or systemd service unit name if is_journal=True.
1063
1082
  lines: Number of trailing lines to return (default: 50, max: 200).
@@ -1099,7 +1118,7 @@ def ssh_list_processes(
1099
1118
  ) -> Dict[str, Any]:
1100
1119
  """
1101
1120
  List top remote processes sorted by CPU or Memory usage with token-safe limits.
1102
-
1121
+
1103
1122
  Args:
1104
1123
  filter: Optional process name or command filter.
1105
1124
  sort_by: Sort metric ('cpu' or 'mem').
@@ -1160,6 +1179,7 @@ def ssh_list_processes(
1160
1179
  # 5. SFTP Remote File Operations Tools
1161
1180
  # ==============================================================================
1162
1181
 
1182
+
1163
1183
  @mcp.tool()
1164
1184
  def sftp_list_dir(
1165
1185
  remote_path: str = ".",
@@ -1170,7 +1190,7 @@ def sftp_list_dir(
1170
1190
  ) -> Dict[str, Any]:
1171
1191
  """
1172
1192
  List contents of a remote directory with file types, sizes, permissions, timestamps, and context window protection.
1173
-
1193
+
1174
1194
  Args:
1175
1195
  remote_path: Remote directory path (default: current directory '.').
1176
1196
  limit: Max items to return per batch (default 100, max 250).
@@ -1193,7 +1213,7 @@ def sftp_read_file(
1193
1213
  ) -> Dict[str, Any]:
1194
1214
  """
1195
1215
  Read text/source code from a remote file with line offset support and token safety.
1196
-
1216
+
1197
1217
  Args:
1198
1218
  remote_path: Remote file path.
1199
1219
  max_chars: Maximum characters to return (default: 15,000).
@@ -1220,7 +1240,7 @@ def sftp_write_file(
1220
1240
  ) -> Dict[str, Any]:
1221
1241
  """
1222
1242
  Write or append text content to a remote file via SFTP.
1223
-
1243
+
1224
1244
  Args:
1225
1245
  remote_path: Remote destination path.
1226
1246
  content: Text content to write.
@@ -1245,7 +1265,7 @@ def sftp_stat(
1245
1265
  ) -> Dict[str, Any]:
1246
1266
  """
1247
1267
  Inspect detailed file/directory metadata, size, permissions, uid/gid, and timestamps.
1248
-
1268
+
1249
1269
  Args:
1250
1270
  remote_path: Remote file or directory path.
1251
1271
  host: Target SSH host (defaults to active host).
@@ -1264,7 +1284,7 @@ def sftp_upload(
1264
1284
  ) -> Dict[str, Any]:
1265
1285
  """
1266
1286
  Upload a local file or entire folder to the remote host.
1267
-
1287
+
1268
1288
  Args:
1269
1289
  local_path: Local path on machine (file or folder).
1270
1290
  remote_path: Remote destination path.
@@ -1284,7 +1304,7 @@ def sftp_download(
1284
1304
  ) -> Dict[str, Any]:
1285
1305
  """
1286
1306
  Download a remote file or folder to the local machine.
1287
-
1307
+
1288
1308
  Args:
1289
1309
  remote_path: Remote source path.
1290
1310
  local_path: Local destination path.
@@ -1304,7 +1324,7 @@ def sftp_remove(
1304
1324
  ) -> Dict[str, Any]:
1305
1325
  """
1306
1326
  Delete a remote file or directory.
1307
-
1327
+
1308
1328
  Args:
1309
1329
  remote_path: Remote path to remove.
1310
1330
  recursive: If True, recursively deletes non-empty directories.
@@ -1320,6 +1340,7 @@ def sftp_remove(
1320
1340
  # 6. Port Forwarding & Tunnels Tools
1321
1341
  # ==============================================================================
1322
1342
 
1343
+
1323
1344
  @mcp.tool()
1324
1345
  def ssh_tunnel_open(
1325
1346
  local_port: int,
@@ -1331,7 +1352,7 @@ def ssh_tunnel_open(
1331
1352
  """
1332
1353
  Establish a local-to-remote SSH port forwarding tunnel in the background.
1333
1354
  Allows accessing remote databases, APIs, or services via 127.0.0.1:<local_port>.
1334
-
1355
+
1335
1356
  Args:
1336
1357
  local_port: Local port to bind on machine (e.g. 15432, 13306, 8080).
1337
1358
  remote_port: Remote target port on the server (e.g. 5432, 3306, 80).
@@ -1365,7 +1386,7 @@ def ssh_tunnel_list() -> Dict[str, Any]:
1365
1386
  def ssh_tunnel_close(tunnel_id_or_name: str) -> Dict[str, Any]:
1366
1387
  """
1367
1388
  Close and terminate an active SSH port forwarding tunnel.
1368
-
1389
+
1369
1390
  Args:
1370
1391
  tunnel_id_or_name: Tunnel name or local port number.
1371
1392
  """
@@ -3,16 +3,14 @@ SFTP File Operations and Directory Traversal for mcp-win-stdio-ssh.
3
3
  Provides token-safe remote file reading, streaming writing, metadata inspection, and sync operations.
4
4
  """
5
5
 
6
- from datetime import datetime
7
6
  import os
8
- from pathlib import Path
9
7
  import stat
10
8
  import threading
11
- import time
12
- from typing import Any, Dict, List, Optional, Tuple, Union
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Optional, Tuple
13
12
 
14
13
  import paramiko
15
-
16
14
  from mcp_win_stdio.ssh.connection import get_cached_or_connect, resolve_host_info
17
15
 
18
16
  _SFTP_CLIENTS: Dict[str, paramiko.SFTPClient] = {}
@@ -56,7 +54,7 @@ def list_remote_directory(
56
54
  ) -> Dict[str, Any]:
57
55
  """
58
56
  List contents of a remote directory with detailed metadata and context window protection.
59
-
57
+
60
58
  Args:
61
59
  remote_path: Target directory path on remote server.
62
60
  limit: Max items to return per batch (default 100, max 250).
@@ -85,16 +83,20 @@ def list_remote_directory(
85
83
  is_symlink = stat.S_ISLNK(attr.st_mode)
86
84
  mtime_dt = datetime.fromtimestamp(attr.st_mtime) if attr.st_mtime else None
87
85
 
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
- })
86
+ items.append(
87
+ {
88
+ "name": attr.filename,
89
+ "type": "directory" if is_dir else ("symlink" if is_symlink else "file"),
90
+ "sizeBytes": attr.st_size,
91
+ "sizeFormatted": f"{attr.st_size / 1024:.1f} KB"
92
+ if attr.st_size < 1024 * 1024
93
+ else f"{attr.st_size / (1024 * 1024):.2f} MB",
94
+ "permissions": format_file_mode(attr.st_mode),
95
+ "modified": mtime_dt.isoformat() if mtime_dt else None,
96
+ "uid": attr.st_uid,
97
+ "gid": attr.st_gid,
98
+ }
99
+ )
98
100
 
99
101
  safe_limit = min(max(1, limit), 250)
100
102
  safe_offset = max(0, offset)
@@ -399,6 +401,7 @@ def remove_remote_path(
399
401
  st = sftp.stat(remote_path)
400
402
  if stat.S_ISDIR(st.st_mode):
401
403
  if recursive:
404
+
402
405
  def _rm_recursive(rem_dir: str):
403
406
  for attr in sftp.listdir_attr(rem_dir):
404
407
  item = f"{rem_dir.rstrip('/')}/{attr.filename}"
@@ -407,6 +410,7 @@ def remove_remote_path(
407
410
  else:
408
411
  sftp.remove(item)
409
412
  sftp.rmdir(rem_dir)
413
+
410
414
  _rm_recursive(remote_path)
411
415
  else:
412
416
  sftp.rmdir(remote_path)
@@ -4,14 +4,11 @@ Allows creating background local-to-remote tunnels using paramiko direct-tcpip c
4
4
  """
5
5
 
6
6
  import select
7
- import socket
8
7
  import socketserver
9
8
  import threading
10
9
  import time
11
10
  from typing import Any, Dict, List, Optional
12
11
 
13
- import paramiko
14
-
15
12
  from mcp_win_stdio.ssh.connection import get_cached_or_connect, resolve_host_info
16
13
 
17
14
  _ACTIVE_TUNNELS: Dict[str, Dict[str, Any]] = {}
@@ -89,9 +86,7 @@ def open_local_tunnel(
89
86
  except Exception as e:
90
87
  raise OSError(f"Failed to bind local port {local_port}: {e}")
91
88
 
92
- server_thread = threading.Thread(
93
- target=server.serve_forever, daemon=True, name=f"SSHTunnel-{name}"
94
- )
89
+ server_thread = threading.Thread(target=server.serve_forever, daemon=True, name=f"SSHTunnel-{name}")
95
90
  server_thread.start()
96
91
 
97
92
  info = {