Files
bolt/src/main.rs
T
jocay 29b7dc3bbb 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 项)
2026-08-27 14:19:10 +08:00

1907 lines
69 KiB
Rust

//! bolt — 极速单文件 MCP 命令执行服务器
//!
//! AI(Claude Desktop / Claude Code / Cursor 等 MCP 客户端)连接后即可在服务器上执行命令。
//!
//! 特性:
//! * 零框架、纯手写 JSON-RPC 2.0 + MCP 协议(仅依赖 serde_json,Unix 下加 libc)
//! * 双传输:stdio(本地 AI 客户端默认方式)与 Streamable HTTP(远程连接)
//! * 工具:
//! - run_command 执行 shell 命令(支持管道/重定向/超时/环境变量/输出截断/进度通知)
//! - shell_open 开启持久化交互式 shell 会话(状态保留,可连续对话)
//! - shell_write 向会话写入输入
//! - shell_read 读取会话输出(可阻塞等待)
//! - shell_close 关闭会话
//! * 取消:客户端发送 notifications/cancelled 可杀掉进行中的命令(Unix 下按进程组杀,连带子进程)
//! * 安全:命令 allow/deny 前缀白黑名单、HTTP Bearer Token、Origin 校验、默认本机绑定
//! * 性能:单线程事件循环 + 复用缓冲区、原子计数、无锁热路径、LTO fat + codegen-units=1
//!
//! 用法:
//! bolt # stdio 模式(默认,MCP 客户端拉起本进程)
//! bolt --http 127.0.0.1:8080 # Streamable HTTP 模式,远程 AI 客户端连接
//! bolt --http 0.0.0.0:8080 --token mysecret --allow "git,docker,curl"
//!
//! 环境变量(CLI 参数优先):BOLT_TOKEN / BOLT_ALLOW / BOLT_DENY / BOLT_TIMEOUT_MS /
//! BOLT_CWD / BOLT_MAX_OUTPUT / BOLT_ALLOW_ORIGIN
//!
//! 客户端配置示例:
//! Claude Code(本地): claude mcp add --transport stdio bolt -- /abs/path/bolt
//! Claude Code(远程): claude mcp add --transport http bolt http://host:8080/mcp
//! 通用 MCP 客户端: {"mcpServers": {"bolt": {"command": "/abs/path/bolt"}}}
use std::collections::HashMap;
use std::env;
use std::io::{self, BufRead, BufReader, BufWriter, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::Path;
use std::process::{Command, ExitStatus, Stdio};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{mpsc, Arc, Condvar, Mutex, OnceLock};
use std::thread;
use std::time::{Duration, Instant};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use serde_json::{json, Value};
// ---------------------------------------------------------------------------
// 常量
// ---------------------------------------------------------------------------
const VERSION: &str = env!("CARGO_PKG_VERSION");
const PROTOCOL_VERSIONS: [&str; 3] = ["2024-11-05", "2025-03-26", "2025-06-18"];
const LATEST_PROTOCOL: &str = "2025-06-18";
const MAX_LINE_BYTES: usize = 16 * 1024 * 1024; // stdio 单条消息上限
const MAX_HTTP_HEAD: usize = 64 * 1024; // HTTP 请求头上限
const MAX_HTTP_BODY: usize = 4 * 1024 * 1024; // HTTP body 上限
const DEFAULT_TIMEOUT_MS: u64 = 30_000; // 命令默认超时
const MAX_TIMEOUT_MS: u64 = 6 * 3600 * 1000; // 命令超时上限(6h)
const DEFAULT_MAX_OUTPUT: usize = 1024 * 1024; // 单流默认捕获上限
const MAX_MAX_OUTPUT: usize = 64 * 1024 * 1024;
const SESSION_BUF_CAP: usize = 16 * 1024 * 1024; // 会话环形缓冲上限
const SESSION_WRITE_TIMEOUT: Duration = Duration::from_secs(10); // 会话写入超时
const SSE_HEARTBEAT: Duration = Duration::from_secs(10); // SSE 保活间隔
// ---------------------------------------------------------------------------
// 配置
// ---------------------------------------------------------------------------
enum Mode {
Stdio,
Http(String),
}
struct Config {
mode: Mode,
token: Option<String>,
allow: Vec<String>,
deny: Vec<String>,
timeout_ms: u64,
cwd: Option<String>,
max_output: usize,
allow_origins: Vec<String>,
}
fn split_list(s: &str) -> Vec<String> {
s.split(',')
.map(str::trim)
.filter(|x| !x.is_empty())
.map(str::to_string)
.collect()
}
/// 解析 "1024" / "64K" / "1M" / "2G" 形式的字节数
fn parse_size(s: &str) -> Option<usize> {
let s = s.trim();
let (num, mult) = match s.chars().last() {
Some('k') | Some('K') => (&s[..s.len() - 1], 1 << 10),
Some('m') | Some('M') => (&s[..s.len() - 1], 1 << 20),
Some('g') | Some('G') => (&s[..s.len() - 1], 1 << 30),
_ => (s, 1),
};
num.parse::<usize>().ok().map(|n| n.saturating_mul(mult))
}
fn parse_args() -> Option<Config> {
let mut mode = Mode::Stdio;
let mut token: Option<String> = None;
let mut allow: Vec<String> = Vec::new();
let mut deny: Vec<String> = Vec::new();
let mut timeout_ms = DEFAULT_TIMEOUT_MS;
let mut cwd: Option<String> = None;
let mut max_output = DEFAULT_MAX_OUTPUT;
let mut allow_origins: Vec<String> = Vec::new();
let args: Vec<String> = env::args().skip(1).collect();
let mut i = 0;
while i < args.len() {
let a = args[i].as_str();
let next = |i: &mut usize| -> Option<String> {
*i += 1;
args.get(*i).cloned()
};
match a {
"-h" | "--help" => return None,
"--http" => mode = Mode::Http(next(&mut i)?),
"--token" => token = next(&mut i),
"--allow" => allow = split_list(&next(&mut i)?),
"--deny" => deny = split_list(&next(&mut i)?),
"--timeout-ms" => timeout_ms = next(&mut i)?.parse().ok()?,
"--cwd" => cwd = next(&mut i),
"--max-output" => max_output = parse_size(&next(&mut i)?)?,
"--allow-origin" => allow_origins.push(next(&mut i)?),
_ => {
eprintln!("[bolt] 未知参数: {a}");
return None;
}
}
i += 1;
}
// 环境变量回退(CLI 优先)
if token.is_none() {
token = env::var("BOLT_TOKEN").ok().filter(|s| !s.is_empty());
}
if allow.is_empty() {
allow = split_list(&env::var("BOLT_ALLOW").unwrap_or_default());
}
if deny.is_empty() {
deny = split_list(&env::var("BOLT_DENY").unwrap_or_default());
}
if let Ok(v) = env::var("BOLT_TIMEOUT_MS") {
if let Ok(n) = v.parse() {
timeout_ms = n;
}
}
if cwd.is_none() {
cwd = env::var("BOLT_CWD").ok().filter(|s| !s.is_empty());
}
if let Ok(v) = env::var("BOLT_MAX_OUTPUT") {
if let Some(n) = parse_size(&v) {
max_output = n;
}
}
if allow_origins.is_empty() {
allow_origins = split_list(&env::var("BOLT_ALLOW_ORIGIN").unwrap_or_default());
}
Some(Config {
mode,
token,
allow,
deny,
timeout_ms,
cwd,
max_output,
allow_origins,
})
}
fn print_help() {
println!(
r#"bolt {VERSION} — 极速单文件 MCP 命令执行服务器
AI 通过 MCP(stdio 或 Streamable HTTP)连接后即可在服务器上执行命令。
用法:
bolt [选项]
选项:
--http <addr> 启用 Streamable HTTP 传输并监听(如 127.0.0.1:8080 或 :8080)
缺省为 stdio 模式(由 MCP 客户端拉起本进程)
--token <token> HTTP 模式要求 Authorization: Bearer <token>
--allow <a,b,...> 命令白名单(命令首词前缀匹配,非空时仅允许列出的命令)
--deny <a,b,...> 命令黑名单(前缀匹配)
--timeout-ms <n> 命令默认超时毫秒(0 = 无限,工具参数可覆盖;默认 30000)
--cwd <dir> 命令默认工作目录
--max-output <n> 单流输出捕获上限,支持 K/M/G 后缀(默认 1M)
--allow-origin <url> 额外允许的 HTTP Origin(默认仅 localhost/127.0.0.1)
-h, --help 显示帮助
环境变量(CLI 优先):BOLT_TOKEN BOLT_ALLOW BOLT_DENY BOLT_TIMEOUT_MS BOLT_CWD BOLT_MAX_OUTPUT BOLT_ALLOW_ORIGIN
客户端配置:
Claude Code 本地: claude mcp add --transport stdio bolt -- /abs/path/bolt
Claude Code 远程: claude mcp add --transport http bolt http://host:8080/mcp
通用 MCP 客户端: {{"mcpServers": {{"bolt": {{"command": "/abs/path/bolt"}}}}}}
"#
);
}
// ---------------------------------------------------------------------------
// 出站通道:stdio 单写者锁 / HTTP 广播到 SSE 流
// ---------------------------------------------------------------------------
struct Hub {
map: Mutex<HashMap<u64, mpsc::Sender<String>>>,
next: AtomicU64,
}
impl Hub {
fn add(&self, tx: mpsc::Sender<String>) -> u64 {
let id = self.next.fetch_add(1, Ordering::Relaxed) + 1;
self.map.lock().unwrap().insert(id, tx);
id
}
fn remove(&self, id: u64) {
self.map.lock().unwrap().remove(&id);
}
/// 广播(发送失败即剔除死连接;mpsc 无界通道,不阻塞)
fn broadcast(&self, msg: &str) {
let mut map = self.map.lock().unwrap();
map.retain(|_, tx| tx.send(msg.to_string()).is_ok());
}
}
#[derive(Clone)]
enum Sink {
Stdio(Arc<Mutex<io::Stdout>>),
Http(Arc<Hub>),
}
impl Sink {
#[inline]
fn send(&self, msg: &str) {
match self {
Sink::Stdio(o) => {
if let Ok(mut g) = o.lock() {
let _ = g.write_all(msg.as_bytes());
let _ = g.write_all(b"\n");
let _ = g.flush();
}
}
Sink::Http(h) => h.broadcast(msg),
}
}
}
// ---------------------------------------------------------------------------
// 应用状态
// ---------------------------------------------------------------------------
struct App {
cfg: Config,
sink: Sink,
hub: Arc<Hub>,
exit_flag: Arc<AtomicBool>,
inflight: Mutex<HashMap<String, Arc<Killable>>>, // 进行中的命令(id -> 可杀句柄)
sessions: Mutex<HashMap<u64, Arc<Session>>>,
next_session: AtomicU64,
}
type Shared = Arc<App>;
impl App {
fn new(cfg: Config) -> App {
let hub = Arc::new(Hub {
map: Mutex::new(HashMap::new()),
next: AtomicU64::new(0),
});
let sink = match &cfg.mode {
Mode::Stdio => Sink::Stdio(Arc::new(Mutex::new(io::stdout()))),
Mode::Http(_) => Sink::Http(hub.clone()),
};
App {
cfg,
sink,
hub,
exit_flag: Arc::new(AtomicBool::new(false)),
inflight: Mutex::new(HashMap::new()),
sessions: Mutex::new(HashMap::new()),
next_session: AtomicU64::new(0),
}
}
}
/// 进程组杀掉 pid 对应的整棵进程树
#[cfg(unix)]
fn kill_tree(pid: u32) {
unsafe {
libc::kill(-(pid as i32), libc::SIGKILL);
}
}
#[cfg(windows)]
fn kill_tree(pid: u32) {
let _ = Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/T", "/F"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
/// 可取消句柄:注册 pid 前收到取消则标记,注册后立即补杀
struct Killable {
pid: AtomicU32,
want_kill: AtomicBool,
cancelled: AtomicBool,
}
impl Default for Killable {
fn default() -> Self {
Killable {
pid: AtomicU32::new(0),
want_kill: AtomicBool::new(false),
cancelled: AtomicBool::new(false),
}
}
}
impl Killable {
fn register_pid(&self, pid: u32) {
self.pid.store(pid, Ordering::SeqCst);
if self.want_kill.load(Ordering::SeqCst) {
kill_tree(pid);
}
}
fn kill(&self, mark_cancelled: bool) {
if mark_cancelled {
self.cancelled.store(true, Ordering::SeqCst);
}
self.want_kill.store(true, Ordering::SeqCst);
let p = self.pid.load(Ordering::SeqCst);
if p != 0 {
kill_tree(p);
}
}
}
fn id_key(id: &Value) -> String {
serde_json::to_string(id).unwrap_or_default()
}
fn register_inflight(app: &Shared, id: &Value) -> Arc<Killable> {
let k = Arc::new(Killable::default());
app.inflight.lock().unwrap().insert(id_key(id), k.clone());
k
}
fn unregister_inflight(app: &Shared, id: &Value, k: &Arc<Killable>) {
let mut m = app.inflight.lock().unwrap();
if m.get(&id_key(id)).map(|x| Arc::ptr_eq(x, k)).unwrap_or(false) {
m.remove(&id_key(id));
}
}
// ---------------------------------------------------------------------------
// JSON-RPC / MCP 辅助
// ---------------------------------------------------------------------------
fn rpc_error(id: &Value, code: i64, message: &str) -> Value {
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
}
const PARSE_ERROR: &str =
r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"parse error"}}"#;
/// 工具执行错误(非协议错误):结果中 isError: true
fn tool_error(msg: &str) -> Value {
json!({"content": [{"type": "text", "text": format!("error: {msg}")}], "isError": true})
}
/// 命令白/黑名单检查(first_token 为命令首词)
fn policy_check(cfg: &Config, first_token: &str) -> Result<(), String> {
if !cfg.deny.is_empty() && cfg.deny.iter().any(|d| first_token.starts_with(d)) {
return Err(format!("命令 '{first_token}' 被服务器策略禁止"));
}
if !cfg.allow.is_empty() && !cfg.allow.iter().any(|a| first_token.starts_with(a)) {
return Err(format!("命令 '{first_token}' 不在服务器白名单中"));
}
Ok(())
}
// ---------------------------------------------------------------------------
// 工具:run_command 执行引擎
// ---------------------------------------------------------------------------
struct RunReq {
command: String,
args: Vec<String>,
shell: bool,
cwd: Option<String>,
env: Vec<(String, String)>,
timeout_ms: u64, // 0 = 无限
max_output: usize,
}
fn parse_run_params(p: &Value, cfg: &Config) -> Result<RunReq, String> {
let command = p
.get("command")
.and_then(Value::as_str)
.ok_or("'command' 必须是非空字符串")?
.to_string();
let shell = p.get("shell").and_then(Value::as_bool).unwrap_or(true);
let args: Vec<String> = match p.get("args") {
None => Vec::new(),
Some(Value::Array(a)) => a
.iter()
.map(|x| {
x.as_str()
.map(str::to_string)
.ok_or_else(|| "'args' 必须是字符串数组".to_string())
})
.collect::<Result<_, _>>()?,
Some(_) => return Err("'args' 必须是字符串数组".into()),
};
if shell && !args.is_empty() {
return Err("shell=true 时不能同时使用 'args'(shell 语法直接写在 command 里)".into());
}
let cwd = match p.get("cwd") {
None => cfg.cwd.clone(),
Some(v) => Some(v.as_str().ok_or("'cwd' 必须是字符串")?.to_string()),
};
let mut env_map: Vec<(String, String)> = Vec::new();
if let Some(e) = p.get("env") {
let obj = e.as_object().ok_or("'env' 必须是对象")?;
for (k, v) in obj {
let s = v.as_str().ok_or_else(|| format!("env 的值 '{k}' 必须是字符串"))?;
env_map.push((k.clone(), s.to_string()));
}
}
let timeout_ms = match p.get("timeout_ms") {
None => cfg.timeout_ms,
Some(v) => {
let n = v.as_u64().ok_or("'timeout_ms' 必须是整数")?;
if n > MAX_TIMEOUT_MS {
return Err(format!("'timeout_ms' 不能超过 {MAX_TIMEOUT_MS}"));
}
n
}
};
let max_output = match p.get("max_output_bytes") {
None => cfg.max_output,
Some(v) => {
let n = v.as_u64().ok_or("'max_output_bytes' 必须是整数")? as usize;
if !(1024..=MAX_MAX_OUTPUT).contains(&n) {
return Err(format!("'max_output_bytes' 必须在 1024..{MAX_MAX_OUTPUT}"));
}
n
}
};
Ok(RunReq {
command,
args,
shell,
cwd,
env: env_map,
timeout_ms,
max_output,
})
}
struct ExecOutcome {
exit_code: Option<i32>,
signal: Option<i32>,
timed_out: bool,
cancelled: bool,
duration_ms: u64,
stdout: String,
stderr: String,
trunc_out: bool,
trunc_err: bool,
}
struct OutBuf {
bytes: Vec<u8>,
truncated: bool,
}
impl Default for OutBuf {
fn default() -> Self {
OutBuf {
bytes: Vec::new(),
truncated: false,
}
}
}
struct OutShared {
stdout: Arc<Mutex<OutBuf>>,
stderr: Arc<Mutex<OutBuf>>,
total: Arc<AtomicU64>,
}
/// 管道读取循环:读取到 cap 后继续排空(防止子进程写满管道阻塞),只标记截断
fn pipe_read_loop<R: Read + Send + 'static>(
mut pipe: R,
out: Arc<Mutex<OutBuf>>,
total: Arc<AtomicU64>,
cap: usize,
) {
let mut chunk = [0u8; 16 * 1024];
loop {
match pipe.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
total.fetch_add(n as u64, Ordering::Relaxed);
let mut b = out.lock().unwrap();
if b.bytes.len() < cap {
let take = (cap - b.bytes.len()).min(n);
b.bytes.extend_from_slice(&chunk[..take]);
if take < n {
b.truncated = true;
}
} else {
b.truncated = true;
}
}
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
}
/// 进度通知循环:每 200ms 上报已读字节数
fn progress_loop(token: Value, total: Arc<AtomicU64>, done: Arc<AtomicBool>, sink: Sink) {
let mut last = 0u64;
while !done.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(200));
let t = total.load(Ordering::Relaxed);
if t != last {
last = t;
let msg = json!({
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {
"progressToken": token,
"progress": t,
"message": format!("已读取 {t} 字节输出"),
}
});
sink.send(&msg.to_string());
}
}
}
fn exec(req: &RunReq, killable: &Killable, progress: Option<Value>, app: &Shared) -> Result<ExecOutcome, String> {
// 构建命令
let mut cmd = if req.shell {
#[cfg(windows)]
let mut c = Command::new("cmd");
#[cfg(windows)]
c.arg("/C").arg(&req.command);
#[cfg(unix)]
let mut c = Command::new("sh");
#[cfg(unix)]
c.arg("-c").arg(&req.command);
c
} else {
let mut c = Command::new(&req.command);
c.args(&req.args);
c
};
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(cwd) = &req.cwd {
if !Path::new(cwd).is_dir() {
return Err(format!("工作目录不存在: {cwd}"));
}
cmd.current_dir(cwd);
}
cmd.envs(req.env.iter().map(|(k, v)| (k.as_str(), v.as_str())));
#[cfg(unix)]
unsafe {
// 子进程放入独立进程组,便于整树杀除
cmd.pre_exec(|| {
libc::setpgid(0, 0);
Ok(())
});
}
let start = Instant::now();
let mut child = cmd
.spawn()
.map_err(|e| format!("启动命令失败: {e}"))?;
killable.register_pid(child.id());
let out = Arc::new(OutShared {
stdout: Arc::new(Mutex::new(OutBuf::default())),
stderr: Arc::new(Mutex::new(OutBuf::default())),
total: Arc::new(AtomicU64::new(0)),
});
let done = Arc::new(AtomicBool::new(false));
let stdout_pipe = child.stdout.take().unwrap();
let stderr_pipe = child.stderr.take().unwrap();
let t_out = {
let buf = out.stdout.clone();
let total = out.total.clone();
let cap = req.max_output;
thread::Builder::new()
.name("bolt-out".into())
.spawn(move || pipe_read_loop(stdout_pipe, buf, total, cap))
.unwrap()
};
let t_err = {
let buf = out.stderr.clone();
let total = out.total.clone();
let cap = req.max_output;
thread::Builder::new()
.name("bolt-err".into())
.spawn(move || pipe_read_loop(stderr_pipe, buf, total, cap))
.unwrap()
};
let t_prog = progress.map(|tok| {
let total = out.total.clone();
let done = done.clone();
let sink = app.sink.clone();
thread::Builder::new()
.name("bolt-progress".into())
.spawn(move || progress_loop(tok, total, done, sink))
.unwrap()
});
// 收割线程独占 child.wait(),避免僵尸进程
let (tx, rx) = mpsc::channel();
let mut child2 = child;
let reaper = thread::Builder::new()
.name("bolt-reaper".into())
.spawn(move || {
let st = child2.wait();
let _ = tx.send(st);
})
.unwrap();
let mut timed_out = false;
let status: Option<ExitStatus> = if req.timeout_ms == 0 {
rx.recv().ok().and_then(Result::ok)
} else {
match rx.recv_timeout(Duration::from_millis(req.timeout_ms)) {
Ok(Ok(st)) => Some(st),
Ok(Err(_)) => None,
Err(mpsc::RecvTimeoutError::Timeout) => {
timed_out = true;
killable.kill(false); // 超时杀,不计为客户端取消
rx.recv().ok().and_then(Result::ok)
}
Err(mpsc::RecvTimeoutError::Disconnected) => None,
}
};
done.store(true, Ordering::Relaxed);
let _ = t_out.join();
let _ = t_err.join();
if let Some(t) = t_prog {
let _ = t.join();
}
let _ = reaper.join();
let stdout = String::from_utf8_lossy(&out.stdout.lock().unwrap().bytes).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr.lock().unwrap().bytes).into_owned();
let trunc_out = out.stdout.lock().unwrap().truncated;
let trunc_err = out.stderr.lock().unwrap().truncated;
Ok(ExecOutcome {
exit_code: status.as_ref().and_then(ExitStatus::code),
signal: status.as_ref().map(exit_signal).unwrap_or(None),
timed_out,
cancelled: killable.cancelled.load(Ordering::SeqCst),
duration_ms: start.elapsed().as_millis() as u64,
stdout,
stderr,
trunc_out,
trunc_err,
})
}
#[cfg(unix)]
fn exit_signal(st: &ExitStatus) -> Option<i32> {
use std::os::unix::process::ExitStatusExt;
st.signal()
}
#[cfg(not(unix))]
fn exit_signal(_st: &ExitStatus) -> Option<i32> {
None
}
fn run_result(o: &ExecOutcome, cwd: Option<&str>) -> Value {
let result = json!({
"exit_code": o.exit_code,
"signal": o.signal,
"timed_out": o.timed_out,
"cancelled": o.cancelled,
"duration_ms": o.duration_ms,
"stdout": o.stdout,
"stderr": o.stderr,
"truncated_stdout": o.trunc_out,
"truncated_stderr": o.trunc_err,
"cwd": cwd,
});
json!({
"content": [{"type": "text", "text": result.to_string()}],
"structuredContent": result,
})
}
// ---------------------------------------------------------------------------
// 工具:shell 持久会话
// ---------------------------------------------------------------------------
struct Session {
pid: u32,
stdin: Mutex<Option<std::process::ChildStdin>>,
out: Arc<SessionOut>,
}
struct SessionOut {
bytes: Mutex<Vec<u8>>,
off: Mutex<usize>, // 已读偏移(相对当前 Vec,裁剪后可能失效)
gen: AtomicU64,
cv: Condvar,
exited: Mutex<Option<i32>>,
dropped: AtomicU64,
}
fn session_read_loop<R: Read + Send + 'static>(mut pipe: R, out: Arc<SessionOut>) {
let mut chunk = [0u8; 16 * 1024];
loop {
match pipe.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
let mut b = out.bytes.lock().unwrap();
if b.len() >= SESSION_BUF_CAP {
let drop_n = b.len() / 2;
out.dropped.fetch_add(drop_n as u64, Ordering::Relaxed);
b.drain(..drop_n);
}
b.extend_from_slice(&chunk[..n]);
out.gen.fetch_add(1, Ordering::Relaxed);
out.cv.notify_all();
}
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
}
fn shell_open(app: &Shared, shell: Option<&str>, cwd: Option<&str>) -> Result<Value, String> {
let shell = shell.unwrap_or(if cfg!(windows) { "cmd" } else { "sh" });
policy_check(&app.cfg, shell)?;
let mut cmd = Command::new(shell);
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(c) = cwd {
if !Path::new(c).is_dir() {
return Err(format!("工作目录不存在: {c}"));
}
cmd.current_dir(c);
}
#[cfg(unix)]
unsafe {
cmd.pre_exec(|| {
libc::setpgid(0, 0);
Ok(())
});
}
let mut child = cmd
.spawn()
.map_err(|e| format!("启动 shell '{shell}' 失败: {e}"))?;
let pid = child.id();
let stdin = child.stdin.take().unwrap();
let stdout_pipe = child.stdout.take().unwrap();
let stderr_pipe = child.stderr.take().unwrap();
let out = Arc::new(SessionOut {
bytes: Mutex::new(Vec::new()),
off: Mutex::new(0),
gen: AtomicU64::new(0),
cv: Condvar::new(),
exited: Mutex::new(None),
dropped: AtomicU64::new(0),
});
let r1 = {
let out1 = out.clone();
thread::Builder::new()
.name("bolt-sess-out".into())
.spawn(move || session_read_loop(stdout_pipe, out1))
.unwrap()
};
let r2 = {
let out2 = out.clone();
thread::Builder::new()
.name("bolt-sess-err".into())
.spawn(move || session_read_loop(stderr_pipe, out2))
.unwrap()
};
// 收割线程:先等两个 reader 结束(EOF 即输出全部读入),再置退出标志,
// 保证 shell_read 看到 exited 时所有输出都已进入缓冲区(消除丢失输出的竞态)
let out_r = out.clone();
let _ = {
thread::Builder::new()
.name("bolt-sess-reaper".into())
.spawn(move || {
let st = child.wait();
let _ = r1.join();
let _ = r2.join();
let code = st.ok().and_then(|s| s.code());
*out_r.exited.lock().unwrap() = code;
out_r.cv.notify_all();
})
.unwrap()
};
let id = app.next_session.fetch_add(1, Ordering::Relaxed) + 1;
app.sessions.lock().unwrap().insert(
id,
Arc::new(Session {
pid,
stdin: Mutex::new(Some(stdin)),
out,
}),
);
Ok(json!({"session_id": id, "shell": shell, "pid": pid}))
}
fn shell_write(session: &Session, input: &str, newline: bool) -> Result<Value, String> {
if session.out.exited.lock().unwrap().is_some() {
return Err("会话已退出".into());
}
let mut data = input.to_string();
if newline && !data.is_empty() && !data.ends_with('\n') {
data.push('\n');
}
let mut stdin_guard = session.stdin.lock().unwrap();
let mut stdin = stdin_guard.take().ok_or("会话标准输入已关闭")?;
let (tx, rx) = mpsc::channel();
let n = data.len();
thread::spawn(move || {
let r = stdin.write_all(data.as_bytes());
let _ = tx.send((r, stdin)); // 把 stdin 一起传回,便于后续复用
});
match rx.recv_timeout(SESSION_WRITE_TIMEOUT) {
Ok((Ok(()), stdin)) => {
*stdin_guard = Some(stdin);
Ok(json!({"ok": true, "bytes_written": n}))
}
Ok((Err(e), _)) => Err(format!("写入失败(会话可能已退出): {e}")),
Err(_) => Err("写入超时(进程未读取输入),可用 shell_close 关闭会话".into()),
}
}
fn shell_read(session: &Session, timeout_ms: u64, max_bytes: usize) -> Value {
let out = &session.out;
let deadline = (timeout_ms != 0).then(|| Instant::now() + Duration::from_millis(timeout_ms));
loop {
// 1) 有新输出则返回
{
let b = out.bytes.lock().unwrap();
let mut off = out.off.lock().unwrap();
if *off > b.len() {
*off = 0; // 缓冲被裁剪,重置偏移
}
if *off < b.len() {
let take = (b.len() - *off).min(max_bytes);
let s = String::from_utf8_lossy(&b[*off..*off + take]).into_owned();
*off += take;
let truncated = *off < b.len();
drop(off);
let exited = *out.exited.lock().unwrap();
let dropped = out.dropped.load(Ordering::Relaxed);
let prefix = if dropped > 0 {
format!("[…已丢弃 {dropped} 字节…]\n")
} else {
String::new()
};
return json!({
"output": prefix + &s,
"exited": exited.is_some(),
"exit_code": exited,
"truncated": truncated,
});
}
}
// 2) 无新输出但已退出
if let Some(code) = *out.exited.lock().unwrap() {
return json!({"output": "", "exited": true, "exit_code": code, "truncated": false});
}
// 3) 等待新输出或退出
let wait = match deadline {
Some(d) => d.saturating_duration_since(Instant::now()),
None => Duration::from_secs(365 * 24 * 3600),
};
if wait.is_zero() {
return json!({"output": "", "exited": false, "exit_code": null, "truncated": false});
}
let gen = out.gen.load(Ordering::Relaxed);
let (guard, tres) = out.cv.wait_timeout(out.bytes.lock().unwrap(), wait).unwrap();
drop(guard);
if out.gen.load(Ordering::Relaxed) != gen || out.exited.lock().unwrap().is_some() {
continue; // 有新事件,重新检查
}
if tres.timed_out() {
return json!({"output": "", "exited": false, "exit_code": null, "truncated": false});
}
}
}
fn shell_close(app: &Shared, id: u64) -> Result<Value, String> {
let s = app
.sessions
.lock()
.unwrap()
.remove(&id)
.ok_or_else(|| format!("会话不存在: {id}"))?;
kill_tree(s.pid);
drop(s.stdin.lock().unwrap().take()); // 关闭 stdin,子进程看到 EOF
Ok(json!({"closed": true, "session_id": id}))
}
// ---------------------------------------------------------------------------
// 工具列表(静态)
// ---------------------------------------------------------------------------
static TOOLS: OnceLock<Value> = OnceLock::new();
fn tools_list() -> &'static Value {
TOOLS.get_or_init(|| {
json!({
"tools": [
{
"name": "run_command",
"description": "在服务器上执行 shell 命令并捕获输出。shell=true(默认)时支持完整 shell 语法:管道、重定向、&&、环境变量等;shell=false 时配合 args 直接执行二进制(不经 shell)。输出超限自动截断,超时自动杀进程,客户端可通过 notifications/cancelled 取消。",
"inputSchema": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "要执行的命令行(shell=true 时为 shell 语法)"},
"args": {"type": "array", "items": {"type": "string"}, "description": "shell=false 时的直接执行参数"},
"shell": {"type": "boolean", "description": "是否经系统 shell 执行(sh -c / cmd /C)。默认 true"},
"cwd": {"type": "string", "description": "工作目录,默认服务器配置目录"},
"env": {"type": "object", "additionalProperties": {"type": "string"}, "description": "附加环境变量"},
"timeout_ms": {"type": "integer", "description": "超时毫秒,0=无限,默认 30000,上限 21600000"},
"max_output_bytes": {"type": "integer", "description": "stdout/stderr 各自捕获上限,默认 1048576"}
},
"required": ["command"]
}
},
{
"name": "shell_open",
"description": "启动持久化交互式 shell 会话(cwd、环境变量等状态跨次保留)。与 shell_write / shell_read 配合使用。无 PTY,交互式程序可能表现为非交互模式。",
"inputSchema": {
"type": "object",
"properties": {
"shell": {"type": "string", "description": "shell 程序,如 sh/bash/zsh/fish,默认 sh(windows 为 cmd)"},
"cwd": {"type": "string", "description": "会话工作目录"}
}
}
},
{
"name": "shell_write",
"description": "向会话写入一行输入(自动补换行)。",
"inputSchema": {
"type": "object",
"properties": {
"session_id": {"type": "integer", "description": "shell_open 返回的会话 ID"},
"input": {"type": "string", "description": "要写入的内容"},
"newline": {"type": "boolean", "description": "是否自动补换行,默认 true"}
},
"required": ["session_id", "input"]
}
},
{
"name": "shell_read",
"description": "读取会话自上次读取以来的新输出;若无输出则阻塞至 timeout_ms(0 = 等到会话退出)。",
"inputSchema": {
"type": "object",
"properties": {
"session_id": {"type": "integer"},
"timeout_ms": {"type": "integer", "description": "等待毫秒,默认 5000,0=等到退出"},
"max_bytes": {"type": "integer", "description": "本次最多返回字节数,默认 1048576"}
},
"required": ["session_id"]
}
},
{
"name": "shell_close",
"description": "关闭并杀掉会话进程树。",
"inputSchema": {
"type": "object",
"properties": {
"session_id": {"type": "integer"}
},
"required": ["session_id"]
}
}
]
})
})
}
// ---------------------------------------------------------------------------
// MCP 分发
// ---------------------------------------------------------------------------
fn initialize_result(params: &Value) -> Value {
let client_version = params.get("protocolVersion").and_then(Value::as_str).unwrap_or("");
let version = if PROTOCOL_VERSIONS.contains(&client_version) {
client_version.to_string()
} else {
LATEST_PROTOCOL.to_string()
};
json!({
"protocolVersion": version,
"capabilities": {"tools": {"listChanged": false}},
"serverInfo": {"name": "bolt", "version": VERSION},
"instructions": "你是连接到 bolt 命令执行服务器的 AI。你可以用 run_command 在服务器上执行 shell 命令(支持管道、重定向、超时与取消);需要保留状态(如 cd、环境变量)时,用 shell_open 开启持久会话,配合 shell_write/shell_read 交互。注意命令受服务器策略限制,输出可能被截断。"
})
}
/// 执行工具调用,返回工具结果(Ok)或协议错误(Err((code, msg)))
/// killable:外部可取消句柄(注册在 inflight 表中,供 notifications/cancelled 使用);
/// 为 None 时(测试或内部调用)自动创建本地句柄。
fn exec_tool_call(app: &Shared, name: &str, args: &Value, progress: Option<&Value>, killable: Option<&Killable>) -> Result<Value, (i64, String)> {
match name {
"run_command" => {
let req = parse_run_params(args, &app.cfg).map_err(|e| (-32602, e))?;
let token = req.command.split_whitespace().next().unwrap_or("");
if let Err(e) = policy_check(&app.cfg, token) {
return Ok(tool_error(&e));
}
let local_k;
let k = match killable {
Some(k) => k,
None => {
local_k = Killable::default();
&local_k
}
};
match exec(&req, k, progress.cloned(), app) {
Ok(o) => Ok(run_result(&o, req.cwd.as_deref())),
Err(e) => Ok(tool_error(&e)),
}
}
"shell_open" => {
let shell = args.get("shell").and_then(Value::as_str);
let cwd = args.get("cwd").and_then(Value::as_str);
match shell_open(app, shell, cwd) {
Ok(v) => Ok(v),
Err(e) => Ok(tool_error(&e)),
}
}
"shell_write" => {
let id = args
.get("session_id")
.and_then(Value::as_u64)
.ok_or_else(|| (-32602, "'session_id' 必须是整数".to_string()))?;
let input = args
.get("input")
.and_then(Value::as_str)
.ok_or_else(|| (-32602, "'input' 必须是字符串".to_string()))?;
let newline = args.get("newline").and_then(Value::as_bool).unwrap_or(true);
let session = match app.sessions.lock().unwrap().get(&id) {
Some(s) => s.clone(),
None => return Ok(tool_error(&format!("会话不存在: {id}"))),
};
match shell_write(&session, input, newline) {
Ok(v) => Ok(v),
Err(e) => Ok(tool_error(&e)),
}
}
"shell_read" => {
let id = args
.get("session_id")
.and_then(Value::as_u64)
.ok_or_else(|| (-32602, "'session_id' 必须是整数".to_string()))?;
let timeout_ms = args.get("timeout_ms").and_then(Value::as_u64).unwrap_or(5000);
let max_bytes = args
.get("max_bytes")
.and_then(Value::as_u64)
.map(|n| n as usize)
.unwrap_or(DEFAULT_MAX_OUTPUT);
let session = match app.sessions.lock().unwrap().get(&id) {
Some(s) => s.clone(),
None => return Ok(tool_error(&format!("会话不存在: {id}"))),
};
Ok(shell_read(&session, timeout_ms, max_bytes))
}
"shell_close" => {
let id = args
.get("session_id")
.and_then(Value::as_u64)
.ok_or_else(|| (-32602, "'session_id' 必须是整数".to_string()))?;
match shell_close(app, id) {
Ok(v) => Ok(v),
Err(e) => Ok(tool_error(&e)),
}
}
_ => Err((-32602, format!("未知工具: {name}"))),
}
}
// 处理 messages,返回需要发送的 JSON-RPC 响应(通知/异步工具调用返回 None)
fn process_message(v: &Value, app: &Shared, async_exec: bool) -> Option<Value> {
let obj = match v.as_object() {
Some(o) => o,
None => return Some(rpc_error(&Value::Null, -32600, "invalid request")),
};
let method = match obj.get("method").and_then(Value::as_str) {
Some(m) => m,
None => return Some(rpc_error(obj.get("id").unwrap_or(&Value::Null), -32600, "invalid request")),
};
let params = obj.get("params").cloned().unwrap_or_else(|| json!({}));
let id = obj.get("id").cloned();
if id.is_none() {
// 通知
match method {
"notifications/initialized" => {}
"notifications/cancelled" => {
if let Some(rid) = params.get("requestId") {
let key = id_key(rid);
if let Some(k) = app.inflight.lock().unwrap().get(&key).cloned() {
k.kill(true);
}
}
}
"notifications/exit" => {
app.exit_flag.store(true, Ordering::SeqCst);
}
_ => {}
}
return None;
}
let id = id.unwrap();
let resp = match method {
"initialize" => json!({"jsonrpc": "2.0", "id": id, "result": initialize_result(&params)}),
"ping" => json!({"jsonrpc": "2.0", "id": id, "result": {}}),
"tools/list" => json!({"jsonrpc": "2.0", "id": id, "result": tools_list()}),
"tools/call" => {
let name = params.get("name").and_then(Value::as_str).unwrap_or("");
let args = params.get("arguments").cloned().unwrap_or_else(|| json!({}));
let progress = params.get("_meta").and_then(|m| m.get("progressToken")).cloned();
if async_exec {
// stdio 模式:worker 线程执行,完成后经 sink 发响应(不阻塞主循环)
let app2 = app.clone();
let id2 = id.clone();
let name2 = name.to_string();
let args2 = args;
let tok2 = progress;
let k = register_inflight(app, &id);
thread::Builder::new()
.name("bolt-tool".into())
.spawn(move || {
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
exec_tool_call(&app2, &name2, &args2, tok2.as_ref(), Some(&k))
}));
unregister_inflight(&app2, &id2, &k);
let resp = match r {
Ok(Ok(v)) => json!({"jsonrpc": "2.0", "id": id2, "result": v}),
Ok(Err((code, msg))) => rpc_error(&id2, code, &msg),
Err(_) => rpc_error(&id2, -32603, "internal error"),
};
app2.sink.send(&resp.to_string());
})
.unwrap();
return None;
} else {
let k = register_inflight(app, &id);
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
exec_tool_call(app, name, &args, progress.as_ref(), Some(&k))
}));
unregister_inflight(app, &id, &k);
match r {
Ok(Ok(v)) => json!({"jsonrpc": "2.0", "id": id, "result": v}),
Ok(Err((code, msg))) => rpc_error(&id, code, &msg),
Err(_) => rpc_error(&id, -32603, "internal error"),
}
}
}
"shutdown" => json!({"jsonrpc": "2.0", "id": id, "result": {}}),
_ => rpc_error(&id, -32601, &format!("method not found: {method}")),
};
Some(resp)
}
// ---------------------------------------------------------------------------
// stdio 传输
// ---------------------------------------------------------------------------
/// 读取一行(以 \n 结尾,不含换行),上限 cap 字节;返回 false 表示 EOF
fn read_line_capped<R: BufRead>(r: &mut R, cap: usize, buf: &mut Vec<u8>) -> io::Result<bool> {
loop {
let (newline_at, n) = {
let available = r.fill_buf()?;
if available.is_empty() {
return Ok(!buf.is_empty());
}
let room = cap.saturating_sub(buf.len());
if room == 0 {
return Ok(true); // 超限,由调用方报 parse error
}
let upto = available.len().min(room);
let nl = available[..upto].iter().position(|&b| b == b'\n');
match nl {
Some(i) => {
buf.extend_from_slice(&available[..i]);
(true, i + 1)
}
None => {
buf.extend_from_slice(&available[..upto]);
(false, upto)
}
}
};
r.consume(n);
if newline_at {
return Ok(true);
}
}
}
fn run_stdio(app: Shared) -> io::Result<()> {
let stdin = io::stdin();
let mut reader = BufReader::with_capacity(64 * 1024, stdin);
let mut buf: Vec<u8> = Vec::with_capacity(512);
loop {
// 顶部检查退出标志:exit 通知/信号处理后,即使 stdin 仍阻塞读取也能退出
if app.exit_flag.load(Ordering::SeqCst) {
break;
}
buf.clear();
match read_line_capped(&mut reader, MAX_LINE_BYTES, &mut buf) {
Ok(true) => {}
Ok(false) => break, // EOF:客户端关闭 stdin
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => {
eprintln!("[bolt] stdin 错误: {e}");
break;
}
}
// 去掉尾部 \r\n
let line = match buf.iter().rposition(|&b| b == b'\n') {
Some(i) => &buf[..i],
None => &buf[..],
};
let line = match line.iter().rposition(|&b| b == b'\r') {
Some(i) if i + 1 == line.len() => &line[..i],
_ => line,
};
if line.is_empty() {
continue;
}
let v = match serde_json::from_slice::<Value>(line) {
Ok(v) => v,
Err(_) => {
app.sink.send(PARSE_ERROR);
continue;
}
};
if let Some(resp) = process_message(&v, &app, true) {
app.sink.send(&resp.to_string());
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Streamable HTTP 传输
// ---------------------------------------------------------------------------
struct ReqHead {
method: String,
path: String,
conn_close: bool,
headers: HashMap<String, String>,
}
fn read_http_head<R: BufRead>(r: &mut R) -> io::Result<Option<Vec<u8>>> {
let mut buf = Vec::with_capacity(512);
loop {
if buf.len() > MAX_HTTP_HEAD {
return Err(io::Error::new(io::ErrorKind::InvalidData, "header too large"));
}
let n = r.read_until(b'\n', &mut buf)?;
if n == 0 {
return Ok(if buf.is_empty() { None } else { Some(buf) });
}
// 空行结束头部(\r\n\r\n 或 \n\n)
let len = buf.len();
if len >= 2 && buf[len - 1] == b'\n' && buf[len - 2] == b'\n' {
return Ok(Some(buf));
}
if len >= 4 && buf[len - 1] == b'\n' && buf[len - 2] == b'\r' && buf[len - 3] == b'\n' && buf[len - 4] == b'\r' {
return Ok(Some(buf));
}
}
}
fn parse_http_head(buf: &[u8]) -> Result<ReqHead, ()> {
let s = std::str::from_utf8(buf).map_err(|_| ())?;
let mut lines = s.lines();
let req_line = lines.next().ok_or(())?;
let mut parts = req_line.split_whitespace();
let method = parts.next().ok_or(())?.to_string();
let raw_path = parts.next().ok_or(())?.to_string();
let path = raw_path.split('?').next().unwrap_or("").to_string();
let mut headers = HashMap::new();
let mut conn_close = false;
for line in lines {
let line = line.trim_end_matches('\r');
if line.is_empty() {
continue;
}
if let Some((k, v)) = line.split_once(':') {
let key = k.trim().to_ascii_lowercase();
let val = v.trim().to_string();
if key == "connection" && val.eq_ignore_ascii_case("close") {
conn_close = true;
}
headers.insert(key, val);
}
}
Ok(ReqHead {
method,
path,
conn_close,
headers,
})
}
fn origin_allowed(cfg: &Config, origin: &str) -> bool {
if cfg.allow_origins.iter().any(|o| origin == o || origin.starts_with(&format!("{o}:"))) {
return true;
}
let host = origin
.split("://")
.nth(1)
.unwrap_or(origin)
.split(':')
.next()
.unwrap_or("");
host == "localhost" || host == "127.0.0.1" || host == "[::1]"
}
fn respond_plain(w: &mut impl Write, status: &str, body: &[u8]) -> io::Result<()> {
write!(
w,
"HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: keep-alive\r\nAccess-Control-Allow-Origin: *\r\n\r\n",
body.len()
)?;
w.write_all(body)?;
w.flush()
}
fn respond_json(w: &mut impl Write, status: &str, body: &str) -> io::Result<()> {
write!(
w,
"HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: keep-alive\r\nAccess-Control-Allow-Origin: *\r\n\r\n",
body.len()
)?;
w.write_all(body.as_bytes())?;
w.flush()
}
fn respond_options(w: &mut impl Write) -> io::Result<()> {
write!(
w,
"HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, Authorization, Accept\r\nAccess-Control-Max-Age: 86400\r\nConnection: keep-alive\r\n\r\n"
)?;
w.flush()
}
fn respond_401(w: &mut impl Write) -> io::Result<()> {
write!(
w,
"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Bearer\r\nContent-Length: 0\r\nConnection: keep-alive\r\nAccess-Control-Allow-Origin: *\r\n\r\n"
)?;
w.flush()
}
/// POST 的单事件 SSE 响应(规范:响应发送后服务器 SHOULD 关闭流)
fn write_sse_event(w: &mut impl Write, data: &str) -> io::Result<()> {
write!(
w,
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nCache-Control: no-cache\r\nConnection: close\r\nAccess-Control-Allow-Origin: *\r\n\r\nevent: message\r\ndata: {data}\r\n\r\n"
)?;
w.flush()
}
/// GET:长连接 SSE 流,接收服务器通知,15s 心跳保活
fn sse_stream(w: &mut impl Write, app: &Shared) {
let _ = write!(
w,
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\nAccess-Control-Allow-Origin: *\r\n\r\n"
);
let _ = w.flush();
let (tx, rx) = mpsc::channel::<String>();
let id = app.hub.add(tx);
loop {
match rx.recv_timeout(SSE_HEARTBEAT) {
Ok(msg) => {
let mut ev = String::with_capacity(msg.len() + 32);
ev.push_str("event: message\r\ndata: ");
ev.push_str(&msg);
ev.push_str("\r\n\r\n");
if w.write_all(ev.as_bytes()).and_then(|_| w.flush()).is_err() {
break;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {
if w.write_all(b": ping\r\n\r\n").and_then(|_| w.flush()).is_err() {
break;
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
app.hub.remove(id);
}
fn conn_loop(stream: TcpStream, app: Shared) {
let _ = stream.set_read_timeout(Some(Duration::from_secs(120)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(60)));
let reader_stream = match stream.try_clone() {
Ok(s) => s,
Err(_) => return,
};
let mut reader = BufReader::with_capacity(16 * 1024, reader_stream);
let mut writer = BufWriter::with_capacity(16 * 1024, stream);
loop {
let head = match read_http_head(&mut reader) {
Ok(Some(h)) => h,
Ok(None) => return, // 客户端关闭
Err(_) => {
let _ = respond_plain(&mut writer, "431 Request Header Fields Too Large", b"header too large");
return;
}
};
let req = match parse_http_head(&head) {
Ok(r) => r,
Err(_) => {
let _ = respond_plain(&mut writer, "400 Bad Request", b"bad request");
return;
}
};
if req.method == "OPTIONS" {
let _ = respond_options(&mut writer);
continue;
}
// 防 DNS rebinding:校验 Origin(规范 MUST)
if let Some(origin) = req.headers.get("origin") {
if !origin_allowed(&app.cfg, origin) {
let _ = respond_plain(&mut writer, "403 Forbidden", b"origin not allowed");
continue;
}
}
if let Some(tok) = &app.cfg.token {
let ok = req
.headers
.get("authorization")
.map(|a| a == &format!("Bearer {tok}"))
.unwrap_or(false);
if !ok {
let _ = respond_401(&mut writer);
continue;
}
}
if req.path != "/mcp" {
let _ = respond_plain(&mut writer, "404 Not Found", b"not found");
return;
}
match req.method.as_str() {
"GET" => {
sse_stream(&mut writer, &app);
return;
}
"POST" => {
let len: usize = req
.headers
.get("content-length")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
if len > MAX_HTTP_BODY {
let _ = respond_plain(&mut writer, "413 Payload Too Large", b"body too large");
return;
}
let mut body = vec![0u8; len];
if reader.read_exact(&mut body).is_err() {
return;
}
let v: Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(_) => {
let e = rpc_error(&Value::Null, -32700, "parse error");
let _ = respond_json(&mut writer, "400 Bad Request", &e.to_string());
return;
}
};
if !v.is_object() {
let e = rpc_error(&Value::Null, -32600, "invalid request");
let _ = respond_json(&mut writer, "400 Bad Request", &e.to_string());
return;
}
let wants_sse = req
.headers
.get("accept")
.map(|a| a.contains("text/event-stream"))
.unwrap_or(false);
match process_message(&v, &app, false) {
Some(resp) => {
let s = resp.to_string();
if wants_sse {
let _ = write_sse_event(&mut writer, &s);
return; // 规范:响应后关闭 SSE 流
}
let _ = respond_json(&mut writer, "200 OK", &s);
}
None => {
// 通知 → 202 Accepted,无 body
let _ = respond_plain(&mut writer, "202 Accepted", b"");
}
}
if req.conn_close {
return;
}
}
_ => {
let _ = respond_plain(&mut writer, "405 Method Not Allowed", b"method not allowed");
return;
}
}
}
}
#[cfg(unix)]
fn run_http(addr: &str, app: Shared) -> io::Result<()> {
use std::os::unix::io::AsRawFd;
let listener = TcpListener::bind(addr)?;
eprintln!("[bolt] {VERSION} 监听 http://{addr}/mcp (Streamable HTTP)");
log_policy(&app);
let fd = listener.as_raw_fd();
let mut pollfd = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
loop {
if app.exit_flag.load(Ordering::SeqCst) {
break;
}
let r = unsafe { libc::poll(&mut pollfd, 1, 200) };
if r > 0 {
match listener.accept() {
Ok((s, _)) => {
let _ = s.set_nodelay(true);
let app2 = app.clone();
thread::Builder::new()
.name("bolt-http".into())
.spawn(move || conn_loop(s, app2))
.unwrap();
}
Err(e) => eprintln!("[bolt] accept 错误: {e}"),
}
} else if r < 0 {
let err = io::Error::last_os_error();
if err.kind() != io::ErrorKind::Interrupted {
eprintln!("[bolt] poll 错误: {err}");
break;
}
}
}
Ok(())
}
#[cfg(windows)]
fn run_http(addr: &str, app: Shared) -> io::Result<()> {
let listener = TcpListener::bind(addr)?;
eprintln!("[bolt] {VERSION} 监听 http://{addr}/mcp (Streamable HTTP)");
log_policy(&app);
loop {
match listener.accept() {
Ok((s, _)) => {
let _ = s.set_nodelay(true);
let app2 = app.clone();
thread::Builder::new()
.name("bolt-http".into())
.spawn(move || conn_loop(s, app2))
.unwrap();
}
Err(e) => eprintln!("[bolt] accept 错误: {e}"),
}
}
}
fn log_policy(app: &Shared) {
if app.cfg.token.is_some() {
eprintln!("[bolt] 认证: Bearer Token 已启用");
}
if !app.cfg.allow.is_empty() {
eprintln!("[bolt] 白名单: {}", app.cfg.allow.join(", "));
}
if !app.cfg.deny.is_empty() {
eprintln!("[bolt] 黑名单: {}", app.cfg.deny.join(", "));
}
}
// ---------------------------------------------------------------------------
// 信号与清理
// ---------------------------------------------------------------------------
#[cfg(unix)]
fn install_signal_flag(flag: Arc<AtomicBool>) {
unsafe {
let mut set: libc::sigset_t = std::mem::zeroed();
libc::sigemptyset(&mut set);
libc::sigaddset(&mut set, libc::SIGINT);
libc::sigaddset(&mut set, libc::SIGTERM);
libc::pthread_sigmask(libc::SIG_BLOCK, &set, std::ptr::null_mut());
thread::spawn(move || {
let mut sig: libc::c_int = 0;
libc::sigwait(&set, &mut sig);
flag.store(true, Ordering::SeqCst);
eprintln!("[bolt] 收到信号 {sig},正在清理并退出…");
});
}
}
#[cfg(not(unix))]
fn install_signal_flag(_flag: Arc<AtomicBool>) {}
/// 退出前杀掉所有进行中的命令与会话,避免孤儿进程
fn shutdown_cleanup(app: &Shared) {
let inflight: Vec<Arc<Killable>> = app.inflight.lock().unwrap().values().cloned().collect();
for k in inflight {
k.kill(true);
}
let sessions: Vec<Arc<Session>> = app.sessions.lock().unwrap().drain().map(|(_, s)| s).collect();
for s in sessions {
kill_tree(s.pid);
}
}
// ---------------------------------------------------------------------------
// 入口
// ---------------------------------------------------------------------------
fn main() {
let Some(cfg) = parse_args() else {
print_help();
return;
};
let app = Arc::new(App::new(cfg));
install_signal_flag(app.exit_flag.clone());
let res = match &app.cfg.mode {
Mode::Stdio => run_stdio(app.clone()),
Mode::Http(addr) => run_http(addr, app.clone()),
};
if let Err(e) = res {
eprintln!("[bolt] 致命错误: {e}");
}
shutdown_cleanup(&app);
}
// ---------------------------------------------------------------------------
// 单元测试
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_size() {
assert_eq!(parse_size("1024"), Some(1024));
assert_eq!(parse_size("64K"), Some(64 * 1024));
assert_eq!(parse_size("1M"), Some(1024 * 1024));
assert_eq!(parse_size("2G"), Some(2 * 1024 * 1024 * 1024));
assert_eq!(parse_size("x"), None);
}
#[test]
fn test_policy() {
let cfg = Config {
mode: Mode::Stdio,
token: None,
allow: vec!["git".into(), "docker".into()],
deny: vec!["rm".into()],
timeout_ms: 1000,
cwd: None,
max_output: 1024,
allow_origins: vec![],
};
assert!(policy_check(&cfg, "git").is_ok());
assert!(policy_check(&cfg, "git status").is_ok());
assert!(policy_check(&cfg, "docker ps").is_ok());
assert!(policy_check(&cfg, "curl").is_err());
let cfg2 = Config {
allow: vec![],
deny: vec!["rm".into()],
..cfg
};
assert!(policy_check(&cfg2, "rm -rf /").is_err());
assert!(policy_check(&cfg2, "ls").is_ok());
}
#[test]
fn test_read_line_capped() {
let data = b"hello\nworld\r\n";
let mut r = BufReader::new(&data[..]);
let mut buf = Vec::new();
assert!(read_line_capped(&mut r, 1024, &mut buf).unwrap());
assert_eq!(buf, b"hello");
buf.clear();
assert!(read_line_capped(&mut r, 1024, &mut buf).unwrap());
assert_eq!(buf, b"world\r");
buf.clear();
assert!(!read_line_capped(&mut r, 1024, &mut buf).unwrap());
// 超限:返回 true 但截断
let mut r2 = BufReader::new(&b"abcdef"[..]);
let mut buf2 = Vec::new();
assert!(read_line_capped(&mut r2, 3, &mut buf2).unwrap());
assert_eq!(buf2.len(), 3);
}
#[test]
fn test_http_head_parse() {
let head = b"POST /mcp HTTP/1.1\r\nHost: x\r\nContent-Length: 10\r\nConnection: close\r\nAccept: application/json, text/event-stream\r\n\r\n";
let req = parse_http_head(head).unwrap();
assert_eq!(req.method, "POST");
assert_eq!(req.path, "/mcp");
assert!(req.conn_close);
assert_eq!(req.headers.get("content-length").unwrap(), "10");
assert!(req.headers.get("accept").unwrap().contains("text/event-stream"));
}
#[test]
fn test_origin() {
let cfg = Config {
mode: Mode::Stdio,
token: None,
allow: vec![],
deny: vec![],
timeout_ms: 1000,
cwd: None,
max_output: 1024,
allow_origins: vec!["https://example.com".into()],
};
assert!(origin_allowed(&cfg, "http://localhost:3000"));
assert!(origin_allowed(&cfg, "https://127.0.0.1"));
assert!(origin_allowed(&cfg, "https://example.com"));
assert!(!origin_allowed(&cfg, "https://evil.com"));
}
#[test]
fn test_dispatch_basic() {
let app = Arc::new(App::new(Config {
mode: Mode::Stdio,
token: None,
allow: vec![],
deny: vec![],
timeout_ms: 1000,
cwd: None,
max_output: 1024,
allow_origins: vec![],
}));
// ping
let v: Value = serde_json::from_str(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#).unwrap();
let r = process_message(&v, &app, false).unwrap();
assert_eq!(r["result"], json!({}));
assert_eq!(r["id"], json!(1));
// initialize
let v: Value = serde_json::from_str(
r#"{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#,
)
.unwrap();
let r = process_message(&v, &app, false).unwrap();
assert_eq!(r["result"]["protocolVersion"], "2024-11-05");
assert_eq!(r["result"]["serverInfo"]["name"], "bolt");
// 未知方法
let v: Value = serde_json::from_str(r#"{"jsonrpc":"2.0","id":3,"method":"nope"}"#).unwrap();
let r = process_message(&v, &app, false).unwrap();
assert_eq!(r["error"]["code"], -32601);
// 未知工具
let v: Value = serde_json::from_str(
r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"nope","arguments":{}}}"#,
)
.unwrap();
let r = process_message(&v, &app, false).unwrap();
assert_eq!(r["error"]["code"], -32602);
// 参数错误
let v: Value = serde_json::from_str(
r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"run_command","arguments":{}}}"#,
)
.unwrap();
let r = process_message(&v, &app, false).unwrap();
assert_eq!(r["error"]["code"], -32602);
// 解析失败走 PARSE_ERROR
assert!(PARSE_ERROR.contains("-32700"));
}
#[test]
fn test_run_command_basic() {
let app = Arc::new(App::new(Config {
mode: Mode::Stdio,
token: None,
allow: vec![],
deny: vec![],
timeout_ms: 5000,
cwd: None,
max_output: 1024 * 1024,
allow_origins: vec![],
}));
let args = json!({"command": "echo hello && printf err >&2 && exit 3"});
let r = exec_tool_call(&app, "run_command", &args, None, None).unwrap();
assert_eq!(r["isError"], Value::Null);
let sc = &r["structuredContent"];
assert_eq!(sc["exit_code"], 3);
assert_eq!(sc["stdout"], "hello\n");
assert_eq!(sc["stderr"], "err");
assert!(!sc["timed_out"].as_bool().unwrap());
// 超时
let args = json!({"command": "sleep 10", "timeout_ms": 300});
let r = exec_tool_call(&app, "run_command", &args, None, None).unwrap();
assert_eq!(r["structuredContent"]["timed_out"], true);
// shell=false 直接执行
let args = json!({"command": "echo", "args": ["direct"], "shell": false});
let r = exec_tool_call(&app, "run_command", &args, None, None).unwrap();
assert_eq!(r["structuredContent"]["stdout"], "direct\n");
// 白名单拦截
let app2 = Arc::new(App::new(Config {
allow: vec!["echo".into()],
..Config {
mode: Mode::Stdio,
token: None,
allow: vec![],
deny: vec![],
timeout_ms: 5000,
cwd: None,
max_output: 1024,
allow_origins: vec![],
}
}));
let r = exec_tool_call(&app2, "run_command", &json!({"command": "ls"}), None, None).unwrap();
assert_eq!(r["isError"], true);
// 经 shell 执行不存在的命令:sh 启动成功,内部报错,exit 127
let r = exec_tool_call(&app, "run_command", &json!({"command": "definitely_not_a_cmd_xyz"}), None, None).unwrap();
assert_eq!(r["structuredContent"]["exit_code"], 127);
assert!(!r["structuredContent"]["stderr"].as_str().unwrap().is_empty());
// shell=false 直接执行不存在的程序:spawn 失败 → isError
let r = exec_tool_call(&app, "run_command", &json!({"command": "definitely_not_a_cmd_xyz", "shell": false}), None, None).unwrap();
assert_eq!(r["isError"], true);
}
#[test]
fn test_shell_session() {
let app = Arc::new(App::new(Config {
mode: Mode::Stdio,
token: None,
allow: vec![],
deny: vec![],
timeout_ms: 5000,
cwd: None,
max_output: 1024,
allow_origins: vec![],
}));
let r = shell_open(&app, Some("sh"), None).unwrap();
let sid = r["session_id"].as_u64().unwrap();
assert!(shell_write(app.sessions.lock().unwrap().get(&sid).unwrap(), "echo FIRST", true).is_ok());
let r0 = shell_read(app.sessions.lock().unwrap().get(&sid).unwrap(), 2000, 1 << 20);
assert!(r0["output"].as_str().unwrap().contains("FIRST"), "output: {}", r0);
// 状态保留:cd 后 pwd(非交互 sh 无回显,分两步写、一次读)
assert!(shell_write(app.sessions.lock().unwrap().get(&sid).unwrap(), "cd /tmp", true).is_ok());
assert!(shell_write(app.sessions.lock().unwrap().get(&sid).unwrap(), "echo PROBE && pwd", true).is_ok());
// 输出分块到达,循环读取直到拿到全部(工具语义:返回当前可用的新输出)
let mut probe_out = String::new();
for _ in 0..10 {
let r = shell_read(app.sessions.lock().unwrap().get(&sid).unwrap(), 500, 1 << 20);
probe_out.push_str(r["output"].as_str().unwrap());
if probe_out.contains("PROBE") && probe_out.contains("/tmp") {
break;
}
}
assert!(probe_out.contains("PROBE"), "output: {probe_out}");
assert!(probe_out.contains("/tmp"), "output: {probe_out}");
assert!(shell_write(app.sessions.lock().unwrap().get(&sid).unwrap(), "echo STATE_OK && exit", true).is_ok());
let mut out = String::new();
for _ in 0..20 {
let r = shell_read(app.sessions.lock().unwrap().get(&sid).unwrap(), 1000, 1 << 20);
out.push_str(r["output"].as_str().unwrap());
if r["exited"].as_bool().unwrap() {
break;
}
}
assert!(out.contains("STATE_OK"), "out: {out}");
let r = shell_close(&app, sid).unwrap();
assert_eq!(r["closed"], true);
}
}