From a41161f7d1b014fbc8bc54d320e070c5da2382b6 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Mon, 6 Jul 2026 19:30:01 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(mcp):=20=E5=8D=95=E4=B8=80=E5=85=A8?= =?UTF-8?q?=E5=B1=80=20lian-jing=20=E6=9C=8D=E5=8A=A1=E5=99=A8=E2=80=94?= =?UTF-8?q?=E2=80=94=E7=BB=84=E7=BA=A7=E6=8C=82=E8=BD=BD=E6=94=B6=E6=95=9B?= =?UTF-8?q?=EF=BC=88T1-T3+T5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 缺失/过期/移组同步一整类问题从根上消失。 --- .blueprint/modules/mcp-config-inject.md | 6 +- .blueprint/modules/mcp-single-server.md | 16 +- .claude/skills/retro-catchup/SKILL.md | 20 +-- docs/ai-context/git-workflow.md | 6 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 3 + .../resources/onboarding/git-workflow.md.tmpl | 6 +- src-tauri/src/commands/groups.rs | 34 +++- src-tauri/src/commands/projects.rs | 15 +- src-tauri/src/lib.rs | 6 + src-tauri/src/mcp_inject.rs | 170 +++++++++++++----- src-tauri/src/mcp_server.rs | 103 +++++++++-- src-tauri/src/mcp_tools.rs | 126 +++++++------ src/components/settings/SettingsPage.tsx | 4 +- 14 files changed, 349 insertions(+), 168 deletions(-) diff --git a/.blueprint/modules/mcp-config-inject.md b/.blueprint/modules/mcp-config-inject.md index f3f108d..1dab0b5 100644 --- a/.blueprint/modules/mcp-config-inject.md +++ b/.blueprint/modules/mcp-config-inject.md @@ -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 提示用户 diff --git a/.blueprint/modules/mcp-single-server.md b/.blueprint/modules/mcp-single-server.md index 715be06..cb62aba 100644 --- a/.blueprint/modules/mcp-single-server.md +++ b/.blueprint/modules/mcp-single-server.md @@ -61,21 +61,21 @@ ## 任务卡 -### 📋 T1. 服务器层双路由:全局 /mcp + 组上下文 Option 化 -- status: todo +### 🔵 T1. 服务器层双路由:全局 /mcp + 组上下文 Option 化 +- status: in_progress - 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(双路由并存)" -### 📋 T2. 工具层去组化:resolve_project_root 按 project_id 直查 -- status: todo +### 🔵 T2. 工具层去组化:resolve_project_root 按 project_id 直查 +- status: in_progress - 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 全绿" -### 📋 T3. 注入层收敛:lian-jing 单 key + 旧配置清理 + liangjing.json 瘦身 -- status: todo +### 🔵 T3. 注入层收敛:lian-jing 单 key + 旧配置清理 + liangjing.json 瘦身 +- status: in_progress - complexity: M - files: src-tauri/src/mcp_inject.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/commands/groups.rs - depends: T1 @@ -88,8 +88,8 @@ - depends: T1, T2, T3(需先发版/重启炼境运行新二进制) - acceptance: "[CLI] claude mcp list → lian-jing-* 旧条目清零、仅剩 lian-jing;[MCP] 抽查 3 个项目(含本项目 + enterprise-system)settings.json/liangjing.json 符合新格式;新 Claude 会话经 lian-jing 实调 get_diagnostics → 返回 JSON 无 error" -### 📋 T5. 文档/模板/技能/UI 寻址说明同步 -- status: todo +### 🔵 T5. 文档/模板/技能/UI 寻址说明同步 +- status: in_progress - 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 diff --git a/.claude/skills/retro-catchup/SKILL.md b/.claude/skills/retro-catchup/SKILL.md index 871c5f9..ce3c974 100644 --- a/.claude/skills/retro-catchup/SKILL.md +++ b/.claude/skills/retro-catchup/SKILL.md @@ -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 直调)`。 diff --git a/docs/ai-context/git-workflow.md b/docs/ai-context/git-workflow.md index a0d1d71..18e56a9 100644 --- a/docs/ai-context/git-workflow.md +++ b/docs/ai-context/git-workflow.md @@ -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:** diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cc2834c..a41e98e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "dev-manager-tauri" -version = "0.1.18" +version = "0.1.19" dependencies = [ "axum", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9c98071..4c406b6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -47,3 +47,6 @@ serde_yaml = "0.9" hmac = "0.12" sha2 = "0.10" +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt"] } + diff --git a/src-tauri/resources/onboarding/git-workflow.md.tmpl b/src-tauri/resources/onboarding/git-workflow.md.tmpl index a0d1d71..18e56a9 100644 --- a/src-tauri/resources/onboarding/git-workflow.md.tmpl +++ b/src-tauri/resources/onboarding/git-workflow.md.tmpl @@ -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:** diff --git a/src-tauri/src/commands/groups.rs b/src-tauri/src/commands/groups.rs index cba7a0e..6caaa73 100644 --- a/src-tauri/src/commands/groups.rs +++ b/src-tauri/src/commands/groups.rs @@ -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 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 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", diff --git a/src-tauri/src/commands/projects.rs b/src-tauri/src/commands/projects.rs index 7298d6d..6e3738c 100644 --- a/src-tauri/src/commands/projects.rs +++ b/src-tauri/src/commands/projects.rs @@ -277,18 +277,9 @@ pub fn delete_project(id: String, delete_local: Option) -> Result<(), Stri ) .unwrap_or((None, None, None)); - // 清理产品组 MCP 配置(在删除 project_group_map 记录之前) - let group_ids: Vec = 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 表,需手动清理) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ad6150b..4391025 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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(|| { diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 72e61c3..25319de 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -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 { 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 { None } -/// 将产品组 MCP 注册到 Claude 全局配置(~/.claude.json),best-effort,失败仅记录日志 -fn try_register_globally(group_id: &str, port: u16) { - let server_name = format!("lian-jing-{}", group_id); - let server_url = format!("http://127.0.0.1:{}/mcp/group/{}", port, group_id); +fn claude_git_bash() -> String { + std::env::var("CLAUDE_CODE_GIT_BASH_PATH") + .unwrap_or_else(|_| r"C:\Program Files\Git\bin\bash.exe".to_string()) +} + +/// 将单一全局 lian-jing 服务器注册到 Claude 全局配置(~/.claude.json),best-effort,失败仅记录日志 +fn try_register_globally(port: u16) { + let server_url = format!("http://127.0.0.1:{}/mcp", port); let Some(claude_bin) = find_claude_bin() else { db::log_event( "mcp_inject", "warn", - "claude CLI 未找到,跳过全局 Mcp 注册", + "claude CLI 未找到,跳过全局 MCP 注册", None, ); return; }; - let git_bash = std::env::var("CLAUDE_CODE_GIT_BASH_PATH").unwrap_or_else(|_| { - r"C:\Program Files\Git\bin\bash.exe".to_string() - }); + let git_bash = claude_git_bash(); match std::process::Command::new(&claude_bin) .args([ @@ -136,38 +141,74 @@ fn try_register_globally(group_id: &str, port: u16) { "sse", "-s", "user", - &server_name, + MCP_KEY, &server_url, ]) .env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash) .output() { Ok(out) if out.status.success() => { - db::log_event( - "mcp_inject", - "info", - "全局 MCP 注册成功", - Some(&server_name), - ); + db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(MCP_KEY)); } Ok(out) => { let msg = format!( "claude mcp add 非零退出: {}", String::from_utf8_lossy(&out.stderr) ); - db::log_event("mcp_inject", "warn", &msg, Some(&server_name)); + db::log_event("mcp_inject", "warn", &msg, Some(MCP_KEY)); } Err(e) => { db::log_event( "mcp_inject", "warn", &format!("执行 claude mcp add 失败: {}", e), - Some(&server_name), + Some(MCP_KEY), ); } } } +/// 清理旧组级全局注册(lian-jing-{gid},含完整 UUID 与短 8 位两种历史命名)。 +/// 幂等 best-effort:不存在的名字 remove 失败仅忽略。返回成功移除的名字列表。 +pub fn cleanup_legacy_global_registrations() -> Vec { + 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 = 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 { @@ -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 = 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 { @@ -261,19 +350,20 @@ pub fn reinject_all_mcp() -> Vec { 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 = { + 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)); } } diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index d3666eb..1c3940f 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -42,7 +42,10 @@ pub async fn start(port: u16) { let app = Router::new() .route("/health", get(health)) - // Streamable HTTP(POST)— 向后兼容,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)) // SSE 消息端点:客户端通过此 URL 发送 JSON-RPC,响应经 SSE 推回 .route("/mcp/group/:group_id/messages", post(handle_sse_message)) @@ -85,7 +88,8 @@ struct SessionQuery { // ── 共享 RPC 处理逻辑 ───────────────────────────────────────────────────────── -async fn process_rpc(group_id: &str, req: &RpcRequest) -> Result { +/// group_id 为 None 表示经全局 /mcp 路由进入(单一服务器寻址,工具按 project_id 定位) +async fn process_rpc(group_id: Option<&str>, req: &RpcRequest) -> Result { match req.method.as_str() { "initialize" => Ok(json!({ "protocolVersion": "2024-11-05", @@ -101,11 +105,19 @@ async fn process_rpc(group_id: &str, req: &RpcRequest) -> Result // ── Streamable HTTP(POST)───────────────────────────────────────────────────── async fn handle_mcp(Path(group_id): Path, Json(req): Json) -> Response { + mcp_response(Some(group_id), req).await +} + +async fn handle_mcp_global(Json(req): Json) -> Response { + mcp_response(None, req).await +} + +async fn mcp_response(group_id: Option, req: 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(group_id.as_deref(), &req).await { Ok(result) => Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })).into_response(), Err(msg) => Json(json!({ "jsonrpc": "2.0", "id": id, @@ -121,15 +133,35 @@ async fn handle_mcp(Path(group_id): Path, Json(req): Json) - async fn handle_sse_connect( State(sessions): State, Path(group_id): Path, +) -> Sse>> { + sse_connect(sessions, Some(group_id)).await +} + +/// GET /mcp — 全局路由 SSE 握手 +async fn handle_sse_connect_global( + State(sessions): State, +) -> Sse>> { + sse_connect(sessions, None).await +} + +async fn sse_connect( + sessions: Sessions, + group_id: Option, ) -> Sse>> { 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 - ); + let endpoint_uri = match &group_id { + Some(gid) => format!( + "http://127.0.0.1:{}/mcp/group/{}/messages?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; sessions.lock().await.insert(session_id, tx); @@ -139,30 +171,79 @@ async fn handle_sse_connect( 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 -/// 立即返回 202,后台异步处理工具调用并经 SSE channel 推回响应 async fn handle_sse_message( State(sessions): State, Path(group_id): Path, Query(query): Query, Json(req): Json, +) -> 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, + Query(query): Query, + Json(req): Json, +) -> Response { + sse_message(sessions, None, query, req).await +} + +/// 立即返回 202,后台异步处理工具调用并经 SSE channel 推回响应 +async fn sse_message( + sessions: Sessions, + group_id: Option, + query: SessionQuery, + req: RpcRequest, ) -> Response { if req.id.is_none() { 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(group_id.as_deref(), &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())) diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 9055b60..2ede2bb 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -14,8 +14,14 @@ pub fn tools_list_result() -> Value { }, { "name": "list_group_projects", - "description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息", - "inputSchema": { "type": "object", "properties": {}, "required": [] } + "description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息。经全局 lian-jing 服务器调用时必须传 group_id(可从 list_all_projects 获取)", + "inputSchema": { + "type": "object", + "properties": { + "group_id": { "type": "string", "description": "产品组 ID;经组级路由调用时可省略(取挂载组)" } + }, + "required": [] + } }, { "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 .and_then(|p| p.get("name")) .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")) .cloned() .unwrap_or(json!({})); - let gid = group_id.to_string(); + let gid: Option = group_ctx.filter(|g| !g.is_empty()).map(str::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 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" => { 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 +351,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 +361,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 +369,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 +410,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 +600,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 +638,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 +827,33 @@ fn list_group_projects(group_id: &str) -> Result { Ok(format!("产品组成员(共 {} 个项目):\n\n{}", rows.len(), rows.join("\n\n"))) } -fn get_project_blueprint(group_id: &str, project_id: &str) -> Result { - let root = resolve_project_root(group_id, project_id)?; +fn get_project_blueprint(project_id: &str) -> Result { + 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 { +fn get_project_file(project_id: &str, relative_path: &str) -> Result { if relative_path.contains("..") { return Err("路径不允许包含 \"..\"".to_string()); } - let root = resolve_project_root(group_id, project_id)?; + let 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 { +/// 按 project_id 直查项目根目录(单一服务器寻址,不做组归属过滤) +pub fn resolve_project_root(project_id: &str) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; let row: rusqlite::Result<(Option, Option, Option)> = conn.query_row( - "SELECT pw.win_path, pw.wsl_path, pw.platform - FROM project_group_map pgm - JOIN project_workspaces pw ON pw.id = pgm.project_id - WHERE pgm.group_id = ?1 AND pgm.project_id = ?2", - [group_id, project_id], + "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 +868,36 @@ pub fn resolve_project_root(group_id: &str, project_id: &str) -> Result Result { - 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 { + 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 { +fn append_project_note(project_id: &str, content: &str) -> Result { if content.trim().is_empty() { return Err("笔记内容不能为空".to_string()); } let conn = db::pool().get().map_err(|e| e.to_string())?; - 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 +906,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 { +fn get_project_snapshots(project_id: &str, days: u32) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; - let exists: bool = conn - .query_row( - "SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2", - [group_id, project_id], - |r| r.get::<_, i64>(0), - ) - .map(|n| n > 0) - .unwrap_or(false); - if !exists { - return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id)); - } + ensure_project_exists(&conn, project_id)?; let mut stmt = conn .prepare( diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index a90f51d..2ed44ba 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -532,9 +532,9 @@ export function SettingsPage() {

- 产品组 MCP 地址: + MCP 地址(单一全局服务器): - http://localhost:{mcpPort}/mcp/group/<group_id> + http://localhost:{mcpPort}/mcp

From df7d52a084055601ff79ecf7d87510f7d17bfecf Mon Sep 17 00:00:00 2001 From: lanrtop Date: Mon, 6 Jul 2026 19:34:51 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(mcp):=20T6=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=97=A7=20/mcp/group=20=E8=B7=AF=E7=94=B1=E4=B8=8E=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E5=B1=82=E2=80=94=E2=80=94=E6=8F=90=E5=89=8D=E5=B9=B6?= =?UTF-8?q?=E5=85=A5=E6=9C=AC=E6=AC=A1=E5=8F=91=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原计划后置一个版本,经评估提前:迁移在新版启动时自动重写全部 客户端配置(settings.json/liangjing.json/全局注册),旧路由的实际 消费者只剩升级前的存量会话,而服务重启后它们本就断开重连。 单人环境下双路由过渡的保留价值趋近于零,一次收口减少一轮发版。 - tools_call 签名去 Option 组上下文,process_rpc 单参数化 - list_group_projects 的 group_id 改为 required 参数 - grep 'mcp/group' src-tauri/src/ 零命中,cargo test 93 全绿 --- .blueprint/manifest.yaml | 4 +- .blueprint/modules/mcp-single-server.md | 45 ++++++----- src-tauri/src/mcp_server.rs | 99 +++++-------------------- src-tauri/src/mcp_tools.rs | 23 +++--- 4 files changed, 56 insertions(+), 115 deletions(-) diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 912cb63..ddc4976 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -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: diff --git a/.blueprint/modules/mcp-single-server.md b/.blueprint/modules/mcp-single-server.md index cb62aba..a68a87c 100644 --- a/.blueprint/modules/mcp-single-server.md +++ b/.blueprint/modules/mcp-single-server.md @@ -61,43 +61,48 @@ ## 任务卡 -### 🔵 T1. 服务器层双路由:全局 /mcp + 组上下文 Option 化 -- status: in_progress +### ✅ 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: in_progress +### ✅ T2. 工具层去组化:resolve_project_root 按 project_id 直查 +- status: done - complexity: M - files: src-tauri/src/mcp_tools.rs - depends: T1 -- acceptance: "[HTTP] 经 /mcp 调 get_project_blueprint(project_id) → 返回 manifest 内容,无'不属于产品组'错误;list_group_projects(group_id=xxx) 显式传参 → 返回成员列表,无参数且无挂载上下文 → 报错提示传参;cargo test 全绿" +- acceptance: "[HTTP] 经 /mcp 调 get_project_blueprint(project_id) → 返回 manifest 内容,无'不属于产品组'错误;list_group_projects(group_id=xxx) 显式传参 → 返回成员列表;cargo test 全绿" +- done_note: 2026-07-06 完成。resolve_project_root 直查 project_workspaces;3 处组归属校验替换为 ensure_project_exists;list_group_projects schema 改 required group_id。HTTP 实测归入 T4 -### 🔵 T3. 注入层收敛:lian-jing 单 key + 旧配置清理 + liangjing.json 瘦身 -- status: in_progress +### ✅ T3. 注入层收敛:lian-jing 单 key + 旧配置清理 + liangjing.json 瘦身 +- status: done - complexity: M -- files: src-tauri/src/mcp_inject.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/commands/groups.rs +- files: src-tauri/src/mcp_inject.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/commands/groups.rs, src-tauri/src/commands/projects.rs, src-tauri/src/lib.rs - depends: T1 -- acceptance: "[Tauri command] inject_project_mcp(pid) → 项目 settings.json 含 key \"lian-jing\"(URL 以 /mcp 结尾)且无 lian-jing:group-* 残留;liangjing.json 含 project_id+mcp_port 且 group_id 键被删除;try_register_globally 注册单个 lian-jing 并对旧名(完整 UUID + 短 8 位两种格式)执行 claude mcp remove;remove_group_member 仅在项目不属于任何组时移除 key;cargo test 全绿" +- acceptance: "[Tauri command] inject_project_mcp(pid) → 项目 settings.json 含 key \"lian-jing\"(URL 以 /mcp 结尾)且无 lian-jing:group-* 残留;liangjing.json 含 project_id+mcp_port 且 group_id 键被删除;remove_group_member 仅在项目不属于任何组时移除 key;cargo test 全绿" +- done_note: 2026-07-06 完成。额外实现 migrate_to_single_server 启动时一次性迁移(标记位 mcp_single_migrated,claude CLI 缺失时不落标记等重试);delete_group/remove_group_member 加"仍属其他组则保留"判断;文件实测归入 T4 ### 📋 T4. 存量批量迁移执行 + 端到端验证 - status: todo - complexity: S -- files: (执行操作,无新代码——复用 reinject_all_mcp) -- depends: T1, T2, T3(需先发版/重启炼境运行新二进制) -- acceptance: "[CLI] claude mcp list → lian-jing-* 旧条目清零、仅剩 lian-jing;[MCP] 抽查 3 个项目(含本项目 + enterprise-system)settings.json/liangjing.json 符合新格式;新 Claude 会话经 lian-jing 实调 get_diagnostics → 返回 JSON 无 error" +- files: (执行操作,无新代码——迁移在新版启动时自动执行) +- depends: T1, T2, T3, T6(需先构建 + 重启炼境运行新二进制) +- acceptance: "[CLI] claude mcp list → lian-jing-* 旧条目清零、仅剩 lian-jing;[MCP] 抽查 3 个项目(含本项目 + enterprise-system)settings.json/liangjing.json 符合新格式;新 Claude 会话经 lian-jing 实调 get_diagnostics → 返回 JSON 无 error;[HTTP] POST /mcp/group/{gid} → 404" -### 🔵 T5. 文档/模板/技能/UI 寻址说明同步 -- status: in_progress +### ✅ T5. 文档/模板/技能/UI 寻址说明同步 +- status: done - complexity: S - files: docs/ai-context/git-workflow.md, src-tauri/resources/onboarding/git-workflow.md.tmpl, .claude/skills/retro-catchup/SKILL.md, src/components/settings/SettingsPage.tsx, .blueprint/modules/mcp-config-inject.md - depends: T3 -- acceptance: "[Bash] grep -rE 'lian-jing-\\{group_id\\}|lian-jing:group-' docs/ src/ src-tauri/resources/ → 零命中(mcp-config-inject.md 保留历史注记除外);pnpm typecheck 通过;另提醒用户同步仓库外 ~/.claude/CLAUDE.md 炼境规则段" +- acceptance: "[Bash] grep -rE 'lian-jing-\\{group_id\\}|lian-jing:group-' docs/ src/ src-tauri/resources/ → 零命中(mcp-config-inject.md 保留历史注记除外);pnpm typecheck 通过" +- done_note: 2026-07-06 完成。grep 零命中、typecheck 通过;用户全局 ~/.claude/CLAUDE.md 炼境规则段已同步改为单一 lian-jing 寻址 -### 📋 T6. 后置:删除旧 /mcp/group 路由与兼容层(下个版本) -- status: todo +### ✅ T6. 删除旧 /mcp/group 路由与兼容层 +- status: done - complexity: S - files: src-tauri/src/mcp_server.rs, src-tauri/src/mcp_tools.rs -- depends: T4(迁移验证通过后至少隔一个发版周期) -- acceptance: "[HTTP] POST /mcp/group/{gid} → 404;cargo test 全绿;grep 'mcp/group' src-tauri/src/ 零命中" +- depends: T3 +- acceptance: "cargo test 全绿;grep 'mcp/group' src-tauri/src/ 零命中;[HTTP] POST /mcp/group/{gid} → 404(归入 T4 实测)" +- done_note: 2026-07-06 完成。原计划后置一个版本,经评估提前并入本次发版:迁移在新版启动时自动重写全部客户端配置,旧路由的实际消费者只剩升级前的存量会话(服务重启后本就断开),单人环境保留价值趋近于零。tools_call 签名去 Option 化,list_group_projects 改 required 参数 diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 1c3940f..76c0200 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Path, Query, State}, + extract::{Query, State}, http::StatusCode, response::{ sse::{Event, KeepAlive, Sse}, @@ -42,13 +42,10 @@ pub async fn start(port: u16) { let app = Router::new() .route("/health", get(health)) - // 全局单一服务器路由(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)) + // 单一全局服务器路由(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); @@ -88,8 +85,7 @@ struct SessionQuery { // ── 共享 RPC 处理逻辑 ───────────────────────────────────────────────────────── -/// group_id 为 None 表示经全局 /mcp 路由进入(单一服务器寻址,工具按 project_id 定位) -async fn process_rpc(group_id: Option<&str>, req: &RpcRequest) -> Result { +async fn process_rpc(req: &RpcRequest) -> Result { match req.method.as_str() { "initialize" => Ok(json!({ "protocolVersion": "2024-11-05", @@ -97,27 +93,19 @@ async fn process_rpc(group_id: Option<&str>, req: &RpcRequest) -> Result Ok(crate::mcp_tools::tools_list_result()), - "tools/call" => Ok(crate::mcp_tools::tools_call(group_id, req.params.as_ref()).await), + "tools/call" => Ok(crate::mcp_tools::tools_call(req.params.as_ref()).await), _ => Err(format!("Method not found: {}", req.method)), } } // ── Streamable HTTP(POST)───────────────────────────────────────────────────── -async fn handle_mcp(Path(group_id): Path, Json(req): Json) -> Response { - mcp_response(Some(group_id), req).await -} - -async fn handle_mcp_global(Json(req): Json) -> Response { - mcp_response(None, req).await -} - -async fn mcp_response(group_id: Option, req: RpcRequest) -> Response { +async fn handle_mcp(Json(req): Json) -> Response { if req.id.is_none() { return StatusCode::ACCEPTED.into_response(); } let id = req.id.clone(); - match process_rpc(group_id.as_deref(), &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, @@ -129,39 +117,18 @@ async fn mcp_response(group_id: Option, req: RpcRequest) -> Response { // ── 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, - Path(group_id): Path, -) -> Sse>> { - sse_connect(sessions, Some(group_id)).await -} - -/// GET /mcp — 全局路由 SSE 握手 -async fn handle_sse_connect_global( - State(sessions): State, -) -> Sse>> { - sse_connect(sessions, None).await -} - -async fn sse_connect( - sessions: Sessions, - group_id: Option, ) -> Sse>> { let session_id = uuid::Uuid::new_v4().to_string(); let (tx, rx) = mpsc::channel::<(String, String)>(32); let port = read_port(); - let endpoint_uri = match &group_id { - Some(gid) => format!( - "http://127.0.0.1:{}/mcp/group/{}/messages?session_id={}", - port, gid, session_id - ), - None => format!( - "http://127.0.0.1:{}/mcp/messages?session_id={}", - port, session_id - ), - }; + let endpoint_uri = format!( + "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); @@ -184,50 +151,24 @@ mod tests { } } - /// 全局路由(group_id=None)下 initialize / tools/list 必须可用——单一 lian-jing 服务器的握手路径 + /// 单一 lian-jing 服务器的握手路径:initialize / tools/list 必须可用 #[tokio::test] - async fn process_rpc_works_without_group_context() { - let init = process_rpc(None, &rpc("initialize")).await.unwrap(); + async fn process_rpc_handshake() { + let init = process_rpc(&rpc("initialize")).await.unwrap(); assert_eq!(init["serverInfo"]["name"], "炼境"); - let tools = process_rpc(None, &rpc("tools/list")).await.unwrap(); + let tools = process_rpc(&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/messages?session_id=xxx +/// 立即返回 202,后台异步处理工具调用并经 SSE channel 推回响应 async fn handle_sse_message( State(sessions): State, - Path(group_id): Path, Query(query): Query, Json(req): Json, -) -> 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, - Query(query): Query, - Json(req): Json, -) -> Response { - sse_message(sessions, None, query, req).await -} - -/// 立即返回 202,后台异步处理工具调用并经 SSE channel 推回响应 -async fn sse_message( - sessions: Sessions, - group_id: Option, - query: SessionQuery, - req: RpcRequest, ) -> Response { if req.id.is_none() { return StatusCode::ACCEPTED.into_response(); @@ -236,7 +177,7 @@ async fn sse_message( let session_id = query.session_id.clone(); tokio::spawn(async move { let id = req.id.clone(); - let response_json = match process_rpc(group_id.as_deref(), &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, diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 2ede2bb..5e9a4b7 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -14,13 +14,13 @@ pub fn tools_list_result() -> Value { }, { "name": "list_group_projects", - "description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息。经全局 lian-jing 服务器调用时必须传 group_id(可从 list_all_projects 获取)", + "description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息(group_id 可从 list_all_projects 获取)", "inputSchema": { "type": "object", "properties": { - "group_id": { "type": "string", "description": "产品组 ID;经组级路由调用时可省略(取挂载组)" } + "group_id": { "type": "string", "description": "产品组 ID" } }, - "required": [] + "required": ["group_id"] } }, { @@ -306,8 +306,7 @@ pub fn tools_list_result() -> Value { // ── 工具分发 ────────────────────────────────────────────────────────────────── -/// group_ctx 为 None 表示经全局 /mcp 路由调用(单一服务器);Some(gid) 为过渡期组级路由 -pub async fn tools_call(group_ctx: Option<&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()) @@ -317,21 +316,17 @@ pub async fn tools_call(group_ctx: Option<&str>, params: Option<&Value>) -> Valu .and_then(|p| p.get("arguments")) .cloned() .unwrap_or(json!({})); - let gid: Option = group_ctx.filter(|g| !g.is_empty()).map(str::to_string); let outcome = tokio::task::spawn_blocking(move || match tool_name.as_str() { "get_diagnostics" => get_diagnostics(), "list_group_projects" => { - // 显式参数优先;无参数时回退到挂载组上下文(过渡期组级路由) - let effective = args + let group_id = 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()), + .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" => {