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