Skip to content

Commit faeaa1d

Browse files
committed
feat: ultraworkers#144 phase 1 + ROADMAP filing — claw mcp degrades gracefully on malformed config
Filing + Phase 1 fix in one commit (sibling of ultraworkers#143). ## Context With ultraworkers#143 Phase 1 landed (`claw status` degrades), `claw mcp` was the remaining diagnostic surface that hard-failed on a malformed `.claw.json`. Same input, same parse error, same partial-success violation. Fresh dogfood at 18:59 KST caught it on main HEAD `e2a43fc`. ## Changes ### ROADMAP.md Added Pinpoint ultraworkers#144 documenting the gap and acceptance criteria. Joins the partial-success / Principle ultraworkers#5 cluster with ultraworkers#143. ### rust/crates/commands/src/lib.rs `render_mcp_report_for()` + `render_mcp_report_json_for()` now catch the ConfigError at loader.load() instead of propagating: - **Text mode** prepends a "Config load error" block (same shape as ultraworkers#143's status output) before the MCP listing. The listing still renders with empty servers so the output structure is preserved. - **JSON mode** adds top-level `status: "ok" | "degraded"` + `config_load_error: string | null` fields alongside existing fields (`kind`, `action`, `working_directory`, `configured_servers`, `servers[]`). On clean runs, `status: "ok"` and `config_load_error: null`. On parse failure, `status: "degraded"`, `config_load_error: "..."`, `servers: []`, exit 0. - Both list and show actions get the same treatment. ### Regression test `commands::tests::mcp_degrades_gracefully_on_malformed_mcp_config_144`: - Injects the same malformed .claw.json as ultraworkers#143 (one valid + one broken mcpServers entry). - Asserts mcp list returns Ok (not Err). - Asserts top-level status: "degraded" and config_load_error names the malformed field path. - Asserts show action also degrades. - Asserts clean path returns status: "ok" with config_load_error null. ## Live verification $ claw mcp --output-format json { "action": "list", "kind": "mcp", "status": "degraded", "config_load_error": ".../.claw.json: mcpServers.missing-command: missing string field command", "working_directory": "/Users/yeongyu/clawd", "configured_servers": 0, "servers": [] } Exit 0. ## Contract alignment after this commit All three diagnostic surfaces match now: - `doctor` — degraded envelope with typed check entries ✅ - `status` — degraded envelope with config_load_error ✅ (ultraworkers#143) - `mcp` — degraded envelope with config_load_error ✅ (this commit) Phase 2 (typed-error object joining taxonomy §4.44) tracked separately across all three surfaces. Full workspace test green except pre-existing resume_latest flake (unrelated). Closes ROADMAP ultraworkers#144 phase 1.
1 parent e2a43fc commit faeaa1d

2 files changed

Lines changed: 222 additions & 22 deletions

File tree

ROADMAP.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5455,3 +5455,68 @@ Doctor keeps going and produces a full typed report. Status refuses to produce a
54555455
**Blocker.** None for Phase 1. Phase 2 depends on the typed-error taxonomy landing (ROADMAP §4.44), but Phase 1 can ship independently and be tightened later.
54565456

54575457
**Source.** Jobdori dogfood 2026-04-21 18:30 KST on main HEAD `e73b6a2`, surfaced by running `claw status` in `/Users/yeongyu/clawd` which contains a `.claw.json` with deliberately broken MCP entries. Joins **partial-success / degraded-mode** cluster (Principle #5, Phase 6) and **surface consistency** cluster (#141 help-contract unification, #108 typo guard). Session tally: ROADMAP #143.
5458+
5459+
## Pinpoint #144. `claw mcp` hard-fails on malformed MCP config — same surface inconsistency as #143, one command over
5460+
5461+
**Gap.** With `claw status` fixed in #143 Phase 1, `claw mcp` is now the remaining diagnostic surface that hard-fails on a malformed `.claw.json`. Same input, same parse error, same partial-success violation.
5462+
5463+
**Verified on main HEAD `e2a43fc` (2026-04-21 18:59 KST):**
5464+
5465+
Same `.claw.json` used for #143 repro (one valid `everything` server + one malformed `missing-command` entry).
5466+
5467+
`claw mcp`:
5468+
```
5469+
error: /Users/.../.claw.json: mcpServers.missing-command: missing string field command
5470+
Run `claw --help` for usage.
5471+
```
5472+
Exit 1. No list. The well-formed `everything` server is invisible.
5473+
5474+
`claw mcp --output-format json`:
5475+
```json
5476+
{"error":"/Users/.../.claw.json: mcpServers.missing-command: missing string field command","type":"error"}
5477+
```
5478+
Exit 1. Same story.
5479+
5480+
`claw status --output-format json` on the same file (post-#143):
5481+
```json
5482+
{"kind":"status","status":"degraded","config_load_error":"...","workspace":{...},"sandbox":{...},...}
5483+
```
5484+
Exit 0. Full envelope with error surfaced.
5485+
5486+
**Why this is a clawability gap (same family as #143).**
5487+
1. **Principle #5 violation**: partial success is first-class. One malformed entry shouldn't make the entire MCP subsystem invisible.
5488+
2. **Surface inconsistency (cluster of 3)**: after #143 Phase 1, the behavior matrix is:
5489+
- `doctor` — degraded envelope ✅
5490+
- `status` — degraded envelope ✅ (#143)
5491+
- `mcp` — hard-fail ❌ (this pinpoint)
5492+
3. **Clawhip impact**: `claw mcp --output-format json` is used by orchestrators to detect which MCP servers are available before invoking tools. A broken probe forces clawhip to fall back to doctor parse, which is suboptimal.
5493+
5494+
**Fix shape (~40 lines, mirrors #143 Phase 1).**
5495+
1. Make `render_mcp_report_json_for()` and `render_mcp_report_for()` catch the `ConfigError` at `loader.load()?`.
5496+
2. On parse failure, emit a degraded envelope:
5497+
```json
5498+
{
5499+
"kind": "mcp",
5500+
"action": "list",
5501+
"status": "degraded",
5502+
"config_load_error": "...",
5503+
"working_directory": "...",
5504+
"configured_servers": 0,
5505+
"servers": []
5506+
}
5507+
```
5508+
3. Text mode: prepend a "Config load error" block (same shape as #143) before the "MCP" block.
5509+
4. Exit 0 so downstream probes don't treat a parse error as process death.
5510+
5511+
**Acceptance.**
5512+
- `claw mcp` and `claw mcp --output-format json` on a workspace with malformed config exit 0.
5513+
- JSON mode includes `status: "degraded"` and `config_load_error` field.
5514+
- Text mode shows the parse error in a separate block, not as the only output.
5515+
- Clean path (no config errors) still returns `status: "ok"` (or equivalent — align with #143 serializer).
5516+
- Regression test: inject malformed config, assert mcp returns degraded envelope.
5517+
5518+
**Blocker.** None. Mirrors #143 Phase 1 shape exactly.
5519+
5520+
**Future phase (joins #143 Phase 2).** When typed-error taxonomy lands (§4.44), promote `config_load_error` from string to typed object across `doctor`, `status`, and `mcp` in one pass.
5521+
5522+
**Source.** Jobdori dogfood 2026-04-21 18:59 KST on main HEAD `e2a43fc`. Joins **partial-success** cluster (#143, Principle #5) and **surface consistency** cluster. Session tally: ROADMAP #144.

rust/crates/commands/src/lib.rs

Lines changed: 157 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2554,11 +2554,22 @@ fn render_mcp_report_for(
25542554

25552555
match normalize_optional_args(args) {
25562556
None | Some("list") => {
2557-
let runtime_config = loader.load()?;
2558-
Ok(render_mcp_summary_report(
2559-
cwd,
2560-
runtime_config.mcp().servers(),
2561-
))
2557+
// #144: degrade gracefully on config parse failure (same contract
2558+
// as #143 for `status`). Text mode prepends a "Config load error"
2559+
// block before the MCP list; the list falls back to empty.
2560+
match loader.load() {
2561+
Ok(runtime_config) => Ok(render_mcp_summary_report(
2562+
cwd,
2563+
runtime_config.mcp().servers(),
2564+
)),
2565+
Err(err) => {
2566+
let empty = std::collections::BTreeMap::new();
2567+
Ok(format!(
2568+
"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{}",
2569+
render_mcp_summary_report(cwd, &empty)
2570+
))
2571+
}
2572+
}
25622573
}
25632574
Some(args) if is_help_arg(args) => Ok(render_mcp_usage(None)),
25642575
Some("show") => Ok(render_mcp_usage(Some("show"))),
@@ -2571,12 +2582,19 @@ fn render_mcp_report_for(
25712582
if parts.next().is_some() {
25722583
return Ok(render_mcp_usage(Some(args)));
25732584
}
2574-
let runtime_config = loader.load()?;
2575-
Ok(render_mcp_server_report(
2576-
cwd,
2577-
server_name,
2578-
runtime_config.mcp().get(server_name),
2579-
))
2585+
// #144: same degradation for `mcp show`; if config won't parse,
2586+
// the specific server lookup can't succeed, so report the parse
2587+
// error with context.
2588+
match loader.load() {
2589+
Ok(runtime_config) => Ok(render_mcp_server_report(
2590+
cwd,
2591+
server_name,
2592+
runtime_config.mcp().get(server_name),
2593+
)),
2594+
Err(err) => Ok(format!(
2595+
"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"
2596+
)),
2597+
}
25802598
}
25812599
Some(args) => Ok(render_mcp_usage(Some(args))),
25822600
}
@@ -2599,11 +2617,35 @@ fn render_mcp_report_json_for(
25992617

26002618
match normalize_optional_args(args) {
26012619
None | Some("list") => {
2602-
let runtime_config = loader.load()?;
2603-
Ok(render_mcp_summary_report_json(
2604-
cwd,
2605-
runtime_config.mcp().servers(),
2606-
))
2620+
// #144: match #143's degraded envelope contract. On config parse
2621+
// failure, emit top-level `status: "degraded"` with
2622+
// `config_load_error`, empty servers[], and exit 0. On clean
2623+
// runs, the existing serializer adds `status: "ok"` below.
2624+
match loader.load() {
2625+
Ok(runtime_config) => {
2626+
let mut value = render_mcp_summary_report_json(
2627+
cwd,
2628+
runtime_config.mcp().servers(),
2629+
);
2630+
if let Some(map) = value.as_object_mut() {
2631+
map.insert("status".to_string(), Value::String("ok".to_string()));
2632+
map.insert("config_load_error".to_string(), Value::Null);
2633+
}
2634+
Ok(value)
2635+
}
2636+
Err(err) => {
2637+
let empty = std::collections::BTreeMap::new();
2638+
let mut value = render_mcp_summary_report_json(cwd, &empty);
2639+
if let Some(map) = value.as_object_mut() {
2640+
map.insert("status".to_string(), Value::String("degraded".to_string()));
2641+
map.insert(
2642+
"config_load_error".to_string(),
2643+
Value::String(err.to_string()),
2644+
);
2645+
}
2646+
Ok(value)
2647+
}
2648+
}
26072649
}
26082650
Some(args) if is_help_arg(args) => Ok(render_mcp_usage_json(None)),
26092651
Some("show") => Ok(render_mcp_usage_json(Some("show"))),
@@ -2616,12 +2658,29 @@ fn render_mcp_report_json_for(
26162658
if parts.next().is_some() {
26172659
return Ok(render_mcp_usage_json(Some(args)));
26182660
}
2619-
let runtime_config = loader.load()?;
2620-
Ok(render_mcp_server_report_json(
2621-
cwd,
2622-
server_name,
2623-
runtime_config.mcp().get(server_name),
2624-
))
2661+
// #144: same degradation pattern for show action.
2662+
match loader.load() {
2663+
Ok(runtime_config) => {
2664+
let mut value = render_mcp_server_report_json(
2665+
cwd,
2666+
server_name,
2667+
runtime_config.mcp().get(server_name),
2668+
);
2669+
if let Some(map) = value.as_object_mut() {
2670+
map.insert("status".to_string(), Value::String("ok".to_string()));
2671+
map.insert("config_load_error".to_string(), Value::Null);
2672+
}
2673+
Ok(value)
2674+
}
2675+
Err(err) => Ok(serde_json::json!({
2676+
"kind": "mcp",
2677+
"action": "show",
2678+
"server": server_name,
2679+
"status": "degraded",
2680+
"config_load_error": err.to_string(),
2681+
"working_directory": cwd.display().to_string(),
2682+
})),
2683+
}
26252684
}
26262685
Some(args) => Ok(render_mcp_usage_json(Some(args))),
26272686
}
@@ -5479,6 +5538,82 @@ mod tests {
54795538
let _ = fs::remove_dir_all(config_home);
54805539
}
54815540

5541+
#[test]
5542+
fn mcp_degrades_gracefully_on_malformed_mcp_config_144() {
5543+
// #144: mirror of #143's partial-success contract for `claw mcp`.
5544+
// Previously `mcp` hard-failed on any config parse error, hiding
5545+
// well-formed servers and forcing claws to fall back to `doctor`.
5546+
// Now `mcp` emits a degraded envelope instead: exit 0, status:
5547+
// "degraded", config_load_error populated, servers[] empty.
5548+
let _guard = env_guard();
5549+
let workspace = temp_dir("mcp-degrades-144");
5550+
let config_home = temp_dir("mcp-degrades-144-cfg");
5551+
fs::create_dir_all(workspace.join(".claw")).expect("create workspace .claw dir");
5552+
fs::create_dir_all(&config_home).expect("create config home");
5553+
// One valid server + one malformed entry missing `command`.
5554+
fs::write(
5555+
workspace.join(".claw.json"),
5556+
r#"{
5557+
"mcpServers": {
5558+
"everything": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-everything"]},
5559+
"missing-command": {"args": ["arg-only-no-command"]}
5560+
}
5561+
}
5562+
"#,
5563+
)
5564+
.expect("write malformed .claw.json");
5565+
5566+
let loader = ConfigLoader::new(&workspace, &config_home);
5567+
// list action: must return Ok (not Err) with degraded envelope.
5568+
let list = render_mcp_report_json_for(&loader, &workspace, None)
5569+
.expect("mcp list should not hard-fail on config parse errors (#144)");
5570+
assert_eq!(list["kind"], "mcp");
5571+
assert_eq!(list["action"], "list");
5572+
assert_eq!(
5573+
list["status"].as_str(),
5574+
Some("degraded"),
5575+
"top-level status should be 'degraded': {list}"
5576+
);
5577+
let err = list["config_load_error"]
5578+
.as_str()
5579+
.expect("config_load_error must be a string on degraded runs");
5580+
assert!(
5581+
err.contains("mcpServers.missing-command"),
5582+
"config_load_error should name the malformed field path: {err}"
5583+
);
5584+
assert_eq!(list["configured_servers"], 0);
5585+
assert!(list["servers"].as_array().unwrap().is_empty());
5586+
5587+
// show action: should also degrade (not hard-fail).
5588+
let show = render_mcp_report_json_for(&loader, &workspace, Some("show everything"))
5589+
.expect("mcp show should not hard-fail on config parse errors (#144)");
5590+
assert_eq!(show["kind"], "mcp");
5591+
assert_eq!(show["action"], "show");
5592+
assert_eq!(
5593+
show["status"].as_str(),
5594+
Some("degraded"),
5595+
"show action should also report status: 'degraded': {show}"
5596+
);
5597+
assert!(show["config_load_error"].is_string());
5598+
5599+
// Clean path: status: "ok", config_load_error: null.
5600+
let clean_ws = temp_dir("mcp-degrades-144-clean");
5601+
fs::create_dir_all(&clean_ws).expect("clean ws");
5602+
let clean_loader = ConfigLoader::new(&clean_ws, &config_home);
5603+
let clean_list = render_mcp_report_json_for(&clean_loader, &clean_ws, None)
5604+
.expect("clean mcp list should succeed");
5605+
assert_eq!(
5606+
clean_list["status"].as_str(),
5607+
Some("ok"),
5608+
"clean run should report status: 'ok'"
5609+
);
5610+
assert!(clean_list["config_load_error"].is_null());
5611+
5612+
let _ = fs::remove_dir_all(workspace);
5613+
let _ = fs::remove_dir_all(config_home);
5614+
let _ = fs::remove_dir_all(clean_ws);
5615+
}
5616+
54825617
#[test]
54835618
fn parses_quoted_skill_frontmatter_values() {
54845619
let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n";

0 commit comments

Comments
 (0)