From 0a92a5b695fb567c5fbf34e4a5f4ace29eb29143 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Thu, 1 Oct 2026 16:55:28 +0900 Subject: [PATCH] =?UTF-8?q?fix(mcp/parity):=20=E6=94=B6=E5=B0=BE=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94Claude=20=E7=8E=B0?= =?UTF-8?q?=E7=8A=B6=E4=BB=A5=20claude=20mcp=20get=20=E4=B8=BA=E5=87=86?= =?UTF-8?q?=EF=BC=8C=E6=8E=A5=E5=85=A5=E5=91=BD=E4=BB=A4=E7=A7=BB=E5=87=BA?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E7=BA=BF=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收尾对抗审查指出:只读扫描 ~/.claude.json 的结论不能代表 Claude 在项目目录实际生效的条目 (共享项目键、worktree、目录迁移下会误删他项目条目或漏写),且同步 CLI 调用会阻塞界面线程。 - 快路径只认「项目根或其祖先目录键下」的一致托管条目;其余一律在项目目录执行 `claude mcp get lian-jing` 取实际生效条目,按归属决策:本项目托管条目 → 刷新/移除 (写入失败用原条目回滚);他项目托管条目或手写条目 → 不碰并记录;无 local 条目 → 写入 - 输出无法识别、CLI 出错或目录不存在时一律不写,只记 warn - 全局迁移需 claude CLI 与 git-bash 均可用才置完成标记;批量重注入的成员复查失败计入错误 - 删组/移出组先删成员关系再清 MCP 配置,避免并发重注入把项目加回;调 CLI 前释放数据库连接 - delete_group / add_group_member / remove_group_member / delete_project / update_settings / apply_blueprint_buff 改为 #[tauri::command(async)],在线程池执行 - 新增 parse_claude_mcp_get / lianjing_project_of / key_covers_root 单元测试 实测(enterprise):稳态注入 174ms 不起 CLI;条目缺失 → 写入;过期 helper → 刷新; 手写条目 → 保留;三种情况 claude mcp get 均为 Local 且 Connected;经 shim 零凭据探针认证通过。 Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/commands/buff.rs | 3 +- src-tauri/src/commands/groups.rs | 50 +-- src-tauri/src/commands/projects.rs | 3 +- src-tauri/src/commands/settings.rs | 3 +- src-tauri/src/mcp_inject.rs | 492 ++++++++++++++++++++++------- 5 files changed, 415 insertions(+), 136 deletions(-) diff --git a/src-tauri/src/commands/buff.rs b/src-tauri/src/commands/buff.rs index afb2e1c..2202fd3 100644 --- a/src-tauri/src/commands/buff.rs +++ b/src-tauri/src/commands/buff.rs @@ -17,7 +17,8 @@ pub struct BuffStatus { // ── Tauri 命令 ─────────────────────────────────────────────────────────────── /// 向项目施加蓝图 Buff:注入 CONVENTIONS + CLAUDE.md,写入 DB -#[tauri::command] +// 会经 claude CLI 写客户端配置:在线程池执行,不占界面线程 +#[tauri::command(async)] pub fn apply_blueprint_buff( project_path: String, project_id: String, diff --git a/src-tauri/src/commands/groups.rs b/src-tauri/src/commands/groups.rs index 25e02f8..4a6a0c0 100644 --- a/src-tauri/src/commands/groups.rs +++ b/src-tauri/src/commands/groups.rs @@ -72,10 +72,10 @@ pub fn update_group(id: String, input: GroupInput) -> Result<(), String> { Ok(()) } -#[tauri::command] +// 会经 claude CLI 清理客户端配置:在线程池执行,不占界面线程 +#[tauri::command(async)] pub fn delete_group(id: String) -> Result<(), String> { let conn = pool().get().map_err(|e| e.to_string())?; - // 先清除所有成员的 MCP 配置(DB cascade 删除之前) let member_ids: Vec = conn .prepare("SELECT project_id FROM project_group_map WHERE group_id = ?1") .map_err(|e| e.to_string())? @@ -83,18 +83,24 @@ pub fn delete_group(id: String) -> Result<(), String> { .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); - for project_id in &member_ids { - // 单一 lian-jing key 为全项目共用:成员还属于其他组时保留配置 - let in_other_group: i64 = conn - .query_row( + // 单一 lian-jing key 为全项目共用:成员还属于其他组时保留配置。删组前先算出需清理的成员 + let to_clear: Vec<&String> = member_ids + .iter() + .filter(|project_id| { + conn.query_row( "SELECT COUNT(*) FROM project_group_map WHERE project_id = ?1 AND group_id != ?2", params![project_id, id], - |row| row.get(0), + |row| row.get::<_, i64>(0), ) - .unwrap_or(0); - if in_other_group > 0 { - continue; - } + .unwrap_or(0) + == 0 + }) + .collect(); + // 先删组(级联删除成员关系)再清 MCP 配置:批量重注入在锁内复查成员关系,先删可避免把它们重新注入 + conn.execute("DELETE FROM product_groups WHERE id = ?1", params![id]) + .map_err(|e| e.to_string())?; + drop(conn); + for project_id in to_clear { if let Err(e) = mcp_inject::remove_project_mcp(project_id) { let ctx = serde_json::json!({"project_id": project_id, "group_id": id}).to_string(); log_event( @@ -105,8 +111,6 @@ pub fn delete_group(id: String) -> Result<(), String> { ); } } - conn.execute("DELETE FROM product_groups WHERE id = ?1", params![id]) - .map_err(|e| e.to_string())?; Ok(()) } @@ -181,6 +185,7 @@ pub fn ensure_standalone_group(project_id: &str) -> Result<(), String> { params![project_id, group_id], ) .map_err(|e| e.to_string())?; + drop(conn); if let Err(e) = mcp_inject::inject_project_mcp(project_id) { let ctx = serde_json::json!({"project_id": project_id, "group_id": &group_id}).to_string(); @@ -195,7 +200,8 @@ pub fn ensure_standalone_group(project_id: &str) -> Result<(), String> { Ok(()) } -#[tauri::command] +// 会经 claude CLI 写客户端配置:在线程池执行,不占界面线程 +#[tauri::command(async)] pub fn add_group_member( project_id: String, group_id: String, @@ -207,6 +213,7 @@ pub fn add_group_member( params![project_id, group_id, role], ) .map_err(|e| e.to_string())?; + drop(conn); // DB 写入成功后注入 MCP 配置(非致命,注入失败不影响成员关系) let inject_warning = match mcp_inject::inject_project_mcp(&project_id) { Ok(()) => None, @@ -220,7 +227,8 @@ pub fn add_group_member( Ok(inject_warning) } -#[tauri::command] +// 会经 claude CLI 清理客户端配置:在线程池执行,不占界面线程 +#[tauri::command(async)] pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), String> { let conn = pool().get().map_err(|e| e.to_string())?; // 仅当项目退出后不再属于任何组时才清除 MCP 配置(单一 lian-jing key 为全项目共用) @@ -231,6 +239,13 @@ pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), S |row| row.get(0), ) .unwrap_or(0); + // 先删成员关系再清 MCP 配置:批量重注入在锁内复查成员关系,先删可避免把它重新注入 + conn.execute( + "DELETE FROM project_group_map WHERE project_id = ?1 AND group_id = ?2", + params![project_id, group_id], + ) + .map_err(|e| e.to_string())?; + drop(conn); if in_other_group == 0 { if let Err(e) = mcp_inject::remove_project_mcp(&project_id) { let ctx = @@ -243,10 +258,5 @@ pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), S ); } } - conn.execute( - "DELETE FROM project_group_map WHERE project_id = ?1 AND group_id = ?2", - params![project_id, group_id], - ) - .map_err(|e| e.to_string())?; Ok(()) } diff --git a/src-tauri/src/commands/projects.rs b/src-tauri/src/commands/projects.rs index cbedfc1..32e6c3a 100644 --- a/src-tauri/src/commands/projects.rs +++ b/src-tauri/src/commands/projects.rs @@ -327,7 +327,8 @@ pub fn update_project_tags(id: String, tags: String) -> Result<(), String> { Ok(()) } -#[tauri::command] +// 会经 claude CLI 清理客户端配置:在线程池执行,不占界面线程 +#[tauri::command(async)] pub fn delete_project(id: String, delete_local: Option) -> Result<(), String> { let conn = pool().get().map_err(|e| e.to_string())?; diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 8daeed1..aae9f83 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -76,7 +76,8 @@ pub fn get_settings(space_id: Option) -> Result, } /// 更新 settings:全局 key 写入 settings 表;其余写入 space_settings 表 -#[tauri::command] +// 端口变更会重注入全部项目(含 claude CLI):在线程池执行,不占界面线程 +#[tauri::command(async)] pub fn update_settings( settings: HashMap, space_id: Option, diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 9400d01..988fe5b 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -31,7 +31,11 @@ fn header_shim_path() -> Result { /// 路径里的 % 必须写成 %%,否则会和 %1 配对被当作变量展开吞掉。 fn render_header_shim(executable: &std::path::Path) -> String { let exe = executable.display().to_string().replace('%', "%%"); - let codepage = if exe.is_ascii() { "" } else { "chcp 65001 >nul\r\n" }; + let codepage = if exe.is_ascii() { + "" + } else { + "chcp 65001 >nul\r\n" + }; format!("@echo off\r\n{codepage}\"{exe}\" --lianjing-mcp-header %1\r\nexit /b %ERRORLEVEL%\r\n") } @@ -214,10 +218,7 @@ fn find_claude_bin() -> Option { } // 最后尝试 where 命令(GUI 进程 PATH 可能不含用户 bin,but worth trying) - if let Ok(out) = crate::silent_cmd("where") - .arg("claude.exe") - .output() - { + if let Ok(out) = crate::silent_cmd("where").arg("claude.exe").output() { if out.status.success() { if let Ok(stdout) = String::from_utf8(out.stdout) { if let Some(line) = stdout.lines().next() { @@ -300,10 +301,24 @@ fn try_register_globally(port: u16) -> bool { &claude_bin, &git_bash, None, - &["mcp", "add", "--transport", "sse", "-s", "user", MCP_KEY, &server_url], + &[ + "mcp", + "add", + "--transport", + "sse", + "-s", + "user", + MCP_KEY, + &server_url, + ], ) { - Ok(()) => { - db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(&global_log_context())); + Ok(_) => { + db::log_event( + "mcp_inject", + "info", + "全局 MCP 注册成功", + Some(&global_log_context()), + ); true } // 同名已存在不是故障——CLI 链路健康,注册目标已达成 @@ -328,7 +343,11 @@ fn project_wants_global_scope(root: &std::path::Path) -> bool { "canonical 项目上下文", ) .ok() - .and_then(|ctx| ctx.get("mcp_scope").and_then(Value::as_str).map(|scope| scope == "global")) + .and_then(|ctx| { + ctx.get("mcp_scope") + .and_then(Value::as_str) + .map(|scope| scope == "global") + }) .unwrap_or(false) } @@ -383,7 +402,10 @@ fn managed_claude_entries(config: &Value, project_id: &str) -> Vec<(String, Valu .filter_map(|(key, project)| { let entry = project.get("mcpServers")?.get(MCP_KEY)?; let url = entry.get("url").and_then(Value::as_str)?; - let helper = entry.get("headersHelper").and_then(Value::as_str).unwrap_or(""); + let helper = entry + .get("headersHelper") + .and_then(Value::as_str) + .unwrap_or(""); (url.starts_with("http://127.0.0.1:") && url.ends_with(&suffix) && helper.contains(MCP_HEADER_SHIM_NAME)) @@ -399,16 +421,16 @@ fn claude_global_registered(config: &Value) -> bool { .is_some() } -/// claude CLI 单次调用上限:卡住的子进程会被终止,不让它拖住持锁的接入操作与界面。 +/// claude CLI 单次调用上限:卡住的子进程会被终止,不让它拖住持锁的接入操作。 const CLAUDE_CLI_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); -/// 运行 claude CLI(不弹窗、stdin 置空、带超时)。成功返回 Ok,失败返回合并后的输出。 +/// 运行 claude CLI(不弹窗、stdin 置空、带超时)。成功返回输出,失败返回合并后的输出。 fn run_claude_cli( claude_bin: &str, git_bash: &str, cwd: Option<&std::path::Path>, args: &[&str], -) -> Result<(), String> { +) -> Result { use std::io::Read; let mut command = crate::silent_cmd(claude_bin); if let Some(dir) = cwd { @@ -448,96 +470,178 @@ fn run_claude_cli( let _ = stderr.read_to_string(&mut output); } if status.success() { - Ok(()) + Ok(output) } else { Err(output) } } +/// 统一分隔符与大小写,便于比较 ~/.claude.json 的项目键与项目根目录。 +fn normalize_root_key(path: &str) -> String { + path.replace('\\', "/").trim_end_matches('/').to_lowercase() +} + +/// Claude 以 git 根为项目键,必为项目根本身或其祖先目录。 +fn key_covers_root(key: &str, root: &str) -> bool { + let key = normalize_root_key(key); + let root = normalize_root_key(root); + root == key || root.starts_with(&format!("{key}/")) +} + +/// 炼境项目路由 URL 里的 project_id;不是炼境项目路由则为 None(视为手写配置)。 +fn lianjing_project_of(url: &str) -> Option { + let rest = url.strip_prefix("http://127.0.0.1:")?; + let encoded = rest.split_once("/mcp/projects/")?.1.trim_end_matches('/'); + urlencoding::decode(encoded).ok().map(|id| id.into_owned()) +} + +#[derive(Debug, PartialEq)] +enum ClaudeEffective { + /// 本目录生效的是 local 条目,附其 URL + Local(String), + /// 本目录没有 local 条目(生效的是 user/project 级,或根本没有 lian-jing) + NotLocal, +} + +/// 纯函数:解析 `claude mcp get lian-jing` 的输出。格式无法识别时返回 None(按「未知」处理,不写入)。 +fn parse_claude_mcp_get(output: &str) -> Option { + if output.contains("No MCP server found") { + return Some(ClaudeEffective::NotLocal); + } + let field = |name: &str| { + output.lines().find_map(|line| { + line.trim() + .strip_prefix(name) + .map(|value| value.trim().to_string()) + }) + }; + let scope = field("Scope:")?; + if scope.starts_with("Local config") { + Some(ClaudeEffective::Local(field("URL:")?)) + } else { + Some(ClaudeEffective::NotLocal) + } +} + /// 客户端侧同步(Claude local、DSH 托管块)的日志单独归类:不覆盖诊断里每个项目的「最后一次注入结果」。 const CLIENT_SYNC_LOG: &str = "mcp_client_sync"; -/// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除炼境托管的条目。 +/// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除本项目的炼境托管条目。 /// -/// 写入一律交给 claude CLI(cwd=项目目录):项目键推导、Claude 自己的文件锁、配置路径与读写细节都由 -/// Claude 负责,炼境不复刻其私有实现(2026-10-01 最终审查:直接编辑 ~/.claude.json 需要复刻 git 根 / -/// worktree / NFC 键推导、.claude.json.lock 锁协议、CLAUDE_CONFIG_DIR 等一整套内部机制,随 Claude 升级易失效)。 -/// 只读扫描判定现状:已一致则不起 CLI(稳态不阻塞);只替换/删除炼境托管条目,手写条目不碰。 -/// 项目目录已不存在时无法经 CLI 定位项目键,只记录残留、不写文件。 -/// 调用方须持有 mcp_op_lock,并在删除本地文件夹之前同步调用(避免与目录删除竞态)。 +/// 原则:写入一律交给 claude CLI(cwd=项目目录),判定以 Claude 自己的回答为准——炼境不复刻 Claude 的项目键 +/// 推导、文件锁与配置路径(2026-10-01 多轮审查结论:任何自行推断都会在 worktree、目录迁移、共享键等边界出错)。 +/// - 快路径(不起 CLI):本项目的托管条目已一致地位于项目根或其祖先目录的键下(Claude 的键是 git 根),视为已同步; +/// 只读确认没有任何托管条目时,移除无需动作。 +/// - 其余情况在项目目录执行 `claude mcp get lian-jing` 取实际生效条目:本项目的托管条目 → 移除或刷新; +/// 他项目的托管条目(共享项目键)或手写条目 → 不碰并记录;没有 local 条目 → 按需写入。 +/// - 刷新时先删后加,写入失败用原条目回滚;无法确认现状(读取失败、CLI 出错、目录不存在)一律不写,只记 warn。 +/// +/// 调用方须持有 mcp_op_lock,并在删除本地文件夹之前同步调用。 fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(String, String)>) { let context = inject_log_context(project_id, "claude"); - let managed = read_claude_config() - .map(|config| managed_claude_entries(&config, project_id)) + let warn = |message: String| db::log_event(CLIENT_SYNC_LOG, "warn", &message, Some(&context)); + let config = read_claude_config(); + let root_text = root.display().to_string(); + let managed_all = config + .as_ref() + .map(|config| managed_claude_entries(config, project_id)) .unwrap_or_default(); + let managed_here: Vec<&(String, Value)> = managed_all + .iter() + .filter(|(key, _)| key_covers_root(key, &root_text)) + .collect(); let desired_entry = desired.map(|(url, helper)| claude_local_server_entry(&url, &helper)); match &desired_entry { - None if managed.is_empty() => return, - Some(entry) if managed.iter().any(|(_, current)| current == entry) => return, + Some(entry) if managed_here.iter().any(|(_, current)| current == entry) => return, + None if config.is_some() && managed_all.is_empty() => return, _ => {} } if !root.is_dir() { - let keys: Vec<&str> = managed.iter().map(|(key, _)| key.as_str()).collect(); - db::log_event( - CLIENT_SYNC_LOG, - "warn", - &format!( - "项目目录已不存在,无法经 claude CLI 同步 Claude 项目级条目;残留托管条目所在项目键 {keys:?},可在对应目录执行 `claude mcp remove lian-jing -s local` 清理" - ), - Some(&context), - ); + let keys: Vec<&str> = managed_all.iter().map(|(key, _)| key.as_str()).collect(); + warn(format!( + "项目目录不存在,无法向 Claude 确认本目录的实际条目,未改动;本项目的托管条目位于项目键 {keys:?},可在对应目录执行 `claude mcp remove lian-jing -s local` 清理" + )); return; } let (Some(claude_bin), Some(git_bash)) = (find_claude_bin(), find_git_bash()) else { - db::log_event( - CLIENT_SYNC_LOG, - "warn", - "claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步", - Some(&context), - ); + warn("claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步".into()); return; }; - let remove_error = if managed.is_empty() { - None - } else { + let effective = + match run_claude_cli(&claude_bin, &git_bash, Some(root), &["mcp", "get", MCP_KEY]) { + Ok(output) | Err(output) => parse_claude_mcp_get(&output), + }; + let Some(effective) = effective else { + warn("无法确认 Claude 在本目录实际生效的 lian-jing 条目(claude mcp get 输出无法识别),未改动".into()); + return; + }; + let remove = || { run_claude_cli( &claude_bin, &git_bash, Some(root), &["mcp", "remove", MCP_KEY, "--scope", "local"], ) - .err() }; - let outcome: Result<&str, String> = match &desired_entry { - None => match remove_error { - None => Ok("Claude 项目级 MCP 条目已移除"), - Some(error) => Err(error), - }, - Some(entry) => { - let payload = entry.to_string(); - match run_claude_cli( - &claude_bin, - &git_bash, - Some(root), - &["mcp", "add-json", MCP_KEY, payload.as_str(), "--scope", "local"], - ) { - Ok(()) => Ok("Claude 项目级 MCP 条目已写入"), - Err(error) if error.contains("already exists") => match remove_error { - None => Ok("本项目已有手写的 lian-jing local 条目,炼境不覆盖"), - Some(remove_error) => Err(format!("旧条目移除失败: {remove_error}")), + let add = |entry: &Value| { + let payload = entry.to_string(); + run_claude_cli( + &claude_bin, + &git_bash, + Some(root), + &[ + "mcp", + "add-json", + MCP_KEY, + payload.as_str(), + "--scope", + "local", + ], + ) + }; + let outcome: Result = match (effective, &desired_entry) { + (ClaudeEffective::Local(url), desired) => match lianjing_project_of(&url) { + Some(owner) if owner == project_id => match remove() { + Err(error) => Err(format!("旧条目移除失败: {error}")), + Ok(_) => match desired { + None => Ok("Claude 项目级 MCP 条目已移除".into()), + Some(entry) => match add(entry) { + Ok(_) => Ok("Claude 项目级 MCP 条目已刷新".into()), + Err(error) => { + // 回滚:用读到的原条目写回,避免项目失去 local 条目 + let restored = managed_here + .first() + .map(|(_, previous)| add(previous).is_ok()) + .unwrap_or(false); + Err(format!( + "新条目写入失败: {error}({})", + if restored { "已恢复原条目" } else { "原条目未能恢复" } + )) + } + }, }, - Err(error) => Err(error), + }, + Some(owner) => Err(format!( + "本目录生效的 lian-jing local 条目属于项目 {owner}(共享项目键,如 worktree 或嵌套项目),炼境不改动" + )), + None => Ok("本目录已有手写的 lian-jing local 条目,炼境不覆盖".into()), + }, + (ClaudeEffective::NotLocal, Some(entry)) => add(entry) + .map(|_| "Claude 项目级 MCP 条目已写入".into()) + .map_err(|error| format!("写入失败: {error}")), + (ClaudeEffective::NotLocal, None) => { + if managed_all.is_empty() { + return; } + let keys: Vec<&str> = managed_all.iter().map(|(key, _)| key.as_str()).collect(); + Err(format!( + "本目录没有生效的 local 条目,但其他项目键 {keys:?} 下残留本项目的托管条目,可在对应目录执行 `claude mcp remove lian-jing -s local` 清理" + )) } }; match outcome { - Ok(message) => db::log_event(CLIENT_SYNC_LOG, "info", message, Some(&context)), - Err(error) => db::log_event( - CLIENT_SYNC_LOG, - "warn", - &format!("Claude 项目级 MCP 同步失败: {error}"), - Some(&context), - ), + Ok(message) => db::log_event(CLIENT_SYNC_LOG, "info", &message, Some(&context)), + Err(error) => warn(format!("Claude 项目级 MCP 同步: {error}")), } } @@ -546,7 +650,9 @@ fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<( static MCP_OP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); fn mcp_op_lock() -> std::sync::MutexGuard<'static, ()> { - MCP_OP_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + MCP_OP_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) } // ── DSH 机器级配置托管块 ────────────────────────────────────────────────────── @@ -598,9 +704,11 @@ fn dsh_project_roots( .filter_map(|(id, win_path)| { let root = validate_registered_path(&win_path, "win_path").ok()?; // 每个项目只读一次 context.json,同时取 project_id 与 mcp_scope - let context = - read_context_object(&root.join(".lian-jing").join("context.json"), "canonical 项目上下文") - .ok()?; + let context = read_context_object( + &root.join(".lian-jing").join("context.json"), + "canonical 项目上下文", + ) + .ok()?; if context.get("project_id").and_then(Value::as_str) != Some(id.as_str()) { return None; } @@ -609,7 +717,9 @@ fn dsh_project_roots( return Some((key, None)); } let has_token = crate::credential_store::mcp_auth_digest(&id) - .and_then(|digest| crate::credential_store::read_mcp_auth_token_for_digest(store, &digest)) + .and_then(|digest| { + crate::credential_store::read_mcp_auth_token_for_digest(store, &digest) + }) .is_ok(); has_token.then(|| (key, Some(id))) }) @@ -619,7 +729,10 @@ fn dsh_project_roots( fn render_dsh_block(port: u16, roots: &[(String, Option)]) -> String { // 逐行显式缩进:YAML 依赖缩进,不能用字符串续行(会吞掉下一行行首空白)。 fn indent(spaces: usize, lines: &[&str]) -> Vec { - lines.iter().map(|line| format!("{}{line}", " ".repeat(spaces))).collect() + lines + .iter() + .map(|line| format!("{}{line}", " ".repeat(spaces))) + .collect() } // 值为 null 的键是控制面项目的停止哨兵:命中即停,回退全局地址。 let roots_map: serde_json::Map = roots @@ -637,12 +750,10 @@ fn render_dsh_block(port: u16, roots: &[(String, Option)]) -> String { "}", ]; let global_url = format!("http://127.0.0.1:{port}/mcp"); - let url_return = format!( - "return id ? '{global_url}/projects/' + encodeURIComponent(id) : '{global_url}';" - ); - let shim_line = format!( - "const shim = path.join(base, 'lian-jing', 'bin', '{MCP_HEADER_SHIM_NAME}');" - ); + let url_return = + format!("return id ? '{global_url}/projects/' + encodeURIComponent(id) : '{global_url}';"); + let shim_line = + format!("const shim = path.join(base, 'lian-jing', 'bin', '{MCP_HEADER_SHIM_NAME}');"); let header_body = [ "if (!id) return {};", "const base = process.env.LOCALAPPDATA, sys = process.env.SystemRoot;", @@ -724,7 +835,10 @@ fn has_manual_lian_jing(outside_block: &str) -> Result { /// 块外存在手写的 lian-jing 条目则拒绝覆盖(与 Codex 手工同名 server 的处理一致)。 fn upsert_dsh_block(current: &str, block: &str) -> Result { const MANUAL: &str = "DSH 补丁文件已手工定义 lian-jing,炼境不会覆盖;移除手写条目后重试"; - match (current.find(DSH_MANAGED_START), current.find(DSH_MANAGED_END)) { + match ( + current.find(DSH_MANAGED_START), + current.find(DSH_MANAGED_END), + ) { (Some(start), Some(end)) if end >= start => { let after = ¤t[end + DSH_MANAGED_END.len()..]; if has_manual_lian_jing(&format!("{}{}", ¤t[..start], after))? { @@ -766,7 +880,11 @@ pub fn ensure_dsh_patch( }; let block = render_dsh_block( port, - &dsh_project_roots(conn, &crate::credential_store::WindowsCredentialStore, exclude), + &dsh_project_roots( + conn, + &crate::credential_store::WindowsCredentialStore, + exclude, + ), ); let next = upsert_dsh_block(¤t, &block)?; if next != current { @@ -843,7 +961,12 @@ pub fn cleanup_legacy_global_registrations() -> Vec { .map(|out| out.status.success()) .unwrap_or(false); if ok { - db::log_event("mcp_inject", "info", "旧组级 MCP 注册已移除", Some(&json!({ "client": "claude-global", "server": &name }).to_string())); + db::log_event( + "mcp_inject", + "info", + "旧组级 MCP 注册已移除", + Some(&json!({ "client": "claude-global", "server": &name }).to_string()), + ); removed.push(name); } } @@ -1114,8 +1237,10 @@ pub fn migrate_to_single_server() { db::log_event("mcp_inject", level, &summary, None); // 全局注册失败说明 claude CLI 链路不通(CLI 缺失/git-bash 缺失/命令报错), - // 此时旧注册也清不掉——不落标记位,等下次启动重试(整个流程幂等) - if !register_ok { + // 此时旧注册也清不掉——不落标记位,等下次启动重试(整个流程幂等)。 + // try_register_globally 在已注册时会只读早返回,不再证明 CLI 可用,所以另外确认 CLI 与 git-bash 都在。 + let cli_available = find_claude_bin().is_some() && find_git_bash().is_some(); + if !register_ok || !cli_available { return; } if let Ok(conn) = db::pool().get() { @@ -1151,18 +1276,22 @@ pub fn reinject_all_mcp() -> Vec { // 快照取自锁外:锁内复查成员关系,已被移出组的项目不再重新注入(避免重建刚删除的 token) let still_member = db::pool() .get() - .ok() + .map_err(|error| error.to_string()) .and_then(|conn| { conn.query_row( "SELECT EXISTS(SELECT 1 FROM project_group_map WHERE project_id = ?1)", [&project_id], |row| row.get::<_, bool>(0), ) - .ok() - }) - .unwrap_or(false); - if !still_member { - continue; + .map_err(|error| error.to_string()) + }); + match still_member { + Ok(true) => {} + Ok(false) => continue, + Err(error) => { + errors.push(format!("项目 {project_id}: 复查组成员关系失败: {error}")); + continue; + } } if let Err(e) = inject_project_mcp_locked(&project_id, false) { errors.push(format!("项目 {}: {}", project_id, e)); @@ -1173,8 +1302,9 @@ pub fn reinject_all_mcp() -> Vec { errors } -/// 项目根目录或平台变更后调用:清理旧根下炼境托管的 Claude local 条目(直接编辑配置,旧目录不存在也能清理); -/// 已接入(有 token)的项目在新根重新注入(会刷新 DSH 白名单),未接入的只刷新白名单。 +/// 项目根目录或平台变更后调用:清理旧根下本项目的 Claude local 托管条目(旧目录已不存在时无法向 Claude +/// 确认实际条目,只记录残留与清理命令);已接入(有 token)的项目在新根重新注入(会刷新 DSH 白名单), +/// 未接入的只刷新白名单。新根的「已同步」判定只认新根或其祖先目录键下的条目,旧键下的残留不会挡住写入。 /// 重新注入会跑 claude CLI 做全局注册,调用方应放在后台线程。持锁执行:与并发的移除/删除串行, /// 移除先完成则 token 已不在,这里就不会再注入。 pub fn on_project_root_changed(project_id: &str, old_root: Option) { @@ -1336,9 +1466,15 @@ mod tests { "@echo off\r\n\"C:\\Apps\\Lian Jing\\app.exe\" --lianjing-mcp-header %1\r\nexit /b %ERRORLEVEL%\r\n" ); let unicode = render_header_shim(std::path::Path::new("C:\\炼境\\app.exe")); - assert!(unicode.contains("chcp 65001 >nul\r\n"), "非 ASCII 路径须切 UTF-8 代码页"); + assert!( + unicode.contains("chcp 65001 >nul\r\n"), + "非 ASCII 路径须切 UTF-8 代码页" + ); let percent = render_header_shim(std::path::Path::new("C:\\Users\\a%b\\app.exe")); - assert!(percent.contains("\"C:\\Users\\a%%b\\app.exe\" --lianjing-mcp-header %1"), "路径 % 须转义"); + assert!( + percent.contains("\"C:\\Users\\a%%b\\app.exe\" --lianjing-mcp-header %1"), + "路径 % 须转义" + ); } #[test] @@ -1351,7 +1487,13 @@ mod tests { crate::credential_store::get_or_create_mcp_auth_token(&store, id).unwrap(); } // notoken:已绑定但没有 token(例如退组后 token 已删),不得进入白名单 - for (id, scope, bound) in [("p1", None, true), ("ctrl", Some("global"), true), ("drift", None, false), ("gone", None, true), ("notoken", None, true)] { + for (id, scope, bound) in [ + ("p1", None, true), + ("ctrl", Some("global"), true), + ("drift", None, false), + ("gone", None, true), + ("notoken", None, true), + ] { let root = base.join(id); std::fs::create_dir_all(root.join(".lian-jing")).unwrap(); let mut ctx = json!({ "project_id": if bound { id } else { "someone-else" }, "schema_version": 1 }); @@ -1384,9 +1526,17 @@ mod tests { #[test] fn dsh_root_keys_match_js_normalization() { assert_eq!(normalize_dsh_root("E:\\Code\\Demo"), "e:\\code\\demo"); - assert_eq!(normalize_dsh_root("E:/Code//Demo/"), "e:\\code\\demo", "正斜杠、重复与尾部分隔符"); + assert_eq!( + normalize_dsh_root("E:/Code//Demo/"), + "e:\\code\\demo", + "正斜杠、重复与尾部分隔符" + ); assert_eq!(normalize_dsh_root("E:\\"), "e:", "盘符根与 JS 去尾后一致"); - assert_eq!(normalize_dsh_root("\\\\Server\\Share\\x"), "\\\\server\\share\\x", "保留 UNC 前缀"); + assert_eq!( + normalize_dsh_root("\\\\Server\\Share\\x"), + "\\\\server\\share\\x", + "保留 UNC 前缀" + ); } #[test] @@ -1397,7 +1547,10 @@ mod tests { ); assert_eq!(entry["type"], "http"); assert_eq!(entry["url"], "http://127.0.0.1:27190/mcp/projects/p1"); - assert!(entry["headersHelper"].as_str().unwrap().contains("%LOCALAPPDATA%")); + assert!(entry["headersHelper"] + .as_str() + .unwrap() + .contains("%LOCALAPPDATA%")); } #[test] @@ -1435,14 +1588,95 @@ mod tests { assert!(managed_claude_entries(&json!({}), "p 1").is_empty()); } + #[test] + fn claude_mcp_get_output_is_parsed_by_effective_scope() { + // 样本取自 claude 2.x 真实输出(2026-10-01) + let local = "lian-jing:\n Scope: Local config (private to you in this project)\n Status: ✔ Connected\n Type: http\n URL: http://127.0.0.1:27190/mcp/projects/p%201\n\nTo remove this server, run: claude mcp remove \"lian-jing\" -s local\n"; + assert_eq!( + parse_claude_mcp_get(local), + Some(ClaudeEffective::Local( + "http://127.0.0.1:27190/mcp/projects/p%201".into() + )) + ); + let user = "lian-jing:\r\n Scope: User config (available in all your projects)\r\n Type: sse\r\n URL: http://127.0.0.1:27190/mcp\r\n"; + assert_eq!(parse_claude_mcp_get(user), Some(ClaudeEffective::NotLocal)); + let missing = "No MCP server found with name: \"lian-jing\". Configured servers: a, b"; + assert_eq!( + parse_claude_mcp_get(missing), + Some(ClaudeEffective::NotLocal) + ); + assert_eq!( + parse_claude_mcp_get("Error: spawn EPERM"), + None, + "无法识别则按未知处理,不写入" + ); + assert_eq!( + parse_claude_mcp_get("lian-jing:\n Scope: Local config\n Type: stdio\n"), + None, + "local 条目缺 URL 时无法判定归属" + ); + } + + #[test] + fn lianjing_project_routes_are_recognized_and_decoded() { + assert_eq!( + lianjing_project_of("http://127.0.0.1:27190/mcp/projects/p%201"), + Some("p 1".into()) + ); + assert_eq!( + lianjing_project_of("http://127.0.0.1:1/mcp/projects/abc/"), + Some("abc".into()) + ); + assert_eq!( + lianjing_project_of("http://127.0.0.1:27190/mcp"), + None, + "全局路由不属于任何项目" + ); + assert_eq!( + lianjing_project_of("http://localhost:27190/mcp/projects/abc"), + None, + "非炼境地址视为手写" + ); + assert_eq!( + lianjing_project_of("https://example.com/mcp/projects/abc"), + None + ); + } + + #[test] + fn claude_key_covers_root_and_its_descendants_only() { + assert!(key_covers_root( + "E:/code-project/demo", + "E:\\code-project\\demo\\" + )); + assert!( + key_covers_root("e:\\code-project", "E:\\Code-Project\\demo"), + "git 根是祖先目录" + ); + assert!( + !key_covers_root("E:/code-project/demo", "E:\\code-project\\demo-2"), + "前缀相同的兄弟目录不算" + ); + assert!( + !key_covers_root("E:/code-project/demo/sub", "E:\\code-project\\demo"), + "子目录的键不覆盖父目录" + ); + } + #[test] fn project_root_change_detection() { let old = PathBuf::from("E:\\old"); let new = PathBuf::from("E:\\new"); assert!(project_root_changed(Some(&old), Some(&new))); - assert!(project_root_changed(None, Some(&new)), "旧根解析不出也要迁移"); + assert!( + project_root_changed(None, Some(&new)), + "旧根解析不出也要迁移" + ); assert!(!project_root_changed(Some(&old), Some(&old))); - assert!(!project_root_changed(Some(&old), None), "新根解析不出则不动"); + assert!( + !project_root_changed(Some(&old), None), + "新根解析不出则不动" + ); } #[test] @@ -1453,21 +1687,36 @@ mod tests { ]; let block = render_dsh_block(27190, &roots); assert!( - block.contains(r#"const roots = {"e:\\code-project":null,"e:\\code-project\\demo":"p1"};"#), + block.contains( + r#"const roots = {"e:\\code-project":null,"e:\\code-project\\demo":"p1"};"# + ), "控制面以 null 作停止哨兵" ); - assert!(block.contains("hasOwnProperty.call(roots, key)") && block.contains(r"replace(/\\+$/, '')")); - let parsed: serde_yaml::Value = serde_yaml::from_str(&block).expect("托管块必须是合法 YAML"); + assert!( + block.contains("hasOwnProperty.call(roots, key)") + && block.contains(r"replace(/\\+$/, '')") + ); + let parsed: serde_yaml::Value = + serde_yaml::from_str(&block).expect("托管块必须是合法 YAML"); let entry = &parsed[0]["insert"][0]; assert_eq!(entry["id"], "mcp-lian-jing"); assert_eq!(entry["config"]["serverName"], MCP_KEY); assert_eq!(entry["config"]["transport"], "streamable-http"); assert!(block.contains("url: !!js |") && block.contains("headers: !!js |")); - assert!(!block.contains("context.json"), "身份只认登记白名单,不读目录里的 context.json"); + assert!( + !block.contains("context.json"), + "身份只认登记白名单,不读目录里的 context.json" + ); assert!(block.contains("'http://127.0.0.1:27190/mcp/projects/' + encodeURIComponent(id)")); - assert!(block.contains(": 'http://127.0.0.1:27190/mcp';"), "未命中回退全局地址"); + assert!( + block.contains(": 'http://127.0.0.1:27190/mcp';"), + "未命中回退全局地址" + ); assert!(block.contains("path.isAbsolute(base)") && block.contains("'System32', 'cmd.exe'")); - assert!(block.contains(MCP_HEADER_SHIM_NAME) && block.contains("windowsVerbatimArguments: true")); + assert!( + block.contains(MCP_HEADER_SHIM_NAME) + && block.contains("windowsVerbatimArguments: true") + ); assert!(!block.contains("Bearer "), "托管块不得含任何 token"); } @@ -1481,19 +1730,36 @@ mod tests { let moved = upsert_dsh_block(&first, &render_dsh_block(28000, &[])).unwrap(); assert!(moved.contains("127.0.0.1:28000") && !moved.contains("127.0.0.1:27190")); - assert_eq!(moved.matches(DSH_MANAGED_START).count(), 1, "原位替换不重复追加"); - assert_eq!(upsert_dsh_block(&moved, &render_dsh_block(28000, &[])).unwrap(), moved, "幂等"); + assert_eq!( + moved.matches(DSH_MANAGED_START).count(), + 1, + "原位替换不重复追加" + ); + assert_eq!( + upsert_dsh_block(&moved, &render_dsh_block(28000, &[])).unwrap(), + moved, + "幂等" + ); for manual in [ "- insert:\n - id: mcp-lian-jing\n config:\n serverName: lian-jing\n", "- insert:\n - id: other\n config:\n serverName: 'lian-jing' # 手写\n", "- insert: [{id: mcp-lian-jing, config: {serverName: x}}]\n", ] { - assert!(upsert_dsh_block(manual, &render_dsh_block(27190, &[])).is_err(), "{manual}"); + assert!( + upsert_dsh_block(manual, &render_dsh_block(27190, &[])).is_err(), + "{manual}" + ); let with_block = format!("{manual}{}", render_dsh_block(27190, &[])); - assert!(upsert_dsh_block(&with_block, &render_dsh_block(27190, &[])).is_err(), "块外手写也须拒绝"); + assert!( + upsert_dsh_block(&with_block, &render_dsh_block(27190, &[])).is_err(), + "块外手写也须拒绝" + ); } - assert!(upsert_dsh_block("- insert: [unclosed\n", "b").is_err(), "无法解析时保守拒绝"); + assert!( + upsert_dsh_block("- insert: [unclosed\n", "b").is_err(), + "无法解析时保守拒绝" + ); assert!(upsert_dsh_block(&format!("{DSH_MANAGED_START}\n- x\n"), "b").is_err()); assert_eq!(upsert_dsh_block("", "BLOCK\n").unwrap(), "BLOCK\n"); }