diff --git a/crates/cccc-daemon/src/ops/runtime_mcp/mod.rs b/crates/cccc-daemon/src/ops/runtime_mcp/mod.rs index 83a6d9847..350d1c3bc 100644 --- a/crates/cccc-daemon/src/ops/runtime_mcp/mod.rs +++ b/crates/cccc-daemon/src/ops/runtime_mcp/mod.rs @@ -161,13 +161,7 @@ fn inspect( env, expected, ), - ActorRuntime::Devin => inspect_cli( - runtime, - &["devin", "mcp", "get", "cccc"], - cwd, - env, - expected, - ), + ActorRuntime::Devin => inspect_devin(cwd, env, expected), ActorRuntime::Grok => inspect_cli( runtime, &["grok", "mcp", "list", "--json"], @@ -179,6 +173,35 @@ fn inspect( } } +fn inspect_devin( + cwd: &Path, + env: &BTreeMap, + expected: &[String], +) -> Result { + let inspected = inspect_cli( + ActorRuntime::Devin, + &["devin", "mcp", "get", "cccc"], + cwd, + env, + expected, + )?; + if inspected.state == State::Ready { + return Ok(inspected); + } + let listed = inspect_cli( + ActorRuntime::Devin, + &["devin", "mcp", "list"], + cwd, + env, + expected, + )?; + if listed.state == State::Ready { + Ok(listed) + } else { + Ok(inspected) + } +} + fn inspect_cli( runtime: ActorRuntime, command: &[&str], diff --git a/crates/cccc-daemon/src/ops/runtime_mcp/state.rs b/crates/cccc-daemon/src/ops/runtime_mcp/state.rs index 80a16c010..163d4f668 100644 --- a/crates/cccc-daemon/src/ops/runtime_mcp/state.rs +++ b/crates/cccc-daemon/src/ops/runtime_mcp/state.rs @@ -160,16 +160,67 @@ pub(super) fn command_output_state( Report { state, source } } ActorRuntime::Devin => { - if !output.to_ascii_lowercase().contains("stdio") { - return Report::new(State::Missing); + if let Ok(document) = serde_json::from_str::(output) + && let Some(entry) = find_devin_entry(&document) + { + return Report::new(if common_matches(entry, expected) { + State::Ready + } else { + State::Stale + }); } let command = debug_string(output, "command"); - let args = debug_args(output); - Report::new(if command_matches(&command, &args, expected) { - State::Ready - } else { - State::Stale - }) + if !command.is_empty() && output.to_ascii_lowercase().contains("stdio") { + let args = debug_args(output); + return Report::new(if command_matches(&command, &args, expected) { + State::Ready + } else { + State::Stale + }); + } + let entry = parse_key_values(output); + let server = entry + .get("server") + .or_else(|| entry.get("name")) + .map(String::as_str); + let transport = entry + .get("transport") + .or_else(|| entry.get("type")) + .map(String::as_str) + .unwrap_or("stdio"); + if server.is_some_and(|server| server.eq_ignore_ascii_case("cccc")) + && matches!( + transport.to_ascii_lowercase().as_str(), + "" | "stdio" | "local" + ) + && let Some(command) = entry.get("command") + { + let args = entry + .get("args") + .map(|value| { + value + .split_whitespace() + .map(str::to_owned) + .collect::>() + }) + .unwrap_or_default(); + return Report::new(if command_matches(command, &args, expected) { + State::Ready + } else { + State::Stale + }); + } + let command_line = devin_list_command(output, "cccc"); + Report::new( + if command_line + .as_deref() + .is_some_and(|line| command_line_matches(line, expected)) + { + State::Ready + } else { + State::Missing + }, + ) } ActorRuntime::Grok => { let Ok(entries) = serde_json::from_str::>(output) else { @@ -288,20 +339,107 @@ fn command_and_args_match(entry: &Value, expected: &[String]) -> bool { fn command_matches(command: &str, args: &[String], expected: &[String]) -> bool { expected.first().is_some_and(|expected_command| { - normalize_path(command) == normalize_path(expected_command) - && args == expected.get(1..).unwrap_or_default() + paths_match(command, expected_command) && args == expected.get(1..).unwrap_or_default() }) } +fn paths_match(actual: &str, expected: &str) -> bool { + let actual = normalize_path(actual); + let expected = normalize_path(expected); + if actual == expected { + return true; + } + let actual = std::fs::canonicalize(&actual).ok(); + let expected = std::fs::canonicalize(&expected).ok(); + match (actual, expected) { + (Some(actual), Some(expected)) => { + normalize_path(&actual.to_string_lossy()) == normalize_path(&expected.to_string_lossy()) + } + _ => false, + } +} + fn normalize_path(value: &str) -> String { let value = value.trim().trim_matches(['"', '\'']); if cfg!(windows) { - value.replace('/', "\\").to_ascii_lowercase() + value + .strip_prefix(r"\\?\") + .unwrap_or(value) + .replace('/', "\\") + .to_ascii_lowercase() } else { value.to_owned() } } +fn find_devin_entry(document: &Value) -> Option<&Value> { + match document { + Value::Object(values) => { + if let Some(entry) = values.get("cccc").filter(|entry| entry.is_object()) { + return Some(entry); + } + for key in ["mcpServers", "servers"] { + if let Some(entry) = values + .get(key) + .and_then(Value::as_object) + .and_then(|servers| servers.get("cccc")) + { + return Some(entry); + } + } + if values + .get("name") + .or_else(|| values.get("server")) + .and_then(Value::as_str) + .is_some_and(|name| name.eq_ignore_ascii_case("cccc")) + { + return Some(document); + } + values.values().find_map(find_devin_entry) + } + Value::Array(values) => values.iter().find_map(find_devin_entry), + _ => None, + } +} + +fn devin_list_command(output: &str, server_name: &str) -> Option { + let mut in_server = false; + for raw in output.lines() { + let line = raw.trim(); + let candidate = line.trim_start_matches(['-', '*', '\u{2022}']).trim(); + if !line.contains(':') && candidate.eq_ignore_ascii_case(server_name) { + in_server = true; + continue; + } + if in_server && !line.contains(':') && !candidate.is_empty() { + break; + } + if in_server + && let Some((key, value)) = line.split_once(':') + && key.trim().eq_ignore_ascii_case("command") + { + return Some(value.trim().to_owned()); + } + } + None +} + +fn command_line_matches(command_line: &str, expected: &[String]) -> bool { + if let Ok(parts) = shell_words::split(command_line) + && let Some((command, args)) = parts.split_first() + && command_matches(command, args, expected) + { + return true; + } + if expected.len() == 2 { + let suffix = format!(" {}", expected[1]); + if let Some(command) = command_line.trim_end().strip_suffix(&suffix) { + return paths_match(command, &expected[0]); + } + } + false +} + fn string_values(value: Option<&Value>) -> Vec { match value { Some(Value::Array(values)) => values @@ -536,4 +674,35 @@ mod tests { ); } } + + #[test] + fn devin_accepts_json_key_value_and_list_outputs() { + let expected = ["/path with spaces/cccc".into(), "mcp".into()]; + let outputs = [ + r#"{"mcpServers":{"cccc":{"transport":"stdio","command":"/path with spaces/cccc","args":["mcp"]}}}"#, + "Server: cccc\nTransport: stdio\nCommand: /path with spaces/cccc\nArgs: mcp\n", + "Configured MCP servers:\n\n • cccc\n Command: \"/path with spaces/cccc\" mcp\n", + ]; + for output in outputs { + assert_eq!( + command_output_state(ActorRuntime::Devin, output, &expected).state, + State::Ready, + "{output}" + ); + } + } + + #[test] + fn devin_list_does_not_accept_an_unrelated_server_command() { + let output = "Configured MCP servers:\n\n • other\n Command: /opt/cccc mcp\n"; + assert_eq!( + command_output_state( + ActorRuntime::Devin, + output, + &["/opt/cccc".into(), "mcp".into()], + ) + .state, + State::Missing + ); + } } diff --git a/src/cccc/daemon/group/bootstrap_actor_ops.py b/src/cccc/daemon/group/bootstrap_actor_ops.py index d8476db7f..e7ecd6e0a 100644 --- a/src/cccc/daemon/group/bootstrap_actor_ops.py +++ b/src/cccc/daemon/group/bootstrap_actor_ops.py @@ -187,11 +187,12 @@ def _launch_env() -> Dict[str, str]: ok_mcp = False if not ok_mcp and runtime in auto_mcp_runtimes: logger.warning( - "MCP server 'cccc' is not installed for %s/%s (runtime=%s); actor will start but tools may not work.", + "Autostart skipped for %s/%s: MCP server 'cccc' is not ready (runtime=%s).", group_id, actor_id, runtime, ) + continue clear_preamble_sent(group, actor_id) diff --git a/src/cccc/daemon/mcp_install.py b/src/cccc/daemon/mcp_install.py index 1597bb7fa..cc3f3c38e 100644 --- a/src/cccc/daemon/mcp_install.py +++ b/src/cccc/daemon/mcp_install.py @@ -57,11 +57,24 @@ def _entry_command_matches_expected(command: Any, args: Any, expected_cmd: list[ if not actual_command: return not strict expected_command = _normalize_mcp_command_value(expected_cmd[0]) - if _normalize_mcp_command_value(actual_command) != expected_command: + if not _mcp_command_paths_equal(actual_command, expected_command): return False return _normalize_mcp_arg_values(args) == _normalize_mcp_arg_values(expected_cmd[1:]) +def _mcp_command_paths_equal(actual: str, expected: str) -> bool: + actual_normalized = _normalize_mcp_command_value(actual) + expected_normalized = _normalize_mcp_command_value(expected) + if actual_normalized == expected_normalized: + return True + try: + actual_resolved = _normalize_mcp_command_value(str(Path(actual_normalized).resolve(strict=False))) + expected_resolved = _normalize_mcp_command_value(str(Path(expected_normalized).resolve(strict=False))) + return actual_resolved == expected_resolved + except (OSError, RuntimeError, ValueError): + return False + + def _mcp_transport_matches(entry: Dict[str, Any]) -> bool: transport = entry.get("transport", entry.get("type", "stdio")) value = str(transport or "stdio").strip().lower() @@ -141,18 +154,98 @@ def _devin_debug_args(output: str) -> list[str]: def _devin_mcp_entry_matches_expected(output: str, expected_cmd: list[str]) -> bool: text = str(output or "") - if "stdio" not in text.lower(): - return False + + entry = _devin_json_entry(text) + if entry is not None: + return _json_mcp_entry_matches_expected(entry, expected_cmd) + command = _devin_debug_string_field(text, "command") - if not command: - return False - args = _devin_debug_args(text) - return _entry_command_matches_expected( - command, - args, - expected_cmd, - strict=sys.platform.startswith("win"), - ) + if command and "stdio" in text.lower(): + args = _devin_debug_args(text) + return _entry_command_matches_expected( + command, + args, + expected_cmd, + strict=sys.platform.startswith("win"), + ) + + values = _parse_mcp_get_output(text) + if (values.get("server") or values.get("name")) and str( + values.get("server") or values.get("name") + ).strip().lower() == "cccc": + transport = str(values.get("transport") or values.get("type") or "stdio").strip().lower() + if values.get("command") and transport in {"", "stdio", "local"}: + return _entry_command_matches_expected( + values.get("command"), + values.get("args"), + expected_cmd, + strict=sys.platform.startswith("win"), + ) + + command_line = _devin_list_command(text, "cccc") + return bool(command_line) and _command_line_matches_expected(command_line, expected_cmd) + + +def _devin_json_entry(output: str) -> Dict[str, Any] | None: + try: + document = json.loads(output) + except (json.JSONDecodeError, TypeError): + return None + + def find(value: Any) -> Dict[str, Any] | None: + if isinstance(value, dict): + direct = value.get("cccc") + if isinstance(direct, dict): + return direct + servers = value.get("mcpServers") or value.get("servers") + if isinstance(servers, dict) and isinstance(servers.get("cccc"), dict): + return servers["cccc"] + if str(value.get("name") or value.get("server") or "").strip().lower() == "cccc": + return value + for child in value.values(): + found = find(child) + if found is not None: + return found + elif isinstance(value, list): + for child in value: + found = find(child) + if found is not None: + return found + return None + + return find(document) + + +def _devin_list_command(output: str, server_name: str) -> str: + in_server = False + for raw in str(output or "").splitlines(): + line = raw.strip() + bullet = re.match(r"^(?:[-*\u2022]\s*)?([^:]+?)\s*$", line) + if bullet and ":" not in line: + candidate = bullet.group(1).strip().lower() + if candidate == server_name.lower(): + in_server = True + continue + if in_server: + break + if in_server and line.lower().startswith("command:"): + return line.split(":", 1)[1].strip() + return "" + + +def _command_line_matches_expected(command_line: str, expected_cmd: list[str]) -> bool: + for posix in (True, False): + try: + parts = shlex.split(command_line, posix=posix) + except ValueError: + continue + if parts and _entry_command_matches_expected(parts[0], parts[1:], expected_cmd, strict=True): + return True + if len(expected_cmd) == 2: + suffix = expected_cmd[1] + if command_line.rstrip().endswith(f" {suffix}"): + return _mcp_command_paths_equal(command_line.rstrip()[: -(len(suffix) + 1)], expected_cmd[0]) + return False def _claude_mcp_entry_matches_expected(output: str, expected_cmd: list[str]) -> bool: @@ -780,9 +873,15 @@ def _runtime_mcp_state(runtime: str, *, cwd: Path | None = None, env: Dict[str, if cwd is not None: kwargs["cwd"] = cwd result = _run_cli(["devin", "mcp", "get", "cccc"], **kwargs) - if result.returncode != 0: - return "missing" - return "ready" if _devin_mcp_entry_matches_expected(result.stdout, expected_cmd) else "stale" + state = "missing" + if result.returncode == 0: + state = "ready" if _devin_mcp_entry_matches_expected(result.stdout, expected_cmd) else "stale" + if state == "ready": + return state + listed = _run_cli(["devin", "mcp", "list"], **kwargs) + if listed.returncode == 0 and _devin_mcp_entry_matches_expected(listed.stdout, expected_cmd): + return "ready" + return state if runtime == "kiro": return _kiro_mcp_state(expected_cmd, cwd=cwd, env=env) diff --git a/tests/test_bootstrap_actor_ops.py b/tests/test_bootstrap_actor_ops.py index 5ea4032f7..a6a58d7eb 100644 --- a/tests/test_bootstrap_actor_ops.py +++ b/tests/test_bootstrap_actor_ops.py @@ -643,6 +643,67 @@ class _Session: finally: cleanup() + def test_autostart_skips_managed_runtime_when_mcp_is_not_ready(self) -> None: + home, cleanup = self._with_home() + try: + create, _ = self._call("group_create", {"title": "devin-bootstrap", "topic": "", "by": "user"}) + self.assertTrue(create.ok, getattr(create, "error", None)) + group_id = str((create.result or {}).get("group_id") or "").strip() + attach, _ = self._call("attach", {"group_id": group_id, "path": ".", "by": "user"}) + self.assertTrue(attach.ok, getattr(attach, "error", None)) + add, _ = self._call( + "actor_add", + { + "group_id": group_id, + "actor_id": "devin-peer", + "runtime": "devin", + "runner": "pty", + "by": "user", + }, + ) + self.assertTrue(add.ok, getattr(add, "error", None)) + + from cccc.kernel.group import load_group + + group = load_group(group_id) + assert group is not None + group.doc["running"] = True + group.doc["state"] = "active" + group.save() + + with patch( + "cccc.daemon.group.bootstrap_actor_ops.pty_runner.SUPERVISOR.start_actor", + side_effect=AssertionError("actor must not start without its managed MCP entry"), + ), patch( + "cccc.daemon.group.bootstrap_actor_ops.runtime_start_preflight_error", + return_value="", + ): + autostart_running_groups( + home, + effective_runner_kind=lambda runner: runner, + find_scope_url=lambda _group, _scope_key: str(Path(".").resolve()), + supported_runtimes=("devin",), + ensure_mcp_installed=lambda _runtime, _cwd, **_kwargs: False, + auto_mcp_runtimes=("devin",), + pty_supported=lambda: True, + merge_actor_env_with_private=lambda _gid, _aid, env: dict(env), + inject_actor_context_env=lambda env, _gid, _aid: dict(env), + prepare_pty_env=lambda env: dict(env), + normalize_runtime_command=lambda _runtime, command: list(command), + pty_backlog_bytes=lambda: 1024, + write_headless_state=lambda _gid, _aid: None, + write_pty_state=lambda _gid, _aid, _pid: None, + clear_preamble_sent=lambda _group, _aid: None, + throttle_reset_actor=lambda _gid, _aid: None, + automation_on_resume=lambda _group: None, + get_group_state=lambda _group: "idle", + load_actor_private_env=lambda _gid, _aid: {}, + update_actor_private_env=lambda *_args, **_kwargs: {}, + delete_actor_private_env=lambda _gid, _aid: None, + ) + finally: + cleanup() + def test_global_profile_start_persists_explicit_scope(self) -> None: """Global profile attach persists profile_scope='global' and start resolves via explicit ref.""" diff --git a/tests/test_mcp_install.py b/tests/test_mcp_install.py index 17ae17281..c98dc5d8b 100644 --- a/tests/test_mcp_install.py +++ b/tests/test_mcp_install.py @@ -274,6 +274,43 @@ def test_is_mcp_installed_devin_parses_stdio_debug_output(self) -> None: self.assertTrue(is_mcp_installed("devin")) mock_run.assert_called_once_with(["devin", "mcp", "get", "cccc"], timeout=10, env=None) + def test_is_mcp_installed_devin_parses_json_output(self) -> None: + output = json.dumps( + {"mcpServers": {"cccc": {"transport": "stdio", "command": "/abs/cccc", "args": ["mcp"]}}} + ) + with patch("cccc.daemon.mcp_install.get_cccc_mcp_stdio_command", return_value=["/abs/cccc", "mcp"]), patch( + "cccc.daemon.mcp_install._run_cli", + return_value=Mock(returncode=0, stdout=output, stderr=""), + ): + self.assertTrue(is_mcp_installed("devin")) + + def test_is_mcp_installed_devin_parses_key_value_output(self) -> None: + output = "Server: cccc\nTransport: stdio\nCommand: /abs/cccc\nArgs: mcp\n" + with patch("cccc.daemon.mcp_install.get_cccc_mcp_stdio_command", return_value=["/abs/cccc", "mcp"]), patch( + "cccc.daemon.mcp_install._run_cli", + return_value=Mock(returncode=0, stdout=output, stderr=""), + ): + self.assertTrue(is_mcp_installed("devin")) + + def test_is_mcp_installed_devin_falls_back_to_list_output(self) -> None: + outputs = [ + Mock(returncode=2, stdout="", stderr="unknown subcommand: get"), + Mock( + returncode=0, + stdout='Configured MCP servers:\n\n • cccc\n Command: "/path with spaces/cccc" mcp\n', + stderr="", + ), + ] + with patch( + "cccc.daemon.mcp_install.get_cccc_mcp_stdio_command", + return_value=["/path with spaces/cccc", "mcp"], + ), patch("cccc.daemon.mcp_install._run_cli", side_effect=outputs) as mock_run: + self.assertTrue(is_mcp_installed("devin")) + self.assertEqual( + [call.args[0] for call in mock_run.call_args_list], + [["devin", "mcp", "get", "cccc"], ["devin", "mcp", "list"]], + ) + def test_is_mcp_installed_devin_rejects_wrong_stdio_command(self) -> None: output = ( 'Server: cccc\n'