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,215 @@
1
+ """
2
+ Interactive PTY / Pseudo-Terminal Session Manager for mcp-win-stdio-ssh.
3
+ Maintains long-lived interactive shell sessions for REPLs, prompts, and CLI wizards.
4
+ """
5
+
6
+ import re
7
+ import select
8
+ import threading
9
+ import time
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ import paramiko
13
+
14
+ from mcp_win_stdio.ssh.connection import get_cached_or_connect, resolve_host_info
15
+
16
+ _PTY_SESSIONS: Dict[str, Dict[str, Any]] = {}
17
+ _PTY_LOCK = threading.RLock()
18
+
19
+ # ANSI escape sequence filter
20
+ ANSI_ESCAPE_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
21
+
22
+
23
+ def strip_ansi(text: str) -> str:
24
+ """Remove terminal ANSI escape codes for clean AI readable output."""
25
+ return ANSI_ESCAPE_RE.sub("", text)
26
+
27
+
28
+ def start_pty_session(
29
+ session_name: str,
30
+ host: Optional[str] = None,
31
+ term: str = "xterm-256color",
32
+ width: int = 80,
33
+ height: int = 24,
34
+ ) -> Dict[str, Any]:
35
+ """Start an interactive pseudo-terminal shell session."""
36
+ host_info = resolve_host_info(host)
37
+ client = get_cached_or_connect(host_info["name"])
38
+
39
+ with _PTY_LOCK:
40
+ if session_name in _PTY_SESSIONS:
41
+ chan = _PTY_SESSIONS[session_name]["channel"]
42
+ if not chan.closed:
43
+ return {
44
+ "success": True,
45
+ "message": f"PTY session '{session_name}' is already active.",
46
+ "sessionName": session_name,
47
+ "host": host_info["name"],
48
+ }
49
+
50
+ channel = client.invoke_shell(term=term, width=width, height=height)
51
+ channel.setblocking(False)
52
+
53
+ # Allow initial prompt to render
54
+ time.sleep(0.5)
55
+ initial_buf = []
56
+ while channel.recv_ready():
57
+ try:
58
+ data = channel.recv(4096)
59
+ if data:
60
+ initial_buf.append(data.decode("utf-8", errors="replace"))
61
+ except Exception:
62
+ break
63
+
64
+ _PTY_SESSIONS[session_name] = {
65
+ "name": session_name,
66
+ "host": host_info["name"],
67
+ "channel": channel,
68
+ "createdAt": time.strftime("%Y-%m-%d %H:%M:%S"),
69
+ "buffer": initial_buf,
70
+ }
71
+
72
+ return {
73
+ "success": True,
74
+ "message": f"Interactive PTY session '{session_name}' started on '{host_info['name']}'.",
75
+ "sessionName": session_name,
76
+ "host": host_info["name"],
77
+ "initialOutput": strip_ansi("".join(initial_buf)).strip(),
78
+ }
79
+
80
+
81
+ def send_to_pty(
82
+ session_name: str,
83
+ input_text: str,
84
+ wait_ms: int = 500,
85
+ ) -> Dict[str, Any]:
86
+ """Send text/commands into an active PTY session and collect immediate output."""
87
+ with _PTY_LOCK:
88
+ if session_name not in _PTY_SESSIONS:
89
+ return {
90
+ "error": True,
91
+ "message": f"PTY session '{session_name}' not found. Active sessions: {list(_PTY_SESSIONS.keys())}",
92
+ }
93
+
94
+ sess = _PTY_SESSIONS[session_name]
95
+ channel: paramiko.Channel = sess["channel"]
96
+
97
+ if channel.closed:
98
+ return {
99
+ "error": True,
100
+ "message": f"PTY session '{session_name}' has closed.",
101
+ }
102
+
103
+ # Ensure newline if not present
104
+ to_send = input_text if input_text.endswith("\n") or input_text.endswith("\r") else input_text + "\n"
105
+ channel.sendall(to_send.encode("utf-8"))
106
+
107
+ time.sleep(max(wait_ms, 100) / 1000.0)
108
+
109
+ chunks = []
110
+ while channel.recv_ready():
111
+ try:
112
+ data = channel.recv(4096)
113
+ if not data:
114
+ break
115
+ decoded = data.decode("utf-8", errors="replace")
116
+ chunks.append(decoded)
117
+ sess["buffer"].append(decoded)
118
+ except Exception:
119
+ break
120
+
121
+ out_str = "".join(chunks)
122
+ clean_out = strip_ansi(out_str)
123
+
124
+ return {
125
+ "success": True,
126
+ "sessionName": session_name,
127
+ "output": clean_out,
128
+ "rawOutput": out_str if len(out_str) < 2000 else out_str[:2000] + "... [truncated]",
129
+ "isClosed": channel.closed,
130
+ }
131
+
132
+
133
+ def read_pty_buffer(session_name: str, max_chars: int = 4000) -> Dict[str, Any]:
134
+ """Read existing buffered output and any pending data from PTY session."""
135
+ with _PTY_LOCK:
136
+ if session_name not in _PTY_SESSIONS:
137
+ return {
138
+ "error": True,
139
+ "message": f"PTY session '{session_name}' not found. Active sessions: {list(_PTY_SESSIONS.keys())}",
140
+ }
141
+
142
+ sess = _PTY_SESSIONS[session_name]
143
+ channel: paramiko.Channel = sess["channel"]
144
+
145
+ # Read any new data
146
+ while not channel.closed and channel.recv_ready():
147
+ try:
148
+ data = channel.recv(4096)
149
+ if data:
150
+ sess["buffer"].append(data.decode("utf-8", errors="replace"))
151
+ except Exception:
152
+ break
153
+
154
+ full_buf = "".join(sess["buffer"])
155
+ clean_buf = strip_ansi(full_buf)
156
+
157
+ if len(clean_buf) > max_chars:
158
+ clean_buf = clean_buf[-max_chars:]
159
+
160
+ return {
161
+ "success": True,
162
+ "sessionName": session_name,
163
+ "buffer": clean_buf,
164
+ "isClosed": channel.closed,
165
+ }
166
+
167
+
168
+ def list_pty_sessions() -> List[Dict[str, Any]]:
169
+ """List all currently active PTY sessions."""
170
+ res = []
171
+ with _PTY_LOCK:
172
+ for name, sess in _PTY_SESSIONS.items():
173
+ chan = sess["channel"]
174
+ res.append({
175
+ "sessionName": name,
176
+ "host": sess["host"],
177
+ "createdAt": sess["createdAt"],
178
+ "isActive": not chan.closed,
179
+ })
180
+ return res
181
+
182
+
183
+ def close_pty_session(session_name: str) -> Dict[str, Any]:
184
+ """Terminate and remove a PTY session."""
185
+ with _PTY_LOCK:
186
+ if session_name not in _PTY_SESSIONS:
187
+ return {
188
+ "error": True,
189
+ "message": f"PTY session '{session_name}' not found.",
190
+ }
191
+
192
+ sess = _PTY_SESSIONS.pop(session_name)
193
+ try:
194
+ sess["channel"].close()
195
+ except Exception:
196
+ pass
197
+
198
+ return {
199
+ "success": True,
200
+ "message": f"PTY session '{session_name}' terminated.",
201
+ }
202
+
203
+
204
+ def close_all_pty_sessions() -> int:
205
+ """Close all open PTY sessions."""
206
+ closed = 0
207
+ with _PTY_LOCK:
208
+ for name in list(_PTY_SESSIONS.keys()):
209
+ sess = _PTY_SESSIONS.pop(name)
210
+ try:
211
+ sess["channel"].close()
212
+ closed += 1
213
+ except Exception:
214
+ pass
215
+ return closed