feat(mcp/parity): 三端经稳定 helper 入口连项目专用地址(P1 + P3) #24

Merged
lanrtop merged 15 commits from feat/agent-client-parity into master 2026-10-02 12:05:16 +08:00
6 changed files with 429 additions and 12 deletions
Showing only changes of commit ebf43d71c0 - Show all commits

View File

@ -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_tools.rs MCP 工具 schema + dispatch + 实现(工具清单以 tools_list_result 为准)
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)
.blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡)
docs/ai-context/ AI 上下文文档(速查卡、契约映射)

View File

@ -6,7 +6,7 @@ contracts:
- label: API 接口(Axum)
paths:
- "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 工具接口
paths:

View File

@ -670,6 +670,23 @@ fn codex_mcp_server_url(content: &str) -> Option<&str> {
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(
content: Option<&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 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(
"Codex MCP 已配置",
VerifyState::Ready,
@ -1501,6 +1524,22 @@ mod tests {
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 批量实调实锤)。
#[test]
fn apply_rejects_empty_or_missing_path() {

View File

@ -116,6 +116,15 @@ pub fn run() {
let mcp_port = mcp_server::read_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 标记位防重复)────
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(10)); // 等 DB 就绪

View File

@ -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_END: &str = "# LIANJING_MCP_MANAGED_END";
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!(
"{}{}{}",
&current[..start],
block.trim_end_matches('\n'),
&current[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(&current, &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 位两种历史命名)。
/// 幂等 best-effort:不存在的名字 remove 失败仅忽略。返回成功移除的名字列表。
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,
project_id,
)?;
let executable = std::env::current_exe()
.map_err(|_| "无法定位炼境可执行文件,拒绝写入 MCP 认证配置".to_string())?;
let header_helper = format!(
"\"{}\" --lianjing-mcp-header {digest}",
executable.display()
);
ensure_header_shim()?;
let header_helper = shim_helper_command(&digest);
write_canonical_context(project_id)?;
upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url, &header_helper)?;
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 为准。
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_claude_local(&project_root(project_id)?, &server_url, &header_helper);
Ok(())
}
@ -611,6 +837,10 @@ pub fn reinject_all_mcp() -> Vec<String> {
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
}
@ -719,6 +949,74 @@ mod tests {
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]
fn registered_path_accepts_absolute_and_unc() {
assert!(validate_registered_path("E:\\code-project\\demo", "win_path").is_ok());

View File

@ -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(
Path(project_id): Path<String>,
headers: HeaderMap,
Json(req): Json<RpcRequest>,
) -> Response {
if !scoped_project_registered(&project_id) {
return unknown_project_response(&project_id, req.id.clone());
}
if authorize_sensitive_request(
&req,
Some(&project_id),
@ -377,7 +409,10 @@ async fn handle_sse_connect(
async fn handle_scoped_sse_connect(
Path(project_id): Path<String>,
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 (tx, rx) = mpsc::channel::<(String, String)>(32);
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)| {
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)]
@ -1455,6 +1492,40 @@ mod tests {
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]
fn loopback_urls_share_one_ipv4_base() {
let port = 27190;