feat: stabilize external mcp appimage registration

Co-authored-by: Domenico Lupinetti <domenico@translated.net>
This commit is contained in:
lucaronin
2026-05-14 15:22:47 +02:00
parent 3b2f675264
commit 62e62790c1
9 changed files with 652 additions and 15 deletions

View File

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

View File

@@ -475,8 +475,9 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.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.

View File

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

View File

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

View File

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

View File

@@ -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<F>(get_var: &mut F) -> bool
where
F: FnMut(&str) -> Option<String>,

View File

@@ -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<PathBuf, String> {
))
}
fn mcp_server_dir_for_registration() -> Result<PathBuf, String> {
#[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<String, String> {
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<String, String> {
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<bool, String> {
}
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

View File

@@ -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<PathBuf> {
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<PathBuf, String> {
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<PathBuf, String> {
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<String> {
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<PathBuf, String> {
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<Self, String> {
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()));
}
}

View File

@@ -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<PathBuf> {
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<bool, String> {
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<bool, String> {
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<Value> {
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<Value, String> {
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, Value>, 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));
}
}