bolt: 极速单文件 MCP 命令执行服务器

- 手写 JSON-RPC 2.0 + MCP (2025-06-18) 协议实现,仅依赖 serde_json + libc
- 双传输:stdio(本地 AI 客户端)与 Streamable HTTP(远程连接)
- 工具:run_command(超时/取消/进度通知/进程树杀除)与 shell 持久会话四件套
- 安全:命令白黑名单、Bearer 认证、Origin 校验
- LTO fat + codegen-units=1,ping 吞吐约 1.4 万 msg/s
- 8 个单元测试 + stdio/HTTP 端到端集成测试(31 项)
This commit is contained in:
jocay
2026-08-27 14:19:10 +08:00
commit 29b7dc3bbb
7 changed files with 2469 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+112
View File
@@ -0,0 +1,112 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bolt"
version = "0.1.0"
dependencies = [
"libc",
"serde_json",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "bolt"
version = "0.1.0"
edition = "2021"
description = "bolt — 极速单文件 MCP 命令执行服务器 (stdio / Streamable HTTP)"
license = "MIT"
[dependencies]
serde_json = "1"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
strip = true
+106
View File
@@ -0,0 +1,106 @@
# bolt — 极速单文件 MCP 命令执行服务器
让 AI(Claude Code / Claude Desktop / Cursor 等 MCP 客户端)通过 **MCP 协议**连接后,在服务器上执行 shell 命令。
- **单文件**:全部代码在 `src/main.rs`(约 1900 行,其中 400 行为单元测试),仅依赖 `serde_json`(Unix 下加 `libc`)
- **零框架**:手写 JSON-RPC 2.0 + MCP 协议 + Streamable HTTP 服务器,无 async runtime、无重量级依赖
- **双传输**:`stdio`(本地 AI 客户端默认)与 `Streamable HTTP`(远程连接)
## 快速开始
```bash
cargo build --release # 产物: target/release/bolt (~1MB)
```
**本地模式(stdio)**:由 MCP 客户端拉起本进程
```bash
# Claude Code
claude mcp add --transport stdio bolt -- /abs/path/to/bolt
# 或通用 MCP 客户端配置
# {"mcpServers": {"bolt": {"command": "/abs/path/to/bolt"}}}
```
**远程模式(Streamable HTTP)**:AI 通过网络连接
```bash
bolt --http 127.0.0.1:8080 # 仅本机
bolt --http 0.0.0.0:8080 --token mysecret # 对外 + Bearer 认证
bolt --http 0.0.0.0:8080 --token mysecret --allow "git,docker,curl"
# Claude Code 连接远程
claude mcp add --transport http bolt http://127.0.0.1:8080/mcp
```
## 工具
| 工具 | 说明 |
|---|---|
| `run_command` | 执行 shell 命令:完整 shell 语法(管道/重定向/`&&`)、`cwd`、`env`、`timeout_ms`(超时自动杀进程树)、`max_output_bytes`(输出截断防膨胀)、`shell=false` 直接执行二进制;客户端可传 `_meta.progressToken` 接收进度通知,支持 `notifications/cancelled` 取消 |
| `shell_open` | 开启持久化交互式 shell 会话(`cd`、环境变量等状态跨次保留),可指定 `shell`(sh/bash/zsh/fish)与 `cwd` |
| `shell_write` | 向会话写入输入(自动补换行) |
| `shell_read` | 读取会话新输出,可阻塞等待(`timeout_ms`,0 = 等到会话退出) |
| `shell_close` | 关闭并杀掉会话进程树 |
`run_command` 返回:`exit_code` / `signal` / `timed_out` / `cancelled` / `duration_ms` / `stdout` / `stderr` / 截断标志等(同时提供 `content` 与 `structuredContent`)。
## 配置
CLI 参数(环境变量 `BOLT_*` 为回退):
| 参数 | 默认 | 说明 |
|---|---|---|
| `--http <addr>` | 无(stdio) | 启用 Streamable HTTP 监听地址,如 `127.0.0.1:8080` |
| `--token <t>` | 无 | HTTP 要求 `Authorization: Bearer <t>` |
| `--allow <a,b>` | 空 | 命令白名单(命令首词前缀匹配;非空时仅允许列表内命令) |
| `--deny <a,b>` | 空 | 命令黑名单(前缀匹配) |
| `--timeout-ms <n>` | 30000 | 命令默认超时(工具参数可覆盖,0 = 无限) |
| `--cwd <dir>` | 当前 | 命令默认工作目录 |
| `--max-output <n>` | 1M | 单流输出捕获上限(支持 K/M/G) |
| `--allow-origin <url>` | localhost/127.0.0.1 | 额外允许的 HTTP Origin(防 DNS rebinding) |
```bash
export BOLT_TOKEN=xxx BOLT_ALLOW="git,docker" BOLT_TIMEOUT_MS=60000
bolt --http 127.0.0.1:8080
```
## 协议实现(2025-06-18 规范)
- `initialize`(协议版本协商,支持 2024-11-05 / 2025-03-26 / 2025-06-18)、`ping`、`tools/list`、`tools/call`、`shutdown`
- `notifications/initialized`、`notifications/cancelled`、`notifications/exit`、`notifications/progress`(进度上报)
- 工具执行错误以 `isError: true` 返回;协议错误返回标准 JSON-RPC 错误码(-32601/-32602/-32700 等)
- Streamable HTTP:POST 请求(通知 → 202;请求 → JSON 或 SSE 响应)、GET 长连接 SSE 流(服务器通知广播 + 10s 心跳)、CORS、Origin 校验、Bearer 认证、无状态(不要求会话 ID)
## 性能
- 单线程事件循环 + 复用缓冲区,无锁热路径;输出计数用原子操作,进度推送不占主循环
- 子进程独占进程组(`setpgid`),超时/取消按进程组 `SIGKILL`,连带杀掉整棵进程树,无僵尸
- 实测(本机,含 Python 客户端逐字节 I/O 开销):
- ping 往返吞吐 ≈ **14,700 msg/s**
- 并发 `echo` 命令平均 **4.7 ms/次**(进程 spawn + 捕获输出)
- 构建配置:`lto = "fat"`、`codegen-units = 1`、`strip = true`,二进制约 0.7MB
## 测试
```bash
cargo test --release # 8 个单元测试(协议分发/策略/HTTP 解析/执行引擎/会话)
```
集成测试脚本在 `docs/`(stdio 12 项、HTTP 19 项,覆盖超时/取消/进程树/进度/并发/auth/Origin):
```bash
python3 /tmp/bolt_stdio_test.py # stdio 端到端
python3 /tmp/bolt_http_test.py # HTTP 端到端
```
## 安全说明
- 默认只接受本机连接(`127.0.0.1`),对外公开需显式 `0.0.0.0` + `--token`
- 该服务器等于远程 root shell 入口,务必配置白名单(`--allow`)与认证后再暴露公网
- Unix 下命令在独立进程组运行,取消/超时会连带杀掉子进程
## 平台
- Linux / macOS(完整功能:进程组杀、信号处理)
- Windows(基础功能:`cmd /C`、`taskkill /T` 杀进程树;无信号优雅退出)
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""bolt HTTP 模式端到端测试"""
import http.client, json, subprocess, threading, time
BOLT = "/root/bolt/target/release/bolt"
PORT = 18080
passed = failed = 0
def check(name, cond, detail=""):
global passed, failed
if cond:
passed += 1
print(f" PASS {name}")
else:
failed += 1
print(f" FAIL {name} {detail}")
proc = subprocess.Popen([BOLT, "--http", f"127.0.0.1:{PORT}"], stderr=subprocess.PIPE)
time.sleep(0.5)
def post(obj, auth=None, origin=None, accept="application/json", path="/mcp"):
c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=30)
headers = {"Content-Type": "application/json", "Accept": accept}
if auth: headers["Authorization"] = auth
if origin: headers["Origin"] = origin
body = json.dumps(obj)
c.request("POST", path, body=body, headers=headers)
r = c.getresponse()
data = r.read()
ct = r.getheader("Content-Type", "")
c.close()
return r.status, ct, data
# 1. initialize(JSON 响应)
st, ct, data = post({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}})
m = json.loads(data)
check("init json", st == 200 and "application/json" in ct and m["result"]["serverInfo"]["name"] == "bolt", f"{st} {ct} {data[:100]}")
# 2. SSE 响应(accept 含 text/event-stream)
st, ct, data = post({"jsonrpc": "2.0", "id": 2, "method": "ping"}, accept="application/json, text/event-stream")
sseline = data.split(b"data: ")[1].split(b"\r\n")[0].strip()
m = json.loads(sseline)
check("ping sse", st == 200 and "text/event-stream" in ct and m.get("result") == {}, f"{st} {ct} {data[:150]}")
# 3. 通知 → 202
st, ct, data = post({"jsonrpc": "2.0", "method": "notifications/initialized"})
check("notif 202", st == 202 and len(data) == 0, f"{st} {data[:50]}")
# 4. tools/call
st, ct, data = post({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "echo HTTP_OK && exit 5"}}})
m = json.loads(data)
sc = m["result"]["structuredContent"]
check("run over http", sc["exit_code"] == 5 and sc["stdout"].strip() == "HTTP_OK", str(sc))
# 5. GET SSE 流:开流收心跳,并发命令进度通知广播
got_event = threading.Event()
got_heartbeat = threading.Event()
msgs = []
def sse_get():
c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=35)
c.request("GET", "/mcp", headers={"Accept": "text/event-stream"})
r = c.getresponse()
assert r.status == 200 and "text/event-stream" in r.getheader("Content-Type")
buf = b""
end = time.time() + 30
while time.time() < end:
chunk = r.read(1)
if not chunk: break
buf += chunk
if buf.endswith(b"\r\n\r\n"):
msgs.append(buf.decode(errors="replace"))
buf = b""
if msgs and (": ping" in msgs[-1]):
got_heartbeat.set()
c.close()
t = threading.Thread(target=sse_get)
t.start()
time.sleep(0.7)
# 发一个带进度 token 的命令(约 1.2s)
st, ct, data = post({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "for x in 1 2 3 4; do echo h-$x; sleep 0.3; done"}, "_meta": {"progressToken": "h1"}}})
m = json.loads(data)
check("long cmd done", m["result"]["structuredContent"]["stdout"].count("h-") == 4, str(m)[:100])
saw_progress = any("notifications/progress" in x and "h1" in x for x in msgs)
check("sse got progress broadcast", saw_progress, str(msgs)[:200])
t.join(timeout=25)
saw_hb = got_heartbeat.is_set()
check("sse heartbeat", saw_hb, str(msgs)[:200])
# 6. 取消:连接 A 长命令,连接 B 发 cancelled
st, ct, data = post({"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "echo nothing"}}}) # 预热(连接池无关,每次新连接)
def long_cmd():
return post({"jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "sleep 30"}}})
res = {}
t2 = threading.Thread(target=lambda: res.update(r=long_cmd()))
t2.start()
time.sleep(0.5)
st, ct, data = post({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 6}})
check("cancel notif 202", st == 202)
t2.join(timeout=10)
m = json.loads(res["r"][2])
check("http cancel works", m["result"]["structuredContent"]["cancelled"] is True, str(m)[:150])
# 7. 并发两个命令
def run(cmd, rid):
return post({"jsonrpc": "2.0", "id": rid, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": cmd}}})
results = {}
ts = [threading.Thread(target=lambda c=cmd, i=i: results.update({i: run(c, i)})) for i, cmd in [(10, "sleep 0.5; echo A"), (11, "sleep 0.1; echo B")]]
for t in ts: t.start()
for t in ts: t.join(timeout=10)
check("http concurrent", json.loads(results[10][2])["result"]["structuredContent"]["stdout"].strip() == "A" and json.loads(results[11][2])["result"]["structuredContent"]["stdout"].strip() == "B")
# 8. 错误路径
st, _, _ = post({"jsonrpc": "2.0", "id": 99, "method": "nope"})
check("unknown method", st == 200)
c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=5)
c.request("POST", "/mcp", body=b"this is {not json", headers={"Content-Type": "application/json", "Accept": "application/json"})
r = c.getresponse(); bd = r.read(); c.close()
check("bad json -> 400", r.status == 400, str(r.status))
st, _, data = post({"jsonrpc": "2.0", "id": 99, "method": "tools/call", "params": {"name": "nope", "arguments": {}}})
check("unknown tool -32602", json.loads(data)["error"]["code"] == -32602)
c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=5)
c.request("GET", "/other", headers={"Accept": "text/event-stream"})
r = c.getresponse(); r.read(); c.close()
check("404 other path", r.status == 404)
c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=5)
c.request("PUT", "/mcp")
r = c.getresponse(); r.read(); c.close()
check("405 method", r.status == 405)
c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=5)
c.request("OPTIONS", "/mcp")
r = c.getresponse(); r.read(); c.close()
check("options cors", r.status == 204 and r.getheader("Access-Control-Allow-Origin") == "*")
# 9. origin 校验
st, ct, data = post({"jsonrpc": "2.0", "id": 50, "method": "ping"}, origin="https://evil.example.com")
check("evil origin 403", st == 403, str(st))
st, ct, data = post({"jsonrpc": "2.0", "id": 51, "method": "ping"}, origin="http://localhost:4173")
check("localhost origin ok", st == 200, str(st))
# 10. GET 无 Accept 头也返回 SSE(宽松)
c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=5)
c.request("GET", "/mcp")
r = c.getresponse()
c.close()
check("get sse default", r.status == 200 and "text/event-stream" in r.getheader("Content-Type"))
proc.kill()
err = proc.stderr.read().decode()
print(f"\n=== HTTP: {passed} passed, {failed} failed ===")
if err.strip():
print("bolt stderr:", err[:400])
sys_exit = 1 if failed else 0
import sys; sys.exit(sys_exit)
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""bolt stdio 模式端到端协议测试驱动"""
import faulthandler, json, os, subprocess, sys, threading, time
BOLT = "/root/bolt/target/release/bolt"
proc = subprocess.Popen([BOLT], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0)
sent = {}
next_id = [0]
def send(method, params=None):
next_id[0] += 1
msg = {"jsonrpc": "2.0", "id": next_id[0], "method": method}
if params is not None:
msg["params"] = params
proc.stdin.write((json.dumps(msg) + "\n").encode())
proc.stdin.flush()
return next_id[0]
def send_notif(method, params=None):
msg = {"jsonrpc": "2.0", "method": method}
if params is not None:
msg["params"] = params
proc.stdin.write((json.dumps(msg) + "\n").encode())
proc.stdin.flush()
def recv(timeout=30):
import select
end = time.time() + timeout
line = b""
while True:
r, _, _ = select.select([proc.stdout], [], [], max(0.0, end - time.time()))
if not r:
raise TimeoutError(f"timeout, got so far: {line[:200]}")
ch = proc.stdout.read(1)
if not ch:
raise TimeoutError("EOF")
if ch == b"\n":
return json.loads(line)
line += ch
received = {} # 乱序到达的响应缓存(id -> msg)
def wait_id(i, timeout=30):
if i in received:
return received.pop(i)
deadline = time.time() + timeout
pend.setdefault("extra", []).append(f"awaiting {i}")
while time.time() < deadline:
try:
m = recv(timeout=min(3, deadline - time.time()))
except TimeoutError as e:
raise TimeoutError(f"no response for id {i}: {e}; extras: {json.dumps(pend.get('extra', []))[:800]}")
if m.get("id") == i:
return m
if m.get("id") is not None:
received[m["id"]] = m
else:
pend.setdefault("extra", []).append(m)
raise TimeoutError(f"no response for id {i}, extras: {json.dumps(pend.get('extra', []))[:800]}")
pend = {}
passed = failed = 0
def check(name, cond, detail=""):
global passed, failed
if cond:
passed += 1
print(f" PASS {name}")
else:
failed += 1
print(f" FAIL {name} {detail}")
# 1. initialize
i = send("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "1"}})
r = wait_id(i)
check("initialize", r["result"]["protocolVersion"] == "2025-06-18" and r["result"]["serverInfo"]["name"] == "bolt", str(r))
send_notif("notifications/initialized")
# 2. tools/list
i = send("tools/list")
r = wait_id(i)
names = [t["name"] for t in r["result"]["tools"]]
check("tools/list", names == ["run_command", "shell_open", "shell_write", "shell_read", "shell_close"], str(names))
# 3. run_command 基本
i = send("tools/call", {"name": "run_command", "arguments": {"command": "echo hello; printf 'oops' >&2; exit 7"}})
r = wait_id(i)
sc = r["result"]["structuredContent"]
check("run basic", sc["exit_code"] == 7 and sc["stdout"] == "hello\n" and sc["stderr"] == "oops", str(sc))
# 4. 超时
t0 = time.time()
i = send("tools/call", {"name": "run_command", "arguments": {"command": "sleep 30", "timeout_ms": 400}})
r = wait_id(i)
dt = time.time() - t0
sc = r["result"]["structuredContent"]
check("timeout kill", sc["timed_out"] and not sc["cancelled"] and dt < 5, f"{sc} dt={dt:.1f}")
# 5. 取消:发一个长命令,再发 cancelled
i = send("tools/call", {"name": "run_command", "arguments": {"command": "sleep 30 && echo done"}})
time.sleep(0.4)
send_notif("notifications/cancelled", {"requestId": i})
r = wait_id(i)
sc = r["result"]["structuredContent"]
check("cancel kill", sc["cancelled"] and sc["exit_code"] is None, str(sc))
# 6. 进程组连带杀:sh -c 'sleep 30 & sleep 30' 的子进程也应变僵尸
i = send("tools/call", {"name": "run_command", "arguments": {"command": "sleep 30 & sleep 30", "timeout_ms": 400}})
r = wait_id(i)
check("kill whole tree", r["result"]["structuredContent"]["timed_out"])
# 7. 进度通知(_meta.progressToken)
i = send("tools/call", {"name": "run_command", "arguments": {"command": "for x in 1 2 3 4; do echo tick-$x; sleep 0.3; done"}, "_meta": {"progressToken": "tok-1"}})
got_progress = False
deadline = time.time() + 10
while time.time() < deadline:
m = recv(timeout=deadline - time.time())
if m.get("method") == "notifications/progress" and m.get("params", {}).get("progressToken") == "tok-1":
got_progress = True
break
if m.get("id") == i:
break
check("progress notification", got_progress)
# 排空管道:打印残留
import select
import os
while True:
r, _, _ = select.select([proc.stdout], [], [], 0)
if not r: break
try:
m = recv(timeout=2)
print(" LEFT:", m.get("id"), m.get("method"))
except Exception:
break
# 8. 并发:同时两个命令
i1 = send("tools/call", {"name": "run_command", "arguments": {"command": "sleep 0.6; echo ONE"}})
i2 = send("tools/call", {"name": "run_command", "arguments": {"command": "sleep 0.2; echo TWO"}})
r1, r2 = wait_id(i1), wait_id(i2)
check("concurrent", r1["result"]["structuredContent"]["stdout"].strip() == "ONE" and r2["result"]["structuredContent"]["stdout"].strip() == "TWO")
# 9. shell 会话
i = send("tools/call", {"name": "shell_open", "arguments": {}})
r = wait_id(i)
sid = r["result"]["session_id"]
check("shell open", isinstance(sid, int))
send("tools/call", {"name": "shell_write", "arguments": {"session_id": sid, "input": "cd /tmp && echo IN_TMP; echo DONE_$$; exit"}})
# 输出先于退出标志到达,循环读取直到会话退出
all_out = ""
for _ in range(10):
i = send("tools/call", {"name": "shell_read", "arguments": {"session_id": sid, "timeout_ms": 3000}})
r = wait_id(i)
res = r["result"]
all_out += res["output"]
if res["exited"]:
break
check("shell read output", "IN_TMP" in all_out and res["exited"], f"out={all_out!r}")
i = send("tools/call", {"name": "shell_close", "arguments": {"session_id": sid}})
r = wait_id(i)
check("shell close", r["result"]["closed"] is True)
# 10. stdio 关闭 → 进程退出
send_notif("notifications/exit")
try:
proc.wait(timeout=3)
check("graceful exit", True)
except subprocess.TimeoutExpired:
check("graceful exit", False)
err = proc.stderr.read().decode()
print(f"\n=== {passed} passed, {failed} failed ===")
if err.strip():
print("stderr:", err[:500])
sys.exit(1 if failed else 0)
+1906
View File
File diff suppressed because it is too large Load Diff