config.rs 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294
  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 CLAW_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. ManagedProxy,
  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. ManagedProxy(McpManagedProxyServerConfig),
  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 McpManagedProxyServerConfig {
  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(".claw.json"),
  171. |parent| parent.join(".claw.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(".claw.json"),
  185. },
  186. ConfigEntry {
  187. source: ConfigSource::Project,
  188. path: self.cwd.join(".claw").join("settings.json"),
  189. },
  190. ConfigEntry {
  191. source: ConfigSource::Local,
  192. path: self.cwd.join(".claw").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("CLAW_CONFIG_HOME")
  360. .map(PathBuf::from)
  361. .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claw")))
  362. .unwrap_or_else(|| PathBuf::from(".claw"))
  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::ManagedProxy(_) => McpTransport::ManagedProxy,
  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(".claw.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::ManagedProxy(McpManagedProxyServerConfig {
  639. url: expect_string(object, "url", context)?.to_string(),
  640. id: expect_string(object, "id", context)?.to_string(),
  641. })),
  642. other => Err(ConfigError::Parse(format!(
  643. "{context}: unsupported MCP server type for {server_name}: {other}"
  644. ))),
  645. }
  646. }
  647. fn parse_mcp_remote_server_config(
  648. object: &BTreeMap<String, JsonValue>,
  649. context: &str,
  650. ) -> Result<McpRemoteServerConfig, ConfigError> {
  651. Ok(McpRemoteServerConfig {
  652. url: expect_string(object, "url", context)?.to_string(),
  653. headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
  654. headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
  655. oauth: parse_optional_mcp_oauth_config(object, context)?,
  656. })
  657. }
  658. fn parse_optional_mcp_oauth_config(
  659. object: &BTreeMap<String, JsonValue>,
  660. context: &str,
  661. ) -> Result<Option<McpOAuthConfig>, ConfigError> {
  662. let Some(value) = object.get("oauth") else {
  663. return Ok(None);
  664. };
  665. let oauth = expect_object(value, &format!("{context}.oauth"))?;
  666. Ok(Some(McpOAuthConfig {
  667. client_id: optional_string(oauth, "clientId", context)?.map(str::to_string),
  668. callback_port: optional_u16(oauth, "callbackPort", context)?,
  669. auth_server_metadata_url: optional_string(oauth, "authServerMetadataUrl", context)?
  670. .map(str::to_string),
  671. xaa: optional_bool(oauth, "xaa", context)?,
  672. }))
  673. }
  674. fn expect_object<'a>(
  675. value: &'a JsonValue,
  676. context: &str,
  677. ) -> Result<&'a BTreeMap<String, JsonValue>, ConfigError> {
  678. value
  679. .as_object()
  680. .ok_or_else(|| ConfigError::Parse(format!("{context}: expected JSON object")))
  681. }
  682. fn expect_string<'a>(
  683. object: &'a BTreeMap<String, JsonValue>,
  684. key: &str,
  685. context: &str,
  686. ) -> Result<&'a str, ConfigError> {
  687. object
  688. .get(key)
  689. .and_then(JsonValue::as_str)
  690. .ok_or_else(|| ConfigError::Parse(format!("{context}: missing string field {key}")))
  691. }
  692. fn optional_string<'a>(
  693. object: &'a BTreeMap<String, JsonValue>,
  694. key: &str,
  695. context: &str,
  696. ) -> Result<Option<&'a str>, ConfigError> {
  697. match object.get(key) {
  698. Some(value) => value
  699. .as_str()
  700. .map(Some)
  701. .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a string"))),
  702. None => Ok(None),
  703. }
  704. }
  705. fn optional_bool(
  706. object: &BTreeMap<String, JsonValue>,
  707. key: &str,
  708. context: &str,
  709. ) -> Result<Option<bool>, ConfigError> {
  710. match object.get(key) {
  711. Some(value) => value
  712. .as_bool()
  713. .map(Some)
  714. .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a boolean"))),
  715. None => Ok(None),
  716. }
  717. }
  718. fn optional_u16(
  719. object: &BTreeMap<String, JsonValue>,
  720. key: &str,
  721. context: &str,
  722. ) -> Result<Option<u16>, ConfigError> {
  723. match object.get(key) {
  724. Some(value) => {
  725. let Some(number) = value.as_i64() else {
  726. return Err(ConfigError::Parse(format!(
  727. "{context}: field {key} must be an integer"
  728. )));
  729. };
  730. let number = u16::try_from(number).map_err(|_| {
  731. ConfigError::Parse(format!("{context}: field {key} is out of range"))
  732. })?;
  733. Ok(Some(number))
  734. }
  735. None => Ok(None),
  736. }
  737. }
  738. fn parse_bool_map(value: &JsonValue, context: &str) -> Result<BTreeMap<String, bool>, ConfigError> {
  739. let Some(map) = value.as_object() else {
  740. return Err(ConfigError::Parse(format!(
  741. "{context}: expected JSON object"
  742. )));
  743. };
  744. map.iter()
  745. .map(|(key, value)| {
  746. value
  747. .as_bool()
  748. .map(|enabled| (key.clone(), enabled))
  749. .ok_or_else(|| {
  750. ConfigError::Parse(format!("{context}: field {key} must be a boolean"))
  751. })
  752. })
  753. .collect()
  754. }
  755. fn optional_string_array(
  756. object: &BTreeMap<String, JsonValue>,
  757. key: &str,
  758. context: &str,
  759. ) -> Result<Option<Vec<String>>, ConfigError> {
  760. match object.get(key) {
  761. Some(value) => {
  762. let Some(array) = value.as_array() else {
  763. return Err(ConfigError::Parse(format!(
  764. "{context}: field {key} must be an array"
  765. )));
  766. };
  767. array
  768. .iter()
  769. .map(|item| {
  770. item.as_str().map(ToOwned::to_owned).ok_or_else(|| {
  771. ConfigError::Parse(format!(
  772. "{context}: field {key} must contain only strings"
  773. ))
  774. })
  775. })
  776. .collect::<Result<Vec<_>, _>>()
  777. .map(Some)
  778. }
  779. None => Ok(None),
  780. }
  781. }
  782. fn optional_string_map(
  783. object: &BTreeMap<String, JsonValue>,
  784. key: &str,
  785. context: &str,
  786. ) -> Result<Option<BTreeMap<String, String>>, ConfigError> {
  787. match object.get(key) {
  788. Some(value) => {
  789. let Some(map) = value.as_object() else {
  790. return Err(ConfigError::Parse(format!(
  791. "{context}: field {key} must be an object"
  792. )));
  793. };
  794. map.iter()
  795. .map(|(entry_key, entry_value)| {
  796. entry_value
  797. .as_str()
  798. .map(|text| (entry_key.clone(), text.to_string()))
  799. .ok_or_else(|| {
  800. ConfigError::Parse(format!(
  801. "{context}: field {key} must contain only string values"
  802. ))
  803. })
  804. })
  805. .collect::<Result<BTreeMap<_, _>, _>>()
  806. .map(Some)
  807. }
  808. None => Ok(None),
  809. }
  810. }
  811. fn deep_merge_objects(
  812. target: &mut BTreeMap<String, JsonValue>,
  813. source: &BTreeMap<String, JsonValue>,
  814. ) {
  815. for (key, value) in source {
  816. match (target.get_mut(key), value) {
  817. (Some(JsonValue::Object(existing)), JsonValue::Object(incoming)) => {
  818. deep_merge_objects(existing, incoming);
  819. }
  820. _ => {
  821. target.insert(key.clone(), value.clone());
  822. }
  823. }
  824. }
  825. }
  826. fn extend_unique(target: &mut Vec<String>, values: &[String]) {
  827. for value in values {
  828. push_unique(target, value.clone());
  829. }
  830. }
  831. fn push_unique(target: &mut Vec<String>, value: String) {
  832. if !target.iter().any(|existing| existing == &value) {
  833. target.push(value);
  834. }
  835. }
  836. #[cfg(test)]
  837. mod tests {
  838. use super::{
  839. ConfigLoader, ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode,
  840. CLAW_SETTINGS_SCHEMA_NAME,
  841. };
  842. use crate::json::JsonValue;
  843. use crate::sandbox::FilesystemIsolationMode;
  844. use std::fs;
  845. use std::time::{SystemTime, UNIX_EPOCH};
  846. fn temp_dir() -> std::path::PathBuf {
  847. let nanos = SystemTime::now()
  848. .duration_since(UNIX_EPOCH)
  849. .expect("time should be after epoch")
  850. .as_nanos();
  851. std::env::temp_dir().join(format!("runtime-config-{nanos}"))
  852. }
  853. #[test]
  854. fn rejects_non_object_settings_files() {
  855. let root = temp_dir();
  856. let cwd = root.join("project");
  857. let home = root.join("home").join(".claw");
  858. fs::create_dir_all(&home).expect("home config dir");
  859. fs::create_dir_all(&cwd).expect("project dir");
  860. fs::write(home.join("settings.json"), "[]").expect("write bad settings");
  861. let error = ConfigLoader::new(&cwd, &home)
  862. .load()
  863. .expect_err("config should fail");
  864. assert!(error
  865. .to_string()
  866. .contains("top-level settings value must be a JSON object"));
  867. fs::remove_dir_all(root).expect("cleanup temp dir");
  868. }
  869. #[test]
  870. fn loads_and_merges_claude_code_config_files_by_precedence() {
  871. let root = temp_dir();
  872. let cwd = root.join("project");
  873. let home = root.join("home").join(".claw");
  874. fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
  875. fs::create_dir_all(&home).expect("home config dir");
  876. fs::write(
  877. home.parent().expect("home parent").join(".claw.json"),
  878. r#"{"model":"haiku","env":{"A":"1"},"mcpServers":{"home":{"command":"uvx","args":["home"]}}}"#,
  879. )
  880. .expect("write user compat config");
  881. fs::write(
  882. home.join("settings.json"),
  883. r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan"}}"#,
  884. )
  885. .expect("write user settings");
  886. fs::write(
  887. cwd.join(".claw.json"),
  888. r#"{"model":"project-compat","env":{"B":"2"}}"#,
  889. )
  890. .expect("write project compat config");
  891. fs::write(
  892. cwd.join(".claw").join("settings.json"),
  893. r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
  894. )
  895. .expect("write project settings");
  896. fs::write(
  897. cwd.join(".claw").join("settings.local.json"),
  898. r#"{"model":"opus","permissionMode":"acceptEdits"}"#,
  899. )
  900. .expect("write local settings");
  901. let loaded = ConfigLoader::new(&cwd, &home)
  902. .load()
  903. .expect("config should load");
  904. assert_eq!(CLAW_SETTINGS_SCHEMA_NAME, "SettingsSchema");
  905. assert_eq!(loaded.loaded_entries().len(), 5);
  906. assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User);
  907. assert_eq!(
  908. loaded.get("model"),
  909. Some(&JsonValue::String("opus".to_string()))
  910. );
  911. assert_eq!(loaded.model(), Some("opus"));
  912. assert_eq!(
  913. loaded.permission_mode(),
  914. Some(ResolvedPermissionMode::WorkspaceWrite)
  915. );
  916. assert_eq!(
  917. loaded
  918. .get("env")
  919. .and_then(JsonValue::as_object)
  920. .expect("env object")
  921. .len(),
  922. 4
  923. );
  924. assert!(loaded
  925. .get("hooks")
  926. .and_then(JsonValue::as_object)
  927. .expect("hooks object")
  928. .contains_key("PreToolUse"));
  929. assert!(loaded
  930. .get("hooks")
  931. .and_then(JsonValue::as_object)
  932. .expect("hooks object")
  933. .contains_key("PostToolUse"));
  934. assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
  935. assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]);
  936. assert!(loaded.mcp().get("home").is_some());
  937. assert!(loaded.mcp().get("project").is_some());
  938. fs::remove_dir_all(root).expect("cleanup temp dir");
  939. }
  940. #[test]
  941. fn parses_sandbox_config() {
  942. let root = temp_dir();
  943. let cwd = root.join("project");
  944. let home = root.join("home").join(".claw");
  945. fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
  946. fs::create_dir_all(&home).expect("home config dir");
  947. fs::write(
  948. cwd.join(".claw").join("settings.local.json"),
  949. r#"{
  950. "sandbox": {
  951. "enabled": true,
  952. "namespaceRestrictions": false,
  953. "networkIsolation": true,
  954. "filesystemMode": "allow-list",
  955. "allowedMounts": ["logs", "tmp/cache"]
  956. }
  957. }"#,
  958. )
  959. .expect("write local settings");
  960. let loaded = ConfigLoader::new(&cwd, &home)
  961. .load()
  962. .expect("config should load");
  963. assert_eq!(loaded.sandbox().enabled, Some(true));
  964. assert_eq!(loaded.sandbox().namespace_restrictions, Some(false));
  965. assert_eq!(loaded.sandbox().network_isolation, Some(true));
  966. assert_eq!(
  967. loaded.sandbox().filesystem_mode,
  968. Some(FilesystemIsolationMode::AllowList)
  969. );
  970. assert_eq!(loaded.sandbox().allowed_mounts, vec!["logs", "tmp/cache"]);
  971. fs::remove_dir_all(root).expect("cleanup temp dir");
  972. }
  973. #[test]
  974. fn parses_typed_mcp_and_oauth_config() {
  975. let root = temp_dir();
  976. let cwd = root.join("project");
  977. let home = root.join("home").join(".claw");
  978. fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
  979. fs::create_dir_all(&home).expect("home config dir");
  980. fs::write(
  981. home.join("settings.json"),
  982. r#"{
  983. "mcpServers": {
  984. "stdio-server": {
  985. "command": "uvx",
  986. "args": ["mcp-server"],
  987. "env": {"TOKEN": "secret"}
  988. },
  989. "remote-server": {
  990. "type": "http",
  991. "url": "https://example.test/mcp",
  992. "headers": {"Authorization": "Bearer token"},
  993. "headersHelper": "helper.sh",
  994. "oauth": {
  995. "clientId": "mcp-client",
  996. "callbackPort": 7777,
  997. "authServerMetadataUrl": "https://issuer.test/.well-known/oauth-authorization-server",
  998. "xaa": true
  999. }
  1000. }
  1001. },
  1002. "oauth": {
  1003. "clientId": "runtime-client",
  1004. "authorizeUrl": "https://console.test/oauth/authorize",
  1005. "tokenUrl": "https://console.test/oauth/token",
  1006. "callbackPort": 54545,
  1007. "manualRedirectUrl": "https://console.test/oauth/callback",
  1008. "scopes": ["org:read", "user:write"]
  1009. }
  1010. }"#,
  1011. )
  1012. .expect("write user settings");
  1013. fs::write(
  1014. cwd.join(".claw").join("settings.local.json"),
  1015. r#"{
  1016. "mcpServers": {
  1017. "remote-server": {
  1018. "type": "ws",
  1019. "url": "wss://override.test/mcp",
  1020. "headers": {"X-Env": "local"}
  1021. }
  1022. }
  1023. }"#,
  1024. )
  1025. .expect("write local settings");
  1026. let loaded = ConfigLoader::new(&cwd, &home)
  1027. .load()
  1028. .expect("config should load");
  1029. let stdio_server = loaded
  1030. .mcp()
  1031. .get("stdio-server")
  1032. .expect("stdio server should exist");
  1033. assert_eq!(stdio_server.scope, ConfigSource::User);
  1034. assert_eq!(stdio_server.transport(), McpTransport::Stdio);
  1035. let remote_server = loaded
  1036. .mcp()
  1037. .get("remote-server")
  1038. .expect("remote server should exist");
  1039. assert_eq!(remote_server.scope, ConfigSource::Local);
  1040. assert_eq!(remote_server.transport(), McpTransport::Ws);
  1041. match &remote_server.config {
  1042. McpServerConfig::Ws(config) => {
  1043. assert_eq!(config.url, "wss://override.test/mcp");
  1044. assert_eq!(
  1045. config.headers.get("X-Env").map(String::as_str),
  1046. Some("local")
  1047. );
  1048. }
  1049. other => panic!("expected ws config, got {other:?}"),
  1050. }
  1051. let oauth = loaded.oauth().expect("oauth config should exist");
  1052. assert_eq!(oauth.client_id, "runtime-client");
  1053. assert_eq!(oauth.callback_port, Some(54_545));
  1054. assert_eq!(oauth.scopes, vec!["org:read", "user:write"]);
  1055. fs::remove_dir_all(root).expect("cleanup temp dir");
  1056. }
  1057. #[test]
  1058. fn parses_plugin_config_from_enabled_plugins() {
  1059. let root = temp_dir();
  1060. let cwd = root.join("project");
  1061. let home = root.join("home").join(".claw");
  1062. fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
  1063. fs::create_dir_all(&home).expect("home config dir");
  1064. fs::write(
  1065. home.join("settings.json"),
  1066. r#"{
  1067. "enabledPlugins": {
  1068. "tool-guard@builtin": true,
  1069. "sample-plugin@external": false
  1070. }
  1071. }"#,
  1072. )
  1073. .expect("write user settings");
  1074. let loaded = ConfigLoader::new(&cwd, &home)
  1075. .load()
  1076. .expect("config should load");
  1077. assert_eq!(
  1078. loaded.plugins().enabled_plugins().get("tool-guard@builtin"),
  1079. Some(&true)
  1080. );
  1081. assert_eq!(
  1082. loaded
  1083. .plugins()
  1084. .enabled_plugins()
  1085. .get("sample-plugin@external"),
  1086. Some(&false)
  1087. );
  1088. fs::remove_dir_all(root).expect("cleanup temp dir");
  1089. }
  1090. #[test]
  1091. fn parses_plugin_config() {
  1092. let root = temp_dir();
  1093. let cwd = root.join("project");
  1094. let home = root.join("home").join(".claw");
  1095. fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
  1096. fs::create_dir_all(&home).expect("home config dir");
  1097. fs::write(
  1098. home.join("settings.json"),
  1099. r#"{
  1100. "enabledPlugins": {
  1101. "core-helpers@builtin": true
  1102. },
  1103. "plugins": {
  1104. "externalDirectories": ["./external-plugins"],
  1105. "installRoot": "plugin-cache/installed",
  1106. "registryPath": "plugin-cache/installed.json",
  1107. "bundledRoot": "./bundled-plugins"
  1108. }
  1109. }"#,
  1110. )
  1111. .expect("write plugin settings");
  1112. let loaded = ConfigLoader::new(&cwd, &home)
  1113. .load()
  1114. .expect("config should load");
  1115. assert_eq!(
  1116. loaded
  1117. .plugins()
  1118. .enabled_plugins()
  1119. .get("core-helpers@builtin"),
  1120. Some(&true)
  1121. );
  1122. assert_eq!(
  1123. loaded.plugins().external_directories(),
  1124. &["./external-plugins".to_string()]
  1125. );
  1126. assert_eq!(
  1127. loaded.plugins().install_root(),
  1128. Some("plugin-cache/installed")
  1129. );
  1130. assert_eq!(
  1131. loaded.plugins().registry_path(),
  1132. Some("plugin-cache/installed.json")
  1133. );
  1134. assert_eq!(loaded.plugins().bundled_root(), Some("./bundled-plugins"));
  1135. fs::remove_dir_all(root).expect("cleanup temp dir");
  1136. }
  1137. #[test]
  1138. fn rejects_invalid_mcp_server_shapes() {
  1139. let root = temp_dir();
  1140. let cwd = root.join("project");
  1141. let home = root.join("home").join(".claw");
  1142. fs::create_dir_all(&home).expect("home config dir");
  1143. fs::create_dir_all(&cwd).expect("project dir");
  1144. fs::write(
  1145. home.join("settings.json"),
  1146. r#"{"mcpServers":{"broken":{"type":"http","url":123}}}"#,
  1147. )
  1148. .expect("write broken settings");
  1149. let error = ConfigLoader::new(&cwd, &home)
  1150. .load()
  1151. .expect_err("config should fail");
  1152. assert!(error
  1153. .to_string()
  1154. .contains("mcpServers.broken: missing string field url"));
  1155. fs::remove_dir_all(root).expect("cleanup temp dir");
  1156. }
  1157. }