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 单一服务器化
|
name: MCP 单一服务器化
|
||||||
area: backend
|
area: backend
|
||||||
status: in_progress
|
status: in_progress
|
||||||
progress: 0
|
progress: 85
|
||||||
updated: 2026-07-06
|
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]
|
position: [1750, 500]
|
||||||
|
|
||||||
edges:
|
edges:
|
||||||
|
|||||||
@ -2,9 +2,13 @@
|
|||||||
|
|
||||||
产品组成员变更时,炼境自动向对应项目的 `.claude/settings.json` 注入或移除 MCP 服务器配置,实现热插拔。
|
产品组成员变更时,炼境自动向对应项目的 `.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,其余配置永远不动
|
- 炼境只操作自己命名空间下的 key,其余配置永远不动
|
||||||
- 加入/退出操作由炼境 UI 驱动,炼境必然在线,无需异步清理机制
|
- 加入/退出操作由炼境 UI 驱动,炼境必然在线,无需异步清理机制
|
||||||
- 注入前做 JSON 有效性校验,文件损坏时不覆盖,在 UI 提示用户
|
- 注入前做 JSON 有效性校验,文件损坏时不覆盖,在 UI 提示用户
|
||||||
|
|||||||
@ -61,43 +61,48 @@
|
|||||||
|
|
||||||
## 任务卡
|
## 任务卡
|
||||||
|
|
||||||
### 📋 T1. 服务器层双路由:全局 /mcp + 组上下文 Option 化
|
### ✅ T1. 服务器层双路由:全局 /mcp + 组上下文 Option 化
|
||||||
- status: todo
|
- status: done
|
||||||
- complexity: M
|
- complexity: M
|
||||||
- files: src-tauri/src/mcp_server.rs
|
- 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 直查
|
### ✅ T2. 工具层去组化:resolve_project_root 按 project_id 直查
|
||||||
- status: todo
|
- status: done
|
||||||
- complexity: M
|
- complexity: M
|
||||||
- files: src-tauri/src/mcp_tools.rs
|
- files: src-tauri/src/mcp_tools.rs
|
||||||
- depends: T1
|
- 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 瘦身
|
### ✅ T3. 注入层收敛:lian-jing 单 key + 旧配置清理 + liangjing.json 瘦身
|
||||||
- status: todo
|
- status: done
|
||||||
- complexity: M
|
- 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
|
- 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. 存量批量迁移执行 + 端到端验证
|
### 📋 T4. 存量批量迁移执行 + 端到端验证
|
||||||
- status: todo
|
- status: todo
|
||||||
- complexity: S
|
- complexity: S
|
||||||
- files: (执行操作,无新代码——复用 reinject_all_mcp)
|
- files: (执行操作,无新代码——迁移在新版启动时自动执行)
|
||||||
- depends: T1, T2, T3(需先发版/重启炼境运行新二进制)
|
- 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"
|
- 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 寻址说明同步
|
### ✅ T5. 文档/模板/技能/UI 寻址说明同步
|
||||||
- status: todo
|
- status: done
|
||||||
- complexity: S
|
- 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
|
- 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
|
- 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 路由与兼容层(下个版本)
|
### ✅ T6. 删除旧 /mcp/group 路由与兼容层
|
||||||
- status: todo
|
- status: done
|
||||||
- complexity: S
|
- complexity: S
|
||||||
- files: src-tauri/src/mcp_server.rs, src-tauri/src/mcp_tools.rs
|
- files: src-tauri/src/mcp_server.rs, src-tauri/src/mcp_tools.rs
|
||||||
- depends: T4(迁移验证通过后至少隔一个发版周期)
|
- depends: T3
|
||||||
- acceptance: "[HTTP] POST /mcp/group/{gid} → 404;cargo test 全绿;grep 'mcp/group' src-tauri/src/ 零命中"
|
- 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` 工具可用,直接调用。
|
**① 原生 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
|
```python
|
||||||
import urllib.request, json, sys
|
import urllib.request, json, sys
|
||||||
|
|
||||||
# 从 .claude/settings.json 提取 group_id
|
# 从 .claude/liangjing.json 提取 project_id 和端口
|
||||||
with open('.claude/settings.json') as f:
|
with open('.claude/liangjing.json') as f:
|
||||||
cfg = json.load(f)
|
ctx = json.load(f)
|
||||||
group_id = None
|
PROJECT_ID = ctx['project_id']
|
||||||
for key in cfg.get('mcpServers', {}):
|
port = ctx.get('mcp_port', 27190)
|
||||||
if key.startswith('lian-jing:group-'):
|
|
||||||
group_id = key.split('lian-jing:group-')[1]
|
|
||||||
break
|
|
||||||
|
|
||||||
# project_id 通过先调用 list_group_projects 获得
|
url = f'http://localhost:{port}/mcp'
|
||||||
url = f'http://localhost:27190/mcp/group/{group_id}'
|
|
||||||
|
|
||||||
def mcp_call(method, params={}):
|
def mcp_call(method, params={}):
|
||||||
payload = json.dumps({'jsonrpc':'2.0','id':1,'method':method,'params':params}).encode()
|
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` 是否在可用工具中 → 是则用**通道①**
|
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
|
3. 均不可达 → 用**通道③**,无需 project_id
|
||||||
|
|
||||||
告知用户当前使用哪个通道,例如:`使用通道②(Python HTTP 直调)`。
|
告知用户当前使用哪个通道,例如:`使用通道②(Python HTTP 直调)`。
|
||||||
|
|||||||
@ -77,10 +77,8 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否
|
|||||||
|
|
||||||
## PR 创建(炼境托管项目)
|
## PR 创建(炼境托管项目)
|
||||||
|
|
||||||
本项目由炼境管理,MCP 已注入且 `.claude/liangjing.json` 含 `project_id` 和 `group_id`
|
本项目由炼境管理,MCP 已注入且 `.claude/liangjing.json` 含 `project_id`。
|
||||||
(`group_id` 用于定位本项目对应的 MCP 服务器 `lian-jing-{group_id}`)。
|
炼境 MCP 是单一全局服务器 `lian-jing`,所有工具显式传 `project_id` 寻址,无需 group_id。
|
||||||
注意:该文件在注入时生成快照——项目移入新产品组时炼境会自动刷新;若发现 `group_id`
|
|
||||||
缺失或过期,在炼境重新执行「注入 MCP」(或调 MCP 工具 `inject_mcp`)即可回填。
|
|
||||||
|
|
||||||
**推送分支后,通过炼境 MCP 创建 PR,禁止用 curl + git credential fill 提取 token:**
|
**推送分支后,通过炼境 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]]
|
[[package]]
|
||||||
name = "dev-manager-tauri"
|
name = "dev-manager-tauri"
|
||||||
version = "0.1.18"
|
version = "0.1.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
|||||||
@ -47,3 +47,6 @@ serde_yaml = "0.9"
|
|||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1", features = ["macros", "rt"] }
|
||||||
|
|
||||||
|
|||||||
@ -77,10 +77,8 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否
|
|||||||
|
|
||||||
## PR 创建(炼境托管项目)
|
## PR 创建(炼境托管项目)
|
||||||
|
|
||||||
本项目由炼境管理,MCP 已注入且 `.claude/liangjing.json` 含 `project_id` 和 `group_id`
|
本项目由炼境管理,MCP 已注入且 `.claude/liangjing.json` 含 `project_id`。
|
||||||
(`group_id` 用于定位本项目对应的 MCP 服务器 `lian-jing-{group_id}`)。
|
炼境 MCP 是单一全局服务器 `lian-jing`,所有工具显式传 `project_id` 寻址,无需 group_id。
|
||||||
注意:该文件在注入时生成快照——项目移入新产品组时炼境会自动刷新;若发现 `group_id`
|
|
||||||
缺失或过期,在炼境重新执行「注入 MCP」(或调 MCP 工具 `inject_mcp`)即可回填。
|
|
||||||
|
|
||||||
**推送分支后,通过炼境 MCP 创建 PR,禁止用 curl + git credential fill 提取 token:**
|
**推送分支后,通过炼境 MCP 创建 PR,禁止用 curl + git credential fill 提取 token:**
|
||||||
|
|
||||||
|
|||||||
@ -74,7 +74,18 @@ pub fn delete_group(id: String) -> Result<(), String> {
|
|||||||
.filter_map(|r| r.ok())
|
.filter_map(|r| r.ok())
|
||||||
.collect();
|
.collect();
|
||||||
for project_id in &member_ids {
|
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();
|
let ctx = serde_json::json!({"project_id": project_id, "group_id": id}).to_string();
|
||||||
log_event("mcp_inject", "warn", &format!("删除组时清除 MCP 配置失败: {e}"), Some(&ctx));
|
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())?;
|
.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();
|
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));
|
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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
// DB 写入成功后注入 MCP 配置(非致命,注入失败不影响成员关系)
|
// 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,
|
Ok(()) => None,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let ctx = serde_json::json!({"project_id": &project_id, "group_id": &group_id}).to_string();
|
let ctx = serde_json::json!({"project_id": &project_id, "group_id": &group_id}).to_string();
|
||||||
@ -184,10 +195,19 @@ pub fn add_group_member(project_id: String, group_id: String, role: Option<Strin
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), String> {
|
pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), String> {
|
||||||
let conn = pool().get().map_err(|e| e.to_string())?;
|
let conn = pool().get().map_err(|e| e.to_string())?;
|
||||||
// 先清除 MCP 配置(需要在 DB 记录删除前查询项目路径)
|
// 仅当项目退出后不再属于任何组时才清除 MCP 配置(单一 lian-jing key 为全项目共用)
|
||||||
if let Err(e) = mcp_inject::remove_project_mcp(&project_id, &group_id) {
|
let in_other_group: i64 = conn
|
||||||
let ctx = serde_json::json!({"project_id": &project_id, "group_id": &group_id}).to_string();
|
.query_row(
|
||||||
log_event("mcp_inject", "warn", &format!("清除 MCP 配置失败: {e}"), Some(&ctx));
|
"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(
|
conn.execute(
|
||||||
"DELETE FROM project_group_map WHERE project_id = ?1 AND group_id = ?2",
|
"DELETE FROM project_group_map WHERE project_id = ?1 AND group_id = ?2",
|
||||||
|
|||||||
@ -277,18 +277,9 @@ pub fn delete_project(id: String, delete_local: Option<bool>) -> Result<(), Stri
|
|||||||
)
|
)
|
||||||
.unwrap_or((None, None, None));
|
.unwrap_or((None, None, None));
|
||||||
|
|
||||||
// 清理产品组 MCP 配置(在删除 project_group_map 记录之前)
|
// 清理炼境 MCP 配置(在删除 project_workspaces 记录之前,需查项目路径)
|
||||||
let group_ids: Vec<String> = conn
|
if let Err(e) = mcp_inject::remove_project_mcp(&id) {
|
||||||
.prepare("SELECT group_id FROM project_group_map WHERE project_id = ?1")
|
eprintln!("[MCP remove] 删除项目时清除失败 project={}: {}", id, e);
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理关联数据(旧 FK 指向 projects 表,需手动清理)
|
// 清理关联数据(旧 FK 指向 projects 表,需手动清理)
|
||||||
|
|||||||
@ -61,6 +61,12 @@ pub fn run() {
|
|||||||
let mcp_port = mcp_server::read_port();
|
let mcp_port = mcp_server::read_port();
|
||||||
tauri::async_runtime::spawn(mcp_server::start(mcp_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 ───
|
// ── Gitea 远端轮询(G6):每 10 分钟拉取绑定仓库 commit ───
|
||||||
// 出站 HTTPS 采集,适配公网 Gitea + 内网炼境拓扑(webhook 不可达时的降级)
|
// 出站 HTTPS 采集,适配公网 Gitea + 内网炼境拓扑(webhook 不可达时的降级)
|
||||||
std::thread::spawn(|| {
|
std::thread::spawn(|| {
|
||||||
|
|||||||
@ -2,7 +2,10 @@ use crate::{db, db::log_event};
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::path::PathBuf;
|
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"))
|
Ok(project_root(project_id)?.join(".claude").join("liangjing.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写炼境上下文文件,供子项目 Claude 会话直接读取 project_id / group_id。
|
/// 写炼境上下文文件,供子项目 Claude 会话直接读取 project_id。
|
||||||
/// 合并写入:保留文件中已有的其他字段,只更新炼境管理的三个字段(重注入可回填缺失的 group_id)
|
/// 合并写入:保留文件中已有的其他字段;主动删除 group_id(单一服务器寻址后已无用,留着会过期误导)
|
||||||
fn write_liangjing_context(project_id: &str, group_id: &str, port: u16) -> Result<(), String> {
|
fn write_liangjing_context(project_id: &str, port: u16) -> Result<(), String> {
|
||||||
let path = liangjing_path(project_id)?;
|
let path = liangjing_path(project_id)?;
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(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 对象");
|
let obj = ctx.as_object_mut().expect("ctx 必为 JSON 对象");
|
||||||
obj.insert("project_id".into(), json!(project_id));
|
obj.insert("project_id".into(), json!(project_id));
|
||||||
obj.insert("group_id".into(), json!(group_id));
|
|
||||||
obj.insert("mcp_port".into(), json!(port));
|
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())?;
|
let content = serde_json::to_string_pretty(&ctx).map_err(|e| e.to_string())?;
|
||||||
std::fs::write(&path, content)
|
std::fs::write(&path, content)
|
||||||
.map_err(|e| format!("写入 liangjing.json 失败: {}", e))
|
.map_err(|e| format!("写入 liangjing.json 失败: {}", e))
|
||||||
@ -109,24 +112,26 @@ fn find_claude_bin() -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将产品组 MCP 注册到 Claude 全局配置(~/.claude.json),best-effort,失败仅记录日志
|
fn claude_git_bash() -> String {
|
||||||
fn try_register_globally(group_id: &str, port: u16) {
|
std::env::var("CLAUDE_CODE_GIT_BASH_PATH")
|
||||||
let server_name = format!("lian-jing-{}", group_id);
|
.unwrap_or_else(|_| r"C:\Program Files\Git\bin\bash.exe".to_string())
|
||||||
let server_url = format!("http://127.0.0.1:{}/mcp/group/{}", port, group_id);
|
}
|
||||||
|
|
||||||
|
/// 将单一全局 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 {
|
let Some(claude_bin) = find_claude_bin() else {
|
||||||
db::log_event(
|
db::log_event(
|
||||||
"mcp_inject",
|
"mcp_inject",
|
||||||
"warn",
|
"warn",
|
||||||
"claude CLI 未找到,跳过全局 Mcp 注册",
|
"claude CLI 未找到,跳过全局 MCP 注册",
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let git_bash = std::env::var("CLAUDE_CODE_GIT_BASH_PATH").unwrap_or_else(|_| {
|
let git_bash = claude_git_bash();
|
||||||
r"C:\Program Files\Git\bin\bash.exe".to_string()
|
|
||||||
});
|
|
||||||
|
|
||||||
match std::process::Command::new(&claude_bin)
|
match std::process::Command::new(&claude_bin)
|
||||||
.args([
|
.args([
|
||||||
@ -136,38 +141,74 @@ fn try_register_globally(group_id: &str, port: u16) {
|
|||||||
"sse",
|
"sse",
|
||||||
"-s",
|
"-s",
|
||||||
"user",
|
"user",
|
||||||
&server_name,
|
MCP_KEY,
|
||||||
&server_url,
|
&server_url,
|
||||||
])
|
])
|
||||||
.env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash)
|
.env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash)
|
||||||
.output()
|
.output()
|
||||||
{
|
{
|
||||||
Ok(out) if out.status.success() => {
|
Ok(out) if out.status.success() => {
|
||||||
db::log_event(
|
db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(MCP_KEY));
|
||||||
"mcp_inject",
|
|
||||||
"info",
|
|
||||||
"全局 MCP 注册成功",
|
|
||||||
Some(&server_name),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Ok(out) => {
|
Ok(out) => {
|
||||||
let msg = format!(
|
let msg = format!(
|
||||||
"claude mcp add 非零退出: {}",
|
"claude mcp add 非零退出: {}",
|
||||||
String::from_utf8_lossy(&out.stderr)
|
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) => {
|
Err(e) => {
|
||||||
db::log_event(
|
db::log_event(
|
||||||
"mcp_inject",
|
"mcp_inject",
|
||||||
"warn",
|
"warn",
|
||||||
&format!("执行 claude mcp add 失败: {}", e),
|
&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 读写 ─────────────────────────────────────────────────────────────────
|
// ── JSON 读写 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn read_json(path: &PathBuf) -> Result<Value, String> {
|
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 配置
|
/// 向项目的 .claude/settings.json 注入单一 lian-jing MCP 配置,
|
||||||
pub fn inject_project_mcp(project_id: &str, group_id: &str) -> Result<(), String> {
|
/// 并清理旧组级 key(迁移内建于重注入)
|
||||||
|
pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
|
||||||
let port = crate::mcp_server::read_port();
|
let port = crate::mcp_server::read_port();
|
||||||
let path = settings_path(project_id)?;
|
let path = settings_path(project_id)?;
|
||||||
let mut settings = read_json(&path)?;
|
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 servers = obj.entry("mcpServers").or_insert(json!({}));
|
||||||
|
|
||||||
let key = format!("{}{}", MCP_KEY_PREFIX, group_id);
|
if let Some(map) = servers.as_object_mut() {
|
||||||
servers[&key] = json!({
|
map.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX));
|
||||||
|
}
|
||||||
|
servers[MCP_KEY] = json!({
|
||||||
"type": "sse",
|
"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(|_| {
|
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));
|
log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx));
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 写炼境上下文(含 group_id),让子项目 Claude 会话无需遍历即可知道 project_id + group_id
|
// 写炼境上下文,让子项目 Claude 会话无需遍历即可知道 project_id
|
||||||
let _ = write_liangjing_context(project_id, group_id, port);
|
let _ = write_liangjing_context(project_id, port);
|
||||||
|
|
||||||
// 同步注册到 ~/.claude.json,让 Claude Code 能加载 MCP 工具(best-effort)
|
// 同步注册到 ~/.claude.json,让 Claude Code 能加载 MCP 工具(best-effort)
|
||||||
try_register_globally(group_id, port);
|
try_register_globally(port);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从项目的 .claude/settings.json 移除产品组 MCP 配置
|
/// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。
|
||||||
pub fn remove_project_mcp(project_id: &str, group_id: &str) -> Result<(), String> {
|
/// 调用方需自行保证项目已不属于任何产品组时才调用。
|
||||||
|
pub fn remove_project_mcp(project_id: &str) -> Result<(), String> {
|
||||||
let path = settings_path(project_id)?;
|
let path = settings_path(project_id)?;
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let mut settings = read_json(&path)?;
|
let mut settings = read_json(&path)?;
|
||||||
|
|
||||||
let key = format!("{}{}", MCP_KEY_PREFIX, group_id);
|
|
||||||
if let Some(servers) = settings
|
if let Some(servers) = settings
|
||||||
.get_mut("mcpServers")
|
.get_mut("mcpServers")
|
||||||
.and_then(|v| v.as_object_mut())
|
.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)
|
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 配置
|
/// 端口变更后重新注入所有产品组成员的 MCP 配置
|
||||||
/// 返回注入失败的错误列表(部分失败不影响其他项目)
|
/// 返回注入失败的错误列表(部分失败不影响其他项目)
|
||||||
pub fn reinject_all_mcp() -> Vec<String> {
|
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()],
|
Err(e) => return vec![e.to_string()],
|
||||||
};
|
};
|
||||||
|
|
||||||
let mappings: Vec<(String, String)> = {
|
let project_ids: Vec<String> = {
|
||||||
let mut stmt = match conn.prepare("SELECT project_id, group_id FROM project_group_map") {
|
let mut stmt = match conn.prepare("SELECT DISTINCT project_id FROM project_group_map") {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => return vec![e.to_string()],
|
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())
|
.map(|iter| iter.filter_map(|r| r.ok()).collect())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
for (project_id, group_id) in mappings {
|
for project_id in project_ids {
|
||||||
if let Err(e) = inject_project_mcp(&project_id, &group_id) {
|
if let Err(e) = inject_project_mcp(&project_id) {
|
||||||
errors.push(format!("项目 {}: {}", project_id, e));
|
errors.push(format!("项目 {}: {}", project_id, e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{
|
response::{
|
||||||
sse::{Event, KeepAlive, Sse},
|
sse::{Event, KeepAlive, Sse},
|
||||||
@ -42,10 +42,10 @@ pub async fn start(port: u16) {
|
|||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
// Streamable HTTP(POST)— 向后兼容,Python 直调仍可用
|
// 单一全局服务器路由(lian-jing):寻址不依赖 group_id,工具显式传 project_id
|
||||||
.route("/mcp/group/:group_id", post(handle_mcp).get(handle_sse_connect))
|
.route("/mcp", post(handle_mcp).get(handle_sse_connect))
|
||||||
// SSE 消息端点:客户端通过此 URL 发送 JSON-RPC,响应经 SSE 推回
|
// 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 事件写入飞轮
|
// Gitea webhook:接收 push/pull_request 事件写入飞轮
|
||||||
.route("/webhook/gitea/:project_id", post(crate::mcp_webhook::handle_gitea_webhook))
|
.route("/webhook/gitea/:project_id", post(crate::mcp_webhook::handle_gitea_webhook))
|
||||||
.with_state(sessions);
|
.with_state(sessions);
|
||||||
@ -85,7 +85,7 @@ struct SessionQuery {
|
|||||||
|
|
||||||
// ── 共享 RPC 处理逻辑 ─────────────────────────────────────────────────────────
|
// ── 共享 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() {
|
match req.method.as_str() {
|
||||||
"initialize" => Ok(json!({
|
"initialize" => Ok(json!({
|
||||||
"protocolVersion": "2024-11-05",
|
"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") }
|
"serverInfo": { "name": "炼境", "version": env!("CARGO_PKG_VERSION") }
|
||||||
})),
|
})),
|
||||||
"tools/list" => Ok(crate::mcp_tools::tools_list_result()),
|
"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)),
|
_ => Err(format!("Method not found: {}", req.method)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Streamable HTTP(POST)─────────────────────────────────────────────────────
|
// ── 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() {
|
if req.id.is_none() {
|
||||||
return StatusCode::ACCEPTED.into_response();
|
return StatusCode::ACCEPTED.into_response();
|
||||||
}
|
}
|
||||||
let id = req.id.clone();
|
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(),
|
Ok(result) => Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })).into_response(),
|
||||||
Err(msg) => Json(json!({
|
Err(msg) => Json(json!({
|
||||||
"jsonrpc": "2.0", "id": id,
|
"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)─────────────────────────────────────────
|
// ── 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(
|
async fn handle_sse_connect(
|
||||||
State(sessions): State<Sessions>,
|
State(sessions): State<Sessions>,
|
||||||
Path(group_id): Path<String>,
|
|
||||||
) -> Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>> {
|
) -> Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>> {
|
||||||
let session_id = uuid::Uuid::new_v4().to_string();
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
let (tx, rx) = mpsc::channel::<(String, String)>(32);
|
let (tx, rx) = mpsc::channel::<(String, String)>(32);
|
||||||
|
|
||||||
let port = read_port();
|
let port = read_port();
|
||||||
let endpoint_uri = format!(
|
let endpoint_uri = format!(
|
||||||
"http://127.0.0.1:{}/mcp/group/{}/messages?session_id={}",
|
"http://127.0.0.1:{}/mcp/messages?session_id={}",
|
||||||
port, group_id, session_id
|
port, session_id
|
||||||
);
|
);
|
||||||
let _ = tx.send(("endpoint".to_string(), endpoint_uri)).await;
|
let _ = tx.send(("endpoint".to_string(), endpoint_uri)).await;
|
||||||
sessions.lock().await.insert(session_id, tx);
|
sessions.lock().await.insert(session_id, tx);
|
||||||
@ -139,11 +138,35 @@ async fn handle_sse_connect(
|
|||||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
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 推回响应
|
/// 立即返回 202,后台异步处理工具调用并经 SSE channel 推回响应
|
||||||
async fn handle_sse_message(
|
async fn handle_sse_message(
|
||||||
State(sessions): State<Sessions>,
|
State(sessions): State<Sessions>,
|
||||||
Path(group_id): Path<String>,
|
|
||||||
Query(query): Query<SessionQuery>,
|
Query(query): Query<SessionQuery>,
|
||||||
Json(req): Json<RpcRequest>,
|
Json(req): Json<RpcRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
@ -151,18 +174,17 @@ async fn handle_sse_message(
|
|||||||
return StatusCode::ACCEPTED.into_response();
|
return StatusCode::ACCEPTED.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let sessions_clone = sessions.clone();
|
|
||||||
let session_id = query.session_id.clone();
|
let session_id = query.session_id.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let id = req.id.clone();
|
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 }),
|
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
|
||||||
Err(msg) => json!({
|
Err(msg) => json!({
|
||||||
"jsonrpc": "2.0", "id": id,
|
"jsonrpc": "2.0", "id": id,
|
||||||
"error": { "code": -32601, "message": msg }
|
"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 let Some(tx) = guard.get(&session_id) {
|
||||||
if tx
|
if tx
|
||||||
.send(("message".to_string(), response_json.to_string()))
|
.send(("message".to_string(), response_json.to_string()))
|
||||||
|
|||||||
@ -14,8 +14,14 @@ pub fn tools_list_result() -> Value {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "list_group_projects",
|
"name": "list_group_projects",
|
||||||
"description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息",
|
"description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息(group_id 可从 list_all_projects 获取)",
|
||||||
"inputSchema": { "type": "object", "properties": {}, "required": [] }
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"group_id": { "type": "string", "description": "产品组 ID" }
|
||||||
|
},
|
||||||
|
"required": ["group_id"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_project_blueprint",
|
"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
|
let tool_name = params
|
||||||
.and_then(|p| p.get("name"))
|
.and_then(|p| p.get("name"))
|
||||||
.and_then(|n| n.as_str())
|
.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"))
|
.and_then(|p| p.get("arguments"))
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or(json!({}));
|
.unwrap_or(json!({}));
|
||||||
let gid = group_id.to_string();
|
|
||||||
|
|
||||||
let outcome = tokio::task::spawn_blocking(move || match tool_name.as_str() {
|
let outcome = tokio::task::spawn_blocking(move || match tool_name.as_str() {
|
||||||
"get_diagnostics" => get_diagnostics(),
|
"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" => {
|
"get_project_blueprint" => {
|
||||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
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" => {
|
"get_blueprint_status" => {
|
||||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
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())
|
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()))
|
.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" {
|
if mode != "init" && mode != "sync" {
|
||||||
Err(format!("mode 只允许 init 或 sync,收到: {mode}"))
|
Err(format!("mode 只允许 init 或 sync,收到: {mode}"))
|
||||||
} else {
|
} else {
|
||||||
resolve_project_root(&gid, pid).and_then(|root| {
|
resolve_project_root(pid).and_then(|root| {
|
||||||
crate::commands::blueprint::generate_blueprint_prompt(
|
crate::commands::blueprint::generate_blueprint_prompt(
|
||||||
root.to_string_lossy().to_string(),
|
root.to_string_lossy().to_string(),
|
||||||
mode.to_string(),
|
mode.to_string(),
|
||||||
@ -342,7 +356,7 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
|||||||
}
|
}
|
||||||
"sync_blueprint_rules" => {
|
"sync_blueprint_rules" => {
|
||||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
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())
|
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()))
|
.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" => {
|
"get_project_file" => {
|
||||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
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("");
|
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" => {
|
"get_project_mentor_context" => {
|
||||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
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" => {
|
"append_project_note" => {
|
||||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
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("");
|
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" => {
|
"get_project_snapshots" => {
|
||||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
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;
|
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" => {
|
"create_pull_request" => {
|
||||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
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" => {
|
"inject_mcp" => {
|
||||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
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())?;
|
crate::mcp_inject::inject_project_mcp(pid)
|
||||||
let group_id: String = conn
|
.map(|_| format!("MCP 注入成功:project_id={}(单一 lian-jing 服务器,无需 group_id)", pid))
|
||||||
.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))
|
|
||||||
}
|
}
|
||||||
"get_ci_status" => {
|
"get_ci_status" => {
|
||||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
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" => {
|
"apply_onboarding_pack" => {
|
||||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
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();
|
let path = root.to_string_lossy().to_string();
|
||||||
crate::commands::onboarding::apply_onboarding_pack(path, pid.to_string()).map(
|
crate::commands::onboarding::apply_onboarding_pack(path, pid.to_string()).map(
|
||||||
|report| {
|
|report| {
|
||||||
@ -628,7 +633,7 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
|||||||
}
|
}
|
||||||
"verify_onboarding" => {
|
"verify_onboarding" => {
|
||||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
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 conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||||
let checks = crate::commands::onboarding::verify_onboarding(&conn, pid, &root);
|
let checks = crate::commands::onboarding::verify_onboarding(&conn, pid, &root);
|
||||||
let passed = checks.iter().filter(|c| c.pass).count();
|
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")))
|
Ok(format!("产品组成员(共 {} 个项目):\n\n{}", rows.len(), rows.join("\n\n")))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_project_blueprint(group_id: &str, project_id: &str) -> Result<String, String> {
|
fn get_project_blueprint(project_id: &str) -> Result<String, String> {
|
||||||
let root = resolve_project_root(group_id, project_id)?;
|
let root = resolve_project_root(project_id)?;
|
||||||
let path = root.join(".blueprint").join("manifest.yaml");
|
let path = root.join(".blueprint").join("manifest.yaml");
|
||||||
std::fs::read_to_string(&path)
|
std::fs::read_to_string(&path)
|
||||||
.map_err(|e| format!("读取蓝图失败({}): {}", path.display(), e))
|
.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("..") {
|
if relative_path.contains("..") {
|
||||||
return Err("路径不允许包含 \"..\"".to_string());
|
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);
|
let path = root.join(relative_path);
|
||||||
std::fs::read_to_string(&path)
|
std::fs::read_to_string(&path)
|
||||||
.map_err(|e| format!("读取文件失败({}): {}", path.display(), e))
|
.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 conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||||
let row: rusqlite::Result<(Option<String>, Option<String>, Option<String>)> = conn.query_row(
|
let row: rusqlite::Result<(Option<String>, Option<String>, Option<String>)> = conn.query_row(
|
||||||
"SELECT pw.win_path, pw.wsl_path, pw.platform
|
"SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1",
|
||||||
FROM project_group_map pgm
|
[project_id],
|
||||||
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)?)),
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
);
|
);
|
||||||
let (win_path, wsl_path, platform) = row
|
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") {
|
if platform.as_deref() == Some("wsl") {
|
||||||
let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?;
|
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
|
let exists: bool = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2",
|
"SELECT COUNT(*) FROM project_workspaces WHERE id = ?1",
|
||||||
[group_id, project_id],
|
[project_id],
|
||||||
|r| r.get::<_, i64>(0),
|
|r| r.get::<_, i64>(0),
|
||||||
)
|
)
|
||||||
.map(|n| n > 0)
|
.map(|n| n > 0)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if !exists {
|
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())
|
let ctx = crate::commands::mentor::get_mentor_context(project_id.to_string())
|
||||||
.map_err(|e| format!("获取导师上下文失败: {e}"))?;
|
.map_err(|e| format!("获取导师上下文失败: {e}"))?;
|
||||||
Ok(ctx.mentor_markdown)
|
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() {
|
if content.trim().is_empty() {
|
||||||
return Err("笔记内容不能为空".to_string());
|
return Err("笔记内容不能为空".to_string());
|
||||||
}
|
}
|
||||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||||
let exists: bool = conn
|
ensure_project_exists(&conn, project_id)?;
|
||||||
.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(
|
conn.execute(
|
||||||
"INSERT INTO project_notes (project_id, source, content) VALUES (?1, 'mcp', ?2)",
|
"INSERT INTO project_notes (project_id, source, content) VALUES (?1, 'mcp', ?2)",
|
||||||
rusqlite::params![project_id, content],
|
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())
|
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 conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||||
let exists: bool = conn
|
ensure_project_exists(&conn, project_id)?;
|
||||||
.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
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
@ -532,9 +532,9 @@ export function SettingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400 mt-1.5">
|
<p className="text-xs text-gray-400 mt-1.5">
|
||||||
产品组 MCP 地址:
|
MCP 地址(单一全局服务器):
|
||||||
<code className="font-mono bg-gray-50 px-1 rounded">
|
<code className="font-mono bg-gray-50 px-1 rounded">
|
||||||
http://localhost:{mcpPort}/mcp/group/<group_id>
|
http://localhost:{mcpPort}/mcp
|
||||||
</code>
|
</code>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user