fix: copy mcp config through native clipboard
This commit is contained in:
@@ -364,7 +364,7 @@ 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 routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and 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. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. Manual MCP config export uses the same generated stdio entry as registration, so the copied snippet remains scoped to the active vault without writing third-party config files. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind.
|
||||
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. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. Manual MCP config export uses the same generated stdio entry as registration, so the copied snippet remains scoped to the active vault without writing third-party config files. Desktop snippet copy goes through the native `copy_text_to_clipboard` command, while browser/mock mode keeps using the Web Clipboard API. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind.
|
||||
|
||||
### Vault Caching
|
||||
|
||||
|
||||
@@ -348,7 +348,7 @@ 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 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 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. 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. 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` and `/usr/lib/tolaria`, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly 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 settings), 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. 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` and `/usr/lib/tolaria`, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly 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
|
||||
|
||||
@@ -750,6 +750,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#[cfg(desktop)]
|
||||
use std::process::Command;
|
||||
use std::io::Write;
|
||||
#[cfg(desktop)]
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[cfg(desktop)]
|
||||
use crate::menu;
|
||||
@@ -140,6 +142,70 @@ pub async fn get_mcp_config_snippet(vault_path: String) -> Result<String, String
|
||||
.map_err(|e| format!("MCP config task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn clipboard_command() -> Command {
|
||||
crate::hidden_command("pbcopy")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn clipboard_command() -> Command {
|
||||
crate::hidden_command("clip.exe")
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
|
||||
fn clipboard_command() -> Command {
|
||||
let mut command = crate::hidden_command("sh");
|
||||
command.args([
|
||||
"-c",
|
||||
"if command -v wl-copy >/dev/null 2>&1; then wl-copy; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --input; else exit 127; fi",
|
||||
]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn clipboard_failure_message(stderr: &[u8]) -> String {
|
||||
let message = String::from_utf8_lossy(stderr).trim().to_string();
|
||||
if message.is_empty() {
|
||||
"Native clipboard command failed".to_string()
|
||||
} else {
|
||||
format!("Native clipboard command failed: {message}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn write_native_clipboard(mut command: Command, text: &str) -> Result<(), String> {
|
||||
let mut child = command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open native clipboard command: {e}"))?;
|
||||
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Native clipboard command did not expose stdin".to_string())?;
|
||||
stdin
|
||||
.write_all(text.as_bytes())
|
||||
.map_err(|e| format!("Failed to write native clipboard text: {e}"))?;
|
||||
drop(stdin);
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| format!("Native clipboard command did not finish: {e}"))?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(clipboard_failure_message(&output.stderr))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn copy_text_to_clipboard(text: String) -> Result<(), String> {
|
||||
write_native_clipboard(clipboard_command(), &text)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn sync_mcp_bridge_vault(
|
||||
@@ -182,6 +248,12 @@ pub async fn get_mcp_config_snippet(_vault_path: String) -> Result<String, Strin
|
||||
Err("MCP is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn copy_text_to_clipboard(_text: String) -> Result<(), String> {
|
||||
Err("Clipboard is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn sync_mcp_bridge_vault(_vault_path: Option<String>) -> Result<String, String> {
|
||||
|
||||
@@ -543,6 +543,7 @@ macro_rules! app_invoke_handler {
|
||||
commands::remove_mcp_tools,
|
||||
commands::check_mcp_status,
|
||||
commands::get_mcp_config_snippet,
|
||||
commands::copy_text_to_clipboard,
|
||||
commands::sync_mcp_bridge_vault,
|
||||
commands::repair_vault,
|
||||
commands::reinit_telemetry,
|
||||
|
||||
@@ -6,11 +6,16 @@ vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(),
|
||||
}))
|
||||
|
||||
const runtimeMock = vi.hoisted(() => ({
|
||||
isTauri: false,
|
||||
}))
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
isTauri: () => runtimeMock.isTauri,
|
||||
mockInvoke: vi.fn(),
|
||||
}))
|
||||
|
||||
const { invoke } = await import('@tauri-apps/api/core') as { invoke: ReturnType<typeof vi.fn> }
|
||||
const { mockInvoke } = await import('../mock-tauri') as { mockInvoke: ReturnType<typeof vi.fn> }
|
||||
|
||||
function mockCommands(handlers: Partial<Record<string, unknown>>) {
|
||||
@@ -83,6 +88,7 @@ async function runMutationScenario({
|
||||
|
||||
describe('useMcpStatus', () => {
|
||||
beforeEach(() => {
|
||||
runtimeMock.isTauri = false
|
||||
vi.clearAllMocks()
|
||||
mockClipboard()
|
||||
})
|
||||
@@ -212,4 +218,30 @@ describe('useMcpStatus', () => {
|
||||
expect(writeText).toHaveBeenCalledWith(snippet)
|
||||
expect(onToast).toHaveBeenCalledWith('Tolaria MCP config copied to clipboard')
|
||||
})
|
||||
|
||||
it('uses the native clipboard command inside the Tauri app', async () => {
|
||||
runtimeMock.isTauri = true
|
||||
const writeText = mockClipboard()
|
||||
const onToast = vi.fn()
|
||||
const snippet = JSON.stringify({ mcpServers: { tolaria: { type: 'stdio' } } })
|
||||
invoke.mockImplementation((command: string) => {
|
||||
if (command === 'check_mcp_status') return Promise.resolve('not_installed')
|
||||
if (command === 'get_mcp_config_snippet') return Promise.resolve(snippet)
|
||||
if (command === 'copy_text_to_clipboard') return Promise.resolve(null)
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
const { result } = renderSubject(onToast)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.mcpStatus).toBe('not_installed')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.copyMcpConfig()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('copy_text_to_clipboard', { text: snippet })
|
||||
expect(writeText).not.toHaveBeenCalled()
|
||||
expect(onToast).toHaveBeenCalledWith('Tolaria MCP config copied to clipboard')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ export type McpStatus = 'checking' | 'installed' | 'not_installed'
|
||||
type ManualConfigSnippet = string
|
||||
type McpCommand =
|
||||
| 'check_mcp_status'
|
||||
| 'copy_text_to_clipboard'
|
||||
| 'get_mcp_config_snippet'
|
||||
| 'register_mcp_tools'
|
||||
| 'remove_mcp_tools'
|
||||
@@ -72,6 +73,11 @@ function visibleManualConfig(
|
||||
}
|
||||
|
||||
async function writeClipboardText(value: ManualConfigSnippet, t: Translator): Promise<void> {
|
||||
if (isTauri()) {
|
||||
await tauriCall('copy_text_to_clipboard', { text: value })
|
||||
return
|
||||
}
|
||||
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
throw new Error(t('mcp.error.clipboardUnavailable'))
|
||||
}
|
||||
|
||||
@@ -194,6 +194,7 @@ describe('mockHandlers additional coverage', () => {
|
||||
expect(mockHandlers.migrate_is_a_to_type()).toBe(0)
|
||||
expect(mockHandlers.register_mcp_tools()).toBe('registered')
|
||||
expect(mockHandlers.check_mcp_status()).toBe('installed')
|
||||
expect(mockHandlers.copy_text_to_clipboard()).toBeNull()
|
||||
expect(mockHandlers.reinit_telemetry()).toBeNull()
|
||||
expect(mockHandlers.stream_claude_chat()).toBe('mock-session')
|
||||
expect(mockHandlers.stream_ai_agent()).toBeNull()
|
||||
|
||||
@@ -503,6 +503,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
},
|
||||
},
|
||||
}, null, 2),
|
||||
copy_text_to_clipboard: () => null,
|
||||
sync_mcp_bridge_vault: (args: { vaultPath?: string | null }) => args.vaultPath ? 'started' : 'stopped',
|
||||
repair_vault: (): string => {
|
||||
mockVaultAiGuidanceStatus = {
|
||||
|
||||
Reference in New Issue
Block a user