数据截至 (上游 commit 3dcf4cad0124)
手脚:MCP 客户端、审批闸门与工具路由
30 秒导读: 上一章(02)讲的是"模型怎么把话说完"。这一章讲模型说完话之后,那句 tool call 怎么变成真实动作——工具从哪来(Rust 侧的 MCP 客户端)、谁批准它执行(前端的审批闸门)、以及工具太多时怎么只把相关的那批塞给模型(两级路由)。
1. 这一章讲什么
一句话: Jan 的"手脚"全部来自 MCP(Model Context Protocol,模型上下文协议——一套让外部程序把"工具"暴露给 LLM 的标准),Jan 自己不实现任何业务工具,它只做四件事:连上 MCP 服务器、枚举工具、挑工具、以及在真正调用前卡一道人工审批。
四件事分别落在不同层:
| 关切 | 白话 | 落在哪 |
|---|---|---|
| 连得 上、活得住 | 起进程/连 HTTP、健康探测、断线重连、退出时不留孤儿 | Rust(src-tauri/src/core/mcp/) |
| 有哪些工具、怎么调 | tools/list、tools/call、超时与取消 | Rust commands.rs + 前端 service 层 |
| 该不该调 | 审批三态、按线程记忆、全局开关 | 前端 useToolApproval |
| 调哪些(工具太多) | 先选服务器再拉工具、关键词打分 + 小模型 | 前端 lib/mcp-orchestrator/ |
为什么值得学: 大多数客户端把 MCP 当"加个配置就能用"的功能。Jan 的实现里真正有工程含量的是三条不显然的暗线——子进程的进程组回收、"工具太多导致上下文爆炸"的两级降级路由、以及审批状态机怎么和流式 UI 对齐。本章按这三条展开。
2. 顶层全景:一次工具调用要过五道关
先看主线。从上往下读,任何一关不过,后面都不会发生:
模型吐出 tool call(toolName + input)
│
▼
① 这名字归谁? ──是内置 RAG 工具──► 直接执行(免审批)
│ 是 MCP 工具
▼
② 审批闸门 ──deny──► 写回 output-error「用户拒绝」
│ allow
▼
③ 前端 service 层(Tauri invoke / 移动端空实现)
│
▼
④ Rust call_tool:超时 + 取消 + 传输错误→踢一脚重连
│
▼
⑤ rmcp 客户端 ──► MCP server(http / sse / stdio 子进程)
注意关卡的顺序:选工具(路由)发生在更早——在组 prompt 那一刻,refreshTools 就已经决定"这一轮把哪些工具的 schema 给模型看"(web-app/src/lib/custom-chat-transport.ts:815 refreshTools)。所以路由是"事前减法",审批是"事中卡点"。
部件职责一览:
| 部件 | 干什么 | 文件 |
|---|---|---|
start_mcp_server / schedule_mcp_start_task | 按 transport 建连接,注册进 mcp_servers | src-tauri/src/core/mcp/helpers.rs:318 / :385 |
monitor_mcp_server_handle | 30 秒一次健康探测 + 指数退避重连 | helpers.rs:163 |
collect_mcp_tools | 枚举工具,顺带清理挂掉的服务器 | src-tauri/src/core/mcp/commands.rs:39 |
call_tool | 找到工具所在服务器并调用,带超时/取消 | commands.rs:388 |
TauriMCPService | 前端到 Rust 的桥 | web-app/src/services/mcp/tauri.ts:12 |
useToolApprovalRequests | 审批待批队列(zustand,不落盘) | web-app/src/hooks/useToolApprovalRequests.ts:40 |
useToolApproval | 持久化白名单(zustand + localStorage) | web-app/src/hooks/useToolApproval.ts:26 |
MCPOrchestrator | 服务器太多时选服务器、缓存工具、发降级遥测 | web-app/src/lib/mcp-orchestrator/mcp-orchestrator.ts:79 |
3. 手脚从哪来:Rust 侧 MCP 客户端的一生
这一节讲连接。Jan 用的是 Rust 的 rmcp crate,把三种 transport 收敛到一个枚举里存进 AppState。
3.1 一个连接的三种长相
分派逻辑都在 schedule_mcp_start_task(helpers.rs:385),按配置里的 type 字段走三条互斥分支:
config.type == "http" 且有 url ──► StreamableHttpClientTransport (helpers.rs:401)
config.type == "sse" 且有 url ──► SseClientTransport (helpers.rs:466)
其它(含没写 type) ──► TokioChildProcess 起子进程 stdio (helpers.rs:537)
三条分支的结果统一装进同一个服务类型:rmcp 的 RunningService<RoleClient, JanClientHandler>——src-tauri/src/core/state.rs:56-57 给它起了 RunningMcpService / SharedMcpServers 两个别名,各分支都是 handler.serve(transport) 拿到 client 后存进共享 map。上层调用不区分传输是什么;对服务器的 call_tool 等操作统一走 commands.rs:405 的命令层,按名字从 map 里取 client 转发。
第一个坑:配置解析比你想的严格。 extract_command_args(helpers.rs:839)用 ? 取 command 和 args,这两个字段缺一个整个配置就返回 None,启动直接失败。所以即便是纯 HTTP 的服务器,配置里也必须写占位:
"exa": {
"type": "http",
"url": "https://mcp.exa.ai/mcp",
"command": "",
"args": [],
"env": {},
"active": true
}
这不是我猜的——Jan 升级时会按这 个形状把 exa 写进用户配置(setup.rs:79-100 migrate_exa_to_http)。内置默认配置 DEFAULT_MCP_CONFIG(src-tauri/src/core/mcp/constants.rs:11-59)如今列的是 browsermcp/fetch/serper 等,但 HTTP 型配置长什么样,这份迁移代码就是权威样例。
第二个坑:注释说的是 env,代码读的是 headers。 HTTP/SSE 两条分支都有一段"把配置映射成请求头"的代码,注释写着 // Map envs to request headers,但循环体实际迭代的是 config_params.headers(helpers.rs:407-425 与 :472-491),envs 在这两条分支里根本没被用到。也就是说:远程 MCP 的鉴权头要写在配置的 headers 字段里,写进 env 不会生效(注释是历史残留)。映射本身很朴素——键名尝试 HeaderName::from_bytes,值尝试 HeaderValue::from_str,任一失败就静默跳过这条头。
3.2 stdio 子进程:四个防崩设计
stdio 分支是最长也最脏的一条(helpers.rs:537-806),因为它要管一个真实的操作系统进程。按执行顺序拆开看:
(1) 优先用捆绑的 bun/uv,失败再退回系统 npx/uvx。
Jan 随包发 bun 和 uv,把 npx foo 重写成 bun x foo、uvx foo 重写成 uv tool run foo(helpers.rs:582-604,build_cmd),并把缓存目录指到 Jan 的数据目录下的 .npx / .uvx。但重写有风险——bun x 的 stdio 管道行为和 npx 不完全一样,而大部分公开 MCP 服务器只在 npx 下测过。所以启动被写成一个循环:
// helpers.rs:661-668,重写失败就把 use_override 关掉重来一次
if use_override && override_available {
log::warn!("MCP server {name} failed to start via bundled bun/uv override ({e}); retrying with system {}", ...);
use_override = false;
continue;
}
override_available 由 can_override_npx / can_override_uvx 判定(helpers.rs:577-580)。一句话:先快后稳,快的那条挂了自动降级。
(2) 子进程当进程组组长,方便整棵树一起杀。
cmd.process_group(0)(helpers.rs:612-615,仅 unix)让子进程 pgid == pid。源码注释点明了动机:MCP bridge 会 fork 孙进程,孙进程握着端口,只杀单个 PID 杀不干净。Windows 侧则用 creation_flags(0x08000000)(CREATE_NO_WINDOW)避免弹出黑窗(helpers.rs:605-608)。
(3) 必须一直读 stderr,否则子进程会被 SIGPIPE 打死。
spawn 时 .stderr(Stdio::piped()),拿到的 stderr 流单独 spawn 一个任务持续 read(helpers.rs:683-700)。注释写得很直白:保活管道 + 收集诊断输出。读到的每一行交给 log_mcp_stderr_line(helpers.rs:811),它取行首第一个词猜日志级别(ERROR/WARN/DEBUG/TRACE),映射到 Jan 自己的 logger,默认 info。
(4) 起来了 ≠ 能用,还要两轮验活。
先 sleep(500ms) 再看服务器是否还在 map 里,防"启动即退出"(helpers.rs:710-721);然后最多 3 次尝试调 tools/list,每次 2 秒超时、失败退避 1 秒(helpers.rs:726-778)。注释解释了为什么:通过 npx mcp-remote 起的服务器,serve() 返回后传输层还没准备好收 JSON-RPC。
值得注意的是 3 次全失败也照样发 mcp-update 事件(helpers.rs:779-780 的注释),把后续修复交给健康监控。这是"乐观放行 + 后台兜底"的取舍。
3.3 健康探测与自动重连
每个成功启动的服务器都会配一个监控任务(helpers.rs:358-374),JoinHandle 存进 AppState.mcp_monitoring_tasks(state.rs:59),关机时好按名字 abort。
监控循环 monitor_mcp_server_handle(helpers.rs:163)的节奏:
┌───────────────────────────────────────────┐
│ 等 30 秒 或 被 reconnect_notify 唤醒 │ (helpers.rs:176-181)
└─── ────────────────┬───────────────────────┘
▼
查 shutdown 标志 → 置位就退出
▼
list_all_tools(2s 超时) 当心跳 (helpers.rs:194)
┌─────┴─────┐
成功 失败/超时/条目不见了
│ │
失败计数清零 移除死条目 + 清 PID + 发事件
│
退避 sleep = base × 2^(n-1),封顶 max
│
再调一次 schedule_mcp_start_task
为什么是 30 秒而不是 2 秒? 源码里留了答案(helpers.rs:175):每次探测都会强制一次 ListToolsRequest,而很多服务器会把这个请求 回显到 stderr,探得太密日志会被刷爆。
退避参数来自可配置的 McpSettings,默认 base 1000ms、乘数 2.0、上限 30000ms(src-tauri/src/core/mcp/constants.rs:7-9)。
reconnect_notify 是一个 tokio::sync::Notify(state.rs:69),它是**"别等满 30 秒,现在就查"**的快捷通道。谁按这个按钮?下一节的 is_transport_error。
3.4 退出与孤儿进程:两套清理路径
路径一:正常关停,按场景给不同的耐心。 ShutdownContext 枚举把"用户关 App / 手动重启 / 恢复出厂"三种场景映射到不同超时(helpers.rs:27-50):
| 场景 | 单个服务器超时 | 总超时 | 意图 |
|---|---|---|---|
AppExit | 500ms | 1500ms | 用户在关窗口,别卡着 |
ManualRestart | 2s | 5s | 可以等一等,要干净 |
FactoryReset | 5s | 10s | 在删数据,必须彻底 |
stop_mcp_servers_with_context(helpers.rs:1138)的顺序很讲究:先用 mcp_shutdown_in_progress 做可重入保护并配一个 ShutdownGuard 在 Drop 时复位(helpers.rs:1120-1136);先 abort 掉所有监控任务再动服务器(helpers.rs:1155-1160),否则监控会把你正在关的服务器当成"挂了"又拉起来;然后并发 cancel,超时的走 kill_process_by_pid 强杀(helpers.rs:1260-1268)。
路径二:孤儿进程回收(端口锁)。 这是给 Jan Browser MCP 这类占固定端口的服务器准备的。启动前先探端口,占用就试着清(helpers.rs:538-560);启动成功后写锁文件(helpers.rs:782-802)。
锁文件长这样(src-tauri/src/core/mcp/lockfile.rs:6-15,McpLockFile),文件名 mcp_lock_<port>.json,放在 app data 目录:
| 字段 | 用途 |
|---|---|
pid | MCP 子进程的 PID |
jan_pid | 写锁的那个 Jan 实例的 PID |
port / server_name / created_at / hostname | 诊断与归属 |
jan_pid 是关键设计:cleanup_own_locks(lockfile.rs:183)靠它只删自己这次运行写下的锁,不会误删另一个 Jan 实例的。
kill_orphaned_mcp_process_with_app(helpers.rs:906)的判定链是"先信锁文件,再信端口扫描":
读 mcp_lock_<port>.json
├─ 没有锁 ──────────────────────────────► 走端口扫描
├─ 锁里的 PID 已死 ──► 删锁,继续往下(孙进程可能还占着端口)
└─ PID 还活着
├─ is_orphaned_mcp_process(进程名/命令行像 MCP) ──► 杀掉 + 删锁 + 等端口释放
└─ 不像 MCP(PID 被复用了) ──► 判定锁过期,删锁
▼
find_process_using_port(lsof/netstat)
├─ 不是 Jan 的进程 ──► 报错让用户自己处理,绝不乱杀
└─ 是 ──► 杀,并轮询等端口空出来
"不是我的进程就不杀"这条边界值得抄。 端口冲突时最省事的写法是 lsof -ti:port | xargs kill,Jan 拒绝这么做,宁可返回一条让用户自己关的错误(helpers.rs:983-987)。
真正的杀进程动作在 kill_process_by_pid(unix 版 helpers.rs:1006):解析 pgid 并过滤掉 <= 1 的(防止误杀 init 组),优先 killpg 而非 kill,先 SIGTERM,轮询 30 × 100ms 还不死才 SIGKILL。Windows 版直接 taskkill /F /T(helpers.rs:1041)。
terminate_browser_mcp(helpers.rs:1070)是浏览器 MCP 专用的收尾:杀进程组 → 等端口 → 还占着就再找一次"落单者"再杀一轮。
4. 工具的枚举与调用
连接搞定,这一节讲数据面:列工具、调工具。全在 src-tauri/src/core/mcp/commands.rs。
4.1 三个列举命令,共用一套清理逻辑
collect_mcp_tools(commands.rs:32)是共享实现,靠一个 Option<HashSet<String>> 过滤器区分两种模式:
| Tauri 命令 | 过滤器 | 谁用 |
|---|---|---|
get_tools(commands.rs:297) | None = 全部已连接服务器 | 设置页、路由降级兜底 |
get_tools_for_servers(commands.rs:307) | Some(names),先去重、空集直接返回 | 路由命中后的选择性拉取 |
get_server_summaries(commands.rs:328) | 不列工具,只返回 name/capabilities/description | 路由器做决策的廉价输入 |
get_server_summaries 是整个路由方案的地基:它从 mcp_active_servers 里的原始配置读 capabilities 和 description(commands.rs:343-357),完全不碰网络,所以前端可以频繁调它而不付代价。
列举失败时的处理是不对称的,这点容易看漏(commands.rs:86-102):
Ok(Err(e))(服务器明确报错):若is_transport_error命中,notify_waiters()踢醒监控立刻重连;并把这个服务器条目从mcp_servers里摘掉(remove_mcp_server_entry,commands.rs:261)。Err(_)(超时):只打一条 warn,不摘条目、不触发重连。 慢 ≠ 死。
is_transport_error(commands.rs:114)是纯字符串匹配,认这五个子串:transport closed、broken pipe、connection reset、channel closed、transport error。朴素但够用——代价是上游改文案就会漏判 (inferred)。
4.2 call_tool:找服务器 + 超时 + 取消
call_tool(commands.rs:388)的签名里 server_name 是 Option。给了就只查那一个服务器,没给就遍历所有服务器、对每个都调一次 list_all_tools,谁有这个工具就用谁(commands.rs:426-446)。
这有两个后果:
- 每次工具调用都附带若干次
tools/list往返,服务器越多越慢。 - 同名工具的归属由 HashMap 迭代顺序决定,不确定。 而前端主路径调用时恰恰没传
serverName(web-app/src/routes/threads/$threadId.tsx:428-431)。这条和 §8 的重名告警是同 一个问题的两端。
超时/取消的实现是一个 tokio::select!(commands.rs:455-478):
// 示意,非源码:三方赛跑
tokio::select! {
result = timeout(timeout_duration, tool_call) => { /* 正常返回 或 超时错误 */ }
_ = cancel_rx => { /* 用户点了取消 */ }
}
超时时长来自 McpSettings::tool_call_timeout_duration()(src-tauri/src/core/mcp/models.rs:88),默认 30 秒,并且强制下限 1 秒(.max(1))——防止配置写 0 造成零时长超时立刻失败。
取消通道是一个 oneshot::Sender 存在 AppState.tool_call_cancellations 里,键是前端生成的 token;cancel_tool_call(commands.rs:518)取出并 send(())。无论成功、失败还是提前 return,都会走 cleanup_cancellation_token(commands.rs:123)把 token 摘掉,不泄漏。
调用本身若命中传输错误,同样 notify_waiters() 触发重连,并给用户一条可重试的话术(commands.rs:496-504):"服务器断开,正在重连,请重试"——而不是干巴巴的 Tool not found。