@ -31,7 +31,11 @@ fn header_shim_path() -> Result<PathBuf, String> {
/// 路径里的 % 必须写成 %%,否则会和 %1 配对被当作变量展开吞掉。
fn render_header_shim ( executable : & std ::path ::Path ) -> 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 \n exit /b %ERRORLEVEL% \r \n " )
}
@ -214,10 +218,7 @@ fn find_claude_bin() -> Option<String> {
}
// 最后尝试 where 命令( GUI 进程 PATH 可能不含用户 bin, but worth trying)
if let Ok ( out ) = crate ::silent_cmd ( " where " )
. arg ( " claude.exe " )
. output ( )
{
if let Ok ( out ) = crate ::silent_cmd ( " where " ) . arg ( " claude.exe " ) . output ( ) {
if out . status . success ( ) {
if let Ok ( stdout ) = String ::from_utf8 ( out . stdout ) {
if let Some ( line ) = stdout . lines ( ) . next ( ) {
@ -300,10 +301,24 @@ fn try_register_globally(port: u16) -> bool {
& claude_bin ,
& git_bash ,
None ,
& [ " mcp " , " add " , " --transport " , " sse " , " -s " , " user " , MCP_KEY , & server_url ] ,
& [
" mcp " ,
" add " ,
" --transport " ,
" sse " ,
" -s " ,
" user " ,
MCP_KEY ,
& server_url ,
] ,
) {
Ok ( ( ) ) = > {
db ::log_event ( " mcp_inject " , " info " , " 全局 MCP 注册成功 " , Some ( & global_log_context ( ) ) ) ;
Ok ( _ ) = > {
db ::log_event (
" mcp_inject " ,
" info " ,
" 全局 MCP 注册成功 " ,
Some ( & global_log_context ( ) ) ,
) ;
true
}
// 同名已存在不是故障——CLI 链路健康,注册目标已达成
@ -328,7 +343,11 @@ fn project_wants_global_scope(root: &std::path::Path) -> bool {
" canonical 项目上下文 " ,
)
. ok ( )
. and_then ( | ctx | ctx . get ( " mcp_scope " ) . and_then ( Value ::as_str ) . map ( | scope | scope = = " global " ) )
. and_then ( | ctx | {
ctx . get ( " mcp_scope " )
. and_then ( Value ::as_str )
. map ( | scope | scope = = " global " )
} )
. unwrap_or ( false )
}
@ -383,7 +402,10 @@ fn managed_claude_entries(config: &Value, project_id: &str) -> Vec<(String, Valu
. filter_map ( | ( key , project ) | {
let entry = project . get ( " mcpServers " ) ? . get ( MCP_KEY ) ? ;
let url = entry . get ( " url " ) . and_then ( Value ::as_str ) ? ;
let helper = entry . get ( " headersHelper " ) . and_then ( Value ::as_str ) . unwrap_or ( " " ) ;
let helper = entry
. get ( " headersHelper " )
. and_then ( Value ::as_str )
. unwrap_or ( " " ) ;
( url . starts_with ( " http://127.0.0.1: " )
& & url . ends_with ( & suffix )
& & helper . contains ( MCP_HEADER_SHIM_NAME ) )
@ -399,16 +421,16 @@ fn claude_global_registered(config: &Value) -> bool {
. is_some ( )
}
/// claude CLI 单次调用上限:卡住的子进程会被终止,不让它拖住持锁的接入操作 与界面 。
/// claude CLI 单次调用上限:卡住的子进程会被终止,不让它拖住持锁的接入操作 。
const CLAUDE_CLI_TIMEOUT : std ::time ::Duration = std ::time ::Duration ::from_secs ( 20 ) ;
/// 运行 claude CLI( 不弹窗、stdin 置空、带超时)。成功返回 Ok ,失败返回合并后的输出。
/// 运行 claude CLI( 不弹窗、stdin 置空、带超时)。成功返回 输出 ,失败返回合并后的输出。
fn run_claude_cli (
claude_bin : & str ,
git_bash : & str ,
cwd : Option < & std ::path ::Path > ,
args : & [ & str ] ,
) -> Result < ( ) , String > {
) -> Result < String , String > {
use std ::io ::Read ;
let mut command = crate ::silent_cmd ( claude_bin ) ;
if let Some ( dir ) = cwd {
@ -448,96 +470,178 @@ fn run_claude_cli(
let _ = stderr . read_to_string ( & mut output ) ;
}
if status . success ( ) {
Ok ( ( ) )
Ok ( output )
} else {
Err ( output )
}
}
/// 统一分隔符与大小写,便于比较 ~/.claude.json 的项目键与项目根目录。
fn normalize_root_key ( path : & str ) -> String {
path . replace ( '\\' , " / " ) . trim_end_matches ( '/' ) . to_lowercase ( )
}
/// Claude 以 git 根为项目键,必为项目根本身或其祖先目录。
fn key_covers_root ( key : & str , root : & str ) -> bool {
let key = normalize_root_key ( key ) ;
let root = normalize_root_key ( root ) ;
root = = key | | root . starts_with ( & format! ( " {key} / " ) )
}
/// 炼境项目路由 URL 里的 project_id; 不是炼境项目路由则为 None( 视为手写配置) 。
fn lianjing_project_of ( url : & str ) -> Option < String > {
let rest = url . strip_prefix ( " http://127.0.0.1: " ) ? ;
let encoded = rest . split_once ( " /mcp/projects/ " ) ? . 1. trim_end_matches ( '/' ) ;
urlencoding ::decode ( encoded ) . ok ( ) . map ( | id | id . into_owned ( ) )
}
#[ derive(Debug, PartialEq) ]
enum ClaudeEffective {
/// 本目录生效的是 local 条目,附其 URL
Local ( String ) ,
/// 本目录没有 local 条目(生效的是 user/project 级,或根本没有 lian-jing)
NotLocal ,
}
/// 纯函数:解析 `claude mcp get lian-jing` 的输出。格式无法识别时返回 None( 按「未知」处理, 不写入) 。
fn parse_claude_mcp_get ( output : & str ) -> Option < ClaudeEffective > {
if output . contains ( " No MCP server found " ) {
return Some ( ClaudeEffective ::NotLocal ) ;
}
let field = | name : & str | {
output . lines ( ) . find_map ( | line | {
line . trim ( )
. strip_prefix ( name )
. map ( | value | value . trim ( ) . to_string ( ) )
} )
} ;
let scope = field ( " Scope: " ) ? ;
if scope . starts_with ( " Local config " ) {
Some ( ClaudeEffective ::Local ( field ( " URL: " ) ? ) )
} else {
Some ( ClaudeEffective ::NotLocal )
}
}
/// 客户端侧同步( Claude local、DSH 托管块)的日志单独归类:不覆盖诊断里每个项目的「最后一次注入结果」。
const CLIENT_SYNC_LOG : & str = " mcp_client_sync " ;
/// 让 Claude 的 local 条目与期望一致: desired=Some 时注册/刷新, None 时只删除炼境托管的条目。
/// 让 Claude 的 local 条目与期望一致: desired=Some 时注册/刷新, None 时只删除 本项目的 炼境托管条目。
///
/// 写入一律交给 claude CLI( cwd=项目目录) : 项目键推导、Claude 自己的文件锁、配置路径与读写细节都由
/// Claude 负责, 炼境不复刻其私有实现( 2026-10-01 最终审查:直接编辑 ~/.claude.json 需要复刻 git 根 /
/// worktree / NFC 键推导、.claude.json.lock 锁协议、CLAUDE_CONFIG_DIR 等一整套内部机制,随 Claude 升级易失效)。
/// 只读扫描判定现状:已一致则不起 CLI( 稳态不阻塞) ; 只替换/删除炼境托管条目,手写条目不碰。
/// 项目目录已不存在时无法经 CLI 定位项目键,只记录残留、不写文件。
/// 调用方须持有 mcp_op_lock, 并在删除本地文件夹之前同步调用( 避免与目录删除竞态) 。
/// 原则:写入一律交给 claude CLI( cwd=项目目录),判定以 Claude 自己的回答为准——炼境不复刻 Claude 的项目键
/// 推导、文件锁与配置路径( 2026-10-01 多轮审查结论:任何自行推断都会在 worktree、目录迁移、共享键等边界出错) 。
/// - 快路径(不起 CLI) : 本项目的托管条目已一致地位于项目根或其祖先目录的键下( Claude 的键是 git 根),视为已同步;
/// 只读确认没有任何托管条目时,移除无需动作。
/// - 其余情况在项目目录执行 `claude mcp get lian-jing` 取实际生效条目:本项目的托管条目 → 移除或刷新;
/// 他项目的托管条目(共享项目键)或手写条目 → 不碰并记录;没有 local 条目 → 按需写入。
/// - 刷新时先删后加, 写入失败用原条目回滚; 无法确认现状( 读取失败、CLI 出错、目录不存在)一律不写,只记 warn。
///
/// 调用方须持有 mcp_op_lock, 并在删除本地文件夹之前同步调用。
fn sync_claude_local ( project_id : & str , root : & std ::path ::Path , desired : Option < ( String , String ) > ) {
let context = inject_log_context ( project_id , " claude " ) ;
let managed = read_claude_config ( )
. map ( | config | managed_claude_entries ( & config , project_id ) )
let warn = | message : String | db ::log_event ( CLIENT_SYNC_LOG , " warn " , & message , Some ( & context ) ) ;
let config = read_claude_config ( ) ;
let root_text = root . display ( ) . to_string ( ) ;
let managed_all = config
. as_ref ( )
. map ( | config | managed_claude_entries ( config , project_id ) )
. unwrap_or_default ( ) ;
let managed_here : Vec < & ( String , Value ) > = managed_all
. iter ( )
. filter ( | ( key , _ ) | key_covers_root ( key , & root_text ) )
. collect ( ) ;
let desired_entry = desired . map ( | ( url , helper ) | claude_local_server_entry ( & url , & helper ) ) ;
match & desired_entry {
None if managed . is_empty ( ) = > return ,
Some ( entry ) if managed . iter ( ) . any ( | ( _ , current ) | current = = entry ) = > return ,
Some( entry ) if managed_here . iter ( ) . any ( | ( _ , current ) | current = = entry ) = > return ,
None if config . is_some ( ) & & managed_all . is_empty ( ) = > return ,
_ = > { }
}
if ! root . is_dir ( ) {
let keys : Vec < & str > = managed . iter ( ) . map ( | ( key , _ ) | key . as_str ( ) ) . collect ( ) ;
db ::log_event (
CLIENT_SYNC_LOG ,
" warn " ,
& format! (
" 项目目录已不存在,无法经 claude CLI 同步 Claude 项目级条目;残留托管条目所在项目键 {keys:?},可在对应目录执行 `claude mcp remove lian-jing -s local` 清理 "
) ,
Some ( & context ) ,
) ;
let keys : Vec < & str > = managed_all . iter ( ) . map ( | ( key , _ ) | key . as_str ( ) ) . collect ( ) ;
warn ( format! (
" 项目目录不存在,无法向 Claude 确认本目录的实际条目,未改动;本项目的托管条目位于项目键 {keys:?},可在对应目录执行 `claude mcp remove lian-jing -s local` 清理 "
) ) ;
return ;
}
let ( Some ( claude_bin ) , Some ( git_bash ) ) = ( find_claude_bin ( ) , find_git_bash ( ) ) else {
db ::log_event (
CLIENT_SYNC_LOG ,
" warn " ,
" claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步 " ,
Some ( & context ) ,
) ;
warn ( " claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步 " . into ( ) ) ;
return ;
} ;
let remove_error = if managed . is_empty ( ) {
None
} else {
let effective =
match run_claude_cli ( & claude_bin , & git_bash , Some ( root ) , & [ " mcp " , " get " , MCP_KEY ] ) {
Ok ( output ) | Err ( output ) = > parse_claude_mcp_get ( & output ) ,
} ;
let Some ( effective ) = effective else {
warn ( " 无法确认 Claude 在本目录实际生效的 lian-jing 条目( claude mcp get 输出无法识别),未改动 " . into ( ) ) ;
return ;
} ;
let remove = | | {
run_claude_cli (
& claude_bin ,
& git_bash ,
Some ( root ) ,
& [ " mcp " , " remove " , MCP_KEY , " --scope " , " local " ] ,
)
. err ( )
} ;
let outcome : Result < & str , String > = match & desired_entry {
None = > match remove_error {
None = > Ok ( " Claude 项目级 MCP 条目已移除 " ) ,
Some ( error ) = > Err ( error ) ,
} ,
Some ( entry ) = > {
let payload = entry . to_string ( ) ;
match run_claude_cli (
& claude_bin ,
& git_bash ,
Some ( root ) ,
& [ " mcp " , " add-json " , MCP_KEY , payload . as_str ( ) , " --scope " , " local " ] ,
) {
Ok ( ( ) ) = > Ok ( " Claude 项目级 MCP 条目已写入 " ) ,
Err ( error ) if error . contains ( " already exists " ) = > match remove_error {
None = > Ok ( " 本项目已有手写的 lian-jing local 条目,炼境不覆盖 " ) ,
Some ( remove_error ) = > Err ( format! ( " 旧条目移除失败: {remove_error} " ) ) ,
let add = | entry : & Value | {
let payload = entry . to_string ( ) ;
run_claude_cli (
& claude_bin ,
& git_bash ,
Some ( root ) ,
& [
" mcp " ,
" add-json " ,
MCP_KEY ,
payload . as_str ( ) ,
" --scope " ,
" local " ,
] ,
)
} ;
let outcome : Result < String , String > = match ( effective , & desired_entry ) {
( ClaudeEffective ::Local ( url ) , desired ) = > match lianjing_project_of ( & url ) {
Some ( owner ) if owner = = project_id = > match remove ( ) {
Err ( error ) = > Err ( format! ( " 旧条目移除失败: {error} " ) ) ,
Ok ( _ ) = > match desired {
None = > Ok ( " Claude 项目级 MCP 条目已移除 " . into ( ) ) ,
Some ( entry ) = > match add ( entry ) {
Ok ( _ ) = > Ok ( " Claude 项目级 MCP 条目已刷新 " . into ( ) ) ,
Err ( error ) = > {
// 回滚:用读到的原条目写回,避免项目失去 local 条目
let restored = managed_here
. first ( )
. map ( | ( _ , previous ) | add ( previous ) . is_ok ( ) )
. unwrap_or ( false ) ;
Err ( format! (
" 新条目写入失败: {error}( {}) " ,
if restored { " 已恢复原条目 " } else { " 原条目未能恢复 " }
) )
}
} ,
} ,
Err ( error ) = > Err ( error ) ,
} ,
Some ( owner ) = > Err ( format! (
" 本目录生效的 lian-jing local 条目属于项目 {owner}(共享项目键,如 worktree 或嵌套项目),炼境不改动 "
) ) ,
None = > Ok ( " 本目录已有手写的 lian-jing local 条目,炼境不覆盖 " . into ( ) ) ,
} ,
( ClaudeEffective ::NotLocal , Some ( entry ) ) = > add ( entry )
. map ( | _ | " Claude 项目级 MCP 条目已写入 " . into ( ) )
. map_err ( | error | format! ( " 写入失败: {error} " ) ) ,
( ClaudeEffective ::NotLocal , None ) = > {
if managed_all . is_empty ( ) {
return ;
}
let keys : Vec < & str > = managed_all . iter ( ) . map ( | ( key , _ ) | key . as_str ( ) ) . collect ( ) ;
Err ( format! (
" 本目录没有生效的 local 条目,但其他项目键 {keys:?} 下残留本项目的托管条目,可在对应目录执行 `claude mcp remove lian-jing -s local` 清理 "
) )
}
} ;
match outcome {
Ok ( message ) = > db ::log_event ( CLIENT_SYNC_LOG , " info " , message , Some ( & context ) ) ,
Err ( error ) = > db ::log_event (
CLIENT_SYNC_LOG ,
" warn " ,
& format! ( " Claude 项目级 MCP 同步失败: {error} " ) ,
Some ( & context ) ,
) ,
Ok ( message ) = > db ::log_event ( CLIENT_SYNC_LOG , " info " , & message , Some ( & context ) ) ,
Err ( error ) = > warn ( format! ( " Claude 项目级 MCP 同步: {error} " ) ) ,
}
}
@ -546,7 +650,9 @@ fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(
static MCP_OP_LOCK : std ::sync ::Mutex < ( ) > = std ::sync ::Mutex ::new ( ( ) ) ;
fn mcp_op_lock ( ) -> std ::sync ::MutexGuard < 'static , ( ) > {
MCP_OP_LOCK . lock ( ) . unwrap_or_else ( | poisoned | poisoned . into_inner ( ) )
MCP_OP_LOCK
. lock ( )
. unwrap_or_else ( | poisoned | poisoned . into_inner ( ) )
}
// ── DSH 机器级配置托管块 ──────────────────────────────────────────────────────
@ -598,9 +704,11 @@ fn dsh_project_roots(
. filter_map ( | ( id , win_path ) | {
let root = validate_registered_path ( & win_path , " win_path " ) . ok ( ) ? ;
// 每个项目只读一次 context.json, 同时取 project_id 与 mcp_scope
let context =
read_context_object ( & root . join ( " .lian-jing " ) . join ( " context.json " ) , " canonical 项目上下文 " )
. ok ( ) ? ;
let context = read_context_object (
& root . join ( " .lian-jing " ) . join ( " context.json " ) ,
" canonical 项目上下文 " ,
)
. ok ( ) ? ;
if context . get ( " project_id " ) . and_then ( Value ::as_str ) ! = Some ( id . as_str ( ) ) {
return None ;
}
@ -609,7 +717,9 @@ fn dsh_project_roots(
return Some ( ( key , None ) ) ;
}
let has_token = crate ::credential_store ::mcp_auth_digest ( & id )
. and_then ( | digest | crate ::credential_store ::read_mcp_auth_token_for_digest ( store , & digest ) )
. and_then ( | digest | {
crate ::credential_store ::read_mcp_auth_token_for_digest ( store , & digest )
} )
. is_ok ( ) ;
has_token . then ( | | ( key , Some ( id ) ) )
} )
@ -619,7 +729,10 @@ fn dsh_project_roots(
fn render_dsh_block ( port : u16 , roots : & [ ( String , Option < String > ) ] ) -> String {
// 逐行显式缩进: YAML 依赖缩进,不能用字符串续行(会吞掉下一行行首空白)。
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 ( )
}
// 值为 null 的键是控制面项目的停止哨兵:命中即停,回退全局地址。
let roots_map : serde_json ::Map < String , Value > = roots
@ -637,12 +750,10 @@ fn render_dsh_block(port: u16, roots: &[(String, Option<String>)]) -> String {
" } " ,
] ;
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(base, 'lian-jing', 'bin', '{MCP_HEADER_SHIM_NAME}'); "
) ;
let url_return =
format! ( " return id ? ' {global_url} /projects/' + encodeURIComponent(id) : ' {global_url} '; " ) ;
let shim_line =
format! ( " const shim = path.join(base, 'lian-jing', 'bin', ' {MCP_HEADER_SHIM_NAME} '); " ) ;
let header_body = [
" if (!id) return {}; " ,
" const base = process.env.LOCALAPPDATA, sys = process.env.SystemRoot; " ,
@ -724,7 +835,10 @@ fn has_manual_lian_jing(outside_block: &str) -> Result<bool, String> {
/// 块外存在手写的 lian-jing 条目则拒绝覆盖(与 Codex 手工同名 server 的处理一致)。
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 = > {
let after = & current [ end + DSH_MANAGED_END . len ( ) .. ] ;
if has_manual_lian_jing ( & format! ( " {} {} " , & current [ .. start ] , after ) ) ? {
@ -766,7 +880,11 @@ pub fn ensure_dsh_patch(
} ;
let block = render_dsh_block (
port ,
& dsh_project_roots ( conn , & crate ::credential_store ::WindowsCredentialStore , exclude ) ,
& dsh_project_roots (
conn ,
& crate ::credential_store ::WindowsCredentialStore ,
exclude ,
) ,
) ;
let next = upsert_dsh_block ( & current , & block ) ? ;
if next ! = current {
@ -843,7 +961,12 @@ pub fn cleanup_legacy_global_registrations() -> Vec<String> {
. map ( | out | out . status . success ( ) )
. unwrap_or ( false ) ;
if ok {
db ::log_event ( " mcp_inject " , " info " , " 旧组级 MCP 注册已移除 " , Some ( & json! ( { " client " : " claude-global " , " server " : & name } ) . to_string ( ) ) ) ;
db ::log_event (
" mcp_inject " ,
" info " ,
" 旧组级 MCP 注册已移除 " ,
Some ( & json! ( { " client " : " claude-global " , " server " : & name } ) . to_string ( ) ) ,
) ;
removed . push ( name ) ;
}
}
@ -1114,8 +1237,10 @@ pub fn migrate_to_single_server() {
db ::log_event ( " mcp_inject " , level , & summary , None ) ;
// 全局注册失败说明 claude CLI 链路不通( CLI 缺失/git-bash 缺失/命令报错),
// 此时旧注册也清不掉——不落标记位,等下次启动重试(整个流程幂等)
if ! register_ok {
// 此时旧注册也清不掉——不落标记位,等下次启动重试(整个流程幂等)。
// try_register_globally 在已注册时会只读早返回,不再证明 CLI 可用,所以另外确认 CLI 与 git-bash 都在。
let cli_available = find_claude_bin ( ) . is_some ( ) & & find_git_bash ( ) . is_some ( ) ;
if ! register_ok | | ! cli_available {
return ;
}
if let Ok ( conn ) = db ::pool ( ) . get ( ) {
@ -1151,18 +1276,22 @@ pub fn reinject_all_mcp() -> Vec<String> {
// 快照取自锁外:锁内复查成员关系,已被移出组的项目不再重新注入(避免重建刚删除的 token)
let still_member = db ::pool ( )
. get ( )
. ok( )
. map_err( | error | error . to_string ( ) )
. and_then ( | conn | {
conn . query_row (
" SELECT EXISTS(SELECT 1 FROM project_group_map WHERE project_id = ?1) " ,
[ & project_id ] ,
| row | row . get ::< _ , bool > ( 0 ) ,
)
. ok ( )
} )
. unwrap_or ( false ) ;
if ! still_member {
continue ;
. map_err ( | error | error . to_string ( ) )
} ) ;
match still_member {
Ok ( true ) = > { }
Ok ( false ) = > continue ,
Err ( error ) = > {
errors . push ( format! ( " 项目 {project_id} : 复查组成员关系失败: {error} " ) ) ;
continue ;
}
}
if let Err ( e ) = inject_project_mcp_locked ( & project_id , false ) {
errors . push ( format! ( " 项目 {} : {} " , project_id , e ) ) ;
@ -1173,8 +1302,9 @@ pub fn reinject_all_mcp() -> Vec<String> {
errors
}
/// 项目根目录或平台变更后调用:清理旧根下炼境托管的 Claude local 条目(直接编辑配置,旧目录不存在也能清理);
/// 已接入(有 token) 的项目在新根重新注入( 会刷新 DSH 白名单),未接入的只刷新白名单。
/// 项目根目录或平台变更后调用:清理旧根下本项目的 Claude local 托管条目(旧目录已不存在时无法向 Claude
/// 确认实际条目,只记录残留与清理命令);已接入(有 token) 的项目在新根重新注入( 会刷新 DSH 白名单),
/// 未接入的只刷新白名单。新根的「已同步」判定只认新根或其祖先目录键下的条目,旧键下的残留不会挡住写入。
/// 重新注入会跑 claude CLI 做全局注册,调用方应放在后台线程。持锁执行:与并发的移除/删除串行,
/// 移除先完成则 token 已不在,这里就不会再注入。
pub fn on_project_root_changed ( project_id : & str , old_root : Option < PathBuf > ) {
@ -1336,9 +1466,15 @@ mod tests {
" @echo off \r \n \" C: \\ Apps \\ Lian Jing \\ app.exe \" --lianjing-mcp-header %1 \r \n exit /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 代码页 " ) ;
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 " ) , " 路径 % 须转义 " ) ;
assert! (
percent . contains ( " \" C: \\ Users \\ a%%b \\ app.exe \" --lianjing-mcp-header %1 " ) ,
" 路径 % 须转义 "
) ;
}
#[ test ]
@ -1351,7 +1487,13 @@ mod tests {
crate ::credential_store ::get_or_create_mcp_auth_token ( & store , id ) . unwrap ( ) ;
}
// notoken: 已绑定但没有 token( 例如退组后 token 已删),不得进入白名单
for ( id , scope , bound ) in [ ( " p1 " , None , true ) , ( " ctrl " , Some ( " global " ) , true ) , ( " drift " , None , false ) , ( " gone " , None , true ) , ( " notoken " , None , true ) ] {
for ( id , scope , bound ) in [
( " p1 " , None , true ) ,
( " ctrl " , Some ( " global " ) , true ) ,
( " drift " , None , false ) ,
( " gone " , None , true ) ,
( " notoken " , 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 } ) ;
@ -1384,9 +1526,17 @@ mod tests {
#[ test ]
fn dsh_root_keys_match_js_normalization ( ) {
assert_eq! ( normalize_dsh_root ( " E: \\ Code \\ Demo " ) , " e: \\ code \\ demo " ) ;
assert_eq! ( normalize_dsh_root ( " E:/Code//Demo/ " ) , " e: \\ code \\ demo " , " 正斜杠、重复与尾部分隔符 " ) ;
assert_eq! (
normalize_dsh_root ( " E:/Code//Demo/ " ) ,
" e: \\ code \\ demo " ,
" 正斜杠、重复与尾部分隔符 "
) ;
assert_eq! ( normalize_dsh_root ( " E: \\ " ) , " e: " , " 盘符根与 JS 去尾后一致 " ) ;
assert_eq! ( normalize_dsh_root ( " \\ \\ Server \\ Share \\ x " ) , " \\ \\ server \\ share \\ x " , " 保留 UNC 前缀 " ) ;
assert_eq! (
normalize_dsh_root ( " \\ \\ Server \\ Share \\ x " ) ,
" \\ \\ server \\ share \\ x " ,
" 保留 UNC 前缀 "
) ;
}
#[ test ]
@ -1397,7 +1547,10 @@ mod tests {
) ;
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% " ) ) ;
assert! ( entry [ " headersHelper " ]
. as_str ( )
. unwrap ( )
. contains ( " %LOCALAPPDATA% " ) ) ;
}
#[ test ]
@ -1435,14 +1588,95 @@ mod tests {
assert! ( managed_claude_entries ( & json! ( { } ) , " p 1 " ) . is_empty ( ) ) ;
}
#[ test ]
fn claude_mcp_get_output_is_parsed_by_effective_scope ( ) {
// 样本取自 claude 2.x 真实输出( 2026-10-01)
let local = " lian-jing: \n Scope: Local config (private to you in this project) \n Status: ✔ Connected \n Type: http \n URL: http://127.0.0.1:27190/mcp/projects/p%201 \n \n To remove this server, run: claude mcp remove \" lian-jing \" -s local \n " ;
assert_eq! (
parse_claude_mcp_get ( local ) ,
Some ( ClaudeEffective ::Local (
" http://127.0.0.1:27190/mcp/projects/p%201 " . into ( )
) )
) ;
let user = " lian-jing: \r \n Scope: User config (available in all your projects) \r \n Type: sse \r \n URL: http://127.0.0.1:27190/mcp \r \n " ;
assert_eq! ( parse_claude_mcp_get ( user ) , Some ( ClaudeEffective ::NotLocal ) ) ;
let missing = " No MCP server found with name: \" lian-jing \" . Configured servers: a, b " ;
assert_eq! (
parse_claude_mcp_get ( missing ) ,
Some ( ClaudeEffective ::NotLocal )
) ;
assert_eq! (
parse_claude_mcp_get ( " Error: spawn EPERM " ) ,
None ,
" 无法识别则按未知处理,不写入 "
) ;
assert_eq! (
parse_claude_mcp_get ( " lian-jing: \n Scope: Local config \n Type: stdio \n " ) ,
None ,
" local 条目缺 URL 时无法判定归属 "
) ;
}
#[ test ]
fn lianjing_project_routes_are_recognized_and_decoded ( ) {
assert_eq! (
lianjing_project_of ( " http://127.0.0.1:27190/mcp/projects/p%201 " ) ,
Some ( " p 1 " . into ( ) )
) ;
assert_eq! (
lianjing_project_of ( " http://127.0.0.1:1/mcp/projects/abc/ " ) ,
Some ( " abc " . into ( ) )
) ;
assert_eq! (
lianjing_project_of ( " http://127.0.0.1:27190/mcp " ) ,
None ,
" 全局路由不属于任何项目 "
) ;
assert_eq! (
lianjing_project_of ( " http://localhost:27190/mcp/projects/abc " ) ,
None ,
" 非炼境地址视为手写 "
) ;
assert_eq! (
lianjing_project_of ( " https://example.com/mcp/projects/abc " ) ,
None
) ;
}
#[ test ]
fn claude_key_covers_root_and_its_descendants_only ( ) {
assert! ( key_covers_root (
" E:/code-project/demo " ,
" E: \\ code-project \\ demo \\ "
) ) ;
assert! (
key_covers_root ( " e: \\ code-project " , " E: \\ Code-Project \\ demo " ) ,
" git 根是祖先目录 "
) ;
assert! (
! key_covers_root ( " E:/code-project/demo " , " E: \\ code-project \\ demo-2 " ) ,
" 前缀相同的兄弟目录不算 "
) ;
assert! (
! key_covers_root ( " E:/code-project/demo/sub " , " E: \\ code-project \\ demo " ) ,
" 子目录的键不覆盖父目录 "
) ;
}
#[ test ]
fn project_root_change_detection ( ) {
let old = PathBuf ::from ( " E: \\ old " ) ;
let new = PathBuf ::from ( " E: \\ new " ) ;
assert! ( project_root_changed ( Some ( & old ) , Some ( & new ) ) ) ;
assert! ( project_root_changed ( None , Some ( & new ) ) , " 旧根解析不出也要迁移 " ) ;
assert! (
project_root_changed ( None , Some ( & new ) ) ,
" 旧根解析不出也要迁移 "
) ;
assert! ( ! project_root_changed ( Some ( & old ) , Some ( & old ) ) ) ;
assert! ( ! project_root_changed ( Some ( & old ) , None ) , " 新根解析不出则不动 " ) ;
assert! (
! project_root_changed ( Some ( & old ) , None ) ,
" 新根解析不出则不动 "
) ;
}
#[ test ]
@ -1453,21 +1687,36 @@ mod tests {
] ;
let block = render_dsh_block ( 27190 , & roots ) ;
assert! (
block . contains ( r # "const roots = {"e:\\code-project":null,"e:\\code-project\\demo":"p1"};"# ) ,
block . contains (
r # "const roots = {"e:\\code-project":null,"e:\\code-project\\demo":"p1"};"#
) ,
" 控制面以 null 作停止哨兵 "
) ;
assert! ( block . contains ( " hasOwnProperty.call(roots, key) " ) & & block . contains ( r "replace(/\\+$/, '')" ) ) ;
let parsed : serde_yaml ::Value = serde_yaml ::from_str ( & block ) . expect ( " 托管块必须是合法 YAML " ) ;
assert! (
block . contains ( " hasOwnProperty.call(roots, key) " )
& & block . contains ( r "replace(/\\+$/, '')" )
) ;
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 ( " context.json " ) , " 身份只认登记白名单,不读目录里的 context.json " ) ;
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'; " ) , " 未命中回退全局地址 " ) ;
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 " ) ;
}
@ -1481,19 +1730,36 @@ mod tests {
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 , " 幂等 " ) ;
assert_eq! (
moved . matches ( DSH_MANAGED_START ) . count ( ) ,
1 ,
" 原位替换不重复追加 "
) ;
assert_eq! (
upsert_dsh_block ( & moved , & render_dsh_block ( 28000 , & [ ] ) ) . unwrap ( ) ,
moved ,
" 幂等 "
) ;
for manual in [
" - 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} " ) ;
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 ( & with_block , & render_dsh_block ( 27190 , & [ ] ) ) . is_err ( ) ,
" 块外手写也须拒绝 "
) ;
}
assert! ( upsert_dsh_block ( " - insert: [unclosed \n " , " b " ) . 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_eq! ( upsert_dsh_block ( " " , " BLOCK \n " ) . unwrap ( ) , " BLOCK \n " ) ;
}