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:
bellman
2026-06-04 18:31:58 +09:00
parent 10fe72498a
commit 4619375c14
8 changed files with 693 additions and 183 deletions

View File

@@ -6,8 +6,9 @@ use std::path::{Path, PathBuf};
use plugins::{PluginError, PluginLoadFailure, PluginManager, PluginSummary};
use runtime::{
compact_session, CompactionConfig, ConfigLoader, ConfigSource, McpOAuthConfig, McpServerConfig,
RuntimeConfig, ScopedMcpServerConfig, Session,
compact_session, CompactionConfig, ConfigLoader, ConfigSource, McpConfigCollection,
McpInvalidServerConfig, McpOAuthConfig, McpServerConfig, RuntimeConfig, ScopedMcpServerConfig,
Session,
};
use serde_json::{json, Value};
@@ -3064,24 +3065,16 @@ fn render_mcp_report_for(
}
match normalize_optional_args(args) {
None | Some("list") => {
// #144: degrade gracefully on config parse failure (same contract
// as #143 for `status`). Text mode prepends a "Config load error"
// block before the MCP list; the list falls back to empty.
match loader.load() {
Ok(runtime_config) => Ok(render_mcp_summary_report(
cwd,
runtime_config.mcp().servers(),
)),
Err(err) => {
let empty = std::collections::BTreeMap::new();
Ok(format!(
"Config load error\n Status fail\n Summary runtime config failed to load; reporting partial MCP view\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun\n\n{}",
render_mcp_summary_report(cwd, &empty)
))
}
None | Some("list") => match loader.load() {
Ok(runtime_config) => Ok(render_mcp_summary_report(cwd, runtime_config.mcp())),
Err(err) => {
let empty = McpConfigCollection::default();
Ok(format!(
"Config load error\n Status fail\n Summary runtime config failed to load; reporting partial MCP view\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun\n\n{}",
render_mcp_summary_report(cwd, &empty)
))
}
}
},
Some(args) if is_help_arg(args) => Ok(render_mcp_usage(None)),
Some("show") => Ok(render_mcp_missing_argument_text("show")),
Some(args) if args.split_whitespace().next() == Some("show") => {
@@ -3100,7 +3093,7 @@ fn render_mcp_report_for(
Ok(runtime_config) => Ok(render_mcp_server_report(
cwd,
server_name,
runtime_config.mcp().get(server_name),
runtime_config.mcp(),
)),
Err(err) => Ok(format!(
"Config load error\n Status fail\n Summary runtime config failed to load; cannot resolve `{server_name}`\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun"
@@ -3162,35 +3155,38 @@ fn render_mcp_report_json_for(
}
match normalize_optional_args(args) {
None | Some("list") => {
// #144: match #143's degraded envelope contract. On config parse
// failure, emit top-level `status: "degraded"` with
// `config_load_error`, empty servers[], and exit 0. On clean
// runs, the existing serializer adds `status: "ok"` below.
match load_runtime_config_without_stderr_warnings(loader) {
Ok(runtime_config) => {
let mut value =
render_mcp_summary_report_json(cwd, runtime_config.mcp().servers());
if let Some(map) = value.as_object_mut() {
map.insert("status".to_string(), Value::String("ok".to_string()));
map.insert("config_load_error".to_string(), Value::Null);
}
Ok(value)
}
Err(err) => {
let empty = std::collections::BTreeMap::new();
let mut value = render_mcp_summary_report_json(cwd, &empty);
if let Some(map) = value.as_object_mut() {
map.insert("status".to_string(), Value::String("degraded".to_string()));
map.insert(
"config_load_error".to_string(),
Value::String(err.to_string()),
);
}
Ok(value)
None | Some("list") => match load_runtime_config_without_stderr_warnings(loader) {
Ok(runtime_config) => {
let mut value = render_mcp_summary_report_json(cwd, runtime_config.mcp());
if let Some(map) = value.as_object_mut() {
map.insert(
"status".to_string(),
Value::String(
if runtime_config.mcp().has_invalid_servers() {
"degraded"
} else {
"ok"
}
.to_string(),
),
);
map.insert("config_load_error".to_string(), Value::Null);
}
Ok(value)
}
}
Err(err) => {
let empty = McpConfigCollection::default();
let mut value = render_mcp_summary_report_json(cwd, &empty);
if let Some(map) = value.as_object_mut() {
map.insert("status".to_string(), Value::String("degraded".to_string()));
map.insert(
"config_load_error".to_string(),
Value::String(err.to_string()),
);
}
Ok(value)
}
},
Some(args) if is_help_arg(args) => Ok(render_mcp_usage_json(None)),
Some("show") => Ok(render_mcp_missing_argument_json("show")),
Some(args) if args.split_whitespace().next() == Some("show") => {
@@ -3205,16 +3201,21 @@ fn render_mcp_report_json_for(
// #144: same degradation pattern for show action.
match load_runtime_config_without_stderr_warnings(loader) {
Ok(runtime_config) => {
let mut value = render_mcp_server_report_json(
cwd,
server_name,
runtime_config.mcp().get(server_name),
);
let mut value =
render_mcp_server_report_json(cwd, server_name, runtime_config.mcp());
if let Some(map) = value.as_object_mut() {
// Only override status to "ok" if the server was found;
// render_mcp_server_report_json already sets status:"error" for not-found.
if map.get("found") == Some(&Value::Bool(true)) {
map.insert("status".to_string(), Value::String("ok".to_string()));
map.insert(
"status".to_string(),
Value::String(
if runtime_config.mcp().has_invalid_servers() {
"degraded"
} else {
"ok"
}
.to_string(),
),
);
}
map.insert("config_load_error".to_string(), Value::Null);
}
@@ -4426,55 +4427,80 @@ fn io_error_reason(error: &std::io::Error) -> &'static str {
}
}
fn render_mcp_summary_report(
cwd: &Path,
servers: &BTreeMap<String, ScopedMcpServerConfig>,
) -> String {
fn render_mcp_summary_report(cwd: &Path, mcp: &McpConfigCollection) -> String {
let servers = mcp.servers();
let mut lines = vec![
"MCP".to_string(),
format!(" Working directory {}", cwd.display()),
format!(" Configured servers {}", servers.len()),
format!(" Configured servers {}", mcp.valid_count()),
format!(" Total entries {}", mcp.total_configured()),
format!(" Invalid entries {}", mcp.invalid_count()),
];
if servers.is_empty() {
lines.push(" No MCP servers configured.".to_string());
return lines.join("\n");
lines.push(" No valid MCP servers configured.".to_string());
}
lines.push(String::new());
for (name, server) in servers {
lines.push(format!(
" {name:<16} {transport:<13} {scope:<7} {summary}",
transport = mcp_transport_label(&server.config),
scope = config_source_label(server.scope),
summary = mcp_server_summary(&server.config)
));
if !servers.is_empty() {
lines.push(String::new());
for (name, server) in servers {
lines.push(format!(
" {name:<16} {transport:<13} {scope:<7} {summary}",
transport = mcp_transport_label(&server.config),
scope = config_source_label(server.scope),
summary = mcp_server_summary(&server.config)
));
}
}
if !mcp.invalid_servers().is_empty() {
lines.push(String::new());
lines.push(" Invalid MCP servers".to_string());
for invalid in mcp.invalid_servers() {
lines.push(format!(" - {}: {}", invalid.name, invalid.reason));
}
}
lines.join("\n")
}
fn render_mcp_summary_report_json(
cwd: &Path,
servers: &BTreeMap<String, ScopedMcpServerConfig>,
) -> Value {
fn render_mcp_summary_report_json(cwd: &Path, mcp: &McpConfigCollection) -> Value {
json!({
"kind": "mcp",
"action": "list",
"working_directory": cwd.display().to_string(),
"configured_servers": servers.len(),
"servers": servers
"configured_servers": mcp.valid_count(),
"total_configured": mcp.total_configured(),
"valid_count": mcp.valid_count(),
"invalid_count": mcp.invalid_count(),
"invalid_servers": invalid_mcp_servers_json(mcp.invalid_servers()),
"servers": mcp
.servers()
.iter()
.map(|(name, server)| mcp_server_json(name, server))
.collect::<Vec<_>>(),
})
}
fn render_mcp_server_report(
cwd: &Path,
server_name: &str,
server: Option<&ScopedMcpServerConfig>,
) -> String {
let Some(server) = server else {
fn invalid_mcp_servers_json(invalid_servers: &[McpInvalidServerConfig]) -> Value {
Value::Array(
invalid_servers
.iter()
.map(|server| {
json!({
"name": &server.name,
"scope": config_source_json(server.scope),
"path": server.path.display().to_string(),
"error_field": &server.error_field,
"reason": &server.reason,
"valid": false,
})
})
.collect::<Vec<_>>(),
)
}
fn render_mcp_server_report(cwd: &Path, server_name: &str, mcp: &McpConfigCollection) -> String {
let Some(server) = mcp.get(server_name) else {
return format!(
"MCP\n Working directory {}\n Result server `{server_name}` is not configured",
cwd.display()
@@ -4552,9 +4578,9 @@ fn render_mcp_server_report(
fn render_mcp_server_report_json(
cwd: &Path,
server_name: &str,
server: Option<&ScopedMcpServerConfig>,
mcp: &McpConfigCollection,
) -> Value {
match server {
match mcp.get(server_name) {
Some(server) => json!({
"kind": "mcp",
"action": "show",
@@ -4562,6 +4588,10 @@ fn render_mcp_server_report_json(
"working_directory": cwd.display().to_string(),
"found": true,
"server": mcp_server_json(server_name, server),
"total_configured": mcp.total_configured(),
"valid_count": mcp.valid_count(),
"invalid_count": mcp.invalid_count(),
"invalid_servers": invalid_mcp_servers_json(mcp.invalid_servers()),
}),
None => json!({
"kind": "mcp",
@@ -4574,6 +4604,10 @@ fn render_mcp_server_report_json(
"message": format!("server `{server_name}` is not configured"),
// #761: hint so callers know how to enumerate configured MCP servers
"hint": "Run `claw mcp list` to see configured servers.",
"total_configured": mcp.total_configured(),
"valid_count": mcp.valid_count(),
"invalid_count": mcp.invalid_count(),
"invalid_servers": invalid_mcp_servers_json(mcp.invalid_servers()),
}),
}
}
@@ -4967,6 +5001,7 @@ fn mcp_server_details_json(config: &McpServerConfig) -> Value {
fn mcp_server_json(name: &str, server: &ScopedMcpServerConfig) -> Value {
json!({
"name": name,
"valid": true,
"required": server.required,
"scope": config_source_json(server.scope),
"transport": mcp_transport_json(&server.config),
@@ -6619,12 +6654,9 @@ mod tests {
}
#[test]
fn mcp_degrades_gracefully_on_malformed_mcp_config_144() {
// #144: mirror of #143's partial-success contract for `claw mcp`.
// Previously `mcp` hard-failed on any config parse error, hiding
// well-formed servers and forcing claws to fall back to `doctor`.
// Now `mcp` emits a degraded envelope instead: exit 0, status:
// "degraded", config_load_error populated, servers[] empty.
fn mcp_loads_valid_servers_and_reports_invalid_siblings_440() {
// #440: invalid sibling MCP entries must not drop valid servers, and
// the JSON envelope must expose all rejected entries for one-pass repair.
let _guard = env_guard();
let workspace = temp_dir("mcp-degrades-144");
let config_home = temp_dir("mcp-degrades-144-cfg");
@@ -6654,17 +6686,19 @@ mod tests {
Some("degraded"),
"top-level status should be 'degraded': {list}"
);
let err = list["config_load_error"]
assert!(list["config_load_error"].is_null());
assert_eq!(list["configured_servers"], 1);
assert_eq!(list["total_configured"], 2);
assert_eq!(list["valid_count"], 1);
assert_eq!(list["invalid_count"], 1);
assert_eq!(list["servers"][0]["name"], "everything");
assert_eq!(list["servers"][0]["valid"], true);
assert_eq!(list["invalid_servers"][0]["name"], "missing-command");
assert!(list["invalid_servers"][0]["reason"]
.as_str()
.expect("config_load_error must be a string on degraded runs");
assert!(
err.contains("mcpServers.missing-command"),
"config_load_error should name the malformed field path: {err}"
);
assert_eq!(list["configured_servers"], 0);
assert!(list["servers"].as_array().unwrap().is_empty());
.is_some_and(|reason| reason.contains("missing string field command")));
// show action: should also degrade (not hard-fail).
// show action still resolves valid siblings while carrying validation metadata.
let show = render_mcp_report_json_for(&loader, &workspace, Some("show everything"))
.expect("mcp show should not hard-fail on config parse errors (#144)");
assert_eq!(show["kind"], "mcp");
@@ -6674,7 +6708,11 @@ mod tests {
Some("degraded"),
"show action should also report status: 'degraded': {show}"
);
assert!(show["config_load_error"].is_string());
assert!(show["config_load_error"].is_null());
assert_eq!(show["found"], true);
assert_eq!(show["server"]["name"], "everything");
assert_eq!(show["server"]["valid"], true);
assert_eq!(show["invalid_count"], 1);
// Clean path: status: "ok", config_load_error: null.
let clean_ws = temp_dir("mcp-degrades-144-clean");