feat(mcp): 单一全局 lian-jing 服务器——组级挂载收敛(T1-T3+T5)
All checks were successful
Push & PR Check / check (push) Successful in 1m4s

T1 服务器层:新增全局 /mcp + /mcp/messages 路由,process_rpc 组上下文
Option 化;旧 /mcp/group/:gid 路由保留过渡,T6 移除。驻军测试 2 条。
T2 工具层:resolve_project_root 按 project_id 直查 project_workspaces,
删除 3 处组归属校验(实测无隔离价值);list_group_projects 改显式传参。
T3 注入层:settings.json 收敛为单 "lian-jing" key(URL /mcp),注入时
清理旧 lian-jing:group-* key;liangjing.json 主动删除 group_id;
启动时一次性迁移(重注入全量 + 清理旧全局注册两种命名格式,标记位防重)。
T5 同步:git-workflow 文档/模板、retro-catchup 技能 Python 兜底、
设置页 URL 展示、mcp-config-inject 模块取代注记。

收益:MCP 客户端配置成本 O(N组)→O(1),授权一次永久生效;
group_id 缺失/过期/移组同步一整类问题从根上消失。
This commit is contained in:
lanrtop 2026-07-06 19:30:01 +09:00
parent daed723438
commit a41161f7d1
14 changed files with 349 additions and 168 deletions

View File

@ -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 提示用户

View File

@ -61,21 +61,21 @@
## 任务卡 ## 任务卡
### 📋 T1. 服务器层双路由:全局 /mcp + 组上下文 Option 化 ### 🔵 T1. 服务器层双路由:全局 /mcp + 组上下文 Option 化
- status: todo - status: in_progress
- 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旧路由 /mcp/group/{gid} 同请求仍 200双路由并存"
### 📋 T2. 工具层去组化resolve_project_root 按 project_id 直查 ### 🔵 T2. 工具层去组化resolve_project_root 按 project_id 直查
- status: todo - status: in_progress
- 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 全绿"
### 📋 T3. 注入层收敛lian-jing 单 key + 旧配置清理 + liangjing.json 瘦身 ### 🔵 T3. 注入层收敛lian-jing 单 key + 旧配置清理 + liangjing.json 瘦身
- status: todo - status: in_progress
- 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
- depends: T1 - depends: T1
@ -88,8 +88,8 @@
- depends: T1, T2, T3需先发版/重启炼境运行新二进制) - depends: T1, T2, T3需先发版/重启炼境运行新二进制)
- acceptance: "[CLI] claude mcp list → lian-jing-* 旧条目清零、仅剩 lian-jing[MCP] 抽查 3 个项目(含本项目 + enterprise-systemsettings.json/liangjing.json 符合新格式;新 Claude 会话经 lian-jing 实调 get_diagnostics → 返回 JSON 无 error" - acceptance: "[CLI] claude mcp list → lian-jing-* 旧条目清零、仅剩 lian-jing[MCP] 抽查 3 个项目(含本项目 + enterprise-systemsettings.json/liangjing.json 符合新格式;新 Claude 会话经 lian-jing 实调 get_diagnostics → 返回 JSON 无 error"
### 📋 T5. 文档/模板/技能/UI 寻址说明同步 ### 🔵 T5. 文档/模板/技能/UI 寻址说明同步
- status: todo - status: in_progress
- 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

View File

@ -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 直调)`。

View File

@ -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
View File

@ -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",

View File

@ -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"] }

View File

@ -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**

View File

@ -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,11 +195,20 @@ 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
.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(); 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));
} }
}
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",
params![project_id, group_id], params![project_id, group_id],

View File

@ -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 表,需手动清理)

View File

@ -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(|| {

View File

@ -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.jsonbest-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.jsonbest-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));
} }
} }

View File

@ -42,7 +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 HTTPPOST— 向后兼容Python 直调仍可用 // 全局单一服务器路由lian-jing寻址不再依赖 group_id
.route("/mcp", post(handle_mcp_global).get(handle_sse_connect_global))
.route("/mcp/messages", post(handle_sse_message_global))
// 组级路由 — 过渡期向后兼容(存量 lian-jing-{gid} 注册仍可用T6 移除
.route("/mcp/group/:group_id", post(handle_mcp).get(handle_sse_connect)) .route("/mcp/group/:group_id", 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/group/:group_id/messages", post(handle_sse_message))
@ -85,7 +88,8 @@ struct SessionQuery {
// ── 共享 RPC 处理逻辑 ───────────────────────────────────────────────────────── // ── 共享 RPC 处理逻辑 ─────────────────────────────────────────────────────────
async fn process_rpc(group_id: &str, req: &RpcRequest) -> Result<Value, String> { /// group_id 为 None 表示经全局 /mcp 路由进入(单一服务器寻址,工具按 project_id 定位)
async fn process_rpc(group_id: Option<&str>, 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",
@ -101,11 +105,19 @@ async fn process_rpc(group_id: &str, req: &RpcRequest) -> Result<Value, String>
// ── Streamable HTTPPOST───────────────────────────────────────────────────── // ── Streamable HTTPPOST─────────────────────────────────────────────────────
async fn handle_mcp(Path(group_id): Path<String>, Json(req): Json<RpcRequest>) -> Response { async fn handle_mcp(Path(group_id): Path<String>, Json(req): Json<RpcRequest>) -> Response {
mcp_response(Some(group_id), req).await
}
async fn handle_mcp_global(Json(req): Json<RpcRequest>) -> Response {
mcp_response(None, req).await
}
async fn mcp_response(group_id: Option<String>, req: 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(group_id.as_deref(), &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,
@ -121,15 +133,35 @@ async fn handle_mcp(Path(group_id): Path<String>, Json(req): Json<RpcRequest>) -
async fn handle_sse_connect( async fn handle_sse_connect(
State(sessions): State<Sessions>, State(sessions): State<Sessions>,
Path(group_id): Path<String>, Path(group_id): Path<String>,
) -> Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>> {
sse_connect(sessions, Some(group_id)).await
}
/// GET /mcp — 全局路由 SSE 握手
async fn handle_sse_connect_global(
State(sessions): State<Sessions>,
) -> Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>> {
sse_connect(sessions, None).await
}
async fn sse_connect(
sessions: Sessions,
group_id: Option<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 = match &group_id {
Some(gid) => format!(
"http://127.0.0.1:{}/mcp/group/{}/messages?session_id={}", "http://127.0.0.1:{}/mcp/group/{}/messages?session_id={}",
port, group_id, session_id port, gid, session_id
); ),
None => format!(
"http://127.0.0.1:{}/mcp/messages?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,30 +171,79 @@ async fn handle_sse_connect(
Sse::new(stream).keep_alive(KeepAlive::default()) Sse::new(stream).keep_alive(KeepAlive::default())
} }
#[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,
}
}
/// 全局路由group_id=None下 initialize / tools/list 必须可用——单一 lian-jing 服务器的握手路径
#[tokio::test]
async fn process_rpc_works_without_group_context() {
let init = process_rpc(None, &rpc("initialize")).await.unwrap();
assert_eq!(init["serverInfo"]["name"], "炼境");
let tools = process_rpc(None, &rpc("tools/list")).await.unwrap();
let list = tools["tools"].as_array().unwrap();
assert!(list.len() >= 20, "工具清单不应因去组化而缺失,实际 {}", list.len());
}
/// 过渡期组级路由Some(gid))仍可握手
#[tokio::test]
async fn process_rpc_keeps_legacy_group_context() {
let init = process_rpc(Some("any-group"), &rpc("initialize")).await.unwrap();
assert_eq!(init["serverInfo"]["name"], "炼境");
}
}
/// POST /mcp/group/:group_id/messages?session_id=xxx /// POST /mcp/group/:group_id/messages?session_id=xxx
/// 立即返回 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>, Path(group_id): Path<String>,
Query(query): Query<SessionQuery>, Query(query): Query<SessionQuery>,
Json(req): Json<RpcRequest>, Json(req): Json<RpcRequest>,
) -> Response {
sse_message(sessions, Some(group_id), query, req).await
}
/// POST /mcp/messages?session_id=xxx — 全局路由消息端点
async fn handle_sse_message_global(
State(sessions): State<Sessions>,
Query(query): Query<SessionQuery>,
Json(req): Json<RpcRequest>,
) -> Response {
sse_message(sessions, None, query, req).await
}
/// 立即返回 202后台异步处理工具调用并经 SSE channel 推回响应
async fn sse_message(
sessions: Sessions,
group_id: Option<String>,
query: SessionQuery,
req: RpcRequest,
) -> Response { ) -> Response {
if req.id.is_none() { if req.id.is_none() {
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(group_id.as_deref(), &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()))

View File

@ -14,8 +14,14 @@ pub fn tools_list_result() -> Value {
}, },
{ {
"name": "list_group_projects", "name": "list_group_projects",
"description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息", "description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息。经全局 lian-jing 服务器调用时必须传 group_id可从 list_all_projects 获取)",
"inputSchema": { "type": "object", "properties": {}, "required": [] } "inputSchema": {
"type": "object",
"properties": {
"group_id": { "type": "string", "description": "产品组 ID经组级路由调用时可省略取挂载组" }
},
"required": []
}
}, },
{ {
"name": "get_project_blueprint", "name": "get_project_blueprint",
@ -300,7 +306,8 @@ pub fn tools_list_result() -> Value {
// ── 工具分发 ────────────────────────────────────────────────────────────────── // ── 工具分发 ──────────────────────────────────────────────────────────────────
pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value { /// group_ctx 为 None 表示经全局 /mcp 路由调用单一服务器Some(gid) 为过渡期组级路由
pub async fn tools_call(group_ctx: Option<&str>, 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 +317,30 @@ 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 gid: Option<String> = group_ctx.filter(|g| !g.is_empty()).map(str::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 effective = args
.get("group_id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string)
.or(gid);
match effective {
Some(g) => list_group_projects(&g),
None => Err("经全局 lian-jing 服务器调用时需显式传 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 +351,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 +361,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 +369,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 +410,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 +600,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 +638,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 +827,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 +868,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 +906,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(

View File

@ -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/&lt;group_id&gt; http://localhost:{mcpPort}/mcp
</code> </code>
</p> </p>
</div> </div>