From 0c40e1a5c99c98d45c94e3442e8321fcb17837bf Mon Sep 17 00:00:00 2001 From: lanrtop Date: Thu, 1 Oct 2026 11:47:41 +0900 Subject: [PATCH] =?UTF-8?q?fix(mcp/parity):=20=E5=AF=B9=E6=8A=97=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=209=20=E9=A1=B9=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E9=9D=A2=E4=BF=9D=E7=95=99=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E3=80=81DSH=20=E6=94=B9=E7=99=BB=E8=AE=B0=E7=99=BD=E5=90=8D?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 控制面项目(context.json mcp_scope=global,炼境自身已声明)不写 Claude local 条目、 不入 DSH 白名单,避免遮蔽全局注册与跨项目工具(审查 high) - DSH 托管块内嵌炼境登记的「项目根目录→project_id」白名单,不再以任意目录里的 context.json 作为取 token 依据;LOCALAPPDATA/SystemRoot 须为绝对路径 - remove_project_mcp 同步清理 Claude local 条目并剔除 DSH 白名单;SSE 消息路由复核登记 - Claude local 只替换炼境托管条目,手写不覆盖,替换失败回滚;改走 silent_cmd 并移至后台线程 - 手写 DSH 条目改用 serde_yaml 检测(引号/注释/流式写法),无法解析时保守拒绝 - shim 中 exe 路径的 % 转义为 %%;注入日志 context 统一为 JSON,诊断查询防御非 JSON 行 Co-Authored-By: Claude Opus 5.5 --- .lian-jing/context.json | 3 +- docs/ai-context/project-context.yaml | 4 +- src-tauri/src/lib.rs | 11 +- src-tauri/src/mcp_inject.rs | 451 +++++++++++++++++++++------ src-tauri/src/mcp_server.rs | 5 + src-tauri/src/mcp_tools.rs | 2 + 6 files changed, 381 insertions(+), 95 deletions(-) diff --git a/.lian-jing/context.json b/.lian-jing/context.json index 1f673e8..2afb1e1 100644 --- a/.lian-jing/context.json +++ b/.lian-jing/context.json @@ -1,4 +1,5 @@ { "project_id": "dev-manager-tauri", - "schema_version": 1 + "schema_version": 1, + "mcp_scope": "global" } \ No newline at end of file diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 16307fd..0b615b3 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -6,7 +6,7 @@ contracts: - label: API 接口(Axum) paths: - "src-tauri/src/mcp_server.rs" - doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ 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 托管块(!!js 按启动目录选项目地址,项目外回退全局)。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" + doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ 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 按钮体系文档。" - label: MCP 工具接口 paths: @@ -25,7 +25,7 @@ contracts: - ".lian-jing/context.json" - ".lian-jing/capability-packs.json" - ".claude/liangjing.json" - doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。" + doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。context.json 可选字段 mcp_scope=global 声明控制面项目(如炼境自身):Claude 不写 local 条目、DSH 不入白名单,保留全局地址与跨项目工具。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。" - label: Tauri command 接口(前后端契约) paths: diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b1e5342..f06fca6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -119,11 +119,14 @@ pub fn run() { // ── 三端共用的稳定 helper 入口与 DSH 托管块(best-effort,失败只记日志)── // 每次启动把 shim 指向当前 exe,换构建目录后各客户端无需逐项目重注入。 if let Err(error) = mcp_inject::ensure_header_shim() { - db::log_event("mcp_inject", "warn", &format!("MCP helper 入口写入失败: {error}"), None); - } - if let Err(error) = mcp_inject::ensure_dsh_patch(mcp_port) { - db::log_event("mcp_inject", "warn", &format!("DSH 托管块写入失败: {error}"), None); + db::log_event( + "mcp_inject", + "warn", + &format!("MCP helper 入口写入失败: {error}"), + Some(r#"{"client":"shim"}"#), + ); } + mcp_inject::refresh_dsh_patch(None); // ── MCP 单一服务器化一次性迁移(settings 标记位防重复)──── std::thread::spawn(|| { diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index d4e1736..ec5fab6 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -28,8 +28,9 @@ fn header_shim_path() -> Result { } /// cmd 按 OEM 代码页读批处理;exe 路径含非 ASCII 字符时先切到 UTF-8。 +/// 路径里的 % 必须写成 %%,否则会和 %1 配对被当作变量展开吞掉。 fn render_header_shim(executable: &std::path::Path) -> String { - let exe = executable.display().to_string(); + let exe = executable.display().to_string().replace('%', "%%"); let codepage = if exe.is_ascii() { "" } else { "chcp 65001 >nul\r\n" }; format!("@echo off\r\n{codepage}\"{exe}\" --lianjing-mcp-header %1\r\nexit /b %ERRORLEVEL%\r\n") } @@ -306,7 +307,7 @@ fn try_register_globally(port: u16) -> bool { .output() { Ok(out) if out.status.success() => { - db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(MCP_KEY)); + db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(&global_log_context())); true } Ok(out) => { @@ -323,7 +324,7 @@ fn try_register_globally(port: u16) -> bool { "mcp_inject", "warn", &format!("claude mcp add 非零退出: {}", combined), - Some(MCP_KEY), + Some(&global_log_context()), ); false } @@ -332,13 +333,34 @@ fn try_register_globally(port: u16) -> bool { "mcp_inject", "warn", &format!("执行 claude mcp add 失败: {}", e), - Some(MCP_KEY), + Some(&global_log_context()), ); false } } } +/// 项目在 .lian-jing/context.json 声明 "mcp_scope": "global" 即为控制面项目(如炼境自身): +/// Claude 与 DSH 保留全局地址与跨项目工具,不写项目级覆盖(否则 local 条目会遮蔽全局注册)。 +fn project_wants_global_scope(root: &std::path::Path) -> bool { + read_context_object( + &root.join(".lian-jing").join("context.json"), + "canonical 项目上下文", + ) + .ok() + .and_then(|ctx| ctx.get("mcp_scope").and_then(Value::as_str).map(|scope| scope == "global")) + .unwrap_or(false) +} + +/// 注入日志的 context 必须是 JSON:get_diagnostics 按 json_extract(context,'$.project_id') 分组。 +fn inject_log_context(project_id: &str, client: &str) -> String { + json!({ "project_id": project_id, "client": client }).to_string() +} + +fn global_log_context() -> String { + json!({ "client": "claude-global", "server": MCP_KEY }).to_string() +} + /// Claude Code local scope 条目:项目专用地址 + 稳定 helper。local 优先于 user 级全局注册且不进仓库。 fn claude_local_server_json(server_url: &str, header_helper: &str) -> String { json!({ @@ -349,75 +371,201 @@ fn claude_local_server_json(server_url: &str, header_helper: &str) -> String { .to_string() } -/// 在项目目录以 local scope 注册 lian-jing(先删后加,幂等)。best-effort:失败只记日志, -/// 不影响 Codex 与项目文件注入;返回是否成功。 -fn try_register_claude_local(root: &std::path::Path, server_url: &str, header_helper: &str) -> bool { +#[derive(Debug, PartialEq)] +enum ClaudeLocalEntry { + Absent, + /// 炼境写入的条目(项目路由 + 稳定 helper),可安全替换或删除;附原值供失败回滚。 + Managed(Value), + /// 用户手写的同名条目:炼境不覆盖、不删除。 + Manual, +} + +/// ~/.claude.json 的项目键是 Claude 记录的路径(实测为正斜杠盘符形式),比较时统一分隔符与大小写。 +fn normalize_root_key(path: &str) -> String { + path.replace('\\', "/").trim_end_matches('/').to_lowercase() +} + +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) + }) + .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 + } +} + +fn read_claude_user_config() -> Option { + 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() +} + +fn run_claude_cli( + claude_bin: &str, + git_bash: &str, + 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(()) + } else { + Err(format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + )) + } +} + +/// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除炼境托管的条目。 +/// 手写条目一律不碰;替换失败时用原值回滚。best-effort,结果只记日志。 +fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(String, String)>) { + let context = inject_log_context(project_id, "claude"); let (Some(claude_bin), Some(git_bash)) = (find_claude_bin(), find_git_bash()) else { db::log_event( "mcp_inject", "warn", - "claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 注册", - None, + "claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步", + Some(&context), ); - return false; + return; }; - let _ = std::process::Command::new(&claude_bin) - .current_dir(root) - .args(["mcp", "remove", MCP_KEY, "--scope", "local"]) - .env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash) - .output(); - let payload = claude_local_server_json(server_url, header_helper); - match std::process::Command::new(&claude_bin) - .current_dir(root) - .args(["mcp", "add-json", MCP_KEY, &payload, "--scope", "local"]) - .env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash) - .output() - { - Ok(out) if out.status.success() => { - db::log_event("mcp_inject", "info", "Claude 项目级 MCP 注册成功", Some(MCP_KEY)); - true + 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, _) => { + Ok("本项目已有手写的 lian-jing local 条目,炼境不覆盖".to_string()) } - Ok(out) => { - let combined = format!( - "{}{}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr) - ); - db::log_event( - "mcp_inject", - "warn", - &format!("claude mcp add-json 非零退出: {combined}"), - Some(MCP_KEY), - ); - false + (ClaudeLocalEntry::Absent, None) => return, + (ClaudeLocalEntry::Managed(_), None) => { + run_claude_cli(&claude_bin, &git_bash, root, &remove_args) + .map(|_| "Claude 项目级 MCP 条目已移除".to_string()) } - Err(error) => { - db::log_event( - "mcp_inject", - "warn", - &format!("执行 claude mcp add-json 失败: {error}"), - Some(MCP_KEY), - ); - false + (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( + "mcp_inject", + "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) + }; + registered.map(|_| "Claude 项目级 MCP 注册成功".to_string()) } + }; + match outcome { + Ok(message) => db::log_event("mcp_inject", "info", &message, Some(&context)), + Err(error) => db::log_event( + "mcp_inject", + "warn", + &format!("Claude 项目级 MCP 同步失败: {error}"), + Some(&context), + ), } } // ── DSH 机器级配置托管块 ────────────────────────────────────────────────────── -// DSH 配置无法按项目拆分,但 !!js 在加载时以启动目录求值:在项目目录(或子目录)启动时连项目专用地址 -// 并经稳定 helper 携带认证;项目外回退全局地址,保持 GUI 等非项目启动方式的现有行为。 +// DSH 配置无法按项目拆分,但 !!js 在加载时以启动目录求值。项目身份只认炼境登记表:托管块内嵌 +// 「已登记项目根目录 → project_id」白名单(非秘密),从启动目录向上精确匹配根目录才连项目专用地址 +// 并经稳定 helper 携带认证;任意目录里的 .lian-jing/context.json 不作为授权依据。未命中则回退全局地址, +// 保持 GUI 等非项目启动方式的现有行为。项目增删、端口变更时重写白名单。 -fn render_dsh_block(port: u16) -> String { +/// 与 JS 端 path.resolve(d).toLowerCase() 对齐:反斜杠分隔、去尾部分隔符、小写。 +fn normalize_dsh_root(path: &str) -> String { + path.replace('/', "\\").trim_end_matches('\\').to_lowercase() +} + +/// 已接入(根目录 context.json 的 project_id 与登记一致)、非控制面、非 WSL 的 Windows 项目。 +fn dsh_project_roots( + conn: &rusqlite::Connection, + exclude: Option<&str>, +) -> Vec<(String, String)> { + let rows: Vec<(String, String)> = conn + .prepare( + "SELECT id, win_path FROM project_workspaces + WHERE win_path IS NOT NULL AND COALESCE(platform, '') != 'wsl' + ORDER BY id", + ) + .and_then(|mut stmt| { + stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .map(|rows| rows.filter_map(Result::ok).collect()) + }) + .unwrap_or_default(); + rows.into_iter() + .filter(|(id, _)| Some(id.as_str()) != exclude) + .filter_map(|(id, win_path)| { + let root = validate_registered_path(&win_path, "win_path").ok()?; + 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)) + }) + .collect() +} + +fn render_dsh_block(port: u16, roots: &[(String, String)]) -> String { // 逐行显式缩进:YAML 依赖缩进,不能用字符串续行(会吞掉下一行行首空白)。 fn indent(spaces: usize, lines: &[&str]) -> Vec { lines.iter().map(|line| format!("{}{line}", " ".repeat(spaces))).collect() } + let roots_map: serde_json::Map = roots + .iter() + .map(|(root, id)| (root.clone(), json!(id))) + .collect(); + let roots_line = format!("const roots = {};", Value::Object(roots_map)); let find_project_id = [ - "const fs = process.getBuiltinModule('fs'), path = process.getBuiltinModule('path');", + "const path = process.getBuiltinModule('path');", + roots_line.as_str(), "let id;", - "for (let d = process.cwd(), p; d !== p; p = d, d = path.dirname(d)) {", - " const f = path.join(d, '.lian-jing', 'context.json');", - " if (fs.existsSync(f)) { try { id = JSON.parse(fs.readFileSync(f, 'utf8')).project_id; } catch {} break; }", + "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; }", "}", ]; let global_url = format!("http://127.0.0.1:{port}/mcp"); @@ -425,22 +573,25 @@ fn render_dsh_block(port: u16) -> String { "return id ? '{global_url}/projects/' + encodeURIComponent(id) : '{global_url}';" ); let shim_line = format!( - "const shim = path.join(process.env.LOCALAPPDATA || '', 'lian-jing', 'bin', '{MCP_HEADER_SHIM_NAME}');" + "const shim = path.join(base, 'lian-jing', 'bin', '{MCP_HEADER_SHIM_NAME}');" ); let header_body = [ "if (!id) return {};", + "const base = process.env.LOCALAPPDATA, sys = process.env.SystemRoot;", + "if (!base || !path.isAbsolute(base) || !sys || !path.isAbsolute(sys)) return {};", + shim_line.as_str(), + "const cmd = path.join(sys, 'System32', 'cmd.exe');", "const crypto = process.getBuiltinModule('crypto'), cp = process.getBuiltinModule('child_process');", "const digest = crypto.createHash('sha256').update(id).digest('hex');", - shim_line.as_str(), "// Node 拒绝直接执行 .cmd(EINVAL),须经 cmd /d /s /c 原样传参。", - "const out = cp.execFileSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', '\"\"' + shim + '\" ' + digest + '\"'],", + "const out = cp.execFileSync(cmd, ['/d', '/s', '/c', '\"\"' + shim + '\" ' + digest + '\"'],", " { encoding: 'utf8', timeout: 15000, windowsHide: true, windowsVerbatimArguments: true, stdio: ['ignore', 'pipe', 'ignore'] });", "return { Authorization: String(JSON.parse(out).Authorization) };", ]; let mut lines: Vec = vec![ DSH_MANAGED_START.to_string(), - "# 炼境托管:按 DSH 启动目录自动连接项目专用地址并携带认证;项目外回退全局地址。勿手改,会被炼境覆盖。".into(), + "# 炼境托管:按 DSH 启动目录匹配已登记项目根目录,连项目专用地址并携带认证;未命中回退全局地址。勿手改,会被炼境覆盖。".into(), "- insert:".into(), " - id: mcp-lian-jing".into(), " name: '@deepseek-ai/dsh-mcp-client'".into(), @@ -474,32 +625,63 @@ fn render_dsh_block(port: u16) -> String { block } +/// 用 YAML 解析判断托管块之外是否已有 lian-jing 条目(id 或 serverName,任意引号/注释/流式写法)。 +/// 解析失败时保守拒绝写入,不冒险追加。 +fn has_manual_lian_jing(outside_block: &str) -> Result { + if outside_block.trim().is_empty() { + return Ok(false); + } + let doc: serde_yaml::Value = serde_yaml::from_str(outside_block) + .map_err(|error| format!("DSH 补丁文件无法解析,炼境不写入: {error}"))?; + let Some(items) = doc.as_sequence() else { + return Ok(false); + }; + Ok(items.iter().any(|item| { + item.get("insert") + .and_then(serde_yaml::Value::as_sequence) + .is_some_and(|entries| { + entries.iter().any(|entry| { + entry.get("id").and_then(serde_yaml::Value::as_str) == Some("mcp-lian-jing") + || entry + .get("config") + .and_then(|config| config.get("serverName")) + .and_then(serde_yaml::Value::as_str) + == Some(MCP_KEY) + }) + }) + })) +} + /// 纯函数:把托管块合并进 DSH 补丁文件内容。已有托管块则原位替换; /// 块外存在手写的 lian-jing 条目则拒绝覆盖(与 Codex 手工同名 server 的处理一致)。 fn upsert_dsh_block(current: &str, block: &str) -> Result { + const MANUAL: &str = "DSH 补丁文件已手工定义 lian-jing,炼境不会覆盖;移除手写条目后重试"; match (current.find(DSH_MANAGED_START), current.find(DSH_MANAGED_END)) { - (Some(start), Some(end)) if end >= start => Ok(format!( - "{}{}{}", - ¤t[..start], - block.trim_end_matches('\n'), - ¤t[end + DSH_MANAGED_END.len()..] - )), - (Some(_), _) | (_, Some(_)) => Err("DSH 补丁文件中的炼境托管块不完整或顺序错误".into()), - (None, None) - if current.lines().any(|line| { - let t = line.trim(); - t == "id: mcp-lian-jing" || t == format!("serverName: {MCP_KEY}") - }) => - { - Err("DSH 补丁文件已手工定义 lian-jing,炼境不会覆盖;移除手写条目后重试".into()) + (Some(start), Some(end)) if end >= start => { + let after = ¤t[end + DSH_MANAGED_END.len()..]; + if has_manual_lian_jing(&format!("{}{}", ¤t[..start], after))? { + return Err(MANUAL.into()); + } + Ok(format!( + "{}{}{}", + ¤t[..start], + block.trim_end_matches('\n'), + after + )) } + (Some(_), _) | (_, Some(_)) => Err("DSH 补丁文件中的炼境托管块不完整或顺序错误".into()), + (None, None) if has_manual_lian_jing(current)? => Err(MANUAL.into()), (None, None) if current.trim().is_empty() => Ok(block.to_string()), (None, None) => Ok(format!("{}\n{}", current.trim_end(), block)), } } -/// 未安装 DSH(无 ~/.dsh)时跳过;返回是否写入或已是最新。 -pub fn ensure_dsh_patch(port: u16) -> Result { +/// 未安装 DSH(无 ~/.dsh)时跳过;返回是否写入或已是最新。exclude 用于删除项目时提前剔除白名单。 +pub fn ensure_dsh_patch( + conn: &rusqlite::Connection, + port: u16, + exclude: Option<&str>, +) -> Result { let home = std::env::var("DSH_HOME").map(PathBuf::from).or_else(|_| { std::env::var("USERPROFILE") .map(|home| PathBuf::from(home).join(".dsh")) @@ -514,13 +696,30 @@ pub fn ensure_dsh_patch(port: u16) -> Result { Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), Err(error) => return Err(format!("读取 DSH 补丁文件失败: {error}")), }; - let next = upsert_dsh_block(¤t, &render_dsh_block(port))?; + let block = render_dsh_block(port, &dsh_project_roots(conn, exclude)); + let next = upsert_dsh_block(¤t, &block)?; if next != current { std::fs::write(&path, next).map_err(|error| format!("写入 DSH 补丁文件失败: {error}"))?; } Ok(true) } +/// 刷新 DSH 托管块的 best-effort 包装:失败只记日志。 +pub fn refresh_dsh_patch(exclude: Option<&str>) { + let result = db::pool() + .get() + .map_err(|error| error.to_string()) + .and_then(|conn| ensure_dsh_patch(&conn, crate::mcp_server::read_port(), exclude)); + if let Err(error) = result { + db::log_event( + "mcp_inject", + "warn", + &format!("DSH 托管块刷新失败: {error}"), + Some(&json!({ "client": "dsh" }).to_string()), + ); + } +} + /// 清理旧组级全局注册(lian-jing-{gid},含完整 UUID 与短 8 位两种历史命名)。 /// 幂等 best-effort:不存在的名字 remove 失败仅忽略。返回成功移除的名字列表。 pub fn cleanup_legacy_global_registrations() -> Vec { @@ -567,7 +766,7 @@ pub fn cleanup_legacy_global_registrations() -> Vec { .map(|out| out.status.success()) .unwrap_or(false); if ok { - db::log_event("mcp_inject", "info", "旧组级 MCP 注册已移除", Some(&name)); + db::log_event("mcp_inject", "info", "旧组级 MCP 注册已移除", Some(&json!({ "client": "claude-global", "server": &name }).to_string())); removed.push(name); } } @@ -734,14 +933,25 @@ pub fn inject_project_mcp(project_id: &str) -> Result<(), String> { // 同步注册到 ~/.claude.json:user 级全局注册作兜底,local 级项目条目优先生效(均 best-effort)。 // 项目 .claude/settings.json 的 mcpServers 不被 Claude Code 读取,仅保留作兼容。 + // 控制面项目(mcp_scope=global)不写 local 条目,并清理炼境曾写入的旧条目,保留全局与跨项目工具。 try_register_globally(port); - try_register_claude_local(&project_root(project_id)?, &server_url, &header_helper); + 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); Ok(()) } /// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。 /// 调用方需自行保证项目已不属于任何产品组时才调用。 pub fn remove_project_mcp(project_id: &str) -> Result<(), String> { + // 先于登记删除清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。 + if let Ok(root) = project_root(project_id) { + sync_claude_local(project_id, &root, None); + } + refresh_dsh_patch(Some(project_id)); remove_codex_mcp_config(&codex_config_path(project_id)?)?; let path = settings_path(project_id)?; if path.exists() { @@ -837,10 +1047,8 @@ pub fn reinject_all_mcp() -> Vec { errors.push(format!("项目 {}: {}", project_id, e)); } } - // DSH 托管块内嵌端口,端口变更后一并刷新 - if let Err(e) = ensure_dsh_patch(crate::mcp_server::read_port()) { - errors.push(format!("DSH: {e}")); - } + // 每次注入已刷新 DSH 托管块(内嵌端口与白名单);此处兜底一次,失败只记日志 + refresh_dsh_patch(None); errors } @@ -969,6 +1177,61 @@ mod tests { ); let unicode = render_header_shim(std::path::Path::new("C:\\炼境\\app.exe")); assert!(unicode.contains("chcp 65001 >nul\r\n"), "非 ASCII 路径须切 UTF-8 代码页"); + let percent = render_header_shim(std::path::Path::new("C:\\Users\\a%b\\app.exe")); + 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())); + 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 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 }); + if let Some(scope) = scope { + ctx["mcp_scope"] = json!(scope); + } + std::fs::write(root.join(".lian-jing/context.json"), ctx.to_string()).unwrap(); + conn.execute( + "INSERT INTO project_profiles (id, name) VALUES (?1, 'QA')", + [format!("{id}-profile")], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id, profile_id, win_path) VALUES (?1, ?2, ?3)", + rusqlite::params![id, format!("{id}-profile"), root.display().to_string()], + ) + .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 _ = std::fs::remove_dir_all(&base); } #[test] @@ -984,16 +1247,20 @@ mod tests { } #[test] - fn dsh_block_is_valid_yaml_with_cwd_routing_and_shim_auth() { - let block = render_dsh_block(27190); + 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 block = render_dsh_block(27190, &roots); 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';"), "项目外回退全局地址"); + assert!(block.contains(": 'http://127.0.0.1:27190/mcp';"), "未命中回退全局地址"); + assert!(block.contains("path.isAbsolute(base)") && block.contains("'System32', 'cmd.exe'")); assert!(block.contains(MCP_HEADER_SHIM_NAME) && block.contains("windowsVerbatimArguments: true")); assert!(!block.contains("Bearer "), "托管块不得含任何 token"); } @@ -1001,18 +1268,26 @@ mod tests { #[test] fn dsh_block_upsert_preserves_user_entries_and_refuses_manual_lian_jing() { let user = "- insert:\n - id: mcp-yljt\n config:\n serverName: yljt\n"; - let first = upsert_dsh_block(user, &render_dsh_block(27190)).unwrap(); + let first = upsert_dsh_block(user, &render_dsh_block(27190, &[])).unwrap(); assert!(first.starts_with(user.trim_end()), "用户条目原样保留"); let parsed: serde_yaml::Value = serde_yaml::from_str(&first).unwrap(); assert_eq!(parsed.as_sequence().unwrap().len(), 2); - let moved = upsert_dsh_block(&first, &render_dsh_block(28000)).unwrap(); + let moved = upsert_dsh_block(&first, &render_dsh_block(28000, &[])).unwrap(); assert!(moved.contains("127.0.0.1:28000") && !moved.contains("127.0.0.1:27190")); assert_eq!(moved.matches(DSH_MANAGED_START).count(), 1, "原位替换不重复追加"); - assert_eq!(upsert_dsh_block(&moved, &render_dsh_block(28000)).unwrap(), moved, "幂等"); + assert_eq!(upsert_dsh_block(&moved, &render_dsh_block(28000, &[])).unwrap(), moved, "幂等"); - let manual = "- insert:\n - id: mcp-lian-jing\n config:\n serverName: lian-jing\n"; - assert!(upsert_dsh_block(manual, &render_dsh_block(27190)).is_err()); + for manual in [ + "- insert:\n - id: mcp-lian-jing\n config:\n serverName: lian-jing\n", + "- insert:\n - id: other\n config:\n serverName: 'lian-jing' # 手写\n", + "- insert: [{id: mcp-lian-jing, config: {serverName: x}}]\n", + ] { + assert!(upsert_dsh_block(manual, &render_dsh_block(27190, &[])).is_err(), "{manual}"); + let with_block = format!("{manual}{}", render_dsh_block(27190, &[])); + assert!(upsert_dsh_block(&with_block, &render_dsh_block(27190, &[])).is_err(), "块外手写也须拒绝"); + } + assert!(upsert_dsh_block("- insert: [unclosed\n", "b").is_err(), "无法解析时保守拒绝"); assert!(upsert_dsh_block(&format!("{DSH_MANAGED_START}\n- x\n"), "b").is_err()); assert_eq!(upsert_dsh_block("", "BLOCK\n").unwrap(), "BLOCK\n"); } diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index d7fde69..b0c3ae3 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -1603,6 +1603,11 @@ async fn handle_scoped_sse_message( headers: HeaderMap, Json(req): Json, ) -> Response { + // 握手后项目可能被删除:每条消息复核登记,失效会话一并移除。 + if !scoped_project_registered(&project_id) { + sessions.lock().await.remove(&query.session_id); + return unknown_project_response(&project_id, req.id.clone()); + } if authorize_sensitive_request( &req, Some(&project_id), diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 90f9ccf..b389e77 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -1292,6 +1292,8 @@ fn get_diagnostics() -> Result { AND id IN ( SELECT MAX(id) FROM runtime_logs WHERE category = 'mcp_inject' + AND json_valid(context) + AND json_extract(context, '$.project_id') IS NOT NULL GROUP BY json_extract(context, '$.project_id') ) ORDER BY ts DESC