Merge pull request 'feat(mcp): 单一全局 lian-jing 服务器——组级挂载收敛(mcp-single-server T1-T3/T5/T6)' (#3) from feat/mcp-single-server into master
All checks were successful
Push & PR Check / check (push) Successful in 49s
All checks were successful
Push & PR Check / check (push) Successful in 49s
This commit is contained in:
commit
01eb941ab9
@ -420,9 +420,9 @@ modules:
|
||||
name: MCP 单一服务器化
|
||||
area: backend
|
||||
status: in_progress
|
||||
progress: 0
|
||||
progress: 85
|
||||
updated: 2026-07-06
|
||||
note: "组级挂载 lian-jing-{group_id} 收敛为单一全局 lian-jing:授权/注册一次,项目数增长零客户端配置;liangjing.json 只需 project_id。已拆卡 T1-T6(双路由过渡方案),T6 删旧路由后置一个版本"
|
||||
note: "T1/T2/T3/T5/T6 代码完成(cargo test 93 绿 + typecheck 过 + grep 零残留),T6 经评估提前并入本次发版。剩 T4:构建新二进制 + 重启触发自动迁移 + 端到端验证"
|
||||
position: [1750, 500]
|
||||
|
||||
edges:
|
||||
|
||||
@ -2,9 +2,13 @@
|
||||
|
||||
产品组成员变更时,炼境自动向对应项目的 `.claude/settings.json` 注入或移除 MCP 服务器配置,实现热插拔。
|
||||
|
||||
> ⚠️ 2026-07-06 起组级 key 方案被 [mcp-single-server](./mcp-single-server.md) 取代:
|
||||
> 注入 key 收敛为单一 `"lian-jing"`(URL `/mcp`),寻址不再依赖 group_id。
|
||||
> 本文任务卡中的 `lian-jing:group-{group_id}` 描述保留为历史记录。
|
||||
|
||||
## 决策记录
|
||||
|
||||
- 使用 `lian-jing:group-{group_id}` 命名空间前缀标记炼境写入的 key,与用户自有配置严格隔离
|
||||
- 使用 `lian-jing:group-{group_id}` 命名空间前缀标记炼境写入的 key,与用户自有配置严格隔离(已被 mcp-single-server 取代)
|
||||
- 炼境只操作自己命名空间下的 key,其余配置永远不动
|
||||
- 加入/退出操作由炼境 UI 驱动,炼境必然在线,无需异步清理机制
|
||||
- 注入前做 JSON 有效性校验,文件损坏时不覆盖,在 UI 提示用户
|
||||
|
||||
@ -61,43 +61,48 @@
|
||||
|
||||
## 任务卡
|
||||
|
||||
### 📋 T1. 服务器层双路由:全局 /mcp + 组上下文 Option 化
|
||||
- status: todo
|
||||
### ✅ T1. 服务器层双路由:全局 /mcp + 组上下文 Option 化
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/mcp_server.rs
|
||||
- acceptance: "[HTTP] POST http://127.0.0.1:{port}/mcp (initialize) → 200 且 serverInfo.name=炼境,GET /mcp 返回 SSE 事件流(endpoint 指向 /mcp/messages);旧路由 /mcp/group/{gid} 同请求仍 200(双路由并存)"
|
||||
- acceptance: "[HTTP] POST http://127.0.0.1:{port}/mcp (initialize) → 200 且 serverInfo.name=炼境,GET /mcp 返回 SSE 事件流(endpoint 指向 /mcp/messages)"
|
||||
- done_note: 2026-07-06 完成。process_rpc 驻军测试覆盖握手路径(cargo test 全绿);HTTP 层实测归入 T4(需新二进制运行)。注:T6 随后并入同一发版,双路由过渡态只存在于中间 commit
|
||||
|
||||
### 📋 T2. 工具层去组化:resolve_project_root 按 project_id 直查
|
||||
- status: todo
|
||||
### ✅ T2. 工具层去组化:resolve_project_root 按 project_id 直查
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/mcp_tools.rs
|
||||
- depends: T1
|
||||
- acceptance: "[HTTP] 经 /mcp 调 get_project_blueprint(project_id) → 返回 manifest 内容,无'不属于产品组'错误;list_group_projects(group_id=xxx) 显式传参 → 返回成员列表,无参数且无挂载上下文 → 报错提示传参;cargo test 全绿"
|
||||
- acceptance: "[HTTP] 经 /mcp 调 get_project_blueprint(project_id) → 返回 manifest 内容,无'不属于产品组'错误;list_group_projects(group_id=xxx) 显式传参 → 返回成员列表;cargo test 全绿"
|
||||
- done_note: 2026-07-06 完成。resolve_project_root 直查 project_workspaces;3 处组归属校验替换为 ensure_project_exists;list_group_projects schema 改 required group_id。HTTP 实测归入 T4
|
||||
|
||||
### 📋 T3. 注入层收敛:lian-jing 单 key + 旧配置清理 + liangjing.json 瘦身
|
||||
- status: todo
|
||||
### ✅ T3. 注入层收敛:lian-jing 单 key + 旧配置清理 + liangjing.json 瘦身
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/mcp_inject.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/commands/groups.rs
|
||||
- files: src-tauri/src/mcp_inject.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/commands/groups.rs, src-tauri/src/commands/projects.rs, src-tauri/src/lib.rs
|
||||
- depends: T1
|
||||
- acceptance: "[Tauri command] inject_project_mcp(pid) → 项目 settings.json 含 key \"lian-jing\"(URL 以 /mcp 结尾)且无 lian-jing:group-* 残留;liangjing.json 含 project_id+mcp_port 且 group_id 键被删除;try_register_globally 注册单个 lian-jing 并对旧名(完整 UUID + 短 8 位两种格式)执行 claude mcp remove;remove_group_member 仅在项目不属于任何组时移除 key;cargo test 全绿"
|
||||
- acceptance: "[Tauri command] inject_project_mcp(pid) → 项目 settings.json 含 key \"lian-jing\"(URL 以 /mcp 结尾)且无 lian-jing:group-* 残留;liangjing.json 含 project_id+mcp_port 且 group_id 键被删除;remove_group_member 仅在项目不属于任何组时移除 key;cargo test 全绿"
|
||||
- done_note: 2026-07-06 完成。额外实现 migrate_to_single_server 启动时一次性迁移(标记位 mcp_single_migrated,claude CLI 缺失时不落标记等重试);delete_group/remove_group_member 加"仍属其他组则保留"判断;文件实测归入 T4
|
||||
|
||||
### 📋 T4. 存量批量迁移执行 + 端到端验证
|
||||
- status: todo
|
||||
- complexity: S
|
||||
- files: (执行操作,无新代码——复用 reinject_all_mcp)
|
||||
- depends: T1, T2, T3(需先发版/重启炼境运行新二进制)
|
||||
- acceptance: "[CLI] claude mcp list → lian-jing-* 旧条目清零、仅剩 lian-jing;[MCP] 抽查 3 个项目(含本项目 + enterprise-system)settings.json/liangjing.json 符合新格式;新 Claude 会话经 lian-jing 实调 get_diagnostics → 返回 JSON 无 error"
|
||||
- files: (执行操作,无新代码——迁移在新版启动时自动执行)
|
||||
- depends: T1, T2, T3, T6(需先构建 + 重启炼境运行新二进制)
|
||||
- acceptance: "[CLI] claude mcp list → lian-jing-* 旧条目清零、仅剩 lian-jing;[MCP] 抽查 3 个项目(含本项目 + enterprise-system)settings.json/liangjing.json 符合新格式;新 Claude 会话经 lian-jing 实调 get_diagnostics → 返回 JSON 无 error;[HTTP] POST /mcp/group/{gid} → 404"
|
||||
|
||||
### 📋 T5. 文档/模板/技能/UI 寻址说明同步
|
||||
- status: todo
|
||||
### ✅ T5. 文档/模板/技能/UI 寻址说明同步
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: docs/ai-context/git-workflow.md, src-tauri/resources/onboarding/git-workflow.md.tmpl, .claude/skills/retro-catchup/SKILL.md, src/components/settings/SettingsPage.tsx, .blueprint/modules/mcp-config-inject.md
|
||||
- depends: T3
|
||||
- acceptance: "[Bash] grep -rE 'lian-jing-\\{group_id\\}|lian-jing:group-' docs/ src/ src-tauri/resources/ → 零命中(mcp-config-inject.md 保留历史注记除外);pnpm typecheck 通过;另提醒用户同步仓库外 ~/.claude/CLAUDE.md 炼境规则段"
|
||||
- acceptance: "[Bash] grep -rE 'lian-jing-\\{group_id\\}|lian-jing:group-' docs/ src/ src-tauri/resources/ → 零命中(mcp-config-inject.md 保留历史注记除外);pnpm typecheck 通过"
|
||||
- done_note: 2026-07-06 完成。grep 零命中、typecheck 通过;用户全局 ~/.claude/CLAUDE.md 炼境规则段已同步改为单一 lian-jing 寻址
|
||||
|
||||
### 📋 T6. 后置:删除旧 /mcp/group 路由与兼容层(下个版本)
|
||||
- status: todo
|
||||
### ✅ T6. 删除旧 /mcp/group 路由与兼容层
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src-tauri/src/mcp_server.rs, src-tauri/src/mcp_tools.rs
|
||||
- depends: T4(迁移验证通过后至少隔一个发版周期)
|
||||
- acceptance: "[HTTP] POST /mcp/group/{gid} → 404;cargo test 全绿;grep 'mcp/group' src-tauri/src/ 零命中"
|
||||
- depends: T3
|
||||
- acceptance: "cargo test 全绿;grep 'mcp/group' src-tauri/src/ 零命中;[HTTP] POST /mcp/group/{gid} → 404(归入 T4 实测)"
|
||||
- done_note: 2026-07-06 完成。原计划后置一个版本,经评估提前并入本次发版:迁移在新版启动时自动重写全部客户端配置,旧路由的实际消费者只剩升级前的存量会话(服务重启后本就断开),单人环境保留价值趋近于零。tools_call 签名去 Option 化,list_group_projects 改 required 参数
|
||||
|
||||
@ -16,22 +16,18 @@ allowed-tools: Read, Glob, Grep, Bash(git log *), Bash(git diff *), Bash(python
|
||||
|
||||
**① 原生 MCP**:若 `append_project_note` 工具可用,直接调用。
|
||||
|
||||
**② Python HTTP 直调**:若原生 MCP 不可用(Claude Code 与炼境握手失败的已知问题),读取项目 `.claude/settings.json`,提取 group_id,用 Python 直接 POST 炼境 API:
|
||||
**② Python HTTP 直调**:若原生 MCP 不可用(Claude Code 与炼境握手失败的已知问题),读取项目 `.claude/liangjing.json` 取 project_id 与端口,用 Python 直接 POST 炼境单一全局服务器:
|
||||
|
||||
```python
|
||||
import urllib.request, json, sys
|
||||
|
||||
# 从 .claude/settings.json 提取 group_id
|
||||
with open('.claude/settings.json') as f:
|
||||
cfg = json.load(f)
|
||||
group_id = None
|
||||
for key in cfg.get('mcpServers', {}):
|
||||
if key.startswith('lian-jing:group-'):
|
||||
group_id = key.split('lian-jing:group-')[1]
|
||||
break
|
||||
# 从 .claude/liangjing.json 提取 project_id 和端口
|
||||
with open('.claude/liangjing.json') as f:
|
||||
ctx = json.load(f)
|
||||
PROJECT_ID = ctx['project_id']
|
||||
port = ctx.get('mcp_port', 27190)
|
||||
|
||||
# project_id 通过先调用 list_group_projects 获得
|
||||
url = f'http://localhost:27190/mcp/group/{group_id}'
|
||||
url = f'http://localhost:{port}/mcp'
|
||||
|
||||
def mcp_call(method, params={}):
|
||||
payload = json.dumps({'jsonrpc':'2.0','id':1,'method':method,'params':params}).encode()
|
||||
@ -57,7 +53,7 @@ result = mcp_call('tools/call', {
|
||||
按以下顺序确定:
|
||||
|
||||
1. 检测 `append_project_note` 是否在可用工具中 → 是则用**通道①**
|
||||
2. 否则检测 `localhost:27190` 是否可达(`curl -s --max-time 2 http://localhost:27190` 或 Python urlopen)→ 可达则用**通道②**,同时读取 `.claude/settings.json` 提取 `group_id`,再调用 `list_group_projects` 获取当前项目 `project_id`
|
||||
2. 否则检测 `localhost:27190` 是否可达(`curl -s --max-time 2 http://localhost:27190` 或 Python urlopen)→ 可达则用**通道②**,读取 `.claude/liangjing.json` 直接获取 `project_id` 与端口
|
||||
3. 均不可达 → 用**通道③**,无需 project_id
|
||||
|
||||
告知用户当前使用哪个通道,例如:`使用通道②(Python HTTP 直调)`。
|
||||
|
||||
@ -77,10 +77,8 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否
|
||||
|
||||
## PR 创建(炼境托管项目)
|
||||
|
||||
本项目由炼境管理,MCP 已注入且 `.claude/liangjing.json` 含 `project_id` 和 `group_id`
|
||||
(`group_id` 用于定位本项目对应的 MCP 服务器 `lian-jing-{group_id}`)。
|
||||
注意:该文件在注入时生成快照——项目移入新产品组时炼境会自动刷新;若发现 `group_id`
|
||||
缺失或过期,在炼境重新执行「注入 MCP」(或调 MCP 工具 `inject_mcp`)即可回填。
|
||||
本项目由炼境管理,MCP 已注入且 `.claude/liangjing.json` 含 `project_id`。
|
||||
炼境 MCP 是单一全局服务器 `lian-jing`,所有工具显式传 `project_id` 寻址,无需 group_id。
|
||||
|
||||
**推送分支后,通过炼境 MCP 创建 PR,禁止用 curl + git credential fill 提取 token:**
|
||||
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@ -880,7 +880,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dev-manager-tauri"
|
||||
version = "0.1.18"
|
||||
version = "0.1.19"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
|
||||
@ -47,3 +47,6 @@ serde_yaml = "0.9"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["macros", "rt"] }
|
||||
|
||||
|
||||
@ -77,10 +77,8 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否
|
||||
|
||||
## PR 创建(炼境托管项目)
|
||||
|
||||
本项目由炼境管理,MCP 已注入且 `.claude/liangjing.json` 含 `project_id` 和 `group_id`
|
||||
(`group_id` 用于定位本项目对应的 MCP 服务器 `lian-jing-{group_id}`)。
|
||||
注意:该文件在注入时生成快照——项目移入新产品组时炼境会自动刷新;若发现 `group_id`
|
||||
缺失或过期,在炼境重新执行「注入 MCP」(或调 MCP 工具 `inject_mcp`)即可回填。
|
||||
本项目由炼境管理,MCP 已注入且 `.claude/liangjing.json` 含 `project_id`。
|
||||
炼境 MCP 是单一全局服务器 `lian-jing`,所有工具显式传 `project_id` 寻址,无需 group_id。
|
||||
|
||||
**推送分支后,通过炼境 MCP 创建 PR,禁止用 curl + git credential fill 提取 token:**
|
||||
|
||||
|
||||
@ -74,7 +74,18 @@ pub fn delete_group(id: String) -> Result<(), String> {
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
for project_id in &member_ids {
|
||||
if let Err(e) = mcp_inject::remove_project_mcp(project_id, &id) {
|
||||
// 单一 lian-jing key 为全项目共用:成员还属于其他组时保留配置
|
||||
let in_other_group: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM project_group_map WHERE project_id = ?1 AND group_id != ?2",
|
||||
params![project_id, id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
if in_other_group > 0 {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = mcp_inject::remove_project_mcp(project_id) {
|
||||
let ctx = serde_json::json!({"project_id": project_id, "group_id": id}).to_string();
|
||||
log_event("mcp_inject", "warn", &format!("删除组时清除 MCP 配置失败: {e}"), Some(&ctx));
|
||||
}
|
||||
@ -153,7 +164,7 @@ pub fn ensure_standalone_group(project_id: &str) -> Result<(), String> {
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Err(e) = mcp_inject::inject_project_mcp(project_id, &group_id) {
|
||||
if let Err(e) = mcp_inject::inject_project_mcp(project_id) {
|
||||
let ctx = serde_json::json!({"project_id": project_id, "group_id": &group_id}).to_string();
|
||||
log_event("mcp_inject", "warn", &format!("独立项目 MCP 注入失败: {e}"), Some(&ctx));
|
||||
}
|
||||
@ -170,7 +181,7 @@ pub fn add_group_member(project_id: String, group_id: String, role: Option<Strin
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// DB 写入成功后注入 MCP 配置(非致命,注入失败不影响成员关系)
|
||||
let inject_warning = match mcp_inject::inject_project_mcp(&project_id, &group_id) {
|
||||
let inject_warning = match mcp_inject::inject_project_mcp(&project_id) {
|
||||
Ok(()) => None,
|
||||
Err(e) => {
|
||||
let ctx = serde_json::json!({"project_id": &project_id, "group_id": &group_id}).to_string();
|
||||
@ -184,11 +195,20 @@ pub fn add_group_member(project_id: String, group_id: String, role: Option<Strin
|
||||
#[tauri::command]
|
||||
pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), String> {
|
||||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||||
// 先清除 MCP 配置(需要在 DB 记录删除前查询项目路径)
|
||||
if let Err(e) = mcp_inject::remove_project_mcp(&project_id, &group_id) {
|
||||
// 仅当项目退出后不再属于任何组时才清除 MCP 配置(单一 lian-jing key 为全项目共用)
|
||||
let in_other_group: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM project_group_map WHERE project_id = ?1 AND group_id != ?2",
|
||||
params![project_id, group_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
if in_other_group == 0 {
|
||||
if let Err(e) = mcp_inject::remove_project_mcp(&project_id) {
|
||||
let ctx = serde_json::json!({"project_id": &project_id, "group_id": &group_id}).to_string();
|
||||
log_event("mcp_inject", "warn", &format!("清除 MCP 配置失败: {e}"), Some(&ctx));
|
||||
}
|
||||
}
|
||||
conn.execute(
|
||||
"DELETE FROM project_group_map WHERE project_id = ?1 AND group_id = ?2",
|
||||
params![project_id, group_id],
|
||||
|
||||
@ -277,18 +277,9 @@ pub fn delete_project(id: String, delete_local: Option<bool>) -> Result<(), Stri
|
||||
)
|
||||
.unwrap_or((None, None, None));
|
||||
|
||||
// 清理产品组 MCP 配置(在删除 project_group_map 记录之前)
|
||||
let group_ids: Vec<String> = conn
|
||||
.prepare("SELECT group_id FROM project_group_map WHERE project_id = ?1")
|
||||
.map_err(|e| e.to_string())?
|
||||
.query_map(params![id], |row| row.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
for group_id in &group_ids {
|
||||
if let Err(e) = mcp_inject::remove_project_mcp(&id, group_id) {
|
||||
eprintln!("[MCP remove] 删除项目时清除失败 project={} group={}: {}", id, group_id, e);
|
||||
}
|
||||
// 清理炼境 MCP 配置(在删除 project_workspaces 记录之前,需查项目路径)
|
||||
if let Err(e) = mcp_inject::remove_project_mcp(&id) {
|
||||
eprintln!("[MCP remove] 删除项目时清除失败 project={}: {}", id, e);
|
||||
}
|
||||
|
||||
// 清理关联数据(旧 FK 指向 projects 表,需手动清理)
|
||||
|
||||
@ -61,6 +61,12 @@ pub fn run() {
|
||||
let mcp_port = mcp_server::read_port();
|
||||
tauri::async_runtime::spawn(mcp_server::start(mcp_port));
|
||||
|
||||
// ── MCP 单一服务器化一次性迁移(settings 标记位防重复)────
|
||||
std::thread::spawn(|| {
|
||||
std::thread::sleep(std::time::Duration::from_secs(10)); // 等 DB 就绪
|
||||
mcp_inject::migrate_to_single_server();
|
||||
});
|
||||
|
||||
// ── Gitea 远端轮询(G6):每 10 分钟拉取绑定仓库 commit ───
|
||||
// 出站 HTTPS 采集,适配公网 Gitea + 内网炼境拓扑(webhook 不可达时的降级)
|
||||
std::thread::spawn(|| {
|
||||
|
||||
@ -2,7 +2,10 @@ use crate::{db, db::log_event};
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
const MCP_KEY_PREFIX: &str = "lian-jing:group-";
|
||||
/// 单一全局服务器的 settings.json key / 全局注册名
|
||||
const MCP_KEY: &str = "lian-jing";
|
||||
/// 旧组级 key 前缀——仅用于注入时清理存量配置,T6 收尾后可删
|
||||
const LEGACY_KEY_PREFIX: &str = "lian-jing:group-";
|
||||
|
||||
// ── 路径解析 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -47,9 +50,9 @@ fn liangjing_path(project_id: &str) -> Result<PathBuf, String> {
|
||||
Ok(project_root(project_id)?.join(".claude").join("liangjing.json"))
|
||||
}
|
||||
|
||||
/// 写炼境上下文文件,供子项目 Claude 会话直接读取 project_id / group_id。
|
||||
/// 合并写入:保留文件中已有的其他字段,只更新炼境管理的三个字段(重注入可回填缺失的 group_id)
|
||||
fn write_liangjing_context(project_id: &str, group_id: &str, port: u16) -> Result<(), String> {
|
||||
/// 写炼境上下文文件,供子项目 Claude 会话直接读取 project_id。
|
||||
/// 合并写入:保留文件中已有的其他字段;主动删除 group_id(单一服务器寻址后已无用,留着会过期误导)
|
||||
fn write_liangjing_context(project_id: &str, port: u16) -> Result<(), String> {
|
||||
let path = liangjing_path(project_id)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
@ -64,8 +67,8 @@ fn write_liangjing_context(project_id: &str, group_id: &str, port: u16) -> Resul
|
||||
};
|
||||
let obj = ctx.as_object_mut().expect("ctx 必为 JSON 对象");
|
||||
obj.insert("project_id".into(), json!(project_id));
|
||||
obj.insert("group_id".into(), json!(group_id));
|
||||
obj.insert("mcp_port".into(), json!(port));
|
||||
obj.remove("group_id");
|
||||
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))
|
||||
@ -109,24 +112,26 @@ fn find_claude_bin() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 将产品组 MCP 注册到 Claude 全局配置(~/.claude.json),best-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);
|
||||
fn claude_git_bash() -> String {
|
||||
std::env::var("CLAUDE_CODE_GIT_BASH_PATH")
|
||||
.unwrap_or_else(|_| r"C:\Program Files\Git\bin\bash.exe".to_string())
|
||||
}
|
||||
|
||||
/// 将单一全局 lian-jing 服务器注册到 Claude 全局配置(~/.claude.json),best-effort,失败仅记录日志
|
||||
fn try_register_globally(port: u16) {
|
||||
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 注册",
|
||||
"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()
|
||||
});
|
||||
let git_bash = claude_git_bash();
|
||||
|
||||
match std::process::Command::new(&claude_bin)
|
||||
.args([
|
||||
@ -136,38 +141,74 @@ fn try_register_globally(group_id: &str, port: u16) {
|
||||
"sse",
|
||||
"-s",
|
||||
"user",
|
||||
&server_name,
|
||||
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(&server_name),
|
||||
);
|
||||
db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(MCP_KEY));
|
||||
}
|
||||
Ok(out) => {
|
||||
let msg = format!(
|
||||
"claude mcp add 非零退出: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
db::log_event("mcp_inject", "warn", &msg, Some(&server_name));
|
||||
db::log_event("mcp_inject", "warn", &msg, Some(MCP_KEY));
|
||||
}
|
||||
Err(e) => {
|
||||
db::log_event(
|
||||
"mcp_inject",
|
||||
"warn",
|
||||
&format!("执行 claude mcp add 失败: {}", e),
|
||||
Some(&server_name),
|
||||
Some(MCP_KEY),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理旧组级全局注册(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 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 git_bash = claude_git_bash();
|
||||
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> {
|
||||
@ -199,8 +240,9 @@ fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> {
|
||||
|
||||
// ── 公开接口 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 向项目的 .claude/settings.json 注入产品组 MCP 配置
|
||||
pub fn inject_project_mcp(project_id: &str, group_id: &str) -> Result<(), String> {
|
||||
/// 向项目的 .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 path = settings_path(project_id)?;
|
||||
let mut settings = read_json(&path)?;
|
||||
@ -215,44 +257,91 @@ pub fn inject_project_mcp(project_id: &str, group_id: &str) -> Result<(), String
|
||||
|
||||
let servers = obj.entry("mcpServers").or_insert(json!({}));
|
||||
|
||||
let key = format!("{}{}", MCP_KEY_PREFIX, group_id);
|
||||
servers[&key] = 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": format!("http://127.0.0.1:{}/mcp/group/{}", port, group_id)
|
||||
"url": format!("http://127.0.0.1:{}/mcp", port)
|
||||
});
|
||||
|
||||
write_json(&path, &settings).inspect(|_| {
|
||||
let ctx = serde_json::json!({"project_id": project_id, "group_id": group_id}).to_string();
|
||||
let ctx = serde_json::json!({"project_id": project_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 会话无需遍历即可知道 project_id
|
||||
let _ = write_liangjing_context(project_id, port);
|
||||
|
||||
// 同步注册到 ~/.claude.json,让 Claude Code 能加载 MCP 工具(best-effort)
|
||||
try_register_globally(group_id, port);
|
||||
try_register_globally(port);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从项目的 .claude/settings.json 移除产品组 MCP 配置
|
||||
pub fn remove_project_mcp(project_id: &str, group_id: &str) -> Result<(), String> {
|
||||
/// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。
|
||||
/// 调用方需自行保证项目已不属于任何产品组时才调用。
|
||||
pub fn remove_project_mcp(project_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);
|
||||
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 summary = format!(
|
||||
"单一服务器迁移完成:重注入失败 {} 个,移除旧全局注册 {} 条{}",
|
||||
errors.len(),
|
||||
removed.len(),
|
||||
if errors.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(";失败详情: {}", errors.join("; "))
|
||||
}
|
||||
);
|
||||
let level = if errors.is_empty() { "info" } else { "warn" };
|
||||
db::log_event("mcp_inject", level, &summary, None);
|
||||
|
||||
// claude CLI 不在时旧全局注册无法清理——不落标记位,等下次启动重试(整个流程幂等)
|
||||
if find_claude_bin().is_none() {
|
||||
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> {
|
||||
@ -261,19 +350,20 @@ pub fn reinject_all_mcp() -> Vec<String> {
|
||||
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") {
|
||||
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| Ok((row.get(0)?, row.get(1)?)))
|
||||
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, group_id) in mappings {
|
||||
if let Err(e) = inject_project_mcp(&project_id, &group_id) {
|
||||
for project_id in project_ids {
|
||||
if let Err(e) = inject_project_mcp(&project_id) {
|
||||
errors.push(format!("项目 {}: {}", project_id, e));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
@ -42,10 +42,10 @@ pub async fn start(port: u16) {
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
// Streamable HTTP(POST)— 向后兼容,Python 直调仍可用
|
||||
.route("/mcp/group/:group_id", post(handle_mcp).get(handle_sse_connect))
|
||||
// 单一全局服务器路由(lian-jing):寻址不依赖 group_id,工具显式传 project_id
|
||||
.route("/mcp", post(handle_mcp).get(handle_sse_connect))
|
||||
// SSE 消息端点:客户端通过此 URL 发送 JSON-RPC,响应经 SSE 推回
|
||||
.route("/mcp/group/:group_id/messages", post(handle_sse_message))
|
||||
.route("/mcp/messages", post(handle_sse_message))
|
||||
// Gitea webhook:接收 push/pull_request 事件写入飞轮
|
||||
.route("/webhook/gitea/:project_id", post(crate::mcp_webhook::handle_gitea_webhook))
|
||||
.with_state(sessions);
|
||||
@ -85,7 +85,7 @@ struct SessionQuery {
|
||||
|
||||
// ── 共享 RPC 处理逻辑 ─────────────────────────────────────────────────────────
|
||||
|
||||
async fn process_rpc(group_id: &str, req: &RpcRequest) -> Result<Value, String> {
|
||||
async fn process_rpc(req: &RpcRequest) -> Result<Value, String> {
|
||||
match req.method.as_str() {
|
||||
"initialize" => Ok(json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
@ -93,19 +93,19 @@ async fn process_rpc(group_id: &str, req: &RpcRequest) -> Result<Value, String>
|
||||
"serverInfo": { "name": "炼境", "version": env!("CARGO_PKG_VERSION") }
|
||||
})),
|
||||
"tools/list" => Ok(crate::mcp_tools::tools_list_result()),
|
||||
"tools/call" => Ok(crate::mcp_tools::tools_call(group_id, req.params.as_ref()).await),
|
||||
"tools/call" => Ok(crate::mcp_tools::tools_call(req.params.as_ref()).await),
|
||||
_ => Err(format!("Method not found: {}", req.method)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Streamable HTTP(POST)─────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_mcp(Path(group_id): Path<String>, Json(req): Json<RpcRequest>) -> Response {
|
||||
async fn handle_mcp(Json(req): Json<RpcRequest>) -> Response {
|
||||
if req.id.is_none() {
|
||||
return StatusCode::ACCEPTED.into_response();
|
||||
}
|
||||
let id = req.id.clone();
|
||||
match process_rpc(&group_id, &req).await {
|
||||
match process_rpc(&req).await {
|
||||
Ok(result) => Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })).into_response(),
|
||||
Err(msg) => Json(json!({
|
||||
"jsonrpc": "2.0", "id": id,
|
||||
@ -117,18 +117,17 @@ async fn handle_mcp(Path(group_id): Path<String>, Json(req): Json<RpcRequest>) -
|
||||
|
||||
// ── SSE 传输(GET + POST /messages)─────────────────────────────────────────
|
||||
|
||||
/// GET /mcp/group/:group_id — Claude Code SSE 握手,首条事件告知后续 POST URL
|
||||
/// GET /mcp — Claude Code SSE 握手,首条事件告知后续 POST URL
|
||||
async fn handle_sse_connect(
|
||||
State(sessions): State<Sessions>,
|
||||
Path(group_id): Path<String>,
|
||||
) -> Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>> {
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let (tx, rx) = mpsc::channel::<(String, String)>(32);
|
||||
|
||||
let port = read_port();
|
||||
let endpoint_uri = format!(
|
||||
"http://127.0.0.1:{}/mcp/group/{}/messages?session_id={}",
|
||||
port, group_id, session_id
|
||||
"http://127.0.0.1:{}/mcp/messages?session_id={}",
|
||||
port, session_id
|
||||
);
|
||||
let _ = tx.send(("endpoint".to_string(), endpoint_uri)).await;
|
||||
sessions.lock().await.insert(session_id, tx);
|
||||
@ -139,11 +138,35 @@ async fn handle_sse_connect(
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
/// POST /mcp/group/:group_id/messages?session_id=xxx
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rpc(method: &str) -> RpcRequest {
|
||||
RpcRequest {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
method: method.to_string(),
|
||||
id: Some(json!(1)),
|
||||
params: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 单一 lian-jing 服务器的握手路径:initialize / tools/list 必须可用
|
||||
#[tokio::test]
|
||||
async fn process_rpc_handshake() {
|
||||
let init = process_rpc(&rpc("initialize")).await.unwrap();
|
||||
assert_eq!(init["serverInfo"]["name"], "炼境");
|
||||
|
||||
let tools = process_rpc(&rpc("tools/list")).await.unwrap();
|
||||
let list = tools["tools"].as_array().unwrap();
|
||||
assert!(list.len() >= 20, "工具清单不应因去组化而缺失,实际 {}", list.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /mcp/messages?session_id=xxx
|
||||
/// 立即返回 202,后台异步处理工具调用并经 SSE channel 推回响应
|
||||
async fn handle_sse_message(
|
||||
State(sessions): State<Sessions>,
|
||||
Path(group_id): Path<String>,
|
||||
Query(query): Query<SessionQuery>,
|
||||
Json(req): Json<RpcRequest>,
|
||||
) -> Response {
|
||||
@ -151,18 +174,17 @@ async fn handle_sse_message(
|
||||
return StatusCode::ACCEPTED.into_response();
|
||||
}
|
||||
|
||||
let sessions_clone = sessions.clone();
|
||||
let session_id = query.session_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let id = req.id.clone();
|
||||
let response_json = match process_rpc(&group_id, &req).await {
|
||||
let response_json = match process_rpc(&req).await {
|
||||
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
|
||||
Err(msg) => json!({
|
||||
"jsonrpc": "2.0", "id": id,
|
||||
"error": { "code": -32601, "message": msg }
|
||||
}),
|
||||
};
|
||||
let mut guard = sessions_clone.lock().await;
|
||||
let mut guard = sessions.lock().await;
|
||||
if let Some(tx) = guard.get(&session_id) {
|
||||
if tx
|
||||
.send(("message".to_string(), response_json.to_string()))
|
||||
|
||||
@ -14,8 +14,14 @@ pub fn tools_list_result() -> Value {
|
||||
},
|
||||
{
|
||||
"name": "list_group_projects",
|
||||
"description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息",
|
||||
"inputSchema": { "type": "object", "properties": {}, "required": [] }
|
||||
"description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息(group_id 可从 list_all_projects 获取)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"group_id": { "type": "string", "description": "产品组 ID" }
|
||||
},
|
||||
"required": ["group_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_project_blueprint",
|
||||
@ -300,7 +306,7 @@ pub fn tools_list_result() -> Value {
|
||||
|
||||
// ── 工具分发 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
||||
pub async fn tools_call(params: Option<&Value>) -> Value {
|
||||
let tool_name = params
|
||||
.and_then(|p| p.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
@ -310,18 +316,26 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
||||
.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),
|
||||
"list_group_projects" => {
|
||||
let group_id = args
|
||||
.get("group_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty());
|
||||
match group_id {
|
||||
Some(g) => list_group_projects(g),
|
||||
None => Err("需显式传 group_id 参数(组 ID 可从 list_all_projects 获取)".to_string()),
|
||||
}
|
||||
}
|
||||
"get_project_blueprint" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
get_project_blueprint(&gid, pid)
|
||||
get_project_blueprint(pid)
|
||||
}
|
||||
"get_blueprint_status" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
resolve_project_root(&gid, pid).and_then(|root| {
|
||||
resolve_project_root(pid).and_then(|root| {
|
||||
crate::commands::blueprint::get_blueprint_status(root.to_string_lossy().to_string())
|
||||
.and_then(|s| serde_json::to_string_pretty(&s).map_err(|e| e.to_string()))
|
||||
})
|
||||
@ -332,7 +346,7 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
||||
if mode != "init" && mode != "sync" {
|
||||
Err(format!("mode 只允许 init 或 sync,收到: {mode}"))
|
||||
} else {
|
||||
resolve_project_root(&gid, pid).and_then(|root| {
|
||||
resolve_project_root(pid).and_then(|root| {
|
||||
crate::commands::blueprint::generate_blueprint_prompt(
|
||||
root.to_string_lossy().to_string(),
|
||||
mode.to_string(),
|
||||
@ -342,7 +356,7 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
||||
}
|
||||
"sync_blueprint_rules" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
resolve_project_root(&gid, pid).and_then(|root| {
|
||||
resolve_project_root(pid).and_then(|root| {
|
||||
crate::commands::blueprint::sync_blueprint_rules(root.to_string_lossy().to_string())
|
||||
.and_then(|r| serde_json::to_string_pretty(&r).map_err(|e| e.to_string()))
|
||||
})
|
||||
@ -350,21 +364,21 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
||||
"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_file(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)
|
||||
get_project_mentor_context(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)
|
||||
append_project_note(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)
|
||||
get_project_snapshots(pid, days)
|
||||
}
|
||||
"create_pull_request" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
@ -391,17 +405,8 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
||||
}
|
||||
"inject_mcp" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
let group_id: String = conn
|
||||
.query_row(
|
||||
"SELECT group_id FROM project_group_map WHERE project_id = ?1 LIMIT 1",
|
||||
rusqlite::params![pid],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(|_| format!("项目 {} 未加入任何产品组", pid))?;
|
||||
drop(conn);
|
||||
crate::mcp_inject::inject_project_mcp(pid, &group_id)
|
||||
.map(|_| format!("MCP 注入成功:project_id={} group_id={}", pid, group_id))
|
||||
crate::mcp_inject::inject_project_mcp(pid)
|
||||
.map(|_| format!("MCP 注入成功:project_id={}(单一 lian-jing 服务器,无需 group_id)", pid))
|
||||
}
|
||||
"get_ci_status" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
@ -590,7 +595,7 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
||||
}
|
||||
"apply_onboarding_pack" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
resolve_project_root(&gid, pid).and_then(|root| {
|
||||
resolve_project_root(pid).and_then(|root| {
|
||||
let path = root.to_string_lossy().to_string();
|
||||
crate::commands::onboarding::apply_onboarding_pack(path, pid.to_string()).map(
|
||||
|report| {
|
||||
@ -628,7 +633,7 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
||||
}
|
||||
"verify_onboarding" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let root = resolve_project_root(&gid, pid)?;
|
||||
let root = resolve_project_root(pid)?;
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
let checks = crate::commands::onboarding::verify_onboarding(&conn, pid, &root);
|
||||
let passed = checks.iter().filter(|c| c.pass).count();
|
||||
@ -817,35 +822,33 @@ fn list_group_projects(group_id: &str) -> Result<String, 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)?;
|
||||
fn get_project_blueprint(project_id: &str) -> Result<String, String> {
|
||||
let root = resolve_project_root(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> {
|
||||
fn get_project_file(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 root = resolve_project_root(project_id)?;
|
||||
let path = root.join(relative_path);
|
||||
std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("读取文件失败({}): {}", path.display(), e))
|
||||
}
|
||||
|
||||
pub fn resolve_project_root(group_id: &str, project_id: &str) -> Result<std::path::PathBuf, String> {
|
||||
/// 按 project_id 直查项目根目录(单一服务器寻址,不做组归属过滤)
|
||||
pub fn resolve_project_root(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],
|
||||
"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, group_id))?;
|
||||
.map_err(|_| format!("项目 {} 不存在", project_id))?;
|
||||
|
||||
if platform.as_deref() == Some("wsl") {
|
||||
let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?;
|
||||
@ -860,40 +863,36 @@ pub fn resolve_project_root(group_id: &str, project_id: &str) -> Result<std::pat
|
||||
}
|
||||
}
|
||||
|
||||
fn get_project_mentor_context(group_id: &str, project_id: &str) -> Result<String, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
/// 项目存在性检查(替代旧的组归属校验——单一服务器寻址后组不再是访问边界)
|
||||
fn ensure_project_exists(conn: &rusqlite::Connection, project_id: &str) -> Result<(), 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],
|
||||
"SELECT COUNT(*) FROM project_workspaces WHERE id = ?1",
|
||||
[project_id],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.map(|n| n > 0)
|
||||
.unwrap_or(false);
|
||||
if !exists {
|
||||
return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id));
|
||||
return Err(format!("项目 {} 不存在", project_id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_project_mentor_context(project_id: &str) -> Result<String, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
ensure_project_exists(&conn, project_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> {
|
||||
fn append_project_note(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())?;
|
||||
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));
|
||||
}
|
||||
ensure_project_exists(&conn, project_id)?;
|
||||
conn.execute(
|
||||
"INSERT INTO project_notes (project_id, source, content) VALUES (?1, 'mcp', ?2)",
|
||||
rusqlite::params![project_id, content],
|
||||
@ -902,19 +901,9 @@ fn append_project_note(group_id: &str, project_id: &str, content: &str) -> Resul
|
||||
Ok("笔记已保存 ✅".to_string())
|
||||
}
|
||||
|
||||
fn get_project_snapshots(group_id: &str, project_id: &str, days: u32) -> Result<String, String> {
|
||||
fn get_project_snapshots(project_id: &str, days: u32) -> Result<String, String> {
|
||||
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));
|
||||
}
|
||||
ensure_project_exists(&conn, project_id)?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
|
||||
@ -532,9 +532,9 @@ export function SettingsPage() {
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1.5">
|
||||
产品组 MCP 地址:
|
||||
MCP 地址(单一全局服务器):
|
||||
<code className="font-mono bg-gray-50 px-1 rounded">
|
||||
http://localhost:{mcpPort}/mcp/group/<group_id>
|
||||
http://localhost:{mcpPort}/mcp
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user