mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-05 22:17:10 +08:00
fix: load partial MCP configs
Generated with https://github.com/Yeachan-Heo/gajae-code Co-authored-by: Gajae Code <dev@gajae-code.com>
This commit is contained in:
@@ -207,9 +207,19 @@ pub struct RuntimePermissionRuleConfig {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct McpConfigCollection {
|
||||
servers: BTreeMap<String, ScopedMcpServerConfig>,
|
||||
invalid_servers: Vec<McpInvalidServerConfig>,
|
||||
total_configured: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpInvalidServerConfig {
|
||||
pub name: String,
|
||||
pub scope: ConfigSource,
|
||||
pub path: PathBuf,
|
||||
pub error_field: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// MCP server config paired with the scope that defined it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ScopedMcpServerConfig {
|
||||
pub required: bool,
|
||||
@@ -386,7 +396,7 @@ impl ConfigLoader {
|
||||
pub fn load(&self) -> Result<RuntimeConfig, ConfigError> {
|
||||
let mut merged = BTreeMap::new();
|
||||
let mut loaded_entries = Vec::new();
|
||||
let mut mcp_servers = BTreeMap::new();
|
||||
let mut mcp = McpConfigCollection::default();
|
||||
let mut all_warnings = Vec::new();
|
||||
|
||||
for entry in self.discover() {
|
||||
@@ -405,7 +415,7 @@ impl ConfigLoader {
|
||||
}
|
||||
all_warnings.extend(validation.warnings);
|
||||
validate_optional_hooks_config(&parsed.object, &entry.path)?;
|
||||
merge_mcp_servers(&mut mcp_servers, entry.source, &parsed.object, &entry.path)?;
|
||||
merge_mcp_servers(&mut mcp, entry.source, &parsed.object, &entry.path)?;
|
||||
deep_merge_objects(&mut merged, &parsed.object);
|
||||
loaded_entries.push(entry);
|
||||
}
|
||||
@@ -414,7 +424,7 @@ impl ConfigLoader {
|
||||
emit_config_warning_once(&warning.to_string());
|
||||
}
|
||||
|
||||
build_runtime_config(merged, loaded_entries, mcp_servers)
|
||||
build_runtime_config(merged, loaded_entries, mcp)
|
||||
}
|
||||
|
||||
/// Like [`load`] but also returns the list of validation warnings collected during
|
||||
@@ -425,7 +435,7 @@ impl ConfigLoader {
|
||||
pub fn load_collecting_warnings(&self) -> Result<(RuntimeConfig, Vec<String>), ConfigError> {
|
||||
let mut merged = BTreeMap::new();
|
||||
let mut loaded_entries = Vec::new();
|
||||
let mut mcp_servers = BTreeMap::new();
|
||||
let mut mcp = McpConfigCollection::default();
|
||||
let mut all_warnings: Vec<String> = Vec::new();
|
||||
|
||||
for entry in self.discover() {
|
||||
@@ -444,12 +454,12 @@ impl ConfigLoader {
|
||||
}
|
||||
all_warnings.extend(validation.warnings.iter().map(|w| w.to_string()));
|
||||
validate_optional_hooks_config(&parsed.object, &entry.path)?;
|
||||
merge_mcp_servers(&mut mcp_servers, entry.source, &parsed.object, &entry.path)?;
|
||||
merge_mcp_servers(&mut mcp, entry.source, &parsed.object, &entry.path)?;
|
||||
deep_merge_objects(&mut merged, &parsed.object);
|
||||
loaded_entries.push(entry);
|
||||
}
|
||||
|
||||
let config = build_runtime_config(merged, loaded_entries, mcp_servers)?;
|
||||
let config = build_runtime_config(merged, loaded_entries, mcp)?;
|
||||
Ok((config, all_warnings))
|
||||
}
|
||||
|
||||
@@ -462,7 +472,7 @@ impl ConfigLoader {
|
||||
pub fn inspect_collecting_warnings(&self) -> ConfigInspection {
|
||||
let mut merged = BTreeMap::new();
|
||||
let mut loaded_entries = Vec::new();
|
||||
let mut mcp_servers = BTreeMap::new();
|
||||
let mut mcp = McpConfigCollection::default();
|
||||
let mut warnings = Vec::new();
|
||||
let mut files = Vec::new();
|
||||
let mut load_error = None;
|
||||
@@ -546,7 +556,7 @@ impl ConfigLoader {
|
||||
}
|
||||
|
||||
if let Err(error) =
|
||||
merge_mcp_servers(&mut mcp_servers, entry.source, &parsed.object, &entry.path)
|
||||
merge_mcp_servers(&mut mcp, entry.source, &parsed.object, &entry.path)
|
||||
{
|
||||
let detail = error.to_string();
|
||||
load_error.get_or_insert_with(|| detail.clone());
|
||||
@@ -567,7 +577,7 @@ impl ConfigLoader {
|
||||
|
||||
annotate_config_file_precedence(&mut files);
|
||||
|
||||
let runtime_config = match build_runtime_config(merged, loaded_entries, mcp_servers) {
|
||||
let runtime_config = match build_runtime_config(merged, loaded_entries, mcp) {
|
||||
Ok(config) => Some(config),
|
||||
Err(error) => {
|
||||
load_error.get_or_insert_with(|| error.to_string());
|
||||
@@ -703,16 +713,14 @@ fn collect_config_key_paths_for_value(prefix: &str, value: &JsonValue, keys: &mu
|
||||
fn build_runtime_config(
|
||||
merged: BTreeMap<String, JsonValue>,
|
||||
loaded_entries: Vec<ConfigEntry>,
|
||||
mcp_servers: BTreeMap<String, ScopedMcpServerConfig>,
|
||||
mcp: McpConfigCollection,
|
||||
) -> Result<RuntimeConfig, ConfigError> {
|
||||
let merged_value = JsonValue::Object(merged.clone());
|
||||
|
||||
let feature_config = RuntimeFeatureConfig {
|
||||
hooks: parse_optional_hooks_config(&merged_value)?,
|
||||
plugins: parse_optional_plugin_config(&merged_value)?,
|
||||
mcp: McpConfigCollection {
|
||||
servers: mcp_servers,
|
||||
},
|
||||
mcp,
|
||||
oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
|
||||
model: parse_optional_model(&merged_value),
|
||||
aliases: parse_optional_aliases(&merged_value)?,
|
||||
@@ -1330,6 +1338,31 @@ impl McpConfigCollection {
|
||||
&self.servers
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn invalid_servers(&self) -> &[McpInvalidServerConfig] {
|
||||
&self.invalid_servers
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn total_configured(&self) -> usize {
|
||||
self.total_configured
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn valid_count(&self) -> usize {
|
||||
self.servers.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn invalid_count(&self) -> usize {
|
||||
self.invalid_servers.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn has_invalid_servers(&self) -> bool {
|
||||
!self.invalid_servers.is_empty()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, name: &str) -> Option<&ScopedMcpServerConfig> {
|
||||
self.servers.get(name)
|
||||
@@ -1421,7 +1454,7 @@ fn read_optional_json_object(path: &Path) -> Result<OptionalConfigFile, ConfigEr
|
||||
}
|
||||
|
||||
fn merge_mcp_servers(
|
||||
target: &mut BTreeMap<String, ScopedMcpServerConfig>,
|
||||
target: &mut McpConfigCollection,
|
||||
source: ConfigSource,
|
||||
root: &BTreeMap<String, JsonValue>,
|
||||
path: &Path,
|
||||
@@ -1430,21 +1463,48 @@ fn merge_mcp_servers(
|
||||
return Ok(());
|
||||
};
|
||||
let servers = expect_object(mcp_servers, &format!("{}: mcpServers", path.display()))?;
|
||||
target.total_configured += servers.len();
|
||||
for (name, value) in servers {
|
||||
let parsed = parse_mcp_server_config(
|
||||
name,
|
||||
value,
|
||||
&format!("{}: mcpServers.{name}", path.display()),
|
||||
)?;
|
||||
target.insert(
|
||||
let context = format!("{}: mcpServers.{name}", path.display());
|
||||
let Ok(object) = expect_object(value, &context) else {
|
||||
let error = expect_object(value, &context).expect_err("object parse must fail");
|
||||
target.servers.remove(name);
|
||||
target
|
||||
.invalid_servers
|
||||
.push(mcp_invalid_server(name, source, path, &context, &error));
|
||||
continue;
|
||||
};
|
||||
let required = match optional_bool(object, "required", &context) {
|
||||
Ok(required) => required.unwrap_or(false),
|
||||
Err(error) => {
|
||||
target.servers.remove(name);
|
||||
target
|
||||
.invalid_servers
|
||||
.push(mcp_invalid_server(name, source, path, &context, &error));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(error) = validate_mcp_server_keys(name, object, &context) {
|
||||
target.servers.remove(name);
|
||||
target
|
||||
.invalid_servers
|
||||
.push(mcp_invalid_server(name, source, path, &context, &error));
|
||||
continue;
|
||||
}
|
||||
let parsed = match parse_mcp_server_config(name, value, &context) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(error) => {
|
||||
target.servers.remove(name);
|
||||
target
|
||||
.invalid_servers
|
||||
.push(mcp_invalid_server(name, source, path, &context, &error));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
target.servers.insert(
|
||||
name.clone(),
|
||||
ScopedMcpServerConfig {
|
||||
required: optional_bool(
|
||||
expect_object(value, &format!("{}: mcpServers.{name}", path.display()))?,
|
||||
"required",
|
||||
&format!("{}: mcpServers.{name}", path.display()),
|
||||
)?
|
||||
.unwrap_or(false),
|
||||
required,
|
||||
scope: source,
|
||||
config: parsed,
|
||||
},
|
||||
@@ -1453,6 +1513,98 @@ fn merge_mcp_servers(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mcp_invalid_server(
|
||||
name: &str,
|
||||
source: ConfigSource,
|
||||
path: &Path,
|
||||
context: &str,
|
||||
error: &ConfigError,
|
||||
) -> McpInvalidServerConfig {
|
||||
let reason = config_error_detail(error);
|
||||
McpInvalidServerConfig {
|
||||
name: name.to_string(),
|
||||
scope: source,
|
||||
path: path.to_path_buf(),
|
||||
error_field: mcp_error_field(name, context, &reason),
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
fn config_error_detail(error: &ConfigError) -> String {
|
||||
match error {
|
||||
ConfigError::Io(error) => error.to_string(),
|
||||
ConfigError::Parse(reason) => reason.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_error_field(name: &str, context: &str, reason: &str) -> String {
|
||||
if let Some(field) = reason
|
||||
.split("missing string field ")
|
||||
.nth(1)
|
||||
.and_then(|tail| tail.split_whitespace().next())
|
||||
{
|
||||
return field
|
||||
.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
|
||||
.to_string();
|
||||
}
|
||||
if let Some(field) = reason
|
||||
.split("field ")
|
||||
.nth(1)
|
||||
.and_then(|tail| tail.split_whitespace().next())
|
||||
{
|
||||
return field
|
||||
.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
|
||||
.to_string();
|
||||
}
|
||||
reason
|
||||
.split_once(context)
|
||||
.and_then(|(_, tail)| tail.trim_start_matches('.').split(':').next())
|
||||
.filter(|field| !field.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("mcpServers.{name}"))
|
||||
}
|
||||
|
||||
fn validate_mcp_server_keys(
|
||||
server_name: &str,
|
||||
object: &BTreeMap<String, JsonValue>,
|
||||
context: &str,
|
||||
) -> Result<(), ConfigError> {
|
||||
let server_type =
|
||||
optional_string(object, "type", context)?.unwrap_or_else(|| infer_mcp_server_type(object));
|
||||
let allowed = match server_type {
|
||||
"stdio" => &[
|
||||
"type",
|
||||
"command",
|
||||
"args",
|
||||
"env",
|
||||
"toolCallTimeoutMs",
|
||||
"required",
|
||||
][..],
|
||||
"sse" | "http" => &[
|
||||
"type",
|
||||
"url",
|
||||
"headers",
|
||||
"headersHelper",
|
||||
"oauth",
|
||||
"required",
|
||||
][..],
|
||||
"ws" => &["type", "url", "headers", "headersHelper", "required"][..],
|
||||
"sdk" => &["type", "name", "required"][..],
|
||||
"claudeai-proxy" => &["type", "url", "id", "required"][..],
|
||||
other => {
|
||||
return Err(ConfigError::Parse(format!(
|
||||
"{context}: unsupported MCP server type for {server_name}: {other}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) {
|
||||
return Err(ConfigError::Parse(format!(
|
||||
"{context}: unknown MCP server field {key}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_optional_model(root: &JsonValue) -> Option<String> {
|
||||
root.as_object()
|
||||
.and_then(|object| object.get("model"))
|
||||
@@ -1719,7 +1871,7 @@ fn parse_mcp_server_config(
|
||||
optional_string(object, "type", context)?.unwrap_or_else(|| infer_mcp_server_type(object));
|
||||
match server_type {
|
||||
"stdio" => Ok(McpServerConfig::Stdio(McpStdioServerConfig {
|
||||
command: expect_string(object, "command", context)?.to_string(),
|
||||
command: expect_non_empty_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(),
|
||||
tool_call_timeout_ms: optional_u64(object, "toolCallTimeoutMs", context)?,
|
||||
@@ -1794,6 +1946,20 @@ fn expect_object<'a>(
|
||||
.ok_or_else(|| ConfigError::Parse(format!("{context}: expected JSON object")))
|
||||
}
|
||||
|
||||
fn expect_non_empty_string<'a>(
|
||||
object: &'a BTreeMap<String, JsonValue>,
|
||||
key: &str,
|
||||
context: &str,
|
||||
) -> Result<&'a str, ConfigError> {
|
||||
let value = expect_string(object, key, context)?;
|
||||
if value.trim().is_empty() {
|
||||
return Err(ConfigError::Parse(format!(
|
||||
"{context}: field {key} must be a non-empty string"
|
||||
)));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn expect_string<'a>(
|
||||
object: &'a BTreeMap<String, JsonValue>,
|
||||
key: &str,
|
||||
@@ -2843,7 +3009,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_mcp_server_shapes() {
|
||||
fn records_invalid_mcp_server_shapes_without_rejecting_config_440() {
|
||||
// given
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
@@ -2857,18 +3023,72 @@ mod tests {
|
||||
.expect("write broken settings");
|
||||
|
||||
// when
|
||||
let error = ConfigLoader::new(&cwd, &home)
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect_err("config should fail");
|
||||
.expect("invalid MCP entries should not block otherwise loadable config");
|
||||
|
||||
// then
|
||||
assert!(error
|
||||
.to_string()
|
||||
assert!(loaded.mcp().servers().is_empty());
|
||||
assert_eq!(loaded.mcp().total_configured(), 1);
|
||||
assert_eq!(loaded.mcp().invalid_count(), 1);
|
||||
let invalid = &loaded.mcp().invalid_servers()[0];
|
||||
assert_eq!(invalid.name, "broken");
|
||||
assert_eq!(invalid.error_field, "url");
|
||||
assert!(invalid
|
||||
.reason
|
||||
.contains("mcpServers.broken: missing string field url"));
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_valid_mcp_servers_and_collects_all_invalid_siblings_440() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claw");
|
||||
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": {
|
||||
"valid-server": {"command": "/bin/echo", "args": ["hello"]},
|
||||
"missing-command": {"args": ["arg-only"]},
|
||||
"empty-command": {"command": ""},
|
||||
"wrong-type-command": {"command": 42},
|
||||
"extra-unknown-field": {"command": "/bin/echo", "extra": true}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write mixed settings");
|
||||
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("valid MCP entries should load beside invalid siblings");
|
||||
|
||||
assert_eq!(loaded.mcp().total_configured(), 5);
|
||||
assert_eq!(loaded.mcp().valid_count(), 1);
|
||||
assert_eq!(loaded.mcp().invalid_count(), 4);
|
||||
assert!(loaded.mcp().get("valid-server").is_some());
|
||||
let invalid_names = loaded
|
||||
.mcp()
|
||||
.invalid_servers()
|
||||
.iter()
|
||||
.map(|server| server.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
invalid_names,
|
||||
vec![
|
||||
"empty-command",
|
||||
"extra-unknown-field",
|
||||
"missing-command",
|
||||
"wrong-type-command",
|
||||
]
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_user_defined_model_aliases_from_settings() {
|
||||
// given
|
||||
|
||||
@@ -67,11 +67,12 @@ pub use compact::{
|
||||
pub use config::{
|
||||
suppress_config_warnings_for_json_mode, ConfigEntry, ConfigError, ConfigFileReport,
|
||||
ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource, McpConfigCollection,
|
||||
McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
|
||||
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
|
||||
ProviderFallbackConfig, ResolvedPermissionMode, RulesImportConfig, RuntimeConfig,
|
||||
RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, RuntimePermissionRuleConfig,
|
||||
RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
||||
McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig,
|
||||
McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
|
||||
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
|
||||
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
|
||||
RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
|
||||
CLAW_SETTINGS_SCHEMA_NAME,
|
||||
};
|
||||
pub use config_validate::{
|
||||
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
|
||||
|
||||
Reference in New Issue
Block a user