fix(mcp): bootstrap desktop live server

This commit is contained in:
Kayshen-X 2026-06-02 09:41:41 +08:00
parent ce813bcb3e
commit f1b7e0989c
5 changed files with 144 additions and 2 deletions

View file

@ -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(

View file

@ -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"
);
}

View file

@ -18,6 +18,13 @@ pub(crate) fn set_cli_enabled(cli: McpCli, enabled: bool, port: u16) -> Result<P
set_cli_enabled_at_path(cli, enabled, port, path)
}
pub(crate) fn detect_enabled_clis() -> [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);
}
}

View file

@ -40,6 +40,10 @@ impl McpLiveServer {
pub(crate) fn start(port: u16) -> Result<Self, String> {
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,
})

View file

@ -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