feat(mcp/parity): 三端经稳定 helper 入口连项目专用地址(P1 + P3) #24

Merged
lanrtop merged 15 commits from feat/agent-client-parity into master 2026-10-02 12:05:16 +08:00
3 changed files with 275 additions and 126 deletions
Showing only changes of commit fc8492b88b - Show all commits

View File

@ -6,7 +6,7 @@ contracts:
- label: API 接口(Axum)
paths:
- "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 工具接口
paths:

View File

@ -304,11 +304,11 @@ pub fn update_project(id: String, input: UpdateProjectInput) -> Result<(), Strin
// 根目录变化:Claude local 条目按目录挂载、DSH 白名单按根目录匹配,都要迁移到新根。
// 会调用 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();
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);
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 级全局注册且不进仓库。
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!({
"type": "http",
"url": server_url,
"headersHelper": header_helper,
})
.to_string()
}
#[derive(Debug, PartialEq)]
enum ClaudeLocalEntry {
Absent,
/// 炼境写入的条目(项目路由 + 稳定 helper),可安全替换或删除;附原值供失败回滚。
/// 炼境写入的条目(项目路由 + 稳定 helper),可安全替换或删除。
Managed(Value),
/// 用户手写的同名条目:炼境不覆盖、不删除。
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> {
let home = std::env::var("USERPROFILE").ok()?;
let text = std::fs::read_to_string(PathBuf::from(home).join(".claude.json")).ok()?;
serde_json::from_str(&text).ok()
#[derive(Debug, PartialEq)]
enum ClaudeLocalOutcome {
Unchanged,
Registered,
Removed,
KeptManual,
}
fn run_claude_cli(
claude_bin: &str,
git_bash: &str,
/// 纯函数:把期望状态写进 ~/.claude.json 的配置对象。只动本项目条目下的 mcpServers.lian-jing,
/// 项目对象里 Claude 自己维护的其他字段与其他 server 原样保留;手写条目不碰。
fn apply_claude_local(
config: &mut Value,
root: &std::path::Path,
args: &[&str],
) -> Result<(), String> {
let out = crate::silent_cmd(claude_bin)
.current_dir(root)
.args(args)
.env("CLAUDE_CODE_GIT_BASH_PATH", git_bash)
.output()
.map_err(|error| format!("执行 claude CLI 失败: {error}"))?;
if out.status.success() {
Ok(())
desired: Option<Value>,
) -> Result<ClaudeLocalOutcome, String> {
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 &current == entry => {
return Ok(ClaudeLocalOutcome::Unchanged)
}
_ => {}
}
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 {
Err(format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
))
canonical
}
}
/// 客户端侧同步(Claude local、DSH 托管块)的日志单独归类:不覆盖诊断里每个项目的「最后一次注入结果」。
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 时只删除炼境托管的条目。
/// 手写条目一律不碰;替换失败时用原值回滚。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)>) {
let context = inject_log_context(project_id, "claude");
let state = read_claude_user_config()
.map(|config| classify_claude_local_entry(&config, root))
.unwrap_or(ClaudeLocalEntry::Absent);
if matches!((&state, &desired), (ClaudeLocalEntry::Absent, None)) {
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 (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_args = ["mcp", "remove", MCP_KEY, "--scope", "local"];
let outcome = match (state, desired) {
(ClaudeLocalEntry::Manual, _) => {
Ok("本项目已有手写的 lian-jing local 条目,炼境不覆盖".to_string())
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)?;
}
(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)
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 条目,炼境不覆盖",
};
registered.map(|_| "Claude 项目级 MCP 注册成功".to_string())
db::log_event(CLIENT_SYNC_LOG, "info", message, Some(&context));
}
};
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}"),
&format!("Claude 项目级 MCP 同步失败(~/.claude.json): {error}"),
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 配置无法按项目拆分,但 !!js 在加载时以启动目录求值。项目身份只认炼境登记表:托管块内嵌
// 「已登记项目根目录 → project_id」白名单(非秘密),从启动目录向上精确匹配根目录才连项目专用地址
@ -761,8 +798,14 @@ pub fn ensure_dsh_patch(
Ok(true)
}
/// DSH 补丁文件的读-算-写在本进程内串行(启动线程与注入/移除可能同时刷新)。
static DSH_PATCH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// 刷新 DSH 托管块的 best-effort 包装:失败只记日志。
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()
.get()
.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 配置,
/// 并清理旧组级 key(迁移内建于重注入)
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 刷新,由调用方在末尾统一刷新一次。
fn inject_project_mcp_with(project_id: &str, refresh_dsh: bool) -> Result<(), String> {
/// 调用方须持有 mcp_op_lock。refresh_dsh=false 供批量重注入使用:逐项目跳过 DSH 刷新,
/// 由调用方在末尾统一刷新一次。
fn inject_project_mcp_locked(project_id: &str, refresh_dsh: bool) -> Result<(), String> {
let port = crate::mcp_server::read_port();
let server_url = crate::mcp_server::scoped_mcp_url(port, 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);
let root = project_root(project_id)?;
let desired = (!project_wants_global_scope(&root)).then(|| (server_url, header_helper));
// claude CLI 每次调用约 1-2 秒:投递到串行后台队列,不阻塞界面,也不与其他同步竞态
enqueue_claude_sync(project_id, root, desired);
sync_claude_local(project_id, &root, desired);
if refresh_dsh {
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)。
/// 调用方需自行保证项目已不属于任何产品组时才调用。
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) {
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));
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)?)?;
let path = settings_path(project_id)?;
if path.exists() {
@ -1030,10 +1095,7 @@ pub fn remove_project_mcp(project_id: &str) -> Result<(), String> {
}
write_json(&path, &settings)?;
}
crate::credential_store::delete_mcp_auth_token_for_project(
&crate::credential_store::WindowsCredentialStore,
project_id,
)
Ok(())
}
/// 单一服务器化一次性迁移:批量重注入所有托管项目(换新 key + 清旧 key + liangjing.json 瘦身)
@ -1108,7 +1170,8 @@ pub fn reinject_all_mcp() -> Vec<String> {
let mut errors = Vec::new();
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));
}
}
@ -1117,11 +1180,14 @@ pub fn reinject_all_mcp() -> Vec<String> {
errors
}
/// 项目根目录或平台变更后调用:排队清理旧根下炼境托管的 Claude local 条目;已接入(有 token)的项目
/// 在新根重新注入(会刷新 DSH 白名单),未接入的只刷新白名单。会调用 claude CLI,调用方应放在后台线程。
/// 项目根目录或平台变更后调用:清理旧根下炼境托管的 Claude local 条目(直接编辑配置,旧目录不存在也能清理);
/// 已接入(有 token)的项目在新根重新注入(会刷新 DSH 白名单),未接入的只刷新白名单。
/// 重新注入会跑 claude CLI 做全局注册,调用方应放在后台线程。持锁执行:与并发的移除/删除串行,
/// 移除先完成则 token 已不在,这里就不会再注入。
pub fn on_project_root_changed(project_id: &str, old_root: Option<PathBuf>) {
let _guard = mcp_op_lock();
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)
.and_then(|digest| {
@ -1135,16 +1201,24 @@ pub fn on_project_root_changed(project_id: &str, old_root: Option<PathBuf>) {
refresh_dsh_patch(None);
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(
CLIENT_SYNC_LOG,
"mcp_inject",
"warn",
&format!("项目根目录变更后重新注入失败: {error}"),
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)]
mod tests {
use super::*;
@ -1345,16 +1419,91 @@ mod tests {
#[test]
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",
&shim_helper_command("d1"),
))
.unwrap();
);
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%"));
}
#[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]
fn dsh_block_is_valid_yaml_with_registry_routing_and_shim_auth() {
let roots = vec![