mcp-interminal 0.2.0__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.
@@ -0,0 +1,6 @@
1
+ __pycache__
2
+ venv
3
+ .kilo
4
+ .claude
5
+ .vscode
6
+ uv.lock
@@ -0,0 +1,148 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ **Interminal** is an MCP server that gives AI assistants terminal access — SSH and local shells — with support for interactive commands. It is distributed via `uvx` and has no build step.
8
+
9
+ ## Running & Installation
10
+
11
+ ```powershell
12
+ # Run directly (no install)
13
+ python mcp_server.py
14
+
15
+ # Install from source
16
+ pip install .
17
+
18
+ # Run via uvx (published package)
19
+ uvx interminal
20
+ ```
21
+
22
+ There are no test, lint, or build commands configured in this project.
23
+
24
+ ## Architecture
25
+
26
+ ```
27
+ mcp_server.py ← FastMCP tool definitions (7 tools)
28
+
29
+ session_manager.py ← SessionManager: SSH + local session lifecycle
30
+
31
+ channel.py ← Channel abstraction (SSHChannel | LocalChannel | PtyChannel)
32
+
33
+ command.py ← RunningCommand: background read loop, UTF-8 decoding
34
+ ```
35
+
36
+ ### Key design decisions
37
+
38
+ **Channel duck-typing** — `SSHChannel`, `LocalChannel`, and `PtyChannel` share an interface without ABC/abstractmethod. All three implement `read()`, `write()`, `close()`, `send_control()`, and `is_finished()`.
39
+
40
+ **Two-phase timeout** (`_wait_for_result` in `session_manager.py`) — Phase 1 waits up to `pause_timeout + 1s` for the first byte; if it times out we return immediately (already satisfies the "silent for pause_timeout" contract — going into Phase 2 would waste another full pause_timeout). Phase 2 only runs when bytes are actually arriving, collecting until a `pause_timeout` gap or `total_timeout` hits.
41
+
42
+ **PtyChannel platform split** — Windows uses `pywinpty.PtyProcess`; Unix uses `stdlib pty` with `fcntl` non-blocking I/O. `pywinpty` is an optional dependency.
43
+
44
+ **SSHChannel stdin reference** — `session_manager.py` explicitly retains a reference to `stdin` to prevent Python GC from calling `__del__` and closing the paramiko channel prematurely.
45
+
46
+ **Incremental UTF-8 decoding** — `command.py` uses `codecs.getincrementaldecoder` so multibyte characters split across reads are assembled correctly.
47
+
48
+ **pyte integration** (`_render_pyte` in `session_manager.py`) — optional ANSI escape sequence cleaning via a `pyte.Screen` subclass that works around a signature mismatch in the upstream library.
49
+
50
+ **`RunningCommand.close` ordering** — closes the channel *before* awaiting `read_task`. Windows PTY's `read()` runs inside `asyncio.to_thread(pty.read)`, which `task.cancel()` cannot actually interrupt — the future stays pending until the underlying thread exits, and the thread won't exit until the PTY is closed. Closing the channel first unblocks the read, then cancel + await tears the task down cleanly.
51
+
52
+ **`_read_loop` uses `try/finally`** — `CancelledError` is a `BaseException` subclass, not `Exception`, so a cancel on the read task bypasses the inner `except Exception`. The `finally` block guarantees `running=False` + `new_data_event.set()` always run, so any `_wait_for_result` waiter wakes immediately on cancel instead of stalling until its own `pause_timeout` fires.
53
+
54
+ ### Session lifecycle
55
+
56
+ 1. `create_local()` or `connect_ssh()` creates a `Session` dataclass entry in `SessionManager.sessions`.
57
+ 2. `execute()` wraps the session's shell in a `Channel`, then a `RunningCommand` with a background `asyncio` read loop.
58
+ 3. `respond()` / `send_control()` write directly to the running command's channel.
59
+ 4. `disconnect()` closes the channel, terminates the subprocess/SSH client, and removes the session.
60
+
61
+ ## Using TUI multiplexers (zellij, tmux) over interminal
62
+
63
+ Each `execute()` call creates an independent `exec_command` channel — there is no
64
+ persistent shell between calls.
65
+
66
+ ### Why `zellij ... &` fails
67
+
68
+ Backgrounding a TUI multiplexer with `&` does **not** work, but not because of
69
+ SIGHUP. The failure is in zellij's init sequence:
70
+
71
+ 1. Shell parses `zellij &`, forks zellij into a **background process group**, and
72
+ immediately exits (only command in the shell was the backgrounded one).
73
+ 2. zellij's early init does TTY setup (`tcsetattr`, query terminal size, raw
74
+ mode). A background process group can't safely do this — it triggers
75
+ SIGTTOU/SIGTTIN, and the operations fail.
76
+ 3. Meanwhile the shell has exited, so the PTY is also collapsing.
77
+ 4. zellij aborts before it ever reaches the `fork()` + `setsid()` that would
78
+ daemonize the server. No server is ever created.
79
+
80
+ So the surface symptom "`list-sessions` shows nothing" is correct, but the cause
81
+ is "init never completed", not "SIGHUP killed a running server".
82
+
83
+ ### The working pattern
84
+
85
+ Start zellij **in the foreground** (no `&`) and let `execute` return `partial`.
86
+ zellij's server has time to fork + setsid, becoming a daemon in its own session.
87
+ After that, the client/channel state is irrelevant — the server lives until
88
+ explicitly killed.
89
+
90
+ ```
91
+ # 1. Start the session. execute returns "partial" once init is done.
92
+ execute("TERM=xterm-256color ~/work/zellij --session train", total_timeout=4)
93
+
94
+ # 2. The partial channel can be ignored, disconnected, or left to time out.
95
+ # The daemonized server survives all of these.
96
+
97
+ # 3. Drive the session from independent execute calls:
98
+ execute("~/work/zellij --session train action new-pane -- bash start.sh")
99
+ execute("~/work/zellij --session train action dump-screen")
100
+ execute("~/work/zellij list-sessions")
101
+ execute("~/work/zellij delete-session train --force")
102
+ ```
103
+
104
+ ### Running a command in a new tab's default pane
105
+
106
+ zellij's `action new-tab` does **not** accept `-- command` (unlike `new-pane`).
107
+ A naive "open a tab and train" therefore ends up as `new-tab` (creates an empty
108
+ default pane running the shell) + `new-pane -- bash start.sh` (a second pane
109
+ split into the tab), which leaves a leftover empty pane.
110
+
111
+ To run the command in the new tab's default pane instead, chain `write-chars`
112
+ + `write 13` — `new-tab` focuses the new tab automatically, so the keystrokes
113
+ land in its default pane:
114
+
115
+ ```
116
+ execute("~/work/zellij --session train action new-tab --name v4")
117
+ execute("~/work/zellij --session train action write-chars 'bash start.sh'")
118
+ execute("~/work/zellij --session train action write 13") # 13 = Enter (CR)
119
+ ```
120
+
121
+ tmux is simpler — `new-window` takes the command directly:
122
+
123
+ ```
124
+ execute("tmux new-window -t main -n train 'bash start.sh'")
125
+ ```
126
+
127
+ This is NOT the same as "puppeting the TUI": `write-chars` / `send-keys` are
128
+ the multiplexer's own structured CLI for injecting input. The thing to avoid
129
+ is using interminal's `respond` / `send_control` against the live TUI display
130
+ — that races against the renderer.
131
+
132
+ `TERM=xterm-256color` is required: zellij reads `TERM` at startup to pick its
133
+ renderer, and the default inherited from paramiko's PTY is often missing or
134
+ minimal. The same prefix is useful for other TUIs.
135
+
136
+ tmux works similarly but supports detached startup directly:
137
+ `tmux new-session -d -s train "bash start.sh"` returns immediately and leaves
138
+ a usable session — no partial-channel dance needed, because tmux's `-d` flag
139
+ explicitly forks the server before any TTY operations.
140
+
141
+ ## Dependencies
142
+
143
+ - `mcp[cli]` — FastMCP framework
144
+ - `paramiko` — SSH client
145
+ - `pywinpty` (optional) — Windows PTY support
146
+ - `pyte` (optional) — ANSI escape sequence rendering
147
+
148
+ Python ≥ 3.11 required.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 QiuwenZheng
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-interminal
3
+ Version: 0.2.0
4
+ License-File: LICENSE
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: mcp[cli]
7
+ Requires-Dist: paramiko
8
+ Provides-Extra: ansi
9
+ Requires-Dist: pyte; extra == 'ansi'
10
+ Provides-Extra: pty
11
+ Requires-Dist: pywinpty; extra == 'pty'
@@ -0,0 +1,242 @@
1
+ import asyncio
2
+ import os
3
+ import sys
4
+ import signal as _signal
5
+
6
+ PTY_AVAILABLE = False
7
+ if sys.platform == "win32":
8
+ try:
9
+ from pywinpty import PtyProcess # type: ignore
10
+ PTY_AVAILABLE = True
11
+ except ImportError:
12
+ pass
13
+ else:
14
+ import pty as pty_mod
15
+ import fcntl
16
+ PTY_AVAILABLE = True
17
+
18
+
19
+ if sys.platform == "win32":
20
+ # CREATE_NEW_PROCESS_GROUP disables CTRL_C_EVENT for the child group;
21
+ # CTRL_BREAK_EVENT is the only interrupt that works with that flag.
22
+ # Windows has no SIGTSTP, so Ctrl+Z is intentionally left unmapped.
23
+ _LOCAL_SIG_MAP = {b'\x03': _signal.CTRL_BREAK_EVENT}
24
+ else:
25
+ _LOCAL_SIG_MAP = {b'\x03': _signal.SIGINT}
26
+ if hasattr(_signal, 'SIGTSTP'):
27
+ _LOCAL_SIG_MAP[b'\x1a'] = _signal.SIGTSTP
28
+ if hasattr(_signal, 'SIGQUIT'):
29
+ _LOCAL_SIG_MAP[b'\x1c'] = _signal.SIGQUIT
30
+
31
+
32
+ class Channel:
33
+ """I/O interface for a running command's data stream."""
34
+
35
+ async def read(self) -> bytes | None:
36
+ """Return data, None (no data yet), or b"" (EOF)."""
37
+
38
+ async def write(self, data: bytes) -> None:
39
+ """Send data to the command's stdin."""
40
+
41
+ async def is_finished(self) -> bool:
42
+ """True when the remote process has exited."""
43
+
44
+ async def get_exit_code(self) -> int | None:
45
+ """Return the exit code (only valid after is_finished)."""
46
+
47
+ async def close(self) -> None:
48
+ """Release underlying resources."""
49
+
50
+ async def send_signal(self, sig: bytes) -> None:
51
+ """Send a control byte (e.g., b'\\x03' for Ctrl+C)."""
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # SSH implementation (paramiko)
56
+ # ---------------------------------------------------------------------------
57
+
58
+ class SSHChannel(Channel):
59
+ """Wraps a paramiko.Channel into the Channel interface."""
60
+
61
+ def __init__(self, paramiko_channel, stdin=None):
62
+ self._ch = paramiko_channel
63
+ # Keep a reference to the stdin ChannelFile so Python GC does NOT call
64
+ # its __del__ / shutdown_write() early, which would set eof_sent=True
65
+ # on the underlying channel and prevent any future writes.
66
+ self._stdin = stdin
67
+
68
+ async def read(self) -> bytes | None:
69
+ if self._ch.recv_ready():
70
+ return self._ch.recv(4096)
71
+ return None
72
+
73
+ async def write(self, data: bytes) -> None:
74
+ await asyncio.to_thread(self._ch.sendall, data)
75
+
76
+ async def is_finished(self) -> bool:
77
+ return self._ch.exit_status_ready()
78
+
79
+ async def get_exit_code(self) -> int | None:
80
+ # recv_exit_status() is a blocking socket read. Normally is_finished()
81
+ # has already returned True (exit_status_ready), so it returns at
82
+ # once — but EOF can arrive a beat before the exit-status message,
83
+ # and a sync call there would stall the whole event loop.
84
+ return await asyncio.to_thread(self._ch.recv_exit_status)
85
+
86
+ async def close(self) -> None:
87
+ try:
88
+ self._ch.close()
89
+ except Exception:
90
+ pass
91
+
92
+ async def send_signal(self, sig: bytes) -> None:
93
+ await self.write(sig)
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Local subprocess implementation
98
+ # ---------------------------------------------------------------------------
99
+
100
+ class LocalChannel(Channel):
101
+ """Wraps an asyncio.subprocess.Process into the Channel interface."""
102
+
103
+ def __init__(self, process: asyncio.subprocess.Process):
104
+ self._proc = process
105
+
106
+ async def read(self) -> bytes | None:
107
+ try:
108
+ data = await asyncio.wait_for(
109
+ self._proc.stdout.read(4096), timeout=0.05
110
+ )
111
+ # read() returns b"" on EOF
112
+ return data
113
+ except asyncio.TimeoutError:
114
+ return None
115
+
116
+ async def write(self, data: bytes) -> None:
117
+ self._proc.stdin.write(data)
118
+ await self._proc.stdin.drain()
119
+
120
+ async def is_finished(self) -> bool:
121
+ return self._proc.returncode is not None
122
+
123
+ async def get_exit_code(self) -> int | None:
124
+ return self._proc.returncode
125
+
126
+ async def close(self) -> None:
127
+ if self._proc.returncode is None:
128
+ self._proc.terminate()
129
+ try:
130
+ await asyncio.wait_for(self._proc.wait(), timeout=2.0)
131
+ except asyncio.TimeoutError:
132
+ self._proc.kill()
133
+ await self._proc.wait()
134
+
135
+ async def send_signal(self, sig: bytes) -> None:
136
+ os_sig = _LOCAL_SIG_MAP.get(sig)
137
+ if os_sig:
138
+ self._proc.send_signal(os_sig)
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # PTY implementation (pywinpty on Windows, stdlib pty on Linux)
143
+ # ---------------------------------------------------------------------------
144
+
145
+ if PTY_AVAILABLE:
146
+ import subprocess
147
+
148
+ class PtyChannel(Channel):
149
+ """PTY-backed channel. Windows: pywinpty. Linux: stdlib pty."""
150
+
151
+ def __init__(self, command: str, shell: str):
152
+ self._finished = False
153
+ self._exit_code = None
154
+
155
+ if sys.platform == "win32":
156
+ self._pty = PtyProcess.spawn(f'{shell} /c {command}')
157
+ self._fd = None
158
+ self._proc = None
159
+ else:
160
+ master, slave = pty_mod.openpty()
161
+ try:
162
+ self._proc = subprocess.Popen(
163
+ [shell, "-c", command],
164
+ stdin=slave, stdout=slave, stderr=slave,
165
+ preexec_fn=os.setsid,
166
+ )
167
+ except Exception:
168
+ # Popen failed (bad shell path, ENOMEM, EPERM, ...).
169
+ # Without this both PTY fds would leak.
170
+ os.close(master)
171
+ os.close(slave)
172
+ raise
173
+ os.close(slave)
174
+ self._fd = master
175
+ flags = fcntl.fcntl(self._fd, fcntl.F_GETFL)
176
+ fcntl.fcntl(self._fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
177
+ self._pty = None
178
+
179
+ async def read(self) -> bytes | None:
180
+ if sys.platform == "win32":
181
+ try:
182
+ data = await asyncio.to_thread(self._pty.read, 4096)
183
+ return data.encode() if isinstance(data, str) else data
184
+ except EOFError:
185
+ self._finished = True
186
+ return b""
187
+ else:
188
+ try:
189
+ return os.read(self._fd, 4096)
190
+ except BlockingIOError:
191
+ return None
192
+ except OSError:
193
+ return b""
194
+
195
+ async def write(self, data: bytes) -> None:
196
+ if sys.platform == "win32":
197
+ # bytes.decode() defaults to utf-8 regardless of locale, so
198
+ # the normal path is fine. errors='replace' is defensive:
199
+ # if upstream ever hands us non-utf-8 bytes, we'd rather
200
+ # write a replacement char than crash the read loop.
201
+ self._pty.write(data.decode('utf-8', errors='replace'))
202
+ else:
203
+ os.write(self._fd, data)
204
+
205
+ async def is_finished(self) -> bool:
206
+ if self._finished:
207
+ return True
208
+ if sys.platform == "win32":
209
+ if not self._pty.isalive():
210
+ self._exit_code = self._pty.exitstatus
211
+ self._finished = True
212
+ else:
213
+ ret = self._proc.poll()
214
+ if ret is not None:
215
+ self._exit_code = ret
216
+ self._finished = True
217
+ return self._finished
218
+
219
+ async def get_exit_code(self) -> int | None:
220
+ return self._exit_code
221
+
222
+ async def close(self) -> None:
223
+ if sys.platform == "win32":
224
+ if self._pty.isalive():
225
+ self._pty.close()
226
+ else:
227
+ try:
228
+ os.close(self._fd)
229
+ except OSError:
230
+ pass
231
+ if not self._finished:
232
+ self._proc.terminate()
233
+ try:
234
+ await asyncio.to_thread(self._proc.wait, 2.0)
235
+ except subprocess.TimeoutExpired:
236
+ self._proc.kill()
237
+ await asyncio.to_thread(self._proc.wait)
238
+
239
+ async def send_signal(self, sig: bytes) -> None:
240
+ await self.write(sig)
241
+ if sys.platform == "win32" and sig == b'\x03':
242
+ self._finished = True
@@ -0,0 +1,105 @@
1
+ import asyncio
2
+ import codecs
3
+ import logging
4
+
5
+ from channel import Channel
6
+
7
+ logger = logging.getLogger("interminal.command")
8
+
9
+
10
+ class RunningCommand:
11
+ """Manages one running command with background output buffering."""
12
+
13
+ def __init__(self, channel: Channel, session_id: str):
14
+ self.channel = channel
15
+ self.session_id = session_id
16
+
17
+ self.buffer = ""
18
+ self.running = True
19
+ self.exit_code = None
20
+ self.new_data_event = asyncio.Event()
21
+ self.decoder = codecs.getincrementaldecoder('utf-8')('replace')
22
+ # Wall-clock of the last byte arrival. Lets _wait_for_result compute
23
+ # "already silent for X seconds" across calls so a poll on a quiet
24
+ # stream returns without re-waiting the full pause_timeout.
25
+ # Initialized to now so the first Phase 2 entry without prior data
26
+ # doesn't short-circuit.
27
+ self.last_data_time = asyncio.get_running_loop().time()
28
+ self.read_task = asyncio.create_task(self._read_loop())
29
+
30
+ async def _read_loop(self):
31
+ # The finally block matters: CancelledError isn't an Exception subclass
32
+ # (it's BaseException), so a cancel on this task skips the except below.
33
+ # Without finally, running=False + event.set() would never run on
34
+ # cancel, leaving any waiter on new_data_event stuck until its own
35
+ # pause_timeout fires.
36
+ try:
37
+ while self.running:
38
+ try:
39
+ raw = await self.channel.read()
40
+ has_data = False
41
+
42
+ if raw is not None and raw != b"":
43
+ self.buffer += self.decoder.decode(raw)
44
+ has_data = True
45
+ self.last_data_time = asyncio.get_running_loop().time()
46
+ self.new_data_event.set()
47
+
48
+ is_eof = (raw == b"")
49
+
50
+ if is_eof or await self.channel.is_finished():
51
+ self.exit_code = await self.channel.get_exit_code()
52
+ # 排空 read() 和 is_finished() 之间可能积累的数据(SSH 必需)
53
+ while True:
54
+ remaining = await self.channel.read()
55
+ if remaining is None or remaining == b"":
56
+ break
57
+ self.buffer += self.decoder.decode(remaining)
58
+ tail = self.decoder.decode(b'', final=True)
59
+ if tail:
60
+ self.buffer += tail
61
+ break
62
+ elif not has_data:
63
+ await asyncio.sleep(0.05)
64
+ except Exception as e:
65
+ logger.warning("_read_loop error: %s", e)
66
+ # Best-effort exit code so the caller doesn't see
67
+ # status="completed" + exit_code=None for an abnormal exit.
68
+ try:
69
+ self.exit_code = await self.channel.get_exit_code()
70
+ except Exception:
71
+ pass
72
+ break
73
+ finally:
74
+ self.running = False
75
+ self.new_data_event.set()
76
+
77
+ def read_output(self) -> str:
78
+ data = self.buffer
79
+ self.buffer = ""
80
+ return data
81
+
82
+ async def write_input(self, text: str) -> None:
83
+ await self.channel.write(text.encode('utf-8'))
84
+
85
+ def check_status(self) -> tuple[bool, int | None]:
86
+ return not self.running, self.exit_code
87
+
88
+ async def close(self):
89
+ self.running = False
90
+ # Wake any waiter on new_data_event immediately — the read_task's
91
+ # finally will set it too, but doing it here means waiters see
92
+ # running=False without having to wait for the task to actually
93
+ # tear down.
94
+ self.new_data_event.set()
95
+ # Close the channel first so any blocking read (notably Windows PTY,
96
+ # which sits in asyncio.to_thread(pty.read) and can't be cancelled)
97
+ # unblocks. Only then await the read task — otherwise the to_thread
98
+ # future will hold us until the thread itself exits, which it can't
99
+ # do while the PTY is still open.
100
+ await self.channel.close()
101
+ self.read_task.cancel()
102
+ try:
103
+ await self.read_task
104
+ except (asyncio.CancelledError, Exception):
105
+ pass