diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 637b4f5b..180b5087 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -375,8 +375,9 @@ Tolaria can register itself as an MCP server in: - `~/.gemini/settings.json` (Gemini CLI) - `~/.cursor/mcp.json` (Cursor) - `~/.config/mcp/mcp.json` (generic MCP-compatible clients) +- `~/.config/opencode/opencode.json` (OpenCode, using its `mcp` config key) -That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes a vault-neutral `type: "stdio"` entry, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. Durable external MCP processes resolve active workspaces at tool-call time: explicit `VAULT_PATH`/`VAULT_PATHS` env still wins for app-owned and legacy launches, otherwise the Node server reads Tolaria's `vaults.json`, uses `active_vault` first, and includes every workspace not marked `mounted: false`. Vault context checks each active workspace root for `AGENTS.md` and includes those instructions in the returned context. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. In the desktop app, `useMcpStatus` copies that snippet through the native `copy_text_to_clipboard` command instead of the Web Clipboard API so macOS WKWebView permission policy cannot block setup. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux package roots such as `/usr/local/Tolaria`, `/usr/lib/tolaria`, and `/usr/lib/tolaria/resources`, and AppImage paths. The `useMcpStatus` hook tracks whether Tolaria's durable MCP entry is connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the Node process instead of keeping it alive in the background. +That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini/OpenCode settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes a vault-neutral `type: "stdio"` entry for standard MCP clients, writes OpenCode's vault-neutral `type: "local"` entry, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. Durable external MCP processes resolve active workspaces at tool-call time: explicit `VAULT_PATH`/`VAULT_PATHS` env still wins for app-owned and legacy launches, otherwise the Node server reads Tolaria's `vaults.json`, uses `active_vault` first, and includes every workspace not marked `mounted: false`. Vault context checks each active workspace root for `AGENTS.md` and includes those instructions in the returned context. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. In the desktop app, `useMcpStatus` copies that snippet through the native `copy_text_to_clipboard` command instead of the Web Clipboard API so macOS WKWebView permission policy cannot block setup. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux package roots such as `/usr/local/Tolaria`, `/usr/lib/tolaria`, and `/usr/lib/tolaria/resources`, and AppImage paths. Linux AppImage startup extracts the bundled `mcp-server/` to `~/.local/share/tolaria/mcp-server/` with a `.tolaria-version` marker, so durable external registrations use a stable path instead of the changing AppImage mount point. The `useMcpStatus` hook tracks whether Tolaria's durable MCP entry is connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the Node process instead of keeping it alive in the background. ### Architecture @@ -387,7 +388,7 @@ flowchart TD VAULT["vault.js\n(findMarkdownFiles, readNote, createNote,\nsearchNotes, appendToNote, editNoteFrontmatter,\ndeleteNote, linkNotes, listNotes, vaultContext)"] WSB["ws-bridge.js"] - IDX -->|"stdio transport"| STDIO["Claude Code / Cursor"] + IDX -->|"stdio transport"| STDIO["Claude Code / Cursor / Gemini / OpenCode"] IDX --> VAULT IDX --> WSB WSB -->|"port 9710 — tool bridge"| AI["AI Clients\n(Claude Code, external)"] @@ -395,7 +396,7 @@ flowchart TD end TAURI["Tauri bridge lifecycle"] -->|"start/stop/restart with VAULT_PATHS"| MCP - UI["Status bar / Command Palette"] -->|"explicit setup or disconnect"| CFG["~/.claude.json\n~/.claude/mcp.json\n~/.cursor/mcp.json\n~/.config/mcp/mcp.json"] + UI["Status bar / Command Palette"] -->|"explicit setup or disconnect"| CFG["~/.claude.json\n~/.claude/mcp.json\n~/.cursor/mcp.json\n~/.config/mcp/mcp.json\n~/.config/opencode/opencode.json"] ``` ### WebSocket Bridge @@ -423,9 +424,10 @@ flowchart LR |----------|---------| | `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with `VAULT_PATH`/`VAULT_PATHS` env | | `sync_mcp_bridge_vault(vault_path?)` | Starts, restarts, or stops the desktop WebSocket bridge as the selected vault changes | -| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged `mcp-server/`, and writes Tolaria's vault-neutral stdio entry to Claude Code, Gemini CLI, Cursor, and generic MCP configs on user request | +| `extract_mcp_server_to_stable_dir(app_version)` | On Linux AppImage launches, copies bundled MCP files to `~/.local/share/tolaria/mcp-server/` with version-gated replacement so external clients can keep a stable `index.js` path | +| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged or stable extracted `mcp-server/`, and writes Tolaria's vault-neutral entry to Claude Code, Gemini CLI, Cursor, OpenCode, and generic MCP configs on user request | | `mcp_config_snippet(vault_path)` | Builds the exact vault-neutral `mcpServers.tolaria` JSON users can copy into any compatible client without writing third-party config files | -| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Gemini CLI, Cursor, and generic MCP configs | +| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Gemini CLI, Cursor, OpenCode, and generic MCP configs | | `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) | The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is replaced on vault switches, stopped when no active vault is selected, and killed plus waited on app exit via the `RunEvent::Exit` handler. The same desktop layer keeps Tauri asset protocol access limited to vault roots loaded during the current app session; command calls remain active-vault scoped for reads, writes, and external opens. @@ -789,9 +791,9 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `get_ai_agents_status` | Check Claude Code + Codex + OpenCode + Pi + Gemini availability | | `get_agent_docs_path` | Resolve the bundled local Tolaria docs folder used in AI-agent system prompts | | `stream_ai_agent` | Stream Claude Code, Codex, OpenCode, Pi, or Gemini through the normalized agent event layer | -| `register_mcp_tools` | Register MCP in Claude/Gemini/Cursor/generic config for the active vault | -| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Gemini/Cursor/generic config | -| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Gemini/Cursor/generic config | +| `register_mcp_tools` | Register vault-neutral MCP in Claude/Gemini/Cursor/OpenCode/generic config | +| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Gemini/Cursor/OpenCode/generic config | +| `check_mcp_status` | Check whether Tolaria's durable MCP entry is registered in Claude/Gemini/Cursor/OpenCode/generic config | | `get_mcp_config_snippet` | Return the exact manual MCP JSON snippet for the active vault | | `copy_text_to_clipboard` | Copy setup snippets through the native desktop clipboard command path | | `read_text_from_clipboard` | Read current desktop clipboard text for command-driven plain-text paste | diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index eed624dc..dfafd95c 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -475,8 +475,9 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/.spec.ts ### Work with external MCP setup -1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows executable-adjacent installs such as `%LOCALAPPDATA%\Tolaria`, Linux package roots (`/usr/local/Tolaria`, `/usr/local/lib/tolaria`, `/usr/lib/tolaria`, `/usr/lib/tolaria/resources`), and AppImage installs, and use a vault-neutral stdio entry with `WS_UI_PORT=9711`. App-owned bridge launches still pass `VAULT_PATH`/`VAULT_PATHS`; durable external registrations rely on the Node MCP server reading `vaults.json` at tool-call time. +1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs` and its `src-tauri/src/mcp/` helpers; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows executable-adjacent installs such as `%LOCALAPPDATA%\Tolaria`, Linux package roots (`/usr/local/Tolaria`, `/usr/local/lib/tolaria`, `/usr/lib/tolaria`, `/usr/lib/tolaria/resources`), and AppImage installs, and use a vault-neutral entry with `WS_UI_PORT=9711`. Linux AppImage startup must extract `mcp-server/` to `~/.local/share/tolaria/mcp-server/` before durable registration uses that stable path. App-owned bridge launches still pass `VAULT_PATH`/`VAULT_PATHS`; durable external registrations rely on the Node MCP server reading `vaults.json` at tool-call time. 2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the Node.js prerequisite, the exact generated manual config, and a copy action before Tolaria writes third-party config files 3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes 4. **Gemini CLI compatibility**: Keep `~/.gemini/settings.json` in the registration path list and keep optional `GEMINI.md` generation behind `restore_vault_ai_guidance`; app-managed Gemini sessions still require the user to install and sign in to Gemini CLI, but Tolaria supplies transient MCP settings when Gemini is selected as the default AI agent -5. **Process lifecycle and vault guidance**: Stdio MCP servers in `mcp-server/index.js` must exit when their external client closes stdin, and the desktop-owned `ws-bridge.js` child must be stopped on vault deselection, vault switch, and app exit. MCP context must include root `AGENTS.md` instructions for every active mounted workspace when those files exist. +5. **OpenCode compatibility**: Keep `~/.config/opencode/opencode.json` in durable registration. OpenCode uses the top-level `mcp` key, `command` as an array, `environment` for env vars, `type: "local"`, and `enabled: true`; it must remain vault-neutral like the standard `mcpServers` entry. +6. **Process lifecycle and vault guidance**: Stdio MCP servers in `mcp-server/index.js` must exit when their external client closes stdin, and the desktop-owned `ws-bridge.js` child must be stopped on vault deselection, vault switch, and app exit. MCP context must include root `AGENTS.md` instructions for every active mounted workspace when those files exist. diff --git a/docs/adr/0120-stable-appimage-mcp-server-path-with-opencode-registration.md b/docs/adr/0120-stable-appimage-mcp-server-path-with-opencode-registration.md new file mode 100644 index 00000000..b1bab701 --- /dev/null +++ b/docs/adr/0120-stable-appimage-mcp-server-path-with-opencode-registration.md @@ -0,0 +1,37 @@ +# 0120. Stable AppImage MCP Server Path With OpenCode Registration + +Status: active + +Date: 2026-05-14 + +## Context + +Domenico Lupinetti's PR #600 identified two gaps in durable external MCP setup: + +- Linux AppImage launches expose bundled resources through a mount path that can change between app starts, so external clients can keep a stale `mcp-server/index.js` path. +- OpenCode uses `~/.config/opencode/opencode.json` with a different MCP schema from Claude Code, Cursor, Gemini, and generic `mcpServers` clients. + +ADR-0119 made durable MCP registration vault-neutral, so PR #600 could not be merged directly: its registered entries still carried `VAULT_PATH`. The stable-path and OpenCode work is still valid, but it has to preserve the current mounted-workspace resolution model. + +## Decision + +On Linux AppImage startup, Tolaria extracts the bundled `mcp-server/` directory to `~/.local/share/tolaria/mcp-server/`. The extracted directory is version-gated by a `.tolaria-version` marker. Extraction runs on first launch or after an app version change, uses a staging directory plus rename, and uses a process lock so concurrent app launches do not write the stable directory at the same time. + +Durable external registration prefers the stable extracted server directory when it is ready. Otherwise it falls back to the packaged resource resolver. + +OpenCode is added to durable MCP registration and removal. Tolaria writes an OpenCode-specific entry under the top-level `mcp` key using: + +- `type: "local"` +- `command: [node, index.js]` +- `enabled: true` +- `environment.WS_UI_PORT = "9711"` + +OpenCode registration remains vault-neutral. It does not write `VAULT_PATH`; the Node MCP server resolves active mounted workspaces from Tolaria state per ADR-0119. + +## Consequences + +Linux AppImage users can register external MCP clients once and keep a valid `index.js` path across restarts and updates. + +OpenCode participates in the same connect, disconnect, and status flow as Claude Code, Cursor, Gemini, and generic MCP clients while preserving its own config schema. + +The stable path fixes the packaging lifecycle without reintroducing static vault pinning. diff --git a/docs/adr/README.md b/docs/adr/README.md index bb80e003..d202938a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -171,3 +171,4 @@ proposed → active → superseded | [0116](0116-rich-raw-transition-and-serialization-ownership.md) | Rich/raw transition and serialization ownership | active | | [0118](0118-entry-scoped-note-windows-without-vault-index-scans.md) | Entry-scoped note windows without vault index scans | active | | [0119](0119-vault-neutral-mcp-registration-with-mounted-workspace-guidance.md) | Vault-neutral MCP registration with mounted workspace guidance | active | +| [0120](0120-stable-appimage-mcp-server-path-with-opencode-registration.md) | Stable AppImage MCP server path with OpenCode registration | active | diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 892aeb13..9044ab55 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -250,6 +250,14 @@ fn sync_ws_bridge_for_selected_vault(app_handle: &tauri::AppHandle) { fn spawn_initial_ws_bridge_sync(app: &tauri::App) { let app_handle = app.handle().clone(); spawn_background_task("tolaria-ws-bridge-startup", move || { + #[cfg(all(desktop, target_os = "linux"))] + if linux_appimage::is_running() { + let app_version = app_handle.package_info().version.to_string(); + if let Err(e) = mcp::extract_mcp_server_to_stable_dir(&app_version) { + log::warn!("Failed to extract MCP server to stable path: {e}"); + } + } + sync_ws_bridge_for_selected_vault(&app_handle); }); } diff --git a/src-tauri/src/linux_appimage.rs b/src-tauri/src/linux_appimage.rs index 689fd602..1bc027ac 100644 --- a/src-tauri/src/linux_appimage.rs +++ b/src-tauri/src/linux_appimage.rs @@ -58,6 +58,11 @@ where .any(|key| get_var(key).is_some_and(|value| !value.trim().is_empty())) } +#[cfg(all(desktop, target_os = "linux"))] +pub(crate) fn is_running() -> bool { + is_linux_appimage_launch(|key| std::env::var(key).ok()) +} + fn should_disable_unstable_webkit_rendering(get_var: &mut F) -> bool where F: FnMut(&str) -> Option, diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index d7143752..343fe31e 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -2,9 +2,15 @@ use serde::Serialize; use std::path::{Path, PathBuf}; use std::process::{Child, Command}; +#[cfg(any(test, all(desktop, target_os = "linux")))] +mod extraction; +mod opencode; mod paths; mod subprocess; +#[cfg(all(desktop, target_os = "linux"))] +pub(crate) use extraction::extract_mcp_server_to_stable_dir; + const MCP_SERVER_NAME: &str = "tolaria"; const LEGACY_MCP_SERVER_NAME: &str = "laputa"; @@ -249,6 +255,15 @@ pub(crate) fn mcp_server_dir() -> Result { )) } +fn mcp_server_dir_for_registration() -> Result { + #[cfg(all(desktop, target_os = "linux"))] + if let Some(stable_dir) = extraction::ready_stable_mcp_server_dir() { + return Ok(stable_dir); + } + + mcp_server_dir() +} + fn build_time_dev_mcp_server_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") @@ -509,7 +524,7 @@ pub fn mcp_config_snippet(vault_path: &str) -> Result { let node = find_node().map_err(|e| { format!("Node.js 18+ is required on PATH before Tolaria can build MCP config: {e}") })?; - let server_dir = mcp_server_dir()?; + let server_dir = mcp_server_dir_for_registration()?; let index_js = server_dir.join("index.js").to_string_lossy().into_owned(); let node_command = node.to_string_lossy().into_owned(); let entry = build_mcp_entry(&node_command, &index_js); @@ -537,11 +552,17 @@ pub fn register_mcp(vault_path: &str) -> Result { let node = find_node().map_err(|e| { format!("Node.js 18+ is required on PATH before Tolaria can register MCP tools: {e}") })?; - let server_dir = mcp_server_dir()?; + let server_dir = mcp_server_dir_for_registration()?; let index_js = server_dir.join("index.js").to_string_lossy().into_owned(); let node_command = node.to_string_lossy().into_owned(); let entry = build_mcp_entry(&node_command, &index_js); + let opencode_entry = opencode::build_entry(&node_command, &index_js); + if let Some(config_path) = opencode::config_path() { + if let Err(e) = opencode::upsert_config(&config_path, &opencode_entry) { + log::warn!("Failed to update {}: {}", config_path.display(), e); + } + } Ok(register_mcp_to_configs(&entry, &mcp_config_paths())) } @@ -643,7 +664,19 @@ fn remove_mcp_from_config(config_path: &Path) -> Result { } pub fn remove_mcp() -> String { - remove_mcp_from_configs(&mcp_config_paths()) + let removed_standard = remove_mcp_from_configs(&mcp_config_paths()) == "removed"; + let removed_opencode = opencode::config_path().is_some_and(|config_path| { + opencode::remove_config(&config_path).unwrap_or_else(|e| { + log::warn!("Failed to update {}: {}", config_path.display(), e); + false + }) + }); + + if removed_standard || removed_opencode { + "removed".to_string() + } else { + "already_absent".to_string() + } } /// Check whether the MCP server is properly installed and registered. @@ -653,11 +686,17 @@ pub fn remove_mcp() -> String { /// Otherwise returns `NotInstalled`. pub fn check_mcp_status(vault_path: &str) -> McpStatus { let _ = vault_path; - if mcp_config_paths().into_iter().any(|config_path| { + let installed_standard = mcp_config_paths().into_iter().any(|config_path| { read_registered_mcp_entry(&config_path).is_some_and(|entry| { entry_uses_stdio(&entry) && entry_index_js_exists(&entry) && entry_has_ui_port(&entry) }) - }) { + }); + let installed_opencode = opencode::config_path().is_some_and(|config_path| { + opencode::read_registered_entry(&config_path) + .is_some_and(|entry| opencode::entry_is_installed(&entry)) + }); + + if installed_standard || installed_opencode { McpStatus::Installed } else { McpStatus::NotInstalled diff --git a/src-tauri/src/mcp/extraction.rs b/src-tauri/src/mcp/extraction.rs new file mode 100644 index 00000000..bdfc6081 --- /dev/null +++ b/src-tauri/src/mcp/extraction.rs @@ -0,0 +1,303 @@ +use std::fs; +#[cfg(all(desktop, target_os = "linux"))] +use std::fs::OpenOptions; +use std::path::{Path, PathBuf}; +#[cfg(all(desktop, target_os = "linux"))] +use std::time::{Duration, Instant, SystemTime}; + +const VERSION_MARKER_FILE: &str = ".tolaria-version"; +#[cfg(all(desktop, target_os = "linux"))] +const LOCK_FILE: &str = "mcp-server.lock"; +const STAGING_DIR: &str = "mcp-server.staging"; +const BACKUP_DIR: &str = "mcp-server.previous"; +#[cfg(all(desktop, target_os = "linux"))] +const LOCK_TIMEOUT: Duration = Duration::from_secs(5); +#[cfg(all(desktop, target_os = "linux"))] +const STALE_LOCK_AFTER: Duration = Duration::from_secs(120); + +#[cfg(all(desktop, target_os = "linux"))] +pub(super) fn ready_stable_mcp_server_dir() -> Option { + let stable_dir = stable_mcp_server_dir().ok()?; + stable_mcp_server_dir_is_ready(&stable_dir).then_some(stable_dir) +} + +#[cfg(all(desktop, target_os = "linux"))] +pub(crate) fn extract_mcp_server_to_stable_dir(app_version: &str) -> Result { + let source_dir = super::mcp_server_dir()?; + let target_dir = stable_mcp_server_dir()?; + + if !needs_extraction(app_version, &target_dir) { + return Ok(target_dir); + } + + let _lock = ExtractionLock::acquire(&extraction_lock_path()?)?; + if !needs_extraction(app_version, &target_dir) { + return Ok(target_dir); + } + + replace_stable_server_dir(&source_dir, &target_dir, app_version)?; + Ok(target_dir) +} + +fn stable_mcp_server_dir() -> Result { + dirs::data_dir() + .map(|data_dir| data_dir.join("tolaria").join("mcp-server")) + .ok_or_else(|| "Unable to resolve data directory for stable MCP server path".to_string()) +} + +fn stable_mcp_server_dir_is_ready(dir: &Path) -> bool { + mcp_server_dir_has_files(dir) && read_version_marker(dir).is_some() +} + +fn mcp_server_dir_has_files(dir: &Path) -> bool { + dir.join("index.js").is_file() && dir.join("ws-bridge.js").is_file() +} + +fn needs_extraction(app_version: &str, target_dir: &Path) -> bool { + !mcp_server_dir_has_files(target_dir) + || read_version_marker(target_dir).as_deref() != Some(app_version) +} + +fn read_version_marker(dir: &Path) -> Option { + fs::read_to_string(dir.join(VERSION_MARKER_FILE)) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn write_version_marker(dir: &Path, app_version: &str) -> Result<(), String> { + let marker = dir.join(VERSION_MARKER_FILE); + fs::write(&marker, app_version) + .map_err(|e| format!("Failed to write version marker {}: {e}", marker.display())) +} + +#[cfg(all(desktop, target_os = "linux"))] +fn extraction_lock_path() -> Result { + let stable_dir = stable_mcp_server_dir()?; + stable_dir + .parent() + .map(|parent| parent.join(LOCK_FILE)) + .ok_or_else(|| { + format!( + "Stable MCP server path has no parent: {}", + stable_dir.display() + ) + }) +} + +fn replace_stable_server_dir( + source_dir: &Path, + target_dir: &Path, + app_version: &str, +) -> Result<(), String> { + let parent = target_dir.parent().ok_or_else(|| { + format!( + "Stable MCP server path has no parent: {}", + target_dir.display() + ) + })?; + let staging_dir = parent.join(STAGING_DIR); + let backup_dir = parent.join(BACKUP_DIR); + + remove_dir_if_exists(&staging_dir)?; + remove_dir_if_exists(&backup_dir)?; + copy_dir_all(source_dir, &staging_dir)?; + write_version_marker(&staging_dir, app_version)?; + swap_staging_into_place(&staging_dir, target_dir, &backup_dir)?; + Ok(()) +} + +fn swap_staging_into_place( + staging_dir: &Path, + target_dir: &Path, + backup_dir: &Path, +) -> Result<(), String> { + if target_dir.exists() { + fs::rename(target_dir, backup_dir) + .map_err(|e| format!("Failed to move stable MCP server aside: {e}"))?; + } + + if let Err(error) = fs::rename(staging_dir, target_dir) { + if backup_dir.exists() { + let _ = fs::rename(backup_dir, target_dir); + } + return Err(format!("Failed to activate stable MCP server: {error}")); + } + + remove_dir_if_exists(backup_dir) +} + +fn copy_dir_all(source: &Path, target: &Path) -> Result<(), String> { + fs::create_dir_all(target) + .map_err(|e| format!("Failed to create {}: {e}", target.display()))?; + + for entry in fs::read_dir(source).map_err(|e| { + format!( + "Failed to read MCP server directory {}: {e}", + source.display() + ) + })? { + let entry = entry.map_err(|e| format!("Failed to read MCP server entry: {e}"))?; + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + if source_path.is_dir() { + copy_dir_all(&source_path, &target_path)?; + } else { + fs::copy(&source_path, &target_path).map_err(|e| { + format!( + "Failed to copy {} to {}: {e}", + source_path.display(), + target_path.display() + ) + })?; + } + } + + Ok(()) +} + +fn remove_dir_if_exists(path: &Path) -> Result<(), String> { + if path.exists() { + fs::remove_dir_all(path) + .map_err(|e| format!("Failed to remove {}: {e}", path.display()))?; + } + Ok(()) +} + +#[cfg(all(desktop, target_os = "linux"))] +struct ExtractionLock { + path: PathBuf, +} + +#[cfg(all(desktop, target_os = "linux"))] +impl ExtractionLock { + fn acquire(path: &Path) -> Result { + let started = Instant::now(); + loop { + match Self::try_create(path) { + Ok(()) => { + return Ok(Self { + path: path.to_path_buf(), + }); + } + Err(error) if lock_is_stale(path) => { + let _ = fs::remove_file(path); + log::warn!("Removed stale MCP extraction lock after error: {error}"); + } + Err(error) if started.elapsed() >= LOCK_TIMEOUT => return Err(error), + Err(_) => std::thread::sleep(Duration::from_millis(50)), + } + } + } + + fn try_create(path: &Path) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create MCP extraction lock dir: {e}"))?; + } + + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|e| format!("Failed to acquire MCP extraction lock: {e}"))?; + use std::io::Write; + writeln!(file, "{}", std::process::id()) + .map_err(|e| format!("Failed to write MCP extraction lock: {e}")) + } +} + +#[cfg(all(desktop, target_os = "linux"))] +impl Drop for ExtractionLock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +#[cfg(all(desktop, target_os = "linux"))] +fn lock_is_stale(path: &Path) -> bool { + path.metadata() + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| SystemTime::now().duration_since(modified).ok()) + .is_some_and(|age| age >= STALE_LOCK_AFTER) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_server_dir(parent: &Path) -> PathBuf { + let dir = parent.join("server"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("index.js"), "console.log('index');").unwrap(); + fs::write(dir.join("ws-bridge.js"), "console.log('bridge');").unwrap(); + dir + } + + #[test] + fn stable_mcp_server_dir_uses_app_data_dir() { + let expected = dirs::data_dir() + .expect("data dir should exist") + .join("tolaria") + .join("mcp-server"); + + assert_eq!(stable_mcp_server_dir().unwrap(), expected); + } + + #[test] + fn stable_mcp_server_dir_requires_marker_and_files() { + let tmp = tempfile::tempdir().unwrap(); + let stable_dir = tmp.path().join("tolaria").join("mcp-server"); + + fs::create_dir_all(&stable_dir).unwrap(); + fs::write(stable_dir.join("index.js"), "").unwrap(); + fs::write(stable_dir.join("ws-bridge.js"), "").unwrap(); + assert!(!stable_mcp_server_dir_is_ready(&stable_dir)); + + write_version_marker(&stable_dir, "2026.5.14").unwrap(); + assert!(stable_mcp_server_dir_is_ready(&stable_dir)); + } + + #[test] + fn needs_extraction_tracks_version_marker() { + let target = tempfile::tempdir().unwrap(); + fs::write(target.path().join("index.js"), "").unwrap(); + fs::write(target.path().join("ws-bridge.js"), "").unwrap(); + + assert!(needs_extraction("2026.5.14", target.path())); + write_version_marker(target.path(), "2026.5.14").unwrap(); + assert!(!needs_extraction("2026.5.14", target.path())); + assert!(needs_extraction("2026.5.15", target.path())); + } + + #[test] + fn copy_dir_all_copies_nested_server_files() { + let tmp = tempfile::tempdir().unwrap(); + let source = create_server_dir(tmp.path()); + fs::create_dir_all(source.join("nested")).unwrap(); + fs::write(source.join("nested").join("package.json"), "{}").unwrap(); + let target = tmp.path().join("target"); + + copy_dir_all(&source, &target).unwrap(); + + assert!(target.join("index.js").is_file()); + assert!(target.join("ws-bridge.js").is_file()); + assert!(target.join("nested").join("package.json").is_file()); + } + + #[test] + fn replace_stable_server_dir_swaps_versioned_copy() { + let tmp = tempfile::tempdir().unwrap(); + let source = create_server_dir(tmp.path()); + let target = tmp.path().join("tolaria").join("mcp-server"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("stale.txt"), "old").unwrap(); + + replace_stable_server_dir(&source, &target, "2026.5.14").unwrap(); + + assert!(target.join("index.js").is_file()); + assert!(!target.join("stale.txt").exists()); + assert_eq!(read_version_marker(&target), Some("2026.5.14".to_string())); + } +} diff --git a/src-tauri/src/mcp/opencode.rs b/src-tauri/src/mcp/opencode.rs new file mode 100644 index 00000000..ad2879c1 --- /dev/null +++ b/src-tauri/src/mcp/opencode.rs @@ -0,0 +1,241 @@ +use std::path::{Path, PathBuf}; + +use serde_json::{Map, Value}; + +use super::{LEGACY_MCP_SERVER_NAME, MCP_SERVER_NAME}; + +const OPENCODE_MCP_KEY: &str = "mcp"; + +pub(super) fn config_path() -> Option { + dirs::config_dir().map(|config_dir| config_dir.join("opencode").join("opencode.json")) +} + +pub(super) fn build_entry(node_command: &str, index_js: &str) -> Value { + serde_json::json!({ + "type": "local", + "command": [node_command, index_js], + "enabled": true, + "environment": { + "WS_UI_PORT": "9711" + } + }) +} + +pub(super) fn upsert_config(config_path: &Path, entry: &Value) -> Result { + let mut config = read_config_or_empty(config_path)?; + let servers = ensure_servers_object(&mut config)?; + let was_update = + servers.get(MCP_SERVER_NAME).is_some() || servers.get(LEGACY_MCP_SERVER_NAME).is_some(); + + servers.remove(LEGACY_MCP_SERVER_NAME); + servers.insert(MCP_SERVER_NAME.to_string(), entry.clone()); + write_config(config_path, &config)?; + Ok(was_update) +} + +pub(super) fn remove_config(config_path: &Path) -> Result { + if !config_path.exists() { + return Ok(false); + } + + let mut config = read_config_or_empty(config_path)?; + let Some(config_object) = config.as_object_mut() else { + return Err("Config is not a JSON object".into()); + }; + let Some(servers_value) = config_object.get_mut(OPENCODE_MCP_KEY) else { + return Ok(false); + }; + let Some(servers) = servers_value.as_object_mut() else { + return Err("mcp is not a JSON object".into()); + }; + + let removed_primary = servers.remove(MCP_SERVER_NAME).is_some(); + let removed_legacy = servers.remove(LEGACY_MCP_SERVER_NAME).is_some(); + if !removed_primary && !removed_legacy { + return Ok(false); + } + + if servers.is_empty() { + config_object.remove(OPENCODE_MCP_KEY); + } + write_config(config_path, &config)?; + Ok(true) +} + +pub(super) fn read_registered_entry(config_path: &Path) -> Option { + let raw = std::fs::read_to_string(config_path).ok()?; + let config: Value = serde_json::from_str(&raw).ok()?; + config + .get(OPENCODE_MCP_KEY) + .and_then(Value::as_object) + .and_then(|servers| { + servers + .get(MCP_SERVER_NAME) + .or_else(|| servers.get(LEGACY_MCP_SERVER_NAME)) + }) + .cloned() +} + +pub(super) fn entry_is_installed(entry: &Value) -> bool { + entry["type"].as_str() == Some("local") + && entry["enabled"].as_bool() == Some(true) + && entry["environment"]["WS_UI_PORT"].as_str() == Some("9711") + && command_index_js_exists(entry) +} + +fn command_index_js_exists(entry: &Value) -> bool { + entry["command"] + .as_array() + .and_then(|command| command.get(1)) + .and_then(Value::as_str) + .is_some_and(|index_js| Path::new(index_js).exists()) +} + +fn read_config_or_empty(config_path: &Path) -> Result { + if !config_path.exists() { + return Ok(serde_json::json!({})); + } + + let raw = std::fs::read_to_string(config_path) + .map_err(|e| format!("Cannot read {}: {e}", config_path.display()))?; + serde_json::from_str(&raw) + .map_err(|e| format!("Invalid JSON in {}: {e}", config_path.display())) +} + +fn ensure_servers_object(config: &mut Value) -> Result<&mut Map, String> { + let servers = config + .as_object_mut() + .ok_or("Config is not a JSON object")? + .entry(OPENCODE_MCP_KEY) + .or_insert_with(|| serde_json::json!({})); + + servers + .as_object_mut() + .ok_or_else(|| "mcp is not a JSON object".to_string()) +} + +fn write_config(config_path: &Path, config: &Value) -> Result<(), String> { + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Cannot create dir {}: {e}", parent.display()))?; + } + + let json = serde_json::to_string_pretty(config) + .map_err(|e| format!("Failed to serialize config: {e}"))?; + std::fs::write(config_path, json) + .map_err(|e| format!("Cannot write {}: {e}", config_path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read_config(config_path: &Path) -> Value { + let raw = std::fs::read_to_string(config_path).unwrap(); + serde_json::from_str(&raw).unwrap() + } + + fn write_config_json(config_path: &Path, config: Value) { + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(config_path, serde_json::to_string(&config).unwrap()).unwrap(); + } + + #[test] + fn build_entry_uses_opencode_schema_without_vault_path() { + let entry = build_entry("node", "/app/mcp-server/index.js"); + + assert_eq!( + entry, + serde_json::json!({ + "type": "local", + "command": ["node", "/app/mcp-server/index.js"], + "enabled": true, + "environment": { + "WS_UI_PORT": "9711" + } + }) + ); + assert!(entry["environment"]["VAULT_PATH"].is_null()); + } + + #[test] + fn upsert_config_preserves_other_opencode_settings() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("opencode.json"); + write_config_json( + &config_path, + serde_json::json!({ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "other": { "type": "local" } + } + }), + ); + + let was_update = upsert_config(&config_path, &build_entry("node", "/index.js")).unwrap(); + let config = read_config(&config_path); + + assert!(!was_update); + assert_eq!(config["$schema"], "https://opencode.ai/config.json"); + assert!(config["mcp"]["other"].is_object()); + assert_eq!(config["mcp"][MCP_SERVER_NAME]["command"][1], "/index.js"); + } + + #[test] + fn upsert_config_migrates_legacy_server_name() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("opencode.json"); + write_config_json( + &config_path, + serde_json::json!({ + "mcp": { + "laputa": { "type": "local", "command": ["node", "/old.js"] } + } + }), + ); + + let was_update = upsert_config(&config_path, &build_entry("node", "/new.js")).unwrap(); + let config = read_config(&config_path); + + assert!(was_update); + assert!(config["mcp"][LEGACY_MCP_SERVER_NAME].is_null()); + assert_eq!(config["mcp"][MCP_SERVER_NAME]["command"][1], "/new.js"); + } + + #[test] + fn remove_config_removes_primary_and_legacy_entries() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("opencode.json"); + write_config_json( + &config_path, + serde_json::json!({ + "mcp": { + "tolaria": { "type": "local" }, + "laputa": { "type": "local" }, + "other": { "type": "local" } + } + }), + ); + + assert!(remove_config(&config_path).unwrap()); + let config = read_config(&config_path); + assert!(config["mcp"][MCP_SERVER_NAME].is_null()); + assert!(config["mcp"][LEGACY_MCP_SERVER_NAME].is_null()); + assert!(config["mcp"]["other"].is_object()); + } + + #[test] + fn entry_is_installed_checks_opencode_shape_and_index_path() { + let tmp = tempfile::tempdir().unwrap(); + let index_js = tmp.path().join("index.js"); + std::fs::write(&index_js, "").unwrap(); + + let entry = build_entry("node", &index_js.to_string_lossy()); + assert!(entry_is_installed(&entry)); + + let missing = build_entry("node", &tmp.path().join("missing.js").to_string_lossy()); + assert!(!entry_is_installed(&missing)); + } +}