dev-manager-tauri/src-tauri/src/mcp_inject.rs
2026-09-21 18:16:32 +09:00

695 lines
26 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use crate::{db, db::log_event};
use serde_json::{json, Value};
use std::path::PathBuf;
/// 单一全局服务器的 settings.json key / 全局注册名
const MCP_KEY: &str = "lian-jing";
/// 旧组级 key 前缀——仅用于注入时清理存量配置,T6 收尾后可删
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;
// ── 路径解析 ──────────────────────────────────────────────────────────────────
/// 校验并解析登记路径:空串/空白/相对路径一律拒绝。
/// 空路径经 PathBuf::from("") 会解析到炼境进程 cwd——2026-07-15 空路径登记
/// 曾把 settings.json/liangjing.json 写进 src-tauri/(apply 链路同日已拦,
/// inject 链路本处单点补防)。
fn validate_registered_path(raw: &str, field: &str) -> Result<PathBuf, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(format!("{field} 为空,拒绝解析(会写进炼境进程 cwd)"));
}
let path = PathBuf::from(trimmed);
if !path.is_absolute() {
return Err(format!("{field} 非绝对路径,拒绝解析: {trimmed:?}"));
}
Ok(path)
}
/// 解析项目根目录路径(WSL 项目自动转为 Windows UNC 路径)
fn project_root(project_id: &str) -> Result<PathBuf, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
project_root_with_conn(&conn, project_id)
}
/// project_root 的依赖注入版:连接由调用方传入(业务层复用 + in-memory 测试)
pub(crate) fn project_root_with_conn(
conn: &rusqlite::Connection,
project_id: &str,
) -> Result<PathBuf, String> {
let row: rusqlite::Result<(Option<String>, Option<String>, Option<String>)> = conn.query_row(
"SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1",
[project_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
);
let (win_path, wsl_path, platform) = row.map_err(|_| format!("项目 {} 不存在", project_id))?;
if platform.as_deref() == Some("wsl") {
let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?;
if wsl.trim().is_empty() {
return Err("wsl_path 为空,拒绝解析(会指向 WSL 发行版根目录)".to_string());
}
let distro: String = conn
.query_row(
"SELECT value FROM settings WHERE key = 'wsl_distro'",
[],
|r| r.get(0),
)
.unwrap_or_else(|_| "Ubuntu".to_string());
let inner = wsl.trim_start_matches('/').replace('/', "\\");
Ok(PathBuf::from(format!(
"\\\\wsl.localhost\\{}\\{}",
distro, inner
)))
} else {
let win = win_path.ok_or_else(|| "Windows 项目未配置 win_path".to_string())?;
validate_registered_path(&win, "win_path")
}
}
fn settings_path(project_id: &str) -> Result<PathBuf, String> {
Ok(project_root(project_id)?
.join(".claude")
.join("settings.json"))
}
fn liangjing_path(project_id: &str) -> Result<PathBuf, String> {
Ok(project_root(project_id)?
.join(".claude")
.join("liangjing.json"))
}
fn codex_config_path(project_id: &str) -> Result<PathBuf, String> {
Ok(project_root(project_id)?.join(".codex").join("config.toml"))
}
fn read_context_object(path: &PathBuf, label: &str) -> Result<Value, String> {
match std::fs::read_to_string(path) {
Ok(content) if content.trim().is_empty() => Ok(json!({})),
Ok(content) => serde_json::from_str::<Value>(&content)
.map_err(|error| format!("读取 {label} 失败({}): {error}", path.display()))
.and_then(|value| {
if value.is_object() {
Ok(value)
} else {
Err(format!(
"{label} 顶层必须是 JSON 对象({})",
path.display()
))
}
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(json!({})),
Err(error) => Err(format!("读取 {label} 失败({}): {error}", path.display())),
}
}
fn write_context_object(path: &PathBuf, value: &Value, label: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("创建 {label} 目录失败: {error}"))?;
}
let content = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?;
std::fs::write(path, content).map_err(|error| format!("写入 {label} 失败: {error}"))
}
/// 写工具无关的项目身份锚点。项目根目录是边界,客户端项目 UI 或用户级配置均不是身份来源。
/// 合并写入:保留未来字段与项目定制字段,只有炼境拥有的身份字段会被刷新。
fn write_canonical_context_at(root: &std::path::Path, project_id: &str) -> Result<(), String> {
let path = root.join(".lian-jing").join("context.json");
let mut ctx = read_context_object(&path, "canonical 项目上下文")?;
let obj = ctx.as_object_mut().expect("ctx 必为 JSON 对象");
obj.insert("schema_version".into(), json!(CONTEXT_SCHEMA_VERSION));
obj.insert("project_id".into(), json!(project_id));
write_context_object(&path, &ctx, "canonical 项目上下文")
}
fn write_canonical_context(project_id: &str) -> Result<(), String> {
let root = project_root(project_id)?;
write_canonical_context_at(&root, project_id)
}
/// 写旧 Claude 上下文投影。它不是项目身份的单一事实来源,保留是为了兼容已接入项目。
fn write_liangjing_context(project_id: &str, port: u16) -> Result<(), String> {
let path = liangjing_path(project_id)?;
let mut ctx = read_context_object(&path, "Claude 兼容上下文")?;
let obj = ctx.as_object_mut().expect("ctx 必为 JSON 对象");
obj.insert("project_id".into(), json!(project_id));
obj.insert("mcp_port".into(), json!(port));
obj.remove("group_id");
write_context_object(&path, &ctx, "Claude 兼容上下文")
}
/// 查找 claude CLI 路径(优先环境变量 CLAUDE_BIN,然后常用安装目录,最后 where 命令)
fn find_claude_bin() -> Option<String> {
if let Ok(path) = std::env::var("CLAUDE_BIN") {
if std::path::Path::new(&path).exists() {
return Some(path);
}
}
let home = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.unwrap_or_default();
let candidates = [
format!(r"{}\.local\bin\claude.exe", home),
format!(r"{}\AppData\Local\AnthropicClaude\claude.exe", home),
r"C:\Program Files\AnthropicClaude\claude.exe".to_string(),
];
for c in &candidates {
if std::path::Path::new(c).exists() {
return Some(c.clone());
}
}
// 最后尝试 where 命令(GUI 进程 PATH 可能不含用户 bin,but worth trying)
if let Ok(out) = std::process::Command::new("where")
.arg("claude.exe")
.output()
{
if out.status.success() {
if let Ok(stdout) = String::from_utf8(out.stdout) {
if let Some(line) = stdout.lines().next() {
return Some(line.trim().to_string());
}
}
}
}
None
}
/// 查找 git-bash 路径(claude CLI 在 Windows 上必需)。
/// 顺序:环境变量(校验存在)→ 常见安装位置(Program Files / Scoop)→ 从 git.exe 位置推导
fn find_git_bash() -> Option<String> {
if let Ok(p) = std::env::var("CLAUDE_CODE_GIT_BASH_PATH") {
if std::path::Path::new(&p).exists() {
return Some(p);
}
}
let home = std::env::var("USERPROFILE").unwrap_or_default();
let scoop = std::env::var("SCOOP").unwrap_or_else(|_| format!(r"{}\scoop", home));
let candidates = [
r"C:\Program Files\Git\bin\bash.exe".to_string(),
format!(r"{}\apps\git\current\bin\bash.exe", scoop),
];
for c in &candidates {
if std::path::Path::new(c).exists() {
return Some(c.clone());
}
}
// 从 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 out.status.success() {
for line in String::from_utf8_lossy(&out.stdout).lines() {
let git = std::path::Path::new(line.trim());
if let Some(root) = git.parent().and_then(|d| d.parent()) {
let bash = root.join("bin").join("bash.exe");
if bash.exists() {
return Some(bash.to_string_lossy().to_string());
}
}
}
}
}
None
}
/// 将单一全局 lian-jing 服务器注册到 Claude 全局配置(~/.claude.json)。
/// best-effort,失败仅记录日志;返回是否成功(同名已存在视为成功,作为 CLI 链路健康信号)
fn try_register_globally(port: u16) -> bool {
let server_url = format!("http://127.0.0.1:{}/mcp", port);
let Some(claude_bin) = find_claude_bin() else {
db::log_event(
"mcp_inject",
"warn",
"claude CLI 未找到,跳过全局 MCP 注册",
None,
);
return false;
};
let Some(git_bash) = find_git_bash() else {
db::log_event(
"mcp_inject",
"warn",
"git-bash 未找到(claude CLI 依赖),跳过全局 MCP 注册",
None,
);
return false;
};
match std::process::Command::new(&claude_bin)
.args([
"mcp",
"add",
"--transport",
"sse",
"-s",
"user",
MCP_KEY,
&server_url,
])
.env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash)
.output()
{
Ok(out) if out.status.success() => {
db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(MCP_KEY));
true
}
Ok(out) => {
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
// 同名已存在(exit 1)不是故障——CLI 链路健康,注册目标已达成
if combined.contains("already exists") {
return true;
}
db::log_event(
"mcp_inject",
"warn",
&format!("claude mcp add 非零退出: {}", combined),
Some(MCP_KEY),
);
false
}
Err(e) => {
db::log_event(
"mcp_inject",
"warn",
&format!("执行 claude mcp add 失败: {}", e),
Some(MCP_KEY),
);
false
}
}
}
/// 清理旧组级全局注册(lian-jing-{gid},含完整 UUID 与短 8 位两种历史命名)。
/// 幂等 best-effort:不存在的名字 remove 失败仅忽略。返回成功移除的名字列表。
pub fn cleanup_legacy_global_registrations() -> Vec<String> {
let Some(claude_bin) = find_claude_bin() else {
db::log_event(
"mcp_inject",
"warn",
"claude CLI 未找到,跳过旧注册清理",
None,
);
return Vec::new();
};
let Some(git_bash) = find_git_bash() else {
db::log_event(
"mcp_inject",
"warn",
"git-bash 未找到,跳过旧注册清理",
None,
);
return Vec::new();
};
let Ok(conn) = db::pool().get() else {
return Vec::new();
};
let group_ids: Vec<String> = conn
.prepare("SELECT id FROM product_groups")
.and_then(|mut stmt| {
stmt.query_map([], |r| r.get::<_, String>(0))
.map(|iter| iter.filter_map(|r| r.ok()).collect())
})
.unwrap_or_default();
let mut removed = Vec::new();
for gid in &group_ids {
let mut names = vec![format!("lian-jing-{}", gid)];
if gid.len() > 8 {
names.push(format!("lian-jing-{}", &gid[..8]));
}
for name in names {
let ok = std::process::Command::new(&claude_bin)
.args(["mcp", "remove", "-s", "user", &name])
.env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash)
.output()
.map(|out| out.status.success())
.unwrap_or(false);
if ok {
db::log_event("mcp_inject", "info", "旧组级 MCP 注册已移除", Some(&name));
removed.push(name);
}
}
}
removed
}
// ── JSON 读写 ─────────────────────────────────────────────────────────────────
fn read_json(path: &PathBuf) -> Result<Value, String> {
if !path.exists() {
return Ok(json!({}));
}
let content =
std::fs::read_to_string(path).map_err(|e| format!("读取 settings.json 失败: {}", e))?;
if content.trim().is_empty() {
return Ok(json!({}));
}
serde_json::from_str(&content).map_err(|e| {
format!(
"settings.json 格式错误,请手动修复后重试({}): {}",
path.display(),
e
)
})
}
fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("创建 .claude 目录失败: {}", e))?;
}
let content = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?;
std::fs::write(path, content).map_err(|e| format!("写入 settings.json 失败: {}", e))
}
fn upsert_codex_mcp_config(path: &PathBuf, server_url: &str) -> Result<(), String> {
let block = format!(
"{CODEX_MCP_MANAGED_START}\n[mcp_servers.{MCP_KEY}]\nurl = \"{server_url}\"\n{CODEX_MCP_MANAGED_END}\n"
);
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!("读取 Codex config.toml 失败: {error}")),
};
let (start, end) = (
current.find(CODEX_MCP_MANAGED_START),
current.find(CODEX_MCP_MANAGED_END),
);
let next = match (start, end) {
(Some(start), Some(end)) if end >= start => format!(
"{}{}{}",
&current[..start],
block,
&current[end + CODEX_MCP_MANAGED_END.len()..]
),
(Some(_), _) | (_, Some(_)) => {
return Err(format!(
"{} 的炼境 Codex 管理块不完整或顺序错误,请修复标记后重试",
path.display()
))
}
(None, None)
if current
.lines()
.any(|line| line.trim() == format!("[mcp_servers.{MCP_KEY}]")) =>
{
return Err(format!(
"{} 已手工定义 [mcp_servers.{MCP_KEY}],炼境不会覆盖",
path.display()
));
}
(None, None) if current.trim().is_empty() => block,
(None, None) => format!("{}\n{}", current.trim_end(), block),
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("创建 .codex 目录失败: {error}"))?;
}
std::fs::write(path, next).map_err(|error| format!("写入 Codex config.toml 失败: {error}"))
}
fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> {
let current = match std::fs::read_to_string(path) {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(format!("读取 Codex config.toml 失败: {error}")),
};
let (Some(start), Some(end)) = (
current.find(CODEX_MCP_MANAGED_START),
current.find(CODEX_MCP_MANAGED_END),
) else {
return Ok(());
};
if end < start {
return Err(format!("{} 的炼境 Codex 管理块不完整", path.display()));
}
let next = format!(
"{}{}",
&current[..start],
&current[end + CODEX_MCP_MANAGED_END.len()..]
)
.trim_matches(['\r', '\n'])
.to_string();
let output = if next.is_empty() {
String::new()
} else {
format!("{next}\n")
};
std::fs::write(path, output).map_err(|error| format!("移除 Codex MCP 配置失败: {error}"))
}
// ── 公开接口 ──────────────────────────────────────────────────────────────────
/// 向项目的 .claude/settings.json 注入单一 lian-jing MCP 配置,
/// 并清理旧组级 key(迁移内建于重注入)
pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
let port = crate::mcp_server::read_port();
let server_url = format!("http://127.0.0.1:{}/mcp", port);
write_canonical_context(project_id)?;
upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url)?;
let path = settings_path(project_id)?;
let mut settings = read_json(&path)?;
let obj = settings
.as_object_mut()
.ok_or("settings.json 顶层必须是 JSON 对象")?;
// 炼境管理的项目需要无人值守运行(/ship 等技能不应被 permission 弹窗打断)
obj.entry("permissions")
.or_insert(json!({"defaultMode": "bypassPermissions"}));
let servers = obj.entry("mcpServers").or_insert(json!({}));
if let Some(map) = servers.as_object_mut() {
map.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX));
}
servers[MCP_KEY] = json!({
"type": "sse",
"url": server_url
});
write_json(&path, &settings).inspect(|_| {
let ctx = serde_json::json!({"project_id": project_id}).to_string();
log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx));
})?;
// 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。
write_liangjing_context(project_id, port)?;
// 同步注册到 ~/.claude.json,让 Claude Code 能加载 MCP 工具(best-effort)
try_register_globally(port);
Ok(())
}
/// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。
/// 调用方需自行保证项目已不属于任何产品组时才调用。
pub fn remove_project_mcp(project_id: &str) -> Result<(), String> {
remove_codex_mcp_config(&codex_config_path(project_id)?)?;
let path = settings_path(project_id)?;
if !path.exists() {
return Ok(());
}
let mut settings = read_json(&path)?;
if let Some(servers) = settings
.get_mut("mcpServers")
.and_then(|v| v.as_object_mut())
{
servers.remove(MCP_KEY);
servers.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX));
}
write_json(&path, &settings)
}
/// 单一服务器化一次性迁移:批量重注入所有托管项目(换新 key + 清旧 key + liangjing.json 瘦身)
/// 并清理旧组级全局注册。settings 表 mcp_single_migrated 标记位保证只执行一次;
/// 部分项目失败不阻塞(记日志,可对单个项目手动重注入补救)。
pub fn migrate_to_single_server() {
let already: Option<String> = db::pool().get().ok().and_then(|c| {
c.query_row(
"SELECT value FROM settings WHERE key = 'mcp_single_migrated'",
[],
|r| r.get(0),
)
.ok()
});
if already.as_deref() == Some("1") {
return;
}
let errors = reinject_all_mcp();
let removed = cleanup_legacy_global_registrations();
let register_ok = try_register_globally(crate::mcp_server::read_port());
let summary = format!(
"单一服务器迁移完成:重注入失败 {} 个,移除旧全局注册 {} 条,全局注册 {}{}",
errors.len(),
removed.len(),
if register_ok { "成功" } else { "失败" },
if errors.is_empty() {
String::new()
} else {
format!(";失败详情: {}", errors.join("; "))
}
);
let level = if errors.is_empty() && register_ok {
"info"
} else {
"warn"
};
db::log_event("mcp_inject", level, &summary, None);
// 全局注册失败说明 claude CLI 链路不通(CLI 缺失/git-bash 缺失/命令报错),
// 此时旧注册也清不掉——不落标记位,等下次启动重试(整个流程幂等)
if !register_ok {
return;
}
if let Ok(conn) = db::pool().get() {
let _ = conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('mcp_single_migrated', '1')",
[],
);
}
}
/// 端口变更后重新注入所有产品组成员的 MCP 配置
/// 返回注入失败的错误列表(部分失败不影响其他项目)
pub fn reinject_all_mcp() -> Vec<String> {
let conn = match db::pool().get() {
Ok(c) => c,
Err(e) => return vec![e.to_string()],
};
let project_ids: Vec<String> = {
let mut stmt = match conn.prepare("SELECT DISTINCT project_id FROM project_group_map") {
Ok(s) => s,
Err(e) => return vec![e.to_string()],
};
stmt.query_map([], |row| row.get(0))
.map(|iter| iter.filter_map(|r| r.ok()).collect())
.unwrap_or_default()
};
drop(conn);
let mut errors = Vec::new();
for project_id in project_ids {
if let Err(e) = inject_project_mcp(&project_id) {
errors.push(format!("项目 {}: {}", project_id, e));
}
}
errors
}
#[cfg(test)]
mod tests {
use super::*;
/// 回归:空路径登记曾使 inject 链路把 settings.json/liangjing.json 写进
/// 炼境进程 cwd(src-tauri/,2026-07-15 实锤,apply 链路同日已拦本处漏拦)。
#[test]
fn registered_path_rejects_empty_and_relative() {
assert!(
validate_registered_path("", "win_path").is_err(),
"空串应拒绝"
);
assert!(
validate_registered_path(" ", "win_path").is_err(),
"空白应拒绝"
);
assert!(
validate_registered_path("src-tauri", "win_path").is_err(),
"相对路径应拒绝(相对炼境 cwd 解析)"
);
}
#[test]
fn codex_mcp_config_preserves_user_content_and_rejects_handwritten_conflict() {
let base =
std::env::temp_dir().join(format!("lianjing_codex_mcp_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join(".codex")).unwrap();
let path = base.join(".codex/config.toml");
std::fs::write(&path, "model = \"gpt-6\"\n").unwrap();
upsert_codex_mcp_config(&path, "http://127.0.0.1:27190/mcp").unwrap();
let configured = std::fs::read_to_string(&path).unwrap();
assert!(configured.contains("model = \"gpt-6\""));
assert!(configured.contains("[mcp_servers.lian-jing]"));
assert!(configured.contains("url = \"http://127.0.0.1:27190/mcp\""));
remove_codex_mcp_config(&path).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"model = \"gpt-6\"\n"
);
let manual = "[mcp_servers.lian-jing]\nurl = \"http://manual/mcp\"\n";
std::fs::write(&path, manual).unwrap();
assert!(upsert_codex_mcp_config(&path, "http://127.0.0.1:27190/mcp").is_err());
assert_eq!(std::fs::read_to_string(&path).unwrap(), manual);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn canonical_context_migrates_legacy_project_id_and_preserves_unknown_fields() {
let base = std::env::temp_dir().join(format!(
"lianjing_canonical_context_test_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join(".claude")).unwrap();
std::fs::write(
base.join(".claude/liangjing.json"),
r#"{"project_id":"legacy-project","mcp_port":27190}"#,
)
.unwrap();
std::fs::create_dir_all(base.join(".lian-jing")).unwrap();
std::fs::write(
base.join(".lian-jing/context.json"),
r#"{"project_setting":"preserve-me"}"#,
)
.unwrap();
write_canonical_context_at(&base, "legacy-project").unwrap();
let context: Value = serde_json::from_str(
&std::fs::read_to_string(base.join(".lian-jing/context.json")).unwrap(),
)
.unwrap();
assert_eq!(context["schema_version"], CONTEXT_SCHEMA_VERSION);
assert_eq!(context["project_id"], "legacy-project");
assert_eq!(context["project_setting"], "preserve-me");
assert!(
!context.as_object().unwrap().contains_key("mcp_port"),
"运行时端口不应进入工具无关的 canonical context"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn registered_path_accepts_absolute_and_unc() {
assert!(validate_registered_path("E:\\code-project\\demo", "win_path").is_ok());
assert!(validate_registered_path("E:/code-project/demo", "win_path").is_ok());
assert!(
validate_registered_path("\\\\wsl.localhost\\Ubuntu\\home\\u\\p", "win_path").is_ok(),
"WSL UNC 路径应通过"
);
}
}