fix: align Claude MCP path for custom config dirs (#3431)

* fix claude mcp path for custom config dir

* fix claude mcp custom profile isolation

* fix claude mcp override path edges

* fix ci test isolation
This commit is contained in:
makoMakoGo
2026-06-23 09:00:56 +08:00
committed by GitHub
parent 895d7af3eb
commit 2d478876fa
13 changed files with 470 additions and 86 deletions
+271 -2
View File
@@ -4,8 +4,9 @@ use std::fs;
use serde_json::json;
use cc_switch_lib::{
get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError,
AppType, McpApps, McpServer, McpService, MultiAppConfig,
get_claude_mcp_path, get_claude_mcp_status, get_claude_settings_path,
import_default_config_test_hook, read_claude_mcp_config, update_settings, AppError,
AppSettings, AppType, McpApps, McpServer, McpService, MultiAppConfig,
};
#[path = "support.rs"]
@@ -673,6 +674,274 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() {
);
}
#[test]
fn explicit_default_claude_dir_keeps_default_split_mcp_path() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let claude_dir = home.join(".claude");
fs::create_dir_all(&claude_dir).expect("create explicit default claude dir");
update_settings(AppSettings {
claude_config_dir: Some(claude_dir.to_string_lossy().to_string()),
..AppSettings::default()
})
.expect("set explicit default claude config dir");
assert_eq!(
get_claude_mcp_path(),
home.join(".claude.json"),
"explicit default Claude dir should keep Claude Code's split MCP path"
);
let state = create_test_state().expect("create test state");
McpService::upsert_server(
&state,
McpServer {
id: "claude-default".to_string(),
name: "Claude Default".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: true,
codex: false,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("sync default Claude MCP");
assert!(
home.join(".claude.json").exists(),
"default split MCP file should be written at home/.claude.json"
);
assert!(
!claude_dir.join(".claude.json").exists(),
"explicit default dir should not use nested .claude/.claude.json"
);
}
#[test]
fn custom_claude_dir_writes_mcp_inside_config_dir() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let custom_dir = home.join("profiles").join(".claude");
fs::create_dir_all(&custom_dir).expect("create custom claude dir");
update_settings(AppSettings {
claude_config_dir: Some(custom_dir.to_string_lossy().to_string()),
..AppSettings::default()
})
.expect("set custom claude config dir");
let expected_mcp_path = custom_dir.join(".claude.json");
assert_eq!(
get_claude_mcp_path(),
expected_mcp_path,
"custom Claude dir should keep MCP state inside the config dir"
);
let state = create_test_state().expect("create test state");
McpService::upsert_server(
&state,
McpServer {
id: "claude-custom".to_string(),
name: "Claude Custom".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: true,
codex: false,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("sync custom Claude MCP");
assert!(
expected_mcp_path.exists(),
"custom Claude MCP file should be written inside custom dir"
);
assert!(
!home.join("profiles").join(".claude.json").exists(),
"custom Claude dir should not write sibling .claude.json"
);
}
#[test]
fn custom_claude_dir_sync_does_not_copy_default_profile() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let home_mcp_path = home.join(".claude.json");
let default_profile = json!({
"hasCompletedOnboarding": true,
"projects": {
"/home-project": {
"hasTrustDialogAccepted": true
}
},
"mcpServers": {
"home-only": {
"type": "stdio",
"command": "home-command"
}
},
"profileSentinel": "home-profile"
});
let default_profile_text =
serde_json::to_string_pretty(&default_profile).expect("serialize default profile");
fs::write(&home_mcp_path, &default_profile_text).expect("seed default Claude profile");
let custom_dir = home.join("profiles").join("work").join(".claude");
fs::create_dir_all(&custom_dir).expect("create custom claude dir");
update_settings(AppSettings {
claude_config_dir: Some(custom_dir.to_string_lossy().to_string()),
..AppSettings::default()
})
.expect("set custom claude config dir");
let expected_mcp_path = custom_dir.join(".claude.json");
assert_eq!(
get_claude_mcp_path(),
expected_mcp_path,
"custom Claude dir should use nested .claude.json"
);
assert!(
!expected_mcp_path.exists(),
"custom profile should start without a live MCP file"
);
let state = create_test_state().expect("create test state");
McpService::upsert_server(
&state,
McpServer {
id: "custom-only".to_string(),
name: "Custom Only".to_string(),
server: json!({
"type": "stdio",
"command": "custom-command"
}),
apps: McpApps {
claude: true,
codex: false,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("sync custom Claude MCP");
let text = fs::read_to_string(&expected_mcp_path).expect("read custom Claude MCP");
let value: serde_json::Value = serde_json::from_str(&text).expect("parse custom Claude MCP");
let servers = value
.get("mcpServers")
.and_then(|v| v.as_object())
.expect("custom profile should contain mcpServers");
assert!(
servers.contains_key("custom-only"),
"custom profile should contain DB-managed Claude server"
);
assert!(
!servers.contains_key("home-only"),
"custom profile should not inherit default profile MCP servers"
);
assert!(
value.get("hasCompletedOnboarding").is_none(),
"custom profile should not inherit onboarding state"
);
assert!(
value.get("projects").is_none(),
"custom profile should not inherit project trust state"
);
assert!(
value.get("profileSentinel").is_none(),
"custom profile should not inherit unrelated default profile fields"
);
assert_eq!(
fs::read_to_string(&home_mcp_path).expect("reread default Claude profile"),
default_profile_text,
"default Claude profile should remain unchanged"
);
}
#[test]
fn custom_claude_dir_read_only_mcp_queries_do_not_create_profile() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let home_mcp_path = home.join(".claude.json");
fs::write(
&home_mcp_path,
serde_json::to_string_pretty(&json!({
"mcpServers": {
"home-only": {
"type": "stdio",
"command": "home-command"
}
},
"profileSentinel": "home-profile"
}))
.expect("serialize default profile"),
)
.expect("seed default Claude profile");
let custom_dir = home.join("profiles").join("work").join(".claude");
fs::create_dir_all(&custom_dir).expect("create custom claude dir");
update_settings(AppSettings {
claude_config_dir: Some(custom_dir.to_string_lossy().to_string()),
..AppSettings::default()
})
.expect("set custom claude config dir");
let expected_mcp_path = custom_dir.join(".claude.json");
assert!(
!expected_mcp_path.exists(),
"custom profile should start without a live MCP file"
);
let status =
futures::executor::block_on(get_claude_mcp_status()).expect("get Claude MCP status");
assert_eq!(
status.user_config_path,
expected_mcp_path.to_string_lossy(),
"status should report the custom profile MCP path"
);
assert!(
!status.user_config_exists,
"status should report missing custom profile MCP file"
);
let text =
futures::executor::block_on(read_claude_mcp_config()).expect("read Claude MCP config");
assert_eq!(text, None, "missing custom profile should read as None");
assert!(
!expected_mcp_path.exists(),
"read-only MCP queries should not copy or create the custom profile"
);
}
#[test]
fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries() {
let _guard = test_mutex().lock().expect("acquire test mutex");
+15 -1
View File
@@ -597,6 +597,14 @@ wire_api = "responses"
let state = create_test_state_with_config(&initial_config).expect("create test state");
let mut proxy_config = state.db.get_proxy_config().await.expect("get proxy config");
proxy_config.listen_port = 0;
state
.db
.update_proxy_config(proxy_config)
.await
.expect("use ephemeral proxy port");
ProviderService::switch(&state, AppType::Codex, "deepseek-provider")
.expect("switch from official subscription to DeepSeek");
@@ -623,6 +631,12 @@ wire_api = "responses"
.set_takeover_for_app("codex", true)
.await
.expect("enable Codex takeover");
let proxy_status = state
.proxy_service
.get_status()
.await
.expect("read proxy status after takeover");
let codex_proxy_base_url = format!("http://127.0.0.1:{}/v1", proxy_status.port);
let auth_after_takeover: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth after takeover");
@@ -634,7 +648,7 @@ wire_api = "responses"
let config_after_takeover =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config");
assert!(
config_after_takeover.contains("http://127.0.0.1:15721/v1"),
config_after_takeover.contains(&codex_proxy_base_url),
"enabling takeover should point Codex config.toml at the local proxy"
);
assert!(
+1
View File
@@ -33,6 +33,7 @@ pub fn reset_test_fs() {
".gemini",
".config",
".openclaw",
"profiles",
] {
let path = home.join(sub);
if path.exists() {