fix(mcp/parity): 最终审查修复——Claude 条目写入回归 claude CLI,只读扫描认领
最终审查指出直接编辑 ~/.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/<id> 认领炼境托管条目(不依赖键推导);已一致则不起 CLI (实测稳态注入 0.04 秒),手写条目不碰;项目目录已不存在时只记录残留、不写文件 - 全局注册只读确认已存在就跳过 CLI;配置路径按 Claude 规则解析,容忍 BOM - 批量重注入在锁内复查组成员关系,已移出组的项目不再注入、不重建 token Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1f90dcdeb0
commit
5f6ea586f8
@ -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 级全局;直接编辑该文件中本项目键下的 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 工具接口
|
- label: MCP 工具接口
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
@ -272,6 +272,10 @@ fn find_git_bash() -> Option<String> {
|
|||||||
/// best-effort,失败仅记录日志;返回是否成功(同名已存在视为成功,作为 CLI 链路健康信号)
|
/// best-effort,失败仅记录日志;返回是否成功(同名已存在视为成功,作为 CLI 链路健康信号)
|
||||||
fn try_register_globally(port: u16) -> bool {
|
fn try_register_globally(port: u16) -> bool {
|
||||||
let server_url = format!("http://127.0.0.1:{}/mcp", port);
|
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 {
|
let Some(claude_bin) = find_claude_bin() else {
|
||||||
db::log_event(
|
db::log_event(
|
||||||
@ -292,47 +296,23 @@ fn try_register_globally(port: u16) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
match crate::silent_cmd(&claude_bin)
|
match run_claude_cli(
|
||||||
.args([
|
&claude_bin,
|
||||||
"mcp",
|
&git_bash,
|
||||||
"add",
|
None,
|
||||||
"--transport",
|
&["mcp", "add", "--transport", "sse", "-s", "user", MCP_KEY, &server_url],
|
||||||
"sse",
|
) {
|
||||||
"-s",
|
Ok(()) => {
|
||||||
"user",
|
|
||||||
MCP_KEY,
|
|
||||||
&server_url,
|
|
||||||
])
|
|
||||||
.env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash)
|
|
||||||
.output()
|
|
||||||
{
|
|
||||||
Ok(out) if out.status.success() => {
|
|
||||||
db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(&global_log_context()));
|
db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(&global_log_context()));
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Ok(out) => {
|
// 同名已存在不是故障——CLI 链路健康,注册目标已达成
|
||||||
let combined = format!(
|
Err(output) if output.contains("already exists") => true,
|
||||||
"{}{}",
|
Err(output) => {
|
||||||
String::from_utf8_lossy(&out.stdout),
|
|
||||||
String::from_utf8_lossy(&out.stderr)
|
|
||||||
);
|
|
||||||
// 同名已存在(exit 1)不是故障——CLI 链路健康,注册目标已达成
|
|
||||||
if combined.contains("already exists") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
db::log_event(
|
db::log_event(
|
||||||
"mcp_inject",
|
"mcp_inject",
|
||||||
"warn",
|
"warn",
|
||||||
&format!("claude mcp add 非零退出: {}", combined),
|
&format!("claude mcp add 失败: {output}"),
|
||||||
Some(&global_log_context()),
|
|
||||||
);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
db::log_event(
|
|
||||||
"mcp_inject",
|
|
||||||
"warn",
|
|
||||||
&format!("执行 claude mcp add 失败: {}", e),
|
|
||||||
Some(&global_log_context()),
|
Some(&global_log_context()),
|
||||||
);
|
);
|
||||||
false
|
false
|
||||||
@ -370,146 +350,107 @@ fn claude_local_server_entry(server_url: &str, header_helper: &str) -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq)]
|
/// Claude 全局配置文件位置,与 Claude 的解析规则一致:旧版 `<配置目录>/.config.json` 存在则优先,
|
||||||
enum ClaudeLocalEntry {
|
/// 否则为 `$CLAUDE_CONFIG_DIR`(或用户目录)下的 `.claude.json`。只用于只读检查。
|
||||||
Absent,
|
fn claude_config_path() -> Option<PathBuf> {
|
||||||
/// 炼境写入的条目(项目路由 + 稳定 helper),可安全替换或删除。
|
let config_dir = std::env::var_os("CLAUDE_CONFIG_DIR").map(PathBuf::from);
|
||||||
Managed(Value),
|
let home = std::env::var_os("USERPROFILE").map(PathBuf::from)?;
|
||||||
/// 用户手写的同名条目:炼境不覆盖、不删除。
|
let legacy = config_dir
|
||||||
Manual,
|
.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 记录的路径(实测为正斜杠盘符形式),比较时统一分隔符与大小写。
|
/// 只读读取 Claude 全局配置(容忍 BOM)。读不到或解析失败返回 None,调用方按「未知」保守处理。
|
||||||
fn normalize_root_key(path: &str) -> String {
|
fn read_claude_config() -> Option<Value> {
|
||||||
path.replace('\\', "/").trim_end_matches('/').to_lowercase()
|
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 {
|
/// 只读扫描:所有项目键下指向本项目专用地址、且经稳定 helper 认证的炼境托管条目。按 URL 匹配,
|
||||||
let wanted = normalize_root_key(&root.display().to_string());
|
/// 不复刻 Claude 的项目键推导(Claude 以 git 根为键、worktree 映射到主仓库根、路径做 NFC 归一化)。
|
||||||
let entry = claude_json
|
fn managed_claude_entries(config: &Value, project_id: &str) -> Vec<(String, Value)> {
|
||||||
.get("projects")
|
let suffix = format!("/mcp/projects/{}", urlencoding::encode(project_id));
|
||||||
.and_then(Value::as_object)
|
let Some(projects) = config.get("projects").and_then(Value::as_object) else {
|
||||||
.and_then(|projects| {
|
return Vec::new();
|
||||||
|
};
|
||||||
projects
|
projects
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(key, _)| normalize_root_key(key) == wanted)
|
.filter_map(|(key, project)| {
|
||||||
.map(|(_, value)| value)
|
let entry = project.get("mcpServers")?.get(MCP_KEY)?;
|
||||||
})
|
let url = entry.get("url").and_then(Value::as_str)?;
|
||||||
.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("");
|
let helper = entry.get("headersHelper").and_then(Value::as_str).unwrap_or("");
|
||||||
if url.starts_with("http://127.0.0.1:")
|
(url.starts_with("http://127.0.0.1:")
|
||||||
&& url.contains("/mcp/projects/")
|
&& url.ends_with(&suffix)
|
||||||
&& helper.contains(MCP_HEADER_SHIM_NAME)
|
&& helper.contains(MCP_HEADER_SHIM_NAME))
|
||||||
{
|
.then(|| (key.clone(), entry.clone()))
|
||||||
ClaudeLocalEntry::Managed(entry.clone())
|
})
|
||||||
} else {
|
.collect()
|
||||||
ClaudeLocalEntry::Manual
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq)]
|
fn claude_global_registered(config: &Value) -> bool {
|
||||||
enum ClaudeLocalOutcome {
|
config
|
||||||
Unchanged,
|
.get("mcpServers")
|
||||||
Registered,
|
.and_then(|servers| servers.get(MCP_KEY))
|
||||||
Removed,
|
.is_some()
|
||||||
KeptManual,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 纯函数:把期望状态写进 ~/.claude.json 的配置对象。只动本项目条目下的 mcpServers.lian-jing,
|
/// claude CLI 单次调用上限:卡住的子进程会被终止,不让它拖住持锁的接入操作与界面。
|
||||||
/// 项目对象里 Claude 自己维护的其他字段与其他 server 原样保留;手写条目不碰。
|
const CLAUDE_CLI_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||||
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)
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 同目录临时文件 + 重命名替换:读者只会看到完整的旧文件或新文件。目标被短暂占用时重试几次。
|
/// 运行 claude CLI(不弹窗、stdin 置空、带超时)。成功返回 Ok,失败返回合并后的输出。
|
||||||
fn write_file_atomic(path: &std::path::Path, content: &str) -> Result<(), String> {
|
fn run_claude_cli(
|
||||||
let tmp = path.with_file_name(format!(
|
claude_bin: &str,
|
||||||
"{}.lianjing-tmp",
|
git_bash: &str,
|
||||||
path.file_name().and_then(|name| name.to_str()).unwrap_or("config")
|
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 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(None) => {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
return Err(format!(
|
||||||
|
"claude CLI 超过 {} 秒未结束,已终止",
|
||||||
|
CLAUDE_CLI_TIMEOUT.as_secs()
|
||||||
));
|
));
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
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();
|
let mut output = String::new();
|
||||||
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
|
if let Some(mut stdout) = child.stdout.take() {
|
||||||
PathBuf::from(format!(r"\\{rest}"))
|
let _ = stdout.read_to_string(&mut output);
|
||||||
} else if let Some(rest) = text.strip_prefix(r"\\?\") {
|
}
|
||||||
PathBuf::from(rest)
|
if let Some(mut stderr) = child.stderr.take() {
|
||||||
|
let _ = stderr.read_to_string(&mut output);
|
||||||
|
}
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
} else {
|
} 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";
|
const CLIENT_SYNC_LOG: &str = "mcp_client_sync";
|
||||||
|
|
||||||
/// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除炼境托管的条目。
|
/// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除炼境托管的条目。
|
||||||
/// 直接编辑 ~/.claude.json(Claude Code 文档列出的配置方式之一),不经 claude CLI:不依赖项目目录
|
///
|
||||||
/// 存在(目录已删除或移动也能清理旧条目)、不起子进程、毫秒级完成,因此可在调用方同步执行。
|
/// 写入一律交给 claude CLI(cwd=项目目录):项目键推导、Claude 自己的文件锁、配置路径与读写细节都由
|
||||||
/// Claude 自己也会写这个文件,读改写窗口压到最小(紧挨写入前读取 + 原子替换)。best-effort,只记日志。
|
/// Claude 负责,炼境不复刻其私有实现(2026-10-01 最终审查:直接编辑 ~/.claude.json 需要复刻 git 根 /
|
||||||
/// 调用方须持有 mcp_op_lock,保证本进程内对该文件的读改写串行。
|
/// 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)>) {
|
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");
|
||||||
match result {
|
let managed = read_claude_config()
|
||||||
Ok(ClaudeLocalOutcome::Unchanged) => {}
|
.map(|config| managed_claude_entries(&config, project_id))
|
||||||
Ok(outcome) => {
|
.unwrap_or_default();
|
||||||
let message = match outcome {
|
let desired_entry = desired.map(|(url, helper)| claude_local_server_entry(&url, &helper));
|
||||||
ClaudeLocalOutcome::Registered => "Claude 项目级 MCP 条目已写入",
|
match &desired_entry {
|
||||||
ClaudeLocalOutcome::Removed => "Claude 项目级 MCP 条目已移除",
|
None if managed.is_empty() => return,
|
||||||
_ => "本项目已有手写的 lian-jing local 条目,炼境不覆盖",
|
Some(entry) if managed.iter().any(|(_, current)| current == entry) => return,
|
||||||
};
|
_ => {}
|
||||||
db::log_event(CLIENT_SYNC_LOG, "info", message, Some(&context));
|
|
||||||
}
|
}
|
||||||
|
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 (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(
|
Err(error) => db::log_event(
|
||||||
CLIENT_SYNC_LOG,
|
CLIENT_SYNC_LOG,
|
||||||
"warn",
|
"warn",
|
||||||
&format!("Claude 项目级 MCP 同步失败(~/.claude.json): {error}"),
|
&format!("Claude 项目级 MCP 同步失败: {error}"),
|
||||||
Some(&context),
|
Some(&context),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@ -1171,6 +1148,22 @@ 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 {
|
||||||
let _guard = mcp_op_lock();
|
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) {
|
if let Err(e) = inject_project_mcp_locked(&project_id, false) {
|
||||||
errors.push(format!("项目 {}: {}", project_id, e));
|
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"), "路径 % 须转义");
|
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]
|
#[test]
|
||||||
fn global_scope_projects_are_excluded_and_roots_follow_registry() {
|
fn global_scope_projects_are_excluded_and_roots_follow_registry() {
|
||||||
let base = std::env::temp_dir().join(format!("lianjing_dsh_roots_{}", std::process::id()));
|
let base = std::env::temp_dir().join(format!("lianjing_dsh_roots_{}", std::process::id()));
|
||||||
@ -1429,69 +1401,38 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn claude_local_direct_edit_touches_only_lian_jing_entry() {
|
fn managed_claude_entries_match_by_project_url_under_any_key() {
|
||||||
let root = std::path::Path::new("E:\\code-project\\Demo");
|
let managed = claude_local_server_entry(
|
||||||
let entry = claude_local_server_entry(
|
"http://127.0.0.1:27190/mcp/projects/p%201",
|
||||||
"http://127.0.0.1:27190/mcp/projects/p1",
|
|
||||||
&shim_helper_command("d1"),
|
&shim_helper_command("d1"),
|
||||||
);
|
);
|
||||||
// 已有项目键(大小写、分隔符与 root 不同):复用该键,保留 Claude 自己的字段与其他 server
|
let moved_port = claude_local_server_entry(
|
||||||
let mut config = json!({
|
"http://127.0.0.1:28000/mcp/projects/p%201",
|
||||||
"numStartups": 3,
|
&shim_helper_command("d1"),
|
||||||
"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"];
|
let config = json!({
|
||||||
assert_eq!(config["projects"].as_object().unwrap().len(), 1, "不新增重复键");
|
"mcpServers": { "lian-jing": { "type": "sse", "url": "http://127.0.0.1:27190/mcp" } },
|
||||||
assert_eq!(project["lastCost"], 1.5);
|
"projects": {
|
||||||
assert_eq!(project["mcpServers"]["other"]["type"], "stdio");
|
// Claude 以 git 根 / 主仓库根为键:条目可能在任意键下,按 URL 认领
|
||||||
assert_eq!(project["mcpServers"][MCP_KEY], entry);
|
"E:/repos/main": { "mcpServers": { "lian-jing": managed.clone() } },
|
||||||
assert_eq!(config["numStartups"], 3);
|
"E:/repos/other": { "mcpServers": { "lian-jing": moved_port.clone() } },
|
||||||
assert_eq!(
|
"E:/repos/p10": { "mcpServers": { "lian-jing": claude_local_server_entry(
|
||||||
apply_claude_local(&mut config, root, Some(entry.clone())).unwrap(),
|
"http://127.0.0.1:27190/mcp/projects/p%2010", &shim_helper_command("d2")) } },
|
||||||
ClaudeLocalOutcome::Unchanged,
|
"E:/repos/manual": { "mcpServers": { "lian-jing": { "type": "http", "url": "http://127.0.0.1:27190/mcp/projects/p%201" } } },
|
||||||
"相同条目不重写"
|
"E:/repos/none": { "lastCost": 1 }
|
||||||
);
|
|
||||||
|
|
||||||
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]
|
let found = managed_claude_entries(&config, "p 1");
|
||||||
fn claude_project_path_is_long_form_without_verbatim_prefix() {
|
let keys: Vec<&str> = found.iter().map(|(key, _)| key.as_str()).collect();
|
||||||
let dir = std::env::temp_dir().join(format!("lianjing_claude_path_{}", std::process::id()));
|
assert_eq!(
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
keys,
|
||||||
let resolved = claude_project_path(&dir).display().to_string();
|
vec!["E:/repos/main", "E:/repos/other"],
|
||||||
assert!(!resolved.starts_with(r"\\?\"), "不得带 verbatim 前缀:{resolved}");
|
"端口变化仍认领;前缀相同的他项目、无 shim 的手写条目不认领"
|
||||||
assert!(!resolved.contains('~'), "8.3 短名须展开为长路径:{resolved}");
|
);
|
||||||
assert!(std::path::Path::new(&resolved).is_dir());
|
assert_eq!(found[0].1, managed);
|
||||||
let missing = std::path::Path::new("E:\\definitely\\missing\\root");
|
assert!(claude_global_registered(&config));
|
||||||
assert_eq!(claude_project_path(missing), missing.to_path_buf(), "不存在时原样返回");
|
assert!(!claude_global_registered(&json!({ "projects": {} })));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
assert!(managed_claude_entries(&json!({}), "p 1").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user