fix(mcp/parity): 对抗审查 9 项修复——控制面保留全局、DSH 改登记白名单
- 控制面项目(context.json mcp_scope=global,炼境自身已声明)不写 Claude local 条目、 不入 DSH 白名单,避免遮蔽全局注册与跨项目工具(审查 high) - DSH 托管块内嵌炼境登记的「项目根目录→project_id」白名单,不再以任意目录里的 context.json 作为取 token 依据;LOCALAPPDATA/SystemRoot 须为绝对路径 - remove_project_mcp 同步清理 Claude local 条目并剔除 DSH 白名单;SSE 消息路由复核登记 - Claude local 只替换炼境托管条目,手写不覆盖,替换失败回滚;改走 silent_cmd 并移至后台线程 - 手写 DSH 条目改用 serde_yaml 检测(引号/注释/流式写法),无法解析时保守拒绝 - shim 中 exe 路径的 % 转义为 %%;注入日志 context 统一为 JSON,诊断查询防御非 JSON 行 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9a1635a225
commit
0c40e1a5c9
@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"project_id": "dev-manager-tauri",
|
"project_id": "dev-manager-tauri",
|
||||||
"schema_version": 1
|
"schema_version": 1,
|
||||||
|
"mcp_scope": "global"
|
||||||
}
|
}
|
||||||
@ -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 拒绝跨项目参数和全局管理写工具。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 按钮体系文档。"
|
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 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,!!js 从启动目录向上精确匹配根目录才选项目地址并取认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。"
|
||||||
|
|
||||||
- label: MCP 工具接口
|
- label: MCP 工具接口
|
||||||
paths:
|
paths:
|
||||||
@ -25,7 +25,7 @@ contracts:
|
|||||||
- ".lian-jing/context.json"
|
- ".lian-jing/context.json"
|
||||||
- ".lian-jing/capability-packs.json"
|
- ".lian-jing/capability-packs.json"
|
||||||
- ".claude/liangjing.json"
|
- ".claude/liangjing.json"
|
||||||
doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。"
|
doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。context.json 可选字段 mcp_scope=global 声明控制面项目(如炼境自身):Claude 不写 local 条目、DSH 不入白名单,保留全局地址与跨项目工具。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。"
|
||||||
|
|
||||||
- label: Tauri command 接口(前后端契约)
|
- label: Tauri command 接口(前后端契约)
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
@ -119,11 +119,14 @@ pub fn run() {
|
|||||||
// ── 三端共用的稳定 helper 入口与 DSH 托管块(best-effort,失败只记日志)──
|
// ── 三端共用的稳定 helper 入口与 DSH 托管块(best-effort,失败只记日志)──
|
||||||
// 每次启动把 shim 指向当前 exe,换构建目录后各客户端无需逐项目重注入。
|
// 每次启动把 shim 指向当前 exe,换构建目录后各客户端无需逐项目重注入。
|
||||||
if let Err(error) = mcp_inject::ensure_header_shim() {
|
if let Err(error) = mcp_inject::ensure_header_shim() {
|
||||||
db::log_event("mcp_inject", "warn", &format!("MCP helper 入口写入失败: {error}"), None);
|
db::log_event(
|
||||||
}
|
"mcp_inject",
|
||||||
if let Err(error) = mcp_inject::ensure_dsh_patch(mcp_port) {
|
"warn",
|
||||||
db::log_event("mcp_inject", "warn", &format!("DSH 托管块写入失败: {error}"), None);
|
&format!("MCP helper 入口写入失败: {error}"),
|
||||||
|
Some(r#"{"client":"shim"}"#),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
mcp_inject::refresh_dsh_patch(None);
|
||||||
|
|
||||||
// ── MCP 单一服务器化一次性迁移(settings 标记位防重复)────
|
// ── MCP 单一服务器化一次性迁移(settings 标记位防重复)────
|
||||||
std::thread::spawn(|| {
|
std::thread::spawn(|| {
|
||||||
|
|||||||
@ -28,8 +28,9 @@ fn header_shim_path() -> Result<PathBuf, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// cmd 按 OEM 代码页读批处理;exe 路径含非 ASCII 字符时先切到 UTF-8。
|
/// cmd 按 OEM 代码页读批处理;exe 路径含非 ASCII 字符时先切到 UTF-8。
|
||||||
|
/// 路径里的 % 必须写成 %%,否则会和 %1 配对被当作变量展开吞掉。
|
||||||
fn render_header_shim(executable: &std::path::Path) -> String {
|
fn render_header_shim(executable: &std::path::Path) -> String {
|
||||||
let exe = executable.display().to_string();
|
let exe = executable.display().to_string().replace('%', "%%");
|
||||||
let codepage = if exe.is_ascii() { "" } else { "chcp 65001 >nul\r\n" };
|
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")
|
format!("@echo off\r\n{codepage}\"{exe}\" --lianjing-mcp-header %1\r\nexit /b %ERRORLEVEL%\r\n")
|
||||||
}
|
}
|
||||||
@ -306,7 +307,7 @@ fn try_register_globally(port: u16) -> bool {
|
|||||||
.output()
|
.output()
|
||||||
{
|
{
|
||||||
Ok(out) if out.status.success() => {
|
Ok(out) if out.status.success() => {
|
||||||
db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(MCP_KEY));
|
db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(&global_log_context()));
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Ok(out) => {
|
Ok(out) => {
|
||||||
@ -323,7 +324,7 @@ fn try_register_globally(port: u16) -> bool {
|
|||||||
"mcp_inject",
|
"mcp_inject",
|
||||||
"warn",
|
"warn",
|
||||||
&format!("claude mcp add 非零退出: {}", combined),
|
&format!("claude mcp add 非零退出: {}", combined),
|
||||||
Some(MCP_KEY),
|
Some(&global_log_context()),
|
||||||
);
|
);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@ -332,13 +333,34 @@ fn try_register_globally(port: u16) -> bool {
|
|||||||
"mcp_inject",
|
"mcp_inject",
|
||||||
"warn",
|
"warn",
|
||||||
&format!("执行 claude mcp add 失败: {}", e),
|
&format!("执行 claude mcp add 失败: {}", e),
|
||||||
Some(MCP_KEY),
|
Some(&global_log_context()),
|
||||||
);
|
);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 项目在 .lian-jing/context.json 声明 "mcp_scope": "global" 即为控制面项目(如炼境自身):
|
||||||
|
/// Claude 与 DSH 保留全局地址与跨项目工具,不写项目级覆盖(否则 local 条目会遮蔽全局注册)。
|
||||||
|
fn project_wants_global_scope(root: &std::path::Path) -> bool {
|
||||||
|
read_context_object(
|
||||||
|
&root.join(".lian-jing").join("context.json"),
|
||||||
|
"canonical 项目上下文",
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
.and_then(|ctx| ctx.get("mcp_scope").and_then(Value::as_str).map(|scope| scope == "global"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注入日志的 context 必须是 JSON:get_diagnostics 按 json_extract(context,'$.project_id') 分组。
|
||||||
|
fn inject_log_context(project_id: &str, client: &str) -> String {
|
||||||
|
json!({ "project_id": project_id, "client": client }).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn global_log_context() -> String {
|
||||||
|
json!({ "client": "claude-global", "server": MCP_KEY }).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Claude Code local scope 条目:项目专用地址 + 稳定 helper。local 优先于 user 级全局注册且不进仓库。
|
/// Claude Code local scope 条目:项目专用地址 + 稳定 helper。local 优先于 user 级全局注册且不进仓库。
|
||||||
fn claude_local_server_json(server_url: &str, header_helper: &str) -> String {
|
fn claude_local_server_json(server_url: &str, header_helper: &str) -> String {
|
||||||
json!({
|
json!({
|
||||||
@ -349,75 +371,201 @@ fn claude_local_server_json(server_url: &str, header_helper: &str) -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 在项目目录以 local scope 注册 lian-jing(先删后加,幂等)。best-effort:失败只记日志,
|
#[derive(Debug, PartialEq)]
|
||||||
/// 不影响 Codex 与项目文件注入;返回是否成功。
|
enum ClaudeLocalEntry {
|
||||||
fn try_register_claude_local(root: &std::path::Path, server_url: &str, header_helper: &str) -> bool {
|
Absent,
|
||||||
|
/// 炼境写入的条目(项目路由 + 稳定 helper),可安全替换或删除;附原值供失败回滚。
|
||||||
|
Managed(Value),
|
||||||
|
/// 用户手写的同名条目:炼境不覆盖、不删除。
|
||||||
|
Manual,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ~/.claude.json 的项目键是 Claude 记录的路径(实测为正斜杠盘符形式),比较时统一分隔符与大小写。
|
||||||
|
fn normalize_root_key(path: &str) -> String {
|
||||||
|
path.replace('\\', "/").trim_end_matches('/').to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_claude_local_entry(claude_json: &Value, root: &std::path::Path) -> ClaudeLocalEntry {
|
||||||
|
let wanted = normalize_root_key(&root.display().to_string());
|
||||||
|
let entry = claude_json
|
||||||
|
.get("projects")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|projects| {
|
||||||
|
projects
|
||||||
|
.iter()
|
||||||
|
.find(|(key, _)| normalize_root_key(key) == wanted)
|
||||||
|
.map(|(_, value)| value)
|
||||||
|
})
|
||||||
|
.and_then(|project| project.get("mcpServers"))
|
||||||
|
.and_then(|servers| servers.get(MCP_KEY));
|
||||||
|
let Some(entry) = entry else {
|
||||||
|
return ClaudeLocalEntry::Absent;
|
||||||
|
};
|
||||||
|
let url = entry.get("url").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let helper = entry.get("headersHelper").and_then(Value::as_str).unwrap_or("");
|
||||||
|
if url.starts_with("http://127.0.0.1:")
|
||||||
|
&& url.contains("/mcp/projects/")
|
||||||
|
&& helper.contains(MCP_HEADER_SHIM_NAME)
|
||||||
|
{
|
||||||
|
ClaudeLocalEntry::Managed(entry.clone())
|
||||||
|
} else {
|
||||||
|
ClaudeLocalEntry::Manual
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_claude_user_config() -> Option<Value> {
|
||||||
|
let home = std::env::var("USERPROFILE").ok()?;
|
||||||
|
let text = std::fs::read_to_string(PathBuf::from(home).join(".claude.json")).ok()?;
|
||||||
|
serde_json::from_str(&text).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_claude_cli(
|
||||||
|
claude_bin: &str,
|
||||||
|
git_bash: &str,
|
||||||
|
root: &std::path::Path,
|
||||||
|
args: &[&str],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let out = crate::silent_cmd(claude_bin)
|
||||||
|
.current_dir(root)
|
||||||
|
.args(args)
|
||||||
|
.env("CLAUDE_CODE_GIT_BASH_PATH", git_bash)
|
||||||
|
.output()
|
||||||
|
.map_err(|error| format!("执行 claude CLI 失败: {error}"))?;
|
||||||
|
if out.status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"{}{}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除炼境托管的条目。
|
||||||
|
/// 手写条目一律不碰;替换失败时用原值回滚。best-effort,结果只记日志。
|
||||||
|
fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(String, String)>) {
|
||||||
|
let context = inject_log_context(project_id, "claude");
|
||||||
let (Some(claude_bin), Some(git_bash)) = (find_claude_bin(), find_git_bash()) else {
|
let (Some(claude_bin), Some(git_bash)) = (find_claude_bin(), find_git_bash()) else {
|
||||||
db::log_event(
|
db::log_event(
|
||||||
"mcp_inject",
|
"mcp_inject",
|
||||||
"warn",
|
"warn",
|
||||||
"claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 注册",
|
"claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步",
|
||||||
None,
|
Some(&context),
|
||||||
);
|
);
|
||||||
return false;
|
return;
|
||||||
};
|
};
|
||||||
let _ = std::process::Command::new(&claude_bin)
|
let state = read_claude_user_config()
|
||||||
.current_dir(root)
|
.map(|config| classify_claude_local_entry(&config, root))
|
||||||
.args(["mcp", "remove", MCP_KEY, "--scope", "local"])
|
.unwrap_or(ClaudeLocalEntry::Absent);
|
||||||
.env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash)
|
let remove_args = ["mcp", "remove", MCP_KEY, "--scope", "local"];
|
||||||
.output();
|
let outcome = match (state, desired) {
|
||||||
let payload = claude_local_server_json(server_url, header_helper);
|
(ClaudeLocalEntry::Manual, _) => {
|
||||||
match std::process::Command::new(&claude_bin)
|
Ok("本项目已有手写的 lian-jing local 条目,炼境不覆盖".to_string())
|
||||||
.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) => {
|
(ClaudeLocalEntry::Absent, None) => return,
|
||||||
let combined = format!(
|
(ClaudeLocalEntry::Managed(_), None) => {
|
||||||
"{}{}",
|
run_claude_cli(&claude_bin, &git_bash, root, &remove_args)
|
||||||
String::from_utf8_lossy(&out.stdout),
|
.map(|_| "Claude 项目级 MCP 条目已移除".to_string())
|
||||||
String::from_utf8_lossy(&out.stderr)
|
|
||||||
);
|
|
||||||
db::log_event(
|
|
||||||
"mcp_inject",
|
|
||||||
"warn",
|
|
||||||
&format!("claude mcp add-json 非零退出: {combined}"),
|
|
||||||
Some(MCP_KEY),
|
|
||||||
);
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
Err(error) => {
|
(previous, Some((server_url, header_helper))) => {
|
||||||
db::log_event(
|
let payload = claude_local_server_json(&server_url, &header_helper);
|
||||||
"mcp_inject",
|
let add_args = ["mcp", "add-json", MCP_KEY, payload.as_str(), "--scope", "local"];
|
||||||
"warn",
|
let registered = if let ClaudeLocalEntry::Managed(old) = &previous {
|
||||||
&format!("执行 claude mcp add-json 失败: {error}"),
|
if let Err(error) = run_claude_cli(&claude_bin, &git_bash, root, &remove_args) {
|
||||||
Some(MCP_KEY),
|
db::log_event(
|
||||||
);
|
"mcp_inject",
|
||||||
false
|
"warn",
|
||||||
|
&format!("Claude 项目级 MCP 旧条目移除失败: {error}"),
|
||||||
|
Some(&context),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
run_claude_cli(&claude_bin, &git_bash, root, &add_args).or_else(|error| {
|
||||||
|
let restore = old.to_string();
|
||||||
|
let _ = run_claude_cli(
|
||||||
|
&claude_bin,
|
||||||
|
&git_bash,
|
||||||
|
root,
|
||||||
|
&["mcp", "add-json", MCP_KEY, restore.as_str(), "--scope", "local"],
|
||||||
|
);
|
||||||
|
Err(format!("{error}(已尝试恢复原条目)"))
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
run_claude_cli(&claude_bin, &git_bash, root, &add_args)
|
||||||
|
};
|
||||||
|
registered.map(|_| "Claude 项目级 MCP 注册成功".to_string())
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
match outcome {
|
||||||
|
Ok(message) => db::log_event("mcp_inject", "info", &message, Some(&context)),
|
||||||
|
Err(error) => db::log_event(
|
||||||
|
"mcp_inject",
|
||||||
|
"warn",
|
||||||
|
&format!("Claude 项目级 MCP 同步失败: {error}"),
|
||||||
|
Some(&context),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DSH 机器级配置托管块 ──────────────────────────────────────────────────────
|
// ── DSH 机器级配置托管块 ──────────────────────────────────────────────────────
|
||||||
// DSH 配置无法按项目拆分,但 !!js 在加载时以启动目录求值:在项目目录(或子目录)启动时连项目专用地址
|
// DSH 配置无法按项目拆分,但 !!js 在加载时以启动目录求值。项目身份只认炼境登记表:托管块内嵌
|
||||||
// 并经稳定 helper 携带认证;项目外回退全局地址,保持 GUI 等非项目启动方式的现有行为。
|
// 「已登记项目根目录 → project_id」白名单(非秘密),从启动目录向上精确匹配根目录才连项目专用地址
|
||||||
|
// 并经稳定 helper 携带认证;任意目录里的 .lian-jing/context.json 不作为授权依据。未命中则回退全局地址,
|
||||||
|
// 保持 GUI 等非项目启动方式的现有行为。项目增删、端口变更时重写白名单。
|
||||||
|
|
||||||
fn render_dsh_block(port: u16) -> String {
|
/// 与 JS 端 path.resolve(d).toLowerCase() 对齐:反斜杠分隔、去尾部分隔符、小写。
|
||||||
|
fn normalize_dsh_root(path: &str) -> String {
|
||||||
|
path.replace('/', "\\").trim_end_matches('\\').to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已接入(根目录 context.json 的 project_id 与登记一致)、非控制面、非 WSL 的 Windows 项目。
|
||||||
|
fn dsh_project_roots(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
exclude: Option<&str>,
|
||||||
|
) -> Vec<(String, String)> {
|
||||||
|
let rows: Vec<(String, String)> = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, win_path FROM project_workspaces
|
||||||
|
WHERE win_path IS NOT NULL AND COALESCE(platform, '') != 'wsl'
|
||||||
|
ORDER BY id",
|
||||||
|
)
|
||||||
|
.and_then(|mut stmt| {
|
||||||
|
stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
||||||
|
.map(|rows| rows.filter_map(Result::ok).collect())
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
rows.into_iter()
|
||||||
|
.filter(|(id, _)| Some(id.as_str()) != exclude)
|
||||||
|
.filter_map(|(id, win_path)| {
|
||||||
|
let root = validate_registered_path(&win_path, "win_path").ok()?;
|
||||||
|
let context =
|
||||||
|
read_context_object(&root.join(".lian-jing").join("context.json"), "canonical 项目上下文")
|
||||||
|
.ok()?;
|
||||||
|
let bound = context.get("project_id").and_then(Value::as_str) == Some(id.as_str());
|
||||||
|
(bound && !project_wants_global_scope(&root))
|
||||||
|
.then(|| (normalize_dsh_root(&win_path), id))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_dsh_block(port: u16, roots: &[(String, String)]) -> String {
|
||||||
// 逐行显式缩进:YAML 依赖缩进,不能用字符串续行(会吞掉下一行行首空白)。
|
// 逐行显式缩进:YAML 依赖缩进,不能用字符串续行(会吞掉下一行行首空白)。
|
||||||
fn indent(spaces: usize, lines: &[&str]) -> Vec<String> {
|
fn indent(spaces: usize, lines: &[&str]) -> Vec<String> {
|
||||||
lines.iter().map(|line| format!("{}{line}", " ".repeat(spaces))).collect()
|
lines.iter().map(|line| format!("{}{line}", " ".repeat(spaces))).collect()
|
||||||
}
|
}
|
||||||
|
let roots_map: serde_json::Map<String, Value> = roots
|
||||||
|
.iter()
|
||||||
|
.map(|(root, id)| (root.clone(), json!(id)))
|
||||||
|
.collect();
|
||||||
|
let roots_line = format!("const roots = {};", Value::Object(roots_map));
|
||||||
let find_project_id = [
|
let find_project_id = [
|
||||||
"const fs = process.getBuiltinModule('fs'), path = process.getBuiltinModule('path');",
|
"const path = process.getBuiltinModule('path');",
|
||||||
|
roots_line.as_str(),
|
||||||
"let id;",
|
"let id;",
|
||||||
"for (let d = process.cwd(), p; d !== p; p = d, d = path.dirname(d)) {",
|
"for (let d = path.resolve(process.cwd()), p; d !== p; p = d, d = path.dirname(d)) {",
|
||||||
" const f = path.join(d, '.lian-jing', 'context.json');",
|
" const hit = roots[d.toLowerCase()];",
|
||||||
" if (fs.existsSync(f)) { try { id = JSON.parse(fs.readFileSync(f, 'utf8')).project_id; } catch {} break; }",
|
" if (hit) { id = hit; break; }",
|
||||||
"}",
|
"}",
|
||||||
];
|
];
|
||||||
let global_url = format!("http://127.0.0.1:{port}/mcp");
|
let global_url = format!("http://127.0.0.1:{port}/mcp");
|
||||||
@ -425,22 +573,25 @@ fn render_dsh_block(port: u16) -> String {
|
|||||||
"return id ? '{global_url}/projects/' + encodeURIComponent(id) : '{global_url}';"
|
"return id ? '{global_url}/projects/' + encodeURIComponent(id) : '{global_url}';"
|
||||||
);
|
);
|
||||||
let shim_line = format!(
|
let shim_line = format!(
|
||||||
"const shim = path.join(process.env.LOCALAPPDATA || '', 'lian-jing', 'bin', '{MCP_HEADER_SHIM_NAME}');"
|
"const shim = path.join(base, 'lian-jing', 'bin', '{MCP_HEADER_SHIM_NAME}');"
|
||||||
);
|
);
|
||||||
let header_body = [
|
let header_body = [
|
||||||
"if (!id) return {};",
|
"if (!id) return {};",
|
||||||
|
"const base = process.env.LOCALAPPDATA, sys = process.env.SystemRoot;",
|
||||||
|
"if (!base || !path.isAbsolute(base) || !sys || !path.isAbsolute(sys)) return {};",
|
||||||
|
shim_line.as_str(),
|
||||||
|
"const cmd = path.join(sys, 'System32', 'cmd.exe');",
|
||||||
"const crypto = process.getBuiltinModule('crypto'), cp = process.getBuiltinModule('child_process');",
|
"const crypto = process.getBuiltinModule('crypto'), cp = process.getBuiltinModule('child_process');",
|
||||||
"const digest = crypto.createHash('sha256').update(id).digest('hex');",
|
"const digest = crypto.createHash('sha256').update(id).digest('hex');",
|
||||||
shim_line.as_str(),
|
|
||||||
"// Node 拒绝直接执行 .cmd(EINVAL),须经 cmd /d /s /c 原样传参。",
|
"// Node 拒绝直接执行 .cmd(EINVAL),须经 cmd /d /s /c 原样传参。",
|
||||||
"const out = cp.execFileSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', '\"\"' + shim + '\" ' + digest + '\"'],",
|
"const out = cp.execFileSync(cmd, ['/d', '/s', '/c', '\"\"' + shim + '\" ' + digest + '\"'],",
|
||||||
" { encoding: 'utf8', timeout: 15000, windowsHide: true, windowsVerbatimArguments: true, stdio: ['ignore', 'pipe', 'ignore'] });",
|
" { encoding: 'utf8', timeout: 15000, windowsHide: true, windowsVerbatimArguments: true, stdio: ['ignore', 'pipe', 'ignore'] });",
|
||||||
"return { Authorization: String(JSON.parse(out).Authorization) };",
|
"return { Authorization: String(JSON.parse(out).Authorization) };",
|
||||||
];
|
];
|
||||||
|
|
||||||
let mut lines: Vec<String> = vec![
|
let mut lines: Vec<String> = vec![
|
||||||
DSH_MANAGED_START.to_string(),
|
DSH_MANAGED_START.to_string(),
|
||||||
"# 炼境托管:按 DSH 启动目录自动连接项目专用地址并携带认证;项目外回退全局地址。勿手改,会被炼境覆盖。".into(),
|
"# 炼境托管:按 DSH 启动目录匹配已登记项目根目录,连项目专用地址并携带认证;未命中回退全局地址。勿手改,会被炼境覆盖。".into(),
|
||||||
"- insert:".into(),
|
"- insert:".into(),
|
||||||
" - id: mcp-lian-jing".into(),
|
" - id: mcp-lian-jing".into(),
|
||||||
" name: '@deepseek-ai/dsh-mcp-client'".into(),
|
" name: '@deepseek-ai/dsh-mcp-client'".into(),
|
||||||
@ -474,32 +625,63 @@ fn render_dsh_block(port: u16) -> String {
|
|||||||
block
|
block
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 用 YAML 解析判断托管块之外是否已有 lian-jing 条目(id 或 serverName,任意引号/注释/流式写法)。
|
||||||
|
/// 解析失败时保守拒绝写入,不冒险追加。
|
||||||
|
fn has_manual_lian_jing(outside_block: &str) -> Result<bool, String> {
|
||||||
|
if outside_block.trim().is_empty() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let doc: serde_yaml::Value = serde_yaml::from_str(outside_block)
|
||||||
|
.map_err(|error| format!("DSH 补丁文件无法解析,炼境不写入: {error}"))?;
|
||||||
|
let Some(items) = doc.as_sequence() else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
Ok(items.iter().any(|item| {
|
||||||
|
item.get("insert")
|
||||||
|
.and_then(serde_yaml::Value::as_sequence)
|
||||||
|
.is_some_and(|entries| {
|
||||||
|
entries.iter().any(|entry| {
|
||||||
|
entry.get("id").and_then(serde_yaml::Value::as_str) == Some("mcp-lian-jing")
|
||||||
|
|| entry
|
||||||
|
.get("config")
|
||||||
|
.and_then(|config| config.get("serverName"))
|
||||||
|
.and_then(serde_yaml::Value::as_str)
|
||||||
|
== Some(MCP_KEY)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// 纯函数:把托管块合并进 DSH 补丁文件内容。已有托管块则原位替换;
|
/// 纯函数:把托管块合并进 DSH 补丁文件内容。已有托管块则原位替换;
|
||||||
/// 块外存在手写的 lian-jing 条目则拒绝覆盖(与 Codex 手工同名 server 的处理一致)。
|
/// 块外存在手写的 lian-jing 条目则拒绝覆盖(与 Codex 手工同名 server 的处理一致)。
|
||||||
fn upsert_dsh_block(current: &str, block: &str) -> Result<String, String> {
|
fn upsert_dsh_block(current: &str, block: &str) -> Result<String, String> {
|
||||||
|
const MANUAL: &str = "DSH 补丁文件已手工定义 lian-jing,炼境不会覆盖;移除手写条目后重试";
|
||||||
match (current.find(DSH_MANAGED_START), current.find(DSH_MANAGED_END)) {
|
match (current.find(DSH_MANAGED_START), current.find(DSH_MANAGED_END)) {
|
||||||
(Some(start), Some(end)) if end >= start => Ok(format!(
|
(Some(start), Some(end)) if end >= start => {
|
||||||
"{}{}{}",
|
let after = ¤t[end + DSH_MANAGED_END.len()..];
|
||||||
¤t[..start],
|
if has_manual_lian_jing(&format!("{}{}", ¤t[..start], after))? {
|
||||||
block.trim_end_matches('\n'),
|
return Err(MANUAL.into());
|
||||||
¤t[end + DSH_MANAGED_END.len()..]
|
}
|
||||||
)),
|
Ok(format!(
|
||||||
(Some(_), _) | (_, Some(_)) => Err("DSH 补丁文件中的炼境托管块不完整或顺序错误".into()),
|
"{}{}{}",
|
||||||
(None, None)
|
¤t[..start],
|
||||||
if current.lines().any(|line| {
|
block.trim_end_matches('\n'),
|
||||||
let t = line.trim();
|
after
|
||||||
t == "id: mcp-lian-jing" || t == format!("serverName: {MCP_KEY}")
|
))
|
||||||
}) =>
|
|
||||||
{
|
|
||||||
Err("DSH 补丁文件已手工定义 lian-jing,炼境不会覆盖;移除手写条目后重试".into())
|
|
||||||
}
|
}
|
||||||
|
(Some(_), _) | (_, Some(_)) => Err("DSH 补丁文件中的炼境托管块不完整或顺序错误".into()),
|
||||||
|
(None, None) if has_manual_lian_jing(current)? => Err(MANUAL.into()),
|
||||||
(None, None) if current.trim().is_empty() => Ok(block.to_string()),
|
(None, None) if current.trim().is_empty() => Ok(block.to_string()),
|
||||||
(None, None) => Ok(format!("{}\n{}", current.trim_end(), block)),
|
(None, None) => Ok(format!("{}\n{}", current.trim_end(), block)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 未安装 DSH(无 ~/.dsh)时跳过;返回是否写入或已是最新。
|
/// 未安装 DSH(无 ~/.dsh)时跳过;返回是否写入或已是最新。exclude 用于删除项目时提前剔除白名单。
|
||||||
pub fn ensure_dsh_patch(port: u16) -> Result<bool, String> {
|
pub fn ensure_dsh_patch(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
port: u16,
|
||||||
|
exclude: Option<&str>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
let home = std::env::var("DSH_HOME").map(PathBuf::from).or_else(|_| {
|
let home = std::env::var("DSH_HOME").map(PathBuf::from).or_else(|_| {
|
||||||
std::env::var("USERPROFILE")
|
std::env::var("USERPROFILE")
|
||||||
.map(|home| PathBuf::from(home).join(".dsh"))
|
.map(|home| PathBuf::from(home).join(".dsh"))
|
||||||
@ -514,13 +696,30 @@ pub fn ensure_dsh_patch(port: u16) -> Result<bool, String> {
|
|||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||||
Err(error) => return Err(format!("读取 DSH 补丁文件失败: {error}")),
|
Err(error) => return Err(format!("读取 DSH 补丁文件失败: {error}")),
|
||||||
};
|
};
|
||||||
let next = upsert_dsh_block(¤t, &render_dsh_block(port))?;
|
let block = render_dsh_block(port, &dsh_project_roots(conn, exclude));
|
||||||
|
let next = upsert_dsh_block(¤t, &block)?;
|
||||||
if next != current {
|
if next != current {
|
||||||
std::fs::write(&path, next).map_err(|error| format!("写入 DSH 补丁文件失败: {error}"))?;
|
std::fs::write(&path, next).map_err(|error| format!("写入 DSH 补丁文件失败: {error}"))?;
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新 DSH 托管块的 best-effort 包装:失败只记日志。
|
||||||
|
pub fn refresh_dsh_patch(exclude: Option<&str>) {
|
||||||
|
let result = db::pool()
|
||||||
|
.get()
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
.and_then(|conn| ensure_dsh_patch(&conn, crate::mcp_server::read_port(), exclude));
|
||||||
|
if let Err(error) = result {
|
||||||
|
db::log_event(
|
||||||
|
"mcp_inject",
|
||||||
|
"warn",
|
||||||
|
&format!("DSH 托管块刷新失败: {error}"),
|
||||||
|
Some(&json!({ "client": "dsh" }).to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 清理旧组级全局注册(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> {
|
||||||
@ -567,7 +766,7 @@ pub fn cleanup_legacy_global_registrations() -> Vec<String> {
|
|||||||
.map(|out| out.status.success())
|
.map(|out| out.status.success())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if ok {
|
if ok {
|
||||||
db::log_event("mcp_inject", "info", "旧组级 MCP 注册已移除", Some(&name));
|
db::log_event("mcp_inject", "info", "旧组级 MCP 注册已移除", Some(&json!({ "client": "claude-global", "server": &name }).to_string()));
|
||||||
removed.push(name);
|
removed.push(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -734,14 +933,25 @@ pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
|
|||||||
|
|
||||||
// 同步注册到 ~/.claude.json:user 级全局注册作兜底,local 级项目条目优先生效(均 best-effort)。
|
// 同步注册到 ~/.claude.json:user 级全局注册作兜底,local 级项目条目优先生效(均 best-effort)。
|
||||||
// 项目 .claude/settings.json 的 mcpServers 不被 Claude Code 读取,仅保留作兼容。
|
// 项目 .claude/settings.json 的 mcpServers 不被 Claude Code 读取,仅保留作兼容。
|
||||||
|
// 控制面项目(mcp_scope=global)不写 local 条目,并清理炼境曾写入的旧条目,保留全局与跨项目工具。
|
||||||
try_register_globally(port);
|
try_register_globally(port);
|
||||||
try_register_claude_local(&project_root(project_id)?, &server_url, &header_helper);
|
let root = project_root(project_id)?;
|
||||||
|
let desired = (!project_wants_global_scope(&root)).then(|| (server_url, header_helper));
|
||||||
|
let owned_id = project_id.to_string();
|
||||||
|
// claude CLI 每次调用约 1-2 秒,放到后台线程,避免同步命令阻塞界面
|
||||||
|
std::thread::spawn(move || sync_claude_local(&owned_id, &root, desired));
|
||||||
|
refresh_dsh_patch(None);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。
|
/// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。
|
||||||
/// 调用方需自行保证项目已不属于任何产品组时才调用。
|
/// 调用方需自行保证项目已不属于任何产品组时才调用。
|
||||||
pub fn remove_project_mcp(project_id: &str) -> Result<(), String> {
|
pub fn remove_project_mcp(project_id: &str) -> Result<(), String> {
|
||||||
|
// 先于登记删除清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。
|
||||||
|
if let Ok(root) = project_root(project_id) {
|
||||||
|
sync_claude_local(project_id, &root, None);
|
||||||
|
}
|
||||||
|
refresh_dsh_patch(Some(project_id));
|
||||||
remove_codex_mcp_config(&codex_config_path(project_id)?)?;
|
remove_codex_mcp_config(&codex_config_path(project_id)?)?;
|
||||||
let path = settings_path(project_id)?;
|
let path = settings_path(project_id)?;
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
@ -837,10 +1047,8 @@ pub fn reinject_all_mcp() -> Vec<String> {
|
|||||||
errors.push(format!("项目 {}: {}", project_id, e));
|
errors.push(format!("项目 {}: {}", project_id, e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// DSH 托管块内嵌端口,端口变更后一并刷新
|
// 每次注入已刷新 DSH 托管块(内嵌端口与白名单);此处兜底一次,失败只记日志
|
||||||
if let Err(e) = ensure_dsh_patch(crate::mcp_server::read_port()) {
|
refresh_dsh_patch(None);
|
||||||
errors.push(format!("DSH: {e}"));
|
|
||||||
}
|
|
||||||
errors
|
errors
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -969,6 +1177,61 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let unicode = render_header_shim(std::path::Path::new("C:\\炼境\\app.exe"));
|
let unicode = render_header_shim(std::path::Path::new("C:\\炼境\\app.exe"));
|
||||||
assert!(unicode.contains("chcp 65001 >nul\r\n"), "非 ASCII 路径须切 UTF-8 代码页");
|
assert!(unicode.contains("chcp 65001 >nul\r\n"), "非 ASCII 路径须切 UTF-8 代码页");
|
||||||
|
let percent = render_header_shim(std::path::Path::new("C:\\Users\\a%b\\app.exe"));
|
||||||
|
assert!(percent.contains("\"C:\\Users\\a%%b\\app.exe\" --lianjing-mcp-header %1"), "路径 % 须转义");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claude_local_entry_classification_protects_manual_entries() {
|
||||||
|
let root = std::path::Path::new("E:\\code-project\\Demo");
|
||||||
|
let config = |entry: Value| json!({ "projects": { "e:/code-project/demo": { "mcpServers": { "lian-jing": entry } } } });
|
||||||
|
assert_eq!(classify_claude_local_entry(&json!({}), root), ClaudeLocalEntry::Absent);
|
||||||
|
let managed = json!({
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://127.0.0.1:27190/mcp/projects/p1",
|
||||||
|
"headersHelper": shim_helper_command("d1"),
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
classify_claude_local_entry(&config(managed.clone()), root),
|
||||||
|
ClaudeLocalEntry::Managed(managed),
|
||||||
|
"键名大小写与分隔符差异须视为同一项目"
|
||||||
|
);
|
||||||
|
let manual = json!({ "type": "http", "url": "https://example.invalid/mcp" });
|
||||||
|
assert_eq!(classify_claude_local_entry(&config(manual), root), ClaudeLocalEntry::Manual);
|
||||||
|
let other_project = json!({ "projects": { "e:/code-project/other": { "mcpServers": { "lian-jing": {} } } } });
|
||||||
|
assert_eq!(classify_claude_local_entry(&other_project, root), ClaudeLocalEntry::Absent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn global_scope_projects_are_excluded_and_roots_follow_registry() {
|
||||||
|
let base = std::env::temp_dir().join(format!("lianjing_dsh_roots_{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
for (id, scope, bound) in [("p1", None, true), ("ctrl", Some("global"), true), ("drift", None, false), ("gone", None, true)] {
|
||||||
|
let root = base.join(id);
|
||||||
|
std::fs::create_dir_all(root.join(".lian-jing")).unwrap();
|
||||||
|
let mut ctx = json!({ "project_id": if bound { id } else { "someone-else" }, "schema_version": 1 });
|
||||||
|
if let Some(scope) = scope {
|
||||||
|
ctx["mcp_scope"] = json!(scope);
|
||||||
|
}
|
||||||
|
std::fs::write(root.join(".lian-jing/context.json"), ctx.to_string()).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_profiles (id, name) VALUES (?1, 'QA')",
|
||||||
|
[format!("{id}-profile")],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_workspaces (id, profile_id, win_path) VALUES (?1, ?2, ?3)",
|
||||||
|
rusqlite::params![id, format!("{id}-profile"), root.display().to_string()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
assert!(project_wants_global_scope(&base.join("ctrl")));
|
||||||
|
let roots = dsh_project_roots(&conn, Some("gone"));
|
||||||
|
let ids: Vec<&str> = roots.iter().map(|(_, id)| id.as_str()).collect();
|
||||||
|
assert_eq!(ids, vec!["p1"], "只保留已绑定、非控制面、未被剔除的项目");
|
||||||
|
assert_eq!(roots[0].0, normalize_dsh_root(&base.join("p1").display().to_string()));
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -984,16 +1247,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dsh_block_is_valid_yaml_with_cwd_routing_and_shim_auth() {
|
fn dsh_block_is_valid_yaml_with_registry_routing_and_shim_auth() {
|
||||||
let block = render_dsh_block(27190);
|
let roots = vec![("e:\\code-project\\demo".to_string(), "p1".to_string())];
|
||||||
|
let block = render_dsh_block(27190, &roots);
|
||||||
let parsed: serde_yaml::Value = serde_yaml::from_str(&block).expect("托管块必须是合法 YAML");
|
let parsed: serde_yaml::Value = serde_yaml::from_str(&block).expect("托管块必须是合法 YAML");
|
||||||
let entry = &parsed[0]["insert"][0];
|
let entry = &parsed[0]["insert"][0];
|
||||||
assert_eq!(entry["id"], "mcp-lian-jing");
|
assert_eq!(entry["id"], "mcp-lian-jing");
|
||||||
assert_eq!(entry["config"]["serverName"], MCP_KEY);
|
assert_eq!(entry["config"]["serverName"], MCP_KEY);
|
||||||
assert_eq!(entry["config"]["transport"], "streamable-http");
|
assert_eq!(entry["config"]["transport"], "streamable-http");
|
||||||
assert!(block.contains("url: !!js |") && block.contains("headers: !!js |"));
|
assert!(block.contains("url: !!js |") && block.contains("headers: !!js |"));
|
||||||
|
assert!(block.contains(r#"const roots = {"e:\\code-project\\demo":"p1"};"#), "白名单内嵌");
|
||||||
|
assert!(!block.contains("context.json"), "身份只认登记白名单,不读目录里的 context.json");
|
||||||
assert!(block.contains("'http://127.0.0.1:27190/mcp/projects/' + encodeURIComponent(id)"));
|
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(": 'http://127.0.0.1:27190/mcp';"), "未命中回退全局地址");
|
||||||
|
assert!(block.contains("path.isAbsolute(base)") && block.contains("'System32', 'cmd.exe'"));
|
||||||
assert!(block.contains(MCP_HEADER_SHIM_NAME) && block.contains("windowsVerbatimArguments: true"));
|
assert!(block.contains(MCP_HEADER_SHIM_NAME) && block.contains("windowsVerbatimArguments: true"));
|
||||||
assert!(!block.contains("Bearer "), "托管块不得含任何 token");
|
assert!(!block.contains("Bearer "), "托管块不得含任何 token");
|
||||||
}
|
}
|
||||||
@ -1001,18 +1268,26 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn dsh_block_upsert_preserves_user_entries_and_refuses_manual_lian_jing() {
|
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 user = "- insert:\n - id: mcp-yljt\n config:\n serverName: yljt\n";
|
||||||
let first = upsert_dsh_block(user, &render_dsh_block(27190)).unwrap();
|
let first = upsert_dsh_block(user, &render_dsh_block(27190, &[])).unwrap();
|
||||||
assert!(first.starts_with(user.trim_end()), "用户条目原样保留");
|
assert!(first.starts_with(user.trim_end()), "用户条目原样保留");
|
||||||
let parsed: serde_yaml::Value = serde_yaml::from_str(&first).unwrap();
|
let parsed: serde_yaml::Value = serde_yaml::from_str(&first).unwrap();
|
||||||
assert_eq!(parsed.as_sequence().unwrap().len(), 2);
|
assert_eq!(parsed.as_sequence().unwrap().len(), 2);
|
||||||
|
|
||||||
let moved = upsert_dsh_block(&first, &render_dsh_block(28000)).unwrap();
|
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!(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!(moved.matches(DSH_MANAGED_START).count(), 1, "原位替换不重复追加");
|
||||||
assert_eq!(upsert_dsh_block(&moved, &render_dsh_block(28000)).unwrap(), moved, "幂等");
|
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";
|
for manual in [
|
||||||
assert!(upsert_dsh_block(manual, &render_dsh_block(27190)).is_err());
|
"- insert:\n - id: mcp-lian-jing\n config:\n serverName: lian-jing\n",
|
||||||
|
"- insert:\n - id: other\n config:\n serverName: 'lian-jing' # 手写\n",
|
||||||
|
"- insert: [{id: mcp-lian-jing, config: {serverName: x}}]\n",
|
||||||
|
] {
|
||||||
|
assert!(upsert_dsh_block(manual, &render_dsh_block(27190, &[])).is_err(), "{manual}");
|
||||||
|
let with_block = format!("{manual}{}", render_dsh_block(27190, &[]));
|
||||||
|
assert!(upsert_dsh_block(&with_block, &render_dsh_block(27190, &[])).is_err(), "块外手写也须拒绝");
|
||||||
|
}
|
||||||
|
assert!(upsert_dsh_block("- insert: [unclosed\n", "b").is_err(), "无法解析时保守拒绝");
|
||||||
assert!(upsert_dsh_block(&format!("{DSH_MANAGED_START}\n- x\n"), "b").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");
|
assert_eq!(upsert_dsh_block("", "BLOCK\n").unwrap(), "BLOCK\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1603,6 +1603,11 @@ async fn handle_scoped_sse_message(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(req): Json<RpcRequest>,
|
Json(req): Json<RpcRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
|
// 握手后项目可能被删除:每条消息复核登记,失效会话一并移除。
|
||||||
|
if !scoped_project_registered(&project_id) {
|
||||||
|
sessions.lock().await.remove(&query.session_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),
|
||||||
|
|||||||
@ -1292,6 +1292,8 @@ fn get_diagnostics() -> Result<String, String> {
|
|||||||
AND id IN (
|
AND id IN (
|
||||||
SELECT MAX(id) FROM runtime_logs
|
SELECT MAX(id) FROM runtime_logs
|
||||||
WHERE category = 'mcp_inject'
|
WHERE category = 'mcp_inject'
|
||||||
|
AND json_valid(context)
|
||||||
|
AND json_extract(context, '$.project_id') IS NOT NULL
|
||||||
GROUP BY json_extract(context, '$.project_id')
|
GROUP BY json_extract(context, '$.project_id')
|
||||||
)
|
)
|
||||||
ORDER BY ts DESC
|
ORDER BY ts DESC
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user