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, #[serde(default)] params: Option, } async fn handle_mcp(Path(group_id): Path, Json(req): Json) -> 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"] } }, { "name": "get_project_snapshots", "description": "获取项目的历史健康快照(每日一条),包含 git 提交间隔、蓝图完成率、阻塞任务数、错误情况。用于分析趋势、识别高价值改进变量。", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, "days": { "type": "integer", "description": "查询最近 N 天的快照,默认 30" } }, "required": ["project_id"] } } ] }) } // ── 工具调用 ────────────────────────────────────────────────────────────────── 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) } "get_project_snapshots" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let days = args.get("days").and_then(|v| v.as_u64()).unwrap_or(30) as u32; get_project_snapshots(&gid, pid, days) } _ => 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 { let conn = db::pool().get().map_err(|e| e.to_string())?; let port = read_port(); // 服务启动时间(首条 mcp_server info 日志) let start_time: Option = 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)> = 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::(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 { 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 = stmt .query_map([group_id], |row| { let id: String = row.get(0)?; let name: String = row.get(1)?; let platform: Option = row.get(2)?; let win_path: Option = row.get(3)?; let wsl_path: Option = row.get(4)?; let tech_stack: Option = row.get(5)?; let role: Option = 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 { 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 { 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\\...) fn resolve_project_root(group_id: &str, project_id: &str) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; let row: rusqlite::Result<(Option, Option, Option)> = 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 { // 验证项目属于该产品组(与其他 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 { 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()) } fn get_project_snapshots(group_id: &str, project_id: &str, days: u32) -> Result { 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 mut stmt = conn .prepare( "SELECT snapshot_date, days_since_commit, blueprint_done_rate, blueprint_in_progress, blueprint_blocked, has_errors FROM project_snapshots WHERE project_id = ?1 AND snapshot_date >= date('now', 'localtime', ?2) ORDER BY snapshot_date ASC", ) .map_err(|e| e.to_string())?; let offset = format!("-{} days", days); let rows: Vec<(String, Option, Option, i64, i64, i64)> = stmt .query_map(rusqlite::params![project_id, offset], |r| { Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); if rows.is_empty() { return Ok(format!( "项目 `{}` 暂无历史快照(快照在每次应用启动时写入,最多保留 {} 天)。", project_id, days )); } let mut md = format!( "# 项目历史快照:`{}`\n\n最近 {} 天,共 {} 条记录\n\n", project_id, days, rows.len() ); md.push_str("| 日期 | 距上次提交(天) | 蓝图完成率 | 进行中模块 | 阻塞任务 | 有错误 |\n"); md.push_str("|------|--------------|-----------|-----------|---------|------|\n"); for (date, days_commit, done_rate, in_prog, blocked, has_err) in &rows { let commit_col = days_commit .map(|d| d.to_string()) .unwrap_or_else(|| "—".to_string()); let rate_col = done_rate .map(|r| format!("{:.0}%", r * 100.0)) .unwrap_or_else(|| "—".to_string()); let err_col = if *has_err == 1 { "⚠️" } else { "✅" }; md.push_str(&format!( "| {} | {} | {} | {} | {} | {} |\n", date, commit_col, rate_col, in_prog, blocked, err_col )); } md.push_str("\n---\n\n请根据以上趋势数据,分析哪些变量与项目停滞或健康度下降最相关,并给出改进建议。\n"); Ok(md) }