Files
claw-code/rust/crates/runtime/src/config.rs
Yeachan-Heo cd01d0e387 Honor Claude config defaults across runtime sessions
The runtime now discovers both legacy and current Claude config files at
user and project scope, merges them in precedence order, and carries the
resolved model, permission mode, instruction files, and MCP server
configuration into session startup.

This keeps CLI defaults aligned with project policy and exposes configured
MCP tools without requiring manual flags.

Constraint: Must support both legacy .claude.json and current .claude/settings.json layouts
Constraint: Session startup must preserve CLI flag precedence over config defaults
Rejected: Read only project settings files | would ignore user-scoped defaults and MCP servers
Rejected: Delay MCP tool discovery until first tool call | model would not see configured MCP tools during planning
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep config precedence synchronized between prompt loading, session startup, and status reporting
Tested: cargo fmt --all --check; cargo clippy --workspace --all-targets --all-features -- -D warnings; cargo test --workspace --all-features
Not-tested: Live remote MCP servers and interactive REPL session startup against external services
2026-04-01 00:36:32 +00:00

909 lines
28 KiB
Rust

use std::collections::BTreeMap;
use std::fmt::{Display, Formatter};
use std::fs;
use std::path::{Path, PathBuf};
use crate::json::JsonValue;
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<String, JsonValue>,
loaded_entries: Vec<ConfigEntry>,
feature_config: RuntimeFeatureConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimeFeatureConfig {
mcp: McpConfigCollection,
oauth: Option<OAuthConfig>,
model: Option<String>,
permission_mode: Option<ResolvedPermissionMode>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct McpConfigCollection {
servers: BTreeMap<String, ScopedMcpServerConfig>,
}
#[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<String>,
pub env: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpRemoteServerConfig {
pub url: String,
pub headers: BTreeMap<String, String>,
pub headers_helper: Option<String>,
pub oauth: Option<McpOAuthConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpWebSocketServerConfig {
pub url: String,
pub headers: BTreeMap<String, String>,
pub headers_helper: Option<String>,
}
#[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<String>,
pub callback_port: Option<u16>,
pub auth_server_metadata_url: Option<String>,
pub xaa: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthConfig {
pub client_id: String,
pub authorize_url: String,
pub token_url: String,
pub callback_port: Option<u16>,
pub manual_redirect_url: Option<String>,
pub scopes: Vec<String>,
}
#[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<std::io::Error> 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<PathBuf>, config_home: impl Into<PathBuf>) -> Self {
Self {
cwd: cwd.into(),
config_home: config_home.into(),
}
}
#[must_use]
pub fn default_for(cwd: impl Into<PathBuf>) -> 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<ConfigEntry> {
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<RuntimeConfig, ConfigError> {
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)?,
};
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<String, JsonValue> {
&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<ResolvedPermissionMode> {
self.feature_config.permission_mode
}
}
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<ResolvedPermissionMode> {
self.permission_mode
}
}
impl McpConfigCollection {
#[must_use]
pub fn servers(&self) -> &BTreeMap<String, ScopedMcpServerConfig> {
&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<Option<BTreeMap<String, JsonValue>>, 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<String, ScopedMcpServerConfig>,
source: ConfigSource,
root: &BTreeMap<String, JsonValue>,
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<String> {
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<Option<ResolvedPermissionMode>, 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<ResolvedPermissionMode, ConfigError> {
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_oauth_config(
root: &JsonValue,
context: &str,
) -> Result<Option<OAuthConfig>, 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<McpServerConfig, ConfigError> {
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<String, JsonValue>,
context: &str,
) -> Result<McpRemoteServerConfig, ConfigError> {
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<String, JsonValue>,
context: &str,
) -> Result<Option<McpOAuthConfig>, 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<String, JsonValue>, ConfigError> {
value
.as_object()
.ok_or_else(|| ConfigError::Parse(format!("{context}: expected JSON object")))
}
fn expect_string<'a>(
object: &'a BTreeMap<String, JsonValue>,
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<String, JsonValue>,
key: &str,
context: &str,
) -> Result<Option<&'a str>, 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<String, JsonValue>,
key: &str,
context: &str,
) -> Result<Option<bool>, 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<String, JsonValue>,
key: &str,
context: &str,
) -> Result<Option<u16>, 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<String, JsonValue>,
key: &str,
context: &str,
) -> Result<Option<Vec<String>>, 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::<Result<Vec<_>, _>>()
.map(Some)
}
None => Ok(None),
}
}
fn optional_string_map(
object: &BTreeMap<String, JsonValue>,
key: &str,
context: &str,
) -> Result<Option<BTreeMap<String, String>>, 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::<Result<BTreeMap<_, _>, _>>()
.map(Some)
}
None => Ok(None),
}
}
fn deep_merge_objects(
target: &mut BTreeMap<String, JsonValue>,
source: &BTreeMap<String, JsonValue>,
) {
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 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_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");
}
}