From 5f6ea586f8862a0419387a602cf32e2c504c8317 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Thu, 1 Oct 2026 15:38:36 +0900 Subject: [PATCH] =?UTF-8?q?fix(mcp/parity):=20=E6=9C=80=E7=BB=88=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94Claude=20=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE=E5=86=99=E5=85=A5=E5=9B=9E=E5=BD=92=20claude=20CLI?= =?UTF-8?q?=EF=BC=8C=E5=8F=AA=E8=AF=BB=E6=89=AB=E6=8F=8F=E8=AE=A4=E9=A2=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 最终审查指出直接编辑 ~/.claude.json 须复刻 Claude 私有机制(git 根 / worktree→主仓库 / NFC 键推导、.claude.json.lock 锁协议、CLAUDE_CONFIG_DIR 与旧版 .config.json、BOM/符号链接/ fsync/键顺序),随 Claude 升级易失效,违背「标准优先、私有机制只进适配层」。 - 写入一律经 claude CLI(cwd=项目目录,同步、带 20 秒超时),由 Claude 自己负责键、锁与路径 - 只读扫描按 URL 中 /mcp/projects/ 认领炼境托管条目(不依赖键推导);已一致则不起 CLI (实测稳态注入 0.04 秒),手写条目不碰;项目目录已不存在时只记录残留、不写文件 - 全局注册只读确认已存在就跳过 CLI;配置路径按 Claude 规则解析,容忍 BOM - 批量重注入在锁内复查组成员关系,已移出组的项目不再注入、不重建 token Co-Authored-By: Claude Opus 5.5 --- docs/ai-context/project-context.yaml | 2 +- src-tauri/src/mcp_inject.rs | 489 ++++++++++++--------------- 2 files changed, 216 insertions(+), 275 deletions(-) diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 08a3cfb..b754030 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -6,7 +6,7 @@ contracts: - label: API 接口(Axum) paths: - "src-tauri/src/mcp_server.rs" - doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %LOCALAPPDATA%/lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe):Codex 写项目 .codex/config.toml,Claude 写 ~/.claude.json 的 local 条目(优先于 user 级全局;直接编辑该文件中本项目键下的 mcpServers.lian-jing,不经 claude CLI、不依赖项目目录存在,项目键用真实长路径;原子替换写入),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,!!js 从启动目录向上精确匹配根目录才选项目地址并取认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" + doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %LOCALAPPDATA%/lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe):Codex 写项目 .codex/config.toml,Claude 写 local 条目(优先于 user 级全局;一律经 claude CLI、cwd=项目目录写入,项目键推导、文件锁与配置路径交给 Claude 自身,不直接编辑 ~/.claude.json;先只读扫描、按 URL 中的项目路由认领炼境托管条目,已一致则不起 CLI;项目目录已不存在时只记录残留不写入),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,!!js 从启动目录向上精确匹配根目录才选项目地址并取认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" - label: MCP 工具接口 paths: diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 8233eef..9400d01 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -272,6 +272,10 @@ fn find_git_bash() -> Option { /// best-effort,失败仅记录日志;返回是否成功(同名已存在视为成功,作为 CLI 链路健康信号) fn try_register_globally(port: u16) -> bool { let server_url = format!("http://127.0.0.1:{}/mcp", port); + // 机器级、幂等:只读确认已注册就不起 CLI(每次注入都会走到这里,避免无谓的 1–2 秒阻塞) + if read_claude_config().is_some_and(|config| claude_global_registered(&config)) { + return true; + } let Some(claude_bin) = find_claude_bin() else { db::log_event( @@ -292,47 +296,23 @@ fn try_register_globally(port: u16) -> bool { return false; }; - match crate::silent_cmd(&claude_bin) - .args([ - "mcp", - "add", - "--transport", - "sse", - "-s", - "user", - MCP_KEY, - &server_url, - ]) - .env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash) - .output() - { - Ok(out) if out.status.success() => { + match run_claude_cli( + &claude_bin, + &git_bash, + None, + &["mcp", "add", "--transport", "sse", "-s", "user", MCP_KEY, &server_url], + ) { + Ok(()) => { db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(&global_log_context())); true } - Ok(out) => { - let combined = format!( - "{}{}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr) - ); - // 同名已存在(exit 1)不是故障——CLI 链路健康,注册目标已达成 - if combined.contains("already exists") { - return true; - } + // 同名已存在不是故障——CLI 链路健康,注册目标已达成 + Err(output) if output.contains("already exists") => true, + Err(output) => { db::log_event( "mcp_inject", "warn", - &format!("claude mcp add 非零退出: {}", combined), - Some(&global_log_context()), - ); - false - } - Err(e) => { - db::log_event( - "mcp_inject", - "warn", - &format!("执行 claude mcp add 失败: {}", e), + &format!("claude mcp add 失败: {output}"), Some(&global_log_context()), ); false @@ -370,146 +350,107 @@ fn claude_local_server_entry(server_url: &str, header_helper: &str) -> Value { }) } -#[derive(Debug, PartialEq)] -enum ClaudeLocalEntry { - Absent, - /// 炼境写入的条目(项目路由 + 稳定 helper),可安全替换或删除。 - Managed(Value), - /// 用户手写的同名条目:炼境不覆盖、不删除。 - Manual, +/// Claude 全局配置文件位置,与 Claude 的解析规则一致:旧版 `<配置目录>/.config.json` 存在则优先, +/// 否则为 `$CLAUDE_CONFIG_DIR`(或用户目录)下的 `.claude.json`。只用于只读检查。 +fn claude_config_path() -> Option { + let config_dir = std::env::var_os("CLAUDE_CONFIG_DIR").map(PathBuf::from); + let home = std::env::var_os("USERPROFILE").map(PathBuf::from)?; + let legacy = config_dir + .clone() + .unwrap_or_else(|| home.join(".claude")) + .join(".config.json"); + if legacy.is_file() { + return Some(legacy); + } + Some(config_dir.unwrap_or(home).join(".claude.json")) } -/// ~/.claude.json 的项目键是 Claude 记录的路径(实测为正斜杠盘符形式),比较时统一分隔符与大小写。 -fn normalize_root_key(path: &str) -> String { - path.replace('\\', "/").trim_end_matches('/').to_lowercase() +/// 只读读取 Claude 全局配置(容忍 BOM)。读不到或解析失败返回 None,调用方按「未知」保守处理。 +fn read_claude_config() -> Option { + let text = std::fs::read_to_string(claude_config_path()?).ok()?; + serde_json::from_str(text.trim_start_matches('\u{feff}')).ok() } -fn classify_claude_local_entry(claude_json: &Value, root: &std::path::Path) -> ClaudeLocalEntry { - let wanted = normalize_root_key(&root.display().to_string()); - let entry = claude_json - .get("projects") - .and_then(Value::as_object) - .and_then(|projects| { - projects - .iter() - .find(|(key, _)| normalize_root_key(key) == wanted) - .map(|(_, value)| value) +/// 只读扫描:所有项目键下指向本项目专用地址、且经稳定 helper 认证的炼境托管条目。按 URL 匹配, +/// 不复刻 Claude 的项目键推导(Claude 以 git 根为键、worktree 映射到主仓库根、路径做 NFC 归一化)。 +fn managed_claude_entries(config: &Value, project_id: &str) -> Vec<(String, Value)> { + let suffix = format!("/mcp/projects/{}", urlencoding::encode(project_id)); + let Some(projects) = config.get("projects").and_then(Value::as_object) else { + return Vec::new(); + }; + projects + .iter() + .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(""); + (url.starts_with("http://127.0.0.1:") + && url.ends_with(&suffix) + && helper.contains(MCP_HEADER_SHIM_NAME)) + .then(|| (key.clone(), entry.clone())) }) - .and_then(|project| project.get("mcpServers")) - .and_then(|servers| servers.get(MCP_KEY)); - let Some(entry) = entry else { - return ClaudeLocalEntry::Absent; - }; - let url = entry.get("url").and_then(Value::as_str).unwrap_or(""); - let helper = entry.get("headersHelper").and_then(Value::as_str).unwrap_or(""); - if url.starts_with("http://127.0.0.1:") - && url.contains("/mcp/projects/") - && helper.contains(MCP_HEADER_SHIM_NAME) - { - ClaudeLocalEntry::Managed(entry.clone()) - } else { - ClaudeLocalEntry::Manual - } + .collect() } -#[derive(Debug, PartialEq)] -enum ClaudeLocalOutcome { - Unchanged, - Registered, - Removed, - KeptManual, +fn claude_global_registered(config: &Value) -> bool { + config + .get("mcpServers") + .and_then(|servers| servers.get(MCP_KEY)) + .is_some() } -/// 纯函数:把期望状态写进 ~/.claude.json 的配置对象。只动本项目条目下的 mcpServers.lian-jing, -/// 项目对象里 Claude 自己维护的其他字段与其他 server 原样保留;手写条目不碰。 -fn apply_claude_local( - config: &mut Value, - root: &std::path::Path, - desired: Option, -) -> Result { - match (classify_claude_local_entry(config, root), &desired) { - (ClaudeLocalEntry::Manual, _) => return Ok(ClaudeLocalOutcome::KeptManual), - (ClaudeLocalEntry::Absent, None) => return Ok(ClaudeLocalOutcome::Unchanged), - (ClaudeLocalEntry::Managed(current), Some(entry)) if ¤t == entry => { - return Ok(ClaudeLocalOutcome::Unchanged) - } - _ => {} +/// claude CLI 单次调用上限:卡住的子进程会被终止,不让它拖住持锁的接入操作与界面。 +const CLAUDE_CLI_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); + +/// 运行 claude CLI(不弹窗、stdin 置空、带超时)。成功返回 Ok,失败返回合并后的输出。 +fn run_claude_cli( + claude_bin: &str, + git_bash: &str, + cwd: Option<&std::path::Path>, + args: &[&str], +) -> Result<(), String> { + use std::io::Read; + let mut command = crate::silent_cmd(claude_bin); + if let Some(dir) = cwd { + command.current_dir(dir); } - let wanted = normalize_root_key(&root.display().to_string()); - let projects = config - .as_object_mut() - .ok_or("~/.claude.json 顶层不是 JSON 对象")? - .entry("projects") - .or_insert_with(|| json!({})) - .as_object_mut() - .ok_or("~/.claude.json 的 projects 不是 JSON 对象")?; - // 复用 Claude 已记录的键(大小写、分隔符以它为准),没有才按它的惯例(正斜杠)新建 - let key = projects - .keys() - .find(|key| normalize_root_key(key) == wanted) - .cloned() - .unwrap_or_else(|| root.display().to_string().replace('\\', "/")); - match desired { - Some(entry) => { - projects - .entry(key) - .or_insert_with(|| json!({})) - .as_object_mut() - .ok_or("~/.claude.json 的项目条目不是 JSON 对象")? - .entry("mcpServers") - .or_insert_with(|| json!({})) - .as_object_mut() - .ok_or("~/.claude.json 的 mcpServers 不是 JSON 对象")? - .insert(MCP_KEY.to_string(), entry); - Ok(ClaudeLocalOutcome::Registered) - } - None => { - if let Some(servers) = projects - .get_mut(&key) - .and_then(|project| project.get_mut("mcpServers")) - .and_then(Value::as_object_mut) - { - servers.remove(MCP_KEY); + let mut child = command + .args(args) + .env("CLAUDE_CODE_GIT_BASH_PATH", git_bash) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|error| format!("执行 claude CLI 失败: {error}"))?; + let deadline = std::time::Instant::now() + CLAUDE_CLI_TIMEOUT; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(100)); } - Ok(ClaudeLocalOutcome::Removed) - } - } -} - -/// 同目录临时文件 + 重命名替换:读者只会看到完整的旧文件或新文件。目标被短暂占用时重试几次。 -fn write_file_atomic(path: &std::path::Path, content: &str) -> Result<(), String> { - let tmp = path.with_file_name(format!( - "{}.lianjing-tmp", - path.file_name().and_then(|name| name.to_str()).unwrap_or("config") - )); - std::fs::write(&tmp, content).map_err(|error| format!("写临时文件失败: {error}"))?; - let mut last_error = None; - for _ in 0..5 { - match std::fs::rename(&tmp, path) { - Ok(()) => return Ok(()), - Err(error) => { - last_error = Some(error); - std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "claude CLI 超过 {} 秒未结束,已终止", + CLAUDE_CLI_TIMEOUT.as_secs() + )); } + Err(error) => return Err(format!("等待 claude CLI 失败: {error}")), } - } - let _ = std::fs::remove_file(&tmp); - Err(format!("替换文件失败: {}", last_error.map(|e| e.to_string()).unwrap_or_default())) -} - -/// Claude 以真实长路径记录项目(8.3 短名与大小写都以文件系统为准;2026-10-01 实测短路径键不会被识别)。 -/// 目录存在时规范化为长路径(去掉 \\?\ 前缀),不存在(已移动或删除)时原样使用。 -fn claude_project_path(root: &std::path::Path) -> PathBuf { - let Ok(canonical) = std::fs::canonicalize(root) else { - return root.to_path_buf(); }; - let text = canonical.display().to_string(); - if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { - PathBuf::from(format!(r"\\{rest}")) - } else if let Some(rest) = text.strip_prefix(r"\\?\") { - PathBuf::from(rest) + let mut output = String::new(); + if let Some(mut stdout) = child.stdout.take() { + let _ = stdout.read_to_string(&mut output); + } + if let Some(mut stderr) = child.stderr.take() { + let _ = stderr.read_to_string(&mut output); + } + if status.success() { + Ok(()) } else { - canonical + Err(output) } } @@ -517,48 +458,84 @@ fn claude_project_path(root: &std::path::Path) -> PathBuf { const CLIENT_SYNC_LOG: &str = "mcp_client_sync"; /// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除炼境托管的条目。 -/// 直接编辑 ~/.claude.json(Claude Code 文档列出的配置方式之一),不经 claude CLI:不依赖项目目录 -/// 存在(目录已删除或移动也能清理旧条目)、不起子进程、毫秒级完成,因此可在调用方同步执行。 -/// Claude 自己也会写这个文件,读改写窗口压到最小(紧挨写入前读取 + 原子替换)。best-effort,只记日志。 -/// 调用方须持有 mcp_op_lock,保证本进程内对该文件的读改写串行。 +/// +/// 写入一律交给 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,并在删除本地文件夹之前同步调用(避免与目录删除竞态)。 fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(String, String)>) { - let Ok(home) = std::env::var("USERPROFILE") else { - return; - }; - let path = PathBuf::from(home).join(".claude.json"); - if !path.is_file() { - // 本机从未运行过 Claude Code:不替它创建配置 + let context = inject_log_context(project_id, "claude"); + let managed = read_claude_config() + .map(|config| managed_claude_entries(&config, project_id)) + .unwrap_or_default(); + 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, + _ => {} + } + 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), + ); return; } - let root = claude_project_path(root); - let root = root.as_path(); - let result = (|| -> Result { - let text = std::fs::read_to_string(&path).map_err(|error| format!("读取失败: {error}"))?; - let mut config: Value = - serde_json::from_str(&text).map_err(|error| format!("解析失败,炼境不写入: {error}"))?; - let entry = desired.map(|(url, helper)| claude_local_server_entry(&url, &helper)); - let outcome = apply_claude_local(&mut config, root, entry)?; - if matches!(outcome, ClaudeLocalOutcome::Registered | ClaudeLocalOutcome::Removed) { - let content = serde_json::to_string_pretty(&config).map_err(|error| error.to_string())?; - write_file_atomic(&path, &content)?; - } - Ok(outcome) - })(); - let context = inject_log_context(project_id, "claude"); - match result { - Ok(ClaudeLocalOutcome::Unchanged) => {} - Ok(outcome) => { - let message = match outcome { - ClaudeLocalOutcome::Registered => "Claude 项目级 MCP 条目已写入", - ClaudeLocalOutcome::Removed => "Claude 项目级 MCP 条目已移除", - _ => "本项目已有手写的 lian-jing local 条目,炼境不覆盖", - }; - db::log_event(CLIENT_SYNC_LOG, "info", message, Some(&context)); + 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), + ); + return; + }; + let remove_error = if managed.is_empty() { + None + } else { + 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}")), + }, + Err(error) => Err(error), + } } + }; + 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 同步失败(~/.claude.json): {error}"), + &format!("Claude 项目级 MCP 同步失败: {error}"), Some(&context), ), } @@ -1171,6 +1148,22 @@ pub fn reinject_all_mcp() -> Vec { let mut errors = Vec::new(); for project_id in project_ids { let _guard = mcp_op_lock(); + // 快照取自锁外:锁内复查成员关系,已被移出组的项目不再重新注入(避免重建刚删除的 token) + let still_member = db::pool() + .get() + .ok() + .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; + } if let Err(e) = inject_project_mcp_locked(&project_id, false) { errors.push(format!("项目 {}: {}", project_id, e)); } @@ -1348,27 +1341,6 @@ mod tests { assert!(percent.contains("\"C:\\Users\\a%%b\\app.exe\" --lianjing-mcp-header %1"), "路径 % 须转义"); } - #[test] - fn claude_local_entry_classification_protects_manual_entries() { - let root = std::path::Path::new("E:\\code-project\\Demo"); - let config = |entry: Value| json!({ "projects": { "e:/code-project/demo": { "mcpServers": { "lian-jing": entry } } } }); - assert_eq!(classify_claude_local_entry(&json!({}), root), ClaudeLocalEntry::Absent); - let managed = json!({ - "type": "http", - "url": "http://127.0.0.1:27190/mcp/projects/p1", - "headersHelper": shim_helper_command("d1"), - }); - assert_eq!( - classify_claude_local_entry(&config(managed.clone()), root), - ClaudeLocalEntry::Managed(managed), - "键名大小写与分隔符差异须视为同一项目" - ); - let manual = json!({ "type": "http", "url": "https://example.invalid/mcp" }); - assert_eq!(classify_claude_local_entry(&config(manual), root), ClaudeLocalEntry::Manual); - let other_project = json!({ "projects": { "e:/code-project/other": { "mcpServers": { "lian-jing": {} } } } }); - assert_eq!(classify_claude_local_entry(&other_project, root), ClaudeLocalEntry::Absent); - } - #[test] fn global_scope_projects_are_excluded_and_roots_follow_registry() { let base = std::env::temp_dir().join(format!("lianjing_dsh_roots_{}", std::process::id())); @@ -1429,69 +1401,38 @@ mod tests { } #[test] - fn claude_local_direct_edit_touches_only_lian_jing_entry() { - let root = std::path::Path::new("E:\\code-project\\Demo"); - let entry = claude_local_server_entry( - "http://127.0.0.1:27190/mcp/projects/p1", + fn managed_claude_entries_match_by_project_url_under_any_key() { + let managed = claude_local_server_entry( + "http://127.0.0.1:27190/mcp/projects/p%201", &shim_helper_command("d1"), ); - // 已有项目键(大小写、分隔符与 root 不同):复用该键,保留 Claude 自己的字段与其他 server - let mut config = json!({ - "numStartups": 3, - "projects": { "e:/code-project/demo": { - "lastCost": 1.5, - "mcpServers": { "other": { "type": "stdio" } } - } } + let moved_port = claude_local_server_entry( + "http://127.0.0.1:28000/mcp/projects/p%201", + &shim_helper_command("d1"), + ); + let config = json!({ + "mcpServers": { "lian-jing": { "type": "sse", "url": "http://127.0.0.1:27190/mcp" } }, + "projects": { + // Claude 以 git 根 / 主仓库根为键:条目可能在任意键下,按 URL 认领 + "E:/repos/main": { "mcpServers": { "lian-jing": managed.clone() } }, + "E:/repos/other": { "mcpServers": { "lian-jing": moved_port.clone() } }, + "E:/repos/p10": { "mcpServers": { "lian-jing": claude_local_server_entry( + "http://127.0.0.1:27190/mcp/projects/p%2010", &shim_helper_command("d2")) } }, + "E:/repos/manual": { "mcpServers": { "lian-jing": { "type": "http", "url": "http://127.0.0.1:27190/mcp/projects/p%201" } } }, + "E:/repos/none": { "lastCost": 1 } + } }); + let found = managed_claude_entries(&config, "p 1"); + let keys: Vec<&str> = found.iter().map(|(key, _)| key.as_str()).collect(); assert_eq!( - apply_claude_local(&mut config, root, Some(entry.clone())).unwrap(), - ClaudeLocalOutcome::Registered + keys, + vec!["E:/repos/main", "E:/repos/other"], + "端口变化仍认领;前缀相同的他项目、无 shim 的手写条目不认领" ); - let project = &config["projects"]["e:/code-project/demo"]; - assert_eq!(config["projects"].as_object().unwrap().len(), 1, "不新增重复键"); - assert_eq!(project["lastCost"], 1.5); - assert_eq!(project["mcpServers"]["other"]["type"], "stdio"); - assert_eq!(project["mcpServers"][MCP_KEY], entry); - assert_eq!(config["numStartups"], 3); - assert_eq!( - apply_claude_local(&mut config, root, Some(entry.clone())).unwrap(), - ClaudeLocalOutcome::Unchanged, - "相同条目不重写" - ); - - assert_eq!(apply_claude_local(&mut config, root, None).unwrap(), ClaudeLocalOutcome::Removed); - let project = &config["projects"]["e:/code-project/demo"]; - assert!(project["mcpServers"].get(MCP_KEY).is_none()); - assert_eq!(project["mcpServers"]["other"]["type"], "stdio", "移除只删 lian-jing"); - assert_eq!(apply_claude_local(&mut config, root, None).unwrap(), ClaudeLocalOutcome::Unchanged); - - // 尚无项目键:按 Claude 惯例(正斜杠)新建 - let mut empty = json!({}); - apply_claude_local(&mut empty, root, Some(entry.clone())).unwrap(); - assert_eq!(empty["projects"]["E:/code-project/Demo"]["mcpServers"][MCP_KEY], entry); - - // 手写条目:注册与移除都不碰 - let manual = json!({ "type": "http", "url": "https://example.invalid/mcp" }); - let mut handwritten = json!({ "projects": { "E:/code-project/Demo": { "mcpServers": { "lian-jing": manual.clone() } } } }); - assert_eq!( - apply_claude_local(&mut handwritten, root, Some(entry)).unwrap(), - ClaudeLocalOutcome::KeptManual - ); - assert_eq!(apply_claude_local(&mut handwritten, root, None).unwrap(), ClaudeLocalOutcome::KeptManual); - assert_eq!(handwritten["projects"]["E:/code-project/Demo"]["mcpServers"][MCP_KEY], manual); - } - - #[test] - fn claude_project_path_is_long_form_without_verbatim_prefix() { - let dir = std::env::temp_dir().join(format!("lianjing_claude_path_{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let resolved = claude_project_path(&dir).display().to_string(); - assert!(!resolved.starts_with(r"\\?\"), "不得带 verbatim 前缀:{resolved}"); - assert!(!resolved.contains('~'), "8.3 短名须展开为长路径:{resolved}"); - assert!(std::path::Path::new(&resolved).is_dir()); - let missing = std::path::Path::new("E:\\definitely\\missing\\root"); - assert_eq!(claude_project_path(missing), missing.to_path_buf(), "不存在时原样返回"); - let _ = std::fs::remove_dir_all(&dir); + assert_eq!(found[0].1, managed); + assert!(claude_global_registered(&config)); + assert!(!claude_global_registered(&json!({ "projects": {} }))); + assert!(managed_claude_entries(&json!({}), "p 1").is_empty()); } #[test]