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

This commit is contained in:
lanrtop 2026-07-06 18:52:55 +08:00
commit 01eb941ab9
15 changed files with 310 additions and 188 deletions

View File

@ -420,9 +420,9 @@ modules:
name: MCP 单一服务器化
area: backend
status: in_progress
progress: 0
progress: 85
updated: 2026-07-06
note: "组级挂载 lian-jing-{group_id} 收敛为单一全局 lian-jing授权/注册一次项目数增长零客户端配置liangjing.json 只需 project_id。已拆卡 T1-T6双路由过渡方案T6 删旧路由后置一个版本"
note: "T1/T2/T3/T5/T6 代码完成cargo test 93 绿 + typecheck 过 + grep 零残留T6 经评估提前并入本次发版。剩 T4构建新二进制 + 重启触发自动迁移 + 端到端验证"
position: [1750, 500]
edges:

View File

@ -2,9 +2,13 @@
产品组成员变更时,炼境自动向对应项目的 `.claude/settings.json` 注入或移除 MCP 服务器配置,实现热插拔。
> ⚠️ 2026-07-06 起组级 key 方案被 [mcp-single-server](./mcp-single-server.md) 取代:
> 注入 key 收敛为单一 `"lian-jing"`URL `/mcp`),寻址不再依赖 group_id。
> 本文任务卡中的 `lian-jing:group-{group_id}` 描述保留为历史记录。
## 决策记录
- 使用 `lian-jing:group-{group_id}` 命名空间前缀标记炼境写入的 key与用户自有配置严格隔离
- 使用 `lian-jing:group-{group_id}` 命名空间前缀标记炼境写入的 key与用户自有配置严格隔离(已被 mcp-single-server 取代)
- 炼境只操作自己命名空间下的 key其余配置永远不动
- 加入/退出操作由炼境 UI 驱动,炼境必然在线,无需异步清理机制
- 注入前做 JSON 有效性校验,文件损坏时不覆盖,在 UI 提示用户

View File

@ -61,43 +61,48 @@
## 任务卡
### 📋 T1. 服务器层双路由:全局 /mcp + 组上下文 Option 化
- status: todo
### T1. 服务器层双路由:全局 /mcp + 组上下文 Option 化
- status: done
- complexity: M
- files: src-tauri/src/mcp_server.rs
- acceptance: "[HTTP] POST http://127.0.0.1:{port}/mcp (initialize) → 200 且 serverInfo.name=炼境GET /mcp 返回 SSE 事件流endpoint 指向 /mcp/messages旧路由 /mcp/group/{gid} 同请求仍 200双路由并存"
- acceptance: "[HTTP] POST http://127.0.0.1:{port}/mcp (initialize) → 200 且 serverInfo.name=炼境GET /mcp 返回 SSE 事件流endpoint 指向 /mcp/messages"
- done_note: 2026-07-06 完成。process_rpc 驻军测试覆盖握手路径cargo test 全绿HTTP 层实测归入 T4需新二进制运行。注T6 随后并入同一发版,双路由过渡态只存在于中间 commit
### 📋 T2. 工具层去组化resolve_project_root 按 project_id 直查
- status: todo
### T2. 工具层去组化resolve_project_root 按 project_id 直查
- status: done
- complexity: M
- files: src-tauri/src/mcp_tools.rs
- depends: T1
- acceptance: "[HTTP] 经 /mcp 调 get_project_blueprint(project_id) → 返回 manifest 内容,无'不属于产品组'错误list_group_projects(group_id=xxx) 显式传参 → 返回成员列表,无参数且无挂载上下文 → 报错提示传参cargo test 全绿"
- acceptance: "[HTTP] 经 /mcp 调 get_project_blueprint(project_id) → 返回 manifest 内容,无'不属于产品组'错误list_group_projects(group_id=xxx) 显式传参 → 返回成员列表cargo test 全绿"
- done_note: 2026-07-06 完成。resolve_project_root 直查 project_workspaces3 处组归属校验替换为 ensure_project_existslist_group_projects schema 改 required group_id。HTTP 实测归入 T4
### 📋 T3. 注入层收敛lian-jing 单 key + 旧配置清理 + liangjing.json 瘦身
- status: todo
### T3. 注入层收敛lian-jing 单 key + 旧配置清理 + liangjing.json 瘦身
- status: done
- complexity: M
- files: src-tauri/src/mcp_inject.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/commands/groups.rs
- files: src-tauri/src/mcp_inject.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/commands/groups.rs, src-tauri/src/commands/projects.rs, src-tauri/src/lib.rs
- depends: T1
- acceptance: "[Tauri command] inject_project_mcp(pid) → 项目 settings.json 含 key \"lian-jing\"URL 以 /mcp 结尾)且无 lian-jing:group-* 残留liangjing.json 含 project_id+mcp_port 且 group_id 键被删除try_register_globally 注册单个 lian-jing 并对旧名(完整 UUID + 短 8 位两种格式)执行 claude mcp removeremove_group_member 仅在项目不属于任何组时移除 keycargo 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 仅在项目不属于任何组时移除 keycargo test 全绿"
- done_note: 2026-07-06 完成。额外实现 migrate_to_single_server 启动时一次性迁移(标记位 mcp_single_migratedclaude CLI 缺失时不落标记等重试delete_group/remove_group_member 加"仍属其他组则保留"判断;文件实测归入 T4
### 📋 T4. 存量批量迁移执行 + 端到端验证
- status: todo
- complexity: S
- files: (执行操作,无新代码——复用 reinject_all_mcp
- depends: T1, T2, T3(需先发版/重启炼境运行新二进制)
- acceptance: "[CLI] claude mcp list → lian-jing-* 旧条目清零、仅剩 lian-jing[MCP] 抽查 3 个项目(含本项目 + enterprise-systemsettings.json/liangjing.json 符合新格式;新 Claude 会话经 lian-jing 实调 get_diagnostics → 返回 JSON 无 error"
- files: (执行操作,无新代码——迁移在新版启动时自动执行
- depends: T1, T2, T3, T6需先构建 + 重启炼境运行新二进制)
- acceptance: "[CLI] claude mcp list → lian-jing-* 旧条目清零、仅剩 lian-jing[MCP] 抽查 3 个项目(含本项目 + enterprise-systemsettings.json/liangjing.json 符合新格式;新 Claude 会话经 lian-jing 实调 get_diagnostics → 返回 JSON 无 error[HTTP] POST /mcp/group/{gid} → 404"
### 📋 T5. 文档/模板/技能/UI 寻址说明同步
- status: todo
### T5. 文档/模板/技能/UI 寻址说明同步
- status: done
- complexity: S
- files: docs/ai-context/git-workflow.md, src-tauri/resources/onboarding/git-workflow.md.tmpl, .claude/skills/retro-catchup/SKILL.md, src/components/settings/SettingsPage.tsx, .blueprint/modules/mcp-config-inject.md
- depends: T3
- acceptance: "[Bash] grep -rE 'lian-jing-\\{group_id\\}|lian-jing:group-' docs/ src/ src-tauri/resources/ → 零命中mcp-config-inject.md 保留历史注记除外pnpm typecheck 通过;另提醒用户同步仓库外 ~/.claude/CLAUDE.md 炼境规则段"
- acceptance: "[Bash] grep -rE 'lian-jing-\\{group_id\\}|lian-jing:group-' docs/ src/ src-tauri/resources/ → 零命中mcp-config-inject.md 保留历史注记除外pnpm typecheck 通过"
- done_note: 2026-07-06 完成。grep 零命中、typecheck 通过;用户全局 ~/.claude/CLAUDE.md 炼境规则段已同步改为单一 lian-jing 寻址
### 📋 T6. 后置:删除旧 /mcp/group 路由与兼容层(下个版本)
- status: todo
### ✅ T6. 删除旧 /mcp/group 路由与兼容层
- status: done
- complexity: S
- files: src-tauri/src/mcp_server.rs, src-tauri/src/mcp_tools.rs
- depends: T4迁移验证通过后至少隔一个发版周期
- acceptance: "[HTTP] POST /mcp/group/{gid} → 404cargo test 全绿grep 'mcp/group' src-tauri/src/ 零命中"
- depends: T3
- acceptance: "cargo test 全绿grep 'mcp/group' src-tauri/src/ 零命中;[HTTP] POST /mcp/group/{gid} → 404归入 T4 实测)"
- done_note: 2026-07-06 完成。原计划后置一个版本经评估提前并入本次发版迁移在新版启动时自动重写全部客户端配置旧路由的实际消费者只剩升级前的存量会话服务重启后本就断开单人环境保留价值趋近于零。tools_call 签名去 Option 化list_group_projects 改 required 参数

View File

@ -16,22 +16,18 @@ allowed-tools: Read, Glob, Grep, Bash(git log *), Bash(git diff *), Bash(python
**① 原生 MCP**:若 `append_project_note` 工具可用,直接调用。
**② Python HTTP 直调**:若原生 MCP 不可用Claude Code 与炼境握手失败的已知问题),读取项目 `.claude/settings.json`,提取 group_id用 Python 直接 POST 炼境 API
**② Python HTTP 直调**:若原生 MCP 不可用Claude Code 与炼境握手失败的已知问题),读取项目 `.claude/liangjing.json` 取 project_id 与端口,用 Python 直接 POST 炼境单一全局服务器
```python
import urllib.request, json, sys
# 从 .claude/settings.json 提取 group_id
with open('.claude/settings.json') as f:
cfg = json.load(f)
group_id = None
for key in cfg.get('mcpServers', {}):
if key.startswith('lian-jing:group-'):
group_id = key.split('lian-jing:group-')[1]
break
# 从 .claude/liangjing.json 提取 project_id 和端口
with open('.claude/liangjing.json') as f:
ctx = json.load(f)
PROJECT_ID = ctx['project_id']
port = ctx.get('mcp_port', 27190)
# project_id 通过先调用 list_group_projects 获得
url = f'http://localhost:27190/mcp/group/{group_id}'
url = f'http://localhost:{port}/mcp'
def mcp_call(method, params={}):
payload = json.dumps({'jsonrpc':'2.0','id':1,'method':method,'params':params}).encode()
@ -57,7 +53,7 @@ result = mcp_call('tools/call', {
按以下顺序确定:
1. 检测 `append_project_note` 是否在可用工具中 → 是则用**通道①**
2. 否则检测 `localhost:27190` 是否可达(`curl -s --max-time 2 http://localhost:27190` 或 Python urlopen→ 可达则用**通道②**同时读取 `.claude/settings.json` 提取 `group_id`,再调用 `list_group_projects` 获取当前项目 `project_id`
2. 否则检测 `localhost:27190` 是否可达(`curl -s --max-time 2 http://localhost:27190` 或 Python urlopen→ 可达则用**通道②**读取 `.claude/liangjing.json` 直接获取 `project_id` 与端口
3. 均不可达 → 用**通道③**,无需 project_id
告知用户当前使用哪个通道,例如:`使用通道②Python HTTP 直调)`。

View File

@ -77,10 +77,8 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否
## PR 创建(炼境托管项目)
本项目由炼境管理MCP 已注入且 `.claude/liangjing.json``project_id``group_id`
`group_id` 用于定位本项目对应的 MCP 服务器 `lian-jing-{group_id}`)。
注意:该文件在注入时生成快照——项目移入新产品组时炼境会自动刷新;若发现 `group_id`
缺失或过期,在炼境重新执行「注入 MCP」或调 MCP 工具 `inject_mcp`)即可回填。
本项目由炼境管理MCP 已注入且 `.claude/liangjing.json``project_id`
炼境 MCP 是单一全局服务器 `lian-jing`,所有工具显式传 `project_id` 寻址,无需 group_id。
**推送分支后,通过炼境 MCP 创建 PR禁止用 curl + git credential fill 提取 token**

2
src-tauri/Cargo.lock generated
View File

@ -880,7 +880,7 @@ dependencies = [
[[package]]
name = "dev-manager-tauri"
version = "0.1.18"
version = "0.1.19"
dependencies = [
"axum",
"base64 0.22.1",

View File

@ -47,3 +47,6 @@ serde_yaml = "0.9"
hmac = "0.12"
sha2 = "0.10"
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }

View File

@ -77,10 +77,8 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否
## PR 创建(炼境托管项目)
本项目由炼境管理MCP 已注入且 `.claude/liangjing.json` 含 `project_id` 和 `group_id`
`group_id` 用于定位本项目对应的 MCP 服务器 `lian-jing-{group_id}`)。
注意:该文件在注入时生成快照——项目移入新产品组时炼境会自动刷新;若发现 `group_id`
缺失或过期,在炼境重新执行「注入 MCP」或调 MCP 工具 `inject_mcp`)即可回填。
本项目由炼境管理MCP 已注入且 `.claude/liangjing.json` 含 `project_id`。
炼境 MCP 是单一全局服务器 `lian-jing`,所有工具显式传 `project_id` 寻址,无需 group_id。
**推送分支后,通过炼境 MCP 创建 PR禁止用 curl + git credential fill 提取 token**

View File

@ -74,7 +74,18 @@ pub fn delete_group(id: String) -> Result<(), String> {
.filter_map(|r| r.ok())
.collect();
for project_id in &member_ids {
if let Err(e) = mcp_inject::remove_project_mcp(project_id, &id) {
// 单一 lian-jing key 为全项目共用:成员还属于其他组时保留配置
let in_other_group: i64 = conn
.query_row(
"SELECT COUNT(*) FROM project_group_map WHERE project_id = ?1 AND group_id != ?2",
params![project_id, id],
|row| row.get(0),
)
.unwrap_or(0);
if in_other_group > 0 {
continue;
}
if let Err(e) = mcp_inject::remove_project_mcp(project_id) {
let ctx = serde_json::json!({"project_id": project_id, "group_id": id}).to_string();
log_event("mcp_inject", "warn", &format!("删除组时清除 MCP 配置失败: {e}"), Some(&ctx));
}
@ -153,7 +164,7 @@ pub fn ensure_standalone_group(project_id: &str) -> Result<(), String> {
)
.map_err(|e| e.to_string())?;
if let Err(e) = mcp_inject::inject_project_mcp(project_id, &group_id) {
if let Err(e) = mcp_inject::inject_project_mcp(project_id) {
let ctx = serde_json::json!({"project_id": project_id, "group_id": &group_id}).to_string();
log_event("mcp_inject", "warn", &format!("独立项目 MCP 注入失败: {e}"), Some(&ctx));
}
@ -170,7 +181,7 @@ pub fn add_group_member(project_id: String, group_id: String, role: Option<Strin
)
.map_err(|e| e.to_string())?;
// DB 写入成功后注入 MCP 配置(非致命,注入失败不影响成员关系)
let inject_warning = match mcp_inject::inject_project_mcp(&project_id, &group_id) {
let inject_warning = match mcp_inject::inject_project_mcp(&project_id) {
Ok(()) => None,
Err(e) => {
let ctx = serde_json::json!({"project_id": &project_id, "group_id": &group_id}).to_string();
@ -184,10 +195,19 @@ pub fn add_group_member(project_id: String, group_id: String, role: Option<Strin
#[tauri::command]
pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
// 先清除 MCP 配置(需要在 DB 记录删除前查询项目路径)
if let Err(e) = mcp_inject::remove_project_mcp(&project_id, &group_id) {
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));
// 仅当项目退出后不再属于任何组时才清除 MCP 配置(单一 lian-jing key 为全项目共用)
let in_other_group: i64 = conn
.query_row(
"SELECT COUNT(*) FROM project_group_map WHERE project_id = ?1 AND group_id != ?2",
params![project_id, group_id],
|row| row.get(0),
)
.unwrap_or(0);
if in_other_group == 0 {
if let Err(e) = mcp_inject::remove_project_mcp(&project_id) {
let ctx = serde_json::json!({"project_id": &project_id, "group_id": &group_id}).to_string();
log_event("mcp_inject", "warn", &format!("清除 MCP 配置失败: {e}"), Some(&ctx));
}
}
conn.execute(
"DELETE FROM project_group_map WHERE project_id = ?1 AND group_id = ?2",

View File

@ -277,18 +277,9 @@ pub fn delete_project(id: String, delete_local: Option<bool>) -> Result<(), Stri
)
.unwrap_or((None, None, None));
// 清理产品组 MCP 配置(在删除 project_group_map 记录之前)
let group_ids: Vec<String> = conn
.prepare("SELECT group_id FROM project_group_map WHERE project_id = ?1")
.map_err(|e| e.to_string())?
.query_map(params![id], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
for group_id in &group_ids {
if let Err(e) = mcp_inject::remove_project_mcp(&id, group_id) {
eprintln!("[MCP remove] 删除项目时清除失败 project={} group={}: {}", id, group_id, e);
}
// 清理炼境 MCP 配置(在删除 project_workspaces 记录之前,需查项目路径)
if let Err(e) = mcp_inject::remove_project_mcp(&id) {
eprintln!("[MCP remove] 删除项目时清除失败 project={}: {}", id, e);
}
// 清理关联数据(旧 FK 指向 projects 表,需手动清理)

View File

@ -61,6 +61,12 @@ pub fn run() {
let mcp_port = mcp_server::read_port();
tauri::async_runtime::spawn(mcp_server::start(mcp_port));
// ── MCP 单一服务器化一次性迁移settings 标记位防重复)────
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(10)); // 等 DB 就绪
mcp_inject::migrate_to_single_server();
});
// ── Gitea 远端轮询G6每 10 分钟拉取绑定仓库 commit ───
// 出站 HTTPS 采集,适配公网 Gitea + 内网炼境拓扑webhook 不可达时的降级)
std::thread::spawn(|| {

View File

@ -2,7 +2,10 @@ use crate::{db, db::log_event};
use serde_json::{json, Value};
use std::path::PathBuf;
const MCP_KEY_PREFIX: &str = "lian-jing:group-";
/// 单一全局服务器的 settings.json key / 全局注册名
const MCP_KEY: &str = "lian-jing";
/// 旧组级 key 前缀——仅用于注入时清理存量配置T6 收尾后可删
const LEGACY_KEY_PREFIX: &str = "lian-jing:group-";
// ── 路径解析 ──────────────────────────────────────────────────────────────────
@ -47,9 +50,9 @@ fn liangjing_path(project_id: &str) -> Result<PathBuf, String> {
Ok(project_root(project_id)?.join(".claude").join("liangjing.json"))
}
/// 写炼境上下文文件,供子项目 Claude 会话直接读取 project_id / group_id
/// 合并写入:保留文件中已有的其他字段,只更新炼境管理的三个字段(重注入可回填缺失的 group_id
fn write_liangjing_context(project_id: &str, group_id: &str, port: u16) -> Result<(), String> {
/// 写炼境上下文文件,供子项目 Claude 会话直接读取 project_id
/// 合并写入:保留文件中已有的其他字段;主动删除 group_id单一服务器寻址后已无用留着会过期误导
fn write_liangjing_context(project_id: &str, port: u16) -> Result<(), String> {
let path = liangjing_path(project_id)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
@ -64,8 +67,8 @@ fn write_liangjing_context(project_id: &str, group_id: &str, port: u16) -> Resul
};
let obj = ctx.as_object_mut().expect("ctx 必为 JSON 对象");
obj.insert("project_id".into(), json!(project_id));
obj.insert("group_id".into(), json!(group_id));
obj.insert("mcp_port".into(), json!(port));
obj.remove("group_id");
let content = serde_json::to_string_pretty(&ctx).map_err(|e| e.to_string())?;
std::fs::write(&path, content)
.map_err(|e| format!("写入 liangjing.json 失败: {}", e))
@ -109,24 +112,26 @@ fn find_claude_bin() -> Option<String> {
None
}
/// 将产品组 MCP 注册到 Claude 全局配置(~/.claude.jsonbest-effort失败仅记录日志
fn try_register_globally(group_id: &str, port: u16) {
let server_name = format!("lian-jing-{}", group_id);
let server_url = format!("http://127.0.0.1:{}/mcp/group/{}", port, group_id);
fn claude_git_bash() -> String {
std::env::var("CLAUDE_CODE_GIT_BASH_PATH")
.unwrap_or_else(|_| r"C:\Program Files\Git\bin\bash.exe".to_string())
}
/// 将单一全局 lian-jing 服务器注册到 Claude 全局配置(~/.claude.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 {
db::log_event(
"mcp_inject",
"warn",
"claude CLI 未找到,跳过全局 Mcp 注册",
"claude CLI 未找到,跳过全局 MCP 注册",
None,
);
return;
};
let git_bash = std::env::var("CLAUDE_CODE_GIT_BASH_PATH").unwrap_or_else(|_| {
r"C:\Program Files\Git\bin\bash.exe".to_string()
});
let git_bash = claude_git_bash();
match std::process::Command::new(&claude_bin)
.args([
@ -136,38 +141,74 @@ fn try_register_globally(group_id: &str, port: u16) {
"sse",
"-s",
"user",
&server_name,
MCP_KEY,
&server_url,
])
.env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash)
.output()
{
Ok(out) if out.status.success() => {
db::log_event(
"mcp_inject",
"info",
"全局 MCP 注册成功",
Some(&server_name),
);
db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(MCP_KEY));
}
Ok(out) => {
let msg = format!(
"claude mcp add 非零退出: {}",
String::from_utf8_lossy(&out.stderr)
);
db::log_event("mcp_inject", "warn", &msg, Some(&server_name));
db::log_event("mcp_inject", "warn", &msg, Some(MCP_KEY));
}
Err(e) => {
db::log_event(
"mcp_inject",
"warn",
&format!("执行 claude mcp add 失败: {}", e),
Some(&server_name),
Some(MCP_KEY),
);
}
}
}
/// 清理旧组级全局注册lian-jing-{gid},含完整 UUID 与短 8 位两种历史命名)。
/// 幂等 best-effort不存在的名字 remove 失败仅忽略。返回成功移除的名字列表。
pub fn cleanup_legacy_global_registrations() -> Vec<String> {
let Some(claude_bin) = find_claude_bin() else {
db::log_event("mcp_inject", "warn", "claude CLI 未找到,跳过旧注册清理", None);
return Vec::new();
};
let Ok(conn) = db::pool().get() else {
return Vec::new();
};
let group_ids: Vec<String> = conn
.prepare("SELECT id FROM product_groups")
.and_then(|mut stmt| {
stmt.query_map([], |r| r.get::<_, String>(0))
.map(|iter| iter.filter_map(|r| r.ok()).collect())
})
.unwrap_or_default();
let git_bash = claude_git_bash();
let mut removed = Vec::new();
for gid in &group_ids {
let mut names = vec![format!("lian-jing-{}", gid)];
if gid.len() > 8 {
names.push(format!("lian-jing-{}", &gid[..8]));
}
for name in names {
let ok = std::process::Command::new(&claude_bin)
.args(["mcp", "remove", "-s", "user", &name])
.env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash)
.output()
.map(|out| out.status.success())
.unwrap_or(false);
if ok {
db::log_event("mcp_inject", "info", "旧组级 MCP 注册已移除", Some(&name));
removed.push(name);
}
}
}
removed
}
// ── JSON 读写 ─────────────────────────────────────────────────────────────────
fn read_json(path: &PathBuf) -> Result<Value, String> {
@ -199,8 +240,9 @@ fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> {
// ── 公开接口 ──────────────────────────────────────────────────────────────────
/// 向项目的 .claude/settings.json 注入产品组 MCP 配置
pub fn inject_project_mcp(project_id: &str, group_id: &str) -> Result<(), String> {
/// 向项目的 .claude/settings.json 注入单一 lian-jing MCP 配置,
/// 并清理旧组级 key迁移内建于重注入
pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
let port = crate::mcp_server::read_port();
let path = settings_path(project_id)?;
let mut settings = read_json(&path)?;
@ -215,44 +257,91 @@ pub fn inject_project_mcp(project_id: &str, group_id: &str) -> Result<(), String
let servers = obj.entry("mcpServers").or_insert(json!({}));
let key = format!("{}{}", MCP_KEY_PREFIX, group_id);
servers[&key] = json!({
if let Some(map) = servers.as_object_mut() {
map.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX));
}
servers[MCP_KEY] = json!({
"type": "sse",
"url": format!("http://127.0.0.1:{}/mcp/group/{}", port, group_id)
"url": format!("http://127.0.0.1:{}/mcp", port)
});
write_json(&path, &settings).inspect(|_| {
let ctx = serde_json::json!({"project_id": project_id, "group_id": group_id}).to_string();
let ctx = serde_json::json!({"project_id": project_id}).to_string();
log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx));
})?;
// 写炼境上下文(含 group_id,让子项目 Claude 会话无需遍历即可知道 project_id + group_id
let _ = write_liangjing_context(project_id, group_id, port);
// 写炼境上下文,让子项目 Claude 会话无需遍历即可知道 project_id
let _ = write_liangjing_context(project_id, port);
// 同步注册到 ~/.claude.json让 Claude Code 能加载 MCP 工具best-effort
try_register_globally(group_id, port);
try_register_globally(port);
Ok(())
}
/// 从项目的 .claude/settings.json 移除产品组 MCP 配置
pub fn remove_project_mcp(project_id: &str, group_id: &str) -> Result<(), String> {
/// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key
/// 调用方需自行保证项目已不属于任何产品组时才调用。
pub fn remove_project_mcp(project_id: &str) -> Result<(), String> {
let path = settings_path(project_id)?;
if !path.exists() {
return Ok(());
}
let mut settings = read_json(&path)?;
let key = format!("{}{}", MCP_KEY_PREFIX, group_id);
if let Some(servers) = settings
.get_mut("mcpServers")
.and_then(|v| v.as_object_mut())
{
servers.remove(&key);
servers.remove(MCP_KEY);
servers.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX));
}
write_json(&path, &settings)
}
/// 单一服务器化一次性迁移:批量重注入所有托管项目(换新 key + 清旧 key + liangjing.json 瘦身)
/// 并清理旧组级全局注册。settings 表 mcp_single_migrated 标记位保证只执行一次;
/// 部分项目失败不阻塞(记日志,可对单个项目手动重注入补救)。
pub fn migrate_to_single_server() {
let already: Option<String> = db::pool().get().ok().and_then(|c| {
c.query_row(
"SELECT value FROM settings WHERE key = 'mcp_single_migrated'",
[],
|r| r.get(0),
)
.ok()
});
if already.as_deref() == Some("1") {
return;
}
let errors = reinject_all_mcp();
let removed = cleanup_legacy_global_registrations();
let summary = format!(
"单一服务器迁移完成:重注入失败 {} 个,移除旧全局注册 {} 条{}",
errors.len(),
removed.len(),
if errors.is_empty() {
String::new()
} else {
format!(";失败详情: {}", errors.join("; "))
}
);
let level = if errors.is_empty() { "info" } else { "warn" };
db::log_event("mcp_inject", level, &summary, None);
// claude CLI 不在时旧全局注册无法清理——不落标记位,等下次启动重试(整个流程幂等)
if find_claude_bin().is_none() {
return;
}
if let Ok(conn) = db::pool().get() {
let _ = conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('mcp_single_migrated', '1')",
[],
);
}
}
/// 端口变更后重新注入所有产品组成员的 MCP 配置
/// 返回注入失败的错误列表(部分失败不影响其他项目)
pub fn reinject_all_mcp() -> Vec<String> {
@ -261,19 +350,20 @@ pub fn reinject_all_mcp() -> Vec<String> {
Err(e) => return vec![e.to_string()],
};
let mappings: Vec<(String, String)> = {
let mut stmt = match conn.prepare("SELECT project_id, group_id FROM project_group_map") {
let project_ids: Vec<String> = {
let mut stmt = match conn.prepare("SELECT DISTINCT project_id FROM project_group_map") {
Ok(s) => s,
Err(e) => return vec![e.to_string()],
};
stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
stmt.query_map([], |row| row.get(0))
.map(|iter| iter.filter_map(|r| r.ok()).collect())
.unwrap_or_default()
};
drop(conn);
let mut errors = Vec::new();
for (project_id, group_id) in mappings {
if let Err(e) = inject_project_mcp(&project_id, &group_id) {
for project_id in project_ids {
if let Err(e) = inject_project_mcp(&project_id) {
errors.push(format!("项目 {}: {}", project_id, e));
}
}

View File

@ -1,5 +1,5 @@
use axum::{
extract::{Path, Query, State},
extract::{Query, State},
http::StatusCode,
response::{
sse::{Event, KeepAlive, Sse},
@ -42,10 +42,10 @@ pub async fn start(port: u16) {
let app = Router::new()
.route("/health", get(health))
// Streamable HTTPPOST— 向后兼容Python 直调仍可用
.route("/mcp/group/:group_id", post(handle_mcp).get(handle_sse_connect))
// 单一全局服务器路由lian-jing寻址不依赖 group_id工具显式传 project_id
.route("/mcp", post(handle_mcp).get(handle_sse_connect))
// SSE 消息端点:客户端通过此 URL 发送 JSON-RPC响应经 SSE 推回
.route("/mcp/group/:group_id/messages", post(handle_sse_message))
.route("/mcp/messages", post(handle_sse_message))
// Gitea webhook接收 push/pull_request 事件写入飞轮
.route("/webhook/gitea/:project_id", post(crate::mcp_webhook::handle_gitea_webhook))
.with_state(sessions);
@ -85,7 +85,7 @@ struct SessionQuery {
// ── 共享 RPC 处理逻辑 ─────────────────────────────────────────────────────────
async fn process_rpc(group_id: &str, req: &RpcRequest) -> Result<Value, String> {
async fn process_rpc(req: &RpcRequest) -> Result<Value, String> {
match req.method.as_str() {
"initialize" => Ok(json!({
"protocolVersion": "2024-11-05",
@ -93,19 +93,19 @@ async fn process_rpc(group_id: &str, req: &RpcRequest) -> Result<Value, String>
"serverInfo": { "name": "炼境", "version": env!("CARGO_PKG_VERSION") }
})),
"tools/list" => Ok(crate::mcp_tools::tools_list_result()),
"tools/call" => Ok(crate::mcp_tools::tools_call(group_id, req.params.as_ref()).await),
"tools/call" => Ok(crate::mcp_tools::tools_call(req.params.as_ref()).await),
_ => Err(format!("Method not found: {}", req.method)),
}
}
// ── Streamable HTTPPOST─────────────────────────────────────────────────────
async fn handle_mcp(Path(group_id): Path<String>, Json(req): Json<RpcRequest>) -> Response {
async fn handle_mcp(Json(req): Json<RpcRequest>) -> Response {
if req.id.is_none() {
return StatusCode::ACCEPTED.into_response();
}
let id = req.id.clone();
match process_rpc(&group_id, &req).await {
match process_rpc(&req).await {
Ok(result) => Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })).into_response(),
Err(msg) => Json(json!({
"jsonrpc": "2.0", "id": id,
@ -117,18 +117,17 @@ async fn handle_mcp(Path(group_id): Path<String>, Json(req): Json<RpcRequest>) -
// ── SSE 传输GET + POST /messages─────────────────────────────────────────
/// GET /mcp/group/:group_id — Claude Code SSE 握手,首条事件告知后续 POST URL
/// GET /mcp — Claude Code SSE 握手,首条事件告知后续 POST URL
async fn handle_sse_connect(
State(sessions): State<Sessions>,
Path(group_id): Path<String>,
) -> Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>> {
let session_id = uuid::Uuid::new_v4().to_string();
let (tx, rx) = mpsc::channel::<(String, String)>(32);
let port = read_port();
let endpoint_uri = format!(
"http://127.0.0.1:{}/mcp/group/{}/messages?session_id={}",
port, group_id, session_id
"http://127.0.0.1:{}/mcp/messages?session_id={}",
port, session_id
);
let _ = tx.send(("endpoint".to_string(), endpoint_uri)).await;
sessions.lock().await.insert(session_id, tx);
@ -139,11 +138,35 @@ async fn handle_sse_connect(
Sse::new(stream).keep_alive(KeepAlive::default())
}
/// POST /mcp/group/:group_id/messages?session_id=xxx
#[cfg(test)]
mod tests {
use super::*;
fn rpc(method: &str) -> RpcRequest {
RpcRequest {
jsonrpc: "2.0".to_string(),
method: method.to_string(),
id: Some(json!(1)),
params: None,
}
}
/// 单一 lian-jing 服务器的握手路径initialize / tools/list 必须可用
#[tokio::test]
async fn process_rpc_handshake() {
let init = process_rpc(&rpc("initialize")).await.unwrap();
assert_eq!(init["serverInfo"]["name"], "炼境");
let tools = process_rpc(&rpc("tools/list")).await.unwrap();
let list = tools["tools"].as_array().unwrap();
assert!(list.len() >= 20, "工具清单不应因去组化而缺失,实际 {}", list.len());
}
}
/// POST /mcp/messages?session_id=xxx
/// 立即返回 202后台异步处理工具调用并经 SSE channel 推回响应
async fn handle_sse_message(
State(sessions): State<Sessions>,
Path(group_id): Path<String>,
Query(query): Query<SessionQuery>,
Json(req): Json<RpcRequest>,
) -> Response {
@ -151,18 +174,17 @@ async fn handle_sse_message(
return StatusCode::ACCEPTED.into_response();
}
let sessions_clone = sessions.clone();
let session_id = query.session_id.clone();
tokio::spawn(async move {
let id = req.id.clone();
let response_json = match process_rpc(&group_id, &req).await {
let response_json = match process_rpc(&req).await {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err(msg) => json!({
"jsonrpc": "2.0", "id": id,
"error": { "code": -32601, "message": msg }
}),
};
let mut guard = sessions_clone.lock().await;
let mut guard = sessions.lock().await;
if let Some(tx) = guard.get(&session_id) {
if tx
.send(("message".to_string(), response_json.to_string()))

View File

@ -14,8 +14,14 @@ pub fn tools_list_result() -> Value {
},
{
"name": "list_group_projects",
"description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息",
"inputSchema": { "type": "object", "properties": {}, "required": [] }
"description": "列出产品组内所有成员项目包含名称、路径、平台、技术栈、角色等信息group_id 可从 list_all_projects 获取)",
"inputSchema": {
"type": "object",
"properties": {
"group_id": { "type": "string", "description": "产品组 ID" }
},
"required": ["group_id"]
}
},
{
"name": "get_project_blueprint",
@ -300,7 +306,7 @@ pub fn tools_list_result() -> Value {
// ── 工具分发 ──────────────────────────────────────────────────────────────────
pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
pub async fn tools_call(params: Option<&Value>) -> Value {
let tool_name = params
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
@ -310,18 +316,26 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
.and_then(|p| p.get("arguments"))
.cloned()
.unwrap_or(json!({}));
let gid = group_id.to_string();
let outcome = tokio::task::spawn_blocking(move || match tool_name.as_str() {
"get_diagnostics" => get_diagnostics(),
"list_group_projects" => list_group_projects(&gid),
"list_group_projects" => {
let group_id = args
.get("group_id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
match group_id {
Some(g) => list_group_projects(g),
None => Err("需显式传 group_id 参数(组 ID 可从 list_all_projects 获取)".to_string()),
}
}
"get_project_blueprint" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
get_project_blueprint(&gid, pid)
get_project_blueprint(pid)
}
"get_blueprint_status" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
resolve_project_root(&gid, pid).and_then(|root| {
resolve_project_root(pid).and_then(|root| {
crate::commands::blueprint::get_blueprint_status(root.to_string_lossy().to_string())
.and_then(|s| serde_json::to_string_pretty(&s).map_err(|e| e.to_string()))
})
@ -332,7 +346,7 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
if mode != "init" && mode != "sync" {
Err(format!("mode 只允许 init 或 sync收到: {mode}"))
} else {
resolve_project_root(&gid, pid).and_then(|root| {
resolve_project_root(pid).and_then(|root| {
crate::commands::blueprint::generate_blueprint_prompt(
root.to_string_lossy().to_string(),
mode.to_string(),
@ -342,7 +356,7 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
}
"sync_blueprint_rules" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
resolve_project_root(&gid, pid).and_then(|root| {
resolve_project_root(pid).and_then(|root| {
crate::commands::blueprint::sync_blueprint_rules(root.to_string_lossy().to_string())
.and_then(|r| serde_json::to_string_pretty(&r).map_err(|e| e.to_string()))
})
@ -350,21 +364,21 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
"get_project_file" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let rel = args.get("relative_path").and_then(|v| v.as_str()).unwrap_or("");
get_project_file(&gid, pid, rel)
get_project_file(pid, rel)
}
"get_project_mentor_context" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
get_project_mentor_context(&gid, pid)
get_project_mentor_context(pid)
}
"append_project_note" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
append_project_note(&gid, pid, content)
append_project_note(pid, content)
}
"get_project_snapshots" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let days = args.get("days").and_then(|v| v.as_u64()).unwrap_or(30) as u32;
get_project_snapshots(&gid, pid, days)
get_project_snapshots(pid, days)
}
"create_pull_request" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
@ -391,17 +405,8 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
}
"inject_mcp" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let conn = db::pool().get().map_err(|e| e.to_string())?;
let group_id: String = conn
.query_row(
"SELECT group_id FROM project_group_map WHERE project_id = ?1 LIMIT 1",
rusqlite::params![pid],
|r| r.get(0),
)
.map_err(|_| format!("项目 {} 未加入任何产品组", pid))?;
drop(conn);
crate::mcp_inject::inject_project_mcp(pid, &group_id)
.map(|_| format!("MCP 注入成功project_id={} group_id={}", pid, group_id))
crate::mcp_inject::inject_project_mcp(pid)
.map(|_| format!("MCP 注入成功project_id={}(单一 lian-jing 服务器,无需 group_id", pid))
}
"get_ci_status" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
@ -590,7 +595,7 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
}
"apply_onboarding_pack" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
resolve_project_root(&gid, pid).and_then(|root| {
resolve_project_root(pid).and_then(|root| {
let path = root.to_string_lossy().to_string();
crate::commands::onboarding::apply_onboarding_pack(path, pid.to_string()).map(
|report| {
@ -628,7 +633,7 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
}
"verify_onboarding" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let root = resolve_project_root(&gid, pid)?;
let root = resolve_project_root(pid)?;
let conn = db::pool().get().map_err(|e| e.to_string())?;
let checks = crate::commands::onboarding::verify_onboarding(&conn, pid, &root);
let passed = checks.iter().filter(|c| c.pass).count();
@ -817,35 +822,33 @@ fn list_group_projects(group_id: &str) -> Result<String, String> {
Ok(format!("产品组成员(共 {} 个项目):\n\n{}", rows.len(), rows.join("\n\n")))
}
fn get_project_blueprint(group_id: &str, project_id: &str) -> Result<String, String> {
let root = resolve_project_root(group_id, project_id)?;
fn get_project_blueprint(project_id: &str) -> Result<String, String> {
let root = resolve_project_root(project_id)?;
let path = root.join(".blueprint").join("manifest.yaml");
std::fs::read_to_string(&path)
.map_err(|e| format!("读取蓝图失败({}: {}", path.display(), e))
}
fn get_project_file(group_id: &str, project_id: &str, relative_path: &str) -> Result<String, String> {
fn get_project_file(project_id: &str, relative_path: &str) -> Result<String, String> {
if relative_path.contains("..") {
return Err("路径不允许包含 \"..\"".to_string());
}
let root = resolve_project_root(group_id, project_id)?;
let root = resolve_project_root(project_id)?;
let path = root.join(relative_path);
std::fs::read_to_string(&path)
.map_err(|e| format!("读取文件失败({}: {}", path.display(), e))
}
pub fn resolve_project_root(group_id: &str, project_id: &str) -> Result<std::path::PathBuf, String> {
/// 按 project_id 直查项目根目录(单一服务器寻址,不做组归属过滤)
pub fn resolve_project_root(project_id: &str) -> Result<std::path::PathBuf, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let row: rusqlite::Result<(Option<String>, Option<String>, Option<String>)> = conn.query_row(
"SELECT pw.win_path, pw.wsl_path, pw.platform
FROM project_group_map pgm
JOIN project_workspaces pw ON pw.id = pgm.project_id
WHERE pgm.group_id = ?1 AND pgm.project_id = ?2",
[group_id, project_id],
"SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1",
[project_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
);
let (win_path, wsl_path, platform) = row
.map_err(|_| format!("项目 {}属于产品组 {},或项目不存在", project_id, group_id))?;
.map_err(|_| format!("项目 {}存在", project_id))?;
if platform.as_deref() == Some("wsl") {
let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?;
@ -860,40 +863,36 @@ pub fn resolve_project_root(group_id: &str, project_id: &str) -> Result<std::pat
}
}
fn get_project_mentor_context(group_id: &str, project_id: &str) -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
/// 项目存在性检查(替代旧的组归属校验——单一服务器寻址后组不再是访问边界)
fn ensure_project_exists(conn: &rusqlite::Connection, project_id: &str) -> Result<(), String> {
let exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2",
[group_id, project_id],
"SELECT COUNT(*) FROM project_workspaces WHERE id = ?1",
[project_id],
|r| r.get::<_, i64>(0),
)
.map(|n| n > 0)
.unwrap_or(false);
if !exists {
return Err(format!("项目 {}属于产品组 {},或项目不存在", project_id, group_id));
return Err(format!("项目 {}存在", project_id));
}
Ok(())
}
fn get_project_mentor_context(project_id: &str) -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
ensure_project_exists(&conn, project_id)?;
let ctx = crate::commands::mentor::get_mentor_context(project_id.to_string())
.map_err(|e| format!("获取导师上下文失败: {e}"))?;
Ok(ctx.mentor_markdown)
}
fn append_project_note(group_id: &str, project_id: &str, content: &str) -> Result<String, String> {
fn append_project_note(project_id: &str, content: &str) -> Result<String, String> {
if content.trim().is_empty() {
return Err("笔记内容不能为空".to_string());
}
let conn = db::pool().get().map_err(|e| e.to_string())?;
let exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2",
[group_id, project_id],
|r| r.get::<_, i64>(0),
)
.map(|n| n > 0)
.unwrap_or(false);
if !exists {
return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id));
}
ensure_project_exists(&conn, project_id)?;
conn.execute(
"INSERT INTO project_notes (project_id, source, content) VALUES (?1, 'mcp', ?2)",
rusqlite::params![project_id, content],
@ -902,19 +901,9 @@ fn append_project_note(group_id: &str, project_id: &str, content: &str) -> Resul
Ok("笔记已保存 ✅".to_string())
}
fn get_project_snapshots(group_id: &str, project_id: &str, days: u32) -> Result<String, String> {
fn get_project_snapshots(project_id: &str, days: u32) -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2",
[group_id, project_id],
|r| r.get::<_, i64>(0),
)
.map(|n| n > 0)
.unwrap_or(false);
if !exists {
return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id));
}
ensure_project_exists(&conn, project_id)?;
let mut stmt = conn
.prepare(

View File

@ -532,9 +532,9 @@ export function SettingsPage() {
</button>
</div>
<p className="text-xs text-gray-400 mt-1.5">
MCP
MCP
<code className="font-mono bg-gray-50 px-1 rounded">
http://localhost:{mcpPort}/mcp/group/&lt;group_id&gt;
http://localhost:{mcpPort}/mcp
</code>
</p>
</div>