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
4 changed files with 183 additions and 43 deletions
Showing only changes of commit e4214cd839 - Show all commits

View File

@ -276,7 +276,12 @@ pub fn update_project(id: String, input: UpdateProjectInput) -> Result<(), Strin
)
.map_err(|e| e.to_string())?;
// 2. 更新 workspace
// 2. 更新 workspace(路径或平台变化时,先记下旧根目录,供客户端侧配置迁移)
let root_may_change =
input.win_path.is_some() || input.wsl_path.is_some() || input.platform.is_some();
let old_root = root_may_change
.then(|| crate::mcp_inject::project_root_with_conn(&conn, &id).ok())
.flatten();
conn.execute(
"UPDATE project_workspaces SET
wsl_path = COALESCE(?1, wsl_path),
@ -297,6 +302,16 @@ pub fn update_project(id: String, input: UpdateProjectInput) -> Result<(), Strin
)
.map_err(|e| e.to_string())?;
// 根目录变化:Claude local 条目按目录挂载、DSH 白名单按根目录匹配,都要迁移到新根。
// 会调用 claude CLI,放到后台线程,避免同步命令卡住界面。
if let Some(old_root) = old_root {
let new_root = crate::mcp_inject::project_root_with_conn(&conn, &id).ok();
if new_root.as_ref() != Some(&old_root) {
drop(conn);
std::thread::spawn(move || crate::mcp_inject::on_project_root_changed(&id, Some(old_root)));
}
}
Ok(())
}

View File

@ -126,7 +126,8 @@ pub fn run() {
Some(r#"{"client":"shim"}"#),
);
}
mcp_inject::refresh_dsh_patch(None);
// 刷新 DSH 白名单要读所有项目根目录的 context.json,放到后台线程,不拖慢窗口启动
std::thread::spawn(|| mcp_inject::refresh_dsh_patch(None));
// ── MCP 单一服务器化一次性迁移(settings 标记位防重复)────
std::thread::spawn(|| {

View File

@ -214,7 +214,7 @@ fn find_claude_bin() -> Option<String> {
}
// 最后尝试 where 命令(GUI 进程 PATH 可能不含用户 bin,but worth trying)
if let Ok(out) = std::process::Command::new("where")
if let Ok(out) = crate::silent_cmd("where")
.arg("claude.exe")
.output()
{
@ -252,7 +252,7 @@ fn find_git_bash() -> Option<String> {
}
// 从 git.exe 推导:<root>\cmd\git.exe / <root>\mingw64\bin\git.exe → <root>\bin\bash.exe
if let Ok(out) = std::process::Command::new("where").arg("git.exe").output() {
if let Ok(out) = crate::silent_cmd("where").arg("git.exe").output() {
if out.status.success() {
for line in String::from_utf8_lossy(&out.stdout).lines() {
let git = std::path::Path::new(line.trim());
@ -292,7 +292,7 @@ fn try_register_globally(port: u16) -> bool {
return false;
};
match std::process::Command::new(&claude_bin)
match crate::silent_cmd(&claude_bin)
.args([
"mcp",
"add",
@ -442,22 +442,49 @@ fn run_claude_cli(
}
}
/// 客户端侧同步(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,结果只记日志。
/// 手写条目一律不碰;替换失败时用原值回滚。best-effort,结果只记日志。只应经 enqueue_claude_sync 调用。
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)) {
return;
}
let (Some(claude_bin), Some(git_bash)) = (find_claude_bin(), find_git_bash()) else {
db::log_event(
"mcp_inject",
CLIENT_SYNC_LOG,
"warn",
"claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步",
Some(&context),
);
return;
};
let state = read_claude_user_config()
.map(|config| classify_claude_local_entry(&config, root))
.unwrap_or(ClaudeLocalEntry::Absent);
let remove_args = ["mcp", "remove", MCP_KEY, "--scope", "local"];
let outcome = match (state, desired) {
(ClaudeLocalEntry::Manual, _) => {
@ -474,7 +501,7 @@ fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(
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(
"mcp_inject",
CLIENT_SYNC_LOG,
"warn",
&format!("Claude 项目级 MCP 旧条目移除失败: {error}"),
Some(&context),
@ -498,9 +525,9 @@ fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(
}
};
match outcome {
Ok(message) => db::log_event("mcp_inject", "info", &message, Some(&context)),
Ok(message) => db::log_event(CLIENT_SYNC_LOG, "info", &message, Some(&context)),
Err(error) => db::log_event(
"mcp_inject",
CLIENT_SYNC_LOG,
"warn",
&format!("Claude 项目级 MCP 同步失败: {error}"),
Some(&context),
@ -514,16 +541,33 @@ fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(
// 并经稳定 helper 携带认证;任意目录里的 .lian-jing/context.json 不作为授权依据。未命中则回退全局地址,
// 保持 GUI 等非项目启动方式的现有行为。项目增删、端口变更时重写白名单。
/// 与 JS 端 path.resolve(d).toLowerCase() 对齐:反斜杠分隔、去尾部分隔符、小写。
/// 白名单键规范化,与 JS 端 `path.resolve(d).toLowerCase().replace(/\\+$/, '')` 对齐:
/// 反斜杠分隔、合并重复分隔符(保留 UNC 前缀 \\)、去尾部分隔符、小写。盘符根 E:\ 两端都得到 e:。
fn normalize_dsh_root(path: &str) -> String {
path.replace('/', "\\").trim_end_matches('\\').to_lowercase()
let unified = path.replace('/', "\\");
let (prefix, rest) = match unified.strip_prefix("\\\\") {
Some(rest) => ("\\\\", rest),
None => ("", unified.as_str()),
};
let mut collapsed = String::with_capacity(rest.len());
for ch in rest.chars() {
if ch == '\\' && collapsed.ends_with('\\') {
continue;
}
collapsed.push(ch);
}
format!("{prefix}{}", collapsed.trim_end_matches('\\')).to_lowercase()
}
/// 已接入(根目录 context.json 的 project_id 与登记一致)、非控制面、非 WSL 的 Windows 项目。
/// DSH 白名单:已登记、非 WSL、根目录 context.json 的 project_id 与登记一致的 Windows 项目。
/// - 普通项目须已有项目 token(helper 能工作的前提)才进入白名单——退组或移除时 token 被删,剔除即持久;
/// - 控制面项目(mcp_scope=global)写入 None 作为停止哨兵:命中即停止向上查找,回退全局地址,
/// 不会越过它匹配到已登记的上级目录。
fn dsh_project_roots(
conn: &rusqlite::Connection,
store: &impl crate::credential_store::CredentialStore,
exclude: Option<&str>,
) -> Vec<(String, String)> {
) -> Vec<(String, Option<String>)> {
let rows: Vec<(String, String)> = conn
.prepare(
"SELECT id, win_path FROM project_workspaces
@ -539,21 +583,31 @@ fn dsh_project_roots(
.filter(|(id, _)| Some(id.as_str()) != exclude)
.filter_map(|(id, win_path)| {
let root = validate_registered_path(&win_path, "win_path").ok()?;
// 每个项目只读一次 context.json,同时取 project_id 与 mcp_scope
let context =
read_context_object(&root.join(".lian-jing").join("context.json"), "canonical 项目上下文")
.ok()?;
let bound = context.get("project_id").and_then(Value::as_str) == Some(id.as_str());
(bound && !project_wants_global_scope(&root))
.then(|| (normalize_dsh_root(&win_path), id))
if context.get("project_id").and_then(Value::as_str) != Some(id.as_str()) {
return None;
}
let key = normalize_dsh_root(&win_path);
if context.get("mcp_scope").and_then(Value::as_str) == Some("global") {
return Some((key, None));
}
let has_token = crate::credential_store::mcp_auth_digest(&id)
.and_then(|digest| crate::credential_store::read_mcp_auth_token_for_digest(store, &digest))
.is_ok();
has_token.then(|| (key, Some(id)))
})
.collect()
}
fn render_dsh_block(port: u16, roots: &[(String, String)]) -> String {
fn render_dsh_block(port: u16, roots: &[(String, Option<String>)]) -> String {
// 逐行显式缩进:YAML 依赖缩进,不能用字符串续行(会吞掉下一行行首空白)。
fn indent(spaces: usize, lines: &[&str]) -> Vec<String> {
lines.iter().map(|line| format!("{}{line}", " ".repeat(spaces))).collect()
}
// 值为 null 的键是控制面项目的停止哨兵:命中即停,回退全局地址。
let roots_map: serde_json::Map<String, Value> = roots
.iter()
.map(|(root, id)| (root.clone(), json!(id)))
@ -564,8 +618,8 @@ fn render_dsh_block(port: u16, roots: &[(String, String)]) -> String {
roots_line.as_str(),
"let id;",
"for (let d = path.resolve(process.cwd()), p; d !== p; p = d, d = path.dirname(d)) {",
" const hit = roots[d.toLowerCase()];",
" if (hit) { id = hit; break; }",
" const key = d.toLowerCase().replace(/\\\\+$/, '');",
" if (Object.prototype.hasOwnProperty.call(roots, key)) { id = roots[key] || undefined; break; }",
"}",
];
let global_url = format!("http://127.0.0.1:{port}/mcp");
@ -696,7 +750,10 @@ pub fn ensure_dsh_patch(
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => return Err(format!("读取 DSH 补丁文件失败: {error}")),
};
let block = render_dsh_block(port, &dsh_project_roots(conn, exclude));
let block = render_dsh_block(
port,
&dsh_project_roots(conn, &crate::credential_store::WindowsCredentialStore, exclude),
);
let next = upsert_dsh_block(&current, &block)?;
if next != current {
std::fs::write(&path, next).map_err(|error| format!("写入 DSH 补丁文件失败: {error}"))?;
@ -712,7 +769,7 @@ pub fn refresh_dsh_patch(exclude: Option<&str>) {
.and_then(|conn| ensure_dsh_patch(&conn, crate::mcp_server::read_port(), exclude));
if let Err(error) = result {
db::log_event(
"mcp_inject",
CLIENT_SYNC_LOG,
"warn",
&format!("DSH 托管块刷新失败: {error}"),
Some(&json!({ "client": "dsh" }).to_string()),
@ -759,7 +816,7 @@ pub fn cleanup_legacy_global_registrations() -> Vec<String> {
names.push(format!("lian-jing-{}", &gid[..8]));
}
for name in names {
let ok = std::process::Command::new(&claude_bin)
let ok = crate::silent_cmd(&claude_bin)
.args(["mcp", "remove", "-s", "user", &name])
.env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash)
.output()
@ -891,6 +948,11 @@ 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)
}
/// refresh_dsh=false 供批量重注入使用:逐项目跳过 DSH 刷新,由调用方在末尾统一刷新一次。
fn inject_project_mcp_with(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)?;
@ -937,20 +999,23 @@ pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
try_register_globally(port);
let root = project_root(project_id)?;
let desired = (!project_wants_global_scope(&root)).then(|| (server_url, header_helper));
let owned_id = project_id.to_string();
// claude CLI 每次调用约 1-2 秒,放到后台线程,避免同步命令阻塞界面
std::thread::spawn(move || sync_claude_local(&owned_id, &root, desired));
refresh_dsh_patch(None);
// claude CLI 每次调用约 1-2 秒:投递到串行后台队列,不阻塞界面,也不与其他同步竞态
enqueue_claude_sync(project_id, root, desired);
if refresh_dsh {
refresh_dsh_patch(None);
}
Ok(())
}
/// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。
/// 调用方需自行保证项目已不属于任何产品组时才调用。
pub fn remove_project_mcp(project_id: &str) -> Result<(), String> {
// 先于登记删除清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。
// 先于登记删除算出根目录并排队清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。
// 与注册共用同一串行队列,保证移除不会被更早提交的注册越过。
if let Ok(root) = project_root(project_id) {
sync_claude_local(project_id, &root, None);
enqueue_claude_sync(project_id, root, None);
}
// 登记此时仍在,用 exclude 立即剔除;token 随后被删,之后的刷新也不会再把它加回白名单
refresh_dsh_patch(Some(project_id));
remove_codex_mcp_config(&codex_config_path(project_id)?)?;
let path = settings_path(project_id)?;
@ -1043,15 +1108,43 @@ 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(&project_id) {
if let Err(e) = inject_project_mcp_with(&project_id, false) {
errors.push(format!("项目 {}: {}", project_id, e));
}
}
// 每次注入已刷新 DSH 托管块(内嵌端口与白名单);此处兜底一次,失败只记日志
// 逐项目注入时跳过了 DSH 刷新(内嵌端口与白名单),此处统一刷新一次,失败只记日志
refresh_dsh_patch(None);
errors
}
/// 项目根目录或平台变更后调用:排队清理旧根下炼境托管的 Claude local 条目;已接入(有 token)的项目
/// 在新根重新注入(会刷新 DSH 白名单),未接入的只刷新白名单。会调用 claude CLI,调用方应放在后台线程。
pub fn on_project_root_changed(project_id: &str, old_root: Option<PathBuf>) {
if let Some(root) = old_root {
enqueue_claude_sync(project_id, root, None);
}
let injected = crate::credential_store::mcp_auth_digest(project_id)
.and_then(|digest| {
crate::credential_store::read_mcp_auth_token_for_digest(
&crate::credential_store::WindowsCredentialStore,
&digest,
)
})
.is_ok();
if !injected {
refresh_dsh_patch(None);
return;
}
if let Err(error) = inject_project_mcp(project_id) {
db::log_event(
CLIENT_SYNC_LOG,
"warn",
&format!("项目根目录变更后重新注入失败: {error}"),
Some(&inject_log_context(project_id, "all")),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -1207,7 +1300,12 @@ mod tests {
let base = std::env::temp_dir().join(format!("lianjing_dsh_roots_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let conn = crate::db::conn_with_schema();
for (id, scope, bound) in [("p1", None, true), ("ctrl", Some("global"), true), ("drift", None, false), ("gone", None, true)] {
let store = crate::credential_store::MemoryCredentialStore::new();
for id in ["p1", "drift", "gone"] {
crate::credential_store::get_or_create_mcp_auth_token(&store, id).unwrap();
}
// notoken:已绑定但没有 token(例如退组后 token 已删),不得进入白名单
for (id, scope, bound) in [("p1", None, true), ("ctrl", Some("global"), true), ("drift", None, false), ("gone", None, true), ("notoken", None, true)] {
let root = base.join(id);
std::fs::create_dir_all(root.join(".lian-jing")).unwrap();
let mut ctx = json!({ "project_id": if bound { id } else { "someone-else" }, "schema_version": 1 });
@ -1227,13 +1325,24 @@ mod tests {
.unwrap();
}
assert!(project_wants_global_scope(&base.join("ctrl")));
let roots = dsh_project_roots(&conn, Some("gone"));
let ids: Vec<&str> = roots.iter().map(|(_, id)| id.as_str()).collect();
assert_eq!(ids, vec!["p1"], "只保留已绑定、非控制面、未被剔除的项目");
assert_eq!(roots[0].0, normalize_dsh_root(&base.join("p1").display().to_string()));
let roots = dsh_project_roots(&conn, &store, Some("gone"));
let key = |id: &str| normalize_dsh_root(&base.join(id).display().to_string());
assert_eq!(
roots,
vec![(key("ctrl"), None), (key("p1"), Some("p1".to_string()))],
"控制面写停止哨兵;只保留已绑定、有 token、未被剔除的项目"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn dsh_root_keys_match_js_normalization() {
assert_eq!(normalize_dsh_root("E:\\Code\\Demo"), "e:\\code\\demo");
assert_eq!(normalize_dsh_root("E:/Code//Demo/"), "e:\\code\\demo", "正斜杠、重复与尾部分隔符");
assert_eq!(normalize_dsh_root("E:\\"), "e:", "盘符根与 JS 去尾后一致");
assert_eq!(normalize_dsh_root("\\\\Server\\Share\\x"), "\\\\server\\share\\x", "保留 UNC 前缀");
}
#[test]
fn claude_local_entry_uses_scoped_url_and_shim() {
let entry: Value = serde_json::from_str(&claude_local_server_json(
@ -1248,15 +1357,22 @@ mod tests {
#[test]
fn dsh_block_is_valid_yaml_with_registry_routing_and_shim_auth() {
let roots = vec![("e:\\code-project\\demo".to_string(), "p1".to_string())];
let roots = vec![
("e:\\code-project".to_string(), None),
("e:\\code-project\\demo".to_string(), Some("p1".to_string())),
];
let block = render_dsh_block(27190, &roots);
assert!(
block.contains(r#"const roots = {"e:\\code-project":null,"e:\\code-project\\demo":"p1"};"#),
"控制面以 null 作停止哨兵"
);
assert!(block.contains("hasOwnProperty.call(roots, key)") && block.contains(r"replace(/\\+$/, '')"));
let parsed: serde_yaml::Value = serde_yaml::from_str(&block).expect("托管块必须是合法 YAML");
let entry = &parsed[0]["insert"][0];
assert_eq!(entry["id"], "mcp-lian-jing");
assert_eq!(entry["config"]["serverName"], MCP_KEY);
assert_eq!(entry["config"]["transport"], "streamable-http");
assert!(block.contains("url: !!js |") && block.contains("headers: !!js |"));
assert!(block.contains(r#"const roots = {"e:\\code-project\\demo":"p1"};"#), "白名单内嵌");
assert!(!block.contains("context.json"), "身份只认登记白名单,不读目录里的 context.json");
assert!(block.contains("'http://127.0.0.1:27190/mcp/projects/' + encodeURIComponent(id)"));
assert!(block.contains(": 'http://127.0.0.1:27190/mcp';"), "未命中回退全局地址");

View File

@ -1603,9 +1603,17 @@ async fn handle_scoped_sse_message(
headers: HeaderMap,
Json(req): Json<RpcRequest>,
) -> Response {
// 握手后项目可能被删除:每条消息复核登记,失效会话一并移除。
// 握手后项目可能被删除:每条消息复核登记。只移除确实属于该项目的会话,
// 不能凭请求里任意给出的 session_id 删掉别的会话。
if !scoped_project_registered(&project_id) {
sessions.lock().await.remove(&query.session_id);
let mut guard = sessions.lock().await;
let owned = guard
.get(&query.session_id)
.and_then(|entry| entry.project_scope.as_deref())
== Some(project_id.as_str());
if owned {
guard.remove(&query.session_id);
}
return unknown_project_response(&project_id, req.id.clone());
}
if authorize_sensitive_request(