feat(mcp/parity): 三端经稳定 helper 入口连项目专用地址(P1 + P3) #24
@ -32,7 +32,7 @@ src-tauri/src/credential_store.rs Windows 本机受保护凭证存储与旧值
|
|||||||
src-tauri/src/mcp_server.rs MCP Server 路由层 + SSE 协议(agent 调用炼境的入口)
|
src-tauri/src/mcp_server.rs MCP Server 路由层 + SSE 协议(agent 调用炼境的入口)
|
||||||
src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具清单以 tools_list_result 为准)
|
src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具清单以 tools_list_result 为准)
|
||||||
src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮)
|
src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮)
|
||||||
src-tauri/src/mcp_inject.rs 接入注入(写入 canonical `.lian-jing/context.json`、`.codex/config.toml` 与 Claude 兼容配置)
|
src-tauri/src/mcp_inject.rs 接入注入与三端适配(canonical context、Codex 项目配置、Claude local 条目、DSH 托管块、%LOCALAPPDATA% 稳定 helper 入口)
|
||||||
src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow)
|
src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow)
|
||||||
.blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡)
|
.blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡)
|
||||||
docs/ai-context/ AI 上下文文档(速查卡、契约映射)
|
docs/ai-context/ AI 上下文文档(速查卡、契约映射)
|
||||||
|
|||||||
@ -6,7 +6,7 @@ contracts:
|
|||||||
- label: API 接口(Axum)
|
- label: API 接口(Axum)
|
||||||
paths:
|
paths:
|
||||||
- "src-tauri/src/mcp_server.rs"
|
- "src-tauri/src/mcp_server.rs"
|
||||||
doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/<project_id> scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。"
|
doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/<project_id> scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %LOCALAPPDATA%/lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe):Codex 写项目 .codex/config.toml,Claude 写 ~/.claude.json 的 local 条目(优先于 user 级全局),DSH 写 ~/.dsh/cordis.patch.yml 托管块(!!js 按启动目录选项目地址,项目外回退全局)。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。"
|
||||||
|
|
||||||
- label: MCP 工具接口
|
- label: MCP 工具接口
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
@ -670,6 +670,23 @@ fn codex_mcp_server_url(content: &str) -> Option<&str> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 旧注入把本机 exe 绝对路径写进 helper(换构建即失效、换机器即错);新注入只引用
|
||||||
|
/// %LOCALAPPDATA% 下的稳定入口。只判定显式的旧写法,未配置 helper 不在此处判定。
|
||||||
|
fn codex_helper_uses_machine_path(content: &str) -> bool {
|
||||||
|
let mut in_lian_jing = false;
|
||||||
|
for line in content.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with('[') {
|
||||||
|
in_lian_jing = trimmed == "[mcp_servers.lian-jing]";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if in_lian_jing && trimmed.starts_with("http_headers_helper") {
|
||||||
|
return trimmed.contains("--lianjing-mcp-header") && !trimmed.contains("%LOCALAPPDATA%");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn check_codex_mcp(
|
pub(crate) fn check_codex_mcp(
|
||||||
content: Option<&str>,
|
content: Option<&str>,
|
||||||
expected_project_id: &str,
|
expected_project_id: &str,
|
||||||
@ -684,7 +701,13 @@ pub(crate) fn check_codex_mcp(
|
|||||||
};
|
};
|
||||||
let expected_url = crate::mcp_server::scoped_mcp_url(expected_port, expected_project_id);
|
let expected_url = crate::mcp_server::scoped_mcp_url(expected_port, expected_project_id);
|
||||||
let configured_url = codex_mcp_server_url(content);
|
let configured_url = codex_mcp_server_url(content);
|
||||||
if configured_url == Some(expected_url.as_str()) {
|
if configured_url == Some(expected_url.as_str()) && codex_helper_uses_machine_path(content) {
|
||||||
|
VerifyCheck::with_state(
|
||||||
|
"Codex MCP 已配置",
|
||||||
|
VerifyState::Legacy,
|
||||||
|
"helper 仍写着本机 exe 绝对路径(换构建即失效,且会随仓库提交)——调用 inject_mcp 升级为 %LOCALAPPDATA% 稳定入口",
|
||||||
|
)
|
||||||
|
} else if configured_url == Some(expected_url.as_str()) {
|
||||||
VerifyCheck::with_state(
|
VerifyCheck::with_state(
|
||||||
"Codex MCP 已配置",
|
"Codex MCP 已配置",
|
||||||
VerifyState::Ready,
|
VerifyState::Ready,
|
||||||
@ -1501,6 +1524,22 @@ mod tests {
|
|||||||
assert_eq!(wrong_port.state, VerifyState::Missing);
|
assert_eq!(wrong_port.state, VerifyState::Missing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_mcp_check_flags_machine_path_helper_for_upgrade() {
|
||||||
|
let url = "url = \"http://127.0.0.1:27190/mcp/projects/p1\"\n";
|
||||||
|
let machine = format!(
|
||||||
|
"[mcp_servers.lian-jing]\n{url}http_headers_helper = \"\\\"E:\\\\x\\\\target\\\\debug\\\\app.exe\\\" --lianjing-mcp-header d1\"\n"
|
||||||
|
);
|
||||||
|
let legacy = check_codex_mcp(Some(&machine), "p1", 27190);
|
||||||
|
assert_eq!(legacy.state, VerifyState::Legacy);
|
||||||
|
assert!(legacy.detail.contains("%LOCALAPPDATA%"));
|
||||||
|
|
||||||
|
let stable = format!(
|
||||||
|
"[mcp_servers.lian-jing]\n{url}http_headers_helper = \"\\\"%LOCALAPPDATA%\\\\lian-jing\\\\bin\\\\lianjing-mcp-header.cmd\\\" d1\"\n"
|
||||||
|
);
|
||||||
|
assert_eq!(check_codex_mcp(Some(&stable), "p1", 27190).state, VerifyState::Ready);
|
||||||
|
}
|
||||||
|
|
||||||
/// 回归:空路径登记曾把接入包写进炼境进程 cwd(src-tauri/,2026-07-15 批量实调实锤)。
|
/// 回归:空路径登记曾把接入包写进炼境进程 cwd(src-tauri/,2026-07-15 批量实调实锤)。
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_rejects_empty_or_missing_path() {
|
fn apply_rejects_empty_or_missing_path() {
|
||||||
|
|||||||
@ -116,6 +116,15 @@ 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));
|
||||||
|
|
||||||
|
// ── 三端共用的稳定 helper 入口与 DSH 托管块(best-effort,失败只记日志)──
|
||||||
|
// 每次启动把 shim 指向当前 exe,换构建目录后各客户端无需逐项目重注入。
|
||||||
|
if let Err(error) = mcp_inject::ensure_header_shim() {
|
||||||
|
db::log_event("mcp_inject", "warn", &format!("MCP helper 入口写入失败: {error}"), None);
|
||||||
|
}
|
||||||
|
if let Err(error) = mcp_inject::ensure_dsh_patch(mcp_port) {
|
||||||
|
db::log_event("mcp_inject", "warn", &format!("DSH 托管块写入失败: {error}"), None);
|
||||||
|
}
|
||||||
|
|
||||||
// ── MCP 单一服务器化一次性迁移(settings 标记位防重复)────
|
// ── MCP 单一服务器化一次性迁移(settings 标记位防重复)────
|
||||||
std::thread::spawn(|| {
|
std::thread::spawn(|| {
|
||||||
std::thread::sleep(std::time::Duration::from_secs(10)); // 等 DB 就绪
|
std::thread::sleep(std::time::Duration::from_secs(10)); // 等 DB 就绪
|
||||||
|
|||||||
@ -9,6 +9,52 @@ const LEGACY_KEY_PREFIX: &str = "lian-jing:group-";
|
|||||||
const CODEX_MCP_MANAGED_START: &str = "# LIANJING_MCP_MANAGED_START";
|
const CODEX_MCP_MANAGED_START: &str = "# LIANJING_MCP_MANAGED_START";
|
||||||
const CODEX_MCP_MANAGED_END: &str = "# LIANJING_MCP_MANAGED_END";
|
const CODEX_MCP_MANAGED_END: &str = "# LIANJING_MCP_MANAGED_END";
|
||||||
const CONTEXT_SCHEMA_VERSION: u8 = 1;
|
const CONTEXT_SCHEMA_VERSION: u8 = 1;
|
||||||
|
/// 稳定 helper 入口文件名:位于 %LOCALAPPDATA%\lian-jing\bin\,每次启动指向当前 exe。
|
||||||
|
const MCP_HEADER_SHIM_NAME: &str = "lianjing-mcp-header.cmd";
|
||||||
|
const DSH_MANAGED_START: &str = "# LIANJING_DSH_MANAGED_START";
|
||||||
|
const DSH_MANAGED_END: &str = "# LIANJING_DSH_MANAGED_END";
|
||||||
|
|
||||||
|
// ── 稳定 helper 入口(三端共用)────────────────────────────────────────────────
|
||||||
|
// Codex 与 Claude 在 Windows 都经 cmd.exe 执行 helper 并展开 %LOCALAPPDATA%;DSH 由 !!js 经 cmd 调用。
|
||||||
|
// 项目配置只引用 %LOCALAPPDATA% 下的固定入口,不含本机 exe 路径与用户名,换构建无需逐项目重注入。
|
||||||
|
|
||||||
|
fn header_shim_path() -> Result<PathBuf, String> {
|
||||||
|
let base = std::env::var("LOCALAPPDATA")
|
||||||
|
.map_err(|_| "LOCALAPPDATA 未设置,无法写入 MCP helper 入口".to_string())?;
|
||||||
|
Ok(PathBuf::from(base)
|
||||||
|
.join("lian-jing")
|
||||||
|
.join("bin")
|
||||||
|
.join(MCP_HEADER_SHIM_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// cmd 按 OEM 代码页读批处理;exe 路径含非 ASCII 字符时先切到 UTF-8。
|
||||||
|
fn render_header_shim(executable: &std::path::Path) -> String {
|
||||||
|
let exe = executable.display().to_string();
|
||||||
|
let codepage = if exe.is_ascii() { "" } else { "chcp 65001 >nul\r\n" };
|
||||||
|
format!("@echo off\r\n{codepage}\"{exe}\" --lianjing-mcp-header %1\r\nexit /b %ERRORLEVEL%\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入或更新稳定 helper 入口;内容未变不重写。炼境启动与每次注入时调用。
|
||||||
|
pub fn ensure_header_shim() -> Result<PathBuf, String> {
|
||||||
|
let path = header_shim_path()?;
|
||||||
|
let executable = std::env::current_exe()
|
||||||
|
.map_err(|_| "无法定位炼境可执行文件,拒绝写入 MCP helper 入口".to_string())?;
|
||||||
|
let content = render_header_shim(&executable);
|
||||||
|
if std::fs::read_to_string(&path).ok().as_deref() != Some(content.as_str()) {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|error| format!("创建 MCP helper 入口目录失败: {error}"))?;
|
||||||
|
}
|
||||||
|
std::fs::write(&path, content)
|
||||||
|
.map_err(|error| format!("写入 MCP helper 入口失败: {error}"))?;
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 客户端配置中的 helper 命令:只含 %LOCALAPPDATA% 与非秘密 digest,可安全提交进项目仓库。
|
||||||
|
fn shim_helper_command(digest: &str) -> String {
|
||||||
|
format!("\"%LOCALAPPDATA%\\lian-jing\\bin\\{MCP_HEADER_SHIM_NAME}\" {digest}")
|
||||||
|
}
|
||||||
|
|
||||||
// ── 路径解析 ──────────────────────────────────────────────────────────────────
|
// ── 路径解析 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -293,6 +339,188 @@ fn try_register_globally(port: u16) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Claude Code local scope 条目:项目专用地址 + 稳定 helper。local 优先于 user 级全局注册且不进仓库。
|
||||||
|
fn claude_local_server_json(server_url: &str, header_helper: &str) -> String {
|
||||||
|
json!({
|
||||||
|
"type": "http",
|
||||||
|
"url": server_url,
|
||||||
|
"headersHelper": header_helper,
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在项目目录以 local scope 注册 lian-jing(先删后加,幂等)。best-effort:失败只记日志,
|
||||||
|
/// 不影响 Codex 与项目文件注入;返回是否成功。
|
||||||
|
fn try_register_claude_local(root: &std::path::Path, server_url: &str, header_helper: &str) -> bool {
|
||||||
|
let (Some(claude_bin), Some(git_bash)) = (find_claude_bin(), find_git_bash()) else {
|
||||||
|
db::log_event(
|
||||||
|
"mcp_inject",
|
||||||
|
"warn",
|
||||||
|
"claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 注册",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let _ = std::process::Command::new(&claude_bin)
|
||||||
|
.current_dir(root)
|
||||||
|
.args(["mcp", "remove", MCP_KEY, "--scope", "local"])
|
||||||
|
.env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash)
|
||||||
|
.output();
|
||||||
|
let payload = claude_local_server_json(server_url, header_helper);
|
||||||
|
match std::process::Command::new(&claude_bin)
|
||||||
|
.current_dir(root)
|
||||||
|
.args(["mcp", "add-json", MCP_KEY, &payload, "--scope", "local"])
|
||||||
|
.env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash)
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
Ok(out) if out.status.success() => {
|
||||||
|
db::log_event("mcp_inject", "info", "Claude 项目级 MCP 注册成功", Some(MCP_KEY));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Ok(out) => {
|
||||||
|
let combined = format!(
|
||||||
|
"{}{}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
db::log_event(
|
||||||
|
"mcp_inject",
|
||||||
|
"warn",
|
||||||
|
&format!("claude mcp add-json 非零退出: {combined}"),
|
||||||
|
Some(MCP_KEY),
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
db::log_event(
|
||||||
|
"mcp_inject",
|
||||||
|
"warn",
|
||||||
|
&format!("执行 claude mcp add-json 失败: {error}"),
|
||||||
|
Some(MCP_KEY),
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DSH 机器级配置托管块 ──────────────────────────────────────────────────────
|
||||||
|
// DSH 配置无法按项目拆分,但 !!js 在加载时以启动目录求值:在项目目录(或子目录)启动时连项目专用地址
|
||||||
|
// 并经稳定 helper 携带认证;项目外回退全局地址,保持 GUI 等非项目启动方式的现有行为。
|
||||||
|
|
||||||
|
fn render_dsh_block(port: u16) -> String {
|
||||||
|
// 逐行显式缩进:YAML 依赖缩进,不能用字符串续行(会吞掉下一行行首空白)。
|
||||||
|
fn indent(spaces: usize, lines: &[&str]) -> Vec<String> {
|
||||||
|
lines.iter().map(|line| format!("{}{line}", " ".repeat(spaces))).collect()
|
||||||
|
}
|
||||||
|
let find_project_id = [
|
||||||
|
"const fs = process.getBuiltinModule('fs'), path = process.getBuiltinModule('path');",
|
||||||
|
"let id;",
|
||||||
|
"for (let d = process.cwd(), p; d !== p; p = d, d = path.dirname(d)) {",
|
||||||
|
" const f = path.join(d, '.lian-jing', 'context.json');",
|
||||||
|
" if (fs.existsSync(f)) { try { id = JSON.parse(fs.readFileSync(f, 'utf8')).project_id; } catch {} break; }",
|
||||||
|
"}",
|
||||||
|
];
|
||||||
|
let global_url = format!("http://127.0.0.1:{port}/mcp");
|
||||||
|
let url_return = format!(
|
||||||
|
"return id ? '{global_url}/projects/' + encodeURIComponent(id) : '{global_url}';"
|
||||||
|
);
|
||||||
|
let shim_line = format!(
|
||||||
|
"const shim = path.join(process.env.LOCALAPPDATA || '', 'lian-jing', 'bin', '{MCP_HEADER_SHIM_NAME}');"
|
||||||
|
);
|
||||||
|
let header_body = [
|
||||||
|
"if (!id) return {};",
|
||||||
|
"const crypto = process.getBuiltinModule('crypto'), cp = process.getBuiltinModule('child_process');",
|
||||||
|
"const digest = crypto.createHash('sha256').update(id).digest('hex');",
|
||||||
|
shim_line.as_str(),
|
||||||
|
"// Node 拒绝直接执行 .cmd(EINVAL),须经 cmd /d /s /c 原样传参。",
|
||||||
|
"const out = cp.execFileSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', '\"\"' + shim + '\" ' + digest + '\"'],",
|
||||||
|
" { encoding: 'utf8', timeout: 15000, windowsHide: true, windowsVerbatimArguments: true, stdio: ['ignore', 'pipe', 'ignore'] });",
|
||||||
|
"return { Authorization: String(JSON.parse(out).Authorization) };",
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut lines: Vec<String> = vec![
|
||||||
|
DSH_MANAGED_START.to_string(),
|
||||||
|
"# 炼境托管:按 DSH 启动目录自动连接项目专用地址并携带认证;项目外回退全局地址。勿手改,会被炼境覆盖。".into(),
|
||||||
|
"- insert:".into(),
|
||||||
|
" - id: mcp-lian-jing".into(),
|
||||||
|
" name: '@deepseek-ai/dsh-mcp-client'".into(),
|
||||||
|
" config:".into(),
|
||||||
|
" transport: streamable-http".into(),
|
||||||
|
format!(" serverName: {MCP_KEY}"),
|
||||||
|
" url: !!js |".into(),
|
||||||
|
" (() => {".into(),
|
||||||
|
];
|
||||||
|
lines.extend(indent(12, &find_project_id));
|
||||||
|
lines.extend(indent(12, &[url_return.as_str()]));
|
||||||
|
lines.extend(indent(10, &["})()"]));
|
||||||
|
lines.extend(indent(8, &["headers: !!js |"]));
|
||||||
|
lines.extend(indent(10, &["(() => {"]));
|
||||||
|
lines.extend(indent(12, &["try {"]));
|
||||||
|
lines.extend(indent(14, &find_project_id));
|
||||||
|
lines.extend(indent(14, &header_body));
|
||||||
|
lines.extend(indent(12, &["} catch (e) {"]));
|
||||||
|
lines.extend(indent(
|
||||||
|
14,
|
||||||
|
&[
|
||||||
|
"process.stderr.write('[lian-jing] mcp header helper failed: status=' + (e.status ?? '') + ' code=' + (e.code ?? '') + '\\n');",
|
||||||
|
"return {};",
|
||||||
|
],
|
||||||
|
));
|
||||||
|
lines.extend(indent(12, &["}"]));
|
||||||
|
lines.extend(indent(10, &["})()"]));
|
||||||
|
lines.push(DSH_MANAGED_END.to_string());
|
||||||
|
let mut block = lines.join("\n");
|
||||||
|
block.push('\n');
|
||||||
|
block
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 纯函数:把托管块合并进 DSH 补丁文件内容。已有托管块则原位替换;
|
||||||
|
/// 块外存在手写的 lian-jing 条目则拒绝覆盖(与 Codex 手工同名 server 的处理一致)。
|
||||||
|
fn upsert_dsh_block(current: &str, block: &str) -> Result<String, String> {
|
||||||
|
match (current.find(DSH_MANAGED_START), current.find(DSH_MANAGED_END)) {
|
||||||
|
(Some(start), Some(end)) if end >= start => Ok(format!(
|
||||||
|
"{}{}{}",
|
||||||
|
¤t[..start],
|
||||||
|
block.trim_end_matches('\n'),
|
||||||
|
¤t[end + DSH_MANAGED_END.len()..]
|
||||||
|
)),
|
||||||
|
(Some(_), _) | (_, Some(_)) => Err("DSH 补丁文件中的炼境托管块不完整或顺序错误".into()),
|
||||||
|
(None, None)
|
||||||
|
if current.lines().any(|line| {
|
||||||
|
let t = line.trim();
|
||||||
|
t == "id: mcp-lian-jing" || t == format!("serverName: {MCP_KEY}")
|
||||||
|
}) =>
|
||||||
|
{
|
||||||
|
Err("DSH 补丁文件已手工定义 lian-jing,炼境不会覆盖;移除手写条目后重试".into())
|
||||||
|
}
|
||||||
|
(None, None) if current.trim().is_empty() => Ok(block.to_string()),
|
||||||
|
(None, None) => Ok(format!("{}\n{}", current.trim_end(), block)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 未安装 DSH(无 ~/.dsh)时跳过;返回是否写入或已是最新。
|
||||||
|
pub fn ensure_dsh_patch(port: u16) -> Result<bool, String> {
|
||||||
|
let home = std::env::var("DSH_HOME").map(PathBuf::from).or_else(|_| {
|
||||||
|
std::env::var("USERPROFILE")
|
||||||
|
.map(|home| PathBuf::from(home).join(".dsh"))
|
||||||
|
.map_err(|_| "USERPROFILE 未设置".to_string())
|
||||||
|
})?;
|
||||||
|
if !home.is_dir() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let path = home.join("cordis.patch.yml");
|
||||||
|
let current = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||||
|
Err(error) => return Err(format!("读取 DSH 补丁文件失败: {error}")),
|
||||||
|
};
|
||||||
|
let next = upsert_dsh_block(¤t, &render_dsh_block(port))?;
|
||||||
|
if next != current {
|
||||||
|
std::fs::write(&path, next).map_err(|error| format!("写入 DSH 补丁文件失败: {error}"))?;
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
/// 清理旧组级全局注册(lian-jing-{gid},含完整 UUID 与短 8 位两种历史命名)。
|
/// 清理旧组级全局注册(lian-jing-{gid},含完整 UUID 与短 8 位两种历史命名)。
|
||||||
/// 幂等 best-effort:不存在的名字 remove 失败仅忽略。返回成功移除的名字列表。
|
/// 幂等 best-effort:不存在的名字 remove 失败仅忽略。返回成功移除的名字列表。
|
||||||
pub fn cleanup_legacy_global_registrations() -> Vec<String> {
|
pub fn cleanup_legacy_global_registrations() -> Vec<String> {
|
||||||
@ -471,12 +699,8 @@ pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
|
|||||||
&crate::credential_store::WindowsCredentialStore,
|
&crate::credential_store::WindowsCredentialStore,
|
||||||
project_id,
|
project_id,
|
||||||
)?;
|
)?;
|
||||||
let executable = std::env::current_exe()
|
ensure_header_shim()?;
|
||||||
.map_err(|_| "无法定位炼境可执行文件,拒绝写入 MCP 认证配置".to_string())?;
|
let header_helper = shim_helper_command(&digest);
|
||||||
let header_helper = format!(
|
|
||||||
"\"{}\" --lianjing-mcp-header {digest}",
|
|
||||||
executable.display()
|
|
||||||
);
|
|
||||||
write_canonical_context(project_id)?;
|
write_canonical_context(project_id)?;
|
||||||
upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url, &header_helper)?;
|
upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url, &header_helper)?;
|
||||||
let path = settings_path(project_id)?;
|
let path = settings_path(project_id)?;
|
||||||
@ -508,8 +732,10 @@ pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
|
|||||||
// 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。
|
// 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。
|
||||||
write_liangjing_context(project_id, port)?;
|
write_liangjing_context(project_id, port)?;
|
||||||
|
|
||||||
// 同步注册到 ~/.claude.json,让 Claude Code 能加载 MCP 工具(best-effort)
|
// 同步注册到 ~/.claude.json:user 级全局注册作兜底,local 级项目条目优先生效(均 best-effort)。
|
||||||
|
// 项目 .claude/settings.json 的 mcpServers 不被 Claude Code 读取,仅保留作兼容。
|
||||||
try_register_globally(port);
|
try_register_globally(port);
|
||||||
|
try_register_claude_local(&project_root(project_id)?, &server_url, &header_helper);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -611,6 +837,10 @@ pub fn reinject_all_mcp() -> Vec<String> {
|
|||||||
errors.push(format!("项目 {}: {}", project_id, e));
|
errors.push(format!("项目 {}: {}", project_id, e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// DSH 托管块内嵌端口,端口变更后一并刷新
|
||||||
|
if let Err(e) = ensure_dsh_patch(crate::mcp_server::read_port()) {
|
||||||
|
errors.push(format!("DSH: {e}"));
|
||||||
|
}
|
||||||
errors
|
errors
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -719,6 +949,74 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&base);
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shim_helper_command_has_no_machine_path() {
|
||||||
|
let helper = shim_helper_command("qa-digest");
|
||||||
|
assert_eq!(
|
||||||
|
helper,
|
||||||
|
"\"%LOCALAPPDATA%\\lian-jing\\bin\\lianjing-mcp-header.cmd\" qa-digest"
|
||||||
|
);
|
||||||
|
assert!(!helper.contains(":\\"), "不得含盘符绝对路径");
|
||||||
|
assert!(!helper.to_lowercase().contains("users"), "不得含用户目录");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_shim_forwards_digest_and_exit_code() {
|
||||||
|
let ascii = render_header_shim(std::path::Path::new("C:\\Apps\\Lian Jing\\app.exe"));
|
||||||
|
assert_eq!(
|
||||||
|
ascii,
|
||||||
|
"@echo off\r\n\"C:\\Apps\\Lian Jing\\app.exe\" --lianjing-mcp-header %1\r\nexit /b %ERRORLEVEL%\r\n"
|
||||||
|
);
|
||||||
|
let unicode = render_header_shim(std::path::Path::new("C:\\炼境\\app.exe"));
|
||||||
|
assert!(unicode.contains("chcp 65001 >nul\r\n"), "非 ASCII 路径须切 UTF-8 代码页");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claude_local_entry_uses_scoped_url_and_shim() {
|
||||||
|
let entry: Value = serde_json::from_str(&claude_local_server_json(
|
||||||
|
"http://127.0.0.1:27190/mcp/projects/p1",
|
||||||
|
&shim_helper_command("d1"),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(entry["type"], "http");
|
||||||
|
assert_eq!(entry["url"], "http://127.0.0.1:27190/mcp/projects/p1");
|
||||||
|
assert!(entry["headersHelper"].as_str().unwrap().contains("%LOCALAPPDATA%"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dsh_block_is_valid_yaml_with_cwd_routing_and_shim_auth() {
|
||||||
|
let block = render_dsh_block(27190);
|
||||||
|
let parsed: serde_yaml::Value = serde_yaml::from_str(&block).expect("托管块必须是合法 YAML");
|
||||||
|
let entry = &parsed[0]["insert"][0];
|
||||||
|
assert_eq!(entry["id"], "mcp-lian-jing");
|
||||||
|
assert_eq!(entry["config"]["serverName"], MCP_KEY);
|
||||||
|
assert_eq!(entry["config"]["transport"], "streamable-http");
|
||||||
|
assert!(block.contains("url: !!js |") && block.contains("headers: !!js |"));
|
||||||
|
assert!(block.contains("'http://127.0.0.1:27190/mcp/projects/' + encodeURIComponent(id)"));
|
||||||
|
assert!(block.contains(": 'http://127.0.0.1:27190/mcp';"), "项目外回退全局地址");
|
||||||
|
assert!(block.contains(MCP_HEADER_SHIM_NAME) && block.contains("windowsVerbatimArguments: true"));
|
||||||
|
assert!(!block.contains("Bearer "), "托管块不得含任何 token");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dsh_block_upsert_preserves_user_entries_and_refuses_manual_lian_jing() {
|
||||||
|
let user = "- insert:\n - id: mcp-yljt\n config:\n serverName: yljt\n";
|
||||||
|
let first = upsert_dsh_block(user, &render_dsh_block(27190)).unwrap();
|
||||||
|
assert!(first.starts_with(user.trim_end()), "用户条目原样保留");
|
||||||
|
let parsed: serde_yaml::Value = serde_yaml::from_str(&first).unwrap();
|
||||||
|
assert_eq!(parsed.as_sequence().unwrap().len(), 2);
|
||||||
|
|
||||||
|
let moved = upsert_dsh_block(&first, &render_dsh_block(28000)).unwrap();
|
||||||
|
assert!(moved.contains("127.0.0.1:28000") && !moved.contains("127.0.0.1:27190"));
|
||||||
|
assert_eq!(moved.matches(DSH_MANAGED_START).count(), 1, "原位替换不重复追加");
|
||||||
|
assert_eq!(upsert_dsh_block(&moved, &render_dsh_block(28000)).unwrap(), moved, "幂等");
|
||||||
|
|
||||||
|
let manual = "- insert:\n - id: mcp-lian-jing\n config:\n serverName: lian-jing\n";
|
||||||
|
assert!(upsert_dsh_block(manual, &render_dsh_block(27190)).is_err());
|
||||||
|
assert!(upsert_dsh_block(&format!("{DSH_MANAGED_START}\n- x\n"), "b").is_err());
|
||||||
|
assert_eq!(upsert_dsh_block("", "BLOCK\n").unwrap(), "BLOCK\n");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registered_path_accepts_absolute_and_unc() {
|
fn registered_path_accepts_absolute_and_unc() {
|
||||||
assert!(validate_registered_path("E:\\code-project\\demo", "win_path").is_ok());
|
assert!(validate_registered_path("E:\\code-project\\demo", "win_path").is_ok());
|
||||||
|
|||||||
@ -318,11 +318,43 @@ async fn handle_mcp(headers: HeaderMap, Json(req): Json<RpcRequest>) -> Response
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 项目专用路由只服务已登记项目。未知 project_id 一律拒绝:否则会给 agent 一套看似可用、
|
||||||
|
/// 实则每次调用都越权失败的工具集(2026-09-30 实测 /mcp/projects/<任意字符串> 返回完整 32 个工具)。
|
||||||
|
/// 数据库不可用时同样拒绝(fail closed)。
|
||||||
|
fn scoped_project_registered(project_id: &str) -> bool {
|
||||||
|
crate::db::pool()
|
||||||
|
.get()
|
||||||
|
.ok()
|
||||||
|
.and_then(|conn| {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM project_workspaces WHERE id = ?1)",
|
||||||
|
[project_id],
|
||||||
|
|row| row.get::<_, bool>(0),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unknown_project_response(project_id: &str, id: Option<Value>) -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(json!({
|
||||||
|
"jsonrpc": "2.0", "id": id,
|
||||||
|
"error": { "code": -32001, "message": format!("项目未登记: {project_id}(检查 .lian-jing/context.json 或重跑 inject_mcp)") }
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_scoped_mcp(
|
async fn handle_scoped_mcp(
|
||||||
Path(project_id): Path<String>,
|
Path(project_id): Path<String>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(req): Json<RpcRequest>,
|
Json(req): Json<RpcRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
|
if !scoped_project_registered(&project_id) {
|
||||||
|
return unknown_project_response(&project_id, req.id.clone());
|
||||||
|
}
|
||||||
if authorize_sensitive_request(
|
if authorize_sensitive_request(
|
||||||
&req,
|
&req,
|
||||||
Some(&project_id),
|
Some(&project_id),
|
||||||
@ -377,7 +409,10 @@ async fn handle_sse_connect(
|
|||||||
async fn handle_scoped_sse_connect(
|
async fn handle_scoped_sse_connect(
|
||||||
Path(project_id): Path<String>,
|
Path(project_id): Path<String>,
|
||||||
State(sessions): State<Sessions>,
|
State(sessions): State<Sessions>,
|
||||||
) -> Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>> {
|
) -> Response {
|
||||||
|
if !scoped_project_registered(&project_id) {
|
||||||
|
return unknown_project_response(&project_id, None);
|
||||||
|
}
|
||||||
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 endpoint_uri = scoped_sse_messages_url(read_port(), &project_id, &session_id);
|
let endpoint_uri = scoped_sse_messages_url(read_port(), &project_id, &session_id);
|
||||||
@ -392,7 +427,9 @@ async fn handle_scoped_sse_connect(
|
|||||||
let stream = ReceiverStream::new(rx).map(|(evt_name, data)| {
|
let stream = ReceiverStream::new(rx).map(|(evt_name, data)| {
|
||||||
Ok::<Event, Infallible>(Event::default().event(evt_name).data(data))
|
Ok::<Event, Infallible>(Event::default().event(evt_name).data(data))
|
||||||
});
|
});
|
||||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
Sse::new(stream)
|
||||||
|
.keep_alive(KeepAlive::default())
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@ -1455,6 +1492,40 @@ mod tests {
|
|||||||
assert!(has_initialized_project_client(project_id));
|
assert!(has_initialized_project_client(project_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scoped_route_rejects_unregistered_project() {
|
||||||
|
db::init_test_pool();
|
||||||
|
let known = format!("scoped-known-{}", uuid::Uuid::new_v4());
|
||||||
|
{
|
||||||
|
let conn = db::pool().get().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_profiles (id, name) VALUES (?1, 'Scoped Known')",
|
||||||
|
[format!("{known}-profile")],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_workspaces (id, profile_id) VALUES (?1, ?2)",
|
||||||
|
rusqlite::params![known, format!("{known}-profile")],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let unknown = handle_scoped_mcp(
|
||||||
|
Path("__not_registered__".to_string()),
|
||||||
|
HeaderMap::new(),
|
||||||
|
Json(rpc("tools/list")),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(unknown.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
let registered =
|
||||||
|
handle_scoped_mcp(Path(known.clone()), HeaderMap::new(), Json(rpc("tools/list"))).await;
|
||||||
|
assert_eq!(registered.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let sessions: Sessions = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
let sse = handle_scoped_sse_connect(Path("__not_registered__".to_string()), State(sessions)).await;
|
||||||
|
assert_eq!(sse.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn loopback_urls_share_one_ipv4_base() {
|
fn loopback_urls_share_one_ipv4_base() {
|
||||||
let port = 27190;
|
let port = 27190;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user