diff --git a/crates/op-host-desktop/src/app_handler.rs b/crates/op-host-desktop/src/app_handler.rs index fe27f3ba2..e185fd764 100644 --- a/crates/op-host-desktop/src/app_handler.rs +++ b/crates/op-host-desktop/src/app_handler.rs @@ -230,6 +230,10 @@ impl ApplicationHandler for DesktopApp { // first paint so the launch document shows immediately. self.drain_opened_files(); + if self.bootstrap_mcp_runtime_from_settings() { + settings_io::save(self.host.editor_state()); + } + if self.try_init_render_context(event_loop) { if let (Some(ctx), Some(backend)) = (self.ctx.as_mut(), self.backend.as_mut()) { frame::paint( diff --git a/crates/op-host-desktop/src/main_tests.rs b/crates/op-host-desktop/src/main_tests.rs index 51fae21be..a9b8464d4 100644 --- a/crates/op-host-desktop/src/main_tests.rs +++ b/crates/op-host-desktop/src/main_tests.rs @@ -126,3 +126,33 @@ fn live_mcp_http_server_applies_write_requests_to_editor_state() { "MCP write should mutate the live editor state" ); } + +#[test] +fn startup_mcp_bootstrap_starts_live_server_for_enabled_cli() { + let mut app = DesktopApp::new(None); + let settings = &mut app.host.editor_state_mut().editor_ui.agent_settings; + settings.mcp_server.port = 0; + settings.mcp_server.running = false; + let codex_idx = op_editor_core::agent_settings::McpCli::ALL + .iter() + .position(|cli| *cli == op_editor_core::agent_settings::McpCli::Codex) + .expect("Codex CLI index"); + settings.mcp_cli_enabled[codex_idx] = true; + + assert!(app.bootstrap_mcp_runtime_from_settings()); + + assert!(app.mcp_server_active()); + assert!( + app.host + .editor_state() + .editor_ui + .agent_settings + .mcp_server + .running + ); + assert_ne!( + app.mcp_server.as_ref().expect("server").port(), + 0, + "ephemeral port should be reported after binding" + ); +} diff --git a/crates/op-host-desktop/src/mcp_integrations.rs b/crates/op-host-desktop/src/mcp_integrations.rs index b01881649..87791a17b 100644 --- a/crates/op-host-desktop/src/mcp_integrations.rs +++ b/crates/op-host-desktop/src/mcp_integrations.rs @@ -18,6 +18,13 @@ pub(crate) fn set_cli_enabled(cli: McpCli, enabled: bool, port: u16) -> Result

[bool; 6] { + let Some(home) = dirs::home_dir() else { + return [false; 6]; + }; + detect_enabled_clis_for_home(&home, true) +} + #[cfg(test)] fn set_cli_enabled_at_home( cli: McpCli, @@ -29,6 +36,20 @@ fn set_cli_enabled_at_home( set_cli_enabled_at_path(cli, enabled, port, path) } +#[cfg(test)] +fn detect_enabled_clis_at_home(home: &Path) -> [bool; 6] { + detect_enabled_clis_for_home(home, false) +} + +fn detect_enabled_clis_for_home(home: &Path, use_env: bool) -> [bool; 6] { + let mut flags = [false; 6]; + for (idx, cli) in McpCli::ALL.iter().copied().enumerate() { + let path = config_path(cli, home, use_env); + flags[idx] = cli_config_has_openpencil(cli, &path); + } + flags +} + fn set_cli_enabled_at_path( cli: McpCli, enabled: bool, @@ -46,6 +67,19 @@ fn set_cli_enabled_at_path( Ok(path) } +fn cli_config_has_openpencil(cli: McpCli, path: &Path) -> bool { + match cli { + McpCli::Codex => fs::read_to_string(path) + .map(|text| codex_config_has_openpencil(&text)) + .unwrap_or(false), + McpCli::ClaudeCode + | McpCli::Gemini + | McpCli::OpenCode + | McpCli::Kiro + | McpCli::GithubCopilot => json_config_has_openpencil(path), + } +} + fn config_path(cli: McpCli, home: &Path, use_env: bool) -> PathBuf { match cli { McpCli::ClaudeCode => home.join(".claude.json"), @@ -66,6 +100,17 @@ fn config_path(cli: McpCli, home: &Path, use_env: bool) -> PathBuf { } } +fn json_config_has_openpencil(path: &Path) -> bool { + read_json_object(path) + .ok() + .and_then(|root| { + root.get("mcpServers") + .and_then(Value::as_object) + .map(|servers| servers.contains_key(SERVER_NAME)) + }) + .unwrap_or(false) +} + fn update_json_config(path: &Path, enabled: bool, port: u16) -> Result<(), String> { let mut root = read_json_object(path)?; if enabled { @@ -145,6 +190,10 @@ fn update_codex_config(path: &Path, enabled: bool, port: u16) -> Result<(), Stri fs::write(path, text).map_err(|e| format!("write {}: {e}", path.display())) } +fn codex_config_has_openpencil(input: &str) -> bool { + input.lines().map(str::trim).any(is_codex_openpencil_table) +} + fn remove_codex_server_block(input: &str) -> String { let mut out = String::new(); let mut skipping = false; @@ -255,4 +304,30 @@ mod tests { let _ = fs::remove_dir_all(home); } + + #[test] + fn detects_legacy_codex_openpencil_server_config() { + let home = temp_home("codex-detect"); + let path = home.join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().expect("parent")).expect("create codex dir"); + fs::write( + &path, + "model = \"gpt-5\"\n\n[mcp_servers.openpencil]\ncommand = \"/usr/local/bin/node\"\nargs = [\"/Applications/OpenPencil.app/Contents/Resources/mcp-server.cjs\"]\n", + ) + .expect("seed legacy config"); + + let flags = detect_enabled_clis_at_home(&home); + + let codex_idx = McpCli::ALL + .iter() + .position(|cli| *cli == McpCli::Codex) + .expect("Codex CLI index"); + assert!(flags[codex_idx]); + assert!( + flags.iter().filter(|enabled| **enabled).count() == 1, + "{flags:?}" + ); + + let _ = fs::remove_dir_all(home); + } } diff --git a/crates/op-host-desktop/src/mcp_live.rs b/crates/op-host-desktop/src/mcp_live.rs index 34e7e6aed..5bbe975bf 100644 --- a/crates/op-host-desktop/src/mcp_live.rs +++ b/crates/op-host-desktop/src/mcp_live.rs @@ -40,6 +40,10 @@ impl McpLiveServer { pub(crate) fn start(port: u16) -> Result { let listener = TcpListener::bind(("127.0.0.1", port)) .map_err(|e| format!("bind 127.0.0.1:{port}: {e}"))?; + let bound_port = listener + .local_addr() + .map_err(|e| format!("read bound MCP port: {e}"))? + .port(); listener .set_nonblocking(true) .map_err(|e| format!("set nonblocking: {e}"))?; @@ -49,9 +53,9 @@ impl McpLiveServer { .name("op-mcp-live-http".into()) .spawn(move || server_loop(listener, req_tx, stop_rx)) .map_err(|e| format!("spawn MCP live server: {e}"))?; - eprintln!("openpencil-desktop mcp: listening on 127.0.0.1:{port}/mcp"); + eprintln!("openpencil-desktop mcp: listening on 127.0.0.1:{bound_port}/mcp"); Ok(Self { - port, + port: bound_port, req_rx, stop_tx, }) diff --git a/crates/op-host-desktop/src/mcp_runtime.rs b/crates/op-host-desktop/src/mcp_runtime.rs index ea2546425..8fff34b7f 100644 --- a/crates/op-host-desktop/src/mcp_runtime.rs +++ b/crates/op-host-desktop/src/mcp_runtime.rs @@ -3,6 +3,35 @@ use super::{mcp_integrations, mcp_live, DesktopApp}; impl DesktopApp { + pub(crate) fn bootstrap_mcp_runtime_from_settings(&mut self) -> bool { + let detected_flags = mcp_integrations::detect_enabled_clis(); + let settings = &mut self.host.editor_state_mut().editor_ui.agent_settings; + let mut changed = false; + for (idx, detected) in detected_flags.iter().copied().enumerate() { + if detected && !settings.mcp_cli_enabled[idx] { + settings.mcp_cli_enabled[idx] = true; + changed = true; + } + } + let any_cli_enabled = settings.mcp_cli_enabled.iter().any(|enabled| *enabled); + let port = settings.mcp_server.port; + if any_cli_enabled && !settings.mcp_server.running { + settings.mcp_server.running = true; + changed = true; + } + if changed { + self.host.mark_editor_state_dirty(); + } + if any_cli_enabled && port != 0 { + changed |= self.reconcile_mcp_cli_integrations(Some(([false; 6], port))); + } + changed |= self.reconcile_mcp_server_from_settings(); + if self.mcp_server_active() { + changed |= self.request_redraw(false); + } + changed + } + pub(crate) fn reconcile_mcp_server_from_settings(&mut self) -> bool { let desired = self .host