tabby-bianbu-mcp 0.7.0 → 0.8.0
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.
- package/CHANGELOG.md +22 -0
- package/README.md +70 -13
- package/assets/bianbu_agent_proxy.meta.json +5 -5
- package/assets/bianbu_agent_proxy.sh +398 -3
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp.service.d.ts +9 -0
- package/dist/ptySession.d.ts +31 -0
- package/dist/remoteRelease.d.ts +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `tabby-bianbu-mcp` will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.8.0] - 2026-03-23
|
|
6
|
+
|
|
7
|
+
### Added (Remote MCP Server v1.4.0)
|
|
8
|
+
- **real PTY terminal sessions**: 5 new MCP tools (`open_pty_session`, `write_pty_input`, `read_pty_output`, `resize_pty`, `close_pty_session`) backed by a Python PTY helper using the standard library `pty` module — enables full interactive terminals (vim, htop, claude CLI, etc.)
|
|
9
|
+
- **long-polling output**: `read_pty_output` holds the request up to 5s (configurable `timeout_ms`) until data arrives, achieving near-instant output delivery with minimal request overhead (~0.2 req/s idle vs 20 req/s with short polling)
|
|
10
|
+
- **PTY session concurrency cap**: `MAX_PTY_SESSIONS` (default 4) limits simultaneous PTY sessions
|
|
11
|
+
- **`pty_session` capability flag** in health `supports` for client feature detection
|
|
12
|
+
|
|
13
|
+
### Changed (Remote MCP Server v1.4.0)
|
|
14
|
+
- script version bumped to 1.4.0, server version bumped to 1.4.0
|
|
15
|
+
- `gracefulShutdown` now cleans up PTY sessions
|
|
16
|
+
- idle session sweeper now covers PTY sessions
|
|
17
|
+
|
|
18
|
+
### Added (Plugin)
|
|
19
|
+
- **`BianbuPtySession`**: new session class extending Tabby's `BaseSession` with 16ms input batching, long-poll output loop, and automatic resize forwarding
|
|
20
|
+
- **PTY auto-detection**: shell tab checks remote `supports.ptySession` and automatically uses real PTY mode when available, falling back to the existing command-based `BianbuShellSession`
|
|
21
|
+
- **PTY lane bypass**: `readPtyOutputDirect()` calls `executeRequest` directly instead of going through the interactive RequestLane, preventing 5s long-polls from blocking other operations
|
|
22
|
+
- PTY capability indicator (`pty=✓/✗`) in the settings diagnostics panel
|
|
23
|
+
|
|
24
|
+
### Changed (Plugin)
|
|
25
|
+
- bundled remote installer updated to script v1.4.0 / server v1.4.0
|
|
26
|
+
|
|
5
27
|
## [0.7.0] - 2026-03-23
|
|
6
28
|
|
|
7
29
|
### Added (Remote MCP Server v1.3.0)
|
package/README.md
CHANGED
|
@@ -164,7 +164,63 @@ Search for `tabby-bianbu-mcp` in **Settings → Plugins → Install from npm**
|
|
|
164
164
|
|
|
165
165
|
## 🚀 Quick Start / 快速上手
|
|
166
166
|
|
|
167
|
-
### 1.
|
|
167
|
+
### 1. Get API Key / 获取 API 密钥
|
|
168
|
+
|
|
169
|
+
<table>
|
|
170
|
+
<tr><td width="50%">
|
|
171
|
+
|
|
172
|
+
**English**: Log in to [Bianbu Cloud](https://cloud.bianbu.org) console, click your avatar (top-right) → **API Key** in the sidebar. Copy the full key.
|
|
173
|
+
|
|
174
|
+
</td><td width="50%">
|
|
175
|
+
|
|
176
|
+
**中文**: 登录 [算能板卡宇宙](https://cloud.bianbu.org) 控制台,点击右上角头像 → 左侧菜单 **API Key**,复制完整密钥。
|
|
177
|
+
|
|
178
|
+
</td></tr>
|
|
179
|
+
</table>
|
|
180
|
+
|
|
181
|
+
<div align="center">
|
|
182
|
+
<img src="docs/images/bianbu-console-overview.png" width="720" />
|
|
183
|
+
<br/>
|
|
184
|
+
<sub>控制台首页 — 点击右上角头像进入个人设置 / Console home — click avatar to enter profile settings</sub>
|
|
185
|
+
<br/><br/>
|
|
186
|
+
<img src="docs/images/bianbu-api-key.png" width="720" />
|
|
187
|
+
<br/>
|
|
188
|
+
<sub>侧栏点击 "API Key",复制星号处的完整密钥 / Click "API Key" in sidebar, copy the full key</sub>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<br/>
|
|
192
|
+
|
|
193
|
+
### 2. Get MCP URL / 获取 MCP 地址
|
|
194
|
+
|
|
195
|
+
<table>
|
|
196
|
+
<tr><td width="50%">
|
|
197
|
+
|
|
198
|
+
**English**: On the console home page, click **"开始远程"** (Start Remote) on your instance card. In the popup, find the domain like `xxx.gdriscv.com`. Your MCP URL is:
|
|
199
|
+
|
|
200
|
+
`https://<that-domain>/mcp`
|
|
201
|
+
|
|
202
|
+
In the plugin settings, you only need to paste the domain part (without `https://` or `/mcp`).
|
|
203
|
+
|
|
204
|
+
</td><td width="50%">
|
|
205
|
+
|
|
206
|
+
**中文**: 在控制台首页,点击实例卡片上的 **"开始远程"**。在弹窗中找到类似 `xxx.gdriscv.com` 的域名。你的 MCP 地址是:
|
|
207
|
+
|
|
208
|
+
`https://<该域名>/mcp`
|
|
209
|
+
|
|
210
|
+
在插件设置中,只需粘贴域名部分(不含 `https://` 和 `/mcp`)。
|
|
211
|
+
|
|
212
|
+
</td></tr>
|
|
213
|
+
</table>
|
|
214
|
+
|
|
215
|
+
<div align="center">
|
|
216
|
+
<img src="docs/images/bianbu-mcp-url.png" width="720" />
|
|
217
|
+
<br/>
|
|
218
|
+
<sub>点击"本地连接",在 connect 行找到域名 / Click "本地连接", find the domain in the connect line</sub>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<br/>
|
|
222
|
+
|
|
223
|
+
### 3. Configure Plugin / 配置插件
|
|
168
224
|
|
|
169
225
|
Open **Settings → Bianbu MCP** in Tabby and fill in:
|
|
170
226
|
|
|
@@ -172,20 +228,20 @@ Open **Settings → Bianbu MCP** in Tabby and fill in:
|
|
|
172
228
|
|
|
173
229
|
| Field / 字段 | Description / 说明 | Example / 示例 |
|
|
174
230
|
|:---|:---|:---|
|
|
175
|
-
| **
|
|
176
|
-
| **
|
|
177
|
-
| **
|
|
231
|
+
| **Domain** | Paste domain only (no `https://`, no `/mcp`) / 只粘贴域名 | `xxx.gdriscv.com` |
|
|
232
|
+
| **API Key** | Full key from step 1 / 第 1 步复制的完整密钥 | `your-api-key` |
|
|
233
|
+
| **Name** | Display name / 显示名称 | `bianbu` |
|
|
178
234
|
|
|
179
|
-
###
|
|
235
|
+
### 4. Connect / 连接
|
|
180
236
|
|
|
181
|
-
Click **"Test
|
|
237
|
+
Click **"Test connection"** to verify. Then use:
|
|
182
238
|
|
|
183
239
|
点击 **"Test connection"** 验证。然后使用:
|
|
184
240
|
|
|
185
|
-
- **"Open
|
|
186
|
-
- **"Open
|
|
241
|
+
- **"Open Shell"** — for terminal access / 打开终端
|
|
242
|
+
- **"Open Files"** — for file management / 打开文件管理器
|
|
187
243
|
|
|
188
|
-
###
|
|
244
|
+
### 5. MCP Snippet / MCP 配置片段
|
|
189
245
|
|
|
190
246
|
Copy the auto-generated JSON config for other MCP clients:
|
|
191
247
|
|
|
@@ -219,15 +275,16 @@ Copy the auto-generated JSON config for other MCP clients:
|
|
|
219
275
|
| Key / 键 | Default / 默认值 | Description (EN) | 说明 (中文) |
|
|
220
276
|
|:---|:---|:---|:---|
|
|
221
277
|
| `name` | `bianbu` | Profile display name | 配置显示名称 |
|
|
222
|
-
| `
|
|
278
|
+
| `domain` | *(empty)* | MCP domain (without `https://` or `/mcp`) | MCP 域名(不含 `https://` 和 `/mcp`) |
|
|
223
279
|
| `apiKey` | *(empty)* | `X-API-KEY` header value | API 密钥 |
|
|
224
280
|
| `interactiveConcurrency` | `2` | Interactive request slots | 交互请求并发槽位数 |
|
|
225
281
|
| `transferConcurrency` | `30` | Transfer request slots | 传输请求并发槽位数 |
|
|
226
|
-
| `
|
|
282
|
+
| `maxConcurrentFiles` | `3` | Max parallel file transfers | 最大并行文件传输数 |
|
|
283
|
+
| `workerCadenceMs` | `0` | Dispatch cycle interval (ms); adaptive throttling auto-adjusts | 调度周期间隔 (毫秒);自适应节流自动调整 |
|
|
227
284
|
| `maxRetries` | `2` | Max retry attempts | 最大重试次数 |
|
|
228
285
|
| `retryBaseMs` | `1000` | Base delay between retries (ms) | 重试基础间隔 (毫秒) |
|
|
229
|
-
| `uploadChunkBytes` | `
|
|
230
|
-
| `downloadChunkBytes` | `
|
|
286
|
+
| `uploadChunkBytes` | `65536` | Upload chunk size (bytes) | 上传分块大小 (字节) |
|
|
287
|
+
| `downloadChunkBytes` | `262144` | Download chunk size (bytes) | 下载分块大小 (字节) |
|
|
231
288
|
| `notes` | *(empty)* | User notes | 用户备注 |
|
|
232
289
|
| `installerRemotePath` | `/tmp/bianbu_agent_proxy.sh` | Remote installer path | 远端安装脚本路径 |
|
|
233
290
|
| `maintenanceAsRoot` | `true` | Run maintenance as root | 以 root 执行维护 |
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"fileName": "bianbu_agent_proxy.sh",
|
|
3
3
|
"sourceFile": "bianbu_agent_proxy.sh",
|
|
4
|
-
"scriptVersion": "1.
|
|
5
|
-
"serverVersion": "1.
|
|
6
|
-
"sha256": "
|
|
7
|
-
"bytes":
|
|
8
|
-
"generatedAt": "2026-03-
|
|
4
|
+
"scriptVersion": "1.4.0",
|
|
5
|
+
"serverVersion": "1.4.0",
|
|
6
|
+
"sha256": "14e858536af489a30d7c7f5d3a078659015ac315d66b5486949ae1f998d33df8",
|
|
7
|
+
"bytes": 85475,
|
|
8
|
+
"generatedAt": "2026-03-23T09:16:09.632Z"
|
|
9
9
|
}
|
|
@@ -4,8 +4,8 @@ set -Eeuo pipefail
|
|
|
4
4
|
umask 077
|
|
5
5
|
|
|
6
6
|
SCRIPT_NAME="$(basename "$0")"
|
|
7
|
-
SCRIPT_VERSION="${SCRIPT_VERSION:-1.
|
|
8
|
-
SERVER_VERSION="${SERVER_VERSION:-1.
|
|
7
|
+
SCRIPT_VERSION="${SCRIPT_VERSION:-1.4.0}"
|
|
8
|
+
SERVER_VERSION="${SERVER_VERSION:-1.4.0}"
|
|
9
9
|
APP_NAME="bianbu-mcp-server"
|
|
10
10
|
INSTALL_ROOT="/opt/${APP_NAME}"
|
|
11
11
|
APP_FILE="${INSTALL_ROOT}/server.mjs"
|
|
@@ -29,6 +29,7 @@ MAX_CONCURRENT_REQUESTS="${MAX_CONCURRENT_REQUESTS:-32}"
|
|
|
29
29
|
MAX_UPLOAD_SESSIONS="${MAX_UPLOAD_SESSIONS:-16}"
|
|
30
30
|
MAX_DOWNLOAD_SESSIONS="${MAX_DOWNLOAD_SESSIONS:-16}"
|
|
31
31
|
MAX_SHELL_SESSIONS="${MAX_SHELL_SESSIONS:-8}"
|
|
32
|
+
MAX_PTY_SESSIONS="${MAX_PTY_SESSIONS:-4}"
|
|
32
33
|
TLS_CERT_FILE="${TLS_CERT_FILE:-}"
|
|
33
34
|
TLS_KEY_FILE="${TLS_KEY_FILE:-}"
|
|
34
35
|
MCP_TRANSPORT_MODE="${MCP_TRANSPORT_MODE:-stateless}"
|
|
@@ -91,6 +92,7 @@ MCP tools:
|
|
|
91
92
|
- delete_path
|
|
92
93
|
- rename_path
|
|
93
94
|
- open_shell_session / exec_shell_session / close_shell_session
|
|
95
|
+
- open_pty_session / write_pty_input / read_pty_output / resize_pty / close_pty_session
|
|
94
96
|
- upload_chunked_begin / upload_chunked_part / upload_chunked_finish / upload_chunked_abort
|
|
95
97
|
- download_chunked_begin / download_chunked_part / download_chunked_close
|
|
96
98
|
|
|
@@ -110,6 +112,7 @@ MCP tools:
|
|
|
110
112
|
MAX_UPLOAD_SESSIONS 最大并发上传会话数,默认: ${MAX_UPLOAD_SESSIONS}
|
|
111
113
|
MAX_DOWNLOAD_SESSIONS 最大并发下载会话数,默认: ${MAX_DOWNLOAD_SESSIONS}
|
|
112
114
|
MAX_SHELL_SESSIONS 最大并发 Shell 会话数,默认: ${MAX_SHELL_SESSIONS}
|
|
115
|
+
MAX_PTY_SESSIONS 最大并发 PTY 会话数,默认: ${MAX_PTY_SESSIONS}
|
|
113
116
|
TLS_CERT_FILE 可选,HTTPS 证书路径
|
|
114
117
|
TLS_KEY_FILE 可选,HTTPS 私钥路径
|
|
115
118
|
|
|
@@ -377,7 +380,7 @@ write_app() {
|
|
|
377
380
|
local app_file="${1:-$APP_FILE}"
|
|
378
381
|
write_root_file "$app_file" 755 <<'EOF'
|
|
379
382
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
380
|
-
import { exec as execCb } from 'node:child_process';
|
|
383
|
+
import { exec as execCb, spawn as spawnCb } from 'node:child_process';
|
|
381
384
|
import { promisify } from 'node:util';
|
|
382
385
|
import os from 'node:os';
|
|
383
386
|
import fs from 'node:fs';
|
|
@@ -408,6 +411,11 @@ const SUPPORTED_TOOLS = [
|
|
|
408
411
|
'open_shell_session',
|
|
409
412
|
'exec_shell_session',
|
|
410
413
|
'close_shell_session',
|
|
414
|
+
'open_pty_session',
|
|
415
|
+
'write_pty_input',
|
|
416
|
+
'read_pty_output',
|
|
417
|
+
'resize_pty',
|
|
418
|
+
'close_pty_session',
|
|
411
419
|
'upload_chunked_begin',
|
|
412
420
|
'upload_chunked_part',
|
|
413
421
|
'upload_chunked_finish',
|
|
@@ -434,11 +442,14 @@ const MAX_CONCURRENT_REQUESTS = Number(process.env.MAX_CONCURRENT_REQUESTS || '3
|
|
|
434
442
|
const MAX_UPLOAD_SESSIONS = Number(process.env.MAX_UPLOAD_SESSIONS || '16');
|
|
435
443
|
const MAX_DOWNLOAD_SESSIONS = Number(process.env.MAX_DOWNLOAD_SESSIONS || '16');
|
|
436
444
|
const MAX_SHELL_SESSIONS = Number(process.env.MAX_SHELL_SESSIONS || '8');
|
|
445
|
+
const MAX_PTY_SESSIONS = Number(process.env.MAX_PTY_SESSIONS || '4');
|
|
446
|
+
const PTY_OUTPUT_BUFFER_MAX = 512 * 1024;
|
|
437
447
|
const CANONICAL_FILE_ROOT = FILE_ROOT === '/' ? '/' : fs.realpathSync(FILE_ROOT);
|
|
438
448
|
const HAS_SUDO = fs.existsSync('/usr/bin/sudo') || fs.existsSync('/bin/sudo');
|
|
439
449
|
const shellSessions = new Map();
|
|
440
450
|
const uploadSessions = new Map();
|
|
441
451
|
const downloadSessions = new Map();
|
|
452
|
+
const ptySessions = new Map();
|
|
442
453
|
const SESSION_IDLE_MS = 60 * 60 * 1000;
|
|
443
454
|
const SERVER_START_TIME = Date.now();
|
|
444
455
|
let activeRequests = 0;
|
|
@@ -731,6 +742,249 @@ async function cleanupUploadSession(session) {
|
|
|
731
742
|
await deleteAnyPath(session.temp_dir, true, session.as_root).catch(() => {});
|
|
732
743
|
}
|
|
733
744
|
|
|
745
|
+
function ptyHelperScript() {
|
|
746
|
+
return String.raw`#!/usr/bin/env python3
|
|
747
|
+
import pty, os, sys, select, signal, struct, fcntl, termios, json, base64
|
|
748
|
+
|
|
749
|
+
def set_winsize(fd, rows, cols):
|
|
750
|
+
winsize = struct.pack('HHHH', rows, cols, 0, 0)
|
|
751
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
|
752
|
+
|
|
753
|
+
def send_msg(msg):
|
|
754
|
+
sys.stdout.write(json.dumps(msg, ensure_ascii=False) + '\n')
|
|
755
|
+
sys.stdout.flush()
|
|
756
|
+
|
|
757
|
+
config = json.loads(base64.b64decode(sys.argv[1]).decode('utf-8'))
|
|
758
|
+
initial_cols = config.get('cols', 80)
|
|
759
|
+
initial_rows = config.get('rows', 24)
|
|
760
|
+
shell = config.get('shell', '/bin/bash')
|
|
761
|
+
cwd = config.get('cwd', '/')
|
|
762
|
+
env_override = config.get('env', {})
|
|
763
|
+
|
|
764
|
+
master_fd, slave_fd = pty.openpty()
|
|
765
|
+
set_winsize(slave_fd, initial_rows, initial_cols)
|
|
766
|
+
|
|
767
|
+
pid = os.fork()
|
|
768
|
+
if pid == 0:
|
|
769
|
+
os.close(master_fd)
|
|
770
|
+
os.setsid()
|
|
771
|
+
fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0)
|
|
772
|
+
os.dup2(slave_fd, 0)
|
|
773
|
+
os.dup2(slave_fd, 1)
|
|
774
|
+
os.dup2(slave_fd, 2)
|
|
775
|
+
if slave_fd > 2:
|
|
776
|
+
os.close(slave_fd)
|
|
777
|
+
try:
|
|
778
|
+
os.chdir(cwd)
|
|
779
|
+
except OSError:
|
|
780
|
+
pass
|
|
781
|
+
env = os.environ.copy()
|
|
782
|
+
env['TERM'] = 'xterm-256color'
|
|
783
|
+
env['COLUMNS'] = str(initial_cols)
|
|
784
|
+
env['LINES'] = str(initial_rows)
|
|
785
|
+
env.update(env_override)
|
|
786
|
+
os.execvpe(shell, [shell, '-l'], env)
|
|
787
|
+
else:
|
|
788
|
+
os.close(slave_fd)
|
|
789
|
+
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
|
790
|
+
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
791
|
+
stdin_fd = sys.stdin.fileno()
|
|
792
|
+
old_stdin_flags = fcntl.fcntl(stdin_fd, fcntl.F_GETFL)
|
|
793
|
+
fcntl.fcntl(stdin_fd, fcntl.F_SETFL, old_stdin_flags | os.O_NONBLOCK)
|
|
794
|
+
running = True
|
|
795
|
+
def on_sigchld(signum, frame):
|
|
796
|
+
nonlocal running
|
|
797
|
+
running = False
|
|
798
|
+
signal.signal(signal.SIGCHLD, on_sigchld)
|
|
799
|
+
stdin_buf = b''
|
|
800
|
+
while running:
|
|
801
|
+
try:
|
|
802
|
+
readable, _, _ = select.select([master_fd, stdin_fd], [], [], 0.05)
|
|
803
|
+
except (select.error, InterruptedError, ValueError):
|
|
804
|
+
if not running:
|
|
805
|
+
break
|
|
806
|
+
continue
|
|
807
|
+
if master_fd in readable:
|
|
808
|
+
try:
|
|
809
|
+
data = os.read(master_fd, 65536)
|
|
810
|
+
if not data:
|
|
811
|
+
break
|
|
812
|
+
send_msg({'type': 'output', 'data': base64.b64encode(data).decode('ascii')})
|
|
813
|
+
except OSError:
|
|
814
|
+
break
|
|
815
|
+
if stdin_fd in readable:
|
|
816
|
+
try:
|
|
817
|
+
chunk = os.read(stdin_fd, 65536)
|
|
818
|
+
if not chunk:
|
|
819
|
+
break
|
|
820
|
+
stdin_buf += chunk
|
|
821
|
+
while b'\n' in stdin_buf:
|
|
822
|
+
line, stdin_buf = stdin_buf.split(b'\n', 1)
|
|
823
|
+
line = line.strip()
|
|
824
|
+
if not line:
|
|
825
|
+
continue
|
|
826
|
+
try:
|
|
827
|
+
cmd = json.loads(line.decode('utf-8'))
|
|
828
|
+
if cmd['type'] == 'input':
|
|
829
|
+
raw = base64.b64decode(cmd['data'])
|
|
830
|
+
os.write(master_fd, raw)
|
|
831
|
+
elif cmd['type'] == 'resize':
|
|
832
|
+
set_winsize(master_fd, cmd['rows'], cmd['cols'])
|
|
833
|
+
try:
|
|
834
|
+
os.kill(pid, signal.SIGWINCH)
|
|
835
|
+
except OSError:
|
|
836
|
+
pass
|
|
837
|
+
elif cmd['type'] == 'close':
|
|
838
|
+
running = False
|
|
839
|
+
break
|
|
840
|
+
except (json.JSONDecodeError, KeyError, OSError):
|
|
841
|
+
pass
|
|
842
|
+
except OSError:
|
|
843
|
+
break
|
|
844
|
+
try:
|
|
845
|
+
os.kill(pid, signal.SIGTERM)
|
|
846
|
+
except OSError:
|
|
847
|
+
pass
|
|
848
|
+
exit_code = -1
|
|
849
|
+
try:
|
|
850
|
+
for _ in range(50):
|
|
851
|
+
rpid, status = os.waitpid(pid, os.WNOHANG)
|
|
852
|
+
if rpid != 0:
|
|
853
|
+
exit_code = os.WEXITSTATUS(status) if os.WIFEXITED(status) else -1
|
|
854
|
+
break
|
|
855
|
+
import time; time.sleep(0.1)
|
|
856
|
+
else:
|
|
857
|
+
try:
|
|
858
|
+
os.kill(pid, signal.SIGKILL)
|
|
859
|
+
os.waitpid(pid, 0)
|
|
860
|
+
except OSError:
|
|
861
|
+
pass
|
|
862
|
+
except ChildProcessError:
|
|
863
|
+
pass
|
|
864
|
+
send_msg({'type': 'exit', 'code': exit_code})
|
|
865
|
+
os.close(master_fd)
|
|
866
|
+
`;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
let ptyHelperPath = '';
|
|
870
|
+
async function ensurePtyHelper() {
|
|
871
|
+
if (!ptyHelperPath) {
|
|
872
|
+
ptyHelperPath = '/tmp/.mcp_pty_helper.py';
|
|
873
|
+
await fs.promises.writeFile(ptyHelperPath, ptyHelperScript(), { mode: 0o755 });
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function createPtySession(id, cwd, asRoot, cols, rows) {
|
|
878
|
+
const config = { cols, rows, shell: '/bin/bash', cwd };
|
|
879
|
+
const encoded = Buffer.from(JSON.stringify(config), 'utf8').toString('base64');
|
|
880
|
+
const args = asRoot
|
|
881
|
+
? ['-c', `sudo -n -- python3 ${ptyHelperPath} ${shellQuote(encoded)}`]
|
|
882
|
+
: ['-c', `python3 ${ptyHelperPath} ${shellQuote(encoded)}`];
|
|
883
|
+
const child = spawnCb('/bin/bash', args, {
|
|
884
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
885
|
+
cwd: '/',
|
|
886
|
+
});
|
|
887
|
+
const session = {
|
|
888
|
+
id,
|
|
889
|
+
child,
|
|
890
|
+
outputBuffer: [],
|
|
891
|
+
outputBufferSize: 0,
|
|
892
|
+
alive: true,
|
|
893
|
+
asRoot,
|
|
894
|
+
cols,
|
|
895
|
+
rows,
|
|
896
|
+
exitCode: null,
|
|
897
|
+
createdAt: Date.now(),
|
|
898
|
+
updatedAt: Date.now(),
|
|
899
|
+
waiters: [],
|
|
900
|
+
};
|
|
901
|
+
let lineBuf = '';
|
|
902
|
+
child.stdout.on('data', (chunk) => {
|
|
903
|
+
lineBuf += chunk.toString();
|
|
904
|
+
let idx;
|
|
905
|
+
while ((idx = lineBuf.indexOf('\n')) >= 0) {
|
|
906
|
+
const line = lineBuf.slice(0, idx).trim();
|
|
907
|
+
lineBuf = lineBuf.slice(idx + 1);
|
|
908
|
+
if (!line) continue;
|
|
909
|
+
try {
|
|
910
|
+
const msg = JSON.parse(line);
|
|
911
|
+
if (msg.type === 'output' && msg.data) {
|
|
912
|
+
const buf = Buffer.from(msg.data, 'base64');
|
|
913
|
+
session.outputBuffer.push(buf);
|
|
914
|
+
session.outputBufferSize += buf.length;
|
|
915
|
+
while (session.outputBufferSize > PTY_OUTPUT_BUFFER_MAX && session.outputBuffer.length > 1) {
|
|
916
|
+
const dropped = session.outputBuffer.shift();
|
|
917
|
+
session.outputBufferSize -= dropped.length;
|
|
918
|
+
}
|
|
919
|
+
session.updatedAt = Date.now();
|
|
920
|
+
// Wake any long-poll waiters
|
|
921
|
+
for (const waiter of session.waiters) {
|
|
922
|
+
waiter();
|
|
923
|
+
}
|
|
924
|
+
session.waiters = [];
|
|
925
|
+
} else if (msg.type === 'exit') {
|
|
926
|
+
session.alive = false;
|
|
927
|
+
session.exitCode = msg.code;
|
|
928
|
+
session.updatedAt = Date.now();
|
|
929
|
+
for (const waiter of session.waiters) {
|
|
930
|
+
waiter();
|
|
931
|
+
}
|
|
932
|
+
session.waiters = [];
|
|
933
|
+
}
|
|
934
|
+
} catch {}
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
child.stderr.on('data', () => {});
|
|
938
|
+
child.on('exit', () => {
|
|
939
|
+
session.alive = false;
|
|
940
|
+
session.updatedAt = Date.now();
|
|
941
|
+
for (const waiter of session.waiters) {
|
|
942
|
+
waiter();
|
|
943
|
+
}
|
|
944
|
+
session.waiters = [];
|
|
945
|
+
});
|
|
946
|
+
ptySessions.set(id, session);
|
|
947
|
+
return session;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function destroyPtySession(id) {
|
|
951
|
+
const session = ptySessions.get(id);
|
|
952
|
+
if (!session) return;
|
|
953
|
+
session.alive = false;
|
|
954
|
+
for (const waiter of session.waiters) {
|
|
955
|
+
waiter();
|
|
956
|
+
}
|
|
957
|
+
session.waiters = [];
|
|
958
|
+
try {
|
|
959
|
+
session.child.stdin.write(JSON.stringify({ type: 'close' }) + '\n');
|
|
960
|
+
} catch {}
|
|
961
|
+
setTimeout(() => {
|
|
962
|
+
try { session.child.kill('SIGKILL'); } catch {}
|
|
963
|
+
}, 3000).unref();
|
|
964
|
+
session.outputBuffer = [];
|
|
965
|
+
session.outputBufferSize = 0;
|
|
966
|
+
ptySessions.delete(id);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function drainPtyOutput(session) {
|
|
970
|
+
if (session.outputBuffer.length === 0) {
|
|
971
|
+
return {
|
|
972
|
+
data_base64: '',
|
|
973
|
+
alive: session.alive,
|
|
974
|
+
exit_code: session.alive ? undefined : session.exitCode,
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
const data = Buffer.concat(session.outputBuffer);
|
|
978
|
+
session.outputBuffer = [];
|
|
979
|
+
session.outputBufferSize = 0;
|
|
980
|
+
session.updatedAt = Date.now();
|
|
981
|
+
return {
|
|
982
|
+
data_base64: data.toString('base64'),
|
|
983
|
+
alive: session.alive,
|
|
984
|
+
exit_code: session.alive ? undefined : session.exitCode,
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
|
|
734
988
|
function sweepSessions() {
|
|
735
989
|
const now = Date.now();
|
|
736
990
|
for (const [id, entry] of shellSessions.entries()) {
|
|
@@ -749,6 +1003,11 @@ function sweepSessions() {
|
|
|
749
1003
|
downloadSessions.delete(id);
|
|
750
1004
|
}
|
|
751
1005
|
}
|
|
1006
|
+
for (const [id, entry] of ptySessions.entries()) {
|
|
1007
|
+
if (now - entry.updatedAt > SESSION_IDLE_MS) {
|
|
1008
|
+
destroyPtySession(id);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
752
1011
|
}
|
|
753
1012
|
setInterval(sweepSessions, 5 * 60 * 1000).unref();
|
|
754
1013
|
|
|
@@ -920,11 +1179,13 @@ function makeServer() {
|
|
|
920
1179
|
shell: MAX_SHELL_SESSIONS,
|
|
921
1180
|
upload: MAX_UPLOAD_SESSIONS,
|
|
922
1181
|
download: MAX_DOWNLOAD_SESSIONS,
|
|
1182
|
+
pty: MAX_PTY_SESSIONS,
|
|
923
1183
|
},
|
|
924
1184
|
active_sessions: {
|
|
925
1185
|
shell: shellSessions.size,
|
|
926
1186
|
upload: uploadSessions.size,
|
|
927
1187
|
download: downloadSessions.size,
|
|
1188
|
+
pty: ptySessions.size,
|
|
928
1189
|
},
|
|
929
1190
|
concurrency: {
|
|
930
1191
|
max_concurrent_requests: MAX_CONCURRENT_REQUESTS,
|
|
@@ -951,6 +1212,7 @@ function makeServer() {
|
|
|
951
1212
|
shell_session: true,
|
|
952
1213
|
rate_limiting: true,
|
|
953
1214
|
iso_timestamps: true,
|
|
1215
|
+
pty_session: true,
|
|
954
1216
|
},
|
|
955
1217
|
};
|
|
956
1218
|
return textResult(JSON.stringify(payload, null, 2), payload);
|
|
@@ -1284,6 +1546,134 @@ function makeServer() {
|
|
|
1284
1546
|
},
|
|
1285
1547
|
);
|
|
1286
1548
|
|
|
1549
|
+
// ── PTY session tools ──────────────────────────────────────────
|
|
1550
|
+
|
|
1551
|
+
server.registerTool(
|
|
1552
|
+
'open_pty_session',
|
|
1553
|
+
{
|
|
1554
|
+
description: 'Open a real PTY shell session with streaming I/O. Use write_pty_input to send keystrokes and read_pty_output to receive terminal output.',
|
|
1555
|
+
inputSchema: {
|
|
1556
|
+
cwd: z.string().default('.'),
|
|
1557
|
+
as_root: z.boolean().default(false),
|
|
1558
|
+
cols: z.number().int().min(1).max(500).default(80),
|
|
1559
|
+
rows: z.number().int().min(1).max(200).default(24),
|
|
1560
|
+
},
|
|
1561
|
+
},
|
|
1562
|
+
async ({ cwd, as_root, cols, rows }) => {
|
|
1563
|
+
if (ptySessions.size >= MAX_PTY_SESSIONS) {
|
|
1564
|
+
throw new Error(`pty session limit reached (max ${MAX_PTY_SESSIONS})`);
|
|
1565
|
+
}
|
|
1566
|
+
const workingDirectory = await resolveRequestedPath(cwd, as_root);
|
|
1567
|
+
const stat = await fs.promises.stat(workingDirectory).catch(() => null);
|
|
1568
|
+
if (!stat || !stat.isDirectory()) {
|
|
1569
|
+
throw new Error(`cwd not found or not a directory: ${workingDirectory}`);
|
|
1570
|
+
}
|
|
1571
|
+
await ensurePtyHelper();
|
|
1572
|
+
const session_id = newSessionId('pty');
|
|
1573
|
+
createPtySession(session_id, workingDirectory, as_root, cols, rows);
|
|
1574
|
+
const payload = { session_id, cwd: workingDirectory, as_root, cols, rows };
|
|
1575
|
+
return textResult(JSON.stringify(payload, null, 2), payload);
|
|
1576
|
+
},
|
|
1577
|
+
);
|
|
1578
|
+
|
|
1579
|
+
server.registerTool(
|
|
1580
|
+
'write_pty_input',
|
|
1581
|
+
{
|
|
1582
|
+
description: 'Send raw terminal input (keystrokes) to a PTY session.',
|
|
1583
|
+
inputSchema: {
|
|
1584
|
+
session_id: z.string(),
|
|
1585
|
+
data_base64: z.string(),
|
|
1586
|
+
},
|
|
1587
|
+
},
|
|
1588
|
+
async ({ session_id, data_base64 }) => {
|
|
1589
|
+
const session = ptySessions.get(session_id);
|
|
1590
|
+
if (!session) throw new Error(`unknown pty session: ${session_id}`);
|
|
1591
|
+
if (!session.alive) throw new Error(`pty session is no longer alive: ${session_id}`);
|
|
1592
|
+
const msg = JSON.stringify({ type: 'input', data: data_base64 }) + '\n';
|
|
1593
|
+
session.child.stdin.write(msg);
|
|
1594
|
+
session.updatedAt = Date.now();
|
|
1595
|
+
const payload = { ok: true, session_id };
|
|
1596
|
+
return textResult(JSON.stringify(payload, null, 2), payload);
|
|
1597
|
+
},
|
|
1598
|
+
);
|
|
1599
|
+
|
|
1600
|
+
server.registerTool(
|
|
1601
|
+
'read_pty_output',
|
|
1602
|
+
{
|
|
1603
|
+
description: 'Read buffered output from a PTY session. Supports long-polling: holds the request up to timeout_ms if no data is available yet.',
|
|
1604
|
+
inputSchema: {
|
|
1605
|
+
session_id: z.string(),
|
|
1606
|
+
timeout_ms: z.number().int().min(0).max(10000).default(5000),
|
|
1607
|
+
},
|
|
1608
|
+
},
|
|
1609
|
+
async ({ session_id, timeout_ms }) => {
|
|
1610
|
+
const session = ptySessions.get(session_id);
|
|
1611
|
+
if (!session) throw new Error(`unknown pty session: ${session_id}`);
|
|
1612
|
+
session.updatedAt = Date.now();
|
|
1613
|
+
// Immediate return if data or dead
|
|
1614
|
+
if (session.outputBuffer.length > 0 || !session.alive) {
|
|
1615
|
+
const payload = drainPtyOutput(session);
|
|
1616
|
+
return textResult(JSON.stringify(payload, null, 2), payload);
|
|
1617
|
+
}
|
|
1618
|
+
// Long-poll: wait for data or timeout
|
|
1619
|
+
const maxWait = Math.min(timeout_ms, 10000);
|
|
1620
|
+
const result = await new Promise((resolve) => {
|
|
1621
|
+
let resolved = false;
|
|
1622
|
+
const finish = () => {
|
|
1623
|
+
if (resolved) return;
|
|
1624
|
+
resolved = true;
|
|
1625
|
+
clearTimeout(timer);
|
|
1626
|
+
const idx = session.waiters.indexOf(waiter);
|
|
1627
|
+
if (idx >= 0) session.waiters.splice(idx, 1);
|
|
1628
|
+
resolve(drainPtyOutput(session));
|
|
1629
|
+
};
|
|
1630
|
+
const waiter = finish;
|
|
1631
|
+
session.waiters.push(waiter);
|
|
1632
|
+
const timer = setTimeout(finish, maxWait);
|
|
1633
|
+
});
|
|
1634
|
+
return textResult(JSON.stringify(result, null, 2), result);
|
|
1635
|
+
},
|
|
1636
|
+
);
|
|
1637
|
+
|
|
1638
|
+
server.registerTool(
|
|
1639
|
+
'resize_pty',
|
|
1640
|
+
{
|
|
1641
|
+
description: 'Resize a PTY session terminal.',
|
|
1642
|
+
inputSchema: {
|
|
1643
|
+
session_id: z.string(),
|
|
1644
|
+
cols: z.number().int().min(1).max(500),
|
|
1645
|
+
rows: z.number().int().min(1).max(200),
|
|
1646
|
+
},
|
|
1647
|
+
},
|
|
1648
|
+
async ({ session_id, cols, rows }) => {
|
|
1649
|
+
const session = ptySessions.get(session_id);
|
|
1650
|
+
if (!session) throw new Error(`unknown pty session: ${session_id}`);
|
|
1651
|
+
if (!session.alive) throw new Error(`pty session is no longer alive: ${session_id}`);
|
|
1652
|
+
const msg = JSON.stringify({ type: 'resize', cols, rows }) + '\n';
|
|
1653
|
+
session.child.stdin.write(msg);
|
|
1654
|
+
session.cols = cols;
|
|
1655
|
+
session.rows = rows;
|
|
1656
|
+
session.updatedAt = Date.now();
|
|
1657
|
+
const payload = { ok: true, session_id, cols, rows };
|
|
1658
|
+
return textResult(JSON.stringify(payload, null, 2), payload);
|
|
1659
|
+
},
|
|
1660
|
+
);
|
|
1661
|
+
|
|
1662
|
+
server.registerTool(
|
|
1663
|
+
'close_pty_session',
|
|
1664
|
+
{
|
|
1665
|
+
description: 'Close a PTY session.',
|
|
1666
|
+
inputSchema: {
|
|
1667
|
+
session_id: z.string(),
|
|
1668
|
+
},
|
|
1669
|
+
},
|
|
1670
|
+
async ({ session_id }) => {
|
|
1671
|
+
destroyPtySession(session_id);
|
|
1672
|
+
const payload = { ok: true, session_id };
|
|
1673
|
+
return textResult(JSON.stringify(payload, null, 2), payload);
|
|
1674
|
+
},
|
|
1675
|
+
);
|
|
1676
|
+
|
|
1287
1677
|
server.registerTool(
|
|
1288
1678
|
'upload_chunked_begin',
|
|
1289
1679
|
{
|
|
@@ -1694,6 +2084,9 @@ async function gracefulShutdown(signal) {
|
|
|
1694
2084
|
}
|
|
1695
2085
|
downloadSessions.clear();
|
|
1696
2086
|
shellSessions.clear();
|
|
2087
|
+
for (const [id] of ptySessions) {
|
|
2088
|
+
destroyPtySession(id);
|
|
2089
|
+
}
|
|
1697
2090
|
await Promise.allSettled(cleanups);
|
|
1698
2091
|
httpServer.close(() => {
|
|
1699
2092
|
console.log('Server closed');
|
|
@@ -1795,6 +2188,7 @@ MAX_CONCURRENT_REQUESTS=${MAX_CONCURRENT_REQUESTS}
|
|
|
1795
2188
|
MAX_UPLOAD_SESSIONS=${MAX_UPLOAD_SESSIONS}
|
|
1796
2189
|
MAX_DOWNLOAD_SESSIONS=${MAX_DOWNLOAD_SESSIONS}
|
|
1797
2190
|
MAX_SHELL_SESSIONS=${MAX_SHELL_SESSIONS}
|
|
2191
|
+
MAX_PTY_SESSIONS=${MAX_PTY_SESSIONS}
|
|
1798
2192
|
TLS_CERT_FILE=${TLS_CERT_FILE}
|
|
1799
2193
|
TLS_KEY_FILE=${TLS_KEY_FILE}
|
|
1800
2194
|
EOF
|
|
@@ -1969,6 +2363,7 @@ MAX_CONCURRENT_REQUESTS=${MAX_CONCURRENT_REQUESTS}
|
|
|
1969
2363
|
MAX_UPLOAD_SESSIONS=${MAX_UPLOAD_SESSIONS}
|
|
1970
2364
|
MAX_DOWNLOAD_SESSIONS=${MAX_DOWNLOAD_SESSIONS}
|
|
1971
2365
|
MAX_SHELL_SESSIONS=${MAX_SHELL_SESSIONS}
|
|
2366
|
+
MAX_PTY_SESSIONS=${MAX_PTY_SESSIONS}
|
|
1972
2367
|
TLS_CERT_FILE=${TLS_CERT_FILE}
|
|
1973
2368
|
TLS_KEY_FILE=${TLS_KEY_FILE}
|
|
1974
2369
|
BACKUP_ROOT=${BACKUP_ROOT}
|