use std::collections::BTreeMap; use std::fmt::{Display, Formatter}; use std::fs; use std::path::{Path, PathBuf}; use crate::json::JsonValue; use crate::sandbox::{FilesystemIsolationMode, SandboxConfig}; pub const CLAUDE_CODE_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema"; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum ConfigSource { User, Project, Local, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ResolvedPermissionMode { ReadOnly, WorkspaceWrite, DangerFullAccess, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfigEntry { pub source: ConfigSource, pub path: PathBuf, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeConfig { merged: BTreeMap, loaded_entries: Vec, feature_config: RuntimeFeatureConfig, } #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct RuntimeFeatureConfig { mcp: McpConfigCollection, oauth: Option, model: Option, permission_mode: Option, sandbox: SandboxConfig, } #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct McpConfigCollection { servers: BTreeMap, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ScopedMcpServerConfig { pub scope: ConfigSource, pub config: McpServerConfig, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum McpTransport { Stdio, Sse, Http, Ws, Sdk, ClaudeAiProxy, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum McpServerConfig { Stdio(McpStdioServerConfig), Sse(McpRemoteServerConfig), Http(McpRemoteServerConfig), Ws(McpWebSocketServerConfig), Sdk(McpSdkServerConfig), ClaudeAiProxy(McpClaudeAiProxyServerConfig), } #[derive(Debug, Clone, PartialEq, Eq)] pub struct McpStdioServerConfig { pub command: String, pub args: Vec, pub env: BTreeMap, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct McpRemoteServerConfig { pub url: String, pub headers: BTreeMap, pub headers_helper: Option, pub oauth: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct McpWebSocketServerConfig { pub url: String, pub headers: BTreeMap, pub headers_helper: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct McpSdkServerConfig { pub name: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct McpClaudeAiProxyServerConfig { pub url: String, pub id: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct McpOAuthConfig { pub client_id: Option, pub callback_port: Option, pub auth_server_metadata_url: Option, pub xaa: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct OAuthConfig { pub client_id: String, pub authorize_url: String, pub token_url: String, pub callback_port: Option, pub manual_redirect_url: Option, pub scopes: Vec, } #[derive(Debug)] pub enum ConfigError { Io(std::io::Error), Parse(String), } impl Display for ConfigError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::Io(error) => write!(f, "{error}"), Self::Parse(error) => write!(f, "{error}"), } } } impl std::error::Error for ConfigError {} impl From for ConfigError { fn from(value: std::io::Error) -> Self { Self::Io(value) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfigLoader { cwd: PathBuf, config_home: PathBuf, } impl ConfigLoader { #[must_use] pub fn new(cwd: impl Into, config_home: impl Into) -> Self { Self { cwd: cwd.into(), config_home: config_home.into(), } } #[must_use] pub fn default_for(cwd: impl Into) -> Self { let cwd = cwd.into(); let config_home = std::env::var_os("CLAUDE_CONFIG_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude"))) .unwrap_or_else(|| PathBuf::from(".claude")); Self { cwd, config_home } } #[must_use] pub fn discover(&self) -> Vec { let user_legacy_path = self.config_home.parent().map_or_else( || PathBuf::from(".claude.json"), |parent| parent.join(".claude.json"), ); vec![ ConfigEntry { source: ConfigSource::User, path: user_legacy_path, }, ConfigEntry { source: ConfigSource::User, path: self.config_home.join("settings.json"), }, ConfigEntry { source: ConfigSource::Project, path: self.cwd.join(".claude.json"), }, ConfigEntry { source: ConfigSource::Project, path: self.cwd.join(".claude").join("settings.json"), }, ConfigEntry { source: ConfigSource::Local, path: self.cwd.join(".claude").join("settings.local.json"), }, ] } pub fn load(&self) -> Result { let mut merged = BTreeMap::new(); let mut loaded_entries = Vec::new(); let mut mcp_servers = BTreeMap::new(); for entry in self.discover() { let Some(value) = read_optional_json_object(&entry.path)? else { continue; }; merge_mcp_servers(&mut mcp_servers, entry.source, &value, &entry.path)?; deep_merge_objects(&mut merged, &value); loaded_entries.push(entry); } let merged_value = JsonValue::Object(merged.clone()); let feature_config = RuntimeFeatureConfig { mcp: McpConfigCollection { servers: mcp_servers, }, oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?, model: parse_optional_model(&merged_value), permission_mode: parse_optional_permission_mode(&merged_value)?, sandbox: parse_optional_sandbox_config(&merged_value)?, }; Ok(RuntimeConfig { merged, loaded_entries, feature_config, }) } } impl RuntimeConfig { #[must_use] pub fn empty() -> Self { Self { merged: BTreeMap::new(), loaded_entries: Vec::new(), feature_config: RuntimeFeatureConfig::default(), } } #[must_use] pub fn merged(&self) -> &BTreeMap { &self.merged } #[must_use] pub fn loaded_entries(&self) -> &[ConfigEntry] { &self.loaded_entries } #[must_use] pub fn get(&self, key: &str) -> Option<&JsonValue> { self.merged.get(key) } #[must_use] pub fn as_json(&self) -> JsonValue { JsonValue::Object(self.merged.clone()) } #[must_use] pub fn feature_config(&self) -> &RuntimeFeatureConfig { &self.feature_config } #[must_use] pub fn mcp(&self) -> &McpConfigCollection { &self.feature_config.mcp } #[must_use] pub fn oauth(&self) -> Option<&OAuthConfig> { self.feature_config.oauth.as_ref() } #[must_use] pub fn model(&self) -> Option<&str> { self.feature_config.model.as_deref() } #[must_use] pub fn permission_mode(&self) -> Option { self.feature_config.permission_mode } #[must_use] pub fn sandbox(&self) -> &SandboxConfig { &self.feature_config.sandbox } } impl RuntimeFeatureConfig { #[must_use] pub fn mcp(&self) -> &McpConfigCollection { &self.mcp } #[must_use] pub fn oauth(&self) -> Option<&OAuthConfig> { self.oauth.as_ref() } #[must_use] pub fn model(&self) -> Option<&str> { self.model.as_deref() } #[must_use] pub fn permission_mode(&self) -> Option { self.permission_mode } #[must_use] pub fn sandbox(&self) -> &SandboxConfig { &self.sandbox } } impl McpConfigCollection { #[must_use] pub fn servers(&self) -> &BTreeMap { &self.servers } #[must_use] pub fn get(&self, name: &str) -> Option<&ScopedMcpServerConfig> { self.servers.get(name) } } impl ScopedMcpServerConfig { #[must_use] pub fn transport(&self) -> McpTransport { self.config.transport() } } impl McpServerConfig { #[must_use] pub fn transport(&self) -> McpTransport { match self { Self::Stdio(_) => McpTransport::Stdio, Self::Sse(_) => McpTransport::Sse, Self::Http(_) => McpTransport::Http, Self::Ws(_) => McpTransport::Ws, Self::Sdk(_) => McpTransport::Sdk, Self::ClaudeAiProxy(_) => McpTransport::ClaudeAiProxy, } } } fn read_optional_json_object( path: &Path, ) -> Result>, ConfigError> { let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claude.json"); let contents = match fs::read_to_string(path) { Ok(contents) => contents, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(ConfigError::Io(error)), }; if contents.trim().is_empty() { return Ok(Some(BTreeMap::new())); } let parsed = match JsonValue::parse(&contents) { Ok(parsed) => parsed, Err(error) if is_legacy_config => return Ok(None), Err(error) => return Err(ConfigError::Parse(format!("{}: {error}", path.display()))), }; let Some(object) = parsed.as_object() else { if is_legacy_config { return Ok(None); } return Err(ConfigError::Parse(format!( "{}: top-level settings value must be a JSON object", path.display() ))); }; Ok(Some(object.clone())) } fn merge_mcp_servers( target: &mut BTreeMap, source: ConfigSource, root: &BTreeMap, path: &Path, ) -> Result<(), ConfigError> { let Some(mcp_servers) = root.get("mcpServers") else { return Ok(()); }; let servers = expect_object(mcp_servers, &format!("{}: mcpServers", path.display()))?; for (name, value) in servers { let parsed = parse_mcp_server_config( name, value, &format!("{}: mcpServers.{name}", path.display()), )?; target.insert( name.clone(), ScopedMcpServerConfig { scope: source, config: parsed, }, ); } Ok(()) } fn parse_optional_model(root: &JsonValue) -> Option { root.as_object() .and_then(|object| object.get("model")) .and_then(JsonValue::as_str) .map(ToOwned::to_owned) } fn parse_optional_permission_mode( root: &JsonValue, ) -> Result, ConfigError> { let Some(object) = root.as_object() else { return Ok(None); }; if let Some(mode) = object.get("permissionMode").and_then(JsonValue::as_str) { return parse_permission_mode_label(mode, "merged settings.permissionMode").map(Some); } let Some(mode) = object .get("permissions") .and_then(JsonValue::as_object) .and_then(|permissions| permissions.get("defaultMode")) .and_then(JsonValue::as_str) else { return Ok(None); }; parse_permission_mode_label(mode, "merged settings.permissions.defaultMode").map(Some) } fn parse_permission_mode_label( mode: &str, context: &str, ) -> Result { match mode { "default" | "plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly), "acceptEdits" | "auto" | "workspace-write" => Ok(ResolvedPermissionMode::WorkspaceWrite), "dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess), other => Err(ConfigError::Parse(format!( "{context}: unsupported permission mode {other}" ))), } } fn parse_optional_sandbox_config(root: &JsonValue) -> Result { let Some(object) = root.as_object() else { return Ok(SandboxConfig::default()); }; let Some(sandbox_value) = object.get("sandbox") else { return Ok(SandboxConfig::default()); }; let sandbox = expect_object(sandbox_value, "merged settings.sandbox")?; let filesystem_mode = optional_string(sandbox, "filesystemMode", "merged settings.sandbox")? .map(parse_filesystem_mode_label) .transpose()?; Ok(SandboxConfig { enabled: optional_bool(sandbox, "enabled", "merged settings.sandbox")?, namespace_restrictions: optional_bool( sandbox, "namespaceRestrictions", "merged settings.sandbox", )?, network_isolation: optional_bool(sandbox, "networkIsolation", "merged settings.sandbox")?, filesystem_mode, allowed_mounts: optional_string_array(sandbox, "allowedMounts", "merged settings.sandbox")? .unwrap_or_default(), }) } fn parse_filesystem_mode_label(value: &str) -> Result { match value { "off" => Ok(FilesystemIsolationMode::Off), "workspace-only" => Ok(FilesystemIsolationMode::WorkspaceOnly), "allow-list" => Ok(FilesystemIsolationMode::AllowList), other => Err(ConfigError::Parse(format!( "merged settings.sandbox.filesystemMode: unsupported filesystem mode {other}" ))), } } fn parse_optional_oauth_config( root: &JsonValue, context: &str, ) -> Result, ConfigError> { let Some(oauth_value) = root.as_object().and_then(|object| object.get("oauth")) else { return Ok(None); }; let object = expect_object(oauth_value, context)?; let client_id = expect_string(object, "clientId", context)?.to_string(); let authorize_url = expect_string(object, "authorizeUrl", context)?.to_string(); let token_url = expect_string(object, "tokenUrl", context)?.to_string(); let callback_port = optional_u16(object, "callbackPort", context)?; let manual_redirect_url = optional_string(object, "manualRedirectUrl", context)?.map(str::to_string); let scopes = optional_string_array(object, "scopes", context)?.unwrap_or_default(); Ok(Some(OAuthConfig { client_id, authorize_url, token_url, callback_port, manual_redirect_url, scopes, })) } fn parse_mcp_server_config( server_name: &str, value: &JsonValue, context: &str, ) -> Result { let object = expect_object(value, context)?; let server_type = optional_string(object, "type", context)?.unwrap_or("stdio"); match server_type { "stdio" => Ok(McpServerConfig::Stdio(McpStdioServerConfig { command: expect_string(object, "command", context)?.to_string(), args: optional_string_array(object, "args", context)?.unwrap_or_default(), env: optional_string_map(object, "env", context)?.unwrap_or_default(), })), "sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config( object, context, )?)), "http" => Ok(McpServerConfig::Http(parse_mcp_remote_server_config( object, context, )?)), "ws" => Ok(McpServerConfig::Ws(McpWebSocketServerConfig { url: expect_string(object, "url", context)?.to_string(), headers: optional_string_map(object, "headers", context)?.unwrap_or_default(), headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string), })), "sdk" => Ok(McpServerConfig::Sdk(McpSdkServerConfig { name: expect_string(object, "name", context)?.to_string(), })), "claudeai-proxy" => Ok(McpServerConfig::ClaudeAiProxy( McpClaudeAiProxyServerConfig { url: expect_string(object, "url", context)?.to_string(), id: expect_string(object, "id", context)?.to_string(), }, )), other => Err(ConfigError::Parse(format!( "{context}: unsupported MCP server type for {server_name}: {other}" ))), } } fn parse_mcp_remote_server_config( object: &BTreeMap, context: &str, ) -> Result { Ok(McpRemoteServerConfig { url: expect_string(object, "url", context)?.to_string(), headers: optional_string_map(object, "headers", context)?.unwrap_or_default(), headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string), oauth: parse_optional_mcp_oauth_config(object, context)?, }) } fn parse_optional_mcp_oauth_config( object: &BTreeMap, context: &str, ) -> Result, ConfigError> { let Some(value) = object.get("oauth") else { return Ok(None); }; let oauth = expect_object(value, &format!("{context}.oauth"))?; Ok(Some(McpOAuthConfig { client_id: optional_string(oauth, "clientId", context)?.map(str::to_string), callback_port: optional_u16(oauth, "callbackPort", context)?, auth_server_metadata_url: optional_string(oauth, "authServerMetadataUrl", context)? .map(str::to_string), xaa: optional_bool(oauth, "xaa", context)?, })) } fn expect_object<'a>( value: &'a JsonValue, context: &str, ) -> Result<&'a BTreeMap, ConfigError> { value .as_object() .ok_or_else(|| ConfigError::Parse(format!("{context}: expected JSON object"))) } fn expect_string<'a>( object: &'a BTreeMap, key: &str, context: &str, ) -> Result<&'a str, ConfigError> { object .get(key) .and_then(JsonValue::as_str) .ok_or_else(|| ConfigError::Parse(format!("{context}: missing string field {key}"))) } fn optional_string<'a>( object: &'a BTreeMap, key: &str, context: &str, ) -> Result, ConfigError> { match object.get(key) { Some(value) => value .as_str() .map(Some) .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a string"))), None => Ok(None), } } fn optional_bool( object: &BTreeMap, key: &str, context: &str, ) -> Result, ConfigError> { match object.get(key) { Some(value) => value .as_bool() .map(Some) .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a boolean"))), None => Ok(None), } } fn optional_u16( object: &BTreeMap, key: &str, context: &str, ) -> Result, ConfigError> { match object.get(key) { Some(value) => { let Some(number) = value.as_i64() else { return Err(ConfigError::Parse(format!( "{context}: field {key} must be an integer" ))); }; let number = u16::try_from(number).map_err(|_| { ConfigError::Parse(format!("{context}: field {key} is out of range")) })?; Ok(Some(number)) } None => Ok(None), } } fn optional_string_array( object: &BTreeMap, key: &str, context: &str, ) -> Result>, ConfigError> { match object.get(key) { Some(value) => { let Some(array) = value.as_array() else { return Err(ConfigError::Parse(format!( "{context}: field {key} must be an array" ))); }; array .iter() .map(|item| { item.as_str().map(ToOwned::to_owned).ok_or_else(|| { ConfigError::Parse(format!( "{context}: field {key} must contain only strings" )) }) }) .collect::, _>>() .map(Some) } None => Ok(None), } } fn optional_string_map( object: &BTreeMap, key: &str, context: &str, ) -> Result>, ConfigError> { match object.get(key) { Some(value) => { let Some(map) = value.as_object() else { return Err(ConfigError::Parse(format!( "{context}: field {key} must be an object" ))); }; map.iter() .map(|(entry_key, entry_value)| { entry_value .as_str() .map(|text| (entry_key.clone(), text.to_string())) .ok_or_else(|| { ConfigError::Parse(format!( "{context}: field {key} must contain only string values" )) }) }) .collect::, _>>() .map(Some) } None => Ok(None), } } fn deep_merge_objects( target: &mut BTreeMap, source: &BTreeMap, ) { for (key, value) in source { match (target.get_mut(key), value) { (Some(JsonValue::Object(existing)), JsonValue::Object(incoming)) => { deep_merge_objects(existing, incoming); } _ => { target.insert(key.clone(), value.clone()); } } } } #[cfg(test)] mod tests { use super::{ ConfigLoader, ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode, CLAUDE_CODE_SETTINGS_SCHEMA_NAME, }; use crate::json::JsonValue; use crate::sandbox::FilesystemIsolationMode; use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; fn temp_dir() -> std::path::PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("time should be after epoch") .as_nanos(); std::env::temp_dir().join(format!("runtime-config-{nanos}")) } #[test] fn rejects_non_object_settings_files() { let root = temp_dir(); let cwd = root.join("project"); let home = root.join("home").join(".claude"); fs::create_dir_all(&home).expect("home config dir"); fs::create_dir_all(&cwd).expect("project dir"); fs::write(home.join("settings.json"), "[]").expect("write bad settings"); let error = ConfigLoader::new(&cwd, &home) .load() .expect_err("config should fail"); assert!(error .to_string() .contains("top-level settings value must be a JSON object")); fs::remove_dir_all(root).expect("cleanup temp dir"); } #[test] fn loads_and_merges_claude_code_config_files_by_precedence() { let root = temp_dir(); let cwd = root.join("project"); let home = root.join("home").join(".claude"); fs::create_dir_all(cwd.join(".claude")).expect("project config dir"); fs::create_dir_all(&home).expect("home config dir"); fs::write( home.parent().expect("home parent").join(".claude.json"), r#"{"model":"haiku","env":{"A":"1"},"mcpServers":{"home":{"command":"uvx","args":["home"]}}}"#, ) .expect("write user compat config"); fs::write( home.join("settings.json"), r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan"}}"#, ) .expect("write user settings"); fs::write( cwd.join(".claude.json"), r#"{"model":"project-compat","env":{"B":"2"}}"#, ) .expect("write project compat config"); fs::write( cwd.join(".claude").join("settings.json"), r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#, ) .expect("write project settings"); fs::write( cwd.join(".claude").join("settings.local.json"), r#"{"model":"opus","permissionMode":"acceptEdits"}"#, ) .expect("write local settings"); let loaded = ConfigLoader::new(&cwd, &home) .load() .expect("config should load"); assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema"); assert_eq!(loaded.loaded_entries().len(), 5); assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User); assert_eq!( loaded.get("model"), Some(&JsonValue::String("opus".to_string())) ); assert_eq!(loaded.model(), Some("opus")); assert_eq!( loaded.permission_mode(), Some(ResolvedPermissionMode::WorkspaceWrite) ); assert_eq!( loaded .get("env") .and_then(JsonValue::as_object) .expect("env object") .len(), 4 ); assert!(loaded .get("hooks") .and_then(JsonValue::as_object) .expect("hooks object") .contains_key("PreToolUse")); assert!(loaded .get("hooks") .and_then(JsonValue::as_object) .expect("hooks object") .contains_key("PostToolUse")); assert!(loaded.mcp().get("home").is_some()); assert!(loaded.mcp().get("project").is_some()); fs::remove_dir_all(root).expect("cleanup temp dir"); } #[test] fn parses_sandbox_config() { let root = temp_dir(); let cwd = root.join("project"); let home = root.join("home").join(".claude"); fs::create_dir_all(cwd.join(".claude")).expect("project config dir"); fs::create_dir_all(&home).expect("home config dir"); fs::write( cwd.join(".claude").join("settings.local.json"), r#"{ "sandbox": { "enabled": true, "namespaceRestrictions": false, "networkIsolation": true, "filesystemMode": "allow-list", "allowedMounts": ["logs", "tmp/cache"] } }"#, ) .expect("write local settings"); let loaded = ConfigLoader::new(&cwd, &home) .load() .expect("config should load"); assert_eq!(loaded.sandbox().enabled, Some(true)); assert_eq!(loaded.sandbox().namespace_restrictions, Some(false)); assert_eq!(loaded.sandbox().network_isolation, Some(true)); assert_eq!( loaded.sandbox().filesystem_mode, Some(FilesystemIsolationMode::AllowList) ); assert_eq!(loaded.sandbox().allowed_mounts, vec!["logs", "tmp/cache"]); fs::remove_dir_all(root).expect("cleanup temp dir"); } #[test] fn parses_typed_mcp_and_oauth_config() { let root = temp_dir(); let cwd = root.join("project"); let home = root.join("home").join(".claude"); fs::create_dir_all(cwd.join(".claude")).expect("project config dir"); fs::create_dir_all(&home).expect("home config dir"); fs::write( home.join("settings.json"), r#"{ "mcpServers": { "stdio-server": { "command": "uvx", "args": ["mcp-server"], "env": {"TOKEN": "secret"} }, "remote-server": { "type": "http", "url": "https://example.test/mcp", "headers": {"Authorization": "Bearer token"}, "headersHelper": "helper.sh", "oauth": { "clientId": "mcp-client", "callbackPort": 7777, "authServerMetadataUrl": "https://issuer.test/.well-known/oauth-authorization-server", "xaa": true } } }, "oauth": { "clientId": "runtime-client", "authorizeUrl": "https://console.test/oauth/authorize", "tokenUrl": "https://console.test/oauth/token", "callbackPort": 54545, "manualRedirectUrl": "https://console.test/oauth/callback", "scopes": ["org:read", "user:write"] } }"#, ) .expect("write user settings"); fs::write( cwd.join(".claude").join("settings.local.json"), r#"{ "mcpServers": { "remote-server": { "type": "ws", "url": "wss://override.test/mcp", "headers": {"X-Env": "local"} } } }"#, ) .expect("write local settings"); let loaded = ConfigLoader::new(&cwd, &home) .load() .expect("config should load"); let stdio_server = loaded .mcp() .get("stdio-server") .expect("stdio server should exist"); assert_eq!(stdio_server.scope, ConfigSource::User); assert_eq!(stdio_server.transport(), McpTransport::Stdio); let remote_server = loaded .mcp() .get("remote-server") .expect("remote server should exist"); assert_eq!(remote_server.scope, ConfigSource::Local); assert_eq!(remote_server.transport(), McpTransport::Ws); match &remote_server.config { McpServerConfig::Ws(config) => { assert_eq!(config.url, "wss://override.test/mcp"); assert_eq!( config.headers.get("X-Env").map(String::as_str), Some("local") ); } other => panic!("expected ws config, got {other:?}"), } let oauth = loaded.oauth().expect("oauth config should exist"); assert_eq!(oauth.client_id, "runtime-client"); assert_eq!(oauth.callback_port, Some(54_545)); assert_eq!(oauth.scopes, vec!["org:read", "user:write"]); fs::remove_dir_all(root).expect("cleanup temp dir"); } #[test] fn rejects_invalid_mcp_server_shapes() { let root = temp_dir(); let cwd = root.join("project"); let home = root.join("home").join(".claude"); fs::create_dir_all(&home).expect("home config dir"); fs::create_dir_all(&cwd).expect("project dir"); fs::write( home.join("settings.json"), r#"{"mcpServers":{"broken":{"type":"http","url":123}}}"#, ) .expect("write broken settings"); let error = ConfigLoader::new(&cwd, &home) .load() .expect_err("config should fail"); assert!(error .to_string() .contains("mcpServers.broken: missing string field url")); fs::remove_dir_all(root).expect("cleanup temp dir"); } }