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
mcp_win_stdio/ssh/cli.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entry point for mcp-win-stdio-ssh.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
# Ensure Windows console uses UTF-8 without crashing on cp1252
|
|
14
|
+
if sys.platform == "win32":
|
|
15
|
+
try:
|
|
16
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
17
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
18
|
+
except Exception:
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
from mcp_win_stdio.ssh import __version__
|
|
22
|
+
from mcp_win_stdio.ssh.connection import (
|
|
23
|
+
SYSTEM_SSH_CONFIG,
|
|
24
|
+
get_all_registered_hosts,
|
|
25
|
+
get_active_host_name,
|
|
26
|
+
resolve_host_info,
|
|
27
|
+
get_cached_or_connect,
|
|
28
|
+
)
|
|
29
|
+
from mcp_win_stdio.ssh.guide import print_ssh_guide
|
|
30
|
+
from mcp_win_stdio.ssh.server import mcp
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def cmd_run(args: argparse.Namespace) -> None:
|
|
34
|
+
"""Run SSH MCP server over stdio."""
|
|
35
|
+
mcp.run()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_doctor(args: argparse.Namespace) -> None:
|
|
39
|
+
"""Run diagnostic checks on SSH drivers, Windows OpenSSH service, keys, and configs."""
|
|
40
|
+
print(f"\n=== mcp-win-stdio-ssh Doctor Diagnostic (v{__version__}) ===\n")
|
|
41
|
+
print(f"[OK] Python Runtime: {sys.version.split()[0]} ({sys.executable})")
|
|
42
|
+
|
|
43
|
+
# 1. Driver Checks
|
|
44
|
+
for dep in ("mcp", "paramiko", "cryptography"):
|
|
45
|
+
try:
|
|
46
|
+
mod = __import__(dep)
|
|
47
|
+
ver = getattr(mod, "__version__", "installed")
|
|
48
|
+
print(f"[OK] Python Module: {dep:<14} (v{ver})")
|
|
49
|
+
except ImportError:
|
|
50
|
+
print(f"[FAIL] Python Module: {dep:<14} (MISSING - run `pip install {dep}`)")
|
|
51
|
+
|
|
52
|
+
# 2. OpenSSH Windows client check
|
|
53
|
+
ssh_bin = shutil.which("ssh")
|
|
54
|
+
if ssh_bin:
|
|
55
|
+
print(f"[OK] System SSH CLI: {ssh_bin}")
|
|
56
|
+
else:
|
|
57
|
+
print(f"[INFO] System SSH CLI: Not on PATH (Optional: Paramiko native SSH client active)")
|
|
58
|
+
|
|
59
|
+
# 3. SSH Config & Keys
|
|
60
|
+
ssh_dir = Path.home() / ".ssh"
|
|
61
|
+
if ssh_dir.is_dir():
|
|
62
|
+
print(f"[OK] SSH Directory: {ssh_dir}")
|
|
63
|
+
if SYSTEM_SSH_CONFIG.exists():
|
|
64
|
+
print(f"[OK] SSH Config: {SYSTEM_SSH_CONFIG}")
|
|
65
|
+
else:
|
|
66
|
+
print(f"[INFO] SSH Config: No ~/.ssh/config found (can use add_host or create config)")
|
|
67
|
+
|
|
68
|
+
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
|
+
if keys:
|
|
70
|
+
print(f"[OK] Found Keys: {', '.join(keys)}")
|
|
71
|
+
else:
|
|
72
|
+
print(f"[INFO] Found Keys: No default id_* keys found in ~/.ssh/")
|
|
73
|
+
else:
|
|
74
|
+
print(f"[INFO] SSH Directory: {ssh_dir} not created yet.")
|
|
75
|
+
|
|
76
|
+
# 4. Registered Hosts
|
|
77
|
+
hosts = get_all_registered_hosts()
|
|
78
|
+
active = get_active_host_name()
|
|
79
|
+
print(f"\n--- Registered Hosts ({len(hosts)}) ---")
|
|
80
|
+
for name, h in hosts.items():
|
|
81
|
+
is_act = " (ACTIVE DEFAULT)" if name == active else ""
|
|
82
|
+
print(f" • {name:<16} -> {h.get('user')}@{h.get('hostname')}:{h.get('port')} [{h.get('source')}]{is_act}")
|
|
83
|
+
|
|
84
|
+
print("\nDiagnostic complete.\n")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cmd_test(args: argparse.Namespace) -> None:
|
|
88
|
+
"""Test SSH connectivity and latency to configured hosts."""
|
|
89
|
+
target = args.host
|
|
90
|
+
hosts = get_all_registered_hosts()
|
|
91
|
+
|
|
92
|
+
if not hosts and not target:
|
|
93
|
+
print("\n[!] No SSH hosts configured. Use 'add_host' tool or configure ~/.ssh/config.\n")
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
targets = [target] if target else list(hosts.keys())
|
|
97
|
+
|
|
98
|
+
print(f"\n=== Testing SSH Connectivity ({len(targets)} host{'s' if len(targets) > 1 else ''}) ===\n")
|
|
99
|
+
|
|
100
|
+
for h_name in targets:
|
|
101
|
+
try:
|
|
102
|
+
h_info = resolve_host_info(h_name)
|
|
103
|
+
t_start = time.time()
|
|
104
|
+
client = get_cached_or_connect(h_info["name"], timeout=10)
|
|
105
|
+
lat_ms = round((time.time() - t_start) * 1000, 2)
|
|
106
|
+
|
|
107
|
+
stdin, stdout, stderr = client.exec_command("uname -srmo 2>/dev/null || ver", timeout=5)
|
|
108
|
+
os_info = stdout.read().decode("utf-8", errors="replace").strip()
|
|
109
|
+
|
|
110
|
+
print(f" [OK] {h_name:<16} -> {h_info.get('user')}@{h_info.get('hostname')}:{h_info.get('port')} ({lat_ms}ms) | {os_info}")
|
|
111
|
+
except Exception as e:
|
|
112
|
+
print(f" [FAIL] {h_name:<14} -> Error: {e}")
|
|
113
|
+
|
|
114
|
+
print("\nTest complete.\n")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def cmd_guide(args: argparse.Namespace) -> None:
|
|
118
|
+
"""Print guide and prompt recipes."""
|
|
119
|
+
print_ssh_guide()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main() -> None:
|
|
123
|
+
parser = argparse.ArgumentParser(
|
|
124
|
+
prog="mcp-win-stdio-ssh",
|
|
125
|
+
description="Unified Multi-SSH Connection & Remote Management MCP Server CLI",
|
|
126
|
+
)
|
|
127
|
+
parser.add_argument("--version", "-v", action="version", version=f"%(prog)s {__version__}")
|
|
128
|
+
subparsers = parser.add_subparsers(dest="command", help="Command to execute")
|
|
129
|
+
|
|
130
|
+
sub_guide = subparsers.add_parser("guide", help="View usage guide and recipes")
|
|
131
|
+
sub_guide.set_defaults(func=cmd_guide)
|
|
132
|
+
|
|
133
|
+
sub_doctor = subparsers.add_parser("doctor", help="Check SSH configuration and drivers")
|
|
134
|
+
sub_doctor.set_defaults(func=cmd_doctor)
|
|
135
|
+
|
|
136
|
+
sub_test = subparsers.add_parser("test", help="Test connectivity to configured hosts")
|
|
137
|
+
sub_test.add_argument("host", nargs="?", help="Specific host name to test")
|
|
138
|
+
sub_test.set_defaults(func=cmd_test)
|
|
139
|
+
|
|
140
|
+
sub_run = subparsers.add_parser("run", help="Run SSH MCP server over stdio")
|
|
141
|
+
sub_run.set_defaults(func=cmd_run)
|
|
142
|
+
|
|
143
|
+
args = parser.parse_args()
|
|
144
|
+
if hasattr(args, "func"):
|
|
145
|
+
args.func(args)
|
|
146
|
+
else:
|
|
147
|
+
# Default action is run
|
|
148
|
+
cmd_run(args)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
main()
|
|
@@ -0,0 +1,369 @@
|
|
|
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
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import socket
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
16
|
+
|
|
17
|
+
import paramiko
|
|
18
|
+
from paramiko.config import SSHConfig
|
|
19
|
+
|
|
20
|
+
from mcp_win_stdio.core.config import INDALA_DIR, ensure_workspace_dirs
|
|
21
|
+
|
|
22
|
+
SSH_CONFIG_DIR = INDALA_DIR
|
|
23
|
+
HOSTS_CONFIG_FILE = SSH_CONFIG_DIR / "ssh_hosts.json"
|
|
24
|
+
SSH_USER_DIR = Path.home() / ".ssh"
|
|
25
|
+
SYSTEM_SSH_CONFIG = SSH_USER_DIR / "config"
|
|
26
|
+
|
|
27
|
+
_ACTIVE_HOST: Optional[str] = None
|
|
28
|
+
_CLIENT_POOL: Dict[str, paramiko.SSHClient] = {}
|
|
29
|
+
_POOL_LOCK = threading.RLock()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _normalize_host_param(host: Any) -> Optional[str]:
|
|
33
|
+
"""Normalize host parameter from LLM tool call, filtering out null/empty strings."""
|
|
34
|
+
if host is None:
|
|
35
|
+
return None
|
|
36
|
+
s = str(host).strip()
|
|
37
|
+
if s.lower() in ("", "null", "none", "undefined", "default"):
|
|
38
|
+
return None
|
|
39
|
+
return s
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_ssh_hosts() -> Dict[str, Any]:
|
|
43
|
+
"""Load persistent SSH hosts from ~/.mcp-win-stdio/ssh_hosts.json."""
|
|
44
|
+
ensure_workspace_dirs()
|
|
45
|
+
if not HOSTS_CONFIG_FILE.exists():
|
|
46
|
+
return {}
|
|
47
|
+
try:
|
|
48
|
+
with open(HOSTS_CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
49
|
+
data = json.load(f)
|
|
50
|
+
return data if isinstance(data, dict) else {}
|
|
51
|
+
except Exception:
|
|
52
|
+
return {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def save_ssh_hosts(data: Dict[str, Any]) -> None:
|
|
56
|
+
"""Save persistent SSH hosts to ~/.mcp-win-stdio/ssh_hosts.json."""
|
|
57
|
+
ensure_workspace_dirs()
|
|
58
|
+
try:
|
|
59
|
+
with open(HOSTS_CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
60
|
+
json.dump(data, f, indent=2)
|
|
61
|
+
except Exception as e:
|
|
62
|
+
sys.stderr.write(f"Warning: Failed to save ssh hosts: {e}\n")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_active_host_name() -> Optional[str]:
|
|
66
|
+
"""Get the currently active host name (memory or persistent)."""
|
|
67
|
+
global _ACTIVE_HOST
|
|
68
|
+
if _ACTIVE_HOST:
|
|
69
|
+
return _ACTIVE_HOST
|
|
70
|
+
data = load_ssh_hosts()
|
|
71
|
+
active = data.get("active_host")
|
|
72
|
+
if active:
|
|
73
|
+
_ACTIVE_HOST = active
|
|
74
|
+
return active
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def set_active_host_name(name: Optional[str]) -> None:
|
|
79
|
+
"""Set and persist the active host name."""
|
|
80
|
+
global _ACTIVE_HOST
|
|
81
|
+
_ACTIVE_HOST = name
|
|
82
|
+
data = load_ssh_hosts()
|
|
83
|
+
data["active_host"] = name
|
|
84
|
+
save_ssh_hosts(data)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_system_ssh_config() -> Dict[str, Dict[str, Any]]:
|
|
88
|
+
"""Parse ~/.ssh/config and return a mapping of host aliases to connection configs."""
|
|
89
|
+
configs: Dict[str, Dict[str, Any]] = {}
|
|
90
|
+
if not SYSTEM_SSH_CONFIG.exists():
|
|
91
|
+
return configs
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
ssh_cfg = SSHConfig()
|
|
95
|
+
with open(SYSTEM_SSH_CONFIG, "r", encoding="utf-8", errors="replace") as f:
|
|
96
|
+
ssh_cfg.parse(f)
|
|
97
|
+
|
|
98
|
+
# Iterate through host entries
|
|
99
|
+
for host in ssh_cfg.get_hostnames():
|
|
100
|
+
if host == "*":
|
|
101
|
+
continue
|
|
102
|
+
entry = ssh_cfg.lookup(host)
|
|
103
|
+
identity_files = entry.get("identityfile", [])
|
|
104
|
+
key_path = identity_files[0] if identity_files else None
|
|
105
|
+
|
|
106
|
+
# Resolve ~ in key path
|
|
107
|
+
if key_path:
|
|
108
|
+
key_path = str(Path(key_path).expanduser())
|
|
109
|
+
|
|
110
|
+
configs[host] = {
|
|
111
|
+
"name": host,
|
|
112
|
+
"hostname": entry.get("hostname", host),
|
|
113
|
+
"user": entry.get("user", os.environ.get("USERNAME", "root")),
|
|
114
|
+
"port": int(entry.get("port", 22)),
|
|
115
|
+
"key_path": key_path,
|
|
116
|
+
"proxyjump": entry.get("proxyjump"),
|
|
117
|
+
"proxycommand": entry.get("proxycommand"),
|
|
118
|
+
"source": "ssh_config",
|
|
119
|
+
}
|
|
120
|
+
except Exception as e:
|
|
121
|
+
sys.stderr.write(f"Warning: Failed to parse system SSH config: {e}\n")
|
|
122
|
+
|
|
123
|
+
return configs
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_all_registered_hosts() -> Dict[str, Dict[str, Any]]:
|
|
127
|
+
"""Merge SSH config hosts, persistent custom hosts, and env variables."""
|
|
128
|
+
all_hosts: Dict[str, Dict[str, Any]] = {}
|
|
129
|
+
|
|
130
|
+
# 1. System ~/.ssh/config
|
|
131
|
+
system_hosts = parse_system_ssh_config()
|
|
132
|
+
all_hosts.update(system_hosts)
|
|
133
|
+
|
|
134
|
+
# 2. Persistent ~/.mcp-win-stdio/ssh_hosts.json
|
|
135
|
+
saved_data = load_ssh_hosts()
|
|
136
|
+
hosts_dict = saved_data.get("hosts", {})
|
|
137
|
+
for name, host_data in hosts_dict.items():
|
|
138
|
+
if isinstance(host_data, dict):
|
|
139
|
+
entry = dict(host_data)
|
|
140
|
+
entry["name"] = name
|
|
141
|
+
entry["source"] = "saved_config"
|
|
142
|
+
all_hosts[name] = entry
|
|
143
|
+
|
|
144
|
+
# 3. Environment variable SSH_SERVERS (JSON format)
|
|
145
|
+
env_servers = os.environ.get("SSH_SERVERS")
|
|
146
|
+
if env_servers:
|
|
147
|
+
try:
|
|
148
|
+
parsed = json.loads(env_servers)
|
|
149
|
+
if isinstance(parsed, dict):
|
|
150
|
+
for name, cfg in parsed.items():
|
|
151
|
+
if isinstance(cfg, dict):
|
|
152
|
+
entry = dict(cfg)
|
|
153
|
+
entry["name"] = name
|
|
154
|
+
entry["source"] = "env"
|
|
155
|
+
all_hosts[name] = entry
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
return all_hosts
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def resolve_host_info(target_name: Optional[str] = None) -> Dict[str, Any]:
|
|
163
|
+
"""
|
|
164
|
+
Resolve connection parameters for a host by name or active default.
|
|
165
|
+
Supports user@host:port strings or registered aliases.
|
|
166
|
+
"""
|
|
167
|
+
norm = _normalize_host_param(target_name)
|
|
168
|
+
host_name = norm or get_active_host_name()
|
|
169
|
+
|
|
170
|
+
registered = get_all_registered_hosts()
|
|
171
|
+
|
|
172
|
+
if host_name and host_name in registered:
|
|
173
|
+
return registered[host_name]
|
|
174
|
+
|
|
175
|
+
# Check if target is a direct user@hostname:port connection string
|
|
176
|
+
if host_name and ("@" in host_name or "." in host_name):
|
|
177
|
+
user = os.environ.get("USERNAME", "root")
|
|
178
|
+
host_str = host_name
|
|
179
|
+
port = 22
|
|
180
|
+
|
|
181
|
+
if "@" in host_str:
|
|
182
|
+
user, host_str = host_str.split("@", 1)
|
|
183
|
+
|
|
184
|
+
if ":" in host_str:
|
|
185
|
+
host_str, port_str = host_str.rsplit(":", 1)
|
|
186
|
+
try:
|
|
187
|
+
port = int(port_str)
|
|
188
|
+
except ValueError:
|
|
189
|
+
port = 22
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
"name": host_name,
|
|
193
|
+
"hostname": host_str,
|
|
194
|
+
"user": user,
|
|
195
|
+
"port": port,
|
|
196
|
+
"key_path": None,
|
|
197
|
+
"source": "ad_hoc",
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if not host_name:
|
|
201
|
+
if registered:
|
|
202
|
+
first_name = next(iter(registered.keys()))
|
|
203
|
+
set_active_host_name(first_name)
|
|
204
|
+
return registered[first_name]
|
|
205
|
+
raise ValueError(
|
|
206
|
+
"No SSH host specified and no active host is configured. "
|
|
207
|
+
"Use 'add_host' or provide 'host' parameter."
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
raise ValueError(f"SSH host '{host_name}' not found. Available hosts: {list(registered.keys())}")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _find_default_keys() -> List[str]:
|
|
214
|
+
"""Find default SSH keys in ~/.ssh/ directory."""
|
|
215
|
+
keys = []
|
|
216
|
+
if SSH_USER_DIR.is_dir():
|
|
217
|
+
for key_name in ["id_ed25519", "id_rsa", "id_ecdsa", "id_dsa"]:
|
|
218
|
+
kp = SSH_USER_DIR / key_name
|
|
219
|
+
if kp.exists():
|
|
220
|
+
keys.append(str(kp))
|
|
221
|
+
return keys
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def create_ssh_client(
|
|
225
|
+
host_info: Dict[str, Any],
|
|
226
|
+
password: Optional[str] = None,
|
|
227
|
+
passphrase: Optional[str] = None,
|
|
228
|
+
timeout: int = 15,
|
|
229
|
+
) -> paramiko.SSHClient:
|
|
230
|
+
"""Instantiate and connect a paramiko.SSHClient based on host info."""
|
|
231
|
+
client = paramiko.SSHClient()
|
|
232
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
233
|
+
|
|
234
|
+
hostname = host_info.get("hostname", "localhost")
|
|
235
|
+
port = int(host_info.get("port", 22))
|
|
236
|
+
username = host_info.get("user") or os.environ.get("USERNAME", "root")
|
|
237
|
+
key_path = host_info.get("key_path")
|
|
238
|
+
pwd = password or host_info.get("password")
|
|
239
|
+
pass_phrase = passphrase or host_info.get("passphrase")
|
|
240
|
+
|
|
241
|
+
key_filename = None
|
|
242
|
+
if key_path and Path(key_path).expanduser().exists():
|
|
243
|
+
key_filename = str(Path(key_path).expanduser())
|
|
244
|
+
else:
|
|
245
|
+
# Check default keys
|
|
246
|
+
default_keys = _find_default_keys()
|
|
247
|
+
if default_keys:
|
|
248
|
+
key_filename = default_keys[0]
|
|
249
|
+
|
|
250
|
+
# Support JumpHost / Bastion if specified
|
|
251
|
+
sock = None
|
|
252
|
+
jump_host = host_info.get("jump_host") or host_info.get("proxyjump")
|
|
253
|
+
if jump_host:
|
|
254
|
+
jump_info = resolve_host_info(jump_host)
|
|
255
|
+
jump_client = get_cached_or_connect(jump_info["name"])
|
|
256
|
+
transport = jump_client.get_transport()
|
|
257
|
+
if transport and transport.is_active():
|
|
258
|
+
dest_addr = (hostname, port)
|
|
259
|
+
local_addr = ("127.0.0.1", 0)
|
|
260
|
+
sock = transport.open_channel("direct-tcpip", dest_addr, local_addr)
|
|
261
|
+
|
|
262
|
+
connect_kwargs: Dict[str, Any] = {
|
|
263
|
+
"hostname": hostname,
|
|
264
|
+
"port": port,
|
|
265
|
+
"username": username,
|
|
266
|
+
"timeout": timeout,
|
|
267
|
+
"allow_agent": True,
|
|
268
|
+
"look_for_keys": True,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if sock:
|
|
272
|
+
connect_kwargs["sock"] = sock
|
|
273
|
+
if pwd:
|
|
274
|
+
connect_kwargs["password"] = pwd
|
|
275
|
+
if key_filename:
|
|
276
|
+
connect_kwargs["key_filename"] = key_filename
|
|
277
|
+
if pass_phrase:
|
|
278
|
+
connect_kwargs["passphrase"] = pass_phrase
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
client.connect(**connect_kwargs)
|
|
282
|
+
# Enable keepalive
|
|
283
|
+
transport = client.get_transport()
|
|
284
|
+
if transport:
|
|
285
|
+
transport.set_keepalive(30)
|
|
286
|
+
return client
|
|
287
|
+
except Exception as e:
|
|
288
|
+
client.close()
|
|
289
|
+
raise ConnectionError(
|
|
290
|
+
f"Failed to connect to SSH host '{host_info.get('name', hostname)}' ({username}@{hostname}:{port}): {e}"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def get_cached_or_connect(
|
|
295
|
+
target_name: Optional[str] = None,
|
|
296
|
+
password: Optional[str] = None,
|
|
297
|
+
passphrase: Optional[str] = None,
|
|
298
|
+
timeout: int = 15,
|
|
299
|
+
) -> paramiko.SSHClient:
|
|
300
|
+
"""Retrieve an active SSH client from the pool or establish a new connection."""
|
|
301
|
+
host_info = resolve_host_info(target_name)
|
|
302
|
+
name = host_info["name"]
|
|
303
|
+
|
|
304
|
+
with _POOL_LOCK:
|
|
305
|
+
if name in _CLIENT_POOL:
|
|
306
|
+
client = _CLIENT_POOL[name]
|
|
307
|
+
transport = client.get_transport()
|
|
308
|
+
if transport and transport.is_active():
|
|
309
|
+
return client
|
|
310
|
+
# Drop dead connection
|
|
311
|
+
try:
|
|
312
|
+
client.close()
|
|
313
|
+
except Exception:
|
|
314
|
+
pass
|
|
315
|
+
del _CLIENT_POOL[name]
|
|
316
|
+
|
|
317
|
+
client = create_ssh_client(
|
|
318
|
+
host_info, password=password, passphrase=passphrase, timeout=timeout
|
|
319
|
+
)
|
|
320
|
+
_CLIENT_POOL[name] = client
|
|
321
|
+
return client
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def close_connection(target_name: Optional[str] = None) -> bool:
|
|
325
|
+
"""Close connection for a specific host in the pool."""
|
|
326
|
+
norm = _normalize_host_param(target_name)
|
|
327
|
+
name = norm or get_active_host_name()
|
|
328
|
+
if not name:
|
|
329
|
+
return False
|
|
330
|
+
|
|
331
|
+
with _POOL_LOCK:
|
|
332
|
+
if name in _CLIENT_POOL:
|
|
333
|
+
try:
|
|
334
|
+
_CLIENT_POOL[name].close()
|
|
335
|
+
except Exception:
|
|
336
|
+
pass
|
|
337
|
+
del _CLIENT_POOL[name]
|
|
338
|
+
return True
|
|
339
|
+
return False
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def close_all_connections() -> int:
|
|
343
|
+
"""Close all open SSH connections in the pool."""
|
|
344
|
+
closed = 0
|
|
345
|
+
with _POOL_LOCK:
|
|
346
|
+
for name, client in list(_CLIENT_POOL.items()):
|
|
347
|
+
try:
|
|
348
|
+
client.close()
|
|
349
|
+
closed += 1
|
|
350
|
+
except Exception:
|
|
351
|
+
pass
|
|
352
|
+
_CLIENT_POOL.clear()
|
|
353
|
+
return closed
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def get_pool_status() -> List[Dict[str, Any]]:
|
|
357
|
+
"""Return status of all live connections in the pool."""
|
|
358
|
+
status = []
|
|
359
|
+
with _POOL_LOCK:
|
|
360
|
+
for name, client in _CLIENT_POOL.items():
|
|
361
|
+
transport = client.get_transport()
|
|
362
|
+
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
|
+
})
|
|
369
|
+
return status
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Comprehensive guide, tool reference, and Claude prompt recipes for mcp-win-stdio-ssh.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
def print_ssh_guide() -> None:
|
|
6
|
+
"""Print complete SSH MCP tool reference and workflow recipes."""
|
|
7
|
+
guide_text = """
|
|
8
|
+
================================================================================
|
|
9
|
+
🚀 mcp-win-stdio-ssh — Advanced Multi-SSH Connection & Remote Management
|
|
10
|
+
================================================================================
|
|
11
|
+
|
|
12
|
+
The SSH MCP server connects AI models directly to remote Linux, macOS, and Windows
|
|
13
|
+
servers over standard SSH, SFTP, and interactive PTY channels.
|
|
14
|
+
|
|
15
|
+
--------------------------------------------------------------------------------
|
|
16
|
+
🌟 Key Architecture & Capabilities
|
|
17
|
+
--------------------------------------------------------------------------------
|
|
18
|
+
• Multi-Host Pooling: Connect to multiple servers concurrently with connection reuse.
|
|
19
|
+
• Auto-Discovery: Automatically reads ~/.ssh/config aliases, identity files & proxy jumps.
|
|
20
|
+
• Sticky Context: 'use_host' sets the default target host across subsequent tool calls.
|
|
21
|
+
• Interactive PTY: Stateful terminal sessions for long-running processes, REPLs & prompts.
|
|
22
|
+
• Service & Health: Fast system metrics, systemd/docker/pm2 inspection, and log tailing.
|
|
23
|
+
• Full SFTP: Remote file reading/writing with token protection, directory recursion.
|
|
24
|
+
• Local Tunnels: Port forwarding to expose remote MySQL/Postgres/Web services locally.
|
|
25
|
+
|
|
26
|
+
--------------------------------------------------------------------------------
|
|
27
|
+
🛠️ Complete Tool Reference (27 Tools)
|
|
28
|
+
--------------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
1. Host & Connection Management
|
|
31
|
+
• list_hosts() -> List all discovered/saved SSH hosts & status
|
|
32
|
+
• use_host(host) -> Switch active sticky host context
|
|
33
|
+
• add_host(name, hostname, ...) -> Save new host to ~/.mcp-win-stdio/ssh_hosts.json
|
|
34
|
+
• remove_host(name) -> Delete saved host configuration
|
|
35
|
+
• test_host(host=None) -> Test SSH handshake, auth, latency, and remote OS
|
|
36
|
+
• list_active_connections() -> Show live connections in the connection pool
|
|
37
|
+
• disconnect_host(host=None) -> Close connection for specific or active host
|
|
38
|
+
|
|
39
|
+
2. Command Execution & Background Jobs
|
|
40
|
+
• ssh_exec(command, cwd, env, ...) -> Execute non-interactive command & return exit code
|
|
41
|
+
• ssh_exec_sudo(command, password) -> Run command with sudo handling password prompt
|
|
42
|
+
• ssh_exec_script(script_content) -> Upload and execute bash/python/sh script
|
|
43
|
+
• ssh_exec_background(command) -> Run detached background job (nohup) & get PID
|
|
44
|
+
• ssh_check_job(job_id_or_pid) -> Inspect status and logs of background job
|
|
45
|
+
• ssh_kill_job(pid, signal) -> Terminate remote process by PID
|
|
46
|
+
|
|
47
|
+
3. Interactive PTY / Shell
|
|
48
|
+
• ssh_pty_start(session_name) -> Spawn interactive terminal session
|
|
49
|
+
• ssh_pty_send(session_name, text) -> Send command/keystroke and read buffer
|
|
50
|
+
• ssh_pty_read(session_name) -> Read output buffer from PTY session
|
|
51
|
+
• ssh_list_pty_sessions() -> List open interactive PTY sessions
|
|
52
|
+
• ssh_pty_close(session_name) -> Terminate PTY session
|
|
53
|
+
|
|
54
|
+
4. Diagnostics & Remote Services
|
|
55
|
+
• ssh_system_overview() -> CPU/RAM/Disk stats, OS distro, kernel, uptime
|
|
56
|
+
• ssh_list_services(type, filter) -> List systemd units, docker containers, or pm2 apps
|
|
57
|
+
• ssh_service_action(name, action) -> Manage service (status/start/stop/restart/reload)
|
|
58
|
+
• ssh_tail_logs(target, lines) -> Tail log files (/var/log/...) or journalctl
|
|
59
|
+
• ssh_list_processes(sort_by) -> Top processes by CPU or Memory
|
|
60
|
+
• ssh_list_packages(manager, ...) -> Paginated installed packages (apt, dnf, pacman, pip, npm)
|
|
61
|
+
|
|
62
|
+
5. SFTP File Management
|
|
63
|
+
• sftp_list_dir(remote_path) -> List directory with permissions & sizes
|
|
64
|
+
• sftp_read_file(remote_path) -> Read remote text file with line offset
|
|
65
|
+
• sftp_write_file(remote_path, ...) -> Write or append remote file
|
|
66
|
+
• sftp_stat(remote_path) -> Get file/folder permissions & metadata
|
|
67
|
+
• sftp_mkdir(remote_path) -> Create remote directory
|
|
68
|
+
• sftp_remove(remote_path) -> Delete remote file or directory
|
|
69
|
+
• sftp_upload(local_path, remote) -> Upload file or folder recursively
|
|
70
|
+
• sftp_download(remote, local_path) -> Download file or folder recursively
|
|
71
|
+
|
|
72
|
+
6. Port Forwarding & Tunnels
|
|
73
|
+
• ssh_tunnel_open(local, remote) -> Forward remote port to 127.0.0.1:<local_port>
|
|
74
|
+
• ssh_tunnel_list() -> List active local tunnels
|
|
75
|
+
• ssh_tunnel_close(tunnel_id) -> Terminate active tunnel
|
|
76
|
+
|
|
77
|
+
--------------------------------------------------------------------------------
|
|
78
|
+
💡 Example Claude Workflows & Prompts
|
|
79
|
+
--------------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
1. Remote Diagnostics:
|
|
82
|
+
"Check the health and disk usage of production-server, then list any failed systemd services."
|
|
83
|
+
|
|
84
|
+
2. Deploy Application & Check Logs:
|
|
85
|
+
"Upload ./dist to /var/www/my-app on web-01, restart the nginx service, and tail the error log."
|
|
86
|
+
|
|
87
|
+
3. Database Tunneling:
|
|
88
|
+
"Open a local tunnel from port 15432 to remote Postgres port 5432 on db-cluster, then check table counts."
|
|
89
|
+
|
|
90
|
+
4. Interactive Configuration:
|
|
91
|
+
"Start an interactive PTY session on staging-server and run the configuration script."
|
|
92
|
+
================================================================================
|
|
93
|
+
"""
|
|
94
|
+
print(guide_text)
|