跳到主要内容

数据截至 (上游 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/listtools/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_serverssrc-tauri/src/core/mcp/helpers.rs:318 / :385
monitor_mcp_server_handle30 秒一次健康探测 + 指数退避重连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)用 ?commandargs,这两个字段缺一个整个配置就返回 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 随包发 bunuv,把 npx foo 重写成 bun x foouvx 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_availablecan_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):

场景单个服务器超时总超时意图
AppExit500ms1500ms用户在关窗口,别卡着
ManualRestart2s5s可以等一等,要干净
FactoryReset5s10s在删数据,必须彻底

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 目录:

字段用途
pidMCP 子进程的 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 里的原始配置读 capabilitiesdescription(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 closedbroken pipeconnection resetchannel closedtransport error。朴素但够用——代价是上游改文案就会漏判 (inferred)

4.2 call_tool:找服务器 + 超时 + 取消

call_tool(commands.rs:388)的签名里 server_nameOption。给了就只查那一个服务器,没给就遍历所有服务器、对每个都调一次 list_all_tools,谁有这个工具就用谁(commands.rs:426-446)。

这有两个后果:

  1. 每次工具调用都附带若干次 tools/list 往返,服务器越多越慢。
  2. 同名工具的归属由 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

4.3 配置读写:读的时候顺手迁移

get_mcp_configs(commands.rs:541)不是纯读:文件不存在就写默认配置;JSON 解析失败就当空对象重建;缺 mcpSettings / mcpServers 就补上;没有 Jan Browser MCP 条目就插一条(active: false)(commands.rs:592-608)。任何一处改动都会 mutated = true 并回写磁盘,同时把设置同步进内存 AppState.mcp_settings

save_mcp_configs(commands.rs:763)是对称的:校验必须是 JSON 对象、补齐两个顶层键、写盘、更新内存设置。

5. 前端服务层:一层薄桥,顺带做移动端降级

MCPService 接口有两个实现,这是 Jan"同一份 UI 跑桌面和移动"的常规手法(和 01 讲的扩展体系同一个思路)。

桌面:TauriMCPService(web-app/src/services/mcp/tauri.ts:12) 全是转发。两种转发风格并存——一部分走 window.core.api.*(getToolscallToolcancelToolCall),一部分直接 invoke('...')(getToolsForServersgetServerSummariesactivate_mcp_server)。

callToolWithCancellation(tauri.ts:90)是"带取消把手"的调用形态,返回三件套:

// 示意,非源码:调用方拿到 promise 的同时也拿到 cancel
const { promise, cancel, token } = mcp.callToolWithCancellation({ toolName, arguments })
// token 默认是 `tool_call_${Date.now()}_${随机}`,传给 Rust 侧登记

诚实说明:web-app/src 里 grep,callToolWithCancellation 目前只有服务层定义和测试桩,没有生产调用点;聊天主路径用的是普通 callTool,靠一个 AbortController 在前端中断工具循环($threadId.tsx:378-395)。也就是说 Rust 侧的取消能力已经做好,但 UI 还没接上。

移动/其它平台:DefaultMCPService(web-app/src/services/mcp/default.ts:9) 全部返回空:getTools() 返回 []getServerSummaries() 返回 []callTool 返回空 content。这不是占位 TODO,是刻意的降级语义——没有 MCP 的平台上,工具列表天然为空,上层 refreshTools 拿到空数组就不给模型挂任何工具,整条链路自动退化成纯聊天,不需要一行 if (isMobile)

6. 审批闸门(本章最该学的一条)

这是整章最值得抄的设计。拆成两个互补的 zustand store:在飞的待批队列 web-app/src/hooks/useToolApprovalRequests.ts(105 行,不落盘)和持久化白名单 web-app/src/hooks/useToolApproval.ts(92 行,落盘)。

6.1 要解决的小问题

模型可以自主决定"我要调 filesystem::write_file"。你不能让它无条件执行,但也不能每次都弹窗——那样十步工具循环要点十次。所以需要一个"记得住"的闸门。

6.2 四态,不是三态

UI 上是四个按钮(web-app/src/components/ai-elements/tool.tsx:348-370),对应 ApprovalDecision 四个值(useToolApprovalRequests.ts:15-20):

决策这次以后落到哪
deny拒绝不记resolve(false)
allow-once放行不记,下次还问resolve(true)
allow-thread放行记进本线程白名单approveToolForThread(threadId, toolName)
allow-always放行记全局:有服务器名就信整台服务器,否则信这个工具approveServer(serverName) / approveToolEverywhere(toolName)

resolveApproval(useToolApprovalRequests.ts:69)是这个状态机唯一的出口:只有 allow-thread / allow-always 会写白名单(写进另一个 store useToolApproval),然后从 pending 里删掉这条,最后 entry.resolve(decision !== 'deny') 把等待中的 Promise 解开。

6.3 一个 Promise,一种等待形态

requestApproval(useToolApprovalRequests.ts:43)是内联式:把 { toolCallId, toolName, threadId, serverName, resolve } 挂进 pending map,由消息流里那条工具卡片自己渲染出四个按钮(tool.tsx:328 ToolApprovalActions)。审批 UI 长在对话流里那条工具调用的下方,而不是盖住整个窗口——多个工具调用可以各自带各自的按钮,toolCallId 就是它们的身份。聊天主路径两处调用它($threadId.tsx:492-504:668-686)。(旧版还有一个弹窗式的 showApprovalModal API,已随重构移除;如今弹窗式审批只保留在模型上下文提示 useModelContextApproval.ts 里。)

requestApproval 入口自带前置短路(useToolApprovalRequests.ts:47-58),顺序一致:

allowAllMCPPermissions 打开? ──是──► 直接 resolve(true),不弹
│否
isToolApproved(threadId, toolName, serverName)? ──是──► 直接 resolve(true)
│否

挂起,等用户点按钮

6.4 记忆的粒度:按线程,不按全局

approvedTools 的类型是 Record<threadId, toolName[]>(useToolApproval.ts:8)。换一个会话,白名单从头来过——这是有意的:你在"整理下载目录"那个线程批准了 write_file,不代表在"帮我写周报"那个线程也该批。

allowAllMCPPermissions 是逃生舱:全局布尔,打开后所有审批直接放行(:67-71)。

持久化用 zustand persist + localStorage(useToolApproval.ts:80-90),partialize 只存四样白名单状态(approvedTools / approvedServers / approvedToolsGlobal / allowAllMCPPermissions);pending 刻意留在另一个不落盘的 store(useToolApprovalRequests.ts:22-25 注释明说:审批高频增删不该刷盘,而且 resolve 回调本身不可序列化)。

旧版的粒度缺口已补上一半: 按线程的 approvedTools 仍存裸工具名(useToolApproval.ts:8),同一线程里 A 服务器的 search 放行后,B 服务器的同名 search 也会被 isToolApproved 放过(:62-72 只查全局表/服务器表和线程表,线程级不区分服务器);但 allow-always 这一档现在已经区分——有 serverName 就写 approvedServers(信整台服务器),没有才写 approvedToolsGlobal。而工具开关(§8)用的是 server::tool 复合键,与线程级白名单的键空间仍不一致。

6.5 内置 RAG 工具为什么跳过审批

$threadId.tsx:400-405:

// Built-in RAG tools are internal and should not require approval.
const approved = ragToolNames.has(toolName) || await useToolApprovalRequests.getState().requestApproval(...)

理由: RAG 工具(检索用户自己刚上传的文档)是 Jan 内置能力,不是第三方进程,没有"把数据发给未知服务器"的风险;而且 RAG 循环里模型会连着检索好几轮,每轮都弹窗完全不可用。

ragToolNames 这个集合从哪来?useTools(web-app/src/hooks/useTools.ts:10-49)在启动和每次 mcp-update 事件时并行拉 MCP 工具和 RAG 工具名,存进 useAppState。同一段代码顺手做了影子检测:如果某个 RAG 工具名被 MCP 工具占了,就把它从 ragOnly 里剔除并 warn——因为下游路由是按名字 has() 判断的,重名会导致 RAG 请求错发到 MCP。

还有一处"预授权":文档嵌入成功后,直接把所有 RAG 工具名批进该线程白名单($threadId.tsx:865-870)。属于双保险。

7. 工具太多怎么办:两级路由 + 全套降级归因

7.1 要解决的小问题

每个 MCP 工具的 JSON Schema 都要进 prompt。连 10 个服务器、每个 15 个工具,就是 150 份 schema——本地小模型的上下文直接被吃光,而且工具越多模型选错的概率越高。

Jan 的思路:先选服务器,再拉那几个服务器的工具。 服务器摘要(名字 + 描述 + capabilities)比工具 schema 便宜两个数量级,所以"选"这一步几乎不花钱。

7.2 什么时候才路由

阈值写死在 web-app/src/lib/mcp-orchestrator/intent-classifier.ts:4-6:

常量含义
ROUTING_THRESHOLD5连接的服务器 ≤ 5 个就完全跳过路由,全量给
MAX_ROUTED_SERVERS5路由生效时,最多选 5 个服务器

getRelevantTools(mcp-orchestrator.ts:85)开头就是这个判断:summaries.length <= ROUTING_THRESHOLD 直接 fetchAllTools 返回(:96-114)。大多数用户永远走不到路由代码——这是个只为重度用户存在的优化。

7.3 第一级:关键词打分(零成本、总有结果)

classifyIntent(intent-classifier.ts:62)先 tokenize(小写、非字母数字切分、去停用词、丢单字符),再对每个服务器打分(scoreServer,:33):

命中类型权重
消息词 == capability(完全相等)+4
capability 与消息词互为子串+2
消息词 == 描述里的某个词+1

然后 score >= 2 才算候选(:83)。这个门槛有明确注释:只靠描述里一个常见动词(比如 "send and read emails" 里的 "read")蹭到 1 分,不足以把请求路由到一个不相干的服务器。

三个 fallback 全部是"退回全量"(:70:74:85):服务器为空、消息切不出词、没有任何候选达标——都返回全部服务器名。宁可多给,不可少给。

7.4 第二级:结构化小模型选服务器

selectServersWithLlm(web-app/src/lib/mcp-orchestrator/mcp-router-llm.ts:35)在关键词之上再来一刀。

实现细节和常见预期有出入:用的是 generateText + Output.object,不是 generateObject(mcp-router-llm.ts:64-75),schema 是 z.object({ selectedServers: z.array(z.string()) })。参数很克制:temperature: 0maxOutputTokens: 256

三重防线:

  1. 超时。 MCP_ROUTER_TIMEOUT_MS = 3_500(:10),自己的 AbortController 定时 abort;同时监听父级 abortSignal 做级联取消(:51-61)。
  2. 白名单校验。 模型返回的名字先过 allowed.has(n) 过滤、去重、slice(0, MAX_ROUTED_SERVERS)(:77-80)。模型编造的服务器名一律丢弃。
  3. 失败即降级。 catch 里区分 abort / timeout / error,一律返回 names: [] 并带上 errorKind,让调用方回退到关键词结果(:91-121)。

关键一行在编排器里(mcp-orchestrator.ts:139-141):只有 LLM 返回了非空选择才覆盖关键词结果。空数组不采纳。

7.5 完整降级链

从上往下,任何一步失败都往下掉,最底下永远是"全量工具":

服务器 ≤ 5 个 ──────────────────────────────► 全量工具(不路由)
│ > 5 个

关键词打分 classifyIntent ──── 无命中 ────► 全部服务器名


LLM 路由 selectServersWithLlm(3.5s 上限)
│ 空/超时/报错 → 保留关键词结果

getToolsForServers(选中的名字)
│ 拉到 0 个工具 或 抛异常

fetchAllTools() 全量兜底

7.6 缓存与遥测

两个 TTL(mcp-orchestrator.ts:76-77):

缓存TTL粒度
toolCacheCACHE_TTL_MS = 30s每服务器一条,按名字存工具数组
summaryCacheSUMMARY_TTL_MS = 60s整份摘要一条

fetchToolsForServersWithStatus(:235)先扫缓存,只对未命中的名字发一次批量请求,再按 tool.server 分桶回填。未命中的服务器即使返回 0 个工具也会写入缓存(:264-267),避免 30 秒内反复问一个空服务器。

invalidateCache(:202)支持按服务器失效,但——诚实说明——当前 web-app/src 里没有生产调用点,实际只靠 TTL 自然过期。

McpRoutingTelemetry(:31-62)是这套机制里最"工程化"的部分:14 个字段,把每一次路由的归因都记下来。fallbackReason 是一个 7 值枚举:

什么情况
none没降级
llm_timeout / llm_abort / llm_errorLLM 路由三种失败
llm_empty_outputLLM 返回了但全被白名单过滤掉,或它就是选了空
selective_tools_empty选择性拉取拿到 0 个工具
selective_fetch_error选择性拉取抛异常

优先级有讲究(:168-173):工具加载类失败盖过 LLM 失败——因为前者才是真正影响本轮能力的那个。遥测回调还被 try/catch 包住(:193,注释:"telemetry must not crash routing")。

7.7 可选的轻量 router model

默认情况下路由用的就是聊天模型本身。开关打开后可以指定一个专门的小模型(custom-chat-transport.ts:912-916),由 resolveRouterModel(:979)解析。

它有三道拒绝逻辑,每一道都降级回聊天模型而不是报错:provider 找不到、模型不在候选集、创建模型抛异常(:807-830)。候选集判定在 isRouterModelSelectable(web-app/src/lib/mcp-router-model-filter.ts:60):

  • deny 正则筛掉贵模型(opuso1o3gpt-4-turbo、不带 mini 的 gpt-5 等)。
  • allow 正则放行小模型(mininanoflashhaiku1b/2b/3b/7b/8bphi-3 等)。
  • 再要求:本地 provider 直接过,远程 provider 必须有 API key。

解析结果按 provider::modelId 缓存在 transport 实例上(:797-800),避免每轮重建。源码注释很坦白:这是启发式,目前没有 API 能问 provider 要"价格档位"

8. 工具开关与重名冲突

8.1 server::tool 复合键

用户可以逐个关掉工具。键是 ${serverName}::${toolName}(web-app/src/hooks/useToolAvailable.ts:7 createToolKey),按线程存(disabledTools: Record<threadId, string[]>),另有一份全局默认 defaultDisabledTools

isToolDisabled(:95)的回退规则:线程没有任何记录时用全局默认,一旦有记录就完全以线程为准(哪怕是空数组)。initializeThreadTools(:130)在建线程时把全局默认里"当前确实存在的工具"抄一份过去。

早期版本用的是裸工具名,所以带了一个迁移:检测到任何不含 :: 的键就整份清空重来(migrateOldFormatIfNeeded,:15-33,version: 1)。粗暴但安全——旧键无法可靠推断属于哪个服务器。

过滤最终发生在两处:编排器出口 filterDisabled(mcp-orchestrator.ts:277)、以及 refreshTools 里对每个工具再判一次(custom-chat-transport.ts:940-943)。

8.2 重名冲突:只告警,不改名

refreshTools 把工具装进 Record<toolName, Tool>(custom-chat-transport.ts:939-951),键是裸工具名,所以两个服务器暴露同名工具时后者必然覆盖前者。Jan 的处理是记一个 seenBy map,发现跨服务器同名就打一条 warn:

[tools] MCP tool name collision: "search" exposed by both "exa" and "serper". Using "serper".

为什么不加前缀? 代码里没写理由 (inferred),但从约束看:工具名要原样进 prompt 给模型,也要原样传回 call_tool 做名字匹配(commands.rs:442),加前缀就得在两端都做双向翻译。当前选择是"接受覆盖 + 留下线索"。

这个决定的代价是可见的: 覆盖顺序取决于工具返回顺序,而 Rust 侧 call_tool 在没有 serverName 时又按 HashMap 迭代顺序找服务器——两边可能选中不同的服务器。也就是说,重名场景下"模型看到的 schema"和"实际被调用的实现"未必来自同一个服务器。前端主路径确实没传 serverName($threadId.tsx:428-431),尽管 Rust 侧支持这个参数。

同一类问题在 RAG 侧的处理不同:useTools 选择让 MCP 赢,把被遮蔽的 RAG 工具名直接剔出 ragToolNames(useTools.ts:41-47),保证名字归属唯一。

9. 巧妙之处(可带走的)

  1. "不是我的进程就不杀"。 端口冲突时先验进程身份(is_orphaned_mcp_process),不是 Jan 的就报错让用户处理(helpers.rs:976-987)。锁文件里的 jan_pid 让多实例互不误伤(lockfile.rs:199)。
  2. 子进程当组长。 process_group(0)(helpers.rs:612-615)+ killpg(helpers.rs:1017-1021),一次干掉整棵进程树。这是"MCP bridge 会 fork 孙进程"踩出来的。
  3. 关停按场景分档。 同一个函数,AppExit 给 500ms、FactoryReset 给 5s(helpers.rs:34-49)——"要快"和"要干净"是不同场景的不同需求,不该用一个常量糊。
  4. 健康探测频率是被日志量决定的。 30 秒不是拍脑袋,是因为每次探测都会被服务器回显到 stderr(helpers.rs:175)。这类"非功能约束反过来决定参数"的注释很值钱。
  5. 超时和错误区别对待。 列工具时"服务器报错"会摘条目并触发重连,"超时"只 warn(commands.rs:86-102)。慢和死不是一回事。
  6. 降级链一定要有归因。 McpRoutingFallbackReason 七个值 + 优先级规则(mcp-orchestrator.ts:168-173),让"为什么这次没走最优路径"可观测、可调阈值。多数项目做了降级但不记原因,出问题只能猜。
  7. 空实现即降级策略。 DefaultMCPService 全返回空(default.ts:9),移动端整条工具链自动退化,上层零分支。
  8. 审批 UI 内联在消息流里。 pendingtoolCallId 索引(useToolApprovalRequests.ts:27),多个工具调用各挂各的按钮,不用全局模态框排队;持久白名单拆在另一个 store,审批增删不刷盘。

10. 边界与局限

  • 线程级审批白名单不含服务器名。 approvedTools 按线程存裸工具名(useToolApproval.ts:8),而工具开关存 server::tool;线程内同名工具会共享审批状态(全局的 allow-always 一档则已按服务器/工具区分)。
  • allowAllMCPPermissions 是全局的、且持久化。 打开后所有线程、所有工具、所有服务器全放行(:71-74:165-168),没有"仅本线程全放行"这一档。
  • 重名工具的实际归属不确定。 详见 §8.2。
  • 取消能力做好了但没接线。 callToolWithCancellation / cancel_tool_call 全链路存在,web-app/src 里却没有生产调用点。
  • invalidateCache 无人调用,工具缓存只靠 30 秒 TTL 过期;刚启用的服务器最长可能 30 秒后才被看见。
  • is_transport_error 是字符串匹配(commands.rs:114),上游改文案就会漏判,重连不会被及时触发。
  • HTTP/SSE 的 env 不生效。 只有 headers 会被映射(helpers.rs:407-425),注释是误导的。
  • 路由阈值是硬编码常量,不在 McpSettings 里,用户改不了(intent-classifier.ts:4-6)。
  • router model 的筛选是正则启发式,新模型名不在词表里就选不上(mcp-router-model-filter.ts:7-44)。

11. 代码地图

主题文件关键符号
启动一个 MCP 服务器src-tauri/src/core/mcp/helpers.rsstart_mcp_serverschedule_mcp_start_taskextract_command_args
三种 transport 分派src-tauri/src/core/mcp/helpers.rsStreamableHttpClientTransportSseClientTransportTokioChildProcess
stdio 子进程构建src-tauri/src/core/mcp/helpers.rsbuild_cmdcan_override_npxcan_override_uvxlog_mcp_stderr_line
健康探测与重连src-tauri/src/core/mcp/helpers.rsmonitor_mcp_server_handleemit_mcp_update_event
关停与强杀src-tauri/src/core/mcp/helpers.rsShutdownContextstop_mcp_servers_with_contextShutdownGuardkill_process_by_pid
孤儿进程与端口src-tauri/src/core/mcp/helpers.rskill_orphaned_mcp_process_with_appterminate_browser_mcpbackground_cleanup_mcp_servers
进程锁文件src-tauri/src/core/mcp/lockfile.rsMcpLockFilecreate_lock_filecheck_and_cleanup_stale_lockcleanup_own_locks
工具枚举src-tauri/src/core/mcp/commands.rscollect_mcp_toolsget_toolsget_tools_for_serversget_server_summaries
工具调用与取消src-tauri/src/core/mcp/commands.rscall_toolcancel_tool_callis_transport_errorcleanup_cancellation_token
配置读写与迁移src-tauri/src/core/mcp/commands.rsget_mcp_configssave_mcp_configsparse_mcp_settings
运行时设置与默认值src-tauri/src/core/mcp/models.rs · constants.rsMcpSettingstool_call_timeout_durationServerSummaryDEFAULT_MCP_CONFIG
共享状态src-tauri/src/core/state.rsRunningServiceEnumSharedMcpServersmcp_monitoring_tasksmcp_reconnect_notify
前端服务桥web-app/src/services/mcp/tauri.ts · default.tsTauriMCPServicecallToolWithCancellationDefaultMCPService
审批待批队列(不落盘)web-app/src/hooks/useToolApprovalRequests.tsrequestApprovalresolveApprovalclearPendingForThread
审批持久白名单web-app/src/hooks/useToolApproval.tsapproveToolForThreadapproveServerapproveToolEverywhereisToolApproved
审批 UIweb-app/src/components/ai-elements/tool.tsxToolApprovalActions
工具循环与 RAG 例外web-app/src/routes/threads/$threadId.tsxragToolNamesmcpToolNamesaddToolOutput
工具名缓存与影子检测web-app/src/hooks/useTools.tsupdateRagToolNamesupdateMcpToolNames
关键词路由web-app/src/lib/mcp-orchestrator/intent-classifier.tsROUTING_THRESHOLDMAX_ROUTED_SERVERSclassifyIntentscoreServer
LLM 路由web-app/src/lib/mcp-orchestrator/mcp-router-llm.tsselectServersWithLlmMCP_ROUTER_TIMEOUT_MSLlmRouterResult
编排、缓存、遥测web-app/src/lib/mcp-orchestrator/mcp-orchestrator.tsMCPOrchestratorgetRelevantToolsMcpRoutingTelemetryCACHE_TTL_MS
工具装配与重名告警web-app/src/lib/custom-chat-transport.tsrefreshToolsresolveRouterModel
router model 筛选web-app/src/lib/mcp-router-model-filter.tsisRouterModelSelectableisLikelyLightweightRouterModel
工具开关web-app/src/hooks/useToolAvailable.tscreateToolKeyisToolDisabledinitializeThreadTools

12. 接着读什么