6 Commits

Author SHA1 Message Date
jocay e2640dad57 docs: README 以一键安装为主线重新组织安装说明
- 开头即给出 IPv4 / IPv6 一键安装命令、参数表与安装后的状态检查
- 新增连接 AI 客户端(带 Authorization 头)、更新、卸载、从源码构建的独立小节
- 说明 Release 目前提供的平台附件及其系统要求
- 部署细节集中到末尾:配置文件位置与修改方式、macOS launchd、systemd 手动部署、发布新版本流程

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:01:09 +08:00
jocay a8d6dd4b87 deploy: 站点探测不再强制 IPv4/IPv6 并加超时
git.jocayn.qzz.io 在 Cloudflare 后面同时有 IPv4/IPv6 地址,部分网络(如 nas)IPv4 不通、
IPv6 可用;原来用 -4 强制 IPv4 且没有连接超时,会重试到卡死。改为由 curl 自动选择协议族,
探测 8 秒连接超时、15 秒总时长且不重试,下载加 15 秒连接超时。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:57:56 +08:00
jocay 76f6fe32f5 deploy: 一键更新 / 卸载脚本,安装脚本自动选择 IPv4/IPv6 站点与最新版本
- 新增 deploy/update.sh:只替换二进制并重启服务,保留配置;已是最新时跳过、不自动降级;
  重启由独立进程执行(通过 bolt 自身运行也安全),新版本起不来时自动回滚
- 新增 deploy/uninstall.sh:删除服务、二进制与备份,默认同时删除配置,--keep-config 保留
- install.sh:先试 IPv4 站点(git.jocayn.qzz.io)再试 IPv6 站点(git.draws.qzz.io),
  通过 Release API 获取最新版本,支持 SHA256SUMS 校验与 --version;
  已安装时拒绝覆盖配置(--reinstall 强制);macOS 缺少签名时补 ad-hoc 签名
- .gitattributes 强制 *.sh 使用 LF
- README / CLAUDE.md 更新部署说明与发布流程

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:46:21 +08:00
jocay 84653bec61 feat: v0.2.0 文件传输(MCP 工具 + HTTP 直传)与安全/正确性修复
文件功能:
- 新增 file_read / file_write / file_stat 工具(文本/base64/图片,分块读取,原子替换写入,SHA-256)
- 新增 file_link 工具与 GET/PUT /files 端点:HMAC 签名限时链接或 Bearer 认证,
  支持 Range、HEAD、chunked、Expect: 100-continue,大文件不经过模型上下文
- 新增 --file-root / --max-upload;配置白名单但未限定目录时文件功能自动禁用

安全:
- 白名单改为程序名精确匹配,shell 模式禁止元字符,shell_write 输入逐行受检;黑名单检查每一段
- HTTP 签发 Mcp-Session-Id,按会话隔离 shell 会话与取消,支持 DELETE 终止会话
- HTTP 客户端不能再用 notifications/exit 关停服务;token 常量时间比较

正确性:
- 后台进程占用输出管道时 run_command 不再挂起(detached=true)
- shell_read 改用绝对偏移,修复缓冲裁剪后错位;被信号杀死的会话能报告退出;阻塞读可取消
- 输出与分块读取不再切断 UTF-8 多字节字符
- shell_* 结果改为标准 content + structuredContent
- POST + SSE 的 tools/call 在同一响应流中推送进度与结果
- /mcp 支持 Expect: 100-continue(curl 发送大请求时不再白等 1 秒)
- CLI 参数优先于环境变量;SIGTERM 在 stdio 模式下也能清理退出;Windows 支持 Ctrl+C 清理与优雅退出

资源:并发工具调用 / HTTP 连接 / shell 会话数量上限,空闲会话自动回收

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:23:15 +08:00
jocay 63d48ec951 feat(deploy): support macOS arm64 installation with launchd 2026-10-05 01:36:27 +08:00
jocay 3b3492a36d deploy: 一键安装脚本 install.sh + Release 发布说明(其他设备最快部署路径) 2026-08-27 14:29:42 +08:00
12 changed files with 3897 additions and 774 deletions
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+50
View File
@@ -0,0 +1,50 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目
bolt 是一个单文件 MCP 服务器(`src/main.rs`),让 MCP 客户端在主机上执行 shell 命令、读写和传输文件。它手写实现了 JSON-RPC 2.0 + MCP(协议版本 2024-11-05 / 2025-03-26 / 2025-06-18),支持两种传输:stdio(默认)和 Streamable HTTP(`--http <addr>`)。依赖只有 `serde_json`,Unix 下再加 `libc`;base64、SHA-256、HMAC、百分号编码、随机数(`/dev/urandom` / `BCryptGenRandom`)都是手写的。请保持这一点:不引入新 crate,不引入 async runtime,也不引入 HTTP/MCP 框架。代码注释、日志信息和文档都用中文,新增内容请保持一致。
## 常用命令
```bash
cargo build --release # 产物 target/release/bolt(release 配置:lto=fat、codegen-units=1、strip)
cargo test --release # 单元测试(位于 src/main.rs 末尾的 mod tests)
cargo test --release test_policy # 按名称运行单个单元测试
cargo clippy --release --all-targets # 当前无警告,请保持
cargo check --release --all-targets --target x86_64-pc-windows-gnu # 在 Linux 上检查 Windows 分支能否编译
./target/release/bolt --help # 查看 CLI 参数;每个参数都可用对应的 BOLT_* 环境变量作为回退(CLI 优先)
./target/release/bolt --http 127.0.0.1:8080 --token t --allow "git,ls" --file-root /tmp
```
- `test_run_command_basic` 和 `test_shell_session` 带 `#[cfg(unix)]`(使用 `sh -c`、`printf`、`sleep`),Windows 上会跳过。Windows 下服务器使用 `cmd /C`。
- 端到端测试在 `docs/bolt_stdio_test.py`(stdio)和 `docs/bolt_http_test.py`(HTTP,会额外起一个带 `--token` 和 `--file-root` 的实例)。它们对管道使用 `select`,需要 Unix 主机;二进制路径默认是 `target/release/bolt`,可用 `BOLT_BIN` 覆盖,例如 `BOLT_BIN=/path/bolt python3 docs/bolt_http_test.py`。HTTP 测试占用端口 18080 和 18081。
## 架构(src/main.rs,自上而下)
文件用 `// ----` 横幅注释分段:常量 → 配置 → 出站通道 → 应用状态 → JSON-RPC 辅助 → 命令策略 → 编码/摘要/随机数 → run_command 执行引擎 → shell 会话 → 文件 → 工具列表 → MCP 分发 → 客户端作用域 → stdio 传输 → HTTP 传输 → 信号与清理 → main → 测试。
- **`App` / `Shared = Arc<App>`**:全局状态。包含 `Config`、按配置生成的 `tools`、`inflight` 表(`作用域\n请求id` → `Arc<Killable>`)、`sessions`(shell 会话,带 `owner` 作用域)、`mcp_sessions`(`Mcp-Session-Id` → 最近活动时间)、并发/连接计数,以及 `file_link` 的签名密钥(启动时随机生成)。
- **`Ctx`(作用域 + 出口)**:每个请求都带一个 `Ctx { scope, sink, base_url }`。`scope` 用来隔离取消和 shell 会话:stdio 下是 `"stdio"`;HTTP 下是 `Mcp-Session-Id`,不带该头的请求共用匿名作用域 `"-"`。`sink` 决定通知和响应发往哪里:`Sink::Stdio`、`Sink::Hub(hub, scope)`(只推给该作用域的 GET SSE 流)、`Sink::Chan`(当前 POST 的 SSE 响应流)。`base_url` 由 `Host` / `X-Forwarded-*` 推出,只有 `file_link` 用到。
- **`process_message(v, app, ctx, async_exec)`**:两种传输共用的唯一入口。通知返回 `None`。`tools/call` 先通过 `CallSlot` 占用一个并发名额(超限时返回 `isError` 结果),再注册 `Killable`。`async_exec` 为 true(stdio)时在 `bolt-tool` 线程中执行,完成后经 `ctx.sink` 发送响应,所以 stdio 响应可能乱序;为 false(HTTP)时同步执行。`notifications/exit` 只在 stdio 作用域生效。
- **`exec_tool_call`**:按工具名分发。工具执行失败返回 `Ok(tool_error(..))`(`isError: true`),成功结果用 `tool_ok(v)` 包成 `content` + `structuredContent`。参数错误或未知工具返回 `Err((code, msg))`,转成 JSON-RPC 错误。参数用 `req_str` / `opt_u64` 等辅助函数读取。新增工具时请保持这一区分,并在 `build_tools(cfg)` 里补上 schema。文件工具只有在 `files_enabled` 时才会出现在列表中,`file_link` 只在 HTTP 模式下出现。
- **进程生命周期**:Unix 下子进程放进独立进程组(`pre_exec` + `setpgid(0,0)`),`kill_tree` 对 `-pid` 发 `SIGKILL`;Windows 下用 `taskkill /T /F`。`Killable` 处理"pid 注册前就收到取消"的情况。主进程被收割后会把 pid 清零,防止 pid 复用时误杀。主进程退出后,读取线程最多再等 `PIPE_DRAIN_GRACE`;如果后台进程还占着管道,就标记 `detached` 直接返回,读取线程转入后台继续排空但不再保存。
- **Shell 会话**:`SessBuf` 里的偏移都是绝对字节偏移(`base` / `read`),裁剪缓冲前部不会让读取位置错位。退出信息也放在同一把锁里,`Condvar` 不会丢唤醒。`shell_read` 会避免切断 UTF-8 字符,每 200ms 检查一次是否被取消。`janitor_loop` 每 30 秒回收空闲会话和过期的 MCP 会话。
- **文件**:`resolve_path` 是所有文件访问(工具和 `/files`)唯一的路径入口。已存在的路径用 `canonicalize` 解析符号链接;不存在的路径先规范化最近的已存在祖先,再拼上剩余部分(剩余部分不允许 `.` / `..`),最后检查是否在 `--file-root` 之内。`write_file` 的 overwrite 模式写同目录临时文件后 rename。错误类型是 `FErr = (HTTP 状态码, 信息)`,工具和 HTTP 共用。
- **HTTP 传输**:手写 HTTP/1.1,每个连接一个线程(`conn_loop`,受 `MAX_HTTP_CONNS` 限制)。检查顺序:OPTIONS(CORS)→ Origin 白名单 → Bearer token(`ct_eq` 常量时间比较;带 `sig` 的 `/files` 请求改为在 `handle_files` 里校验 HMAC 签名)→ 路由到 `handle_mcp` 或 `handle_files`。读取 body 之前出错一律关闭连接。请求体统一经 `BodyReader` 读取,支持 Content-Length 和 chunked。`handle_*` 对读写端是泛型的,单元测试直接喂内存缓冲。
- **退出**:Unix 下屏蔽 SIGINT 和 SIGTERM,由 `sigwait` 线程接收信号,执行 `shutdown_cleanup` 后直接 `process::exit`(stdio 主线程可能阻塞在读 stdin 上)。Windows 下用 `SetConsoleCtrlHandler` 做同样的事。
- **命令策略**(`policy_check(cfg, command, shell)`):allow/deny 都按程序名精确匹配(会去掉路径、引号、转义和 `.exe` 等扩展名)。配置了 allow 时,shell 模式禁止元字符;deny 会检查按元字符切分后每一段的程序名。作用范围:`run_command`、`shell_open` 的 shell 名称、`shell_write` 写入的每一行。配置了 allow 但没有 `--file-root` 时文件功能禁用。
各种上限和默认值是文件顶部的 `const`(`MAX_HTTP_BODY`、`MAX_CONCURRENT_CALLS`、`SESSION_IDLE`、`DEFAULT_READ_LEN`、`DEFAULT_MAX_UPLOAD` 等)。
## 部署(deploy/)
- 三个独立的 POSIX sh 脚本(需要 root,设计为 `curl … | sudo sh` 直接运行,所以每个脚本都自带下载/探测函数,不能互相引用):
- `install.sh`:首次安装。自动识别 Linux(systemd:`/usr/local/bin/bolt`、`/etc/bolt.env`、`bolt.service`)或 macOS(launchd 标签 `io.jocay.bolt`,plist 放在 `/Library/Application Support/bolt/`,启用开机自启时复制到 `/Library/LaunchDaemons/`)。已安装时拒绝执行,除非加 `--reinstall`,因为它会按本次参数重写配置。
- `update.sh`:只替换二进制,保留配置。重启和健康检查交给独立进程执行(Linux 用 `systemd-run`,macOS 用 `(trap '' HUP; sh … &)`;macOS 上 `nohup … &` 启动的进程会在调用返回后消失),失败时从 `/usr/local/bin/bolt.bak` 回滚。
- `uninstall.sh`:先删文件,最后才停服务,因为通过 bolt 自身执行时,停服务会结束脚本本身。
- 下载站点:IPv4 用 `https://git.jocayn.qzz.io`,IPv6 用 `https://git.draws.qzz.io`(同一个 Gitea,仓库 `jocay/bolt` 已公开)。脚本先试 IPv4 站点再试 IPv6 站点(不强制 `-4`/`-6`:`git.jocayn.qzz.io` 在 Cloudflare 后面,同时有 IPv4 和 IPv6 地址,有的网络只有其中一种能通;探测带 8 秒连接超时、不重试);最新版本通过 `/api/v1/repos/jocay/bolt/releases/latest` 获取(这个 Gitea 不支持 `releases/latest/download/` 直链),附件命名为 `bolt-<linux|darwin>-<amd64|arm64>`,可选附带 `SHA256SUMS`。`BOLT_GIT_HOST`、`BOLT_VERSION`、`BOLT_DL_URL`、`--binary` 可以覆盖;`BOLT_DL_TOKEN`(下载认证)与 `--token`(服务器的 Bearer 令牌)相互独立。
- 发布用的二进制用 cargo-zigbuild 编译:`x86_64-unknown-linux-gnu.2.28`(普通 `cargo build` 产物依赖构建机的 glibc 版本,较老的系统上跑不了)和 `aarch64-apple-darwin`。
- `bolt.service` 和 `bolt.env.example` 用于手动 systemd 部署。监听地址写在 unit 文件的 `ExecStart` 里,其余配置来自 `/etc/bolt.env` 中的 `BOLT_*` 变量。
- 修改 CLI 参数时,需同步更新 `parse_args`/`print_help`、`install.sh`(参数解析及其生成的 env/plist)、`bolt.env.example` 和 README 中的参数表。
Generated
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]] [[package]]
name = "bolt" name = "bolt"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"libc", "libc",
"serde_json", "serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "bolt" name = "bolt"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
description = "bolt — 极速单文件 MCP 命令执行服务器 (stdio / Streamable HTTP)" description = "bolt — 极速单文件 MCP 命令执行服务器 (stdio / Streamable HTTP)"
license = "MIT" license = "MIT"
+199 -45
View File
@@ -1,49 +1,157 @@
# bolt — 极速单文件 MCP 命令执行服务器 # bolt — 极速单文件 MCP 命令执行服务器
让 AI(Claude Code / Claude Desktop / Cursor 等 MCP 客户端)通过 **MCP 协议**连接后,在服务器上执行 shell 命令。 让 AI(Claude Code / Claude Desktop / Cursor 等 MCP 客户端)通过 **MCP 协议**连接后,在服务器上执行 shell 命令、读写和传输文件。
- **单文件**:全部代码在 `src/main.rs`(约 1900 行,其中 400 行为单元测试),仅依赖 `serde_json`(Unix 下加 `libc`) - **单文件**:全部代码在 `src/main.rs`,仅依赖 `serde_json`(Unix 下加 `libc`);base64 / SHA-256 / HMAC 均为手写
- **零框架**:手写 JSON-RPC 2.0 + MCP 协议 + Streamable HTTP 服务器,无 async runtime、无重量级依赖 - **零框架**:手写 JSON-RPC 2.0 + MCP 协议 + Streamable HTTP 服务器,无 async runtime、无重量级依赖
- **双传输**:`stdio`(本地 AI 客户端默认)与 `Streamable HTTP`(远程连接) - **双传输**:`stdio`(本地 AI 客户端默认)与 `Streamable HTTP`(远程连接)
## 快速开始 ## 安装
### 一键安装(Linux / macOS)
在目标设备上执行(需要 root;已经是 root 时去掉 `sudo`)。IPv4 网络用 `git.jocayn.qzz.io`,IPv6 网络用 `git.draws.qzz.io`:
```bash
# IPv4
curl -fsSL https://git.jocayn.qzz.io/jocay/bolt/raw/branch/main/deploy/install.sh | sudo sh -s -- --http 0.0.0.0:8080 --token 你的令牌
# IPv6
curl -fsSL https://git.draws.qzz.io/jocay/bolt/raw/branch/main/deploy/install.sh | sudo sh -s -- --http 0.0.0.0:8080 --token 你的令牌
```
脚本会:识别平台 → 从 Release 下载最新版二进制并校验 SHA-256 → 装到 `/usr/local/bin/bolt` → 写入配置 → 注册系统服务(Linux 为 systemd 的 `bolt`,macOS 为 launchd 的 `io.jocay.bolt`)→ 设置开机自启并启动。
- 目前 Release 提供 `linux-amd64`(glibc ≥ 2.28,Debian 10 / Ubuntu 18.10 及以上)和 `darwin-arm64`(macOS 11+,Apple Silicon);其他平台请[从源码构建](#从源码构建)后用 `--binary` 安装。
- 只在本机使用、或放在反向代理后面时,监听 `127.0.0.1:端口` 即可;直接对外监听 `0.0.0.0` 时务必设置 `--token`。
- 已安装的设备再次执行会被拒绝,以免按新参数重写配置时丢掉令牌。升级请用[更新](#更新),确实要重装加 `--reinstall`。
更多参数写在 `sh -s --` 之后,例如限制命令和文件目录:
```bash
curl -fsSL https://git.jocayn.qzz.io/jocay/bolt/raw/branch/main/deploy/install.sh | sudo sh -s -- \
--http 0.0.0.0:8080 --token 你的令牌 --allow "git,docker,ls" --file-root /srv/data
```
| 参数 | 含义 |
|---|---|
| `--http <addr>` | 监听地址,默认 `127.0.0.1:8080`;对外用 `0.0.0.0:8080` |
| `--token <t>` | Bearer 认证令牌(对外监听时必填) |
| `--allow <a,b>` / `--deny <a,b>` | 命令白/黑名单 |
| `--timeout-ms <n>` | 命令默认超时,默认 30000 |
| `--file-root <d1,d2>` | 文件功能允许访问的目录(配置 `--allow` 时必填,否则文件功能禁用) |
| `--max-upload <n>` | `PUT /files` 单次上传上限,默认 2G |
| `--version <tag>` | 安装指定版本,默认最新 |
| `--binary <路径或URL>` | 不从 Release 下载,改用本地二进制(如 `scp` 过去的)或指定 URL |
| `--reinstall` | 已安装时按本次参数重写配置 |
| `--no-start` / `--no-enable` | 只安装不启动 / 不设开机自启 |
环境变量:`BOLT_GIT_HOST` 指定下载站点(默认先试 `git.jocayn.qzz.io`,不通再试 `git.draws.qzz.io`),`BOLT_DL_URL` 直接指定二进制地址,`BOLT_DL_TOKEN` 用于私有仓库下载(与 `--token` 无关)。
装好后查看状态:
```bash
systemctl status bolt # Linux
sudo launchctl print system/io.jocay.bolt # macOS
```
### 连接 AI 客户端
```bash
# Claude Code 连接远程 bolt(带令牌)
claude mcp add --transport http bolt http://服务器:8080/mcp --header "Authorization: Bearer 你的令牌"
# 放在 HTTPS 反向代理后面时,直接用代理地址
claude mcp add --transport http bolt https://bolt.example.com/mcp --header "Authorization: Bearer 你的令牌"
```
其他 MCP 客户端按 Streamable HTTP 方式配置 `http://服务器:8080/mcp`,并加上 `Authorization: Bearer 你的令牌` 请求头。
### 更新
只替换二进制并重启服务,**保留现有配置**:
```bash
# IPv4
curl -fsSL https://git.jocayn.qzz.io/jocay/bolt/raw/branch/main/deploy/update.sh | sudo sh
# IPv6
curl -fsSL https://git.draws.qzz.io/jocay/bolt/raw/branch/main/deploy/update.sh | sudo sh
```
- 已是最新版本时直接跳过,不会自动降级;新版本服务起不来时自动回滚,旧版本备份在 `/usr/local/bin/bolt.bak`。
- 重启由独立进程完成,所以让 AI 通过 bolt 自己执行更新也安全(只是这次调用的连接会断开,稍后重连即可)。
- 可选参数(写在 `sh -s --` 之后):`--version v0.2.0` 安装指定版本,`--binary ./bolt` 使用本地二进制,`--force` 同版本也重装。
### 卸载
```bash
# IPv4
curl -fsSL https://git.jocayn.qzz.io/jocay/bolt/raw/branch/main/deploy/uninstall.sh | sudo sh
# IPv6
curl -fsSL https://git.draws.qzz.io/jocay/bolt/raw/branch/main/deploy/uninstall.sh | sudo sh
```
删除服务、二进制和备份,默认同时删除配置与日志;要保留配置加 `sh -s -- --keep-config`。
### 从源码构建
```bash ```bash
cargo build --release # 产物: target/release/bolt (~1MB) cargo build --release # 产物: target/release/bolt (~1MB)
``` ```
**本地模式(stdio)**:由 MCP 客户端拉起本进程 **本地模式(stdio)**:不需要安装服务,由 MCP 客户端直接拉起本进程
```bash ```bash
# Claude Code
claude mcp add --transport stdio bolt -- /abs/path/to/bolt claude mcp add --transport stdio bolt -- /abs/path/to/bolt
# 通用 MCP 客户端: {"mcpServers": {"bolt": {"command": "/abs/path/to/bolt"}}}
# 或通用 MCP 客户端配置
# {"mcpServers": {"bolt": {"command": "/abs/path/to/bolt"}}}
``` ```
**远程模式(Streamable HTTP)**:AI 通过网络连接 **远程模式(Streamable HTTP)**:直接前台运行
```bash ```bash
bolt --http 127.0.0.1:8080 # 仅本机 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 # 对外 + Bearer 认证
bolt --http 0.0.0.0:8080 --token mysecret --allow "git,docker,curl" bolt --http 0.0.0.0:8080 --token mysecret --allow "git,docker,curl" --file-root /srv/data
# Claude Code 连接远程
claude mcp add --transport http bolt http://127.0.0.1:8080/mcp
``` ```
要注册成系统服务,把构建产物交给安装脚本:`sudo sh deploy/install.sh --binary target/release/bolt --http 0.0.0.0:8080 --token 你的令牌`。
## 工具 ## 工具
| 工具 | 说明 | | 工具 | 说明 |
|---|---| |---|---|
| `run_command` | 执行 shell 命令:完整 shell 语法(管道/重定向/`&&`)、`cwd`、`env`、`timeout_ms`(超时自动杀进程树)、`max_output_bytes`(输出截断防膨胀)、`shell=false` 直接执行二进制;客户端可传 `_meta.progressToken` 接收进度通知,支持 `notifications/cancelled` 取消 | | `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_open` | 开启持久化交互式 shell 会话(`cd`、环境变量等状态跨次保留),可指定 `shell`(sh/bash/zsh/fish)与 `cwd`;空闲 30 分钟自动回收 |
| `shell_write` | 向会话写入输入(自动补换行) | | `shell_write` | 向会话写入输入(自动补换行) |
| `shell_read` | 读取会话新输出,可阻塞等待(`timeout_ms`,0 = 等到会话退出) | | `shell_read` | 读取会话新输出,可阻塞等待(`timeout_ms`,0 = 等到会话退出),可被取消 |
| `shell_close` | 关闭并杀掉会话进程树 | | `shell_close` | 关闭并杀掉会话进程树 |
| `file_read` | 读取文件:UTF-8 文本按文本返回,图片按 image 返回,其余按 base64;`offset` + `length` 分块读取,不会切断多字节字符 |
| `file_write` | 写入文件:`overwrite`(默认,临时文件 + rename 原子替换,保留原权限)/ `create`(已存在则失败)/ `append`;`encoding=base64` 写二进制;`mkdirs` 自动建目录;返回 SHA-256 |
| `file_stat` | 文件信息(是否存在、类型、大小、修改时间、权限),可选计算 SHA-256 |
| `file_link` | 仅 HTTP 模式:生成带 HMAC 签名、限时有效的 `/files` 直传链接,并给出可在客户端本机执行的 `curl` 命令 |
`run_command` 返回:`exit_code` / `signal` / `timed_out` / `cancelled` / `duration_ms` / `stdout` / `stderr` / 截断标志等(同时提供 `content` 与 `structuredContent`)。 所有工具结果都同时提供 `content` 与 `structuredContent`(`file_read` 为两块 `content`:元数据 JSON + 文件内容)。
`run_command` 返回:`exit_code` / `signal` / `timed_out` / `cancelled` / `detached` / `duration_ms` / `stdout` / `stderr` / 截断标志等。命令退出后若仍有后台进程占用输出管道(如 `python -m http.server &`),等待 300ms 后返回 `detached=true`,后台进程继续运行。
## 文件传输
MCP 工具的参数和结果都要经过模型上下文,所以分两层:
- **小文件**(配置、脚本、日志片段、截图):直接用 `file_read` / `file_write`,stdio 和 HTTP 都可用。
- **大文件**(HTTP 模式):数据走 `/files` 端点,不经过模型。
- AI 调用 `file_link` 拿到签名链接,在客户端本机执行返回的 `curl` 命令即可:
```bash
curl -fsSL -o ./backup.tar.gz 'http://host:8080/files?path=/srv/data/backup.tar.gz&exp=...&sig=...' # 下载
curl -fsS -T ./dataset.zip 'http://host:8080/files?path=/srv/data/dataset.zip&exp=...&sig=...&mode=overwrite&mkdirs=0' # 上传
```
- 也可以直接带 Bearer 令牌访问:`curl -H "Authorization: Bearer $T" -T f.bin "http://host:8080/files?path=/srv/data/f.bin&mkdirs=1"`。
- 支持 `Range` 断点续传(206)、`HEAD`、chunked 上传、`Expect: 100-continue`;上传同样是原子替换,失败不破坏原文件,响应里带 SHA-256。
- 签名覆盖方法、路径、写入模式和过期时间,链接只能用于签名时指定的那一个操作;签名密钥在进程启动时随机生成,重启后旧链接失效。
## 配置 ## 配置
@@ -51,62 +159,104 @@ CLI 参数(环境变量 `BOLT_*` 为回退):
| 参数 | 默认 | 说明 | | 参数 | 默认 | 说明 |
|---|---|---| |---|---|---|
| `--http <addr>` | 无(stdio) | 启用 Streamable HTTP 监听地址,如 `127.0.0.1:8080` | | `--http <addr>` | 无(stdio) | 启用 Streamable HTTP 监听地址,如 `127.0.0.1:8080`(`:8080` 等价于 `127.0.0.1:8080`) |
| `--token <t>` | 无 | HTTP 要求 `Authorization: Bearer <t>` | | `--token <t>` | 无 | HTTP 要求 `Authorization: Bearer <t>` |
| `--allow <a,b>` | 空 | 命令白名单(命令首词前缀匹配;非空时仅允许列表内命令) | | `--allow <a,b>` | 空 | 命令白名单(按程序名精确匹配;非空时 shell 模式禁止元字符) |
| `--deny <a,b>` | 空 | 命令黑名单(前缀匹配) | | `--deny <a,b>` | 空 | 命令黑名单(按程序名匹配命令中每一段,尽力而为) |
| `--timeout-ms <n>` | 30000 | 命令默认超时(工具参数可覆盖,0 = 无限) | | `--timeout-ms <n>` | 30000 | 命令默认超时(工具参数可覆盖,0 = 无限) |
| `--cwd <dir>` | 当前 | 命令默认工作目录 | | `--cwd <dir>` | 当前 | 命令与相对文件路径的默认工作目录 |
| `--max-output <n>` | 1M | 单流输出捕获上限(支持 K/M/G) | | `--max-output <n>` | 1M | 单流输出捕获上限(支持 K/M/G) |
| `--allow-origin <url>` | localhost/127.0.0.1 | 额外允许的 HTTP Origin(防 DNS rebinding) | | `--allow-origin <url>` | localhost/127.0.0.1 | 额外允许的 HTTP Origin(防 DNS rebinding) |
| `--file-root <d1,d2>` | 不限 | 文件工具与 `/files` 只能访问这些目录;配置了 `--allow` 时必须设置,否则文件功能禁用 |
| `--max-upload <n>` | 2G | `PUT /files` 单次上传上限(支持 K/M/G) |
```bash ```bash
export BOLT_TOKEN=xxx BOLT_ALLOW="git,docker" BOLT_TIMEOUT_MS=60000 export BOLT_TOKEN=xxx BOLT_ALLOW="git,docker" BOLT_TIMEOUT_MS=60000 BOLT_FILE_ROOT=/srv/data
bolt --http 127.0.0.1:8080 bolt --http 127.0.0.1:8080
``` ```
## 协议实现(2025-06-18 规范) ## 协议实现(2025-06-18 规范)
- `initialize`(协议版本协商,支持 2024-11-05 / 2025-03-26 / 2025-06-18)、`ping`、`tools/list`、`tools/call`、`shutdown` - `initialize`(协议版本协商,支持 2024-11-05 / 2025-03-26 / 2025-06-18)、`ping`、`tools/list`、`tools/call`、`shutdown`
- `notifications/initialized`、`notifications/cancelled`、`notifications/exit`、`notifications/progress`(进度上报) - `notifications/initialized`、`notifications/cancelled`、`notifications/exit`(仅 stdio 生效)、`notifications/progress`(进度上报)
- 工具执行错误以 `isError: true` 返回;协议错误返回标准 JSON-RPC 错误码(-32601/-32602/-32700 等) - 工具执行错误以 `isError: true` 返回;协议错误返回标准 JSON-RPC 错误码(-32601/-32602/-32700 等)
- Streamable HTTP:POST 请求(通知 → 202;请求 → JSON 或 SSE 响应)、GET 长连接 SSE 流(服务器通知广播 + 10s 心跳)、CORS、Origin 校验、Bearer 认证、无状态(不要求会话 ID) - Streamable HTTP:
- `initialize` 时签发 `Mcp-Session-Id`;携带该头的请求按会话隔离 shell 会话与取消,未知/过期 ID 返回 404,`DELETE /mcp` 终止会话并清理其资源;不带该头的客户端共用一个匿名作用域(兼容无状态客户端)
- POST:通知 → 202;请求 → JSON 或 SSE。`Accept` 含 `text/event-stream` 的 `tools/call` 会在同一个 SSE 响应里先推进度通知、最后推结果
- GET:长连接 SSE 流(接收本会话的服务器通知 + 10s 心跳)
- 支持 chunked 请求体、CORS、Origin 校验、Bearer 认证
## 性能 ## 性能与资源
- 单线程事件循环 + 复用缓冲区,无锁热路径;输出计数用原子操作,进度推送不占主循环 - 复用缓冲区,输出计数用原子操作,进度推送不占主循环;stdio 每个工具调用一个工作线程,HTTP 每个连接一个线程
- 子进程独占进程组(`setpgid`),超时/取消按进程组 `SIGKILL`,连带杀掉整棵进程树,无僵尸 - 子进程独占进程组(`setpgid`),超时/取消按进程组 `SIGKILL`,连带杀掉整棵进程树,无僵尸
- 上限:并发工具调用 128、HTTP 连接 512、shell 会话 64(空闲 30 分钟回收)、MCP 会话 1024(空闲 24 小时回收)
- 实测(本机,含 Python 客户端逐字节 I/O 开销): - 实测(本机,含 Python 客户端逐字节 I/O 开销):
- ping 往返吞吐 ≈ **14,700 msg/s** - ping 往返吞吐 ≈ **14,700 msg/s**
- 并发 `echo` 命令平均 **4.7 ms/次**(进程 spawn + 捕获输出) - 并发 `echo` 命令平均 **4.7 ms/次**(进程 spawn + 捕获输出)
- 构建配置:`lto = "fat"`、`codegen-units = 1`、`strip = true`,二进制约 0.7MB - 构建配置:`lto = "fat"`、`codegen-units = 1`、`strip = true`
## 测试 ## 测试
```bash ```bash
cargo test --release # 8 个单元测试(协议分发/策略/HTTP 解析/执行引擎/会话) cargo test --release # 单元测试(策略/编码与摘要/HTTP 解析/文件读写与目录限制/会话缓冲/执行引擎等)
cargo test --release test_files # 只跑某一个
``` ```
集成测试脚本在 `docs/`(stdio 12 项、HTTP 19 项,覆盖超时/取消/进程树/进度/并发/auth/Origin): 端到端测试脚本在 `docs/`(需要 Unix 主机;二进制路径默认 `target/release/bolt`,可用 `BOLT_BIN` 指定):
```bash ```bash
python3 /tmp/bolt_stdio_test.py # stdio 端到端 python3 docs/bolt_stdio_test.py # stdio 端到端
python3 /tmp/bolt_http_test.py # HTTP 端到端 python3 docs/bolt_http_test.py # HTTP 端到端(含 Mcp-Session-Id、/files、签名链接)
``` ```
## 安全说明 ## 安全说明
- 默认只接受本机连接(`127.0.0.1`),对外公开需显式 `0.0.0.0` + `--token` - 默认只接受本机连接(`127.0.0.1`),对外公开需显式 `0.0.0.0` + `--token`(未设置时启动日志会警告)
- 该服务器等于远程 root shell 入口,务必配置白名单(`--allow`)与认证后再暴露公网 - 该服务器等于远程 root shell 入口,务必配置认证后再暴露公网
- Unix 下命令在独立进程组运行,取消/超时会连带杀掉子进程 - 白名单(`--allow`):按程序名精确匹配,shell 模式下禁止 `; & | $ \` < > ( ) { }` 和换行,防止 `git status; rm -rf ~` 这类串联绕过;`shell_write` 写入的每一行同样受检。注意白名单内的程序如果自身能执行任意命令(`sh`、`env`、`docker`、`find -exec`、`git -c ...`),白名单就约束不了它
- 黑名单(`--deny`):检查命令每一段的程序名(去掉路径、引号、转义和前置的 `VAR=x`),但仍可能被包装命令绕过,只适合防误操作
- 文件功能等同于任意写文件(可写 `authorized_keys`、crontab),所以配置白名单时必须用 `--file-root` 限定目录,否则文件功能自动禁用;路径会解析符号链接并拒绝 `..` 逃逸
- 不同 MCP 会话之间的 shell 会话与取消相互隔离;HTTP 客户端无法通过 `notifications/exit` 关停服务
- Unix 下命令在独立进程组运行,取消/超时会连带杀掉子进程;收到 SIGINT/SIGTERM(Windows 为 Ctrl+C)时先清理全部子进程再退出
## 平台 ## 平台
- Linux / macOS(完整功能:进程组杀、信号处理) - Linux / macOS(完整功能:进程组杀、信号处理)
- Windows(基础功能:`cmd /C`、`taskkill /T` 杀进程树;无信号优雅退出) - Windows(基础功能:`cmd /C`、`taskkill /T` 杀进程树、Ctrl+C 清理退出)
## systemd 服务部署(开机自启)
仓库内 `deploy/` 提供现成的部署文件: ## 部署细节
### 配置文件与修改
| | Linux(systemd) | macOS(launchd) |
|---|---|---|
| 二进制 | `/usr/local/bin/bolt` | `/usr/local/bin/bolt` |
| 配置 | `/etc/bolt.env`(`BOLT_*` 环境变量);监听地址在 `/etc/systemd/system/bolt.service` 的 `ExecStart` | `/Library/Application Support/bolt/io.jocay.bolt.plist`(开机自启时复制到 `/Library/LaunchDaemons/`) |
| 日志 | `journalctl -u bolt -f` | `/var/log/bolt/stdout.log`、`/var/log/bolt/stderr.log` |
| 重启 | `sudo systemctl restart bolt` | `sudo launchctl kickstart -k system/io.jocay.bolt` |
Linux 上改完 `/etc/bolt.env` 后重启服务即可生效,可用的变量见 `deploy/bolt.env.example`。macOS 的两份 plist 需要保持一致,改参数建议用 `--reinstall` 重新执行安装脚本(记得带上原来的 `--token` 等参数)。两份配置均仅 root 可读写。
### macOS(launchd)
`--no-enable` 只保留手动启动配置,取消本服务的开机配置,仍可立即启动;`--no-start` 本次不加载服务,已运行的本服务也会卸载,下次开机是否启动由 `--no-enable` 决定。两个参数可一起使用。
```bash
# 查看服务 / 重启已加载的服务 / 停止并卸载
sudo launchctl print system/io.jocay.bolt
sudo launchctl kickstart -k system/io.jocay.bolt
sudo launchctl bootout system/io.jocay.bolt
# 停止后或使用 --no-start 安装后,手动加载并启动
sudo launchctl bootstrap system '/Library/Application Support/bolt/io.jocay.bolt.plist'
```
macOS 服务的工作目录为 `/var/root`,PATH 包含 `/opt/homebrew/bin` 和 `/opt/homebrew/sbin`,不会加载个人 shell 配置。
### systemd 手动部署
不用安装脚本时,仓库内 `deploy/` 提供现成的部署文件:
```bash ```bash
sudo install -m 0755 target/release/bolt /usr/local/bin/bolt sudo install -m 0755 target/release/bolt /usr/local/bin/bolt
@@ -116,14 +266,18 @@ sudo systemctl daemon-reload
sudo systemctl enable --now bolt # 开机自启 + 立即启动 sudo systemctl enable --now bolt # 开机自启 + 立即启动
``` ```
常用管理命令: 服务特性:崩溃自动重启(`Restart=on-failure`,2 秒间隔)、优雅停机(SIGTERM 时杀掉所有子进程树)、日志进 journald、`LimitNOFILE=65536`。
```bash ### 发布新版本
systemctl status bolt # 查看状态
systemctl restart bolt # 重启(改完配置后)
systemctl stop bolt # 停止
systemctl disable bolt # 取消开机自启
journalctl -u bolt -f # 实时查看日志
```
特性:崩溃自动重启(`Restart=on-failure`,2 秒间隔)、优雅停机(SIGTERM 时杀掉所有子进程树)、日志进 journald、`LimitNOFILE=65536`。默认监听 `127.0.0.1:8080`,对外暴露前务必在 `/etc/bolt.env` 设置 `BOLT_TOKEN`。 1. 修改 `Cargo.toml` 的 `version`,提交并推送。
2. 编译附件(需要 [cargo-zigbuild](https://github.com/rust-cross/cargo-zigbuild) 与 zig):
```bash
cargo zigbuild --release --target x86_64-unknown-linux-gnu.2.28 # bolt-linux-amd64,只依赖 glibc 2.28
cargo zigbuild --release --target aarch64-apple-darwin # bolt-darwin-arm64,安装时缺少签名会自动补 ad-hoc 签名
sha256sum bolt-* > SHA256SUMS
```
直接 `cargo build` 的产物依赖构建机的 glibc 版本,拿到较老的系统上可能无法运行。
3. 在 Gitea 上新建 Release(tag 如 `v0.3.0`),上传 `bolt-linux-amd64`、`bolt-darwin-arm64` 和 `SHA256SUMS`。脚本按 `/api/v1/repos/jocay/bolt/releases/latest` 识别最新版本,发布后各设备执行[更新](#更新)命令即可。
+8 -2
View File
@@ -7,12 +7,18 @@
# 认证令牌(必填,若监听 0.0.0.0 对外暴露) # 认证令牌(必填,若监听 0.0.0.0 对外暴露)
#BOLT_TOKEN=请改成强随机串 #BOLT_TOKEN=请改成强随机串
# 命令白名单(非空时仅允许列出的命令,用逗号分隔) # 命令白名单(按程序名精确匹配,逗号分隔;非空时 shell 模式禁止管道/重定向/命令串联等元字符)
#BOLT_ALLOW=git,docker,curl #BOLT_ALLOW=git,docker,curl
# 命令黑名单(前缀匹配) # 命令黑名单(按程序名精确匹配命令中每一段;尽力而为,不能替代白名单)
#BOLT_DENY=rm,mkfs #BOLT_DENY=rm,mkfs
# 文件工具与 /files 允许访问的目录(逗号分隔;设置了 BOLT_ALLOW 时必须配置,否则文件功能禁用)
#BOLT_FILE_ROOT=/srv/data,/tmp
# PUT /files 单次上传上限(支持 K/M/G,默认 2G)
#BOLT_MAX_UPLOAD=2G
# 默认命令超时毫秒(0 = 无限) # 默认命令超时毫秒(0 = 无限)
#BOLT_TIMEOUT_MS=30000 #BOLT_TIMEOUT_MS=30000
+325
View File
@@ -0,0 +1,325 @@
#!/bin/sh
# bolt 一键安装脚本(POSIX sh,需 root; Linux 使用 systemd,macOS 使用 launchd)
#
# 用法(在目标设备上):
# 方式 A - 从 Gitea Release 下载最新版二进制并安装:
# IPv4: curl -fsSL https://git.jocayn.qzz.io/jocay/bolt/raw/branch/main/deploy/install.sh | sudo sh -s -- --http 0.0.0.0:8080 --token 你的令牌
# IPv6: curl -fsSL https://git.draws.qzz.io/jocay/bolt/raw/branch/main/deploy/install.sh | sudo sh -s -- --http 0.0.0.0:8080 --token 你的令牌
# 方式 B - 已把二进制拷到目标设备(如 scp):
# ./install.sh --binary ./bolt --http 0.0.0.0:8080 --token xxx --allow "git,docker"
#
# 已安装的设备升级请用 update.sh(保留配置);卸载用 uninstall.sh。
#
# 选项:
# --http <addr> 监听地址,默认 127.0.0.1:8080
# --token <t> HTTP Bearer 认证令牌(监听公网时必填)
# --allow <a,b> 命令白名单
# --deny <a,b> 命令黑名单
# --timeout-ms <n> 命令默认超时(默认 30000)
# --file-root <d1,d2> 文件工具与 /files 只能访问这些目录(配置 --allow 时必须设置,否则文件功能禁用)
# --max-upload <n> PUT /files 单次上传上限,支持 K/M/G(默认 2G)
# --binary <src> 二进制来源:本地路径 或 http(s) URL(默认从 Gitea Release 下载)
# --version <tag> 安装指定版本(默认 Release 上的最新版本)
# --reinstall 已安装时按本次参数重写配置(不加则拒绝覆盖,避免丢失令牌等配置)
# --no-start 只安装不启动
# --no-enable 不设置开机自启
#
# 环境变量:
# BOLT_GIT_HOST 指定下载站点(如 https://git.jocayn.qzz.io),默认先试 IPv4 站点再试 IPv6 站点
# BOLT_DL_URL 直接指定二进制下载地址
# BOLT_DL_TOKEN 仓库为私有时的下载令牌(与 --token 无关)
set -eu
HTTP_ADDR="${HTTP_ADDR:-127.0.0.1:8080}"
TOKEN="${BOLT_TOKEN:-}"
ALLOW=""
DENY=""
TIMEOUT_MS="${BOLT_TIMEOUT_MS:-30000}"
FILE_ROOT=""
MAX_UPLOAD=""
BINARY_SRC=""
VERSION="${BOLT_VERSION:-}"
REINSTALL=0
NO_START=0
NO_ENABLE=0
while [ $# -gt 0 ]; do
case "$1" in
--http) HTTP_ADDR="${2:-}"; shift 2 ;;
--token) TOKEN="${2:-}"; shift 2 ;;
--allow) ALLOW="${2:-}"; shift 2 ;;
--deny) DENY="${2:-}"; shift 2 ;;
--timeout-ms) TIMEOUT_MS="${2:-}"; shift 2 ;;
--file-root) FILE_ROOT="${2:-}"; shift 2 ;;
--max-upload) MAX_UPLOAD="${2:-}"; shift 2 ;;
--binary) BINARY_SRC="${2:-}"; shift 2 ;;
--version) VERSION="${2:-}"; shift 2 ;;
--reinstall) REINSTALL=1; shift ;;
--no-start) NO_START=1; shift ;;
--no-enable) NO_ENABLE=1; shift ;;
*) echo "未知参数: $1"; echo "用法见脚本头部注释"; exit 1 ;;
esac
done
REPO=jocay/bolt
HOST4=https://git.jocayn.qzz.io
HOST6=https://git.draws.qzz.io
BIN_PATH=/usr/local/bin/bolt
ENV_FILE=/etc/bolt.env
UNIT_FILE=/etc/systemd/system/bolt.service
say() { printf '\033[1;32m[bolt]\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m[bolt] 错误:\033[0m %s\n' "$*"; exit 1; }
[ "$(id -u)" -eq 0 ] || die "需要 root 权限"
OS=$(uname -s)
ARCH=$(uname -m)
case "$OS" in
Linux)
command -v systemctl >/dev/null 2>&1 || die "未检测到 systemd"
PLATFORM=linux
;;
Darwin)
command -v launchctl >/dev/null 2>&1 || die "未检测到 launchd"
PLATFORM=darwin
# Rosetta 下 uname 可能返回 x86_64,优先识别实际的 Apple Silicon 硬件。
if [ "$(sysctl -n hw.optional.arm64 2>/dev/null || true)" = 1 ]; then
ARCH=arm64
fi
;;
*) die "不支持的操作系统: $OS" ;;
esac
case "$ARCH" in
x86_64|amd64) ARCH=amd64 ;;
aarch64|arm64) ARCH=arm64 ;;
*) die "不支持的架构: $ARCH" ;;
esac
say "目标平台: $PLATFORM/$ARCH"
# 已安装时默认拒绝:重写配置会丢掉原来的令牌、白名单等
if [ "$REINSTALL" -eq 0 ] && [ -x "$BIN_PATH" ] && { [ -e "$ENV_FILE" ] || [ -e "/Library/Application Support/bolt/io.jocay.bolt.plist" ]; }; then
die "检测到已安装的 bolt。升级请用 update.sh(保留配置);确实要按本次参数重写配置请加 --reinstall"
fi
# fetch <url> <输出文件> [probe]:probe 用于探测站点,连接最多等 8 秒、总共 15 秒且不重试
fetch() {
if [ "${3:-}" = probe ]; then
c_opt="--connect-timeout 8 -m 15"; w_opt="-T 15 -t 1"
else
c_opt="--connect-timeout 15 --retry 3"; w_opt="-T 60 -t 3"
fi
if command -v curl >/dev/null 2>&1; then
if [ -n "${BOLT_DL_TOKEN:-}" ]; then
curl -fsSL $c_opt -H "Authorization: token $BOLT_DL_TOKEN" -o "$2" "$1"
else
curl -fsSL $c_opt -o "$2" "$1"
fi
elif command -v wget >/dev/null 2>&1; then
if [ -n "${BOLT_DL_TOKEN:-}" ]; then
wget -q $w_opt --header="Authorization: token $BOLT_DL_TOKEN" -O "$2" "$1"
else
wget -q $w_opt -O "$2" "$1"
fi
else
die "需要 curl 或 wget"
fi
}
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
mkdir -p /usr/local/bin
# ---------- 1. 获取二进制 ----------
if [ -n "$BINARY_SRC" ]; then
if [ -f "$BINARY_SRC" ]; then
say "使用本地二进制: $BINARY_SRC"
cp "$BINARY_SRC" "$TMP/bolt"
elif echo "$BINARY_SRC" | grep -q '^https\?://'; then
say "下载二进制: $BINARY_SRC"
fetch "$BINARY_SRC" "$TMP/bolt" || die "下载失败"
else
die "二进制来源不存在: $BINARY_SRC"
fi
elif [ -n "${BOLT_DL_URL:-}" ]; then
say "下载二进制: $BOLT_DL_URL"
fetch "$BOLT_DL_URL" "$TMP/bolt" || die "下载失败"
else
# 选择站点:BOLT_GIT_HOST 优先,否则先试 IPv4 站点再试 IPv6 站点
HOST=""
if [ -n "${BOLT_GIT_HOST:-}" ]; then
HOST="${BOLT_GIT_HOST%/}"
else
# 不强制协议族,由 curl 自动选择可用的 IPv4 / IPv6 地址
for h in "$HOST4" "$HOST6"; do
if fetch "$h/api/v1/version" "$TMP/probe" probe 2>/dev/null; then
HOST=$h
break
fi
done
[ -n "$HOST" ] || die "无法连接 $HOST4 或 $HOST6,可用 BOLT_GIT_HOST 指定站点"
fi
say "下载站点: $HOST"
if [ -z "$VERSION" ]; then
fetch "$HOST/api/v1/repos/$REPO/releases/latest" "$TMP/latest.json" || die "查询最新版本失败"
VERSION=$(sed -n 's/.*"tag_name":"\([^"]*\)".*/\1/p' "$TMP/latest.json")
[ -n "$VERSION" ] || die "无法解析最新版本号"
fi
BASE="$HOST/$REPO/releases/download/$VERSION"
say "从 Release 下载: $BASE/bolt-$PLATFORM-$ARCH"
fetch "$BASE/bolt-$PLATFORM-$ARCH" "$TMP/bolt" || die "下载失败,请确认 Release $VERSION 有 bolt-$PLATFORM-$ARCH 附件"
# 有 SHA256SUMS 附件时校验
if fetch "$BASE/SHA256SUMS" "$TMP/SHA256SUMS" 2>/dev/null; then
want=$(awk -v f="bolt-$PLATFORM-$ARCH" '$2 == f || $2 == "*" f {print $1}' "$TMP/SHA256SUMS")
if command -v sha256sum >/dev/null 2>&1; then
got=$(sha256sum "$TMP/bolt" | awk '{print $1}')
else
got=$(shasum -a 256 "$TMP/bolt" | awk '{print $1}')
fi
[ -n "$want" ] && [ "$want" = "$got" ] || die "SHA-256 校验失败(期望 $want,实际 $got)"
say "SHA-256 校验通过"
fi
fi
chmod 0755 "$TMP/bolt"
if [ "$PLATFORM" = darwin ]; then
# Apple Silicon 要求可执行文件带签名;没有签名时补 ad-hoc 签名
codesign -v "$TMP/bolt" 2>/dev/null || codesign -s - -f "$TMP/bolt" >/dev/null 2>&1 || true
fi
"$TMP/bolt" --help >/dev/null 2>&1 || die "二进制无法在本机运行"
# 先写临时文件再 rename,替换正在运行的旧版本也安全
cp "$TMP/bolt" "$BIN_PATH.new"
chmod 0755 "$BIN_PATH.new"
mv -f "$BIN_PATH.new" "$BIN_PATH"
say "二进制就绪: $BIN_PATH ($("$BIN_PATH" --help | head -1))"
# macOS 将环境配置直接写入 plist,不通过 shell 解释令牌或命令规则。
if [ "$OS" = Darwin ]; then
LABEL=io.jocay.bolt
PLIST_DIR="/Library/Application Support/bolt"
PLIST_FILE="$PLIST_DIR/$LABEL.plist"
AUTO_PLIST="/Library/LaunchDaemons/$LABEL.plist"
xml_escape() {
printf '%s' "$1" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g; s/"/\&quot;/g; s/'"'"'/\&apos;/g'
}
mkdir -p "$PLIST_DIR" /Library/LaunchDaemons /var/log/bolt
chmod 700 "$PLIST_DIR" /var/log/bolt
# 令牌所在配置仅供 root 读取。
umask 077
say "写入 launchd 配置: $PLIST_FILE"
cat > "$PLIST_FILE" <<PLISTEOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>$LABEL</string>
<key>ProgramArguments</key>
<array>
<string>$BIN_PATH</string>
<string>--http</string><string>$(xml_escape "$HTTP_ADDR")</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key><string>/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>BOLT_TOKEN</key><string>$(xml_escape "$TOKEN")</string>
<key>BOLT_ALLOW</key><string>$(xml_escape "$ALLOW")</string>
<key>BOLT_DENY</key><string>$(xml_escape "$DENY")</string>
<key>BOLT_TIMEOUT_MS</key><string>$(xml_escape "$TIMEOUT_MS")</string>
<key>BOLT_FILE_ROOT</key><string>$(xml_escape "$FILE_ROOT")</string>
<key>BOLT_MAX_UPLOAD</key><string>$(xml_escape "$MAX_UPLOAD")</string>
</dict>
<key>WorkingDirectory</key><string>/var/root</string>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
<key>ThrottleInterval</key><integer>2</integer>
<key>ExitTimeOut</key><integer>10</integer>
<key>StandardOutPath</key><string>/var/log/bolt/stdout.log</string>
<key>StandardErrorPath</key><string>/var/log/bolt/stderr.log</string>
</dict>
</plist>
PLISTEOF
chown root:wheel "$PLIST_FILE"
chmod 600 "$PLIST_FILE"
if [ "$NO_ENABLE" -eq 0 ]; then
cp "$PLIST_FILE" "$AUTO_PLIST"
chown root:wheel "$AUTO_PLIST"
chmod 600 "$AUTO_PLIST"
say "已设置开机自启"
else
# 仅移除本脚本管理的开机配置,保留手动启动配置。
rm -f "$AUTO_PLIST"
fi
launchctl enable "system/$LABEL"
if launchctl print "system/$LABEL" >/dev/null 2>&1; then
launchctl bootout "system/$LABEL"
fi
if [ "$NO_START" -eq 0 ]; then
launchctl bootstrap system "$PLIST_FILE" || die "服务注册失败"
say "已提交服务启动: http://$HTTP_ADDR/mcp"
else
say "已安装(未启动),执行: sudo launchctl bootstrap system '$PLIST_FILE'"
fi
say "管理: sudo launchctl print system/$LABEL | sudo launchctl kickstart -k system/$LABEL"
say "日志: /var/log/bolt/stdout.log 与 /var/log/bolt/stderr.log"
exit 0
fi
# ---------- 2. 写入环境配置 ----------
say "写入配置: $ENV_FILE"
printf '# bolt 服务配置,修改后 systemctl restart bolt\n' > "$ENV_FILE"
printf 'BOLT_TIMEOUT_MS=%s\n' "$TIMEOUT_MS" >> "$ENV_FILE"
if [ -n "$TOKEN" ]; then
printf 'BOLT_TOKEN=%s\n' "$TOKEN" >> "$ENV_FILE"
fi
if [ -n "$ALLOW" ]; then
printf 'BOLT_ALLOW=%s\n' "$ALLOW" >> "$ENV_FILE"
fi
if [ -n "$DENY" ]; then
printf 'BOLT_DENY=%s\n' "$DENY" >> "$ENV_FILE"
fi
if [ -n "$FILE_ROOT" ]; then
printf 'BOLT_FILE_ROOT=%s\n' "$FILE_ROOT" >> "$ENV_FILE"
fi
if [ -n "$MAX_UPLOAD" ]; then
printf 'BOLT_MAX_UPLOAD=%s\n' "$MAX_UPLOAD" >> "$ENV_FILE"
fi
[ -z "$TOKEN" ] && [ -z "$ALLOW" ] && [ -z "$DENY" ] && \
printf '# 默认无认证无白名单;监听公网时请设置 BOLT_TOKEN 与 BOLT_ALLOW\n' >> "$ENV_FILE"
# ---------- 3. 写入 systemd 单元 ----------
say "写入服务单元: $UNIT_FILE"
cat > "$UNIT_FILE" <<UNITEOF
[Unit]
Description=bolt - 极速单文件 MCP 命令执行服务器
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=$BIN_PATH --http $HTTP_ADDR
EnvironmentFile=-$ENV_FILE
Restart=on-failure
RestartSec=2
TimeoutStopSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=bolt
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
UNITEOF
# ---------- 4. 注册并启动 ----------
systemctl daemon-reload
if [ "$NO_ENABLE" -eq 0 ]; then
systemctl enable bolt >/dev/null 2>&1 && say "已设置开机自启"
fi
if [ "$NO_START" -eq 0 ]; then
systemctl restart bolt
sleep 1
systemctl is-active bolt >/dev/null 2>&1 || { systemctl status bolt --no-pager -l | tail -15; die "服务启动失败"; }
say "服务运行中: http://$HTTP_ADDR/mcp $(systemctl show -p MainPID --value bolt)"
[ -z "$TOKEN" ] || say "已启用 Bearer 认证"
else
say "已安装(未启动),执行 systemctl start bolt 启动"
fi
say "完成!管理: systemctl status bolt | journalctl -u bolt -f | systemctl restart bolt"
+94
View File
@@ -0,0 +1,94 @@
#!/bin/sh
# bolt 卸载脚本(POSIX sh,需 root;Linux 使用 systemd,macOS 使用 launchd)
#
# 用法(在目标设备上):
# IPv4: curl -fsSL https://git.jocayn.qzz.io/jocay/bolt/raw/branch/main/deploy/uninstall.sh | sudo sh
# IPv6: curl -fsSL https://git.draws.qzz.io/jocay/bolt/raw/branch/main/deploy/uninstall.sh | sudo sh
# 保留配置: ... | sudo sh -s -- --keep-config
#
# 选项:
# --keep-config 保留配置(Linux: /etc/bolt.env;macOS: /Library/Application Support/bolt 与 /var/log/bolt)
#
# 注意:如果是通过 bolt 自己执行本脚本,最后停止服务时连接会断开,这是正常现象。
set -eu
BIN_PATH=/usr/local/bin/bolt
LABEL=io.jocay.bolt
KEEP_CONFIG=0
while [ $# -gt 0 ]; do
case "$1" in
--keep-config) KEEP_CONFIG=1; shift ;;
*) echo "未知参数: $1"; echo "用法见脚本头部注释"; exit 1 ;;
esac
done
say() { printf '\033[1;32m[bolt]\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m[bolt] 错误:\033[0m %s\n' "$*"; exit 1; }
[ "$(id -u)" -eq 0 ] || die "需要 root 权限(请用 sudo 运行)"
# 二进制及更新脚本留下的备份(正在运行的进程不受影响,最后再停服务)
rm_bins() {
for f in "$BIN_PATH" "$BIN_PATH.bak" "$BIN_PATH.new" "$BIN_PATH.rollback" "$BIN_PATH".v*.bak; do
if [ -e "$f" ]; then
rm -f "$f"
say "已删除 $f"
fi
done
rm -rf /tmp/bolt-update.* /tmp/bolt-upgrade 2>/dev/null || true
}
case "$(uname -s)" in
Linux)
UNIT=/etc/systemd/system/bolt.service
HAS_SYSTEMD=0
command -v systemctl >/dev/null 2>&1 && HAS_SYSTEMD=1
if [ "$HAS_SYSTEMD" -eq 1 ]; then
systemctl disable bolt >/dev/null 2>&1 && say "已取消开机自启" || true
fi
rm_bins
if [ -e "$UNIT" ] || [ -d "$UNIT.d" ]; then
rm -rf "$UNIT" "$UNIT.d"
say "已删除服务单元 $UNIT"
fi
if [ "$KEEP_CONFIG" -eq 0 ]; then
if [ -e /etc/bolt.env ]; then
rm -f /etc/bolt.env
say "已删除配置 /etc/bolt.env"
fi
else
[ -e /etc/bolt.env ] && say "保留配置 /etc/bolt.env"
fi
say "卸载完成,正在停止服务…"
if [ "$HAS_SYSTEMD" -eq 1 ]; then
systemctl daemon-reload || true
systemctl stop bolt >/dev/null 2>&1 || true
systemctl reset-failed bolt >/dev/null 2>&1 || true
fi
pkill -x bolt 2>/dev/null || true
;;
Darwin)
AUTO_PLIST="/Library/LaunchDaemons/$LABEL.plist"
PLIST_DIR="/Library/Application Support/bolt"
rm_bins
if [ -e "$AUTO_PLIST" ]; then
rm -f "$AUTO_PLIST"
say "已删除开机配置 $AUTO_PLIST"
fi
if [ "$KEEP_CONFIG" -eq 0 ]; then
for d in "$PLIST_DIR" /var/log/bolt; do
if [ -e "$d" ]; then
rm -rf "$d"
say "已删除 $d"
fi
done
else
say "保留配置 $PLIST_DIR 与日志 /var/log/bolt"
fi
say "卸载完成,正在停止服务…"
launchctl bootout "system/$LABEL" 2>/dev/null || true
launchctl disable "system/$LABEL" 2>/dev/null || true
;;
*) die "不支持的操作系统: $(uname -s)" ;;
esac
+264
View File
@@ -0,0 +1,264 @@
#!/bin/sh
# bolt 在线更新脚本(POSIX sh,需 root;Linux 使用 systemd,macOS 使用 launchd)
#
# 只替换二进制并重启服务,保留现有配置(/etc/bolt.env 或 launchd plist);
# 新版本启动失败时自动回滚到旧版本。
#
# 用法(在目标设备上):
# IPv4: curl -fsSL https://git.jocayn.qzz.io/jocay/bolt/raw/branch/main/deploy/update.sh | sudo sh
# IPv6: curl -fsSL https://git.draws.qzz.io/jocay/bolt/raw/branch/main/deploy/update.sh | sudo sh
# 传参: ... | sudo sh -s -- --version v0.2.0
#
# 选项:
# --version <tag> 安装指定版本(默认 Release 上的最新版本)
# --binary <src> 使用本地二进制或 http(s) URL,跳过 Release 下载
# --force 版本相同或更旧也照样安装
#
# 环境变量:
# BOLT_GIT_HOST 指定下载站点(如 https://git.jocayn.qzz.io),默认先试 IPv4 站点再试 IPv6 站点
# BOLT_DL_TOKEN 仓库为私有时的下载令牌
set -eu
REPO=jocay/bolt
HOST4=https://git.jocayn.qzz.io
HOST6=https://git.draws.qzz.io
BIN_PATH=/usr/local/bin/bolt
LABEL=io.jocay.bolt
VERSION="${BOLT_VERSION:-}"
BINARY_SRC=""
FORCE=0
while [ $# -gt 0 ]; do
case "$1" in
--version) VERSION="${2:-}"; shift 2 ;;
--binary) BINARY_SRC="${2:-}"; shift 2 ;;
--force) FORCE=1; shift ;;
*) echo "未知参数: $1"; echo "用法见脚本头部注释"; exit 1 ;;
esac
done
say() { printf '\033[1;32m[bolt]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[bolt]\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m[bolt] 错误:\033[0m %s\n' "$*"; exit 1; }
[ "$(id -u)" -eq 0 ] || die "需要 root 权限(请用 sudo 运行)"
[ -x "$BIN_PATH" ] || die "未找到已安装的 $BIN_PATH,请先用 install.sh 安装"
# ---------- 平台 ----------
OS=$(uname -s)
ARCH=$(uname -m)
case "$OS" in
Linux) PLATFORM=linux ;;
Darwin)
PLATFORM=darwin
# Rosetta 下 uname 可能返回 x86_64,优先识别实际的 Apple Silicon 硬件
if [ "$(sysctl -n hw.optional.arm64 2>/dev/null || true)" = 1 ]; then
ARCH=arm64
fi
;;
*) die "不支持的操作系统: $OS" ;;
esac
case "$ARCH" in
x86_64|amd64) ARCH=amd64 ;;
aarch64|arm64) ARCH=arm64 ;;
*) die "不支持的架构: $ARCH" ;;
esac
# ---------- 下载工具 ----------
# fetch <url> <输出文件> [probe]:probe 用于探测站点,连接最多等 8 秒、总共 15 秒且不重试
fetch() {
if [ "${3:-}" = probe ]; then
c_opt="--connect-timeout 8 -m 15"; w_opt="-T 15 -t 1"
else
c_opt="--connect-timeout 15 --retry 3"; w_opt="-T 60 -t 3"
fi
if command -v curl >/dev/null 2>&1; then
if [ -n "${BOLT_DL_TOKEN:-}" ]; then
curl -fsSL $c_opt -H "Authorization: token $BOLT_DL_TOKEN" -o "$2" "$1"
else
curl -fsSL $c_opt -o "$2" "$1"
fi
elif command -v wget >/dev/null 2>&1; then
if [ -n "${BOLT_DL_TOKEN:-}" ]; then
wget -q $w_opt --header="Authorization: token $BOLT_DL_TOKEN" -O "$2" "$1"
else
wget -q $w_opt -O "$2" "$1"
fi
else
die "需要 curl 或 wget"
fi
}
# 探测可用站点:先 IPv4 站点再 IPv6 站点(不强制协议族,由 curl 自动选择可用的 IPv4 / IPv6 地址)
pick_host() {
if [ -n "${BOLT_GIT_HOST:-}" ]; then
HOST="${BOLT_GIT_HOST%/}"
return
fi
for h in "$HOST4" "$HOST6"; do
if fetch "$h/api/v1/version" "$TMP/probe" probe 2>/dev/null; then
HOST=$h
return
fi
done
die "无法连接 $HOST4 或 $HOST6,可用 BOLT_GIT_HOST 指定站点"
}
# ver_cmp a b:输出 1 / 0 / -1
ver_cmp() {
awk -v a="$1" -v b="$2" 'BEGIN {
n = split(a, x, "."); m = split(b, y, "."); k = n > m ? n : m
for (i = 1; i <= k; i++) {
if (x[i] + 0 > y[i] + 0) { print 1; exit }
if (x[i] + 0 < y[i] + 0) { print -1; exit }
}
print 0
}'
}
bin_version() { "$1" --help 2>/dev/null | head -1 | awk '{print $2}'; }
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
CUR=$(bin_version "$BIN_PATH")
[ -n "$CUR" ] || CUR=unknown
say "当前版本: $CUR 平台: $PLATFORM/$ARCH"
# ---------- 1. 获取新二进制 ----------
NEW_BIN="$TMP/bolt"
if [ -n "$BINARY_SRC" ]; then
if [ -f "$BINARY_SRC" ]; then
cp "$BINARY_SRC" "$NEW_BIN"
elif echo "$BINARY_SRC" | grep -q '^https\?://'; then
say "下载: $BINARY_SRC"
fetch "$BINARY_SRC" "$NEW_BIN" || die "下载失败"
else
die "二进制来源不存在: $BINARY_SRC"
fi
else
pick_host
say "下载站点: $HOST"
if [ -z "$VERSION" ]; then
fetch "$HOST/api/v1/repos/$REPO/releases/latest" "$TMP/latest.json" || die "查询最新版本失败"
VERSION=$(sed -n 's/.*"tag_name":"\([^"]*\)".*/\1/p' "$TMP/latest.json")
[ -n "$VERSION" ] || die "无法解析最新版本号"
EXPLICIT=0
else
EXPLICIT=1
fi
TARGET=${VERSION#v}
if [ "$FORCE" -eq 0 ] && [ "$CUR" != unknown ]; then
c=$(ver_cmp "$TARGET" "$CUR")
if [ "$c" = 0 ]; then
say "已是最新版本 $CUR,无需更新(--force 可强制重装)"
exit 0
fi
if [ "$c" = -1 ] && [ "$EXPLICIT" -eq 0 ]; then
say "Release 上最新的是 $VERSION,比已安装的 $CUR 旧,不降级(需要降级请用 --version 指定)"
exit 0
fi
fi
BASE="$HOST/$REPO/releases/download/$VERSION"
say "下载: $BASE/bolt-$PLATFORM-$ARCH"
fetch "$BASE/bolt-$PLATFORM-$ARCH" "$NEW_BIN" || die "下载失败,请确认 Release $VERSION 有 bolt-$PLATFORM-$ARCH 附件"
# 有 SHA256SUMS 附件时校验
if fetch "$BASE/SHA256SUMS" "$TMP/SHA256SUMS" 2>/dev/null; then
want=$(awk -v f="bolt-$PLATFORM-$ARCH" '$2 == f || $2 == "*" f {print $1}' "$TMP/SHA256SUMS")
if command -v sha256sum >/dev/null 2>&1; then
got=$(sha256sum "$NEW_BIN" | awk '{print $1}')
else
got=$(shasum -a 256 "$NEW_BIN" | awk '{print $1}')
fi
[ -n "$want" ] && [ "$want" = "$got" ] || die "SHA-256 校验失败(期望 $want,实际 $got)"
say "SHA-256 校验通过"
fi
fi
chmod 0755 "$NEW_BIN"
if [ "$PLATFORM" = darwin ]; then
# Apple Silicon 要求可执行文件带签名;没有签名时补 ad-hoc 签名
codesign -v "$NEW_BIN" 2>/dev/null || codesign -s - -f "$NEW_BIN" >/dev/null 2>&1 || true
fi
NEW_VER=$(bin_version "$NEW_BIN")
[ -n "$NEW_VER" ] || die "新二进制无法在本机运行"
if [ "$FORCE" -eq 0 ] && [ "$NEW_VER" = "$CUR" ]; then
say "已是版本 $CUR,无需更新(--force 可强制重装)"
exit 0
fi
say "新版本: $NEW_VER"
# ---------- 2. 备份并原子替换 ----------
cp -p "$BIN_PATH" "$BIN_PATH.bak"
cp "$NEW_BIN" "$BIN_PATH.new"
chmod 0755 "$BIN_PATH.new"
mv -f "$BIN_PATH.new" "$BIN_PATH"
say "已替换 $BIN_PATH(旧版本备份为 $BIN_PATH.bak)"
# ---------- 3. 重启服务(独立进程执行,失败自动回滚) ----------
if [ "$PLATFORM" = linux ] && command -v systemctl >/dev/null 2>&1 && systemctl cat bolt >/dev/null 2>&1; then
RESTART="systemctl restart bolt"
MAINPID="systemctl show -p MainPID --value bolt"
ACTIVE="systemctl is-active --quiet bolt"
elif [ "$PLATFORM" = darwin ] && launchctl print "system/$LABEL" >/dev/null 2>&1; then
RESTART="launchctl kickstart -k system/$LABEL"
MAINPID="launchctl print system/$LABEL | awk '/^[[:space:]]*pid = /{print \$3; exit}'"
ACTIVE="launchctl print system/$LABEL | grep -q 'state = running'"
else
say "未发现已注册的 bolt 服务,只替换了二进制(stdio 模式下由 MCP 客户端重新拉起即可)"
exit 0
fi
# 重启必须由独立进程执行:如果本脚本是通过 bolt 自己运行的,重启 bolt 会连带结束本脚本
WORK=$(mktemp -d /tmp/bolt-update.XXXXXX)
STATUS="$WORK/status"
cat > "$WORK/restart.sh" <<EOF
#!/bin/sh
healthy() {
$ACTIVE || return 1
p1=\$($MAINPID)
sleep 3
$ACTIVE || return 1
p2=\$($MAINPID)
[ -n "\$p1" ] && [ "\$p1" != 0 ] && [ "\$p1" = "\$p2" ]
}
sleep 1
$RESTART
sleep 2
if healthy; then
echo ok > "$STATUS"
else
cp -p "$BIN_PATH.bak" "$BIN_PATH.rollback" && mv -f "$BIN_PATH.rollback" "$BIN_PATH"
$RESTART
echo rolled-back > "$STATUS"
fi
EOF
if [ "$PLATFORM" = linux ] && command -v systemd-run >/dev/null 2>&1; then
# 临时服务不在 bolt 的 cgroup 里,停止 bolt 时不会被一起杀掉
systemd-run --quiet --collect --unit="bolt-update-$$" /bin/sh "$WORK/restart.sh"
else
(trap '' HUP; sh "$WORK/restart.sh" </dev/null >/dev/null 2>&1 &)
fi
say "正在重启服务…"
i=0
while [ $i -lt 30 ]; do
if [ -f "$STATUS" ]; then
break
fi
sleep 1
i=$((i + 1))
done
case "$(cat "$STATUS" 2>/dev/null || echo timeout)" in
ok)
say "更新完成: $CUR -> $(bin_version "$BIN_PATH"),服务运行正常"
rm -rf "$WORK"
;;
rolled-back)
rm -rf "$WORK"
die "新版本服务启动失败,已回滚到 $CUR。查看日志: $([ "$PLATFORM" = linux ] && echo 'journalctl -u bolt -n 50' || echo '/var/log/bolt/stderr.log')"
;;
*)
warn "等待超时,重启结果写在 $STATUS(ok / rolled-back)"
;;
esac
+140 -40
View File
@@ -1,9 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""bolt HTTP 模式端到端测试""" """bolt HTTP 模式端到端测试"""
import http.client, json, subprocess, threading, time import hashlib, http.client, json, os, shutil, subprocess, sys, tempfile, threading, time
from urllib.parse import urlsplit
BOLT = "/root/bolt/target/release/bolt" # 二进制路径:环境变量 BOLT_BIN 优先,默认为仓库内的 release 产物
BOLT = os.environ.get("BOLT_BIN") or os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "target", "release", "bolt")
PORT = 18080 PORT = 18080
PORT2 = 18081 # 带 token + --file-root 的第二个实例
TOKEN = "e2e-secret"
passed = failed = 0 passed = failed = 0
def check(name, cond, detail=""): def check(name, cond, detail=""):
global passed, failed global passed, failed
@@ -14,26 +18,50 @@ def check(name, cond, detail=""):
failed += 1 failed += 1
print(f" FAIL {name} {detail}") print(f" FAIL {name} {detail}")
tmpdir = tempfile.mkdtemp(prefix="bolt-http-e2e-")
proc = subprocess.Popen([BOLT, "--http", f"127.0.0.1:{PORT}"], stderr=subprocess.PIPE) proc = subprocess.Popen([BOLT, "--http", f"127.0.0.1:{PORT}"], stderr=subprocess.PIPE)
proc2 = subprocess.Popen([BOLT, "--http", f"127.0.0.1:{PORT2}", "--token", TOKEN, "--file-root", tmpdir], stderr=subprocess.PIPE)
time.sleep(0.5) time.sleep(0.5)
def post(obj, auth=None, origin=None, accept="application/json", path="/mcp"): def request(method, path, body=None, headers=None, port=PORT, timeout=30):
c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=30) c = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout)
c.request(method, path, body=body, headers=headers or {})
r = c.getresponse()
data = r.read()
hdrs = {k.lower(): v for k, v in r.getheaders()}
c.close()
return r.status, hdrs, data
def post(obj, auth=None, origin=None, accept="application/json", path="/mcp", sid=None, port=PORT):
headers = {"Content-Type": "application/json", "Accept": accept} headers = {"Content-Type": "application/json", "Accept": accept}
if auth: headers["Authorization"] = auth if auth: headers["Authorization"] = auth
if origin: headers["Origin"] = origin if origin: headers["Origin"] = origin
body = json.dumps(obj) if sid: headers["Mcp-Session-Id"] = sid
c.request("POST", path, body=body, headers=headers) st, hdrs, data = request("POST", path, json.dumps(obj), headers, port=port)
r = c.getresponse() return st, hdrs.get("content-type", ""), data
data = r.read()
ct = r.getheader("Content-Type", "")
c.close()
return r.status, ct, data
# 1. initialize(JSON 响应) def post_h(obj, sid=None, accept="application/json", port=PORT, auth=None):
st, ct, data = post({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}}) """返回 (status, headers, data)"""
headers = {"Content-Type": "application/json", "Accept": accept}
if sid: headers["Mcp-Session-Id"] = sid
if auth: headers["Authorization"] = auth
return request("POST", "/mcp", json.dumps(obj), headers, port=port)
def call(name, args, rid=100, sid=None, port=PORT, auth=None):
st, _, data = post_h({"jsonrpc": "2.0", "id": rid, "method": "tools/call", "params": {"name": name, "arguments": args}}, sid=sid, port=port, auth=auth)
return json.loads(data)
def sse_events(data):
return [json.loads(line[len(b"data: "):]) for line in data.split(b"\r\n") if line.startswith(b"data: ")]
INIT = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}}
# 1. initialize(JSON 响应 + 签发 Mcp-Session-Id)
st, hdrs, data = post_h(INIT)
m = json.loads(data) 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]}") sid_a = hdrs.get("mcp-session-id")
check("init json", st == 200 and "application/json" in hdrs.get("content-type", "") and m["result"]["serverInfo"]["name"] == "bolt", f"{st} {data[:100]}")
check("init issues session id", bool(sid_a) and len(sid_a) == 32, str(hdrs))
# 2. SSE 响应(accept 含 text/event-stream) # 2. SSE 响应(accept 含 text/event-stream)
st, ct, data = post({"jsonrpc": "2.0", "id": 2, "method": "ping"}, accept="application/json, text/event-stream") st, ct, data = post({"jsonrpc": "2.0", "id": 2, "method": "ping"}, accept="application/json, text/event-stream")
@@ -51,8 +79,7 @@ m = json.loads(data)
sc = m["result"]["structuredContent"] sc = m["result"]["structuredContent"]
check("run over http", sc["exit_code"] == 5 and sc["stdout"].strip() == "HTTP_OK", str(sc)) check("run over http", sc["exit_code"] == 5 and sc["stdout"].strip() == "HTTP_OK", str(sc))
# 5. GET SSE 流:开流收心跳,并发命令进度通知广播 # 5. GET SSE 流:开流收心跳,同作用域命令的进度通知推送到流上
got_event = threading.Event()
got_heartbeat = threading.Event() got_heartbeat = threading.Event()
msgs = [] msgs = []
def sse_get(): def sse_get():
@@ -71,6 +98,7 @@ def sse_get():
buf = b"" buf = b""
if msgs and (": ping" in msgs[-1]): if msgs and (": ping" in msgs[-1]):
got_heartbeat.set() got_heartbeat.set()
break
c.close() c.close()
t = threading.Thread(target=sse_get) t = threading.Thread(target=sse_get)
t.start() t.start()
@@ -80,20 +108,29 @@ st, ct, data = post({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params"
m = json.loads(data) m = json.loads(data)
check("long cmd done", m["result"]["structuredContent"]["stdout"].count("h-") == 4, str(m)[:100]) 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) 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]) check("sse got progress on GET stream", saw_progress, str(msgs)[:200])
t.join(timeout=25) t.join(timeout=25)
saw_hb = got_heartbeat.is_set() check("sse heartbeat", got_heartbeat.is_set(), str(msgs)[:200])
check("sse heartbeat", saw_hb, str(msgs)[:200])
# 6. 取消:连接 A 长命令,连接 B 发 cancelled # 5b. POST + SSE:进度通知与结果在同一个响应流里
st, ct, data = post({"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "echo nothing"}}}) # 预热(连接池无关,每次新连接) st, hdrs, data = post_h({"jsonrpc": "2.0", "id": 41, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "for x in 1 2 3; do echo p-$x; sleep 0.3; done"}, "_meta": {"progressToken": "p1"}}}, accept="application/json, text/event-stream", sid=sid_a)
evs = sse_events(data)
prog = [e for e in evs if e.get("method") == "notifications/progress" and e["params"]["progressToken"] == "p1"]
final = [e for e in evs if e.get("id") == 41]
check("post sse progress + result", st == 200 and prog and final and final[0]["result"]["structuredContent"]["stdout"].count("p-") == 3, str(evs)[:300])
check("post sse result is last", evs and evs[-1].get("id") == 41, str(evs)[-200:])
# 6. 取消:连接 A 长命令,连接 B 发 cancelled(同一作用域)
def long_cmd(): def long_cmd():
return post({"jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "sleep 30"}}}) return post({"jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "sleep 30"}}})
res = {} res = {}
t2 = threading.Thread(target=lambda: res.update(r=long_cmd())) t2 = threading.Thread(target=lambda: res.update(r=long_cmd()))
t2.start() t2.start()
time.sleep(0.5) time.sleep(0.5)
# 其他会话发同 id 的取消不能误杀
st, _, _ = post({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 6}}, sid=sid_a)
time.sleep(0.3)
check("cancel from other scope ignored", t2.is_alive())
st, ct, data = post({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 6}}) st, ct, data = post({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 6}})
check("cancel notif 202", st == 202) check("cancel notif 202", st == 202)
t2.join(timeout=10) t2.join(timeout=10)
@@ -112,24 +149,16 @@ check("http concurrent", json.loads(results[10][2])["result"]["structuredContent
# 8. 错误路径 # 8. 错误路径
st, _, _ = post({"jsonrpc": "2.0", "id": 99, "method": "nope"}) st, _, _ = post({"jsonrpc": "2.0", "id": 99, "method": "nope"})
check("unknown method", st == 200) check("unknown method", st == 200)
c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=5) st, _, _ = request("POST", "/mcp", b"this is {not json", {"Content-Type": "application/json", "Accept": "application/json"})
c.request("POST", "/mcp", body=b"this is {not json", headers={"Content-Type": "application/json", "Accept": "application/json"}) check("bad json -> 400", st == 400, str(st))
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": {}}}) 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) check("unknown tool -32602", json.loads(data)["error"]["code"] == -32602)
c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=5) st, _, _ = request("GET", "/other", headers={"Accept": "text/event-stream"})
c.request("GET", "/other", headers={"Accept": "text/event-stream"}) check("404 other path", st == 404)
r = c.getresponse(); r.read(); c.close() st, _, _ = request("PUT", "/mcp")
check("404 other path", r.status == 404) check("405 method", st == 405)
c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=5) st, hdrs, _ = request("OPTIONS", "/mcp")
c.request("PUT", "/mcp") check("options cors", st == 204 and hdrs.get("access-control-allow-origin") == "*" and "Mcp-Session-Id" in hdrs.get("access-control-allow-headers", ""))
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 校验 # 9. origin 校验
st, ct, data = post({"jsonrpc": "2.0", "id": 50, "method": "ping"}, origin="https://evil.example.com") st, ct, data = post({"jsonrpc": "2.0", "id": 50, "method": "ping"}, origin="https://evil.example.com")
@@ -144,10 +173,81 @@ r = c.getresponse()
c.close() c.close()
check("get sse default", r.status == 200 and "text/event-stream" in r.getheader("Content-Type")) check("get sse default", r.status == 200 and "text/event-stream" in r.getheader("Content-Type"))
# 11. MCP 会话:隔离、未知 ID → 404、DELETE 终止
st, hdrs, _ = post_h(INIT)
sid_b = hdrs.get("mcp-session-id")
r = call("shell_open", {}, sid=sid_a)
shell_id = r["result"]["structuredContent"]["session_id"]
r = call("shell_read", {"session_id": shell_id, "timeout_ms": 10}, sid=sid_b)
check("shell session isolated", r["result"].get("isError") is True, str(r))
r = call("shell_read", {"session_id": shell_id, "timeout_ms": 10}, sid=sid_a)
check("shell session owner ok", r["result"].get("isError") is None, str(r))
st, _, _ = post_h({"jsonrpc": "2.0", "id": 60, "method": "ping"}, sid="0" * 32)
check("unknown session 404", st == 404, str(st))
st, _, _ = request("DELETE", "/mcp", headers={"Mcp-Session-Id": sid_a})
check("delete session", st == 200, str(st))
st, _, _ = post_h({"jsonrpc": "2.0", "id": 61, "method": "ping"}, sid=sid_a)
check("deleted session 404", st == 404, str(st))
r = call("shell_read", {"session_id": shell_id, "timeout_ms": 10}, sid=sid_b)
check("deleted session shells closed", r["result"].get("isError") is True)
# 12. HTTP 客户端不能用 notifications/exit 关停服务
post({"jsonrpc": "2.0", "method": "notifications/exit"})
time.sleep(0.4)
st, _, _ = post({"jsonrpc": "2.0", "id": 70, "method": "ping"})
check("exit notif ignored over http", st == 200 and proc.poll() is None)
# 13. tools/list 含 file_link
st, _, data = post({"jsonrpc": "2.0", "id": 71, "method": "tools/list"})
names = [t["name"] for t in json.loads(data)["result"]["tools"]]
check("tools/list http", names[-4:] == ["file_read", "file_write", "file_stat", "file_link"], str(names))
# 14. /files:Bearer 认证、签名链接、Range、chunked 上传、目录限制
AUTH = {"Authorization": f"Bearer {TOKEN}"}
fpath = os.path.join(tmpdir, "data.bin")
payload = os.urandom(300_000)
# 未认证请求在读取 body 前即返回 401 并关闭连接,这里用小 body 避免客户端写入时 broken pipe
st, _, _ = request("PUT", f"/files?path={fpath}", b"x", port=PORT2)
check("files put needs token", st == 401 and not os.path.exists(fpath), str(st))
st, _, data = request("PUT", f"/files?path={fpath}", payload, AUTH, port=PORT2)
info = json.loads(data)
check("files put", st == 201 and info["sha256"] == hashlib.sha256(payload).hexdigest(), f"{st} {data[:200]}")
st, hdrs, data = request("GET", f"/files?path={fpath}", headers=AUTH, port=PORT2)
check("files get", st == 200 and data == payload and hdrs.get("accept-ranges") == "bytes")
st, hdrs, data = request("GET", f"/files?path={fpath}", headers={**AUTH, "Range": "bytes=100-199"}, port=PORT2)
check("files range", st == 206 and data == payload[100:200] and hdrs.get("content-range") == f"bytes 100-199/{len(payload)}", f"{st} {hdrs}")
st, _, _ = request("GET", "/files?path=/etc/hosts", headers=AUTH, port=PORT2)
check("files outside root 403", st == 403, str(st))
# chunked 上传(http.client 对迭代器 body 使用 chunked 编码)
cpath = os.path.join(tmpdir, "chunked.txt")
c = http.client.HTTPConnection("127.0.0.1", PORT2, timeout=10)
c.request("PUT", f"/files?path={cpath}&mkdirs=1", body=iter([b"hello ", b"chunked ", b"world"]), headers=AUTH, encode_chunked=True)
r = c.getresponse(); r.read(); c.close()
check("files chunked put", r.status == 201 and open(cpath, "rb").read() == b"hello chunked world", str(r.status))
# 签名链接:不带 token 也能访问,且仅限签名的路径/方法
st, _, data = post_h(INIT, port=PORT2, auth=AUTH["Authorization"])
r = call("file_link", {"path": fpath}, port=PORT2, auth=AUTH["Authorization"])
url = r["result"]["structuredContent"]["url"]
u = urlsplit(url)
check("file_link url", u.netloc == f"127.0.0.1:{PORT2}" and u.path == "/files" and "curl" in r["result"]["structuredContent"]["curl"], url)
st, _, data = request("GET", f"{u.path}?{u.query}", port=PORT2)
check("signed get", st == 200 and data == payload, str(st))
st, _, _ = request("PUT", f"{u.path}?{u.query}", b"x", port=PORT2)
check("signed get cannot put", st == 403, str(st))
r = call("file_link", {"path": os.path.join(tmpdir, "up", "new.txt"), "method": "PUT", "mkdirs": True}, port=PORT2, auth=AUTH["Authorization"])
u = urlsplit(r["result"]["structuredContent"]["url"])
st, _, data = request("PUT", f"{u.path}?{u.query}", b"uploaded via link", {"Expect": "100-continue"}, port=PORT2)
check("signed put", st == 201 and open(os.path.join(tmpdir, "up", "new.txt"), "rb").read() == b"uploaded via link", f"{st} {data[:200]}")
st, _, _ = request("GET", f"{u.path}?{u.query.replace('sig=', 'sig=0')}", port=PORT2)
check("bad signature 403", st == 403, str(st))
r = call("file_link", {"path": "/etc/hosts"}, port=PORT2, auth=AUTH["Authorization"])
check("file_link outside root", r["result"].get("isError") is True)
proc.kill() proc.kill()
proc2.kill()
err = proc.stderr.read().decode() err = proc.stderr.read().decode()
shutil.rmtree(tmpdir, ignore_errors=True)
print(f"\n=== HTTP: {passed} passed, {failed} failed ===") print(f"\n=== HTTP: {passed} passed, {failed} failed ===")
if err.strip(): if failed and err.strip():
print("bolt stderr:", err[:400]) print("bolt stderr:", err[:400])
sys_exit = 1 if failed else 0 sys.exit(1 if failed else 0)
import sys; sys.exit(sys_exit)
+52 -8
View File
@@ -3,8 +3,9 @@
import faulthandler, json, os, subprocess, sys, threading, time import faulthandler, json, os, subprocess, sys, threading, time
BOLT = "/root/bolt/target/release/bolt" # 二进制路径:环境变量 BOLT_BIN 优先,默认为仓库内的 release 产物
proc = subprocess.Popen([BOLT], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0) BOLT = os.environ.get("BOLT_BIN") or os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "target", "release", "bolt")
proc =subprocess.Popen([BOLT], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0)
sent = {} sent = {}
next_id = [0] next_id = [0]
@@ -79,7 +80,7 @@ send_notif("notifications/initialized")
i = send("tools/list") i = send("tools/list")
r = wait_id(i) r = wait_id(i)
names = [t["name"] for t in r["result"]["tools"]] 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)) check("tools/list", names == ["run_command", "shell_open", "shell_write", "shell_read", "shell_close", "file_read", "file_write", "file_stat"], str(names))
# 3. run_command 基本 # 3. run_command 基本
i = send("tools/call", {"name": "run_command", "arguments": {"command": "echo hello; printf 'oops' >&2; exit 7"}}) i = send("tools/call", {"name": "run_command", "arguments": {"command": "echo hello; printf 'oops' >&2; exit 7"}})
@@ -138,25 +139,68 @@ i2 = send("tools/call", {"name": "run_command", "arguments": {"command": "sleep
r1, r2 = wait_id(i1), wait_id(i2) r1, r2 = wait_id(i1), wait_id(i2)
check("concurrent", r1["result"]["structuredContent"]["stdout"].strip() == "ONE" and r2["result"]["structuredContent"]["stdout"].strip() == "TWO") check("concurrent", r1["result"]["structuredContent"]["stdout"].strip() == "ONE" and r2["result"]["structuredContent"]["stdout"].strip() == "TWO")
# 9. shell 会话 # 9. shell 会话(结果为标准 MCP 格式:content + structuredContent)
i = send("tools/call", {"name": "shell_open", "arguments": {}}) i = send("tools/call", {"name": "shell_open", "arguments": {}})
r = wait_id(i) r = wait_id(i)
sid = r["result"]["session_id"] sid = r["result"]["structuredContent"]["session_id"]
check("shell open", isinstance(sid, int)) check("shell open", isinstance(sid, int) and isinstance(r["result"]["content"], list), str(r))
send("tools/call", {"name": "shell_write", "arguments": {"session_id": sid, "input": "cd /tmp && echo IN_TMP; echo DONE_$$; exit"}}) send("tools/call", {"name": "shell_write", "arguments": {"session_id": sid, "input": "cd /tmp && echo IN_TMP; echo DONE_$$; exit"}})
# 输出先于退出标志到达,循环读取直到会话退出 # 输出先于退出标志到达,循环读取直到会话退出
all_out = "" all_out = ""
for _ in range(10): for _ in range(10):
i = send("tools/call", {"name": "shell_read", "arguments": {"session_id": sid, "timeout_ms": 3000}}) i = send("tools/call", {"name": "shell_read", "arguments": {"session_id": sid, "timeout_ms": 3000}})
r = wait_id(i) r = wait_id(i)
res = r["result"] res = r["result"]["structuredContent"]
all_out += res["output"] all_out += res["output"]
if res["exited"]: if res["exited"]:
break break
check("shell read output", "IN_TMP" in all_out and res["exited"], f"out={all_out!r}") 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}}) i = send("tools/call", {"name": "shell_close", "arguments": {"session_id": sid}})
r = wait_id(i) r = wait_id(i)
check("shell close", r["result"]["closed"] is True) check("shell close", r["result"]["structuredContent"]["closed"] is True)
# 9b. shell_read 阻塞等待时可被取消
i = send("tools/call", {"name": "shell_open", "arguments": {}})
sid = wait_id(i)["result"]["structuredContent"]["session_id"]
i = send("tools/call", {"name": "shell_read", "arguments": {"session_id": sid, "timeout_ms": 0}})
time.sleep(0.3)
send_notif("notifications/cancelled", {"requestId": i})
r = wait_id(i, timeout=5)
check("shell_read cancel", r["result"]["structuredContent"]["cancelled"] is True, str(r))
wait_id(send("tools/call", {"name": "shell_close", "arguments": {"session_id": sid}}))
# 9c. 后台进程占用输出管道时不阻塞
t0 = time.time()
i = send("tools/call", {"name": "run_command", "arguments": {"command": "(sleep 5; echo late) & echo now"}})
r = wait_id(i)
sc = r["result"]["structuredContent"]
check("background detach", sc["stdout"] == "now\n" and sc["detached"] and time.time() - t0 < 3, f"{sc} dt={time.time() - t0:.1f}")
# 9d. 文件读写(文本 / base64 / 分块)
import base64, hashlib, tempfile
tmpdir = tempfile.mkdtemp(prefix="bolt-e2e-")
fp = os.path.join(tmpdir, "sub", "hello.txt")
i = send("tools/call", {"name": "file_write", "arguments": {"path": fp, "content": "你好, bolt\n", "mkdirs": True}})
sc = wait_id(i)["result"]["structuredContent"]
check("file_write text", sc["sha256"] == hashlib.sha256("你好, bolt\n".encode()).hexdigest(), str(sc))
i = send("tools/call", {"name": "file_read", "arguments": {"path": fp, "length": 4}})
content = wait_id(i)["result"]["content"]
meta = json.loads(content[0]["text"])
check("file_read chunk utf8", content[1]["text"] == "你" and meta["next_offset"] == 3 and not meta["eof"], str(content))
blob = bytes(range(256))
bp = os.path.join(tmpdir, "blob.bin")
i = send("tools/call", {"name": "file_write", "arguments": {"path": bp, "content": base64.b64encode(blob).decode(), "encoding": "base64"}})
wait_id(i)
i = send("tools/call", {"name": "file_read", "arguments": {"path": bp}})
content = wait_id(i)["result"]["content"]
check("file_read binary", json.loads(content[0]["text"])["encoding"] == "base64" and base64.b64decode(content[1]["text"]) == blob)
i = send("tools/call", {"name": "file_stat", "arguments": {"path": bp, "sha256": True}})
sc = wait_id(i)["result"]["structuredContent"]
check("file_stat sha256", sc["size"] == 256 and sc["sha256"] == hashlib.sha256(blob).hexdigest(), str(sc))
i = send("tools/call", {"name": "file_link", "arguments": {"path": bp}})
r = wait_id(i)
check("file_link stdio unavailable", "error" in r and r["error"]["code"] == -32602 or r["result"].get("isError") is True, str(r))
import shutil; shutil.rmtree(tmpdir, ignore_errors=True)
# 10. stdio 关闭 → 进程退出 # 10. stdio 关闭 → 进程退出
send_notif("notifications/exit") send_notif("notifications/exit")
+2685 -600
View File
File diff suppressed because it is too large Load Diff