dev-manager-tauri/src-tauri/src/mcp_server.rs
lanrtop af88408669 feat: complete project mentor feature with WSL path fix
- 新增 project_notes 数据层(T5):DB 迁移 + get/add 命令
- MCP 新增 append_project_note 工具,含 group 归属校验(T6)
- MentorPanel 底部展示 AI 笔记区域(T7)
- 后端全组巡检命令 get_group_mentor_report(T8)
- ManagePage 产品组卡片新增「巡检全组」按钮(T9)
- 启动健康扫描 + App.tsx 顶部告警 Banner(T10)
- 修复 WSL 项目路径检测:wsl_path 为 UNC 格式时
  用 unc_to_distro_and_linux() 转换后再调用 wsl 子命令
2026-04-04 23:20:09 +09:00

505 lines
20 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 axum::{
extract::Path,
http::StatusCode,
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::net::SocketAddr;
use crate::{db, db::log_event};
/// 从 settings 表读取 MCP 端口,默认 27190
pub fn read_port() -> u16 {
db::pool()
.get()
.ok()
.and_then(|c| {
c.query_row(
"SELECT value FROM settings WHERE key = 'mcp_port'",
[],
|r| r.get::<_, String>(0),
)
.ok()
})
.and_then(|v| v.parse().ok())
.unwrap_or(27190)
}
/// 启动 MCP HTTP 服务Streamable HTTP transport
pub async fn start(port: u16) {
let app = Router::new()
.route("/health", get(health))
.route("/mcp/group/:group_id", post(handle_mcp));
let addr = SocketAddr::from(([127, 0, 0, 1], port));
match tokio::net::TcpListener::bind(addr).await {
Ok(listener) => {
log_event("mcp_server", "info", &format!("MCP Server 已启动,端口 {port}"), None);
if let Err(e) = axum::serve(listener, app).await {
log_event("mcp_server", "error", &format!("Server 异常退出: {e}"), None);
}
}
Err(e) => log_event("mcp_server", "error", &format!("绑定端口 {port} 失败: {e}"), None),
}
}
async fn health() -> &'static str {
"炼境 MCP Server running"
}
// ── JSON-RPC 类型 ─────────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct RpcRequest {
#[allow(dead_code)]
jsonrpc: String,
method: String,
id: Option<Value>,
#[serde(default)]
params: Option<Value>,
}
async fn handle_mcp(Path(group_id): Path<String>, Json(req): Json<RpcRequest>) -> Response {
// MCP 通知(无 id→ 202 Accepted无需响应体
if req.id.is_none() {
return StatusCode::ACCEPTED.into_response();
}
let id = req.id.clone();
let result = match req.method.as_str() {
"initialize" => json!({
"protocolVersion": "2024-11-05",
"capabilities": { "tools": {} },
"serverInfo": {
"name": "炼境",
"version": env!("CARGO_PKG_VERSION")
}
}),
"tools/list" => tools_list_result(),
"tools/call" => tools_call(&group_id, req.params.as_ref()).await,
_ => {
return Json(json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": -32601, "message": "Method not found" }
}))
.into_response();
}
};
Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })).into_response()
}
// ── 工具定义 ──────────────────────────────────────────────────────────────────
fn tools_list_result() -> Value {
json!({
"tools": [
{
"name": "get_diagnostics",
"description": "查询炼境运行时诊断信息MCP Server 启动状态、各项目 MCP 配置注入结果、最近错误日志。排查功能是否正常时首先调用此工具。",
"inputSchema": {
"type": "object",
"properties": {},
"required": []
}
},
{
"name": "list_group_projects",
"description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息",
"inputSchema": {
"type": "object",
"properties": {},
"required": []
}
},
{
"name": "get_project_blueprint",
"description": "读取产品组内某个项目的蓝图文件(.blueprint/manifest.yaml了解该项目的模块与任务状态",
"inputSchema": {
"type": "object",
"properties": {
"project_id": {
"type": "string",
"description": "项目 workspace ID来自 list_group_projects 的返回结果)"
}
},
"required": ["project_id"]
}
},
{
"name": "get_project_file",
"description": "读取产品组内某个项目的任意文件内容,如 openapi.yaml、README.md、src/types.ts 等",
"inputSchema": {
"type": "object",
"properties": {
"project_id": {
"type": "string",
"description": "项目 workspace ID"
},
"relative_path": {
"type": "string",
"description": "相对于项目根目录的文件路径,如 openapi.yaml 或 src/api/routes.ts"
}
},
"required": ["project_id", "relative_path"]
}
},
{
"name": "get_project_mentor_context",
"description": "获取产品组内某个项目的完整导师上下文——包含健康状态、蓝图进度摘要、近期错误日志,以及格式化好的 Markdown 报告(可直接用于质量分析和成长建议)",
"inputSchema": {
"type": "object",
"properties": {
"project_id": {
"type": "string",
"description": "项目 workspace ID来自 list_group_projects 的返回结果)"
}
},
"required": ["project_id"]
}
},
{
"name": "append_project_note",
"description": "将 Claude 的分析结论、建议或洞察写入项目笔记,供未来会话和用户在炼境中查阅。分析完项目后,将核心观点记录在此。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": {
"type": "string",
"description": "项目 workspace ID来自 list_group_projects 的返回结果)"
},
"content": {
"type": "string",
"description": "笔记内容,支持 Markdown。建议包含核心观点、发现的问题、下一步建议。"
}
},
"required": ["project_id", "content"]
}
}
]
})
}
// ── 工具调用 ──────────────────────────────────────────────────────────────────
async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
let tool_name = params
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let args = params
.and_then(|p| p.get("arguments"))
.cloned()
.unwrap_or(json!({}));
let gid = group_id.to_string();
let outcome = tokio::task::spawn_blocking(move || match tool_name.as_str() {
"get_diagnostics" => get_diagnostics(),
"list_group_projects" => list_group_projects(&gid),
"get_project_blueprint" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
get_project_blueprint(&gid, pid)
}
"get_project_file" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let rel = args
.get("relative_path")
.and_then(|v| v.as_str())
.unwrap_or("");
get_project_file(&gid, pid, rel)
}
"get_project_mentor_context" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
get_project_mentor_context(&gid, pid)
}
"append_project_note" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
append_project_note(&gid, pid, content)
}
_ => Err(format!("未知工具: {}", tool_name)),
})
.await;
match outcome {
Ok(Ok(text)) => json!({ "content": [{ "type": "text", "text": text }] }),
Ok(Err(e)) => json!({ "content": [{ "type": "text", "text": e }], "isError": true }),
Err(e) => json!({ "content": [{ "type": "text", "text": e.to_string() }], "isError": true }),
}
}
// ── 工具实现(同步,由 spawn_blocking 包裹)──────────────────────────────────
fn get_diagnostics() -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let port = read_port();
// 服务启动时间(首条 mcp_server info 日志)
let start_time: Option<String> = conn.query_row(
"SELECT ts FROM runtime_logs WHERE category = 'mcp_server' AND level = 'info' ORDER BY id ASC LIMIT 1",
[],
|r| r.get(0),
).ok();
let mut out = String::new();
out.push_str("## 炼境 MCP 诊断报告\n\n");
out.push_str(&format!("- **服务端口**: {}\n", port));
out.push_str(&format!("- **启动时间**: {}\n\n", start_time.as_deref().unwrap_or("未知(可能在本次日志之前启动)")));
// MCP 注入状态:每个 project_id 取最后一条
out.push_str("### MCP 注入状态(每个项目最后一次结果)\n\n");
let mut stmt = conn.prepare(
"SELECT context, level, message, ts
FROM runtime_logs
WHERE category = 'mcp_inject'
AND id IN (
SELECT MAX(id) FROM runtime_logs
WHERE category = 'mcp_inject'
GROUP BY json_extract(context, '$.project_id')
)
ORDER BY ts DESC
LIMIT 50",
).map_err(|e| e.to_string())?;
let inject_rows: Vec<(Option<String>, String, String, String)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if inject_rows.is_empty() {
out.push_str("_暂无注入记录成员加入产品组时会产生_\n\n");
} else {
for (ctx, level, msg, ts) in &inject_rows {
let project_id = ctx.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v.get("project_id").and_then(|x| x.as_str()).map(|s| s.to_string()))
.unwrap_or_else(|| "unknown".to_string());
let icon = if level == "info" { "" } else { "⚠️" };
out.push_str(&format!("- {} `{}` — {} _({})\n", icon, project_id, msg, ts));
}
out.push('\n');
}
// 最近 20 条 error/warn
out.push_str("### 最近错误日志(最多 20 条)\n\n");
let mut stmt2 = conn.prepare(
"SELECT ts, category, level, message FROM runtime_logs
WHERE level IN ('error', 'warn')
ORDER BY id DESC LIMIT 20",
).map_err(|e| e.to_string())?;
let error_rows: Vec<(String, String, String, String)> = stmt2
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if error_rows.is_empty() {
out.push_str("_无错误记录_ ✅\n");
} else {
for (ts, cat, level, msg) in &error_rows {
out.push_str(&format!("- `[{}]` **{}** `{}` — {}\n", ts, level, cat, msg));
}
}
// ── 系统健康(复用 health check 逻辑)────────────────────────────────────
out.push_str("\n### 系统健康\n\n");
let report = crate::commands::settings::collect_health_report();
out.push_str(&format!("检查时间:{}\n\n", report.checked_at));
out.push_str("| 检查项 | 状态 | 详情 |\n|--------|------|------|\n");
for item in &report.items {
let icon = match item.status.as_str() {
"ok" => "",
"warn" => "⚠️",
"error" => "",
_ => "",
};
out.push_str(&format!(
"| {} | {} {} | {} |\n",
item.name, icon, item.status,
item.detail.as_deref().unwrap_or("-")
));
}
// ── 质量指标 ──────────────────────────────────────────────────────────────
out.push_str("\n### 质量指标\n\n");
let q = &report.quality;
out.push_str(&format!("- **休眠项目**>30天未 push{}\n", q.dormant_projects.len()));
for p in &q.dormant_projects {
out.push_str(&format!(" - {} {} 天)\n", p.name, p.days_since_push));
}
out.push_str(&format!("- **孤立项目**(无分组 & 无 repo{}\n", q.orphaned_projects.len()));
for p in &q.orphaned_projects {
out.push_str(&format!(" - {}\n", p.name));
}
out.push_str(&format!(
"- **MCP 注入异常产品组**(近 7 天):{}\n",
q.groups_with_inject_warn.len()
));
for gid in &q.groups_with_inject_warn {
out.push_str(&format!(" - `{}`\n", gid));
}
out.push_str(&format!("- **无本地路径项目**{}\n", q.projects_without_path));
Ok(out)
}
fn list_group_projects(group_id: &str) -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT pw.id, pp.name, pw.platform, pw.win_path, pw.wsl_path,
pp.tech_stack, pgm.role
FROM project_group_map pgm
JOIN project_workspaces pw ON pw.id = pgm.project_id
JOIN project_profiles pp ON pp.id = pw.profile_id
WHERE pgm.group_id = ?1",
)
.map_err(|e| e.to_string())?;
let rows: Vec<String> = stmt
.query_map([group_id], |row| {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let platform: Option<String> = row.get(2)?;
let win_path: Option<String> = row.get(3)?;
let wsl_path: Option<String> = row.get(4)?;
let tech_stack: Option<String> = row.get(5)?;
let role: Option<String> = row.get(6)?;
Ok(format!(
"- **{}** (id: `{}`)\n 平台: {} | 技术栈: {}{}\n Windows 路径: {}\n WSL 路径: {}",
name,
id,
platform.as_deref().unwrap_or("unknown"),
tech_stack.as_deref().unwrap_or("未配置"),
role.map(|r| format!(" | 角色: {}", r)).unwrap_or_default(),
win_path.as_deref().unwrap_or("未配置"),
wsl_path.as_deref().unwrap_or("未配置"),
))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if rows.is_empty() {
return Ok("该产品组暂无成员项目。".to_string());
}
Ok(format!(
"产品组成员(共 {} 个项目):\n\n{}",
rows.len(),
rows.join("\n\n")
))
}
fn get_project_blueprint(group_id: &str, project_id: &str) -> Result<String, String> {
let root = resolve_project_root(group_id, project_id)?;
let path = root.join(".blueprint").join("manifest.yaml");
std::fs::read_to_string(&path)
.map_err(|e| format!("读取蓝图失败({}: {}", path.display(), e))
}
fn get_project_file(group_id: &str, project_id: &str, relative_path: &str) -> Result<String, String> {
if relative_path.contains("..") {
return Err("路径不允许包含 \"..\"".to_string());
}
let root = resolve_project_root(group_id, project_id)?;
let path = root.join(relative_path);
std::fs::read_to_string(&path)
.map_err(|e| format!("读取文件失败({}: {}", path.display(), e))
}
/// 根据 group_id + project_id 解析项目根目录路径
/// WSL 项目自动转换为 Windows UNC 路径(\\wsl.localhost\<distro>\...
fn resolve_project_root(group_id: &str, project_id: &str) -> Result<std::path::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 pw.win_path, pw.wsl_path, pw.platform
FROM project_group_map pgm
JOIN project_workspaces pw ON pw.id = pgm.project_id
WHERE pgm.group_id = ?1 AND pgm.project_id = ?2",
[group_id, 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, group_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());
// /home/user/project → \\wsl.localhost\Ubuntu\home\user\project
let inner = wsl.trim_start_matches('/').replace('/', "\\");
Ok(std::path::PathBuf::from(format!(
"\\\\wsl.localhost\\{}\\{}",
distro, inner
)))
} else {
let win = win_path.ok_or_else(|| "Windows 项目未配置 win_path".to_string())?;
Ok(std::path::PathBuf::from(win))
}
}
fn get_project_mentor_context(group_id: &str, project_id: &str) -> Result<String, String> {
// 验证项目属于该产品组(与其他 MCP 工具一致)
let conn = db::pool().get().map_err(|e| e.to_string())?;
let exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2",
[group_id, project_id],
|r| r.get::<_, i64>(0),
)
.map(|n| n > 0)
.unwrap_or(false);
if !exists {
return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id));
}
let ctx = crate::commands::mentor::get_mentor_context(project_id.to_string())
.map_err(|e| format!("获取导师上下文失败: {e}"))?;
Ok(ctx.mentor_markdown)
}
fn append_project_note(group_id: &str, project_id: &str, content: &str) -> Result<String, String> {
if content.trim().is_empty() {
return Err("笔记内容不能为空".to_string());
}
let conn = db::pool().get().map_err(|e| e.to_string())?;
// 校验 project_id 属于该 group
let exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2",
[group_id, project_id],
|r| r.get::<_, i64>(0),
)
.map(|n| n > 0)
.unwrap_or(false);
if !exists {
return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id));
}
conn.execute(
"INSERT INTO project_notes (project_id, source, content) VALUES (?1, 'mcp', ?2)",
rusqlite::params![project_id, content],
)
.map_err(|e| e.to_string())?;
Ok("笔记已保存 ✅".to_string())
}