feat(mcp/parity): 三端经稳定 helper 入口连项目专用地址(P1 + P3) #24
@ -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:
|
||||
|
||||
@ -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 ¤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<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]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user