@ -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 \n exit /b %ERRORLEVEL% \r \n " )
format! ( " @echo off \r \n {codepage} \" {exe} \" --lianjing-mcp-header %1 \r \n exit /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 = p ath.resolve(p rocess.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 = & current [ end + DSH_MANAGED_END . len ( ) .. ] ;
& current [ .. start ] ,
if has_manual_lian_jing ( & format! ( " {} {} " , & current [ .. start ] , after ) ) ? {
block . trim_end_matches ( '\n' ) ,
return Err ( MANUAL . into ( ) ) ;
& current [ end + DSH_MANAGED_END . len ( ) .. ]
}
) ) ,
Ok ( format! (
( Some ( _ ) , _ ) | ( _ , Some ( _ ) ) = > Err ( " DSH 补丁文件中的炼境托管块不完整或顺序错误 " . into ( ) ) ,
" {}{}{} " ,
( None , None )
& current [ .. 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 ( & current , & render_dsh_block ( port ) ) ? ;
let block = render_dsh_block ( port , & dsh_project_roots ( conn , exclude ) ) ;
let next = upsert_dsh_block ( & current , & 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 " ) ;
}
}