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
+4 -2
View File
@@ -1339,8 +1339,10 @@ mod tests {
}
fn set_proxy_port(db: &Database, port: u16) {
let mut config = crate::proxy::types::ProxyConfig::default();
config.listen_port = port;
let config = crate::proxy::types::ProxyConfig {
listen_port: port,
..Default::default()
};
futures::executor::block_on(db.update_proxy_config(config)).expect("update proxy config");
}
+1 -43
View File
@@ -4,7 +4,7 @@ use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};
use crate::config::{atomic_write, get_claude_mcp_path};
use crate::error::AppError;
/// 需要在 Windows 上用 cmd /c 包装的命令
@@ -98,51 +98,9 @@ pub struct McpStatus {
}
fn user_config_path() -> PathBuf {
ensure_mcp_override_migrated();
get_claude_mcp_path()
}
fn ensure_mcp_override_migrated() {
if crate::settings::get_claude_override_dir().is_none() {
return;
}
let new_path = get_claude_mcp_path();
if new_path.exists() {
return;
}
let legacy_path = get_default_claude_mcp_path();
if !legacy_path.exists() {
return;
}
if let Some(parent) = new_path.parent() {
if let Err(err) = fs::create_dir_all(parent) {
log::warn!("创建 MCP 目录失败: {err}");
return;
}
}
match fs::copy(&legacy_path, &new_path) {
Ok(_) => {
log::info!(
"已根据覆盖目录复制 MCP 配置: {} -> {}",
legacy_path.display(),
new_path.display()
);
}
Err(err) => {
log::warn!(
"复制 MCP 配置失败: {} -> {}: {}",
legacy_path.display(),
new_path.display(),
err
);
}
}
}
fn read_json_value(path: &Path) -> Result<Value, AppError> {
if !path.exists() {
return Ok(serde_json::json!({}));
+159 -26
View File
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use crate::error::AppError;
@@ -47,25 +47,118 @@ pub fn get_default_claude_mcp_path() -> PathBuf {
get_home_dir().join(".claude.json")
}
fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
let file_name = dir
.file_name()
.map(|name| name.to_string_lossy().to_string())?
.trim()
.to_string();
if file_name.is_empty() {
return None;
fn normalize_path_lexically(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
if !normalized.pop() {
normalized.push(component.as_os_str());
}
}
Component::Normal(part) => normalized.push(part),
Component::RootDir | Component::Prefix(_) => normalized.push(component.as_os_str()),
}
}
let parent = dir.parent().unwrap_or_else(|| Path::new(""));
Some(parent.join(format!("{file_name}.json")))
normalized
}
/// 获取 Claude MCP 配置文件路径,若设置了目录覆盖则与覆盖目录同级
fn comparable_path_key(path: &Path) -> String {
let mut key = normalize_path_lexically(path).to_string_lossy().to_string();
#[cfg(windows)]
{
key = key.replace('\\', "/");
}
while key.len() > 1 && key.ends_with('/') {
key.pop();
}
#[cfg(windows)]
{
key.make_ascii_lowercase();
}
key
}
fn path_eq_lexical(left: &Path, right: &Path) -> bool {
comparable_path_key(left) == comparable_path_key(right)
}
#[cfg(windows)]
fn derive_wsl_default_mcp_path(dir: &Path) -> Option<PathBuf> {
use std::path::Prefix;
let normalized = normalize_path_lexically(dir);
let mut components = normalized.components();
let prefix = match components.next()? {
Component::Prefix(prefix) => prefix,
_ => return None,
};
let server = match prefix.kind() {
Prefix::UNC(server, _) | Prefix::VerbatimUNC(server, _) => server.to_string_lossy(),
_ => return None,
};
if !server.eq_ignore_ascii_case("wsl$") && !server.eq_ignore_ascii_case("wsl.localhost") {
return None;
}
let mut parts = Vec::new();
for component in components {
match component {
Component::RootDir | Component::CurDir => {}
Component::Normal(part) => parts.push(part.to_string_lossy().to_string()),
Component::ParentDir | Component::Prefix(_) => return None,
}
}
let is_wsl_home_default =
parts.len() == 3 && parts[0] == "home" && !parts[1].is_empty() && parts[2] == ".claude";
let is_wsl_root_default = parts.len() == 2 && parts[0] == "root" && parts[1] == ".claude";
if is_wsl_home_default || is_wsl_root_default {
return normalized
.parent()
.map(|parent| parent.join(".claude.json"));
}
None
}
fn default_mcp_path_for_config_dir(dir: &Path) -> Option<PathBuf> {
let default_config_dir = get_home_dir().join(".claude");
if path_eq_lexical(dir, &default_config_dir) {
return Some(get_default_claude_mcp_path());
}
#[cfg(windows)]
{
if let Some(path) = derive_wsl_default_mcp_path(dir) {
return Some(path);
}
}
None
}
fn derive_mcp_path_from_override(dir: &Path) -> PathBuf {
dir.join(".claude.json")
}
/// 获取 Claude MCP 配置文件路径
pub fn get_claude_mcp_path() -> PathBuf {
if let Some(custom_dir) = crate::settings::get_claude_override_dir() {
if let Some(path) = derive_mcp_path_from_override(&custom_dir) {
if let Some(path) = default_mcp_path_for_config_dir(&custom_dir) {
return path;
}
return derive_mcp_path_from_override(&custom_dir);
}
get_default_claude_mcp_path()
}
@@ -263,33 +356,73 @@ mod tests {
use super::*;
#[test]
fn derive_mcp_path_from_override_preserves_folder_name() {
fn derive_mcp_path_from_override_uses_config_dir_for_custom_path() {
let override_dir = PathBuf::from("/tmp/profile/.claude");
let derived = derive_mcp_path_from_override(&override_dir)
.expect("should derive path for nested dir");
assert_eq!(derived, PathBuf::from("/tmp/profile/.claude.json"));
let derived = derive_mcp_path_from_override(&override_dir);
assert_eq!(derived, PathBuf::from("/tmp/profile/.claude/.claude.json"));
}
#[test]
fn derive_mcp_path_from_override_handles_non_hidden_folder() {
fn derive_mcp_path_from_override_uses_config_dir_for_non_hidden_folder() {
let override_dir = PathBuf::from("/data/claude-config");
let derived = derive_mcp_path_from_override(&override_dir)
.expect("should derive path for standard dir");
assert_eq!(derived, PathBuf::from("/data/claude-config.json"));
let derived = derive_mcp_path_from_override(&override_dir);
assert_eq!(derived, PathBuf::from("/data/claude-config/.claude.json"));
}
#[test]
fn derive_mcp_path_from_override_supports_relative_rootless_dir() {
let override_dir = PathBuf::from("claude");
let derived = derive_mcp_path_from_override(&override_dir)
.expect("should derive path for single segment");
assert_eq!(derived, PathBuf::from("claude.json"));
let derived = derive_mcp_path_from_override(&override_dir);
assert_eq!(derived, PathBuf::from("claude/.claude.json"));
}
#[test]
fn derive_mcp_path_from_root_like_dir_returns_none() {
fn derive_mcp_path_from_root_like_dir_uses_root_file() {
let override_dir = PathBuf::from("/");
assert!(derive_mcp_path_from_override(&override_dir).is_none());
let derived = derive_mcp_path_from_override(&override_dir);
assert_eq!(derived, PathBuf::from("/.claude.json"));
}
#[test]
fn derive_mcp_path_from_override_preserves_leading_parent_dirs() {
let override_dir = PathBuf::from("../../profiles/work/.claude");
let derived = derive_mcp_path_from_override(&override_dir);
assert_eq!(derived, override_dir.join(".claude.json"));
}
#[cfg(windows)]
#[test]
fn wsl_unc_home_default_uses_split_mcp_path() {
let override_dir = PathBuf::from(r"\\wsl$\Ubuntu\home\travis\.claude");
let derived = default_mcp_path_for_config_dir(&override_dir)
.expect("WSL home default should use split MCP path");
assert_eq!(
derived,
PathBuf::from(r"\\wsl$\Ubuntu\home\travis\.claude.json")
);
}
#[cfg(windows)]
#[test]
fn wsl_unc_root_default_uses_split_mcp_path() {
let override_dir = PathBuf::from(r"\\wsl.localhost\Ubuntu\root\.claude");
let derived = default_mcp_path_for_config_dir(&override_dir)
.expect("WSL root default should use split MCP path");
assert_eq!(
derived,
PathBuf::from(r"\\wsl.localhost\Ubuntu\root\.claude.json")
);
}
#[cfg(windows)]
#[test]
fn wsl_unc_custom_dir_uses_nested_mcp_path() {
let override_dir = PathBuf::from(r"\\wsl$\Ubuntu\opt\claude\.claude");
assert!(default_mcp_path_for_config_dir(&override_dir).is_none());
assert_eq!(
derive_mcp_path_from_override(&override_dir),
PathBuf::from(r"\\wsl$\Ubuntu\opt\claude\.claude\.claude.json")
);
}
#[test]
+4 -2
View File
@@ -493,8 +493,10 @@ mod tests {
{
let conn = crate::database::lock_conn!(db.conn);
let date_str = chrono::DateTime::from_timestamp(old_ts, 0)
.unwrap()
let date_str = Local
.timestamp_opt(old_ts, 0)
.single()
.expect("old timestamp should be a valid local datetime")
.format("%Y-%m-%d")
.to_string();
conn.execute(
+3 -2
View File
@@ -463,6 +463,7 @@ base_url = "http://localhost:8080"
db.update_proxy_config(ProxyConfig {
live_takeover_active: true,
listen_port: 0,
..Default::default()
})
.await
@@ -491,7 +492,7 @@ base_url = "http://localhost:8080"
)
.expect("seed taken-over live file");
state
let proxy_info = state
.proxy_service
.start()
.await
@@ -544,7 +545,7 @@ base_url = "http://localhost:8080"
live.get("env")
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
.and_then(|v| v.as_str()),
Some("http://127.0.0.1:15721"),
Some(format!("http://127.0.0.1:{}", proxy_info.port).as_str()),
"proxy base URL should stay intact"
);
assert!(
+8 -4
View File
@@ -5444,8 +5444,10 @@ command = "shared-command"
)
.expect("set common config snippet");
let mut proxy_config = ProxyConfig::default();
proxy_config.listen_port = 0;
let proxy_config = ProxyConfig {
listen_port: 0,
..Default::default()
};
db.update_proxy_config(proxy_config)
.await
.expect("set test proxy config");
@@ -5582,8 +5584,10 @@ requires_openai_auth = true
let db = Arc::new(Database::memory().expect("init db"));
let state = crate::store::AppState::new(db.clone());
let mut proxy_config = ProxyConfig::default();
proxy_config.listen_port = 0;
let proxy_config = ProxyConfig {
listen_port: 0,
..Default::default()
};
db.update_proxy_config(proxy_config)
.await
.expect("set test proxy config");