config.rs 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296
  1. use std::collections::BTreeMap;
  2. use std::fmt::{Display, Formatter};
  3. use std::fs;
  4. use std::path::{Path, PathBuf};
  5. use crate::json::JsonValue;
  6. use crate::sandbox::{FilesystemIsolationMode, SandboxConfig};
  7. pub const CLAUDE_CODE_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
  8. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
  9. pub enum ConfigSource {
  10. User,
  11. Project,
  12. Local,
  13. }
  14. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  15. pub enum ResolvedPermissionMode {
  16. ReadOnly,
  17. WorkspaceWrite,
  18. DangerFullAccess,
  19. }
  20. #[derive(Debug, Clone, PartialEq, Eq)]
  21. pub struct ConfigEntry {
  22. pub source: ConfigSource,
  23. pub path: PathBuf,
  24. }
  25. #[derive(Debug, Clone, PartialEq, Eq)]
  26. pub struct RuntimeConfig {
  27. merged: BTreeMap<String, JsonValue>,
  28. loaded_entries: Vec<ConfigEntry>,
  29. feature_config: RuntimeFeatureConfig,
  30. }
  31. #[derive(Debug, Clone, PartialEq, Eq, Default)]
  32. pub struct RuntimePluginConfig {
  33. enabled_plugins: BTreeMap<String, bool>,
  34. external_directories: Vec<String>,
  35. install_root: Option<String>,
  36. registry_path: Option<String>,
  37. bundled_root: Option<String>,
  38. }
  39. #[derive(Debug, Clone, PartialEq, Eq, Default)]
  40. pub struct RuntimeFeatureConfig {
  41. hooks: RuntimeHookConfig,
  42. plugins: RuntimePluginConfig,
  43. mcp: McpConfigCollection,
  44. oauth: Option<OAuthConfig>,
  45. model: Option<String>,
  46. permission_mode: Option<ResolvedPermissionMode>,
  47. sandbox: SandboxConfig,
  48. }
  49. #[derive(Debug, Clone, PartialEq, Eq, Default)]
  50. pub struct RuntimeHookConfig {
  51. pre_tool_use: Vec<String>,
  52. post_tool_use: Vec<String>,
  53. }
  54. #[derive(Debug, Clone, PartialEq, Eq, Default)]
  55. pub struct McpConfigCollection {
  56. servers: BTreeMap<String, ScopedMcpServerConfig>,
  57. }
  58. #[derive(Debug, Clone, PartialEq, Eq)]
  59. pub struct ScopedMcpServerConfig {
  60. pub scope: ConfigSource,
  61. pub config: McpServerConfig,
  62. }
  63. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  64. pub enum McpTransport {
  65. Stdio,
  66. Sse,
  67. Http,
  68. Ws,
  69. Sdk,
  70. ClaudeAiProxy,
  71. }
  72. #[derive(Debug, Clone, PartialEq, Eq)]
  73. pub enum McpServerConfig {
  74. Stdio(McpStdioServerConfig),
  75. Sse(McpRemoteServerConfig),
  76. Http(McpRemoteServerConfig),
  77. Ws(McpWebSocketServerConfig),
  78. Sdk(McpSdkServerConfig),
  79. ClaudeAiProxy(McpClaudeAiProxyServerConfig),
  80. }
  81. #[derive(Debug, Clone, PartialEq, Eq)]
  82. pub struct McpStdioServerConfig {
  83. pub command: String,
  84. pub args: Vec<String>,
  85. pub env: BTreeMap<String, String>,
  86. }
  87. #[derive(Debug, Clone, PartialEq, Eq)]
  88. pub struct McpRemoteServerConfig {
  89. pub url: String,
  90. pub headers: BTreeMap<String, String>,
  91. pub headers_helper: Option<String>,
  92. pub oauth: Option<McpOAuthConfig>,
  93. }
  94. #[derive(Debug, Clone, PartialEq, Eq)]
  95. pub struct McpWebSocketServerConfig {
  96. pub url: String,
  97. pub headers: BTreeMap<String, String>,
  98. pub headers_helper: Option<String>,
  99. }
  100. #[derive(Debug, Clone, PartialEq, Eq)]
  101. pub struct McpSdkServerConfig {
  102. pub name: String,
  103. }
  104. #[derive(Debug, Clone, PartialEq, Eq)]
  105. pub struct McpClaudeAiProxyServerConfig {
  106. pub url: String,
  107. pub id: String,
  108. }
  109. #[derive(Debug, Clone, PartialEq, Eq)]
  110. pub struct McpOAuthConfig {
  111. pub client_id: Option<String>,
  112. pub callback_port: Option<u16>,
  113. pub auth_server_metadata_url: Option<String>,
  114. pub xaa: Option<bool>,
  115. }
  116. #[derive(Debug, Clone, PartialEq, Eq)]
  117. pub struct OAuthConfig {
  118. pub client_id: String,
  119. pub authorize_url: String,
  120. pub token_url: String,
  121. pub callback_port: Option<u16>,
  122. pub manual_redirect_url: Option<String>,
  123. pub scopes: Vec<String>,
  124. }
  125. #[derive(Debug)]
  126. pub enum ConfigError {
  127. Io(std::io::Error),
  128. Parse(String),
  129. }
  130. impl Display for ConfigError {
  131. fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
  132. match self {
  133. Self::Io(error) => write!(f, "{error}"),
  134. Self::Parse(error) => write!(f, "{error}"),
  135. }
  136. }
  137. }
  138. impl std::error::Error for ConfigError {}
  139. impl From<std::io::Error> for ConfigError {
  140. fn from(value: std::io::Error) -> Self {
  141. Self::Io(value)
  142. }
  143. }
  144. #[derive(Debug, Clone, PartialEq, Eq)]
  145. pub struct ConfigLoader {
  146. cwd: PathBuf,
  147. config_home: PathBuf,
  148. }
  149. impl ConfigLoader {
  150. #[must_use]
  151. pub fn new(cwd: impl Into<PathBuf>, config_home: impl Into<PathBuf>) -> Self {
  152. Self {
  153. cwd: cwd.into(),
  154. config_home: config_home.into(),
  155. }
  156. }
  157. #[must_use]
  158. pub fn default_for(cwd: impl Into<PathBuf>) -> Self {
  159. let cwd = cwd.into();
  160. let config_home = default_config_home();
  161. Self { cwd, config_home }
  162. }
  163. #[must_use]
  164. pub fn config_home(&self) -> &Path {
  165. &self.config_home
  166. }
  167. #[must_use]
  168. pub fn discover(&self) -> Vec<ConfigEntry> {
  169. let user_legacy_path = self.config_home.parent().map_or_else(
  170. || PathBuf::from(".claude.json"),
  171. |parent| parent.join(".claude.json"),
  172. );
  173. vec![
  174. ConfigEntry {
  175. source: ConfigSource::User,
  176. path: user_legacy_path,
  177. },
  178. ConfigEntry {
  179. source: ConfigSource::User,
  180. path: self.config_home.join("settings.json"),
  181. },
  182. ConfigEntry {
  183. source: ConfigSource::Project,
  184. path: self.cwd.join(".claude.json"),
  185. },
  186. ConfigEntry {
  187. source: ConfigSource::Project,
  188. path: self.cwd.join(".claude").join("settings.json"),
  189. },
  190. ConfigEntry {
  191. source: ConfigSource::Local,
  192. path: self.cwd.join(".claude").join("settings.local.json"),
  193. },
  194. ]
  195. }
  196. pub fn load(&self) -> Result<RuntimeConfig, ConfigError> {
  197. let mut merged = BTreeMap::new();
  198. let mut loaded_entries = Vec::new();
  199. let mut mcp_servers = BTreeMap::new();
  200. for entry in self.discover() {
  201. let Some(value) = read_optional_json_object(&entry.path)? else {
  202. continue;
  203. };
  204. merge_mcp_servers(&mut mcp_servers, entry.source, &value, &entry.path)?;
  205. deep_merge_objects(&mut merged, &value);
  206. loaded_entries.push(entry);
  207. }
  208. let merged_value = JsonValue::Object(merged.clone());
  209. let feature_config = RuntimeFeatureConfig {
  210. hooks: parse_optional_hooks_config(&merged_value)?,
  211. plugins: parse_optional_plugin_config(&merged_value)?,
  212. mcp: McpConfigCollection {
  213. servers: mcp_servers,
  214. },
  215. oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
  216. model: parse_optional_model(&merged_value),
  217. permission_mode: parse_optional_permission_mode(&merged_value)?,
  218. sandbox: parse_optional_sandbox_config(&merged_value)?,
  219. };
  220. Ok(RuntimeConfig {
  221. merged,
  222. loaded_entries,
  223. feature_config,
  224. })
  225. }
  226. }
  227. impl RuntimeConfig {
  228. #[must_use]
  229. pub fn empty() -> Self {
  230. Self {
  231. merged: BTreeMap::new(),
  232. loaded_entries: Vec::new(),
  233. feature_config: RuntimeFeatureConfig::default(),
  234. }
  235. }
  236. #[must_use]
  237. pub fn merged(&self) -> &BTreeMap<String, JsonValue> {
  238. &self.merged
  239. }
  240. #[must_use]
  241. pub fn loaded_entries(&self) -> &[ConfigEntry] {
  242. &self.loaded_entries
  243. }
  244. #[must_use]
  245. pub fn get(&self, key: &str) -> Option<&JsonValue> {
  246. self.merged.get(key)
  247. }
  248. #[must_use]
  249. pub fn as_json(&self) -> JsonValue {
  250. JsonValue::Object(self.merged.clone())
  251. }
  252. #[must_use]
  253. pub fn feature_config(&self) -> &RuntimeFeatureConfig {
  254. &self.feature_config
  255. }
  256. #[must_use]
  257. pub fn mcp(&self) -> &McpConfigCollection {
  258. &self.feature_config.mcp
  259. }
  260. #[must_use]
  261. pub fn hooks(&self) -> &RuntimeHookConfig {
  262. &self.feature_config.hooks
  263. }
  264. #[must_use]
  265. pub fn plugins(&self) -> &RuntimePluginConfig {
  266. &self.feature_config.plugins
  267. }
  268. #[must_use]
  269. pub fn oauth(&self) -> Option<&OAuthConfig> {
  270. self.feature_config.oauth.as_ref()
  271. }
  272. #[must_use]
  273. pub fn model(&self) -> Option<&str> {
  274. self.feature_config.model.as_deref()
  275. }
  276. #[must_use]
  277. pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
  278. self.feature_config.permission_mode
  279. }
  280. #[must_use]
  281. pub fn sandbox(&self) -> &SandboxConfig {
  282. &self.feature_config.sandbox
  283. }
  284. }
  285. impl RuntimeFeatureConfig {
  286. #[must_use]
  287. pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self {
  288. self.hooks = hooks;
  289. self
  290. }
  291. #[must_use]
  292. pub fn with_plugins(mut self, plugins: RuntimePluginConfig) -> Self {
  293. self.plugins = plugins;
  294. self
  295. }
  296. #[must_use]
  297. pub fn hooks(&self) -> &RuntimeHookConfig {
  298. &self.hooks
  299. }
  300. #[must_use]
  301. pub fn plugins(&self) -> &RuntimePluginConfig {
  302. &self.plugins
  303. }
  304. #[must_use]
  305. pub fn mcp(&self) -> &McpConfigCollection {
  306. &self.mcp
  307. }
  308. #[must_use]
  309. pub fn oauth(&self) -> Option<&OAuthConfig> {
  310. self.oauth.as_ref()
  311. }
  312. #[must_use]
  313. pub fn model(&self) -> Option<&str> {
  314. self.model.as_deref()
  315. }
  316. #[must_use]
  317. pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
  318. self.permission_mode
  319. }
  320. #[must_use]
  321. pub fn sandbox(&self) -> &SandboxConfig {
  322. &self.sandbox
  323. }
  324. }
  325. impl RuntimePluginConfig {
  326. #[must_use]
  327. pub fn enabled_plugins(&self) -> &BTreeMap<String, bool> {
  328. &self.enabled_plugins
  329. }
  330. #[must_use]
  331. pub fn external_directories(&self) -> &[String] {
  332. &self.external_directories
  333. }
  334. #[must_use]
  335. pub fn install_root(&self) -> Option<&str> {
  336. self.install_root.as_deref()
  337. }
  338. #[must_use]
  339. pub fn registry_path(&self) -> Option<&str> {
  340. self.registry_path.as_deref()
  341. }
  342. #[must_use]
  343. pub fn bundled_root(&self) -> Option<&str> {
  344. self.bundled_root.as_deref()
  345. }
  346. pub fn set_plugin_state(&mut self, plugin_id: String, enabled: bool) {
  347. self.enabled_plugins.insert(plugin_id, enabled);
  348. }
  349. #[must_use]
  350. pub fn state_for(&self, plugin_id: &str, default_enabled: bool) -> bool {
  351. self.enabled_plugins
  352. .get(plugin_id)
  353. .copied()
  354. .unwrap_or(default_enabled)
  355. }
  356. }
  357. #[must_use]
  358. pub fn default_config_home() -> PathBuf {
  359. std::env::var_os("CLAUDE_CONFIG_HOME")
  360. .map(PathBuf::from)
  361. .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude")))
  362. .unwrap_or_else(|| PathBuf::from(".claude"))
  363. }
  364. impl RuntimeHookConfig {
  365. #[must_use]
  366. pub fn new(pre_tool_use: Vec<String>, post_tool_use: Vec<String>) -> Self {
  367. Self {
  368. pre_tool_use,
  369. post_tool_use,
  370. }
  371. }
  372. #[must_use]
  373. pub fn pre_tool_use(&self) -> &[String] {
  374. &self.pre_tool_use
  375. }
  376. #[must_use]
  377. pub fn post_tool_use(&self) -> &[String] {
  378. &self.post_tool_use
  379. }
  380. #[must_use]
  381. pub fn merged(&self, other: &Self) -> Self {
  382. let mut merged = self.clone();
  383. merged.extend(other);
  384. merged
  385. }
  386. pub fn extend(&mut self, other: &Self) {
  387. extend_unique(&mut self.pre_tool_use, other.pre_tool_use());
  388. extend_unique(&mut self.post_tool_use, other.post_tool_use());
  389. }
  390. }
  391. impl McpConfigCollection {
  392. #[must_use]
  393. pub fn servers(&self) -> &BTreeMap<String, ScopedMcpServerConfig> {
  394. &self.servers
  395. }
  396. #[must_use]
  397. pub fn get(&self, name: &str) -> Option<&ScopedMcpServerConfig> {
  398. self.servers.get(name)
  399. }
  400. }
  401. impl ScopedMcpServerConfig {
  402. #[must_use]
  403. pub fn transport(&self) -> McpTransport {
  404. self.config.transport()
  405. }
  406. }
  407. impl McpServerConfig {
  408. #[must_use]
  409. pub fn transport(&self) -> McpTransport {
  410. match self {
  411. Self::Stdio(_) => McpTransport::Stdio,
  412. Self::Sse(_) => McpTransport::Sse,
  413. Self::Http(_) => McpTransport::Http,
  414. Self::Ws(_) => McpTransport::Ws,
  415. Self::Sdk(_) => McpTransport::Sdk,
  416. Self::ClaudeAiProxy(_) => McpTransport::ClaudeAiProxy,
  417. }
  418. }
  419. }
  420. fn read_optional_json_object(
  421. path: &Path,
  422. ) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> {
  423. let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claude.json");
  424. let contents = match fs::read_to_string(path) {
  425. Ok(contents) => contents,
  426. Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
  427. Err(error) => return Err(ConfigError::Io(error)),
  428. };
  429. if contents.trim().is_empty() {
  430. return Ok(Some(BTreeMap::new()));
  431. }
  432. let parsed = match JsonValue::parse(&contents) {
  433. Ok(parsed) => parsed,
  434. Err(error) if is_legacy_config => return Ok(None),
  435. Err(error) => return Err(ConfigError::Parse(format!("{}: {error}", path.display()))),
  436. };
  437. let Some(object) = parsed.as_object() else {
  438. if is_legacy_config {
  439. return Ok(None);
  440. }
  441. return Err(ConfigError::Parse(format!(
  442. "{}: top-level settings value must be a JSON object",
  443. path.display()
  444. )));
  445. };
  446. Ok(Some(object.clone()))
  447. }
  448. fn merge_mcp_servers(
  449. target: &mut BTreeMap<String, ScopedMcpServerConfig>,
  450. source: ConfigSource,
  451. root: &BTreeMap<String, JsonValue>,
  452. path: &Path,
  453. ) -> Result<(), ConfigError> {
  454. let Some(mcp_servers) = root.get("mcpServers") else {
  455. return Ok(());
  456. };
  457. let servers = expect_object(mcp_servers, &format!("{}: mcpServers", path.display()))?;
  458. for (name, value) in servers {
  459. let parsed = parse_mcp_server_config(
  460. name,
  461. value,
  462. &format!("{}: mcpServers.{name}", path.display()),
  463. )?;
  464. target.insert(
  465. name.clone(),
  466. ScopedMcpServerConfig {
  467. scope: source,
  468. config: parsed,
  469. },
  470. );
  471. }
  472. Ok(())
  473. }
  474. fn parse_optional_model(root: &JsonValue) -> Option<String> {
  475. root.as_object()
  476. .and_then(|object| object.get("model"))
  477. .and_then(JsonValue::as_str)
  478. .map(ToOwned::to_owned)
  479. }
  480. fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, ConfigError> {
  481. let Some(object) = root.as_object() else {
  482. return Ok(RuntimeHookConfig::default());
  483. };
  484. let Some(hooks_value) = object.get("hooks") else {
  485. return Ok(RuntimeHookConfig::default());
  486. };
  487. let hooks = expect_object(hooks_value, "merged settings.hooks")?;
  488. Ok(RuntimeHookConfig {
  489. pre_tool_use: optional_string_array(hooks, "PreToolUse", "merged settings.hooks")?
  490. .unwrap_or_default(),
  491. post_tool_use: optional_string_array(hooks, "PostToolUse", "merged settings.hooks")?
  492. .unwrap_or_default(),
  493. })
  494. }
  495. fn parse_optional_plugin_config(root: &JsonValue) -> Result<RuntimePluginConfig, ConfigError> {
  496. let Some(object) = root.as_object() else {
  497. return Ok(RuntimePluginConfig::default());
  498. };
  499. let mut config = RuntimePluginConfig::default();
  500. if let Some(enabled_plugins) = object.get("enabledPlugins") {
  501. config.enabled_plugins = parse_bool_map(enabled_plugins, "merged settings.enabledPlugins")?;
  502. }
  503. let Some(plugins_value) = object.get("plugins") else {
  504. return Ok(config);
  505. };
  506. let plugins = expect_object(plugins_value, "merged settings.plugins")?;
  507. if let Some(enabled_value) = plugins.get("enabled") {
  508. config.enabled_plugins = parse_bool_map(enabled_value, "merged settings.plugins.enabled")?;
  509. }
  510. config.external_directories =
  511. optional_string_array(plugins, "externalDirectories", "merged settings.plugins")?
  512. .unwrap_or_default();
  513. config.install_root =
  514. optional_string(plugins, "installRoot", "merged settings.plugins")?.map(str::to_string);
  515. config.registry_path =
  516. optional_string(plugins, "registryPath", "merged settings.plugins")?.map(str::to_string);
  517. config.bundled_root =
  518. optional_string(plugins, "bundledRoot", "merged settings.plugins")?.map(str::to_string);
  519. Ok(config)
  520. }
  521. fn parse_optional_permission_mode(
  522. root: &JsonValue,
  523. ) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
  524. let Some(object) = root.as_object() else {
  525. return Ok(None);
  526. };
  527. if let Some(mode) = object.get("permissionMode").and_then(JsonValue::as_str) {
  528. return parse_permission_mode_label(mode, "merged settings.permissionMode").map(Some);
  529. }
  530. let Some(mode) = object
  531. .get("permissions")
  532. .and_then(JsonValue::as_object)
  533. .and_then(|permissions| permissions.get("defaultMode"))
  534. .and_then(JsonValue::as_str)
  535. else {
  536. return Ok(None);
  537. };
  538. parse_permission_mode_label(mode, "merged settings.permissions.defaultMode").map(Some)
  539. }
  540. fn parse_permission_mode_label(
  541. mode: &str,
  542. context: &str,
  543. ) -> Result<ResolvedPermissionMode, ConfigError> {
  544. match mode {
  545. "default" | "plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly),
  546. "acceptEdits" | "auto" | "workspace-write" => Ok(ResolvedPermissionMode::WorkspaceWrite),
  547. "dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess),
  548. other => Err(ConfigError::Parse(format!(
  549. "{context}: unsupported permission mode {other}"
  550. ))),
  551. }
  552. }
  553. fn parse_optional_sandbox_config(root: &JsonValue) -> Result<SandboxConfig, ConfigError> {
  554. let Some(object) = root.as_object() else {
  555. return Ok(SandboxConfig::default());
  556. };
  557. let Some(sandbox_value) = object.get("sandbox") else {
  558. return Ok(SandboxConfig::default());
  559. };
  560. let sandbox = expect_object(sandbox_value, "merged settings.sandbox")?;
  561. let filesystem_mode = optional_string(sandbox, "filesystemMode", "merged settings.sandbox")?
  562. .map(parse_filesystem_mode_label)
  563. .transpose()?;
  564. Ok(SandboxConfig {
  565. enabled: optional_bool(sandbox, "enabled", "merged settings.sandbox")?,
  566. namespace_restrictions: optional_bool(
  567. sandbox,
  568. "namespaceRestrictions",
  569. "merged settings.sandbox",
  570. )?,
  571. network_isolation: optional_bool(sandbox, "networkIsolation", "merged settings.sandbox")?,
  572. filesystem_mode,
  573. allowed_mounts: optional_string_array(sandbox, "allowedMounts", "merged settings.sandbox")?
  574. .unwrap_or_default(),
  575. })
  576. }
  577. fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
  578. match value {
  579. "off" => Ok(FilesystemIsolationMode::Off),
  580. "workspace-only" => Ok(FilesystemIsolationMode::WorkspaceOnly),
  581. "allow-list" => Ok(FilesystemIsolationMode::AllowList),
  582. other => Err(ConfigError::Parse(format!(
  583. "merged settings.sandbox.filesystemMode: unsupported filesystem mode {other}"
  584. ))),
  585. }
  586. }
  587. fn parse_optional_oauth_config(
  588. root: &JsonValue,
  589. context: &str,
  590. ) -> Result<Option<OAuthConfig>, ConfigError> {
  591. let Some(oauth_value) = root.as_object().and_then(|object| object.get("oauth")) else {
  592. return Ok(None);
  593. };
  594. let object = expect_object(oauth_value, context)?;
  595. let client_id = expect_string(object, "clientId", context)?.to_string();
  596. let authorize_url = expect_string(object, "authorizeUrl", context)?.to_string();
  597. let token_url = expect_string(object, "tokenUrl", context)?.to_string();
  598. let callback_port = optional_u16(object, "callbackPort", context)?;
  599. let manual_redirect_url =
  600. optional_string(object, "manualRedirectUrl", context)?.map(str::to_string);
  601. let scopes = optional_string_array(object, "scopes", context)?.unwrap_or_default();
  602. Ok(Some(OAuthConfig {
  603. client_id,
  604. authorize_url,
  605. token_url,
  606. callback_port,
  607. manual_redirect_url,
  608. scopes,
  609. }))
  610. }
  611. fn parse_mcp_server_config(
  612. server_name: &str,
  613. value: &JsonValue,
  614. context: &str,
  615. ) -> Result<McpServerConfig, ConfigError> {
  616. let object = expect_object(value, context)?;
  617. let server_type = optional_string(object, "type", context)?.unwrap_or("stdio");
  618. match server_type {
  619. "stdio" => Ok(McpServerConfig::Stdio(McpStdioServerConfig {
  620. command: expect_string(object, "command", context)?.to_string(),
  621. args: optional_string_array(object, "args", context)?.unwrap_or_default(),
  622. env: optional_string_map(object, "env", context)?.unwrap_or_default(),
  623. })),
  624. "sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config(
  625. object, context,
  626. )?)),
  627. "http" => Ok(McpServerConfig::Http(parse_mcp_remote_server_config(
  628. object, context,
  629. )?)),
  630. "ws" => Ok(McpServerConfig::Ws(McpWebSocketServerConfig {
  631. url: expect_string(object, "url", context)?.to_string(),
  632. headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
  633. headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
  634. })),
  635. "sdk" => Ok(McpServerConfig::Sdk(McpSdkServerConfig {
  636. name: expect_string(object, "name", context)?.to_string(),
  637. })),
  638. "claudeai-proxy" => Ok(McpServerConfig::ClaudeAiProxy(
  639. McpClaudeAiProxyServerConfig {
  640. url: expect_string(object, "url", context)?.to_string(),
  641. id: expect_string(object, "id", context)?.to_string(),
  642. },
  643. )),
  644. other => Err(ConfigError::Parse(format!(
  645. "{context}: unsupported MCP server type for {server_name}: {other}"
  646. ))),
  647. }
  648. }
  649. fn parse_mcp_remote_server_config(
  650. object: &BTreeMap<String, JsonValue>,
  651. context: &str,
  652. ) -> Result<McpRemoteServerConfig, ConfigError> {
  653. Ok(McpRemoteServerConfig {
  654. url: expect_string(object, "url", context)?.to_string(),
  655. headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
  656. headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
  657. oauth: parse_optional_mcp_oauth_config(object, context)?,
  658. })
  659. }
  660. fn parse_optional_mcp_oauth_config(
  661. object: &BTreeMap<String, JsonValue>,
  662. context: &str,
  663. ) -> Result<Option<McpOAuthConfig>, ConfigError> {
  664. let Some(value) = object.get("oauth") else {
  665. return Ok(None);
  666. };
  667. let oauth = expect_object(value, &format!("{context}.oauth"))?;
  668. Ok(Some(McpOAuthConfig {
  669. client_id: optional_string(oauth, "clientId", context)?.map(str::to_string),
  670. callback_port: optional_u16(oauth, "callbackPort", context)?,
  671. auth_server_metadata_url: optional_string(oauth, "authServerMetadataUrl", context)?
  672. .map(str::to_string),
  673. xaa: optional_bool(oauth, "xaa", context)?,
  674. }))
  675. }
  676. fn expect_object<'a>(
  677. value: &'a JsonValue,
  678. context: &str,
  679. ) -> Result<&'a BTreeMap<String, JsonValue>, ConfigError> {
  680. value
  681. .as_object()
  682. .ok_or_else(|| ConfigError::Parse(format!("{context}: expected JSON object")))
  683. }
  684. fn expect_string<'a>(
  685. object: &'a BTreeMap<String, JsonValue>,
  686. key: &str,
  687. context: &str,
  688. ) -> Result<&'a str, ConfigError> {
  689. object
  690. .get(key)
  691. .and_then(JsonValue::as_str)
  692. .ok_or_else(|| ConfigError::Parse(format!("{context}: missing string field {key}")))
  693. }
  694. fn optional_string<'a>(
  695. object: &'a BTreeMap<String, JsonValue>,
  696. key: &str,
  697. context: &str,
  698. ) -> Result<Option<&'a str>, ConfigError> {
  699. match object.get(key) {
  700. Some(value) => value
  701. .as_str()
  702. .map(Some)
  703. .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a string"))),
  704. None => Ok(None),
  705. }
  706. }
  707. fn optional_bool(
  708. object: &BTreeMap<String, JsonValue>,
  709. key: &str,
  710. context: &str,
  711. ) -> Result<Option<bool>, ConfigError> {
  712. match object.get(key) {
  713. Some(value) => value
  714. .as_bool()
  715. .map(Some)
  716. .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a boolean"))),
  717. None => Ok(None),
  718. }
  719. }
  720. fn optional_u16(
  721. object: &BTreeMap<String, JsonValue>,
  722. key: &str,
  723. context: &str,
  724. ) -> Result<Option<u16>, ConfigError> {
  725. match object.get(key) {
  726. Some(value) => {
  727. let Some(number) = value.as_i64() else {
  728. return Err(ConfigError::Parse(format!(
  729. "{context}: field {key} must be an integer"
  730. )));
  731. };
  732. let number = u16::try_from(number).map_err(|_| {
  733. ConfigError::Parse(format!("{context}: field {key} is out of range"))
  734. })?;
  735. Ok(Some(number))
  736. }
  737. None => Ok(None),
  738. }
  739. }
  740. fn parse_bool_map(value: &JsonValue, context: &str) -> Result<BTreeMap<String, bool>, ConfigError> {
  741. let Some(map) = value.as_object() else {
  742. return Err(ConfigError::Parse(format!(
  743. "{context}: expected JSON object"
  744. )));
  745. };
  746. map.iter()
  747. .map(|(key, value)| {
  748. value
  749. .as_bool()
  750. .map(|enabled| (key.clone(), enabled))
  751. .ok_or_else(|| {
  752. ConfigError::Parse(format!("{context}: field {key} must be a boolean"))
  753. })
  754. })
  755. .collect()
  756. }
  757. fn optional_string_array(
  758. object: &BTreeMap<String, JsonValue>,
  759. key: &str,
  760. context: &str,
  761. ) -> Result<Option<Vec<String>>, ConfigError> {
  762. match object.get(key) {
  763. Some(value) => {
  764. let Some(array) = value.as_array() else {
  765. return Err(ConfigError::Parse(format!(
  766. "{context}: field {key} must be an array"
  767. )));
  768. };
  769. array
  770. .iter()
  771. .map(|item| {
  772. item.as_str().map(ToOwned::to_owned).ok_or_else(|| {
  773. ConfigError::Parse(format!(
  774. "{context}: field {key} must contain only strings"
  775. ))
  776. })
  777. })
  778. .collect::<Result<Vec<_>, _>>()
  779. .map(Some)
  780. }
  781. None => Ok(None),
  782. }
  783. }
  784. fn optional_string_map(
  785. object: &BTreeMap<String, JsonValue>,
  786. key: &str,
  787. context: &str,
  788. ) -> Result<Option<BTreeMap<String, String>>, ConfigError> {
  789. match object.get(key) {
  790. Some(value) => {
  791. let Some(map) = value.as_object() else {
  792. return Err(ConfigError::Parse(format!(
  793. "{context}: field {key} must be an object"
  794. )));
  795. };
  796. map.iter()
  797. .map(|(entry_key, entry_value)| {
  798. entry_value
  799. .as_str()
  800. .map(|text| (entry_key.clone(), text.to_string()))
  801. .ok_or_else(|| {
  802. ConfigError::Parse(format!(
  803. "{context}: field {key} must contain only string values"
  804. ))
  805. })
  806. })
  807. .collect::<Result<BTreeMap<_, _>, _>>()
  808. .map(Some)
  809. }
  810. None => Ok(None),
  811. }
  812. }
  813. fn deep_merge_objects(
  814. target: &mut BTreeMap<String, JsonValue>,
  815. source: &BTreeMap<String, JsonValue>,
  816. ) {
  817. for (key, value) in source {
  818. match (target.get_mut(key), value) {
  819. (Some(JsonValue::Object(existing)), JsonValue::Object(incoming)) => {
  820. deep_merge_objects(existing, incoming);
  821. }
  822. _ => {
  823. target.insert(key.clone(), value.clone());
  824. }
  825. }
  826. }
  827. }
  828. fn extend_unique(target: &mut Vec<String>, values: &[String]) {
  829. for value in values {
  830. push_unique(target, value.clone());
  831. }
  832. }
  833. fn push_unique(target: &mut Vec<String>, value: String) {
  834. if !target.iter().any(|existing| existing == &value) {
  835. target.push(value);
  836. }
  837. }
  838. #[cfg(test)]
  839. mod tests {
  840. use super::{
  841. ConfigLoader, ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode,
  842. CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
  843. };
  844. use crate::json::JsonValue;
  845. use crate::sandbox::FilesystemIsolationMode;
  846. use std::fs;
  847. use std::time::{SystemTime, UNIX_EPOCH};
  848. fn temp_dir() -> std::path::PathBuf {
  849. let nanos = SystemTime::now()
  850. .duration_since(UNIX_EPOCH)
  851. .expect("time should be after epoch")
  852. .as_nanos();
  853. std::env::temp_dir().join(format!("runtime-config-{nanos}"))
  854. }
  855. #[test]
  856. fn rejects_non_object_settings_files() {
  857. let root = temp_dir();
  858. let cwd = root.join("project");
  859. let home = root.join("home").join(".claude");
  860. fs::create_dir_all(&home).expect("home config dir");
  861. fs::create_dir_all(&cwd).expect("project dir");
  862. fs::write(home.join("settings.json"), "[]").expect("write bad settings");
  863. let error = ConfigLoader::new(&cwd, &home)
  864. .load()
  865. .expect_err("config should fail");
  866. assert!(error
  867. .to_string()
  868. .contains("top-level settings value must be a JSON object"));
  869. fs::remove_dir_all(root).expect("cleanup temp dir");
  870. }
  871. #[test]
  872. fn loads_and_merges_claude_code_config_files_by_precedence() {
  873. let root = temp_dir();
  874. let cwd = root.join("project");
  875. let home = root.join("home").join(".claude");
  876. fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
  877. fs::create_dir_all(&home).expect("home config dir");
  878. fs::write(
  879. home.parent().expect("home parent").join(".claude.json"),
  880. r#"{"model":"haiku","env":{"A":"1"},"mcpServers":{"home":{"command":"uvx","args":["home"]}}}"#,
  881. )
  882. .expect("write user compat config");
  883. fs::write(
  884. home.join("settings.json"),
  885. r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan"}}"#,
  886. )
  887. .expect("write user settings");
  888. fs::write(
  889. cwd.join(".claude.json"),
  890. r#"{"model":"project-compat","env":{"B":"2"}}"#,
  891. )
  892. .expect("write project compat config");
  893. fs::write(
  894. cwd.join(".claude").join("settings.json"),
  895. r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
  896. )
  897. .expect("write project settings");
  898. fs::write(
  899. cwd.join(".claude").join("settings.local.json"),
  900. r#"{"model":"opus","permissionMode":"acceptEdits"}"#,
  901. )
  902. .expect("write local settings");
  903. let loaded = ConfigLoader::new(&cwd, &home)
  904. .load()
  905. .expect("config should load");
  906. assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema");
  907. assert_eq!(loaded.loaded_entries().len(), 5);
  908. assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User);
  909. assert_eq!(
  910. loaded.get("model"),
  911. Some(&JsonValue::String("opus".to_string()))
  912. );
  913. assert_eq!(loaded.model(), Some("opus"));
  914. assert_eq!(
  915. loaded.permission_mode(),
  916. Some(ResolvedPermissionMode::WorkspaceWrite)
  917. );
  918. assert_eq!(
  919. loaded
  920. .get("env")
  921. .and_then(JsonValue::as_object)
  922. .expect("env object")
  923. .len(),
  924. 4
  925. );
  926. assert!(loaded
  927. .get("hooks")
  928. .and_then(JsonValue::as_object)
  929. .expect("hooks object")
  930. .contains_key("PreToolUse"));
  931. assert!(loaded
  932. .get("hooks")
  933. .and_then(JsonValue::as_object)
  934. .expect("hooks object")
  935. .contains_key("PostToolUse"));
  936. assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
  937. assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]);
  938. assert!(loaded.mcp().get("home").is_some());
  939. assert!(loaded.mcp().get("project").is_some());
  940. fs::remove_dir_all(root).expect("cleanup temp dir");
  941. }
  942. #[test]
  943. fn parses_sandbox_config() {
  944. let root = temp_dir();
  945. let cwd = root.join("project");
  946. let home = root.join("home").join(".claude");
  947. fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
  948. fs::create_dir_all(&home).expect("home config dir");
  949. fs::write(
  950. cwd.join(".claude").join("settings.local.json"),
  951. r#"{
  952. "sandbox": {
  953. "enabled": true,
  954. "namespaceRestrictions": false,
  955. "networkIsolation": true,
  956. "filesystemMode": "allow-list",
  957. "allowedMounts": ["logs", "tmp/cache"]
  958. }
  959. }"#,
  960. )
  961. .expect("write local settings");
  962. let loaded = ConfigLoader::new(&cwd, &home)
  963. .load()
  964. .expect("config should load");
  965. assert_eq!(loaded.sandbox().enabled, Some(true));
  966. assert_eq!(loaded.sandbox().namespace_restrictions, Some(false));
  967. assert_eq!(loaded.sandbox().network_isolation, Some(true));
  968. assert_eq!(
  969. loaded.sandbox().filesystem_mode,
  970. Some(FilesystemIsolationMode::AllowList)
  971. );
  972. assert_eq!(loaded.sandbox().allowed_mounts, vec!["logs", "tmp/cache"]);
  973. fs::remove_dir_all(root).expect("cleanup temp dir");
  974. }
  975. #[test]
  976. fn parses_typed_mcp_and_oauth_config() {
  977. let root = temp_dir();
  978. let cwd = root.join("project");
  979. let home = root.join("home").join(".claude");
  980. fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
  981. fs::create_dir_all(&home).expect("home config dir");
  982. fs::write(
  983. home.join("settings.json"),
  984. r#"{
  985. "mcpServers": {
  986. "stdio-server": {
  987. "command": "uvx",
  988. "args": ["mcp-server"],
  989. "env": {"TOKEN": "secret"}
  990. },
  991. "remote-server": {
  992. "type": "http",
  993. "url": "https://example.test/mcp",
  994. "headers": {"Authorization": "Bearer token"},
  995. "headersHelper": "helper.sh",
  996. "oauth": {
  997. "clientId": "mcp-client",
  998. "callbackPort": 7777,
  999. "authServerMetadataUrl": "https://issuer.test/.well-known/oauth-authorization-server",
  1000. "xaa": true
  1001. }
  1002. }
  1003. },
  1004. "oauth": {
  1005. "clientId": "runtime-client",
  1006. "authorizeUrl": "https://console.test/oauth/authorize",
  1007. "tokenUrl": "https://console.test/oauth/token",
  1008. "callbackPort": 54545,
  1009. "manualRedirectUrl": "https://console.test/oauth/callback",
  1010. "scopes": ["org:read", "user:write"]
  1011. }
  1012. }"#,
  1013. )
  1014. .expect("write user settings");
  1015. fs::write(
  1016. cwd.join(".claude").join("settings.local.json"),
  1017. r#"{
  1018. "mcpServers": {
  1019. "remote-server": {
  1020. "type": "ws",
  1021. "url": "wss://override.test/mcp",
  1022. "headers": {"X-Env": "local"}
  1023. }
  1024. }
  1025. }"#,
  1026. )
  1027. .expect("write local settings");
  1028. let loaded = ConfigLoader::new(&cwd, &home)
  1029. .load()
  1030. .expect("config should load");
  1031. let stdio_server = loaded
  1032. .mcp()
  1033. .get("stdio-server")
  1034. .expect("stdio server should exist");
  1035. assert_eq!(stdio_server.scope, ConfigSource::User);
  1036. assert_eq!(stdio_server.transport(), McpTransport::Stdio);
  1037. let remote_server = loaded
  1038. .mcp()
  1039. .get("remote-server")
  1040. .expect("remote server should exist");
  1041. assert_eq!(remote_server.scope, ConfigSource::Local);
  1042. assert_eq!(remote_server.transport(), McpTransport::Ws);
  1043. match &remote_server.config {
  1044. McpServerConfig::Ws(config) => {
  1045. assert_eq!(config.url, "wss://override.test/mcp");
  1046. assert_eq!(
  1047. config.headers.get("X-Env").map(String::as_str),
  1048. Some("local")
  1049. );
  1050. }
  1051. other => panic!("expected ws config, got {other:?}"),
  1052. }
  1053. let oauth = loaded.oauth().expect("oauth config should exist");
  1054. assert_eq!(oauth.client_id, "runtime-client");
  1055. assert_eq!(oauth.callback_port, Some(54_545));
  1056. assert_eq!(oauth.scopes, vec!["org:read", "user:write"]);
  1057. fs::remove_dir_all(root).expect("cleanup temp dir");
  1058. }
  1059. #[test]
  1060. fn parses_plugin_config_from_enabled_plugins() {
  1061. let root = temp_dir();
  1062. let cwd = root.join("project");
  1063. let home = root.join("home").join(".claude");
  1064. fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
  1065. fs::create_dir_all(&home).expect("home config dir");
  1066. fs::write(
  1067. home.join("settings.json"),
  1068. r#"{
  1069. "enabledPlugins": {
  1070. "tool-guard@builtin": true,
  1071. "sample-plugin@external": false
  1072. }
  1073. }"#,
  1074. )
  1075. .expect("write user settings");
  1076. let loaded = ConfigLoader::new(&cwd, &home)
  1077. .load()
  1078. .expect("config should load");
  1079. assert_eq!(
  1080. loaded.plugins().enabled_plugins().get("tool-guard@builtin"),
  1081. Some(&true)
  1082. );
  1083. assert_eq!(
  1084. loaded
  1085. .plugins()
  1086. .enabled_plugins()
  1087. .get("sample-plugin@external"),
  1088. Some(&false)
  1089. );
  1090. fs::remove_dir_all(root).expect("cleanup temp dir");
  1091. }
  1092. #[test]
  1093. fn parses_plugin_config() {
  1094. let root = temp_dir();
  1095. let cwd = root.join("project");
  1096. let home = root.join("home").join(".claude");
  1097. fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
  1098. fs::create_dir_all(&home).expect("home config dir");
  1099. fs::write(
  1100. home.join("settings.json"),
  1101. r#"{
  1102. "enabledPlugins": {
  1103. "core-helpers@builtin": true
  1104. },
  1105. "plugins": {
  1106. "externalDirectories": ["./external-plugins"],
  1107. "installRoot": "plugin-cache/installed",
  1108. "registryPath": "plugin-cache/installed.json",
  1109. "bundledRoot": "./bundled-plugins"
  1110. }
  1111. }"#,
  1112. )
  1113. .expect("write plugin settings");
  1114. let loaded = ConfigLoader::new(&cwd, &home)
  1115. .load()
  1116. .expect("config should load");
  1117. assert_eq!(
  1118. loaded
  1119. .plugins()
  1120. .enabled_plugins()
  1121. .get("core-helpers@builtin"),
  1122. Some(&true)
  1123. );
  1124. assert_eq!(
  1125. loaded.plugins().external_directories(),
  1126. &["./external-plugins".to_string()]
  1127. );
  1128. assert_eq!(
  1129. loaded.plugins().install_root(),
  1130. Some("plugin-cache/installed")
  1131. );
  1132. assert_eq!(
  1133. loaded.plugins().registry_path(),
  1134. Some("plugin-cache/installed.json")
  1135. );
  1136. assert_eq!(loaded.plugins().bundled_root(), Some("./bundled-plugins"));
  1137. fs::remove_dir_all(root).expect("cleanup temp dir");
  1138. }
  1139. #[test]
  1140. fn rejects_invalid_mcp_server_shapes() {
  1141. let root = temp_dir();
  1142. let cwd = root.join("project");
  1143. let home = root.join("home").join(".claude");
  1144. fs::create_dir_all(&home).expect("home config dir");
  1145. fs::create_dir_all(&cwd).expect("project dir");
  1146. fs::write(
  1147. home.join("settings.json"),
  1148. r#"{"mcpServers":{"broken":{"type":"http","url":123}}}"#,
  1149. )
  1150. .expect("write broken settings");
  1151. let error = ConfigLoader::new(&cwd, &home)
  1152. .load()
  1153. .expect_err("config should fail");
  1154. assert!(error
  1155. .to_string()
  1156. .contains("mcpServers.broken: missing string field url"));
  1157. fs::remove_dir_all(root).expect("cleanup temp dir");
  1158. }
  1159. }