fix: use selected vault for mcp bridge

This commit is contained in:
lucaronin
2026-04-28 02:13:24 +02:00
parent 49717dc671
commit e912ab9a9f
12 changed files with 283 additions and 24 deletions

View File

@@ -342,6 +342,8 @@ Command-layer path access is fenced to the active vault before file operations r
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder and external-open calls route through the Tauri opener plugin, while copy-path uses the browser clipboard API; none of those actions mutate vault contents or bypass the backend write boundary.
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`.
### Vault Caching
`vault::scan_vault_cached(path)` wraps scanning with git-based caching:

View File

@@ -327,13 +327,13 @@ Tolaria can register itself as an MCP server in:
- `~/.cursor/mcp.json` (Cursor)
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
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), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`).
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), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`). 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`.
### Architecture
```mermaid
flowchart TD
subgraph MCP["MCP Server (Node.js) — spawned by Tauri on startup"]
subgraph MCP["MCP Server (Node.js) — selected-vault scoped"]
IDX["index.js"]
VAULT["vault.js\n(findMarkdownFiles, readNote, createNote,\nsearchNotes, appendToNote, editNoteFrontmatter,\ndeleteNote, linkNotes, listNotes, vaultContext)"]
WSB["ws-bridge.js"]
@@ -345,7 +345,7 @@ flowchart TD
WSB -->|"port 9711 — UI bridge"| FE["Frontend\n(useAiActivity)"]
end
TAURI["Tauri (mcp.rs)"] -->|"spawn on startup"| MCP
TAURI["Tauri bridge lifecycle"] -->|"start/stop/restart with active VAULT_PATH"| 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"]
```
@@ -373,11 +373,12 @@ flowchart LR
| Function | Purpose |
|----------|---------|
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH 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 explicit stdio entry to Claude Code, Cursor, and generic MCP configs on user request |
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Cursor, 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 killed on app exit via `RunEvent::Exit` handler. The same desktop layer now keeps the Tauri asset protocol scoped to the active vault instead of every filesystem path.
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 on app exit via the `RunEvent::Exit` handler. The same desktop layer now keeps the Tauri asset protocol scoped to the active vault instead of every filesystem path.
## Search
@@ -723,6 +724,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `register_mcp_tools` | Register MCP in Claude/Cursor/generic config for the active vault |
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Cursor/generic config |
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Cursor/generic config |
| `sync_mcp_bridge_vault` | Sync the desktop WebSocket bridge process to the selected vault, or stop it when no vault is selected |
The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bridge.js` binds both bridge ports to loopback, rejects non-loopback clients, accepts browser/Tauri origins only on the UI bridge, and rejects browser-origin requests on the tool bridge so remote pages cannot drive vault tools directly.

View File

@@ -21,8 +21,9 @@ import {
} from '@modelcontextprotocol/sdk/types.js'
import WebSocket from 'ws'
import { searchNotes, getNote, vaultContext } from './vault.js'
import { requireVaultPath } from './vault-path.js'
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
const VAULT_PATH = requireVaultPath()
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`

View File

@@ -6,6 +6,7 @@ import os from 'node:os'
import {
findMarkdownFiles, getNote, searchNotes, vaultContext,
} from './vault.js'
import { requireVaultPath } from './vault-path.js'
import { evaluateBridgeRequest } from './ws-bridge.js'
let tmpDir
@@ -191,6 +192,22 @@ describe('evaluateBridgeRequest', () => {
})
})
describe('requireVaultPath', () => {
it('returns the explicit configured vault path', () => {
assert.equal(
requireVaultPath({ VAULT_PATH: '/tmp/Selected Vault' }),
'/tmp/Selected Vault',
)
})
it('rejects missing vault paths instead of falling back to ~/Laputa', () => {
assert.throws(
() => requireVaultPath({}),
/VAULT_PATH is required/,
)
})
})
async function assertRejectsOutsideVault(prefix, resolveNotePath) {
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
const outsideNote = path.join(outsideDir, 'outside.md')

7
mcp-server/vault-path.js Normal file
View File

@@ -0,0 +1,7 @@
export function requireVaultPath(env = process.env) {
const vaultPath = env.VAULT_PATH?.trim()
if (!vaultPath) {
throw new Error('VAULT_PATH is required. Open a vault in Tolaria before starting MCP tools.')
}
return vaultPath
}

View File

@@ -24,8 +24,8 @@ import { WebSocketServer } from 'ws'
import {
getNote, searchNotes, vaultContext,
} from './vault.js'
import { requireVaultPath } from './vault-path.js'
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10)
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
const LOOPBACK_HOST = 'localhost'
@@ -37,6 +37,12 @@ const TRUSTED_UI_ORIGINS = new Set([
/** @type {WebSocketServer | null} */
let uiBridge = null
let vaultPath = null
function activeVaultPath() {
vaultPath ??= requireVaultPath()
return vaultPath
}
function broadcastUiAction(action, payload) {
if (!uiBridge) return
@@ -48,10 +54,10 @@ function broadcastUiAction(action, payload) {
const TOOL_HANDLERS = {
open_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
read_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit),
vault_context: () => vaultContext(VAULT_PATH),
open_note: (args) => getNote(activeVaultPath(), args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
read_note: (args) => getNote(activeVaultPath(), args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
search_notes: (args) => searchNotes(activeVaultPath(), args.query, args.limit),
vault_context: () => vaultContext(activeVaultPath()),
ui_open_note: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
ui_open_tab: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
@@ -164,6 +170,7 @@ export function startUiBridge(port = WS_UI_PORT) {
}
export function startBridge(port = WS_PORT) {
const currentVaultPath = activeVaultPath()
const wss = new WebSocketServer({
port,
host: LOOPBACK_HOST,
@@ -171,7 +178,7 @@ export function startBridge(port = WS_PORT) {
})
wss.on('connection', (ws) => {
console.error(`[ws-bridge] Client connected (vault: ${VAULT_PATH})`)
console.error(`[ws-bridge] Client connected (vault: ${currentVaultPath})`)
ws.on('message', async (raw) => {
try {
@@ -192,5 +199,11 @@ export function startBridge(port = WS_PORT) {
// Run directly if invoked as main module
const isMain = process.argv[1]?.endsWith('ws-bridge.js')
if (isMain) {
startUiBridge().then(() => startBridge())
try {
activeVaultPath()
startUiBridge().then(() => startBridge())
} catch (err) {
console.error(`[ws-bridge] ${err.message}`)
process.exit(1)
}
}

View File

@@ -131,6 +131,22 @@ pub async fn check_mcp_status(vault_path: String) -> Result<crate::mcp::McpStatu
.map_err(|e| format!("MCP status check failed: {e}"))
}
#[cfg(desktop)]
#[tauri::command]
pub async fn sync_mcp_bridge_vault(
app: tauri::AppHandle,
vault_path: Option<String>,
) -> Result<String, String> {
let expanded_vault_path = vault_path
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty())
.map(|path| super::expand_tilde(path).into_owned());
let vault_path = expanded_vault_path.as_deref().map(std::path::Path::new);
crate::sync_ws_bridge_for_vault(&app, vault_path).map(str::to_string)
}
// ── MCP commands (mobile stubs) ─────────────────────────────────────────────
#[cfg(mobile)]
@@ -151,6 +167,12 @@ pub async fn check_mcp_status(_vault_path: String) -> Result<crate::mcp::McpStat
Ok(crate::mcp::McpStatus::NotInstalled)
}
#[cfg(mobile)]
#[tauri::command]
pub async fn sync_mcp_bridge_vault(_vault_path: Option<String>) -> Result<String, String> {
Err("MCP is not available on mobile".into())
}
// ── Menu commands ───────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]

View File

@@ -102,6 +102,78 @@ fn log_startup_result(label: &str, result: Result<usize, String>) {
}
}
#[cfg(desktop)]
fn selected_mcp_bridge_vault_path(vault_list: &vault_list::VaultList) -> Option<PathBuf> {
vault_list
.active_vault
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty())
.map(PathBuf::from)
}
#[cfg(desktop)]
fn validate_mcp_bridge_vault_path(vault_path: &Path) -> Result<PathBuf, String> {
let resolved = std::fs::canonicalize(vault_path).map_err(|e| {
format!(
"MCP bridge vault is not available: {} ({e})",
vault_path.display()
)
})?;
if !resolved.is_dir() {
return Err(format!(
"MCP bridge vault is not available: {} is not a directory",
vault_path.display()
));
}
Ok(resolved)
}
#[cfg(desktop)]
fn stop_ws_bridge_child(active_child: &mut Option<Child>) {
if let Some(mut child) = active_child.take() {
let _ = child.kill();
let _ = child.wait();
log::info!("ws-bridge child process stopped");
}
}
#[cfg(desktop)]
pub(crate) fn sync_ws_bridge_for_vault(
app_handle: &tauri::AppHandle,
vault_path: Option<&Path>,
) -> Result<&'static str, String> {
use tauri::Manager;
let state: tauri::State<'_, WsBridgeChild> = app_handle.state();
let mut active_child = state
.0
.lock()
.map_err(|_| "Failed to lock ws-bridge state".to_string())?;
let Some(vault_path) = vault_path else {
stop_ws_bridge_child(&mut active_child);
return Ok("stopped");
};
let resolved_vault_path = match validate_mcp_bridge_vault_path(vault_path) {
Ok(path) => path,
Err(e) => {
stop_ws_bridge_child(&mut active_child);
return Err(e);
}
};
stop_ws_bridge_child(&mut active_child);
let child = mcp::spawn_ws_bridge(&resolved_vault_path)?;
*active_child = Some(child);
Ok("started")
}
/// Run startup housekeeping on the default vault (migrate legacy frontmatter, seed configs).
#[cfg(desktop)]
fn run_startup_tasks() {
@@ -124,17 +196,21 @@ fn run_startup_tasks() {
#[cfg(desktop)]
fn spawn_ws_bridge(app: &mut tauri::App) {
use tauri::Manager;
let vault_path = dirs::home_dir()
.map(|h| h.join("Laputa"))
.unwrap_or_default();
let vp_str = vault_path.to_string_lossy().to_string();
match mcp::spawn_ws_bridge(&vp_str) {
Ok(child) => {
let state: tauri::State<'_, WsBridgeChild> = app.state();
*state.0.lock().unwrap() = Some(child);
let vault_path = match vault_list::load_vault_list() {
Ok(vault_list) => selected_mcp_bridge_vault_path(&vault_list),
Err(e) => {
log::warn!("Failed to load active vault for ws-bridge startup: {}", e);
None
}
Err(e) => log::warn!("Failed to start ws-bridge: {}", e),
};
let Some(vault_path) = vault_path else {
log::info!("ws-bridge not started: no active vault selected");
return;
};
if let Err(e) = sync_ws_bridge_for_vault(app.handle(), Some(&vault_path)) {
log::warn!("Failed to start ws-bridge: {}", e);
}
}
@@ -356,6 +432,7 @@ macro_rules! app_invoke_handler {
commands::register_mcp_tools,
commands::remove_mcp_tools,
commands::check_mcp_status,
commands::sync_mcp_bridge_vault,
commands::repair_vault,
commands::reinit_telemetry,
commands::list_views,
@@ -416,6 +493,11 @@ mod tests {
use super::MACOS_WEBVIEW_RESERVED_COMMAND_KEYS;
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
#[cfg(desktop)]
use super::{selected_mcp_bridge_vault_path, validate_mcp_bridge_vault_path};
#[cfg(desktop)]
use crate::vault_list::VaultList;
#[cfg(all(desktop, unix))]
use super::vault_asset_scope_roots;
@@ -459,6 +541,48 @@ mod tests {
assert!(overrides.is_empty());
}
#[cfg(desktop)]
#[test]
fn selected_mcp_bridge_vault_path_uses_persisted_active_vault() {
let list = VaultList {
vaults: Vec::new(),
active_vault: Some("/tmp/Selected Vault".to_string()),
hidden_defaults: Vec::new(),
};
assert_eq!(
selected_mcp_bridge_vault_path(&list),
Some(std::path::PathBuf::from("/tmp/Selected Vault"))
);
}
#[cfg(desktop)]
#[test]
fn selected_mcp_bridge_vault_path_ignores_blank_active_vault() {
let list = VaultList {
vaults: Vec::new(),
active_vault: Some(" ".to_string()),
hidden_defaults: Vec::new(),
};
assert_eq!(selected_mcp_bridge_vault_path(&list), None);
}
#[cfg(desktop)]
#[test]
fn validate_mcp_bridge_vault_path_requires_existing_directory() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("Vault With Spaces");
std::fs::create_dir(&vault).unwrap();
let resolved = validate_mcp_bridge_vault_path(&vault).unwrap();
assert_eq!(resolved, vault.canonicalize().unwrap());
let missing = dir.path().join("Missing Vault");
let err = validate_mcp_bridge_vault_path(&missing).unwrap_err();
assert!(err.contains("MCP bridge vault is not available"));
}
#[cfg(all(desktop, unix))]
#[test]
fn vault_asset_scope_roots_include_requested_symlink_path() {

View File

@@ -215,10 +215,11 @@ fn mcp_server_dir_has_files(path: &Path) -> bool {
}
/// Spawn the WebSocket bridge as a child process.
pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
pub fn spawn_ws_bridge(vault_path: impl AsRef<Path>) -> Result<Child, String> {
let node = find_node()?;
let server_dir = mcp_server_dir()?;
let script = server_dir.join("ws-bridge.js");
let vault_path = vault_path.as_ref();
let child = crate::hidden_command(node)
.arg(&script)
@@ -231,7 +232,11 @@ pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
.spawn()
.map_err(|e| format!("Failed to spawn ws-bridge: {e}"))?;
log::info!("ws-bridge spawned (pid: {})", child.id());
log::info!(
"ws-bridge spawned (pid: {}, vault: {})",
child.id(),
vault_path.display()
);
Ok(child)
}

View File

@@ -15,6 +15,7 @@ const mockInvokeFn = vi.fn((cmd: string, args?: Record<string, unknown>): Promis
return Promise.resolve(null)
}
if (cmd === 'get_default_vault_path') return Promise.resolve(mockDefaultVaultPath)
if (cmd === 'sync_mcp_bridge_vault') return Promise.resolve(args?.vaultPath ? 'started' : 'stopped')
if (cmd === 'check_vault_exists') return Promise.resolve(true)
return Promise.resolve(null)
})
@@ -56,6 +57,7 @@ describe('useVaultSwitcher', () => {
return Promise.resolve(null)
}
if (cmd === 'get_default_vault_path') return Promise.resolve(mockDefaultVaultPath)
if (cmd === 'sync_mcp_bridge_vault') return Promise.resolve(args?.vaultPath ? 'started' : 'stopped')
if (cmd === 'check_vault_exists') {
const checkVaultExists = overrides.checkVaultExists
return Promise.resolve(typeof checkVaultExists === 'function'
@@ -228,6 +230,46 @@ describe('useVaultSwitcher', () => {
expect(mockVaultListStore.active_vault).toBeNull()
})
it('stops the MCP bridge when there is no selected active vault', async () => {
await renderLoadedVaultSwitcher()
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('sync_mcp_bridge_vault', { vaultPath: null })
})
})
it('syncs the MCP bridge to a persisted active vault', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [],
}
await renderLoadedVaultSwitcher()
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('sync_mcp_bridge_vault', { vaultPath: '/work/vault' })
})
})
it('syncs the MCP bridge after switching vaults', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: null,
hidden_defaults: [],
}
const { result } = await renderLoadedVaultSwitcher()
act(() => {
result.current.switchVault('/work/vault')
})
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('sync_mcp_bridge_vault', { vaultPath: '/work/vault' })
})
})
it('keeps the implicit default vault out of the list when its path is missing', async () => {
setMockInvokeBehavior({ checkVaultExists: false })

View File

@@ -162,6 +162,17 @@ function syncDefaultVaultExport(path: string) {
DEFAULT_VAULTS[0] = { label: GETTING_STARTED_LABEL, path }
}
function selectedBridgeVaultPath(selectedVaultPath: string | null): string | null {
const path = selectedVaultPath?.trim()
return path ? path : null
}
async function syncMcpBridgeVault(selectedVaultPath: string | null): Promise<void> {
await tauriCall<string>('sync_mcp_bridge_vault', {
vaultPath: selectedBridgeVaultPath(selectedVaultPath),
})
}
function isCanonicalGettingStartedPath(path: string, resolvedDefaultPath: string): boolean {
return path === resolvedDefaultPath
}
@@ -522,6 +533,16 @@ function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): Pers
}
}
function useMcpBridgeVaultSync(loaded: boolean, selectedVaultPath: string | null) {
useEffect(() => {
if (!loaded) return
syncMcpBridgeVault(selectedVaultPath).catch(err => {
console.warn('Failed to sync MCP bridge vault:', err)
})
}, [loaded, selectedVaultPath])
}
function formatGettingStartedRestoreError(err: unknown): string {
const message =
typeof err === 'string'
@@ -1145,6 +1166,8 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
selectedVaultPath,
vaultPath,
} = persistedState
useMcpBridgeVaultSync(loaded, selectedVaultPath)
const { allVaults, defaultVaults, isGettingStartedHidden } = useVaultCollections(
defaultAvailable,
defaultPath,

View File

@@ -486,6 +486,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
},
register_mcp_tools: () => 'registered',
check_mcp_status: () => 'installed',
sync_mcp_bridge_vault: (args: { vaultPath?: string | null }) => args.vaultPath ? 'started' : 'stopped',
repair_vault: (): string => {
mockVaultAiGuidanceStatus = {
agents_state: 'managed',