From ebf43d71c0db4801702056523f832adccff5d48e Mon Sep 17 00:00:00 2001 From: lanrtop Date: Thu, 1 Oct 2026 10:40:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(mcp/parity):=20=E4=B8=89=E7=AB=AF=E7=BB=8F?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=20helper=20=E5=85=A5=E5=8F=A3=E8=BF=9E?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E4=B8=93=E7=94=A8=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P3:炼境启动写 %LOCALAPPDATA%\lian-jing\bin\lianjing-mcp-header.cmd 指向当前 exe; Codex 项目配置改为引用该入口,不再写本机 exe 绝对路径;verify_onboarding 对旧写法报可升级 - P1:inject_mcp 以 local scope 为 Claude 注册项目专用地址 + headersHelper(不进仓库); DSH 机器级补丁写托管块,!!js 按启动目录选项目地址并经 cmd 调用同一入口,项目外回退全局 - 服务端:scoped route 拒绝未登记的 project_id(404,数据库不可用同样拒绝) Feature-Confirmed: true Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- docs/ai-context/project-context.yaml | 2 +- src-tauri/src/commands/onboarding.rs | 41 +++- src-tauri/src/lib.rs | 9 + src-tauri/src/mcp_inject.rs | 312 ++++++++++++++++++++++++++- src-tauri/src/mcp_server.rs | 75 ++++++- 6 files changed, 429 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ecefa32..60e869a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ src-tauri/src/credential_store.rs Windows 本机受保护凭证存储与旧值 src-tauri/src/mcp_server.rs MCP Server 路由层 + SSE 协议(agent 调用炼境的入口) src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具清单以 tools_list_result 为准) src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮) -src-tauri/src/mcp_inject.rs 接入注入(写入 canonical `.lian-jing/context.json`、`.codex/config.toml` 与 Claude 兼容配置) +src-tauri/src/mcp_inject.rs 接入注入与三端适配(canonical context、Codex 项目配置、Claude local 条目、DSH 托管块、%LOCALAPPDATA% 稳定 helper 入口) src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow) .blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡) docs/ai-context/ AI 上下文文档(速查卡、契约映射) diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 62ded75..16307fd 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 拒绝跨项目参数和全局管理写工具。旧 /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 托管块(!!js 按启动目录选项目地址,项目外回退全局)。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" - label: MCP 工具接口 paths: diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 7411b06..f230bc0 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -670,6 +670,23 @@ fn codex_mcp_server_url(content: &str) -> Option<&str> { None } +/// 旧注入把本机 exe 绝对路径写进 helper(换构建即失效、换机器即错);新注入只引用 +/// %LOCALAPPDATA% 下的稳定入口。只判定显式的旧写法,未配置 helper 不在此处判定。 +fn codex_helper_uses_machine_path(content: &str) -> bool { + let mut in_lian_jing = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_lian_jing = trimmed == "[mcp_servers.lian-jing]"; + continue; + } + if in_lian_jing && trimmed.starts_with("http_headers_helper") { + return trimmed.contains("--lianjing-mcp-header") && !trimmed.contains("%LOCALAPPDATA%"); + } + } + false +} + pub(crate) fn check_codex_mcp( content: Option<&str>, expected_project_id: &str, @@ -684,7 +701,13 @@ pub(crate) fn check_codex_mcp( }; let expected_url = crate::mcp_server::scoped_mcp_url(expected_port, expected_project_id); let configured_url = codex_mcp_server_url(content); - if configured_url == Some(expected_url.as_str()) { + if configured_url == Some(expected_url.as_str()) && codex_helper_uses_machine_path(content) { + VerifyCheck::with_state( + "Codex MCP 已配置", + VerifyState::Legacy, + "helper 仍写着本机 exe 绝对路径(换构建即失效,且会随仓库提交)——调用 inject_mcp 升级为 %LOCALAPPDATA% 稳定入口", + ) + } else if configured_url == Some(expected_url.as_str()) { VerifyCheck::with_state( "Codex MCP 已配置", VerifyState::Ready, @@ -1501,6 +1524,22 @@ mod tests { assert_eq!(wrong_port.state, VerifyState::Missing); } + #[test] + fn codex_mcp_check_flags_machine_path_helper_for_upgrade() { + let url = "url = \"http://127.0.0.1:27190/mcp/projects/p1\"\n"; + let machine = format!( + "[mcp_servers.lian-jing]\n{url}http_headers_helper = \"\\\"E:\\\\x\\\\target\\\\debug\\\\app.exe\\\" --lianjing-mcp-header d1\"\n" + ); + let legacy = check_codex_mcp(Some(&machine), "p1", 27190); + assert_eq!(legacy.state, VerifyState::Legacy); + assert!(legacy.detail.contains("%LOCALAPPDATA%")); + + let stable = format!( + "[mcp_servers.lian-jing]\n{url}http_headers_helper = \"\\\"%LOCALAPPDATA%\\\\lian-jing\\\\bin\\\\lianjing-mcp-header.cmd\\\" d1\"\n" + ); + assert_eq!(check_codex_mcp(Some(&stable), "p1", 27190).state, VerifyState::Ready); + } + /// 回归:空路径登记曾把接入包写进炼境进程 cwd(src-tauri/,2026-07-15 批量实调实锤)。 #[test] fn apply_rejects_empty_or_missing_path() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d9dba65..b1e5342 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -116,6 +116,15 @@ pub fn run() { let mcp_port = mcp_server::read_port(); tauri::async_runtime::spawn(mcp_server::start(mcp_port)); + // ── 三端共用的稳定 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); + } + // ── MCP 单一服务器化一次性迁移(settings 标记位防重复)──── std::thread::spawn(|| { std::thread::sleep(std::time::Duration::from_secs(10)); // 等 DB 就绪 diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 433563b..d4e1736 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -9,6 +9,52 @@ const LEGACY_KEY_PREFIX: &str = "lian-jing:group-"; const CODEX_MCP_MANAGED_START: &str = "# LIANJING_MCP_MANAGED_START"; const CODEX_MCP_MANAGED_END: &str = "# LIANJING_MCP_MANAGED_END"; const CONTEXT_SCHEMA_VERSION: u8 = 1; +/// 稳定 helper 入口文件名:位于 %LOCALAPPDATA%\lian-jing\bin\,每次启动指向当前 exe。 +const MCP_HEADER_SHIM_NAME: &str = "lianjing-mcp-header.cmd"; +const DSH_MANAGED_START: &str = "# LIANJING_DSH_MANAGED_START"; +const DSH_MANAGED_END: &str = "# LIANJING_DSH_MANAGED_END"; + +// ── 稳定 helper 入口(三端共用)──────────────────────────────────────────────── +// Codex 与 Claude 在 Windows 都经 cmd.exe 执行 helper 并展开 %LOCALAPPDATA%;DSH 由 !!js 经 cmd 调用。 +// 项目配置只引用 %LOCALAPPDATA% 下的固定入口,不含本机 exe 路径与用户名,换构建无需逐项目重注入。 + +fn header_shim_path() -> Result { + let base = std::env::var("LOCALAPPDATA") + .map_err(|_| "LOCALAPPDATA 未设置,无法写入 MCP helper 入口".to_string())?; + Ok(PathBuf::from(base) + .join("lian-jing") + .join("bin") + .join(MCP_HEADER_SHIM_NAME)) +} + +/// cmd 按 OEM 代码页读批处理;exe 路径含非 ASCII 字符时先切到 UTF-8。 +fn render_header_shim(executable: &std::path::Path) -> String { + let exe = executable.display().to_string(); + 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") +} + +/// 写入或更新稳定 helper 入口;内容未变不重写。炼境启动与每次注入时调用。 +pub fn ensure_header_shim() -> Result { + let path = header_shim_path()?; + let executable = std::env::current_exe() + .map_err(|_| "无法定位炼境可执行文件,拒绝写入 MCP helper 入口".to_string())?; + let content = render_header_shim(&executable); + if std::fs::read_to_string(&path).ok().as_deref() != Some(content.as_str()) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("创建 MCP helper 入口目录失败: {error}"))?; + } + std::fs::write(&path, content) + .map_err(|error| format!("写入 MCP helper 入口失败: {error}"))?; + } + Ok(path) +} + +/// 客户端配置中的 helper 命令:只含 %LOCALAPPDATA% 与非秘密 digest,可安全提交进项目仓库。 +fn shim_helper_command(digest: &str) -> String { + format!("\"%LOCALAPPDATA%\\lian-jing\\bin\\{MCP_HEADER_SHIM_NAME}\" {digest}") +} // ── 路径解析 ────────────────────────────────────────────────────────────────── @@ -293,6 +339,188 @@ fn try_register_globally(port: u16) -> bool { } } +/// Claude Code local scope 条目:项目专用地址 + 稳定 helper。local 优先于 user 级全局注册且不进仓库。 +fn claude_local_server_json(server_url: &str, header_helper: &str) -> String { + json!({ + "type": "http", + "url": server_url, + "headersHelper": header_helper, + }) + .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 { + 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, + ); + return false; + }; + 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 + } + 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 + } + Err(error) => { + db::log_event( + "mcp_inject", + "warn", + &format!("执行 claude mcp add-json 失败: {error}"), + Some(MCP_KEY), + ); + false + } + } +} + +// ── DSH 机器级配置托管块 ────────────────────────────────────────────────────── +// DSH 配置无法按项目拆分,但 !!js 在加载时以启动目录求值:在项目目录(或子目录)启动时连项目专用地址 +// 并经稳定 helper 携带认证;项目外回退全局地址,保持 GUI 等非项目启动方式的现有行为。 + +fn render_dsh_block(port: u16) -> String { + // 逐行显式缩进:YAML 依赖缩进,不能用字符串续行(会吞掉下一行行首空白)。 + fn indent(spaces: usize, lines: &[&str]) -> Vec { + lines.iter().map(|line| format!("{}{line}", " ".repeat(spaces))).collect() + } + let find_project_id = [ + "const fs = process.getBuiltinModule('fs'), path = process.getBuiltinModule('path');", + "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; }", + "}", + ]; + let global_url = format!("http://127.0.0.1:{port}/mcp"); + let url_return = format!( + "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}');" + ); + let header_body = [ + "if (!id) return {};", + "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 + '\"'],", + " { 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(), + "- insert:".into(), + " - id: mcp-lian-jing".into(), + " name: '@deepseek-ai/dsh-mcp-client'".into(), + " config:".into(), + " transport: streamable-http".into(), + format!(" serverName: {MCP_KEY}"), + " url: !!js |".into(), + " (() => {".into(), + ]; + lines.extend(indent(12, &find_project_id)); + lines.extend(indent(12, &[url_return.as_str()])); + lines.extend(indent(10, &["})()"])); + lines.extend(indent(8, &["headers: !!js |"])); + lines.extend(indent(10, &["(() => {"])); + lines.extend(indent(12, &["try {"])); + lines.extend(indent(14, &find_project_id)); + lines.extend(indent(14, &header_body)); + lines.extend(indent(12, &["} catch (e) {"])); + lines.extend(indent( + 14, + &[ + "process.stderr.write('[lian-jing] mcp header helper failed: status=' + (e.status ?? '') + ' code=' + (e.code ?? '') + '\\n');", + "return {};", + ], + )); + lines.extend(indent(12, &["}"])); + lines.extend(indent(10, &["})()"])); + lines.push(DSH_MANAGED_END.to_string()); + let mut block = lines.join("\n"); + block.push('\n'); + block +} + +/// 纯函数:把托管块合并进 DSH 补丁文件内容。已有托管块则原位替换; +/// 块外存在手写的 lian-jing 条目则拒绝覆盖(与 Codex 手工同名 server 的处理一致)。 +fn upsert_dsh_block(current: &str, block: &str) -> Result { + 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()) + } + (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 { + let home = std::env::var("DSH_HOME").map(PathBuf::from).or_else(|_| { + std::env::var("USERPROFILE") + .map(|home| PathBuf::from(home).join(".dsh")) + .map_err(|_| "USERPROFILE 未设置".to_string()) + })?; + if !home.is_dir() { + return Ok(false); + } + let path = home.join("cordis.patch.yml"); + let current = match std::fs::read_to_string(&path) { + Ok(value) => value, + 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))?; + if next != current { + std::fs::write(&path, next).map_err(|error| format!("写入 DSH 补丁文件失败: {error}"))?; + } + Ok(true) +} + /// 清理旧组级全局注册(lian-jing-{gid},含完整 UUID 与短 8 位两种历史命名)。 /// 幂等 best-effort:不存在的名字 remove 失败仅忽略。返回成功移除的名字列表。 pub fn cleanup_legacy_global_registrations() -> Vec { @@ -471,12 +699,8 @@ pub fn inject_project_mcp(project_id: &str) -> Result<(), String> { &crate::credential_store::WindowsCredentialStore, project_id, )?; - let executable = std::env::current_exe() - .map_err(|_| "无法定位炼境可执行文件,拒绝写入 MCP 认证配置".to_string())?; - let header_helper = format!( - "\"{}\" --lianjing-mcp-header {digest}", - executable.display() - ); + ensure_header_shim()?; + let header_helper = shim_helper_command(&digest); write_canonical_context(project_id)?; upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url, &header_helper)?; let path = settings_path(project_id)?; @@ -508,8 +732,10 @@ pub fn inject_project_mcp(project_id: &str) -> Result<(), String> { // 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。 write_liangjing_context(project_id, port)?; - // 同步注册到 ~/.claude.json,让 Claude Code 能加载 MCP 工具(best-effort) + // 同步注册到 ~/.claude.json:user 级全局注册作兜底,local 级项目条目优先生效(均 best-effort)。 + // 项目 .claude/settings.json 的 mcpServers 不被 Claude Code 读取,仅保留作兼容。 try_register_globally(port); + try_register_claude_local(&project_root(project_id)?, &server_url, &header_helper); Ok(()) } @@ -611,6 +837,10 @@ 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}")); + } errors } @@ -719,6 +949,74 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + #[test] + fn shim_helper_command_has_no_machine_path() { + let helper = shim_helper_command("qa-digest"); + assert_eq!( + helper, + "\"%LOCALAPPDATA%\\lian-jing\\bin\\lianjing-mcp-header.cmd\" qa-digest" + ); + assert!(!helper.contains(":\\"), "不得含盘符绝对路径"); + assert!(!helper.to_lowercase().contains("users"), "不得含用户目录"); + } + + #[test] + fn header_shim_forwards_digest_and_exit_code() { + let ascii = render_header_shim(std::path::Path::new("C:\\Apps\\Lian Jing\\app.exe")); + assert_eq!( + ascii, + "@echo off\r\n\"C:\\Apps\\Lian Jing\\app.exe\" --lianjing-mcp-header %1\r\nexit /b %ERRORLEVEL%\r\n" + ); + let unicode = render_header_shim(std::path::Path::new("C:\\炼境\\app.exe")); + assert!(unicode.contains("chcp 65001 >nul\r\n"), "非 ASCII 路径须切 UTF-8 代码页"); + } + + #[test] + fn claude_local_entry_uses_scoped_url_and_shim() { + let entry: Value = serde_json::from_str(&claude_local_server_json( + "http://127.0.0.1:27190/mcp/projects/p1", + &shim_helper_command("d1"), + )) + .unwrap(); + assert_eq!(entry["type"], "http"); + assert_eq!(entry["url"], "http://127.0.0.1:27190/mcp/projects/p1"); + assert!(entry["headersHelper"].as_str().unwrap().contains("%LOCALAPPDATA%")); + } + + #[test] + fn dsh_block_is_valid_yaml_with_cwd_routing_and_shim_auth() { + let block = render_dsh_block(27190); + 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("'http://127.0.0.1:27190/mcp/projects/' + encodeURIComponent(id)")); + assert!(block.contains(": 'http://127.0.0.1:27190/mcp';"), "项目外回退全局地址"); + assert!(block.contains(MCP_HEADER_SHIM_NAME) && block.contains("windowsVerbatimArguments: true")); + assert!(!block.contains("Bearer "), "托管块不得含任何 token"); + } + + #[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(); + 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(); + 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, "幂等"); + + 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()); + assert!(upsert_dsh_block(&format!("{DSH_MANAGED_START}\n- x\n"), "b").is_err()); + assert_eq!(upsert_dsh_block("", "BLOCK\n").unwrap(), "BLOCK\n"); + } + #[test] fn registered_path_accepts_absolute_and_unc() { assert!(validate_registered_path("E:\\code-project\\demo", "win_path").is_ok()); diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 52b80aa..d7fde69 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -318,11 +318,43 @@ async fn handle_mcp(headers: HeaderMap, Json(req): Json) -> Response } } +/// 项目专用路由只服务已登记项目。未知 project_id 一律拒绝:否则会给 agent 一套看似可用、 +/// 实则每次调用都越权失败的工具集(2026-09-30 实测 /mcp/projects/<任意字符串> 返回完整 32 个工具)。 +/// 数据库不可用时同样拒绝(fail closed)。 +fn scoped_project_registered(project_id: &str) -> bool { + crate::db::pool() + .get() + .ok() + .and_then(|conn| { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM project_workspaces WHERE id = ?1)", + [project_id], + |row| row.get::<_, bool>(0), + ) + .ok() + }) + .unwrap_or(false) +} + +fn unknown_project_response(project_id: &str, id: Option) -> Response { + ( + StatusCode::NOT_FOUND, + Json(json!({ + "jsonrpc": "2.0", "id": id, + "error": { "code": -32001, "message": format!("项目未登记: {project_id}(检查 .lian-jing/context.json 或重跑 inject_mcp)") } + })), + ) + .into_response() +} + async fn handle_scoped_mcp( Path(project_id): Path, headers: HeaderMap, Json(req): Json, ) -> Response { + if !scoped_project_registered(&project_id) { + return unknown_project_response(&project_id, req.id.clone()); + } if authorize_sensitive_request( &req, Some(&project_id), @@ -377,7 +409,10 @@ async fn handle_sse_connect( async fn handle_scoped_sse_connect( Path(project_id): Path, State(sessions): State, -) -> Sse>> { +) -> Response { + if !scoped_project_registered(&project_id) { + return unknown_project_response(&project_id, None); + } let session_id = uuid::Uuid::new_v4().to_string(); let (tx, rx) = mpsc::channel::<(String, String)>(32); let endpoint_uri = scoped_sse_messages_url(read_port(), &project_id, &session_id); @@ -392,7 +427,9 @@ async fn handle_scoped_sse_connect( let stream = ReceiverStream::new(rx).map(|(evt_name, data)| { Ok::(Event::default().event(evt_name).data(data)) }); - Sse::new(stream).keep_alive(KeepAlive::default()) + Sse::new(stream) + .keep_alive(KeepAlive::default()) + .into_response() } #[cfg(test)] @@ -1455,6 +1492,40 @@ mod tests { assert!(has_initialized_project_client(project_id)); } + #[tokio::test] + async fn scoped_route_rejects_unregistered_project() { + db::init_test_pool(); + let known = format!("scoped-known-{}", uuid::Uuid::new_v4()); + { + let conn = db::pool().get().unwrap(); + conn.execute( + "INSERT INTO project_profiles (id, name) VALUES (?1, 'Scoped Known')", + [format!("{known}-profile")], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id, profile_id) VALUES (?1, ?2)", + rusqlite::params![known, format!("{known}-profile")], + ) + .unwrap(); + } + let unknown = handle_scoped_mcp( + Path("__not_registered__".to_string()), + HeaderMap::new(), + Json(rpc("tools/list")), + ) + .await; + assert_eq!(unknown.status(), StatusCode::NOT_FOUND); + + let registered = + handle_scoped_mcp(Path(known.clone()), HeaderMap::new(), Json(rpc("tools/list"))).await; + assert_eq!(registered.status(), StatusCode::OK); + + let sessions: Sessions = Arc::new(Mutex::new(HashMap::new())); + let sse = handle_scoped_sse_connect(Path("__not_registered__".to_string()), State(sessions)).await; + assert_eq!(sse.status(), StatusCode::NOT_FOUND); + } + #[test] fn loopback_urls_share_one_ipv4_base() { let port = 27190;