feat: stabilize external mcp appimage registration
Co-authored-by: Domenico Lupinetti <domenico@translated.net>
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
303
src-tauri/src/mcp/extraction.rs
Normal file
303
src-tauri/src/mcp/extraction.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
241
src-tauri/src/mcp/opencode.rs
Normal file
241
src-tauri/src/mcp/opencode.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user