fix(mcp/parity): 第三轮审查修复——Claude 条目改为直接编辑配置,接入操作加进程锁

- Claude local 条目不再经 claude CLI(cwd=项目目录)+ 后台队列管理,改为直接编辑
  ~/.claude.json 中本项目键下的 mcpServers.lian-jing:同步、毫秒级、原子替换,只动该字段;
  不依赖项目目录存在,消除删除本地文件夹时的竞态、目录移动后旧条目无法清理、队列丢任务/卡死
- 项目键用真实长路径(实测 8.3 短路径键不被 Claude 识别);手写条目不碰
- 注入、移除、根目录迁移在进程锁内串行;移除时最先删 token,后续清理收集错误统一返回
- 根目录迁移:旧根解析不出也迁移;重新注入失败归入 mcp_inject 并补刷 DSH;DSH 补丁写入加锁

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lanrtop 2026-10-01 14:37:37 +09:00
parent 7b702a9d88
commit fc8492b88b
3 changed files with 275 additions and 126 deletions

View File

@ -6,7 +6,7 @@ contracts:
- label: API 接口(Axum) - label: API 接口(Axum)
paths: paths:
- "src-tauri/src/mcp_server.rs" - "src-tauri/src/mcp_server.rs"
doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/<project_id> 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 级全局),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/<project_id> 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 按钮体系文档。"
- label: MCP 工具接口 - label: MCP 工具接口
paths: paths:

View File

@ -304,11 +304,11 @@ pub fn update_project(id: String, input: UpdateProjectInput) -> Result<(), Strin
// 根目录变化:Claude local 条目按目录挂载、DSH 白名单按根目录匹配,都要迁移到新根。 // 根目录变化:Claude local 条目按目录挂载、DSH 白名单按根目录匹配,都要迁移到新根。
// 会调用 claude CLI,放到后台线程,避免同步命令卡住界面。 // 会调用 claude CLI,放到后台线程,避免同步命令卡住界面。
if let Some(old_root) = old_root { if root_may_change {
let new_root = crate::mcp_inject::project_root_with_conn(&conn, &id).ok(); let new_root = crate::mcp_inject::project_root_with_conn(&conn, &id).ok();
if new_root.as_ref() != Some(&old_root) { if crate::mcp_inject::project_root_changed(old_root.as_ref(), new_root.as_ref()) {
drop(conn); drop(conn);
std::thread::spawn(move || crate::mcp_inject::on_project_root_changed(&id, Some(old_root))); std::thread::spawn(move || crate::mcp_inject::on_project_root_changed(&id, old_root));
} }
} }

View File

@ -362,19 +362,18 @@ fn global_log_context() -> String {
} }
/// Claude Code local scope 条目:项目专用地址 + 稳定 helper。local 优先于 user 级全局注册且不进仓库。 /// Claude Code local scope 条目:项目专用地址 + 稳定 helper。local 优先于 user 级全局注册且不进仓库。
fn claude_local_server_json(server_url: &str, header_helper: &str) -> String { fn claude_local_server_entry(server_url: &str, header_helper: &str) -> Value {
json!({ json!({
"type": "http", "type": "http",
"url": server_url, "url": server_url,
"headersHelper": header_helper, "headersHelper": header_helper,
}) })
.to_string()
} }
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
enum ClaudeLocalEntry { enum ClaudeLocalEntry {
Absent, Absent,
/// 炼境写入的条目(项目路由 + 稳定 helper),可安全替换或删除;附原值供失败回滚。 /// 炼境写入的条目(项目路由 + 稳定 helper),可安全替换或删除。
Managed(Value), Managed(Value),
/// 用户手写的同名条目:炼境不覆盖、不删除。 /// 用户手写的同名条目:炼境不覆盖、不删除。
Manual, Manual,
@ -413,128 +412,166 @@ fn classify_claude_local_entry(claude_json: &Value, root: &std::path::Path) -> C
} }
} }
fn read_claude_user_config() -> Option<Value> { #[derive(Debug, PartialEq)]
let home = std::env::var("USERPROFILE").ok()?; enum ClaudeLocalOutcome {
let text = std::fs::read_to_string(PathBuf::from(home).join(".claude.json")).ok()?; Unchanged,
serde_json::from_str(&text).ok() Registered,
Removed,
KeptManual,
} }
fn run_claude_cli( /// 纯函数:把期望状态写进 ~/.claude.json 的配置对象。只动本项目条目下的 mcpServers.lian-jing,
claude_bin: &str, /// 项目对象里 Claude 自己维护的其他字段与其他 server 原样保留;手写条目不碰。
git_bash: &str, fn apply_claude_local(
config: &mut Value,
root: &std::path::Path, root: &std::path::Path,
args: &[&str], desired: Option<Value>,
) -> Result<(), String> { ) -> Result<ClaudeLocalOutcome, String> {
let out = crate::silent_cmd(claude_bin) match (classify_claude_local_entry(config, root), &desired) {
.current_dir(root) (ClaudeLocalEntry::Manual, _) => return Ok(ClaudeLocalOutcome::KeptManual),
.args(args) (ClaudeLocalEntry::Absent, None) => return Ok(ClaudeLocalOutcome::Unchanged),
.env("CLAUDE_CODE_GIT_BASH_PATH", git_bash) (ClaudeLocalEntry::Managed(current), Some(entry)) if &current == entry => {
.output() return Ok(ClaudeLocalOutcome::Unchanged)
.map_err(|error| format!("执行 claude CLI 失败: {error}"))?; }
if out.status.success() { _ => {}
Ok(()) }
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);
}
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));
}
}
}
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)
} else { } else {
Err(format!( canonical
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
))
} }
} }
/// 客户端侧同步(Claude local、DSH 托管块)的日志单独归类:不覆盖诊断里每个项目的「最后一次注入结果」。 /// 客户端侧同步(Claude local、DSH 托管块)的日志单独归类:不覆盖诊断里每个项目的「最后一次注入结果」。
const CLIENT_SYNC_LOG: &str = "mcp_client_sync"; const CLIENT_SYNC_LOG: &str = "mcp_client_sync";
type ClaudeSyncJob = (String, PathBuf, Option<(String, String)>);
static CLAUDE_SYNC_QUEUE: std::sync::OnceLock<std::sync::Mutex<std::sync::mpsc::Sender<ClaudeSyncJob>>> =
std::sync::OnceLock::new();
/// 所有 Claude local 同步投递到同一个后台线程,按提交顺序串行执行:避免多个 claude CLI 并发
/// 读改写 ~/.claude.json,也保证「注入后立刻移除」时移除不会被更早的注册越过。调用方不阻塞。
fn enqueue_claude_sync(project_id: &str, root: PathBuf, desired: Option<(String, String)>) {
let queue = CLAUDE_SYNC_QUEUE.get_or_init(|| {
let (sender, receiver) = std::sync::mpsc::channel::<ClaudeSyncJob>();
std::thread::spawn(move || {
for (project_id, root, desired) in receiver {
sync_claude_local(&project_id, &root, desired);
}
});
std::sync::Mutex::new(sender)
});
if let Ok(sender) = queue.lock() {
let _ = sender.send((project_id.to_string(), root, desired));
}
}
/// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除炼境托管的条目。 /// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除炼境托管的条目。
/// 手写条目一律不碰;替换失败时用原值回滚。best-effort,结果只记日志。只应经 enqueue_claude_sync 调用。 /// 直接编辑 ~/.claude.json(Claude Code 文档列出的配置方式之一),不经 claude CLI:不依赖项目目录
/// 存在(目录已删除或移动也能清理旧条目)、不起子进程、毫秒级完成,因此可在调用方同步执行。
/// Claude 自己也会写这个文件,读改写窗口压到最小(紧挨写入前读取 + 原子替换)。best-effort,只记日志。
/// 调用方须持有 mcp_op_lock,保证本进程内对该文件的读改写串行。
fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(String, String)>) { 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:不替它创建配置
return;
}
let root = claude_project_path(root);
let root = root.as_path();
let result = (|| -> Result<ClaudeLocalOutcome, String> {
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"); let context = inject_log_context(project_id, "claude");
let state = read_claude_user_config() match result {
.map(|config| classify_claude_local_entry(&config, root)) Ok(ClaudeLocalOutcome::Unchanged) => {}
.unwrap_or(ClaudeLocalEntry::Absent); Ok(outcome) => {
if matches!((&state, &desired), (ClaudeLocalEntry::Absent, None)) { let message = match outcome {
return; ClaudeLocalOutcome::Registered => "Claude 项目级 MCP 条目已写入",
} ClaudeLocalOutcome::Removed => "Claude 项目级 MCP 条目已移除",
let (Some(claude_bin), Some(git_bash)) = (find_claude_bin(), find_git_bash()) else { _ => "本项目已有手写的 lian-jing local 条目,炼境不覆盖",
db::log_event(
CLIENT_SYNC_LOG,
"warn",
"claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步",
Some(&context),
);
return;
}; };
let remove_args = ["mcp", "remove", MCP_KEY, "--scope", "local"]; db::log_event(CLIENT_SYNC_LOG, "info", message, Some(&context));
let outcome = match (state, desired) {
(ClaudeLocalEntry::Manual, _) => {
Ok("本项目已有手写的 lian-jing local 条目,炼境不覆盖".to_string())
} }
(ClaudeLocalEntry::Absent, None) => return,
(ClaudeLocalEntry::Managed(_), None) => {
run_claude_cli(&claude_bin, &git_bash, root, &remove_args)
.map(|_| "Claude 项目级 MCP 条目已移除".to_string())
}
(previous, Some((server_url, header_helper))) => {
let payload = claude_local_server_json(&server_url, &header_helper);
let add_args = ["mcp", "add-json", MCP_KEY, payload.as_str(), "--scope", "local"];
let registered = if let ClaudeLocalEntry::Managed(old) = &previous {
if let Err(error) = run_claude_cli(&claude_bin, &git_bash, root, &remove_args) {
db::log_event(
CLIENT_SYNC_LOG,
"warn",
&format!("Claude 项目级 MCP 旧条目移除失败: {error}"),
Some(&context),
);
return;
}
run_claude_cli(&claude_bin, &git_bash, root, &add_args).or_else(|error| {
let restore = old.to_string();
let _ = run_claude_cli(
&claude_bin,
&git_bash,
root,
&["mcp", "add-json", MCP_KEY, restore.as_str(), "--scope", "local"],
);
Err(format!("{error}(已尝试恢复原条目)"))
})
} else {
run_claude_cli(&claude_bin, &git_bash, root, &add_args)
};
registered.map(|_| "Claude 项目级 MCP 注册成功".to_string())
}
};
match outcome {
Ok(message) => db::log_event(CLIENT_SYNC_LOG, "info", &message, Some(&context)),
Err(error) => db::log_event( Err(error) => db::log_event(
CLIENT_SYNC_LOG, CLIENT_SYNC_LOG,
"warn", "warn",
&format!("Claude 项目级 MCP 同步失败: {error}"), &format!("Claude 项目级 MCP 同步失败(~/.claude.json): {error}"),
Some(&context), Some(&context),
), ),
} }
} }
/// 进程内所有「炼境 MCP 接入」写操作(注入、移除、根目录迁移)串行执行:
/// 检查 token → 写项目文件 → 写客户端配置作为一个整体,不与其他接入操作交错。
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())
}
// ── DSH 机器级配置托管块 ────────────────────────────────────────────────────── // ── DSH 机器级配置托管块 ──────────────────────────────────────────────────────
// DSH 配置无法按项目拆分,但 !!js 在加载时以启动目录求值。项目身份只认炼境登记表:托管块内嵌 // DSH 配置无法按项目拆分,但 !!js 在加载时以启动目录求值。项目身份只认炼境登记表:托管块内嵌
// 「已登记项目根目录 → project_id」白名单(非秘密),从启动目录向上精确匹配根目录才连项目专用地址 // 「已登记项目根目录 → project_id」白名单(非秘密),从启动目录向上精确匹配根目录才连项目专用地址
@ -761,8 +798,14 @@ pub fn ensure_dsh_patch(
Ok(true) Ok(true)
} }
/// DSH 补丁文件的读-算-写在本进程内串行(启动线程与注入/移除可能同时刷新)。
static DSH_PATCH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// 刷新 DSH 托管块的 best-effort 包装:失败只记日志。 /// 刷新 DSH 托管块的 best-effort 包装:失败只记日志。
pub fn refresh_dsh_patch(exclude: Option<&str>) { pub fn refresh_dsh_patch(exclude: Option<&str>) {
let _guard = DSH_PATCH_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let result = db::pool() let result = db::pool()
.get() .get()
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())
@ -948,11 +991,13 @@ fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> {
/// 向项目的 .claude/settings.json 注入单一 lian-jing MCP 配置, /// 向项目的 .claude/settings.json 注入单一 lian-jing MCP 配置,
/// 并清理旧组级 key(迁移内建于重注入) /// 并清理旧组级 key(迁移内建于重注入)
pub fn inject_project_mcp(project_id: &str) -> Result<(), String> { pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
inject_project_mcp_with(project_id, true) let _guard = mcp_op_lock();
inject_project_mcp_locked(project_id, true)
} }
/// refresh_dsh=false 供批量重注入使用:逐项目跳过 DSH 刷新,由调用方在末尾统一刷新一次。 /// 调用方须持有 mcp_op_lock。refresh_dsh=false 供批量重注入使用:逐项目跳过 DSH 刷新,
fn inject_project_mcp_with(project_id: &str, refresh_dsh: bool) -> Result<(), String> { /// 由调用方在末尾统一刷新一次。
fn inject_project_mcp_locked(project_id: &str, refresh_dsh: bool) -> Result<(), String> {
let port = crate::mcp_server::read_port(); let port = crate::mcp_server::read_port();
let server_url = crate::mcp_server::scoped_mcp_url(port, project_id); let server_url = crate::mcp_server::scoped_mcp_url(port, project_id);
let digest = crate::credential_store::mcp_auth_digest(project_id)?; let digest = crate::credential_store::mcp_auth_digest(project_id)?;
@ -999,8 +1044,7 @@ fn inject_project_mcp_with(project_id: &str, refresh_dsh: bool) -> Result<(), St
try_register_globally(port); try_register_globally(port);
let root = project_root(project_id)?; let root = project_root(project_id)?;
let desired = (!project_wants_global_scope(&root)).then(|| (server_url, header_helper)); let desired = (!project_wants_global_scope(&root)).then(|| (server_url, header_helper));
// claude CLI 每次调用约 1-2 秒:投递到串行后台队列,不阻塞界面,也不与其他同步竞态 sync_claude_local(project_id, &root, desired);
enqueue_claude_sync(project_id, root, desired);
if refresh_dsh { if refresh_dsh {
refresh_dsh_patch(None); refresh_dsh_patch(None);
} }
@ -1010,13 +1054,34 @@ fn inject_project_mcp_with(project_id: &str, refresh_dsh: bool) -> Result<(), St
/// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。 /// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。
/// 调用方需自行保证项目已不属于任何产品组时才调用。 /// 调用方需自行保证项目已不属于任何产品组时才调用。
pub fn remove_project_mcp(project_id: &str) -> Result<(), String> { pub fn remove_project_mcp(project_id: &str) -> Result<(), String> {
// 先于登记删除算出根目录并排队清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。 let _guard = mcp_op_lock();
// 与注册共用同一串行队列,保证移除不会被更早提交的注册越过。 // 先于登记删除、也先于删除本地文件夹同步清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。
if let Ok(root) = project_root(project_id) { if let Ok(root) = project_root(project_id) {
enqueue_claude_sync(project_id, root, None); sync_claude_local(project_id, &root, None);
} }
// 登记此时仍在,用 exclude 立即剔除;token 随后被删,之后的刷新也不会再把它加回白名单 // token 最先删:DSH 白名单剔除与根目录迁移判定都以 token 为准,后面的文件清理即使失败也不影响这一点
let token_removed = crate::credential_store::delete_mcp_auth_token_for_project(
&crate::credential_store::WindowsCredentialStore,
project_id,
);
// 登记此时仍在,exclude 立即剔除;token 已删,之后的刷新也不会再把它加回白名单
refresh_dsh_patch(Some(project_id)); refresh_dsh_patch(Some(project_id));
let mut errors: Vec<String> = Vec::new();
if let Err(error) = remove_project_files_mcp(project_id) {
errors.push(error);
}
if let Err(error) = token_removed {
errors.push(error);
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join(";"))
}
}
/// 清理项目文件中的炼境 MCP 配置(Codex 托管块与 .claude/settings.json 兼容条目)。
fn remove_project_files_mcp(project_id: &str) -> Result<(), String> {
remove_codex_mcp_config(&codex_config_path(project_id)?)?; remove_codex_mcp_config(&codex_config_path(project_id)?)?;
let path = settings_path(project_id)?; let path = settings_path(project_id)?;
if path.exists() { if path.exists() {
@ -1030,10 +1095,7 @@ pub fn remove_project_mcp(project_id: &str) -> Result<(), String> {
} }
write_json(&path, &settings)?; write_json(&path, &settings)?;
} }
crate::credential_store::delete_mcp_auth_token_for_project( Ok(())
&crate::credential_store::WindowsCredentialStore,
project_id,
)
} }
/// 单一服务器化一次性迁移:批量重注入所有托管项目(换新 key + 清旧 key + liangjing.json 瘦身) /// 单一服务器化一次性迁移:批量重注入所有托管项目(换新 key + 清旧 key + liangjing.json 瘦身)
@ -1108,7 +1170,8 @@ pub fn reinject_all_mcp() -> Vec<String> {
let mut errors = Vec::new(); let mut errors = Vec::new();
for project_id in project_ids { for project_id in project_ids {
if let Err(e) = inject_project_mcp_with(&project_id, false) { let _guard = mcp_op_lock();
if let Err(e) = inject_project_mcp_locked(&project_id, false) {
errors.push(format!("项目 {}: {}", project_id, e)); errors.push(format!("项目 {}: {}", project_id, e));
} }
} }
@ -1117,11 +1180,14 @@ pub fn reinject_all_mcp() -> Vec<String> {
errors errors
} }
/// 项目根目录或平台变更后调用:排队清理旧根下炼境托管的 Claude local 条目;已接入(有 token)的项目 /// 项目根目录或平台变更后调用:清理旧根下炼境托管的 Claude local 条目(直接编辑配置,旧目录不存在也能清理);
/// 在新根重新注入(会刷新 DSH 白名单),未接入的只刷新白名单。会调用 claude CLI,调用方应放在后台线程。 /// 已接入(有 token)的项目在新根重新注入(会刷新 DSH 白名单),未接入的只刷新白名单。
/// 重新注入会跑 claude CLI 做全局注册,调用方应放在后台线程。持锁执行:与并发的移除/删除串行,
/// 移除先完成则 token 已不在,这里就不会再注入。
pub fn on_project_root_changed(project_id: &str, old_root: Option<PathBuf>) { pub fn on_project_root_changed(project_id: &str, old_root: Option<PathBuf>) {
let _guard = mcp_op_lock();
if let Some(root) = old_root { if let Some(root) = old_root {
enqueue_claude_sync(project_id, root, None); sync_claude_local(project_id, &root, None);
} }
let injected = crate::credential_store::mcp_auth_digest(project_id) let injected = crate::credential_store::mcp_auth_digest(project_id)
.and_then(|digest| { .and_then(|digest| {
@ -1135,16 +1201,24 @@ pub fn on_project_root_changed(project_id: &str, old_root: Option<PathBuf>) {
refresh_dsh_patch(None); refresh_dsh_patch(None);
return; return;
} }
if let Err(error) = inject_project_mcp(project_id) { if let Err(error) = inject_project_mcp_locked(project_id, true) {
// 属于注入结果,归入 mcp_inject;注入中途失败可能没走到刷新,这里补一次
db::log_event( db::log_event(
CLIENT_SYNC_LOG, "mcp_inject",
"warn", "warn",
&format!("项目根目录变更后重新注入失败: {error}"), &format!("项目根目录变更后重新注入失败: {error}"),
Some(&inject_log_context(project_id, "all")), Some(&inject_log_context(project_id, "all")),
); );
refresh_dsh_patch(None);
} }
} }
/// 纯函数:update_project 后是否需要迁移客户端配置。只要能解析出新根且与旧根不同就迁移;
/// 旧根解析不出(例如旧路径为空)也照样迁移,只是没有旧条目要清理。
pub(crate) fn project_root_changed(old_root: Option<&PathBuf>, new_root: Option<&PathBuf>) -> bool {
new_root.is_some() && new_root != old_root
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -1345,16 +1419,91 @@ mod tests {
#[test] #[test]
fn claude_local_entry_uses_scoped_url_and_shim() { fn claude_local_entry_uses_scoped_url_and_shim() {
let entry: Value = serde_json::from_str(&claude_local_server_json( let entry = claude_local_server_entry(
"http://127.0.0.1:27190/mcp/projects/p1", "http://127.0.0.1:27190/mcp/projects/p1",
&shim_helper_command("d1"), &shim_helper_command("d1"),
)) );
.unwrap();
assert_eq!(entry["type"], "http"); assert_eq!(entry["type"], "http");
assert_eq!(entry["url"], "http://127.0.0.1:27190/mcp/projects/p1"); 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]
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",
&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" } }
} }
});
assert_eq!(
apply_claude_local(&mut config, root, Some(entry.clone())).unwrap(),
ClaudeLocalOutcome::Registered
);
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);
}
#[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(Some(&old), Some(&old)));
assert!(!project_root_changed(Some(&old), None), "新根解析不出则不动");
}
#[test] #[test]
fn dsh_block_is_valid_yaml_with_registry_routing_and_shim_auth() { fn dsh_block_is_valid_yaml_with_registry_routing_and_shim_auth() {
let roots = vec![ let roots = vec![