Keep values in Windows Credential Manager, require a project bearer for single-value reads, and preserve existing non-secret MCP behavior. Feature-Confirmed: true Rules-Applied: R01,R03,R05,R06,R07,R10,R12,R13
732 lines
27 KiB
Rust
732 lines
27 KiB
Rust
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,
|
||
header_helper: &str,
|
||
) -> Result<(), String> {
|
||
let helper_literal = serde_json::to_string(header_helper)
|
||
.map_err(|_| "MCP helper 配置序列化失败".to_string())?;
|
||
let block = format!(
|
||
"{CODEX_MCP_MANAGED_START}\n[mcp_servers.{MCP_KEY}]\nurl = \"{server_url}\"\nhttp_headers_helper = {helper_literal}\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!(
|
||
"{}{}{}",
|
||
¤t[..start],
|
||
block,
|
||
¤t[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!(
|
||
"{}{}",
|
||
¤t[..start],
|
||
¤t[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 = crate::mcp_server::scoped_mcp_url(port, project_id);
|
||
let digest = crate::credential_store::mcp_auth_digest(project_id)?;
|
||
crate::credential_store::get_or_create_mcp_auth_token(
|
||
&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()
|
||
);
|
||
write_canonical_context(project_id)?;
|
||
upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url, &header_helper)?;
|
||
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() {
|
||
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)?;
|
||
}
|
||
crate::credential_store::delete_mcp_auth_token_for_project(
|
||
&crate::credential_store::WindowsCredentialStore,
|
||
project_id,
|
||
)
|
||
}
|
||
|
||
/// 单一服务器化一次性迁移:批量重注入所有托管项目(换新 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();
|
||
let helper = "\"C:/Lianjing.exe\" --lianjing-mcp-header qa-only-digest";
|
||
upsert_codex_mcp_config(&path, "http://127.0.0.1:27190/mcp", helper).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\""));
|
||
assert!(configured.contains("http_headers_helper"));
|
||
assert!(!configured.contains("qa-only-synthetic-mcp-token"));
|
||
|
||
upsert_codex_mcp_config(
|
||
&path,
|
||
"http://127.0.0.1:27190/mcp/projects/project%20one",
|
||
helper,
|
||
)
|
||
.unwrap();
|
||
let migrated = std::fs::read_to_string(&path).unwrap();
|
||
assert!(migrated.contains("model = \"gpt-6\""));
|
||
assert!(migrated.contains("url = \"http://127.0.0.1:27190/mcp/projects/project%20one\""));
|
||
assert!(!migrated.contains("url = \"http://127.0.0.1:27190/mcp\"\n"));
|
||
|
||
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/projects/project%20one",
|
||
helper,
|
||
)
|
||
.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 路径应通过"
|
||
);
|
||
}
|
||
}
|