dev-manager-tauri/src-tauri/src/mcp_inject.rs
lanrtop 2116e54e29 feat(mcp-inject): 注入时同步注册全局 ~/.claude.json + liangjing.json 补 group_id
- inject_project_mcp 末尾新增 try_register_globally 调用,通过
  find_claude_bin 自动定位 claude CLI 并执行 claude mcp add -s user,
  让新接入项目的产品组 MCP 立即对 Claude Code 可用
- write_liangjing_context 增加 group_id 参数,子项目读取后可直接
  构造 lian-jing-{group_id} ToolSearch 查询,无需额外推断
- try_register_globally 为 best-effort:CLI 未找到或调用失败时
  仅写日志,不影响 settings.json 注入结果
2026-07-04 13:29:40 +09:00

275 lines
9.6 KiB
Rust
Raw 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;
const MCP_KEY_PREFIX: &str = "lian-jing:group-";
// ── 路径解析 ──────────────────────────────────────────────────────────────────
/// 解析项目根目录路径WSL 项目自动转为 Windows UNC 路径)
fn project_root(project_id: &str) -> Result<PathBuf, String> {
let conn = db::pool().get().map_err(|e| e.to_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())?;
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())?;
Ok(PathBuf::from(win))
}
}
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"))
}
/// 写炼境上下文文件,供子项目 Claude 会话直接读取 project_id / group_id
fn write_liangjing_context(project_id: &str, group_id: &str, port: u16) -> Result<(), String> {
let path = liangjing_path(project_id)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("创建 .claude 目录失败: {}", e))?;
}
let ctx = json!({
"project_id": project_id,
"group_id": group_id,
"mcp_port": port
});
let content = serde_json::to_string_pretty(&ctx).map_err(|e| e.to_string())?;
std::fs::write(&path, content)
.map_err(|e| format!("写入 liangjing.json 失败: {}", e))
}
/// 查找 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 可能不含用户 binbut 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
}
/// 将产品组 MCP 注册到 Claude 全局配置(~/.claude.jsonbest-effort失败仅记录日志
fn try_register_globally(group_id: &str, port: u16) {
let server_name = format!("lian-jing-{}", group_id);
let server_url = format!("http://127.0.0.1:{}/mcp/group/{}", port, group_id);
let Some(claude_bin) = find_claude_bin() else {
db::log_event(
"mcp_inject",
"warn",
"claude CLI 未找到,跳过全局 Mcp 注册",
None,
);
return;
};
let git_bash = std::env::var("CLAUDE_CODE_GIT_BASH_PATH").unwrap_or_else(|_| {
r"C:\Program Files\Git\bin\bash.exe".to_string()
});
match std::process::Command::new(&claude_bin)
.args([
"mcp",
"add",
"--transport",
"sse",
"-s",
"user",
&server_name,
&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(&server_name),
);
}
Ok(out) => {
let msg = format!(
"claude mcp add 非零退出: {}",
String::from_utf8_lossy(&out.stderr)
);
db::log_event("mcp_inject", "warn", &msg, Some(&server_name));
}
Err(e) => {
db::log_event(
"mcp_inject",
"warn",
&format!("执行 claude mcp add 失败: {}", e),
Some(&server_name),
);
}
}
}
// ── 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))
}
// ── 公开接口 ──────────────────────────────────────────────────────────────────
/// 向项目的 .claude/settings.json 注入产品组 MCP 配置
pub fn inject_project_mcp(project_id: &str, group_id: &str) -> Result<(), String> {
let port = crate::mcp_server::read_port();
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!({}));
let key = format!("{}{}", MCP_KEY_PREFIX, group_id);
servers[&key] = json!({
"type": "sse",
"url": format!("http://127.0.0.1:{}/mcp/group/{}", port, group_id)
});
write_json(&path, &settings).inspect(|_| {
let ctx = serde_json::json!({"project_id": project_id, "group_id": group_id}).to_string();
log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx));
})?;
// 写炼境上下文(含 group_id让子项目 Claude 会话无需遍历即可知道 project_id + group_id
let _ = write_liangjing_context(project_id, group_id, port);
// 同步注册到 ~/.claude.json让 Claude Code 能加载 MCP 工具best-effort
try_register_globally(group_id, port);
Ok(())
}
/// 从项目的 .claude/settings.json 移除产品组 MCP 配置
pub fn remove_project_mcp(project_id: &str, group_id: &str) -> Result<(), String> {
let path = settings_path(project_id)?;
if !path.exists() {
return Ok(());
}
let mut settings = read_json(&path)?;
let key = format!("{}{}", MCP_KEY_PREFIX, group_id);
if let Some(servers) = settings
.get_mut("mcpServers")
.and_then(|v| v.as_object_mut())
{
servers.remove(&key);
}
write_json(&path, &settings)
}
/// 端口变更后重新注入所有产品组成员的 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 mappings: Vec<(String, String)> = {
let mut stmt = match conn.prepare("SELECT project_id, group_id FROM project_group_map") {
Ok(s) => s,
Err(e) => return vec![e.to_string()],
};
stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.map(|iter| iter.filter_map(|r| r.ok()).collect())
.unwrap_or_default()
};
let mut errors = Vec::new();
for (project_id, group_id) in mappings {
if let Err(e) = inject_project_mcp(&project_id, &group_id) {
errors.push(format!("项目 {}: {}", project_id, e));
}
}
errors
}