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
2 changed files with 216 additions and 275 deletions
Showing only changes of commit 5f6ea586f8 - 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 级全局;直接编辑该文件中本项目键下的 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/<project_id> 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:

View File

@ -272,6 +272,10 @@ fn find_git_bash() -> Option<String> {
/// 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<PathBuf> {
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<Value> {
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<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)
}
_ => {}
/// 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<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");
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<String> {
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]